| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* HTML mount point for the reusable "Send debug log to developer" button. |
| 9 |
* |
| 10 |
* Pure rendering helper. No DAO calls, no singleton, no globals. Returns |
| 11 |
* a single empty <div> carrying the data attributes the JS component |
| 12 |
* (includes/js/support-request-button.js) needs to bootstrap itself. The |
| 13 |
* component reads `data-triggered-from` (allowlisted server-side by |
| 14 |
* Ajax_SupportRequest::ALLOWED_TRIGGER_SOURCES) and the optional |
| 15 |
* `data-context-summary` (a one-line free-text hint shown to the admin in |
| 16 |
* the confirmation modal so they remember which screen they're sending |
| 17 |
* from). |
| 18 |
* |
| 19 |
* Caller responsibilities: |
| 20 |
* 1. wp_enqueue_script() the abj404-support-request-button bundle and |
| 21 |
* its dependency abj404-support-request-client (handled by |
| 22 |
* AdminAssetEnqueuer::registerSupportRequestAssets()). |
| 23 |
* 2. Pass a triggered_from slug that exists in the AJAX allowlist. |
| 24 |
* Drift is caught at the server side (400 response) but the lint in |
| 25 |
* tests/SupportRequestButtonRenderTest enforces the matching set. |
| 26 |
*/ |
| 27 |
final class ABJ_404_Solution_SupportRequestButton { |
| 28 |
|
| 29 |
/** |
| 30 |
* Render an empty mount-point div for the support-request button. The |
| 31 |
* JS component (window.ABJ404.SupportRequestButton.mount) discovers |
| 32 |
* the div via its CSS class and bootstraps the button, modal, and |
| 33 |
* AJAX wiring. |
| 34 |
* |
| 35 |
* Both data attributes are escaped with esc_attr() so the caller may |
| 36 |
* pass arbitrary user-derived strings (e.g. an error message excerpt |
| 37 |
* as the context_summary) without opening an XSS hole. |
| 38 |
* |
| 39 |
* @param string $triggeredFrom Required. One of |
| 40 |
* Ajax_SupportRequest::ALLOWED_TRIGGER_SOURCES. |
| 41 |
* @param string|null $contextSummary Optional one-line summary shown |
| 42 |
* inside the modal so the user knows which screen the report is |
| 43 |
* anchored to. Pass null or '' to omit. |
| 44 |
* @return string HTML safe to print directly into an admin page. |
| 45 |
*/ |
| 46 |
public static function render(string $triggeredFrom, ?string $contextSummary = null): string { |
| 47 |
$contextAttr = ''; |
| 48 |
if ($contextSummary !== null && $contextSummary !== '') { |
| 49 |
$contextAttr = ' data-context-summary="' . self::escAttr($contextSummary) . '"'; |
| 50 |
} |
| 51 |
$template = self::readTemplate('supportRequestButtonMount.html'); |
| 52 |
$html = strtr($template, [ |
| 53 |
'{triggered_from}' => self::escAttr($triggeredFrom), |
| 54 |
'{context_summary_attr}' => $contextAttr, |
| 55 |
]); |
| 56 |
return rtrim($html, "\n"); |
| 57 |
} |
| 58 |
|
| 59 |
/** |
| 60 |
* Render an inline `<a class="abj404-support-request-link">` anchor that |
| 61 |
* the JS component (window.ABJ404.SupportRequestButton.mountAll) discovers |
| 62 |
* and binds to the modal via attachLink(). Used inside admin error |
| 63 |
* notices where the affordance must read as a small text link in the |
| 64 |
* body of the notice (UI_AESTHETIC.md "Notices"), not a button or a |
| 65 |
* call-to-action chip. |
| 66 |
* |
| 67 |
* The href fallback is the same-page `#abj404-support-request` anchor |
| 68 |
* so JS-disabled browsers degrade to a working flow. The link label |
| 69 |
* is the user-visible text and is escaped with esc_html() because |
| 70 |
* callers may pass a translated string. |
| 71 |
* |
| 72 |
* @param string $triggeredFrom One of |
| 73 |
* Ajax_SupportRequest::ALLOWED_TRIGGER_SOURCES. |
| 74 |
* @param string|null $contextSummary Optional one-line summary shown |
| 75 |
* inside the support modal so the admin knows which screen the |
| 76 |
* report is anchored to. |
| 77 |
* @param string $label User-visible link text. Defaults |
| 78 |
* to "Send debug log to developer" (translated when WP is loaded). |
| 79 |
* @return string HTML safe to print inside a notice body. |
| 80 |
*/ |
| 81 |
public static function renderInlineLink(string $triggeredFrom, |
| 82 |
?string $contextSummary = null, string $label = ''): string { |
| 83 |
if ($label === '') { |
| 84 |
$label = function_exists('__') |
| 85 |
? (string)__('Send debug log to developer', '404-solution') |
| 86 |
: 'Send debug log to developer'; |
| 87 |
} |
| 88 |
$contextAttr = ''; |
| 89 |
if ($contextSummary !== null && $contextSummary !== '') { |
| 90 |
$contextAttr = ' data-context-summary="' . self::escAttr($contextSummary) . '"'; |
| 91 |
} |
| 92 |
$template = self::readTemplate('supportRequestInlineLink.html'); |
| 93 |
$html = str_replace('{triggered_from}', self::escAttr($triggeredFrom), $template); |
| 94 |
$html = str_replace('{context_summary_attr}', $contextAttr, $html); |
| 95 |
$html = str_replace('{label}', self::escHtml($label), $html); |
| 96 |
return rtrim($html, "\n"); |
| 97 |
} |
| 98 |
|
| 99 |
/** |
| 100 |
* Read an HTML template from includes/html/. Kept private and minimal |
| 101 |
* so the helper has no dependency on the main Functions facade (so it |
| 102 |
* works on the degraded admin page where the autoloader may be |
| 103 |
* incomplete). |
| 104 |
* |
| 105 |
* @param string $filename Template filename relative to includes/html/. |
| 106 |
* @return string Template contents or '' on read failure. |
| 107 |
*/ |
| 108 |
private static function readTemplate(string $filename): string { |
| 109 |
$path = dirname(__DIR__) . '/html/' . $filename; |
| 110 |
if (!is_readable($path)) { |
| 111 |
return ''; |
| 112 |
} |
| 113 |
$contents = @file_get_contents($path); |
| 114 |
return is_string($contents) ? $contents : ''; |
| 115 |
} |
| 116 |
|
| 117 |
/** |
| 118 |
* esc_html() that survives running outside of a real WordPress request. |
| 119 |
* Mirrors escAttr() above. |
| 120 |
* |
| 121 |
* @param string $value |
| 122 |
* @return string |
| 123 |
*/ |
| 124 |
private static function escHtml(string $value): string { |
| 125 |
if (function_exists('esc_html')) { |
| 126 |
return (string)esc_html($value); |
| 127 |
} |
| 128 |
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); |
| 129 |
} |
| 130 |
|
| 131 |
/** |
| 132 |
* Wrapper around esc_attr() that survives running outside of a real |
| 133 |
* WordPress request (PHPStan-bench, structural tests). When WP is |
| 134 |
* loaded the real esc_attr() handles the escaping; otherwise a |
| 135 |
* minimal htmlspecialchars() shim keeps the output safe. |
| 136 |
* |
| 137 |
* @param string $value |
| 138 |
* @return string |
| 139 |
*/ |
| 140 |
private static function escAttr(string $value): string { |
| 141 |
if (function_exists('esc_attr')) { |
| 142 |
return (string)esc_attr($value); |
| 143 |
} |
| 144 |
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); |
| 145 |
} |
| 146 |
} |
| 147 |
|