PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.4
1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 All 30 releases
xspeed / includes / class-minify-filters.php

class-minify-filters.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.4, at includes/class-minify-filters.php

2,137 lines 79.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Minify_Filters — frontend HTML rewriters for the "smarter minifier"
4 * sub-features (Phase 4.1a): defer JS, delay JS, async CSS, remove
5 * query strings.
6 *
7 * Each method is a WordPress filter callback. None of them touch the
8 * file system — they're pure tag rewrites or src-string rewrites
9 * applied to enqueued asset URLs / tags.
10 *
11 * The heavier combine-CSS / combine-JS engine lands in Phase 4.1b
12 * with its own class; keeping the filter-only logic isolated here
13 * makes that future split clean.
14 *
15 * @package XSpeed
16 */
17
18 declare(strict_types=1);
19
20 namespace XSpeed;
21
22 defined( 'ABSPATH' ) || exit;
23
24 final class Minify_Filters {
25
26 /**
27 * Settings cache (one read per request).
28 *
29 * @var array|null
30 */
31 private static $opts = null;
32
33 /**
34 * Has the delay-JS bootstrap snippet been printed? Guards against
35 * duplicate emission in pages that hit wp_footer multiple times.
36 */
37 private static $delay_bootstrap_printed = false;
38
39 /**
40 * Pre-minify script URLs, keyed by handle.
41 *
42 * `script_loader_src` (priority 10) rewrites a local script's URL to a
43 * hashed /cache/xspeed/min/<key>.js path long before
44 * `script_loader_tag` (priority 20/30) runs, so the delay + exclusion
45 * checks only ever see the hashed URL. A user targeting a script by
46 * URL substring — the obvious thing to do, and what the UI invites —
47 * would silently stop matching the moment minification was enabled.
48 * Minifier::rewrite_script() records the original here so those
49 * checks can test both. (FBS field report against 1.1.2)
50 *
51 * @var array<string,string>
52 */
53 private static $original_src = array();
54
55 /**
56 * Record a script's URL as it was BEFORE minification rewrote it.
57 * Called from Minifier::rewrite_script().
58 *
59 * @param string $handle Script handle.
60 * @param string $src Original (pre-minify) URL.
61 */
62 public static function remember_original_src( string $handle, string $src ): void {
63 if ( '' !== $handle && '' !== $src ) {
64 self::$original_src[ $handle ] = $src;
65 }
66 }
67
68 /**
69 * The pre-minify URL for a handle, or '' when we never rewrote it
70 * (external script, minification off, or a handle we didn't touch).
71 *
72 * @param string $handle Script handle.
73 */
74 public static function original_src( string $handle ): string {
75 return isset( self::$original_src[ $handle ] ) ? self::$original_src[ $handle ] : '';
76 }
77
78 /**
79 * Reset the remembered URLs. Test-only seam.
80 */
81 public static function reset_original_src(): void {
82 self::$original_src = array();
83 }
84
85 /**
86 * Does this tag (or attribute string) opt out of optimization?
87 *
88 * `data-no-optimize` / `data-no-minify` are the de-facto convention
89 * consent managers and other plugins print so optimizers keep hands
90 * off (Borlabs Cookie stamps both on its config script). The CSS
91 * combine buffer has honored `data-no-optimize` from the start; the
92 * JS paths did not, so a marked consent script was still minified
93 * into a hashed cache file — and a stale copy of a legally relevant
94 * consent config is a correctness problem, not a cosmetic one. (#456)
95 *
96 * @param string $tag A full tag, or just its attribute string.
97 */
98 public static function tag_opts_out( string $tag ): bool {
99 return (bool) preg_match( '#\sdata-no-(?:optimize|minify)\b#i', $tag );
100 }
101
102 /**
103 * Pristine tags as they looked before any of our transforms, keyed by
104 * handle. See snapshot_tag() / revert_late_marked_tag().
105 *
106 * @var array<string,string>
107 */
108 private static $pristine_tag = array();
109
110 /**
111 * Priority for the late opt-out re-check. Past Borlabs' ScriptBlocker
112 * at 999 — the highest stamper we have seen in the wild — so the
113 * marker has certainly landed by the time we look. (#469)
114 */
115 private const LATE_OPT_OUT_PRIORITY = 1000;
116
117 /**
118 * The priority the late opt-out re-check runs at.
119 *
120 * A site whose stamper hooks even later can move ours past it.
121 */
122 public static function late_opt_out_priority(): int {
123 /**
124 * Filter the priority of xSpeed's late data-no-optimize re-check.
125 *
126 * @param int $priority Default 1000.
127 */
128 return (int) apply_filters( 'xspeed_late_opt_out_priority', self::LATE_OPT_OUT_PRIORITY );
129 }
130
131 /**
132 * Filter: `script_loader_tag`, priority 9 — remember the tag before we
133 * touch it, so a marker stamped later can still be honored.
134 *
135 * Our three opt-out-aware transforms run at 15/20/30. A plugin that
136 * stamps `data-no-optimize` AFTER them is invisible to all three:
137 * Borlabs Cookie stamps at priority 100, so its consent config was
138 * still minified into a hashed cache file AND delayed — the script
139 * that has to run before anything else on the page ran only on first
140 * interaction. Snapshotting here is what lets the late pass put the
141 * original back verbatim, rather than trying to unpick each transform
142 * in reverse. (#469)
143 *
144 * @param string $tag
145 * @param string $handle
146 * @param string $src
147 */
148 public static function snapshot_tag( $tag, $handle, $src ): string {
149 if ( is_string( $tag ) && '' !== $tag && '' !== (string) $handle ) {
150 self::$pristine_tag[ (string) $handle ] = $tag;
151 }
152 return (string) $tag;
153 }
154
155 /**
156 * Filter: `script_loader_tag`, priority `LATE_OPT_OUT_PRIORITY` — hand
157 * back the untouched tag when a late filter stamped an opt-out marker
158 * after our transforms had already run.
159 *
160 * The priority has to clear the stamper, not merely the transforms:
161 * Borlabs stamps at 100 and Borlabs' own script blocker at 999, so an
162 * earlier hook reads a tag whose marker has not landed yet. PHP_INT_MAX
163 * would be unfriendly to a site that legitimately wants the last word,
164 * so this sits just past the highest stamper we know of and is
165 * filterable. Reverting to the snapshot is deliberate: undoing
166 * a delay rewrite in place would mean re-deriving `src` from
167 * `data-xs-src` and stripping markers, and #273 is a standing reminder
168 * that regex-editing these attributes in reverse goes wrong quietly.
169 *
170 * The pristine tag still carries whatever priority-10 filters did to
171 * it, so only OUR changes are dropped. (#469)
172 *
173 * @param string $tag
174 * @param string $handle
175 * @param string $src
176 */
177 public static function revert_late_marked_tag( $tag, $handle, $src ): string {
178 if ( ! is_string( $tag ) || '' === $tag || ! self::tag_opts_out( $tag ) ) {
179 return (string) $tag;
180 }
181 $handle = (string) $handle;
182 $pristine = isset( self::$pristine_tag[ $handle ] ) ? self::$pristine_tag[ $handle ] : '';
183 if ( '' !== $pristine && $pristine !== $tag ) {
184 // The marker is on the tag we were handed, not on the snapshot,
185 // so carry it — and everything else the late filter set in the
186 // same pass — over. A consumer reading the rendered HTML (or
187 // our own buffer passes) must still see the opt-out it asked
188 // for.
189 $tag = self::copy_late_attributes( $tag, $pristine );
190 }
191 // The snapshot was taken on `script_loader_tag`, by which point
192 // `script_loader_src` (priority 10) had ALREADY swapped in the
193 // hashed cache URL — so reverting the tag alone still leaves the
194 // minified src behind, which is the half the client actually
195 // reported. Undo that here too, using the URL rewrite_script()
196 // recorded. (#469)
197 return self::restore_marked_script_src( $tag, $handle, self::current_src( $tag, (string) $src ) );
198 }
199
200 /**
201 * The src currently on a tag, falling back to the one WordPress passed.
202 *
203 * After a revert the tag carries the snapshot's src, which is not
204 * necessarily the `$src` argument this late in the chain.
205 *
206 * @param string $tag Tag to read.
207 * @param string $fallback Value to use when the tag has no src.
208 */
209 private static function current_src( string $tag, string $fallback ): string {
210 $open = self::open_tag_offsets( $tag );
211 if ( null !== $open
212 && preg_match( '#(?<![-\w])src\s*=\s*["\']([^"\']*)["\']#i', $open['attrs'], $m ) ) {
213 return $m[1];
214 }
215 return $fallback;
216 }
217
218 /**
219 * Carry the attributes a late filter added onto the snapshot tag.
220 *
221 * Copying only `data-no-*` would silently drop the rest of what the
222 * stamper set in the same pass. Borlabs adds `data-cfasync="false"`
223 * alongside its markers — the attribute that keeps Cloudflare Rocket
224 * Loader off the consent config, i.e. the same class of breakage this
225 * fix exists to prevent, reintroduced by the fix itself. So diff the
226 * attribute names and bring over every one the snapshot lacks.
227 *
228 * Our own transform markers are excluded: they are what we are
229 * reverting, and re-adding `data-xs-delay` would re-delay the script.
230 *
231 * @param string $from Tag as the late filter left it.
232 * @param string $to Snapshot tag to stamp onto.
233 */
234 private static function copy_late_attributes( string $from, string $to ): string {
235 $late_tags = self::open_tags( $from );
236 $to_tags = self::open_tags( $to );
237 if ( empty( $late_tags ) || empty( $to_tags ) || count( $late_tags ) !== count( $to_tags ) ) {
238 // Counts differ when a stamper/blocker injected or replaced a
239 // tag inside the concatenated string, or a transform dropped an
240 // inline block. Positional pairing is meaningless then — but
241 // returning the bare snapshot would silently strip the opt-out,
242 // and the buffer passes would re-optimize an unmarked tag: #469
243 // again, on the mismatch path. Over-marking merely leaves a tag
244 // unoptimized, so stamp the protective attributes onto every
245 // snapshot tag instead. (#470)
246 return self::stamp_protective_attributes( $from, $to, $to_tags );
247 }
248 // Pair the tags positionally and stamp each one from its own
249 // counterpart. A stamper runs over the whole concatenated string
250 // and may mark several of the tags in it; collapsing that onto one
251 // tag would strip the opt-out from the others, and the buffer
252 // passes re-test `tag_opts_out()` per tag, so an unmarked sibling
253 // is free to be re-optimized downstream — #469 again, one pass
254 // later. (#469)
255 $out = $to;
256 // Right to left: an earlier splice would shift every later offset.
257 for ( $i = count( $to_tags ) - 1; $i >= 0; $i-- ) {
258 $add = self::late_attribute_delta( $late_tags[ $i ]['attrs'], $to_tags[ $i ]['attrs'] );
259 if ( '' !== $add ) {
260 $out = substr_replace( $out, $add, $to_tags[ $i ]['attrs_end'], 0 );
261 }
262 }
263 return $out;
264 }
265
266 /**
267 * Fallback when the late tag and the snapshot cannot be paired
268 * positionally: copy only the attributes that protect the script from
269 * optimizers — the opt-out markers plus `data-cfasync` — onto every
270 * snapshot tag missing them. Values are taken as the stamper wrote
271 * them on the late tag. (#470)
272 *
273 * @param string $from Tag as the late filter left it.
274 * @param string $to Snapshot tag to stamp onto.
275 * @param array $to_tags open_tags() result for $to.
276 */
277 private static function stamp_protective_attributes( string $from, string $to, array $to_tags ): string {
278 $protect = array();
279 foreach ( array( 'data-no-optimize', 'data-no-minify', 'data-cfasync' ) as $name ) {
280 if ( preg_match(
281 '#\s(' . preg_quote( $name, '#' ) . ')(\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]*))?#i',
282 $from,
283 $m
284 ) ) {
285 $protect[ $name ] = ' ' . $name . ( isset( $m[2] ) ? $m[2] : '' );
286 }
287 }
288 if ( empty( $protect ) ) {
289 return $to;
290 }
291 $out = $to;
292 // Right to left: an earlier splice would shift every later offset.
293 for ( $i = count( $to_tags ) - 1; $i >= 0; $i-- ) {
294 $add = '';
295 foreach ( $protect as $name => $attr ) {
296 if ( ! preg_match( '#\s' . preg_quote( $name, '#' ) . '\b#i', $to_tags[ $i ]['attrs'] ) ) {
297 $add .= $attr;
298 }
299 }
300 if ( '' !== $add ) {
301 $out = substr_replace( $out, $add, $to_tags[ $i ]['attrs_end'], 0 );
302 }
303 }
304 return $out;
305 }
306
307 /**
308 * The attributes present on the late tag but not the snapshot, minus
309 * the ones our own transforms add.
310 *
311 * @param string $late_attrs Attribute string from the transformed tag.
312 * @param string $to_attrs Attribute string from the snapshot tag.
313 */
314 private static function late_attribute_delta( string $late_attrs, string $to_attrs ): string {
315 $pattern = '#\s([-\w:]+)(?:\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]*))?#';
316 if ( ! preg_match_all( $pattern, $late_attrs, $late, PREG_SET_ORDER ) ) {
317 return '';
318 }
319 $have = array();
320 if ( preg_match_all( $pattern, $to_attrs, $existing, PREG_SET_ORDER ) ) {
321 foreach ( $existing as $attr ) {
322 $have[ strtolower( $attr[1] ) ] = true;
323 }
324 }
325 $add = '';
326 foreach ( $late as $attr ) {
327 $name = strtolower( $attr[1] );
328 if ( isset( $have[ $name ] ) || in_array( $name, self::OUR_TRANSFORM_ATTRS, true ) ) {
329 continue;
330 }
331 if ( 0 === strpos( $name, 'data-xs-' ) ) {
332 continue;
333 }
334 $add .= $attr[0];
335 }
336 return $add;
337 }
338
339 /**
340 * Attributes our own transforms add. Copying any of these from the
341 * transformed tag back onto the snapshot would re-apply the very
342 * transform we are undoing:
343 *
344 * defer/async — defer_script_tag()
345 * type — delay_script_tag() parks an inline block as
346 * text/xspeed-delayed; a type the author set is on the
347 * snapshot already and matches by name before we get here
348 * src — belongs to the snapshot, never to the late tag
349 *
350 * `data-xs-*` is handled by prefix separately. (#469)
351 */
352 private const OUR_TRANSFORM_ATTRS = array( 'defer', 'async', 'type', 'src' );
353
354 /**
355 * Locate the opening `<script>` that carries the src, falling back to
356 * the last one when none does.
357 *
358 * WP_Scripts::do_item() hands `script_loader_tag` the concatenation of
359 * before_inline + external + after_inline, so the FIRST `<script` is
360 * routinely an inline block rather than the asset — the same trap
361 * #234 fixed for defer and #273 for delay. Scanning attribute-wise
362 * also means a quoted value containing `>` (an `onerror` guard, a JSON
363 * payload) cannot truncate the tag the way `[^>]*` did. (#469)
364 *
365 * @param string $tag Full tag string.
366 * @return array{attrs:string,attrs_end:int}|null
367 */
368 private static function open_tag_offsets( string $tag ): ?array {
369 $tags = self::open_tags( $tag );
370 $fallback = null;
371 foreach ( $tags as $found ) {
372 if ( preg_match( '#(?<![-\w])src\s*=#i', $found['attrs'] ) ) {
373 return $found;
374 }
375 $fallback = $found;
376 }
377 return $fallback;
378 }
379
380 /**
381 * Every well-formed opening `<script>` in the string, in order.
382 *
383 * @param string $tag Full tag string.
384 * @return array<int,array{attrs:string,attrs_end:int}>
385 */
386 private static function open_tags( string $tag ): array {
387 if ( ! preg_match_all( '#<script\b#i', $tag, $m, PREG_OFFSET_CAPTURE ) ) {
388 return array();
389 }
390 $found = array();
391 foreach ( $m[0] as $hit ) {
392 $start = (int) $hit[1] + strlen( $hit[0] );
393 $end = self::scan_open_tag_end( $tag, $start );
394 if ( null === $end ) {
395 continue;
396 }
397 $found[] = array(
398 'attrs' => substr( $tag, $start, $end - $start ),
399 'attrs_end' => $end,
400 );
401 }
402 return $found;
403 }
404
405 /**
406 * Offset of the `>` closing an opening tag, skipping any that sit
407 * inside a quoted attribute value. Null when the tag is unterminated.
408 *
409 * @param string $tag Full tag string.
410 * @param int $offset Index just past `<script`.
411 */
412 private static function scan_open_tag_end( string $tag, int $offset ): ?int {
413 $len = strlen( $tag );
414 $quote = '';
415 for ( $i = $offset; $i < $len; $i++ ) {
416 $char = $tag[ $i ];
417 if ( '' !== $quote ) {
418 if ( $char === $quote ) {
419 $quote = '';
420 }
421 continue;
422 }
423 if ( '"' === $char || "'" === $char ) {
424 $quote = $char;
425 continue;
426 }
427 if ( '>' === $char ) {
428 // A self-closing `/>` keeps the slash out of the attributes.
429 return ( $i > $offset && '/' === $tag[ $i - 1 ] ) ? $i - 1 : $i;
430 }
431 }
432 return null;
433 }
434
435 /**
436 * Filter: `script_loader_tag`, priority 15 — undo the minify-cache
437 * rewrite for a script whose printed tag opts out.
438 *
439 * The src rewrite happens on `script_loader_src` (priority 10), long
440 * before any plugin's own `script_loader_tag` filter can stamp
441 * `data-no-minify` onto the tag — so the marker arrived too late to
442 * prevent the rewrite. This runs after the filters that stamp at the
443 * default priority 10 and swaps the hashed cache URL back to the
444 * recorded original. A marker stamped later than our transforms is
445 * caught by revert_late_marked_tag() in the late pass instead. (#469)
446 *
447 * @param string $tag
448 * @param string $handle
449 * @param string $src
450 */
451 public static function restore_marked_script_src( $tag, $handle, $src ): string {
452 if ( ! is_string( $tag ) || '' === $tag || ! self::tag_opts_out( $tag ) ) {
453 return (string) $tag;
454 }
455 $original = self::original_src( (string) $handle );
456 if ( '' === $original || '' === (string) $src || false === strpos( $tag, (string) $src ) ) {
457 return $tag;
458 }
459 return str_replace( (string) $src, $original, $tag );
460 }
461
462 /**
463 * Does a user-supplied target match this script?
464 *
465 * A target is either a script handle (exact) or a URL substring. The
466 * URL is checked against BOTH the current src and the pre-minify src,
467 * so a target written against the real asset path keeps working once
468 * minification starts rewriting URLs to hashed cache paths.
469 *
470 * @param string $needle Target from the user's list.
471 * @param string $handle Script handle.
472 * @param string $src Current (possibly rewritten) src.
473 */
474 private static function target_matches( string $needle, string $handle, string $src ): bool {
475 if ( '' === $needle ) {
476 return false;
477 }
478 if ( $handle === $needle ) {
479 return true;
480 }
481 if ( '' !== $src && false !== stripos( $src, $needle ) ) {
482 return true;
483 }
484 $original = self::original_src( $handle );
485 return '' !== $original && false !== stripos( $original, $needle );
486 }
487
488 /**
489 * Filter: `script_loader_tag` — add defer="defer" to non-excluded
490 * scripts. WordPress passes the full <script> tag string, the
491 * handle, and the src. We bail when:
492 * - the user excluded this handle / src substring,
493 * - the tag already has defer or async (don't double-set),
494 * - the tag has no src (inline scripts can't be deferred — would
495 * execute synchronously regardless).
496 *
497 * @param string $tag
498 * @param string $handle
499 * @param string $src
500 */
501 public static function defer_script_tag( $tag, $handle, $src ): string {
502 if ( ! is_string( $tag ) || '' === $tag ) {
503 return (string) $tag;
504 }
505 // Self-guard: even though Minifier::__construct() bails on admin/
506 // AJAX/REST/cron at registration, a late context switch (e.g. a
507 // custom wp_print_scripts() call inside an admin page render) can
508 // leave the filter attached. Skipping here keeps the React admin
509 // bundle's <script> tag intact so the dashboard mounts.
510 if ( self::skip_in_non_frontend_context() ) {
511 return $tag;
512 }
513 if ( '' === (string) $src ) {
514 return $tag;
515 }
516 if ( self::is_excluded_script( (string) $handle, (string) $src ) ) {
517 return $tag;
518 }
519 // The tag itself asked to be left alone. (#456)
520 if ( self::tag_opts_out( $tag ) ) {
521 return $tag;
522 }
523 // Inline code elsewhere on the page reads this handle (or something
524 // it depends on). Inline blocks never defer, so deferring this one
525 // would run the consumer first. Defer only — delay is an opt-in
526 // target list, where the user has named the script deliberately.
527 if ( isset( self::inline_bound_handles()[ (string) $handle ] ) ) {
528 return $tag;
529 }
530 // NB: is_protected_from_bundling() is the same two rules in one call
531 // for the combiner; the split here is deliberate, since the
532 // exclusion check above already ran and short-circuits earlier.
533 if ( false !== stripos( $tag, ' defer' ) || false !== stripos( $tag, ' async' ) ) {
534 return $tag;
535 }
536 // Target the <script> that actually carries a src, NOT simply the
537 // first one in the string. WP_Scripts::do_item() hands this filter
538 // the CONCATENATION of before_inline + external + after_inline, so
539 // for any handle carrying a `before` inline script the first
540 // `<script` is the inline block. Deferring that is a no-op (the HTML
541 // spec ignores defer on inline scripts) AND leaves the external
542 // script undeferred while its dependencies get deferred — which
543 // inverts WordPress's guaranteed execution order and throws in any
544 // dependent that touches a global its dependency defines. (#234)
545 //
546 // The lookahead scans only within the tag (`[^>]*`) for ` src=`, so
547 // an inline `<script id="…-js-before">` can never match.
548 return (string) preg_replace( '#<script\b(?=[^>]*\ssrc\s*=)#i', '<script defer="defer"', $tag, 1 );
549 }
550
551 /**
552 * Filter: `script_loader_tag` — rewrite src= to data-xs-src= so the
553 * browser ignores it until the bootstrap (printed once on
554 * wp_footer) swaps it back on first user interaction. Same
555 * exclusion rules as defer. Inline scripts (no src) are also
556 * deferred until the first interaction.
557 *
558 * @param string $tag
559 * @param string $handle
560 * @param string $src
561 */
562 public static function delay_script_tag( $tag, $handle, $src ): string {
563 if ( ! is_string( $tag ) || '' === $tag ) {
564 return (string) $tag;
565 }
566 if ( self::skip_in_non_frontend_context() ) {
567 return $tag;
568 }
569 if ( self::is_excluded_script( (string) $handle, (string) $src ) ) {
570 return $tag;
571 }
572 // The tag itself asked to be left alone. (#456)
573 if ( self::tag_opts_out( $tag ) ) {
574 return $tag;
575 }
576 if ( ! self::is_delay_target( (string) $handle, (string) $src ) ) {
577 return $tag;
578 }
579 // Inline code elsewhere on the page reads this handle (or something
580 // it depends on) — same registry walk defer uses. Delaying it runs
581 // the consumer at parse time against a global that arrives on first
582 // interaction: `wp_add_inline_script( 'jquery-ui-core',
583 // 'jQuery.uiBackCompat…', 'before' )` throws "jQuery is not defined"
584 // the moment jquery-core is delayed. A handle the user NAMED in
585 // delay_js_targets is still delayed — an explicit entry is the user
586 // saying they know the inline consumer is safe to break or absent.
587 if ( isset( self::inline_bound_handles()[ (string) $handle ] )
588 && ! self::is_user_named_target( (string) $handle, (string) $src ) ) {
589 return $tag;
590 }
591 // A non-executable type means this tag is data, or is being held by
592 // somebody else on purpose. The buffer pass has always checked this;
593 // the enqueue path did not, so a consent-blocked or JSON-carrying
594 // handle could still be rewritten here. (#274)
595 if ( in_array( self::extract_type( $tag ), self::NON_EXECUTABLE_TYPES, true ) ) {
596 return $tag;
597 }
598 // src= variant: swap src → data-xs-src and add data-xs-delay marker.
599 if ( '' !== (string) $src ) {
600 // Anchor on the opening <script …> tag that carries the src.
601 // Matching a bare `src=` across the whole string would rewrite
602 // the first occurrence anywhere — including inside a `before`
603 // inline block, where JS like `el.src = "…"` becomes the
604 // syntax error `el.data-xs-src="…" data-xs-delay="1"` and the
605 // real external script is left undelayed. $tag is the
606 // concatenation of before_inline + external + after_inline,
607 // so that is a routine shape, not a corner case. (#234)
608 // `(?<![-\w])` where `\b` used to be. A hyphen is a non-word
609 // character, so `\bsrc=` also matches the TAIL of any
610 // `data-…-src=` attribute — and consent managers and other
611 // optimizers park a blocked script's real URL in exactly that
612 // shape. Complianz's `data-cmplz-src` became
613 // `data-cmplz-data-xs-src`, so after the visitor clicked Accept
614 // the plugin looked for an attribute that no longer existed and
615 // the script never loaded: analytics and pixels silently dead,
616 // no console error, nothing in the UI. Same class of bug as the
617 // image-dimension resolver in #328. (#273)
618 return (string) preg_replace(
619 '#(<script\b[^>]*?)(?<![-\w])src\s*=\s*(["\'][^"\']*["\'])#i',
620 '$1data-xs-src=$2 data-xs-delay="1"',
621 $tag,
622 1
623 );
624 }
625 // Inline script: change type to text/xspeed-delayed so the browser
626 // doesn't execute, mark for bootstrap rewriter. Any existing type
627 // is REPLACED, not appended-after: HTML keeps an attribute's first
628 // occurrence, so a snippet carrying its own `type="text/javascript"`
629 // would win over a marker appended behind it and keep executing.
630 // A non-default original type is stashed in data-xs-type so the
631 // bootstrap can restore it on replay (#274 — type is what a script
632 // IS; a parked `type="module"` must come back as a module).
633 $tag = (string) preg_replace_callback(
634 '#<script\b([^>]*)>#i',
635 static function ( array $m ): string {
636 return '<script' . self::park_type_attrs( $m[1] ) . '>';
637 },
638 $tag,
639 1
640 );
641 return $tag;
642 }
643
644 /**
645 * Script types the buffer pass must never touch. `<script>` carries
646 * data as often as it carries code: JSON-LD feeds structured-data
647 * consumers, importmaps must resolve before any module runs, and our
648 * own delayed-inline marker is already handled by the bootstrap.
649 * Rewriting any of these breaks the page or its metadata.
650 */
651 private const NON_EXECUTABLE_TYPES = array(
652 'application/ld+json',
653 'application/json',
654 'importmap',
655 'speculationrules',
656 'text/template',
657 'text/x-template',
658 'text/xspeed-delayed',
659 // A consent manager parks a blocked third-party script here and
660 // swaps the type back only once the visitor has agreed. Whatever we
661 // do to such a tag we do on behalf of a decision the visitor has not
662 // made yet, so the only correct move is to leave it alone. (#274)
663 'text/plain',
664 );
665
666 /**
667 * The `type` attribute, quoted OR unquoted, anchored to attribute
668 * position — a required leading whitespace, never a bare `\b`.
669 *
670 * The anchoring matters twice over. `\btype` also matches the tail of
671 * any hyphenated `data-…-type` attribute (a `-` is a non-word char, so
672 * the boundary sits inside the name — the same #273 class as `src`),
673 * and it matches a `type=` sitting INSIDE another attribute's value
674 * (`onload="this.type='done'"`). Requiring whitespace before the name
675 * rules both out: attributes are whitespace-separated, while `.type`
676 * and `-type` never are. The unquoted branch exists because
677 * `type=text/javascript` is valid HTML: a quoted-only pattern left it
678 * standing, the parking type appended after it lost the
679 * first-occurrence race, and the snippet executed immediately AND
680 * replayed on interaction — every vendor event fired twice.
681 */
682 private const TYPE_ATTR_RE = '#\stype\s*=\s*(?:(["\'])(.*?)\1|([^\s>]+))#is';
683
684 /**
685 * `type` values a parked tag need not remember: the replay default is
686 * already JavaScript, so stashing these would only fatten the markup.
687 */
688 private const DEFAULT_JS_TYPES = array(
689 'text/javascript',
690 'application/javascript',
691 );
692
693 /**
694 * Read a tag's `type` attribute value, lowercased and trimmed.
695 *
696 * @param string $haystack Full tag or its attribute string.
697 * @return string '' when no type attribute is present.
698 */
699 private static function extract_type( string $haystack ): string {
700 if ( ! preg_match( self::TYPE_ATTR_RE, $haystack, $m ) ) {
701 return '';
702 }
703 $value = ( isset( $m[3] ) && '' !== $m[3] ) ? $m[3] : $m[2];
704 return strtolower( trim( $value ) );
705 }
706
707 /**
708 * Rewrite an inline tag's attribute string for parking: strip its own
709 * `type`, stash a non-default one in `data-xs-type` (the bootstrap
710 * restores it on replay, so a parked `type="module"` comes back as a
711 * module rather than a classic script — #274), and append the parking
712 * marker pair.
713 *
714 * @param string $attrs Raw attribute string (everything between
715 * `<script` and `>`).
716 */
717 private static function park_type_attrs( string $attrs ): string {
718 $orig = self::extract_type( $attrs );
719 $attrs = (string) preg_replace( self::TYPE_ATTR_RE, '', $attrs );
720 $stash = '';
721 if ( '' !== $orig && ! in_array( $orig, self::DEFAULT_JS_TYPES, true ) ) {
722 // MIME-ish charset only — a type value is never markup, and this
723 // string is re-emitted inside a double-quoted attribute.
724 $orig = (string) preg_replace( '#[^a-z0-9/+.\-]#', '', $orig );
725 if ( '' !== $orig ) {
726 $stash = ' data-xs-type="' . $orig . '"';
727 }
728 }
729 return $attrs . $stash . ' type="text/xspeed-delayed" data-xs-delay="1"';
730 }
731
732 /**
733 * URL fragments that must keep a live src no matter what. The enqueue
734 * path guards these by handle (ALWAYS_EXCLUDED_HANDLES), but a buffer
735 * pass only ever sees a URL, so the same protection is re-expressed
736 * here. Without this the admin bundle could be delayed on a frontend
737 * render and the dashboard would not mount.
738 */
739 private const ALWAYS_EXCLUDED_SRC = array(
740 '/plugins/xspeed/assets/',
741 '/wp-includes/js/dist/hooks',
742 '/wp-includes/js/dist/i18n',
743 );
744
745 /**
746 * Delay `<script src>` tags that never passed through wp_enqueue_script.
747 *
748 * `delay_script_tag()` hooks `script_loader_tag`, so it only ever sees
749 * enqueued scripts. Analytics, pixels, chat widgets and most third-party
750 * embeds are printed straight into `wp_head` / `wp_footer` as literal
751 * markup, bypassing that filter entirely — and those are exactly the
752 * scripts most worth delaying. On the site that surfaced this, 39
753 * enqueued scripts were correctly delayed while one un-enqueued
754 * analytics tag still downloaded 441 KB: 98% of the page's JS payload.
755 *
756 * Runs on the finished page buffer via `xspeed_cache_final_html`, so the
757 * rewrite is baked into the cached HTML and replays on every static hit
758 * (where PHP never boots). Deliberately conservative — it rewrites only
759 * `src`, leaves inline code to the enqueue path, and skips any tag whose
760 * `type` marks it as data rather than code.
761 *
762 * @param string $html Complete page HTML.
763 */
764 public static function delay_raw_script_tags( $html ): string {
765 if ( ! is_string( $html ) || '' === $html ) {
766 return (string) $html;
767 }
768 if ( self::skip_in_non_frontend_context() ) {
769 return $html;
770 }
771 $opts = self::opts();
772 if ( empty( $opts['delay_js'] ) ) {
773 return $html;
774 }
775
776 return (string) preg_replace_callback(
777 '#<script\b[^>]*>#i',
778 static function ( array $m ): string {
779 $tag = $m[0];
780
781 // Already handled by the enqueue-path filter.
782 if ( false !== stripos( $tag, 'data-xs-delay' ) || false !== stripos( $tag, 'data-xs-src' ) ) {
783 return $tag;
784 }
785
786 // The tag itself asked to be left alone. (#456)
787 if ( self::tag_opts_out( $tag ) ) {
788 return $tag;
789 }
790
791 // No src → inline code. The enqueue path owns those; a
792 // buffer rewrite here would have to reason about execution
793 // order it cannot see.
794 // `(?<![-\w])` not `\b` — see the note on the enqueue-path
795 // rewrite above. With `\b`, a tag whose ONLY url lives in
796 // `data-cmplz-src` (a consent-blocked script, no real src at
797 // all) read as an external script here, and the rewrite
798 // below then mangled that attribute. (#273)
799 if ( ! preg_match( '#(?<![-\w])src\s*=\s*(["\'])(.*?)\1#is', $tag, $src_m ) ) {
800 return $tag;
801 }
802 $src = $src_m[2];
803
804 // Data, not code.
805 if ( in_array( self::extract_type( $tag ), self::NON_EXECUTABLE_TYPES, true ) ) {
806 return $tag;
807 }
808
809 foreach ( self::ALWAYS_EXCLUDED_SRC as $needle ) {
810 if ( false !== stripos( $src, $needle ) ) {
811 return $tag;
812 }
813 }
814
815 // Recover the handle from the tag's id before deciding.
816 //
817 // This pass used to pass '' as the handle, on the reasoning
818 // that a tag reaching the buffer was never enqueued and so has
819 // none. That holds for the third-party snippets this pass
820 // exists for — but NOT for enqueued scripts, which also travel
821 // through here, and which WordPress prints with
822 // `id="<handle>-js"`. Passing '' meant every handle-based
823 // exclusion was silently inert at this layer: the user writes
824 // `jquery-core`, the enqueue path honours it, and then the
825 // buffer pass — which only ever compared URLs — delayed the
826 // very script the list was protecting.
827 //
828 // That is how a site with jquery-core AND jquery-migrate
829 // excluded still shipped jQuery delayed while migrate loaded
830 // normally, and every inline `jQuery(...)` on the page threw
831 // "jQuery is not defined". The two behaved differently for no
832 // reason a user could see, which is what made it look like a
833 // matching quirk rather than a whole layer ignoring the list.
834 $tag_handle = '';
835 if ( preg_match( '#\sid\s*=\s*(["\'])(.*?)\1#i', $tag, $id_m ) ) {
836 // WP appends `-js`; anything else is somebody's own id and
837 // is still worth matching literally.
838 $tag_handle = (string) preg_replace( '/-js$/', '', $id_m[2] );
839 }
840
841 if ( self::is_excluded_script( $tag_handle, $src ) ) {
842 return $tag;
843 }
844 if ( ! self::is_delay_target( $tag_handle, $src ) ) {
845 return $tag;
846 }
847 // Mirror of the enqueue-path guard: a handle that inline code
848 // reads stays eager unless the user named it. wp_scripts()
849 // is still populated at xspeed_cache_final_html time on a
850 // MISS, so the registry walk is consultable here too; an
851 // unrecoverable handle ('') simply never matches the set.
852 if ( '' !== $tag_handle
853 && isset( self::inline_bound_handles()[ $tag_handle ] )
854 && ! self::is_user_named_target( $tag_handle, $src ) ) {
855 return $tag;
856 }
857
858 return (string) preg_replace(
859 '#(?<![-\w])src\s*=\s*(["\'][^"\']*["\'])#i',
860 'data-xs-src=$1 data-xs-delay="1"',
861 $tag,
862 1
863 );
864 },
865 $html
866 );
867 }
868
869 /**
870 * Delay inline vendor snippets that reference a known third-party host.
871 *
872 * The pass above rewrites `src` and deliberately leaves inline code
873 * alone — but the OFFICIAL install for Clarity, GA, GTM and the Meta
874 * pixel is an inline loader (`(function(c,l,a,r,i,t,y){…t.src=…})`)
875 * with no `src` attribute at all. That snippet executes on every page
876 * load, fetches the vendor bundle inside the measurement window, and
877 * puts the one host whose Cache-Control the site cannot set straight
878 * into the cache-policy and TBT audits. Delaying the enqueue path and
879 * the raw-src path while this runs untouched is delaying everything
880 * except the tag the feature exists for.
881 *
882 * The judgment call is the same one KNOWN_THIRD_PARTY_SRC already
883 * makes: an inline body that names one of those hosts is that vendor's
884 * loader or its config — never something first-party code holds a
885 * synchronous reference to. The body is the haystack for the user's
886 * exclusion and target lists too, so the same fragment that protects a
887 * `src` tag protects its inline install.
888 *
889 * `document.write` bodies are skipped outright: replayed after the
890 * parser has closed the document, a delayed write would replace the
891 * page rather than add to it.
892 *
893 * @param string $html Complete page HTML.
894 */
895 public static function delay_inline_snippets( $html ): string {
896 if ( ! is_string( $html ) || '' === $html ) {
897 return (string) $html;
898 }
899 if ( self::skip_in_non_frontend_context() ) {
900 return $html;
901 }
902 $opts = self::opts();
903 if ( empty( $opts['delay_js'] ) ) {
904 return $html;
905 }
906
907 $out = preg_replace_callback(
908 '#<script\b([^>]*)>(.*?)</script>#is',
909 static function ( array $m ): string {
910 list( $whole, $attrs, $body ) = $m;
911
912 if ( '' === trim( $body ) ) {
913 return $whole;
914 }
915
916 // The tag itself asked to be left alone. (#456)
917 if ( self::tag_opts_out( $attrs ) ) {
918 return $whole;
919 }
920
921 // Our own replay bootstrap. Its body quotes the delay
922 // machinery's own strings, so a pathological user target
923 // fragment could match it — and a parked bootstrap means
924 // nothing on the page ever replays.
925 if ( false !== stripos( $attrs, 'xspeed-delay-bootstrap' ) ) {
926 return $whole;
927 }
928
929 // Already marked, or a real src= — the src passes own those.
930 // `(?<![-\w])` for the same reason as above: `data-cmplz-src`
931 // must not read as a src. (#273)
932 if ( false !== stripos( $attrs, 'data-xs-delay' ) || false !== stripos( $attrs, 'data-xs-src' ) ) {
933 return $whole;
934 }
935 if ( preg_match( '#(?<![-\w])src\s*=\s*(["\']).*?\1#is', $attrs ) ) {
936 return $whole;
937 }
938
939 // Data, a module map, or a consent manager's parked tag.
940 if ( in_array( self::extract_type( $attrs ), self::NON_EXECUTABLE_TYPES, true ) ) {
941 return $whole;
942 }
943
944 // A delayed document.write replays after the document has
945 // closed and replaces the page. Never delay one.
946 if ( false !== stripos( $body, 'document.write' ) ) {
947 return $whole;
948 }
949
950 // The body stands in for the URL in the lists the src passes
951 // consult — but NOT via is_delay_target(), whose empty-list
952 // default is "delay everything". That default is right for a
953 // tag with a URL and catastrophic here: it would park every
954 // inline script on the page. Inline code is delayed only on a
955 // positive identification — the body names a known vendor
956 // host, or a fragment the user targeted — and the exclusion
957 // list still wins first.
958 if ( self::is_excluded_script( '', $body ) ) {
959 return $whole;
960 }
961 if ( ! self::matches_known_third_party( $body ) && ! self::matches_user_targets( $body ) ) {
962 return $whole;
963 }
964
965 // Replace — not append — any existing type. Attributes keep
966 // their FIRST occurrence in HTML, so appending the parking
967 // type after the snippet's own `type="text/javascript"`
968 // would leave the original executable. A non-default type is
969 // stashed in data-xs-type for the bootstrap to restore.
970 return '<script' . self::park_type_attrs( $attrs ) . '>' . $body . '</script>';
971 },
972 $html
973 );
974 // A PCRE failure (backtrack limit on a huge inline body) returns
975 // null — and casting that to '' would serve AND cache a blank page.
976 // The unrewritten original is always the safe fallback.
977 return null === $out ? $html : $out;
978 }
979
980 /**
981 * Inline bootstrap that flips delayed scripts on the first user
982 * interaction. Printed once on wp_footer priority 1000.
983 */
984 public static function print_delay_bootstrap(): void {
985 if ( self::skip_in_non_frontend_context() ) {
986 return;
987 }
988 if ( self::$delay_bootstrap_printed ) {
989 return;
990 }
991 self::$delay_bootstrap_printed = true;
992
993 // Failsafe timer for visitors who never interact. 0 disables it
994 // entirely (interaction-only), which is what lab tools measure
995 // best: a timer that fires inside Lighthouse's / GTmetrix's
996 // measurement window loads the "delayed" scripts anyway and
997 // inflates the reported TTI, so the delay looks ineffective.
998 $opts = self::opts();
999 $timeout = isset( $opts['delay_js_timeout'] ) ? (int) $opts['delay_js_timeout'] : 8000;
1000 $timeout = max( 0, min( 60000, $timeout ) );
1001
1002 // Tiny vanilla bootstrap; keep it self-contained so the page
1003 // has no JS dependencies before the first interaction.
1004 ?>
1005 <script id="xspeed-delay-bootstrap">
1006 (function(){
1007 var events=['mousemove','keydown','touchstart','scroll','wheel'];
1008 var fired=false;
1009 function load(){
1010 if(fired)return;fired=true;
1011 events.forEach(function(e){window.removeEventListener(e,load,{passive:true,capture:true});});
1012 var delayed=document.querySelectorAll('script[data-xs-delay]');
1013 delayed.forEach(function(s){
1014 var n=document.createElement('script');
1015 // A dynamically-created script is async by default, so replayed
1016 // EXTERNALS would race each other; async=false restores document
1017 // order among the externals. Narrower guarantee, stated plainly:
1018 // a replayed INLINE script still executes synchronously at its
1019 // replaceChild, i.e. possibly before an earlier external has
1020 // finished LOADING — so an inline consumer of a delayed external
1021 // is only safe when both were delayed by explicit user targeting
1022 // (the inline-bound guard keeps the implicit case eager).
1023 n.async=false;
1024 // Nonce hiding: a connected element's nonce CONTENT attribute reads
1025 // as "", so copying it via the attribute loop would hand the clone
1026 // an empty nonce and a nonce-based CSP would block the replay. The
1027 // IDL property still carries the real value.
1028 if(s.nonce){n.nonce=s.nonce;}
1029 Array.prototype.slice.call(s.attributes).forEach(function(a){
1030 if(a.name==='data-xs-src'){n.setAttribute('src',a.value);return;}
1031 if(a.name==='data-xs-delay')return;
1032 if(a.name==='nonce')return;
1033 // A parked inline tag's ORIGINAL type (module, mostly) rides in
1034 // data-xs-type — restore it, or the replay runs a module as a
1035 // classic script and its imports throw. (#274)
1036 if(a.name==='data-xs-type'){n.setAttribute('type',a.value);return;}
1037 // `type` is what a script IS, not decoration, so it is carried over
1038 // — with ONE exception: our own inline parking marker, which exists
1039 // only to stop the browser executing the original and must not be
1040 // copied onto the replacement. Dropping type wholesale broke two
1041 // things: `type="module"` became a classic script (core's Script
1042 // Modules — Navigation, lightbox, Query Loop — threw "Cannot use
1043 // import statement outside a module" on the default theme), and
1044 // `type="text/plain"`, which is precisely how a consent manager
1045 // parks a blocked third-party script, became executable again. The
1046 // second is a privacy failure, not a broken feature. (#274)
1047 if(a.name==='type'&&a.value==='text/xspeed-delayed')return;
1048 n.setAttribute(a.name,a.value);
1049 });
1050 if(!s.hasAttribute('data-xs-src')){n.text=s.text;}
1051 s.parentNode.replaceChild(n,s);
1052 });
1053 }
1054 events.forEach(function(e){window.addEventListener(e,load,{passive:true,capture:true});});
1055 <?php if ( $timeout > 0 ) : ?>
1056 setTimeout(load,<?php echo (int) $timeout; ?>);
1057 <?php endif; ?>
1058 })();
1059 </script>
1060 <?php
1061 }
1062
1063 /**
1064 * Filter: `style_loader_tag` — wrap stylesheets in the
1065 * print → onload="all" pattern so they download non-blocking.
1066 * Pairs with critical CSS workflows. Adds a <noscript> fallback so
1067 * users with JS disabled still get styles applied (via media="all").
1068 *
1069 * @param string $tag
1070 * @param string $handle
1071 */
1072 public static function async_style_tag( $tag, $handle ): string {
1073 if ( ! is_string( $tag ) || '' === $tag ) {
1074 return (string) $tag;
1075 }
1076 if ( self::skip_in_non_frontend_context() ) {
1077 return $tag;
1078 }
1079 // Only operate on <link rel=stylesheet> with a media attribute
1080 // we can swap. Skip anything custom (preload, etc.) — we don't
1081 // want to fight with explicit author intent.
1082 if ( false === stripos( $tag, 'rel=\'stylesheet\'' ) && false === stripos( $tag, 'rel="stylesheet"' ) ) {
1083 return $tag;
1084 }
1085 // The stylesheets that lay the page out stay render-blocking.
1086 //
1087 // This transform moves a sheet to AFTER first paint. That is the
1088 // point of it — but a sheet the layout depends on is then missing
1089 // from the only paint the visitor sees, and the page renders as
1090 // unstyled HTML (bulleted nav, underlined links) until the swap
1091 // runs. The pattern is only safe when something already styles the
1092 // above-the-fold area, i.e. critical CSS — which Free does not
1093 // generate. Deferring EVERY sheet on a site without it guarantees
1094 // the flash rather than risking it: on the reported Kadence site
1095 // all 17 stylesheets were deferred and none was render-blocking,
1096 // so there was nothing left to paint the page with. (#269)
1097 if ( self::is_layout_critical_style( $handle ) ) {
1098 return $tag;
1099 }
1100 // A JS-measured layout on this page makes deferral unsafe for EVERY
1101 // sheet, not just the theme's.
1102 //
1103 // Masonry, isotope, packery and the slider libraries lay elements out
1104 // by MEASURING them and then writing absolute positions. Deferring the
1105 // stylesheet that sizes those elements means the script measures them
1106 // unstyled — zero or full-width — computes positions from those wrong
1107 // numbers, and commits them. The CSS arriving a moment later cannot
1108 // undo it: the script has already run and does not re-measure. The
1109 // result is a permanently broken grid (items overlapping, or stranded
1110 // with a large gap), which is worse than the flash this feature's
1111 // other guard prevents, because it never resolves itself.
1112 //
1113 // This is checked per PAGE rather than per handle deliberately. The
1114 // script that measures is rarely the one whose handle matches the
1115 // sheet — Kadence's gallery is styled by
1116 // `kadence-blocks-advancedgallery` but laid out by core's `masonry` —
1117 // so pairing handles misses it. Whether a measuring library is present
1118 // at all is the signal that generalises. (#269)
1119 if ( self::page_has_js_measured_layout() ) {
1120 return $tag;
1121 }
1122 // Avoid double-wrapping.
1123 if ( false !== stripos( $tag, 'data-xs-async' ) ) {
1124 return $tag;
1125 }
1126 // Someone else already made this sheet non-render-blocking.
1127 //
1128 // Plugins that ship their own async-CSS handling apply the same
1129 // media="print" + onload swap we do, and they run on the SAME
1130 // filter — SureCookie's consent banner does it at style_loader_tag
1131 // priority 10, ours is priority 20, so its finished tag arrives
1132 // here looking like a plain stylesheet with no marker of ours.
1133 //
1134 // Transforming it again breaks the sheet two ways: the media we'd
1135 // capture as "the original to restore" is already `print`, so we
1136 // emit onload="this.media='print'" — a swap to itself that never
1137 // activates the stylesheet — and we append a SECOND onload
1138 // attribute, of which the parser honours only the first (ours),
1139 // discarding the plugin's correct this.media='all'. The banner
1140 // then mounts unstyled, in both logged-in and logged-out states.
1141 //
1142 // An onload handler or a print media on a stylesheet link is only
1143 // ever this pattern; a genuinely print-only sheet is already off
1144 // the critical path and gains nothing from us. Either way the
1145 // right move is to leave the tag alone — the same "don't fight
1146 // explicit author intent" rule the rel= check above applies. (#216)
1147 if ( preg_match( '#\bonload\s*=#i', $tag ) ) {
1148 return $tag;
1149 }
1150 if ( preg_match( '#\bmedia\s*=\s*(["\'])\s*print\s*\1#i', $tag ) ) {
1151 return $tag;
1152 }
1153 return self::async_link_markup( $tag );
1154 }
1155
1156 /**
1157 * The one place the async-CSS output shape lives: swap the link's media
1158 * to `print`, restore the original media onload, record it in
1159 * `data-xs-async`, and re-emit the untouched tag inside `<noscript>` for
1160 * clients that never run the onload handler.
1161 *
1162 * Shared by the enqueue-path filter above and the raw-tag buffer pass
1163 * below so the two can never drift — Pro's Critical CSS recognises this
1164 * exact marker to avoid double-wrapping, and a second copy of the
1165 * pattern is how that kind of contract quietly breaks.
1166 *
1167 * Callers own every skip decision (markers, onload, non-screen media);
1168 * this helper only produces the markup.
1169 *
1170 * @param string $tag A `<link rel="stylesheet">` tag deemed safe to defer.
1171 */
1172 private static function async_link_markup( string $tag ): string {
1173 $async = (string) preg_replace_callback(
1174 '#\bmedia\s*=\s*(["\'])([^"\']*)\1#i',
1175 static function ( $m ) {
1176 $orig = $m[2];
1177 return 'media="print" onload="this.media=\'' . esc_attr( $orig ) . '\'" data-xs-async="' . esc_attr( $orig ) . '"';
1178 },
1179 $tag,
1180 1
1181 );
1182 // If no media= was present (rare), inject one.
1183 if ( $async === $tag ) {
1184 $async = (string) preg_replace(
1185 '#<link\b#i',
1186 '<link media="print" onload="this.media=\'all\'" data-xs-async="all"',
1187 $tag,
1188 1
1189 );
1190 }
1191 // Fallback for noscript users — re-emit the original tag inside <noscript>.
1192 return $async . '<noscript>' . $tag . '</noscript>';
1193 }
1194
1195 /**
1196 * Stylesheet hosts that serve FONT CSS — small, render-blocking sheets of
1197 * `@font-face` rules. The buffer pass below defers only these: a raw
1198 * cross-origin `<link>` could carry anything, and blindly deferring an
1199 * unknown vendor's layout CSS from the buffer would reintroduce the
1200 * unstyled-flash failure async_style_tag()'s guards exist to prevent.
1201 * Font CSS is the safe subset — text renders in a fallback face and swaps,
1202 * which is exactly what `font-display: swap` does on purpose.
1203 */
1204 private const FONT_CSS_HOSTS = array(
1205 'fonts.googleapis.com',
1206 'fonts.bunny.net',
1207 'use.typekit.net',
1208 'p.typekit.net',
1209 'fonts.cdnfonts.com',
1210 );
1211
1212 /**
1213 * The font-CSS host allowlist, filtered and normalised.
1214 *
1215 * @return string[] Lowercase hostnames.
1216 */
1217 private static function font_css_hosts(): array {
1218 /**
1219 * Hosts whose stylesheet links the async-CSS buffer pass rewrites to
1220 * the non-blocking print → onload pattern. Only font-CSS providers
1221 * belong here: every listed host's sheets are safe to load late
1222 * because they only add `@font-face` rules.
1223 *
1224 * @param string[] $hosts Hostnames (exact match, case-insensitive).
1225 */
1226 $hosts = (array) apply_filters( 'xspeed_async_css_font_hosts', self::FONT_CSS_HOSTS );
1227
1228 return array_map( 'strtolower', array_map( 'strval', $hosts ) );
1229 }
1230
1231 /**
1232 * Media values that never apply to a screen paint. A sheet restricted to
1233 * one of these is not render-blocking for screen, so deferring it saves
1234 * nothing — and `print` in particular is either a genuine print sheet or
1235 * somebody's finished async pattern, both of which must be left alone.
1236 */
1237 private const NON_SCREEN_MEDIA = array(
1238 'print',
1239 'speech',
1240 'aural',
1241 'braille',
1242 'embossed',
1243 'handheld',
1244 'projection',
1245 'tty',
1246 'tv',
1247 );
1248
1249 /**
1250 * Filter: `xspeed_cache_final_html` — defer RAW font-CSS stylesheet links
1251 * that never passed through wp_enqueue_style.
1252 *
1253 * `async_style_tag()` hooks `style_loader_tag`, so it only ever sees
1254 * enqueued stylesheets. Themes and font plugins print Google Fonts (and
1255 * Bunny, Typekit, CDNFonts) as literal
1256 * `<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=…">`
1257 * markup in the head — on the site that surfaced this, four such tags —
1258 * and each one stays render-blocking with no plugin lever. Unused CSS
1259 * skips cross-origin hrefs by design, so nothing else picks them up.
1260 *
1261 * Runs on the finished page buffer, so the rewrite is baked into the
1262 * cached HTML and replays on every static hit. Deliberately narrow: only
1263 * links whose host is on the font-CSS allowlist are touched — see
1264 * FONT_CSS_HOSTS. Same-origin links (no host, or the site's own) never
1265 * match the allowlist and are untouched.
1266 *
1267 * @param string $html Complete page HTML.
1268 */
1269 public static function async_raw_font_css_links( $html ): string {
1270 if ( ! is_string( $html ) || '' === $html ) {
1271 return (string) $html;
1272 }
1273 if ( self::skip_in_non_frontend_context() ) {
1274 return $html;
1275 }
1276 $opts = self::opts();
1277 if ( empty( $opts['async_css'] ) ) {
1278 return $html;
1279 }
1280
1281 // Never rewrite inside a <noscript>. That block IS the no-JS
1282 // fallback — its <link> is a plain blocking stylesheet on purpose,
1283 // and async_style_tag() itself emits one for every sheet it defers.
1284 // Rewriting it would nest <noscript> (invalid; the parser closes the
1285 // outer block at the first </noscript>) and hand no-JS visitors a
1286 // media="print" sheet whose onload never runs: no stylesheet at all.
1287 // Splitting the buffer on <noscript> spans and rewriting only the
1288 // slices between them also makes the pass idempotent against
1289 // whatever an earlier pass emitted.
1290 $parts = preg_split(
1291 '#(<noscript\b[^>]*>.*?</noscript\s*>)#is',
1292 $html,
1293 -1,
1294 PREG_SPLIT_DELIM_CAPTURE
1295 );
1296
1297 // preg_split failed (pathological buffer / backtrack limit). Without
1298 // the split we cannot tell a fallback link from a live one, so leave
1299 // the page untouched — a few blocking font sheets beat a broken
1300 // no-JS fallback.
1301 if ( ! is_array( $parts ) ) {
1302 return $html;
1303 }
1304
1305 foreach ( $parts as $i => $part ) {
1306 // Odd indices are the captured <noscript> blocks.
1307 if ( 1 === $i % 2 || '' === $part ) {
1308 continue;
1309 }
1310 $parts[ $i ] = self::async_font_links_in_slice( $part );
1311 }
1312
1313 return implode( '', $parts );
1314 }
1315
1316 /**
1317 * Rewrite the font-CSS links in one <noscript>-free slice of the buffer.
1318 *
1319 * @param string $html Slice of page HTML with no <noscript> spans.
1320 */
1321 private static function async_font_links_in_slice( string $html ): string {
1322 $hosts = self::font_css_hosts();
1323
1324 $out = preg_replace_callback(
1325 '#<link\b[^>]*>#i',
1326 static function ( array $m ) use ( $hosts ): string {
1327 $tag = $m[0];
1328
1329 // Only plain stylesheets — never preload/alternate/anything
1330 // carrying explicit author intent. `(?<![-\w])` not `\b`, so
1331 // a `data-rel=` attribute can never read as the rel — same
1332 // reason the delay passes spell src that way. (#273)
1333 if ( ! preg_match( '#(?<![-\w])rel\s*=\s*(["\']?)\s*stylesheet\s*\1#i', $tag ) ) {
1334 return $tag;
1335 }
1336
1337 // Already deferred (either marker spelling — ours and Pro's),
1338 // or explicitly opted out by the theme.
1339 foreach ( array( 'data-xs-async', 'data-xspeed-async', 'data-xspeed-keep' ) as $marker ) {
1340 if ( false !== stripos( $tag, $marker ) ) {
1341 return $tag;
1342 }
1343 }
1344
1345 // An onload handler on a stylesheet link is only ever
1346 // somebody's finished async pattern — same rule as
1347 // async_style_tag(). (#216)
1348 if ( preg_match( '#(?<![-\w])onload\s*=#i', $tag ) ) {
1349 return $tag;
1350 }
1351
1352 // A sheet that never applies on screen is not blocking paint.
1353 if ( preg_match( '#(?<![-\w])media\s*=\s*(["\'])([^"\']*)\1#i', $tag, $mm )
1354 && in_array( strtolower( trim( $mm[2] ) ), self::NON_SCREEN_MEDIA, true ) ) {
1355 return $tag;
1356 }
1357
1358 if ( ! preg_match( '#(?<![-\w])href\s*=\s*(["\'])([^"\']+)\1#i', $tag, $hm ) ) {
1359 return $tag;
1360 }
1361 // No host means a relative URL — same-origin, and the enqueue
1362 // path's business if it is anybody's.
1363 $host = strtolower( (string) wp_parse_url( $hm[2], PHP_URL_HOST ) );
1364 if ( '' === $host || ! in_array( $host, $hosts, true ) ) {
1365 return $tag;
1366 }
1367
1368 return self::async_link_markup( $tag );
1369 },
1370 $html
1371 );
1372
1373 // A PCRE failure returns null — the unrewritten slice is the safe
1374 // fallback, never an empty page.
1375 return null === $out ? $html : $out;
1376 }
1377
1378 /**
1379 * Whether a stylesheet handle carries the page's layout, and so must
1380 * keep blocking the first paint.
1381 *
1382 * Two families qualify:
1383 *
1384 * - The ACTIVE THEME's own sheets. A theme stylesheet is the page's
1385 * layout by definition; without it the document paints as unstyled
1386 * HTML. Resolved from the live theme's stem (`kadence` →
1387 * `kadence-global`, `kadence-header`, …) plus the handles WordPress
1388 * itself registers for a theme, so this holds for any theme rather
1389 * than a hard-coded list.
1390 * - WordPress' own BLOCK and layout sheets (`wp-block-library`,
1391 * `global-styles`, `classic-theme-styles`). These style block
1392 * content on the front end and are as structural as the theme's.
1393 *
1394 * Everything else — plugin sheets, icon fonts, widget and page-builder
1395 * add-ons, the long tail that makes async CSS worth having — is still
1396 * deferred, so the optimization keeps most of its benefit.
1397 *
1398 * A site WITH critical CSS can defer these too; that is what the
1399 * `xspeed_async_css_layout_critical` filter is for.
1400 *
1401 * Pure aside from the theme lookup — unit-tested via the filter.
1402 *
1403 * @param string $handle Stylesheet handle from `style_loader_tag`.
1404 */
1405 public static function is_layout_critical_style( string $handle ): bool {
1406 $handle = strtolower( $handle );
1407
1408 // Core's front-end block + global styles.
1409 $core = array(
1410 'wp-block-library',
1411 'wp-block-library-theme',
1412 'global-styles',
1413 'classic-theme-styles',
1414 );
1415 $critical = in_array( $handle, $core, true );
1416
1417 // The active theme's own sheets.
1418 //
1419 // Matched on the theme stem, but NOT as a bare prefix: a plugin from
1420 // the same vendor shares it (the Kadence theme is `kadence`, while
1421 // `kadence-blocks-rowlayout` and `kadence-fonts-gfonts` come from the
1422 // Kadence Blocks PLUGIN and a webfont loader). Treating those as
1423 // layout-critical would leave almost nothing deferred and quietly
1424 // undo the feature. So the stem must be followed by a recognised
1425 // theme-area segment, which is how themes name their split sheets.
1426 if ( ! $critical && function_exists( 'get_template' ) ) {
1427 $areas = array(
1428 'style',
1429 'global',
1430 'header',
1431 'content',
1432 'footer',
1433 'main',
1434 'layout',
1435 'base',
1436 'core',
1437 'theme',
1438 'woocommerce',
1439 );
1440 foreach ( array( get_template(), get_stylesheet() ) as $stem ) {
1441 $stem = strtolower( (string) $stem );
1442 if ( '' === $stem ) {
1443 continue;
1444 }
1445 if ( $handle === $stem ) {
1446 $critical = true;
1447 break;
1448 }
1449 foreach ( $areas as $area ) {
1450 if ( $handle === $stem . '-' . $area ) {
1451 $critical = true;
1452 break 2;
1453 }
1454 }
1455 }
1456 }
1457
1458 /**
1459 * Whether this stylesheet must keep blocking the first paint.
1460 *
1461 * Return false for a handle to let async CSS defer it anyway — the
1462 * right call on a site that ships critical CSS. Return true to
1463 * protect an additional sheet the layout depends on.
1464 *
1465 * @param bool $critical Whether the sheet is treated as layout-critical.
1466 * @param string $handle The stylesheet handle.
1467 */
1468 return (bool) apply_filters( 'xspeed_async_css_layout_critical', $critical, $handle );
1469 }
1470
1471 /**
1472 * Filter: `style_loader_src` + `script_loader_src` — strip the
1473 * ?ver=X.Y query string that WP appends for cache busting. Some
1474 * CDNs / reverse proxies cache better when the URL has no query.
1475 *
1476 * Skip URLs whose query carries non-ver params — those might be
1477 * intentional (e.g. a CDN providing per-image transforms).
1478 *
1479 * `ver` is load-bearing on one class of asset: a file a plugin
1480 * REGENERATES IN PLACE. Complianz rewrites
1481 * uploads/complianz/css/banner-1-optin.css whenever the banner is
1482 * edited, Beaver Builder rewrites uploads/bb-plugin/cache/<post>-layout.css
1483 * on every layout save, Elementor uploads/elementor/css/post-<id>.css on
1484 * publish. The path never changes, so `?ver=<timestamp|hash>` is the only
1485 * thing telling a browser — or our own Browser Cache `immutable` rule — to
1486 * refetch. Strip it and the old styling is served until the browser cache
1487 * gives up, which for us is a year. So anything under the uploads root
1488 * keeps its version.
1489 *
1490 * Release assets under plugins/, themes/ and core are still stripped, but
1491 * not because they are safe: an update overwrites the same path there too,
1492 * and only `?ver=` changed. The difference is frequency, not mechanism — a
1493 * plugin update lands rarely and is expected to, a banner edit is a setting
1494 * the user just changed and expects to see. Stripping is the feature the
1495 * toggle is for; with Browser Cache on it is what the user is buying, and
1496 * `docs/user/minification.md` states the cost. (#276)
1497 *
1498 * @param string $src
1499 */
1500 public static function strip_version_query( $src ): string {
1501 if ( ! is_string( $src ) || '' === $src ) {
1502 return (string) $src;
1503 }
1504 if ( self::skip_in_non_frontend_context() ) {
1505 return $src;
1506 }
1507 $parts = wp_parse_url( $src );
1508 if ( ! is_array( $parts ) || empty( $parts['query'] ) ) {
1509 return $src;
1510 }
1511 parse_str( $parts['query'], $query );
1512 if ( ! is_array( $query ) || ! array_key_exists( 'ver', $query ) ) {
1513 return $src;
1514 }
1515
1516 $strip = ! self::is_regenerated_asset( $parts );
1517
1518 /**
1519 * Whether Remove Query Strings drops `?ver` from this asset URL.
1520 *
1521 * False by default under the uploads root, where page builders and
1522 * consent plugins rewrite generated CSS/JS in place and `ver` is its
1523 * only cache-buster. Return false to protect a generator that writes
1524 * somewhere else, true to force stripping.
1525 *
1526 * @param bool $strip Whether `ver` will be removed.
1527 * @param string $src The asset URL as enqueued.
1528 */
1529 if ( ! apply_filters( 'xspeed_strip_asset_version', $strip, $src ) ) {
1530 return $src;
1531 }
1532
1533 // Only strip 'ver' — keep anything else the asset URL needs.
1534 unset( $query['ver'] );
1535 $new_query = http_build_query( $query );
1536
1537 // Rebuild the authority only when the source had one. An enqueued
1538 // src is not always absolute: `//cdn.example/x.css` says "the
1539 // page's own scheme", and defaulting that to http:// is mixed
1540 // content an https page blocks outright; `/wp-includes/x.js` has no
1541 // host at all, and pasting one in produced `http:///wp-includes/…`,
1542 // which resolves nowhere.
1543 $new_url = '';
1544 if ( isset( $parts['host'] ) && '' !== $parts['host'] ) {
1545 $new_url = isset( $parts['scheme'] ) ? $parts['scheme'] . '://' : '//';
1546 $new_url .= $parts['host'];
1547 if ( isset( $parts['port'] ) ) {
1548 $new_url .= ':' . $parts['port'];
1549 }
1550 }
1551 $new_url .= $parts['path'] ?? '';
1552 if ( '' !== $new_query ) {
1553 $new_url .= '?' . $new_query;
1554 }
1555 if ( ! empty( $parts['fragment'] ) ) {
1556 $new_url .= '#' . $parts['fragment'];
1557 }
1558 return $new_url;
1559 }
1560
1561 /**
1562 * Memoised uploads root, see uploads_base(). Cleared by reset_state().
1563 *
1564 * @var array{host:string,path:string}|null
1565 */
1566 private static $uploads_base = null;
1567
1568 /**
1569 * The uploads root as a URL host + PATH, read from wp_get_upload_dir()
1570 * rather than hardcoded so a moved uploads dir, the `UPLOADS` constant and
1571 * the legacy multisite `/files/` layout all work.
1572 *
1573 * On multisite wp_get_upload_dir() answers with the per-site
1574 * `…/uploads/sites/<id>`. Generated assets live under the network root
1575 * too, so the suffix comes off and the whole tree matches.
1576 *
1577 * @return array{host:string,path:string}
1578 */
1579 private static function uploads_base(): array {
1580 if ( null !== self::$uploads_base ) {
1581 return self::$uploads_base;
1582 }
1583 $base = '';
1584 if ( function_exists( 'wp_get_upload_dir' ) ) {
1585 $dir = wp_get_upload_dir();
1586 $base = is_array( $dir ) && isset( $dir['baseurl'] ) ? (string) $dir['baseurl'] : '';
1587 }
1588 $host = '';
1589 $path = '';
1590 if ( '' !== $base ) {
1591 $host = strtolower( (string) wp_parse_url( $base, PHP_URL_HOST ) );
1592 $path = (string) wp_parse_url( $base, PHP_URL_PATH );
1593 }
1594 $path = (string) preg_replace( '#/sites/\d+/?$#', '', rtrim( $path, '/' ) );
1595 if ( '' === $path && '' === $host ) {
1596 // Unreadable. An empty prefix would match every asset on the
1597 // site, so fall back to where uploads normally is.
1598 $path = '/wp-content/uploads';
1599 }
1600 self::$uploads_base = array(
1601 'host' => $host,
1602 'path' => $path,
1603 );
1604 return self::$uploads_base;
1605 }
1606
1607 /**
1608 * Does this URL sit under the uploads root — i.e. is it a file some plugin
1609 * generates at runtime and rewrites in place?
1610 *
1611 * @param array<string,mixed> $parts wp_parse_url() output for the asset.
1612 */
1613 private static function is_regenerated_asset( array $parts ): bool {
1614 $base = self::uploads_base();
1615
1616 if ( '' !== $base['path'] ) {
1617 // Path only, never host: a pull-zone CDN, a protocol-relative URL
1618 // and an http/https flip all leave the path alone.
1619 $path = (string) ( $parts['path'] ?? '' );
1620 return '' !== $path && 0 === strpos( $path, $base['path'] . '/' );
1621 }
1622
1623 // Uploads AT the root of their own domain — an offload plugin
1624 // pointing `upload_url_path` at https://cdn.example.com. There is no
1625 // prefix left to test, and testing the path anyway would have read
1626 // every generated file on that CDN as an ordinary release asset and
1627 // stripped the one thing telling a browser it had changed. The host
1628 // is the whole answer here: everything served from it is an upload.
1629 $host = strtolower( (string) ( $parts['host'] ?? '' ) );
1630 return '' !== $host && $host === $base['host'];
1631 }
1632
1633 /**
1634 * Defensive context guard for filter callbacks. Mirrors the registration-
1635 * time bail in Minifier::__construct() so a late context flip (admin page
1636 * render kicked off mid-request, REST_REQUEST set after plugins_loaded,
1637 * etc.) doesn't let frontend tag rewrites leak into wp-admin / AJAX /
1638 * REST / cron responses.
1639 *
1640 * Specifically prevents the React admin bundle's <script> tag from being
1641 * deferred or src-swapped to data-xs-src — which would stop the dashboard
1642 * from booting and make toggles appear unchecked until first interaction.
1643 */
1644 private static function skip_in_non_frontend_context(): bool {
1645 if ( is_admin() ) {
1646 return true;
1647 }
1648 if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
1649 return true;
1650 }
1651 if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
1652 return true;
1653 }
1654 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
1655 return true;
1656 }
1657 return false;
1658 }
1659
1660 /**
1661 * Built-in exclusion list — always skipped regardless of user settings.
1662 * Covers our own admin bundle and the WP script-modules it depends on,
1663 * so that even if the registration-time admin guard is somehow bypassed,
1664 * the dashboard's React app can still boot.
1665 */
1666 private const ALWAYS_EXCLUDED_HANDLES = array(
1667 'xspeed-admin',
1668 'wp-hooks',
1669 'wp-i18n',
1670 'wp-url',
1671 'wp-api-fetch',
1672 );
1673
1674 private static function is_excluded_script( string $handle, string $src ): bool {
1675 if ( in_array( $handle, self::ALWAYS_EXCLUDED_HANDLES, true ) ) {
1676 return true;
1677 }
1678 // Never defer or delay our own scripts. The fold and RUM beacons
1679 // measure the FIRST paint — delayed to first interaction they
1680 // measure a scrolled page or nothing, so fold quorum never fills
1681 // and full CSS deferral never licenses. Found live: delay_js with
1682 // empty targets delayed the fold beacon itself, and the site sat
1683 // at zero fold reports for hours while its stylesheets stayed
1684 // render-blocking. Prefix, not a handle list, so a Pro module's
1685 // beacon added later cannot re-open the hole.
1686 if ( 0 === strpos( $handle, 'xspeed-' ) ) {
1687 return true;
1688 }
1689 $opts = self::opts();
1690 $excluded = is_array( $opts['defer_js_excluded'] ?? null ) ? $opts['defer_js_excluded'] : array();
1691 if ( empty( $excluded ) ) {
1692 return false;
1693 }
1694 foreach ( $excluded as $needle ) {
1695 // Matched against the pre-minify URL too: an exclusion that
1696 // stops matching is worse than a delay target that does — the
1697 // script the user explicitly protected gets deferred anyway.
1698 if ( self::target_matches( (string) $needle, $handle, $src ) ) {
1699 return true;
1700 }
1701 }
1702 return false;
1703 }
1704
1705 /**
1706 * Include-list targeting for delay (issue #36): when delay_js_targets
1707 * is non-empty, ONLY matching scripts are delayed — a heavy
1708 * third-party embed can be postponed without delaying the whole
1709 * page's JS. Empty targets = historical behavior (delay everything
1710 * minus exclusions). Same matching semantics as the exclusion list:
1711 * exact handle match OR case-insensitive URL substring.
1712 */
1713 /**
1714 * Whether the user's delay_js_targets list matches this haystack.
1715 *
1716 * The inline-snippet pass needs the target list WITHOUT
1717 * is_delay_target()'s empty-list-means-everything default — an inline
1718 * body is only ever delayed on a positive match.
1719 *
1720 * @param string $haystack Script body (or URL) to match fragments against.
1721 */
1722 private static function matches_user_targets( string $haystack ): bool {
1723 $opts = self::opts();
1724 $targets = is_array( $opts['delay_js_targets'] ?? null ) ? $opts['delay_js_targets'] : array();
1725 foreach ( $targets as $needle ) {
1726 $needle = (string) $needle;
1727 if ( '' !== $needle && false !== stripos( $haystack, $needle ) ) {
1728 return true;
1729 }
1730 }
1731 return false;
1732 }
1733
1734 /**
1735 * Whether the user EXPLICITLY named this script in delay_js_targets.
1736 *
1737 * Unlike is_delay_target() this never treats an empty list as
1738 * everything and never falls back to the vendor list — it answers
1739 * only "did the user deliberately point at this handle/URL?", which
1740 * is what lets an explicit entry override the inline-bound guard.
1741 *
1742 * @param string $handle Script handle.
1743 * @param string $src Script URL.
1744 */
1745 private static function is_user_named_target( string $handle, string $src ): bool {
1746 $opts = self::opts();
1747 $targets = is_array( $opts['delay_js_targets'] ?? null ) ? $opts['delay_js_targets'] : array();
1748 foreach ( $targets as $needle ) {
1749 $needle = (string) $needle;
1750 if ( '' !== $needle && self::target_matches( $needle, $handle, $src ) ) {
1751 return true;
1752 }
1753 }
1754 return false;
1755 }
1756
1757 private static function is_delay_target( string $handle, string $src ): bool {
1758 $opts = self::opts();
1759 $targets = is_array( $opts['delay_js_targets'] ?? null ) ? $opts['delay_js_targets'] : array();
1760 $targets = array_filter( array_map( 'strval', $targets ), static fn( $t ) => '' !== $t );
1761 if ( empty( $targets ) ) {
1762 return true;
1763 }
1764 foreach ( $targets as $needle ) {
1765 if ( self::target_matches( $needle, $handle, $src ) ) {
1766 return true;
1767 }
1768 }
1769 // The user's list is an ALLOW-list, so a target they never thought to
1770 // add is not delayed — and the scripts worth delaying are third-party
1771 // tags nobody enumerates by hand. Falling back to the built-in vendor
1772 // list means a site that lists one heavy embed still gets the obvious
1773 // analytics and widget tags postponed, instead of silently keeping
1774 // them on the main thread. (A user who wants one of these to run
1775 // early excludes it; the exclusion list is checked before this.)
1776 return self::matches_known_third_party( $src );
1777 }
1778
1779 /**
1780 * Whether a URL belongs to a third-party tag that is safe to postpone.
1781 *
1782 * These are analytics, tag managers, chat widgets, review embeds, session
1783 * recorders and error trackers: scripts that never paint anything above
1784 * the fold and that no first-party code holds a synchronous reference to.
1785 * They are also the scripts that dominate a real page's blocking time —
1786 * on embedpress.com one chat widget alone accounted for ~450ms of TBT and
1787 * a 22-point score swing between runs, purely on whether it happened to
1788 * arrive inside the measurement window.
1789 *
1790 * Matched on URL only, never on handle: these tags are printed straight
1791 * into wp_head / wp_footer by their vendors' snippets and usually have no
1792 * WordPress handle at all. Host fragments rather than whole domains, so a
1793 * regional or versioned CDN path still matches.
1794 *
1795 * Deliberately NOT here: anything from the site's own origin, jQuery, or
1796 * any wp-* core script. Those carry inline consumers, and delaying them
1797 * is what breaks pages — see inline_bound_handles().
1798 */
1799 private const KNOWN_THIRD_PARTY_SRC = array(
1800 // Tag managers and analytics.
1801 'googletagmanager.com',
1802 'google-analytics.com',
1803 'analytics.google.com',
1804 '/gtag/js',
1805 'gtm4wp',
1806 'plausible.io',
1807 'matomo',
1808 'segment.com/analytics.js',
1809 'stats.wp.com',
1810 // Advertising and conversion pixels.
1811 'connect.facebook.net',
1812 'fbevents.js',
1813 'ads-twitter.com',
1814 'snap.licdn.com',
1815 'analytics.tiktok.com',
1816 'googleadservices.com',
1817 'doubleclick.net',
1818 // Session recording and heatmaps.
1819 'hotjar.com',
1820 'clarity.ms',
1821 'mouseflow.com',
1822 'fullstory.com',
1823 'luckyorange',
1824 // Chat and support widgets.
1825 'client.crisp.chat',
1826 'widget.intercom.io',
1827 'js.driftt.com',
1828 'tawk.to',
1829 'livechatinc.com',
1830 'zdassets.com',
1831 'helpscout.net',
1832 // Reviews, social proof and marketing.
1833 'tp.widget.bootstrap',
1834 'trustpilot.com',
1835 'static.klaviyo.com',
1836 'js.hs-scripts.com',
1837 'list-manage.com',
1838 'sumo.com',
1839 // Error and performance monitoring.
1840 'sentry-cdn.com',
1841 'browser.sentry',
1842 'bugsnag.com',
1843 'newrelic.com',
1844 );
1845
1846 /**
1847 * Match a script URL against the built-in third-party list.
1848 *
1849 * @param string $src Script source URL.
1850 */
1851 private static function matches_known_third_party( string $src ): bool {
1852 if ( '' === $src ) {
1853 return false;
1854 }
1855
1856 $known = self::KNOWN_THIRD_PARTY_SRC;
1857
1858 /**
1859 * URL fragments the delay pass treats as safe-to-postpone third-party
1860 * tags when the user's target list does not match.
1861 *
1862 * Append a vendor this list does not know yet, or remove one the site
1863 * genuinely needs early. Entries are case-insensitive substrings of
1864 * the script URL.
1865 *
1866 * @param string[] $known Built-in fragments.
1867 * @param string $src The script URL being tested.
1868 */
1869 $known = (array) apply_filters( 'xspeed_delay_known_third_party', $known, $src );
1870
1871 foreach ( $known as $needle ) {
1872 $needle = (string) $needle;
1873 if ( '' !== $needle && false !== stripos( $src, $needle ) ) {
1874 return true;
1875 }
1876 }
1877 return false;
1878 }
1879
1880 private static function opts(): array {
1881 if ( null === self::$opts ) {
1882 self::$opts = Settings_Manager::get( 'minify' );
1883 }
1884 return self::$opts;
1885 }
1886
1887 /**
1888 * Test-only — clear cached opts + bootstrap-printed flag.
1889 */
1890 public static function reset_state(): void {
1891 self::$opts = null;
1892 self::$uploads_base = null;
1893 self::$delay_bootstrap_printed = false;
1894 self::$js_measured_layout = null;
1895 self::$inline_bound_handles = null;
1896 self::$pristine_tag = array();
1897 }
1898
1899 /**
1900 * Per-request memo for inline_bound_handles(). Null = not resolved.
1901 *
1902 * @var array<string,true>|null
1903 */
1904 private static $inline_bound_handles = null;
1905
1906 /**
1907 * Handles that cannot be deferred because inline code depends on them.
1908 *
1909 * #234 fixed the case where a handle carries its OWN inline block: the
1910 * tag WordPress hands the filter is `before_inline + external +
1911 * after_inline`, so defer goes on the external <script> and order holds.
1912 * That leaves the cross-handle case, which is the one that actually
1913 * breaks sites: `wp_add_inline_script( 'foo', … )` prints a bare inline
1914 * block that runs at parse time and calls into whatever `foo` — or any
1915 * of foo's DEPENDENCIES — defined. Inline scripts can never be deferred
1916 * (the HTML spec ignores the attribute), so deferring anything they read
1917 * from inverts the order WordPress guarantees and throws on a global
1918 * that is not there yet.
1919 *
1920 * jQuery is the canonical victim: one `wp_add_inline_script( 'jquery',
1921 * 'jQuery(function($){…})' )` anywhere on the page makes `jquery-core`
1922 * undeferrable, and every hand-maintained exclusion list in the wild
1923 * exists to say so. The registry already knows it, so read it instead of
1924 * asking the user.
1925 *
1926 * Walks each handle carrying `after`/`before` inline data and marks the
1927 * handle plus its transitive dependency chain. Cycles are guarded by the
1928 * seen-map, so a self- or mutually-referential deps array terminates.
1929 *
1930 * Pure aside from the global registry read; memoised per request and
1931 * cleared by reset_state().
1932 *
1933 * @return array<string,true> Handle => true, for O(1) lookup.
1934 */
1935 public static function inline_bound_handles(): array {
1936 if ( null !== self::$inline_bound_handles ) {
1937 return self::$inline_bound_handles;
1938 }
1939
1940 $bound = array();
1941 if ( function_exists( 'wp_scripts' ) ) {
1942 $scripts = wp_scripts();
1943 if ( $scripts instanceof \WP_Scripts ) {
1944 foreach ( array_keys( (array) $scripts->registered ) as $handle ) {
1945 $handle = (string) $handle;
1946 if ( ! self::handle_carries_inline( $scripts, $handle ) ) {
1947 continue;
1948 }
1949 self::mark_with_deps( $scripts, $handle, $bound );
1950 }
1951 }
1952 }
1953
1954 /**
1955 * Handles auto-excluded from defer because inline code reads them.
1956 *
1957 * Return a handle => true map. Add an entry to protect a script whose
1958 * inline consumer this cannot see (one printed directly by a theme
1959 * rather than through wp_add_inline_script), or remove one to defer a
1960 * handle whose inline block is known not to touch it.
1961 *
1962 * @param array<string,true> $bound Detected handles.
1963 */
1964 $bound = (array) apply_filters( 'xspeed_defer_inline_bound_handles', $bound );
1965
1966 self::$inline_bound_handles = $bound;
1967
1968 return self::$inline_bound_handles;
1969 }
1970
1971 /**
1972 * Whether a handle must be kept out of a combined bundle.
1973 *
1974 * Combining re-homes a script's code under a different handle, so every
1975 * protection keyed to the ORIGINAL handle or URL stops matching: the
1976 * user's `defer_js_excluded` entry, and the inline-bound set above. The
1977 * combiner already refuses a handle carrying its own inline data, which
1978 * is why the gap is invisible until you look for it — a DEPENDENCY of an
1979 * inline consumer carries none of its own, so `jquery-core` lands in the
1980 * bundle while the exclusion list still reads as though it were honoured.
1981 *
1982 * Returning true here is enough on its own: the combiner drops any
1983 * dependent of an uncombinable handle transitively, so the whole chain
1984 * stays in the queue where WordPress prints it in the right order.
1985 *
1986 * @param string $handle Script handle.
1987 * @param string $src Registered source URL.
1988 */
1989 public static function is_protected_from_bundling( string $handle, string $src ): bool {
1990 if ( self::is_excluded_script( $handle, $src ) ) {
1991 return true;
1992 }
1993 return isset( self::inline_bound_handles()[ $handle ] );
1994 }
1995
1996 /**
1997 * Whether a handle has inline JS attached in either position.
1998 *
1999 * `get_data()` returns the raw value, which is an array of code chunks
2000 * for `after` and a string for `before`; both are falsy when absent, and
2001 * an empty chunk array must not count as inline code.
2002 *
2003 * @param \WP_Scripts $scripts Registry.
2004 * @param string $handle Handle to inspect.
2005 */
2006 private static function handle_carries_inline( \WP_Scripts $scripts, string $handle ): bool {
2007 foreach ( array( 'after', 'before' ) as $position ) {
2008 $data = $scripts->get_data( $handle, $position );
2009 if ( is_array( $data ) ) {
2010 foreach ( $data as $chunk ) {
2011 if ( '' !== trim( (string) $chunk ) ) {
2012 return true;
2013 }
2014 }
2015 continue;
2016 }
2017 if ( '' !== trim( (string) $data ) ) {
2018 return true;
2019 }
2020 }
2021 return false;
2022 }
2023
2024 /**
2025 * Mark a handle and everything it depends on, transitively.
2026 *
2027 * @param \WP_Scripts $scripts Registry.
2028 * @param string $handle Handle to mark.
2029 * @param array<string,true> $seen Accumulator, by reference.
2030 */
2031 private static function mark_with_deps( \WP_Scripts $scripts, string $handle, array &$seen ): void {
2032 if ( isset( $seen[ $handle ] ) ) {
2033 return;
2034 }
2035 $seen[ $handle ] = true;
2036 if ( ! isset( $scripts->registered[ $handle ]->deps ) ) {
2037 return;
2038 }
2039 foreach ( (array) $scripts->registered[ $handle ]->deps as $dep ) {
2040 self::mark_with_deps( $scripts, (string) $dep, $seen );
2041 }
2042 }
2043
2044 /**
2045 * Per-request memo for page_has_js_measured_layout(). Null = not resolved.
2046 *
2047 * @var bool|null
2048 */
2049 private static $js_measured_layout = null;
2050
2051 /**
2052 * Scripts that lay out the page by measuring the DOM.
2053 *
2054 * Each of these reads element sizes and then writes positions. If the CSS
2055 * that sizes those elements has not applied when the script runs, it
2056 * measures the wrong values and commits a broken layout that no later
2057 * stylesheet can correct.
2058 *
2059 * Matched as a substring of the registered handle, so a plugin shipping
2060 * `acme-masonry` or `masonry-init` is covered without naming it here.
2061 *
2062 * @return string[]
2063 */
2064 private static function js_layout_script_markers(): array {
2065 return array(
2066 'masonry',
2067 'isotope',
2068 'packery',
2069 'salvattore',
2070 'justified-gallery',
2071 'slick',
2072 'splide',
2073 'swiper',
2074 'flickity',
2075 'owl-carousel',
2076 'matchheight',
2077 );
2078 }
2079
2080 /**
2081 * True when a script that measures the DOM to build a layout is enqueued
2082 * for this request.
2083 *
2084 * Reads the enqueue registry rather than the finished HTML, because this
2085 * runs on `style_loader_tag` — while the head is being printed, before any
2086 * body markup exists to scan. Both the queue and each queued handle's
2087 * dependencies are checked: core registers `masonry` as a DEPENDENCY of a
2088 * plugin's init script, so it is frequently absent from the queue itself.
2089 *
2090 * Pure aside from the global registry read; the result is memoised per
2091 * request and cleared by reset_state().
2092 */
2093 public static function page_has_js_measured_layout(): bool {
2094 if ( null !== self::$js_measured_layout ) {
2095 return self::$js_measured_layout;
2096 }
2097
2098 $found = false;
2099 if ( function_exists( 'wp_scripts' ) ) {
2100 $scripts = wp_scripts();
2101 if ( $scripts instanceof \WP_Scripts ) {
2102 $handles = (array) $scripts->queue;
2103 // Pull in dependencies — `masonry` usually arrives that way.
2104 foreach ( (array) $scripts->queue as $queued ) {
2105 if ( isset( $scripts->registered[ $queued ]->deps ) ) {
2106 $handles = array_merge( $handles, (array) $scripts->registered[ $queued ]->deps );
2107 }
2108 }
2109 $markers = self::js_layout_script_markers();
2110 foreach ( $handles as $handle ) {
2111 $handle = strtolower( (string) $handle );
2112 foreach ( $markers as $marker ) {
2113 if ( false !== strpos( $handle, $marker ) ) {
2114 $found = true;
2115 break 2;
2116 }
2117 }
2118 }
2119 }
2120 }
2121
2122 /**
2123 * Whether this request renders a JS-measured layout, making async CSS
2124 * unsafe for the whole page.
2125 *
2126 * Return false to defer anyway (a site that ships critical CSS, or one
2127 * whose grid is pure CSS), or true to protect a library not detected
2128 * by handle.
2129 *
2130 * @param bool $found Whether a measuring script was detected.
2131 */
2132 self::$js_measured_layout = (bool) apply_filters( 'xspeed_async_css_js_measured_layout', $found );
2133
2134 return self::$js_measured_layout;
2135 }
2136 }
2137