PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.1
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.1
2.0.4 2.0.3 2.0.2 2.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2 1.3 2.0.0
easyfonts / src / Frontend / HtmlProcessor.php

HtmlProcessor.php in EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant 2.0.1, at src/Frontend/HtmlProcessor.php

308 lines 8.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * HTML manipulation backed by WordPress's native HTML API.
4 *
5 * Link discovery/rewriting goes through WP_HTML_Tag_Processor (a real
6 * tokenizer — no catastrophic backtracking). Operations the tokenizer can't do
7 * in core (reading <style> text, deleting nodes) use tightly-scoped helpers.
8 *
9 * @package EasyFonts
10 */
11
12 namespace EasyFonts\Frontend;
13
14 use WP_HTML_Tag_Processor;
15
16 defined( 'ABSPATH' ) || exit;
17
18 /**
19 * Reliable HTML read/rewrite helpers.
20 */
21 class HtmlProcessor {
22
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 /**
35 * Iterate every <link rel="stylesheet">, letting a callback rewrite the href.
36 *
37 * @param string $html HTML.
38 * @param callable $callback fn(string $href, array $attrs): ?string — return new href or null to skip.
39 * @return array{html:string,changed:bool}
40 */
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
46 $processor = new WP_HTML_Tag_Processor( $html );
47 $changed = false;
48
49 while ( $processor->next_tag( array( 'tag_name' => 'LINK' ) ) ) {
50 $rel = (string) $processor->get_attribute( 'rel' );
51
52 if ( false === stripos( $rel, 'stylesheet' ) ) {
53 continue;
54 }
55
56 $href = $processor->get_attribute( 'href' );
57
58 if ( ! is_string( $href ) || '' === $href ) {
59 continue;
60 }
61
62 $attrs = array(
63 'id' => $processor->get_attribute( 'id' ),
64 'media' => $processor->get_attribute( 'media' ),
65 );
66
67 $new = $callback( $href, $attrs );
68
69 if ( is_string( $new ) && $new !== $href ) {
70 $processor->set_attribute( 'href', $new );
71 $changed = true;
72 }
73 }
74
75 return array(
76 'html' => $processor->get_updated_html(),
77 'changed' => $changed,
78 );
79 }
80
81 /**
82 * Collect all <link rel="stylesheet"> hrefs (read-only).
83 *
84 * @param string $html HTML.
85 * @return string[]
86 */
87 public function stylesheet_hrefs( string $html ): array {
88 if ( ! $this->api_ready() ) {
89 return array();
90 }
91
92 $processor = new WP_HTML_Tag_Processor( $html );
93 $hrefs = array();
94
95 while ( $processor->next_tag( array( 'tag_name' => 'LINK' ) ) ) {
96 $rel = (string) $processor->get_attribute( 'rel' );
97
98 if ( false === stripos( $rel, 'stylesheet' ) ) {
99 continue;
100 }
101
102 $href = $processor->get_attribute( 'href' );
103
104 if ( is_string( $href ) && '' !== $href ) {
105 $hrefs[] = $href;
106 }
107 }
108
109 return $hrefs;
110 }
111
112 /**
113 * Physically remove specific <link> stylesheet tags by exact href.
114 *
115 * The tokenizer can't delete nodes, so we mark them inert with the
116 * tokenizer (reliable matching) and then strip the marked tags by regex.
117 *
118 * @param string $html HTML.
119 * @param string[] $hrefs Exact href values to remove.
120 * @return string
121 */
122 public function remove_stylesheet_links( string $html, array $hrefs ): string {
123 if ( empty( $hrefs ) ) {
124 return $html;
125 }
126
127 if ( ! $this->api_ready() ) {
128 return $html;
129 }
130
131 $targets = array_fill_keys( $hrefs, true );
132 $processor = new WP_HTML_Tag_Processor( $html );
133 $hit = false;
134
135 while ( $processor->next_tag( array( 'tag_name' => 'LINK' ) ) ) {
136 $rel = (string) $processor->get_attribute( 'rel' );
137
138 if ( false === stripos( $rel, 'stylesheet' ) ) {
139 continue;
140 }
141
142 $href = $processor->get_attribute( 'href' );
143
144 if ( is_string( $href ) && isset( $targets[ $href ] ) ) {
145 $processor->set_attribute( 'data-easyfonts-remove', '1' );
146 $hit = true;
147 }
148 }
149
150 if ( ! $hit ) {
151 return $html;
152 }
153
154 $updated = $processor->get_updated_html();
155
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 );
159
160 return null === $cleaned ? $updated : $cleaned;
161 }
162
163 /**
164 * Process the CSS contents of each <style> block.
165 *
166 * @param string $html HTML.
167 * @param callable $callback fn(string $css): ?string — return new CSS or null to leave unchanged.
168 * @return array{html:string,changed:bool}
169 */
170 public function rewrite_style_blocks( string $html, callable $callback ): array {
171 $changed = false;
172
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 );
182
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 ) {
223 $changed = true;
224 $content = $new;
225 }
226 }
227
228 $out .= $tag_open . $content . '</style>';
229 $offset = $close + 8;
230 }
231
232 return array(
233 'html' => $out,
234 'changed' => $changed,
235 );
236 }
237
238 /**
239 * Remove Google-font resource hints (preconnect / dns-prefetch / preload).
240 *
241 * @param string $html HTML.
242 * @param string[] $domains Domains to strip hints for.
243 * @return array{html:string,changed:bool}
244 */
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;
251 $processor = new WP_HTML_Tag_Processor( $html );
252 $remove = array();
253
254 // Identify offending hint tags by their href (read pass).
255 while ( $processor->next_tag( array( 'tag_name' => 'LINK' ) ) ) {
256 $rel = strtolower( (string) $processor->get_attribute( 'rel' ) );
257
258 if ( ! in_array( $rel, array( 'preconnect', 'dns-prefetch', 'preload' ), true ) ) {
259 continue;
260 }
261
262 $href = (string) $processor->get_attribute( 'href' );
263
264 foreach ( $domains as $domain ) {
265 if ( false !== strpos( $href, $domain ) ) {
266 // Neutralise: blank the href + rel so the hint is inert.
267 $processor->set_attribute( 'href', '' );
268 $processor->set_attribute( 'rel', 'easyfonts-removed' );
269 $remove[] = true;
270 break;
271 }
272 }
273 }
274
275 $html = $processor->get_updated_html();
276
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 );
280
281 if ( null === $cleaned ) {
282 return array( 'html' => $input, 'changed' => false );
283 }
284
285 return array(
286 'html' => $cleaned,
287 'changed' => ! empty( $remove ),
288 );
289 }
290
291 /**
292 * Insert a snippet immediately before </head>.
293 *
294 * @param string $html HTML.
295 * @param string $snippet Snippet.
296 * @return string
297 */
298 public function before_head_close( string $html, string $snippet ): string {
299 $pos = stripos( $html, '</head>' );
300
301 if ( false === $pos ) {
302 return $html;
303 }
304
305 return substr_replace( $html, $snippet . "\n</head>", $pos, 7 );
306 }
307 }
308