| @@ -20,8 +20,19 @@ | ||
| 20 | 20 | */ |
| 21 | 21 | class HtmlProcessor { |
| 22 | 22 | |
| 23 | 23 | /** |
| 24 | + * Is WordPress's HTML API available? (WP 6.2+.) When it isn't, every method | |
| 25 | + * here fails open — returning the HTML unchanged — so a missing core class can | |
| 26 | + * never fatal inside the output buffer and white-screen the site. | |
| 27 | + * | |
| 28 | + * @return bool | |
| 29 | + */ | |
| 30 | + private function api_ready(): bool { | |
| 31 | + return class_exists( '\\WP_HTML_Tag_Processor' ); | |
| 32 | + } | |
| 33 | + | |
| 34 | + /** | |
| 24 | 35 | * Iterate every <link rel="stylesheet">, letting a callback rewrite the href. |
| 25 | 36 | * |
| 26 | 37 | * @param string $html HTML. |
| 27 | 38 | * @param callable $callback fn(string $href, array $attrs): ?string — return new href or null to skip. |
| @@ -27,8 +38,12 @@ | ||
| 27 | 38 | * @param callable $callback fn(string $href, array $attrs): ?string — return new href or null to skip. |
| 28 | 39 | * @return array{html:string,changed:bool} |
| 29 | 40 | */ |
| 30 | 41 | public function rewrite_stylesheet_links( string $html, callable $callback ): array { |
| 42 | + if ( ! $this->api_ready() ) { | |
| 43 | + return array( 'html' => $html, 'changed' => false ); | |
| 44 | + } | |
| 45 | + | |
| 31 | 46 | $processor = new WP_HTML_Tag_Processor( $html ); |
| 32 | 47 | $changed = false; |
| 33 | 48 | |
| 34 | 49 | while ( $processor->next_tag( array( 'tag_name' => 'LINK' ) ) ) { |
| @@ -69,8 +84,12 @@ | ||
| 69 | 84 | * @param string $html HTML. |
| 70 | 85 | * @return string[] |
| 71 | 86 | */ |
| 72 | 87 | public function stylesheet_hrefs( string $html ): array { |
| 88 | + if ( ! $this->api_ready() ) { | |
| 89 | + return array(); | |
| 90 | + } | |
| 91 | + | |
| 73 | 92 | $processor = new WP_HTML_Tag_Processor( $html ); |
| 74 | 93 | $hrefs = array(); |
| 75 | 94 | |
| 76 | 95 | while ( $processor->next_tag( array( 'tag_name' => 'LINK' ) ) ) { |
| @@ -104,8 +123,12 @@ | ||
| 104 | 123 | if ( empty( $hrefs ) ) { |
| 105 | 124 | return $html; |
| 106 | 125 | } |
| 107 | 126 | |
| 127 | + if ( ! $this->api_ready() ) { | |
| 128 | + return $html; | |
| 129 | + } | |
| 130 | + | |
| 108 | 131 | $targets = array_fill_keys( $hrefs, true ); |
| 109 | 132 | $processor = new WP_HTML_Tag_Processor( $html ); |
| 110 | 133 | $hit = false; |
| 111 | 134 | |
| @@ -127,14 +150,15 @@ | ||
| 127 | 150 | if ( ! $hit ) { |
| 128 | 151 | return $html; |
| 129 | 152 | } |
| 130 | 153 | |
| 131 | - $html = $processor->get_updated_html(); | |
| 154 | + $updated = $processor->get_updated_html(); | |
| 132 | 155 | |
| 133 | - // Drop any <link …> carrying our marker. | |
| 134 | - $html = preg_replace( '#<link\b[^>]*\bdata-easyfonts-remove=([\'"])1\1[^>]*/?>\s*#i', '', $html ); | |
| 156 | + // Drop any <link …> carrying our marker. If PCRE bails (e.g. backtrack | |
| 157 | + // limit), keep the tokenized HTML rather than blanking the document. | |
| 158 | + $cleaned = preg_replace( '#<link\b[^>]*\bdata-easyfonts-remove=([\'"])1\1[^>]*/?>\s*#i', '', $updated ); | |
| 135 | 159 | |
| 136 | - return null === $html ? '' : $html; | |
| 160 | + return null === $cleaned ? $updated : $cleaned; | |
| 137 | 161 | } |
| 138 | 162 | |
| 139 | 163 | /** |
| 140 | 164 | * Process the CSS contents of each <style> block. |
| @@ -145,25 +169,69 @@ | ||
| 145 | 169 | */ |
| 146 | 170 | public function rewrite_style_blocks( string $html, callable $callback ): array { |
| 147 | 171 | $changed = false; |
| 148 | 172 | |
| 149 | - $out = preg_replace_callback( | |
| 150 | - '/(<style\b[^>]*>)(.*?)(<\/style>)/is', | |
| 151 | - static function ( $m ) use ( $callback, &$changed ) { | |
| 152 | - $new = $callback( $m[2] ); | |
| 173 | + // Linear <style>…</style> scanner — NOT a regex. A regex such as | |
| 174 | + // `(<style\b[^>]*>)(.*?)(</style>)/s` catastrophically backtracks (and on | |
| 175 | + // PCRE2+JIT can crash the process) over the megabyte-sized inline CSS that | |
| 176 | + // page builders emit, which silently blanked the page. This strpos walk is | |
| 177 | + // O(n) and cannot backtrack or overflow. | |
| 178 | + $max = (int) apply_filters( 'easyfonts_max_style_block', 2 * 1024 * 1024 ); | |
| 179 | + $out = ''; | |
| 180 | + $offset = 0; | |
| 181 | + $len = strlen( $html ); | |
| 153 | 182 | |
| 154 | - if ( is_string( $new ) && $new !== $m[2] ) { | |
| 183 | + while ( $offset < $len ) { | |
| 184 | + $open = stripos( $html, '<style', $offset ); | |
| 185 | + | |
| 186 | + if ( false === $open ) { | |
| 187 | + $out .= substr( $html, $offset ); | |
| 188 | + break; | |
| 189 | + } | |
| 190 | + | |
| 191 | + // Make sure "<style" is a real tag start (next char ends the name). | |
| 192 | + $boundary = $html[ $open + 6 ] ?? ''; | |
| 193 | + | |
| 194 | + if ( '' !== $boundary && '>' !== $boundary && '/' !== $boundary && ! ctype_space( $boundary ) ) { | |
| 195 | + $out .= substr( $html, $offset, ( $open + 6 ) - $offset ); | |
| 196 | + $offset = $open + 6; | |
| 197 | + continue; | |
| 198 | + } | |
| 199 | + | |
| 200 | + $gt = strpos( $html, '>', $open ); | |
| 201 | + | |
| 202 | + if ( false === $gt ) { | |
| 203 | + $out .= substr( $html, $offset ); | |
| 204 | + break; | |
| 205 | + } | |
| 206 | + | |
| 207 | + $close = stripos( $html, '</style>', $gt + 1 ); | |
| 208 | + | |
| 209 | + if ( false === $close ) { | |
| 210 | + $out .= substr( $html, $offset ); | |
| 211 | + break; | |
| 212 | + } | |
| 213 | + | |
| 214 | + // Copy everything up to this <style>, then the opener verbatim. | |
| 215 | + $out .= substr( $html, $offset, $open - $offset ); | |
| 216 | + $tag_open = substr( $html, $open, ( $gt - $open ) + 1 ); | |
| 217 | + $content = substr( $html, $gt + 1, $close - ( $gt + 1 ) ); | |
| 218 | + | |
| 219 | + if ( strlen( $content ) <= $max ) { | |
| 220 | + $new = $callback( $content ); | |
| 221 | + | |
| 222 | + if ( is_string( $new ) && $new !== $content ) { | |
| 155 | 223 | $changed = true; |
| 156 | - return $m[1] . $new . $m[3]; | |
| 224 | + $content = $new; | |
| 157 | 225 | } |
| 226 | + } | |
| 158 | 227 | |
| 159 | - return $m[0]; | |
| 160 | - }, | |
| 161 | - $html | |
| 162 | - ); | |
| 228 | + $out .= $tag_open . $content . '</style>'; | |
| 229 | + $offset = $close + 8; | |
| 230 | + } | |
| 163 | 231 | |
| 164 | 232 | return array( |
| 165 | - 'html' => null === $out ? $html : $out, | |
| 233 | + 'html' => $out, | |
| 166 | 234 | 'changed' => $changed, |
| 167 | 235 | ); |
| 168 | 236 | } |
| 169 | 237 | |
| @@ -174,8 +242,13 @@ | ||
| 174 | 242 | * @param string[] $domains Domains to strip hints for. |
| 175 | 243 | * @return array{html:string,changed:bool} |
| 176 | 244 | */ |
| 177 | 245 | public function strip_font_hints( string $html, array $domains ): array { |
| 246 | + if ( ! $this->api_ready() ) { | |
| 247 | + return array( 'html' => $html, 'changed' => false ); | |
| 248 | + } | |
| 249 | + | |
| 250 | + $input = $html; | |
| 178 | 251 | $processor = new WP_HTML_Tag_Processor( $html ); |
| 179 | 252 | $remove = array(); |
| 180 | 253 | |
| 181 | 254 | // Identify offending hint tags by their href (read pass). |
| @@ -200,13 +273,18 @@ | ||
| 200 | 273 | } |
| 201 | 274 | |
| 202 | 275 | $html = $processor->get_updated_html(); |
| 203 | 276 | |
| 204 | - // Physically drop the neutralised tags. | |
| 205 | - $html = preg_replace( '/<link\b[^>]*\brel=([\'"])easyfonts-removed\1[^>]*>\s*/i', '', $html ); | |
| 277 | + // Physically drop the neutralised tags. If PCRE bails, fall back to the | |
| 278 | + // input HTML rather than returning '' (which would blank the page). | |
| 279 | + $cleaned = preg_replace( '/<link\b[^>]*\brel=([\'"])easyfonts-removed\1[^>]*>\s*/i', '', $html ); | |
| 206 | 280 | |
| 281 | + if ( null === $cleaned ) { | |
| 282 | + return array( 'html' => $input, 'changed' => false ); | |
| 283 | + } | |
| 284 | + | |
| 207 | 285 | return array( |
| 208 | - 'html' => null === $html ? '' : $html, | |
| 286 | + 'html' => $cleaned, | |
| 209 | 287 | 'changed' => ! empty( $remove ), |
| 210 | 288 | ); |
| 211 | 289 | } |
| 212 | 290 | |