PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 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 All 33 releases
xspeed / includes / class-minify-filters.php

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

2,749 lines 106.7 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, $handle );
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 $handle ): 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'], $handle );
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 transforms put there for this handle.
310 *
311 * Only attributes recorded in $our_late_attrs are dropped — a blanket
312 * defer/type skip threw away a defer the STAMPER set in the same pass
313 * as its marker. Themify prints main.js with defer + data-no-optimize
314 * together, and its config rides a deferred data: URI script printed
315 * just before it; stripping the theme's defer made main.js
316 * parser-blocking, so it ran ahead of its config and the theme died
317 * with "themify_vars is not defined". `src` is still never copied:
318 * it belongs to the snapshot, and restore_marked_script_src() owns
319 * undoing a minified URL.
320 *
321 * @param string $late_attrs Attribute string from the transformed tag.
322 * @param string $to_attrs Attribute string from the snapshot tag.
323 * @param string $handle Script handle the tags belong to.
324 */
325 private static function late_attribute_delta( string $late_attrs, string $to_attrs, string $handle ): string {
326 $pattern = '#\s([-\w:]+)(?:\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]*))?#';
327 if ( ! preg_match_all( $pattern, $late_attrs, $late, PREG_SET_ORDER ) ) {
328 return '';
329 }
330 $have = array();
331 if ( preg_match_all( $pattern, $to_attrs, $existing, PREG_SET_ORDER ) ) {
332 foreach ( $existing as $attr ) {
333 $have[ strtolower( $attr[1] ) ] = true;
334 }
335 }
336 $add = '';
337 foreach ( $late as $attr ) {
338 $name = strtolower( $attr[1] );
339 if ( isset( $have[ $name ] ) || 'src' === $name || isset( self::$our_late_attrs[ $handle ][ $name ] ) ) {
340 continue;
341 }
342 if ( 0 === strpos( $name, 'data-xs-' ) ) {
343 continue;
344 }
345 $add .= $attr[0];
346 }
347 return $add;
348 }
349
350 /**
351 * Attribute names OUR transforms added in this request, keyed by
352 * handle: defer_script_tag() records `defer`, delay_script_tag()
353 * records `type` when it parks an inline block. Copying one of these
354 * from the late tag back onto the snapshot would re-apply the very
355 * transform the revert is undoing — but the same names coming from a
356 * STAMPER are the author's intent and must survive, so the skip is
357 * per-handle, never by name alone. `data-xs-*` is handled by prefix
358 * separately. (#469)
359 *
360 * @var array<string,array<string,true>>
361 */
362 private static $our_late_attrs = array();
363
364 /**
365 * Locate the opening `<script>` that carries the src, falling back to
366 * the last one when none does.
367 *
368 * WP_Scripts::do_item() hands `script_loader_tag` the concatenation of
369 * before_inline + external + after_inline, so the FIRST `<script` is
370 * routinely an inline block rather than the asset — the same trap
371 * #234 fixed for defer and #273 for delay. Scanning attribute-wise
372 * also means a quoted value containing `>` (an `onerror` guard, a JSON
373 * payload) cannot truncate the tag the way `[^>]*` did. (#469)
374 *
375 * @param string $tag Full tag string.
376 * @return array{attrs:string,attrs_end:int}|null
377 */
378 private static function open_tag_offsets( string $tag ): ?array {
379 $tags = self::open_tags( $tag );
380 $fallback = null;
381 foreach ( $tags as $found ) {
382 if ( preg_match( '#(?<![-\w])src\s*=#i', $found['attrs'] ) ) {
383 return $found;
384 }
385 $fallback = $found;
386 }
387 return $fallback;
388 }
389
390 /**
391 * Every well-formed opening `<script>` in the string, in order.
392 *
393 * @param string $tag Full tag string.
394 * @return array<int,array{attrs:string,attrs_end:int}>
395 */
396 private static function open_tags( string $tag ): array {
397 if ( ! preg_match_all( '#<script\b#i', $tag, $m, PREG_OFFSET_CAPTURE ) ) {
398 return array();
399 }
400 $found = array();
401 foreach ( $m[0] as $hit ) {
402 $start = (int) $hit[1] + strlen( $hit[0] );
403 $end = self::scan_open_tag_end( $tag, $start );
404 if ( null === $end ) {
405 continue;
406 }
407 $found[] = array(
408 'attrs' => substr( $tag, $start, $end - $start ),
409 'attrs_end' => $end,
410 );
411 }
412 return $found;
413 }
414
415 /**
416 * Offset of the `>` closing an opening tag, skipping any that sit
417 * inside a quoted attribute value. Null when the tag is unterminated.
418 *
419 * @param string $tag Full tag string.
420 * @param int $offset Index just past `<script`.
421 */
422 private static function scan_open_tag_end( string $tag, int $offset ): ?int {
423 $len = strlen( $tag );
424 $quote = '';
425 for ( $i = $offset; $i < $len; $i++ ) {
426 $char = $tag[ $i ];
427 if ( '' !== $quote ) {
428 if ( $char === $quote ) {
429 $quote = '';
430 }
431 continue;
432 }
433 if ( '"' === $char || "'" === $char ) {
434 $quote = $char;
435 continue;
436 }
437 if ( '>' === $char ) {
438 // A self-closing `/>` keeps the slash out of the attributes.
439 return ( $i > $offset && '/' === $tag[ $i - 1 ] ) ? $i - 1 : $i;
440 }
441 }
442 return null;
443 }
444
445 /**
446 * Filter: `script_loader_tag`, priority 15 — undo the minify-cache
447 * rewrite for a script whose printed tag opts out.
448 *
449 * The src rewrite happens on `script_loader_src` (priority 10), long
450 * before any plugin's own `script_loader_tag` filter can stamp
451 * `data-no-minify` onto the tag — so the marker arrived too late to
452 * prevent the rewrite. This runs after the filters that stamp at the
453 * default priority 10 and swaps the hashed cache URL back to the
454 * recorded original. A marker stamped later than our transforms is
455 * caught by revert_late_marked_tag() in the late pass instead. (#469)
456 *
457 * @param string $tag
458 * @param string $handle
459 * @param string $src
460 */
461 public static function restore_marked_script_src( $tag, $handle, $src ): string {
462 if ( ! is_string( $tag ) || '' === $tag || ! self::tag_opts_out( $tag ) ) {
463 return (string) $tag;
464 }
465 $original = self::original_src( (string) $handle );
466 if ( '' === $original || '' === (string) $src || false === strpos( $tag, (string) $src ) ) {
467 return $tag;
468 }
469 return str_replace( (string) $src, $original, $tag );
470 }
471
472 /**
473 * Does a user-supplied target match this script?
474 *
475 * A target is either a script handle (exact) or a URL substring. The
476 * URL is checked against BOTH the current src and the pre-minify src,
477 * so a target written against the real asset path keeps working once
478 * minification starts rewriting URLs to hashed cache paths.
479 *
480 * @param string $needle Target from the user's list.
481 * @param string $handle Script handle.
482 * @param string $src Current (possibly rewritten) src.
483 */
484 private static function target_matches( string $needle, string $handle, string $src ): bool {
485 if ( '' === $needle ) {
486 return false;
487 }
488 if ( $handle === $needle ) {
489 return true;
490 }
491 if ( '' !== $src && false !== stripos( $src, $needle ) ) {
492 return true;
493 }
494 $original = self::original_src( $handle );
495 return '' !== $original && false !== stripos( $original, $needle );
496 }
497
498 /**
499 * Filter: `script_loader_tag` — add defer="defer" to non-excluded
500 * scripts. WordPress passes the full <script> tag string, the
501 * handle, and the src. We bail when:
502 * - the user excluded this handle / src substring,
503 * - the tag already has defer or async (don't double-set),
504 * - the tag has no src (inline scripts can't be deferred — would
505 * execute synchronously regardless).
506 *
507 * @param string $tag
508 * @param string $handle
509 * @param string $src
510 */
511 public static function defer_script_tag( $tag, $handle, $src ): string {
512 if ( ! is_string( $tag ) || '' === $tag ) {
513 return (string) $tag;
514 }
515 // Self-guard: even though Minifier::__construct() bails on admin/
516 // AJAX/REST/cron at registration, a late context switch (e.g. a
517 // custom wp_print_scripts() call inside an admin page render) can
518 // leave the filter attached. Skipping here keeps the React admin
519 // bundle's <script> tag intact so the dashboard mounts.
520 if ( self::skip_in_non_frontend_context() ) {
521 return $tag;
522 }
523 // The author asked every optimizer to leave this tag alone.
524 if ( self::carries_optimizer_opt_out( $tag ) ) {
525 return $tag;
526 }
527 if ( '' === (string) $src ) {
528 return $tag;
529 }
530 if ( self::is_excluded_script( (string) $handle, (string) $src ) ) {
531 return $tag;
532 }
533 // The tag itself asked to be left alone. (#456)
534 if ( self::tag_opts_out( $tag ) ) {
535 return $tag;
536 }
537 // Inline code elsewhere on the page reads this handle (or something
538 // it depends on). Inline blocks never defer, so deferring this one
539 // would run the consumer first. Defer only — delay is an opt-in
540 // target list, where the user has named the script deliberately.
541 if ( isset( self::inline_bound_handles()[ (string) $handle ] ) ) {
542 return $tag;
543 }
544 // NB: is_protected_from_bundling() is the same two rules in one call
545 // for the combiner; the split here is deliberate, since the
546 // exclusion check above already ran and short-circuits earlier.
547 if ( false !== stripos( $tag, ' defer' ) || false !== stripos( $tag, ' async' ) ) {
548 return $tag;
549 }
550 // Target the <script> that actually carries a src, NOT simply the
551 // first one in the string. WP_Scripts::do_item() hands this filter
552 // the CONCATENATION of before_inline + external + after_inline, so
553 // for any handle carrying a `before` inline script the first
554 // `<script` is the inline block. Deferring that is a no-op (the HTML
555 // spec ignores defer on inline scripts) AND leaves the external
556 // script undeferred while its dependencies get deferred — which
557 // inverts WordPress's guaranteed execution order and throws in any
558 // dependent that touches a global its dependency defines. (#234)
559 //
560 // The lookahead scans only within the tag (`[^>]*`) for ` src=`, so
561 // an inline `<script id="…-js-before">` can never match.
562 $deferred = (string) preg_replace( '#<script\b(?=[^>]*\ssrc\s*=)#i', '<script defer="defer"', $tag, 1 );
563 if ( $deferred !== $tag ) {
564 // Remember that THIS defer is ours, so the late opt-out revert
565 // drops it — and only it, never a stamper's own defer.
566 self::$our_late_attrs[ (string) $handle ]['defer'] = true;
567 }
568 return $deferred;
569 }
570
571 /**
572 * Filter: `script_loader_tag` — rewrite src= to data-xs-src= so the
573 * browser ignores it until the bootstrap (printed once on
574 * wp_footer) swaps it back on first user interaction. Same
575 * exclusion rules as defer. Inline scripts (no src) are also
576 * deferred until the first interaction.
577 *
578 * @param string $tag
579 * @param string $handle
580 * @param string $src
581 */
582 public static function delay_script_tag( $tag, $handle, $src ): string {
583 if ( ! is_string( $tag ) || '' === $tag ) {
584 return (string) $tag;
585 }
586 if ( self::skip_in_non_frontend_context() ) {
587 return $tag;
588 }
589 // The author asked every optimizer to leave this tag alone.
590 if ( self::carries_optimizer_opt_out( $tag ) ) {
591 return $tag;
592 }
593 if ( self::is_excluded_script( (string) $handle, (string) $src, true ) ) {
594 return $tag;
595 }
596 // The tag itself asked to be left alone. (#456)
597 if ( self::tag_opts_out( $tag ) ) {
598 return $tag;
599 }
600 if ( ! self::is_delay_target( (string) $handle, (string) $src ) ) {
601 return $tag;
602 }
603 // Inline code elsewhere on the page reads this handle (or something
604 // it depends on) — same registry walk defer uses. Delaying it runs
605 // the consumer at parse time against a global that arrives on first
606 // interaction: `wp_add_inline_script( 'jquery-ui-core',
607 // 'jQuery.uiBackCompat…', 'before' )` throws "jQuery is not defined"
608 // the moment jquery-core is delayed. A handle the user NAMED in
609 // delay_js_targets is still delayed — an explicit entry is the user
610 // saying they know the inline consumer is safe to break or absent.
611 if ( isset( self::inline_bound_handles()[ (string) $handle ] )
612 && ! self::is_user_named_target( (string) $handle, (string) $src )
613 // Smart Delay inverts this protection: the handle is delayed and
614 // its own before/after snippets are parked WITH it (see
615 // park_smart_inline()), so the consumer no longer runs against a
616 // missing global — it replays after its provider, in page order.
617 // On a builder page nearly every script is inline-bound, which is
618 // why delay-all without this delayed almost nothing.
619 && ! self::smart_delay_enabled() ) {
620 return $tag;
621 }
622 // A non-executable type means this tag is data, or is being held by
623 // somebody else on purpose. The buffer pass has always checked this;
624 // the enqueue path did not, so a consent-blocked or JSON-carrying
625 // handle could still be rewritten here. (#274)
626 if ( in_array( self::extract_type( $tag ), self::NON_EXECUTABLE_TYPES, true ) ) {
627 return $tag;
628 }
629 // src= variant: swap src → data-xs-src and add data-xs-delay marker.
630 if ( '' !== (string) $src ) {
631 // Anchor on the opening <script …> tag that carries the src.
632 // Matching a bare `src=` across the whole string would rewrite
633 // the first occurrence anywhere — including inside a `before`
634 // inline block, where JS like `el.src = "…"` becomes the
635 // syntax error `el.data-xs-src="…" data-xs-delay="1"` and the
636 // real external script is left undelayed. $tag is the
637 // concatenation of before_inline + external + after_inline,
638 // so that is a routine shape, not a corner case. (#234)
639 // `(?<![-\w])` where `\b` used to be. A hyphen is a non-word
640 // character, so `\bsrc=` also matches the TAIL of any
641 // `data-…-src=` attribute — and consent managers and other
642 // optimizers park a blocked script's real URL in exactly that
643 // shape. Complianz's `data-cmplz-src` became
644 // `data-cmplz-data-xs-src`, so after the visitor clicked Accept
645 // the plugin looked for an attribute that no longer existed and
646 // the script never loaded: analytics and pixels silently dead,
647 // no console error, nothing in the UI. Same class of bug as the
648 // image-dimension resolver in #328. (#273)
649 return (string) preg_replace(
650 '#(<script\b[^>]*?)(?<![-\w])src\s*=\s*(["\'][^"\']*["\'])#i',
651 '$1data-xs-src=$2 data-xs-delay="1"',
652 $tag,
653 1
654 );
655 }
656 // Inline script: change type to text/xspeed-delayed so the browser
657 // doesn't execute, mark for bootstrap rewriter. Any existing type
658 // is REPLACED, not appended-after: HTML keeps an attribute's first
659 // occurrence, so a snippet carrying its own `type="text/javascript"`
660 // would win over a marker appended behind it and keep executing.
661 // A non-default original type is stashed in data-xs-type so the
662 // bootstrap can restore it on replay (#274 — type is what a script
663 // IS; a parked `type="module"` must come back as a module).
664 $parked = (string) preg_replace_callback(
665 '#<script\b([^>]*)>#i',
666 static function ( array $m ): string {
667 return '<script' . self::park_type_attrs( $m[1] ) . '>';
668 },
669 $tag,
670 1
671 );
672 if ( $parked !== $tag ) {
673 // The parked type is ours to drop on a late opt-out revert; an
674 // author-set type sits on the snapshot and survives regardless.
675 self::$our_late_attrs[ (string) $handle ]['type'] = true;
676 }
677 return $parked;
678 }
679
680 /**
681 * Script types the buffer pass must never touch. `<script>` carries
682 * data as often as it carries code: JSON-LD feeds structured-data
683 * consumers, importmaps must resolve before any module runs, and our
684 * own delayed-inline marker is already handled by the bootstrap.
685 * Rewriting any of these breaks the page or its metadata.
686 */
687 /**
688 * Attributes by which a script's own author tells optimizers to stand down.
689 *
690 * The report behind #275 also asked us to leave a script alone when its
691 * author "already marked it to load late". Read literally that means
692 * `defer`/`async`, and that reading is wrong twice over: `defer` is
693 * stamped onto every enqueued script by our OWN Defer JS filter at
694 * priority 20, before Delay JS sees it at 30 — so honouring it would
695 * switch Delay JS off entirely on sites running both — and `async` is the
696 * shape of gtag, GTM and every pixel loader, which is precisely the
697 * payload Delay JS exists to postpone. `defer`/`async` say WHEN TO FETCH,
698 * not "leave me alone".
699 *
700 * These attributes do say it. Each is an established opt-out honoured by
701 * another optimizer — WP Rocket, LiteSpeed, Autoptimize, NitroPack,
702 * Jetpack Boost — so an author who prints one has already declared that
703 * no optimizer should touch this tag. Consent banners are the main
704 * beneficiary, but the rule is general and needs neither a handle nor a
705 * recognised URL, so it works identically on both passes.
706 *
707 * Two deliberate omissions:
708 *
709 * - `data-cfasync="false"` is a Cloudflare Rocket Loader opt-out, and
710 * ad stacks (Mediavine, Ezoic, AdThrive) print it on exactly the
711 * heavy loaders a site turns Delay JS on for. Honouring it would
712 * un-delay the ads.
713 * - `data-no-minify` is about minification, not execution timing.
714 */
715 private const OPT_OUT_ATTRIBUTES = array(
716 'nowprocket', // WP Rocket
717 'data-nowprocket', // WP Rocket
718 'data-no-optimize', // LiteSpeed
719 'data-noptimize', // Autoptimize
720 'data-no-defer', // used by several optimizers
721 'nitro-exclude', // NitroPack
722 'data-jetpack-boost', // Jetpack Boost (value "ignore")
723 'data-wpmeteor-nooptimize', // WP Meteor
724 'data-xs-nodelay', // ours
725 );
726
727 /**
728 * Does this tag carry an explicit "optimizers keep out" attribute?
729 *
730 * Same `(?<![-\w])` lookbehind the rest of this file uses, so
731 * `data-nowprocket` does not also satisfy a bare `nowprocket` lookup, and
732 * a trailing `\b` so `data-no-defer` does not match `data-no-deferral`.
733 * Bare and valued forms both count: an author writes `nowprocket`,
734 * `nowprocket=""` and `data-noptimize="1"` interchangeably.
735 *
736 * @param string $tag Full opening tag.
737 */
738 private static function carries_optimizer_opt_out( string $tag ): bool {
739 // Read ATTRIBUTE NAMES, not the tag as a string. A substring scan
740 // matched `src="https://cdn/nowprocket/loader.js"`, `?nowprocket=1`,
741 // `class="nowprocket"` and any inline body that merely mentioned one
742 // of these names -- each silently un-delaying a script that should
743 // have been delayed.
744 //
745 // EVERY opening tag is checked, not just the first. On the enqueue
746 // path WP_Scripts::do_item() hands us translations + before-inline +
747 // the real tag + after-inline concatenated, so the first `<script`
748 // is often an inline block and the author's opt-out sits on the
749 // external tag behind it. Reading only the first tag missed it and
750 // delayed the script anyway -- the wrong direction: a consent banner
751 // its author told optimizers to leave alone would not appear.
752 //
753 // The tag regex is quote-aware because `[^>]*>` stops at a `>` inside
754 // a quoted value, and consent managers routinely ship JSON in a
755 // data-* attribute.
756 if ( ! preg_match_all( '#<script\b(?:[^>"\']|"[^"]*"|\'[^\']*\')*>#is', $tag, $tags ) ) {
757 return false;
758 }
759
760 foreach ( $tags[0] as $open ) {
761 // Walk name/value pairs. The name pattern is deliberately
762 // permissive: a name we cannot recognise (Alpine's `@load`, say)
763 // must still consume ITS OWN VALUE, or the value gets scanned as
764 // if it were more attribute names.
765 if ( ! preg_match_all(
766 '#\s+([^\s=/>]+)(?:\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]*))?#s',
767 substr( $open, 7, -1 ),
768 $found
769 ) ) {
770 continue;
771 }
772
773 foreach ( $found[1] as $name ) {
774 if ( in_array( strtolower( $name ), self::OPT_OUT_ATTRIBUTES, true ) ) {
775 return true;
776 }
777 }
778 }
779
780 return false;
781 }
782
783 private const NON_EXECUTABLE_TYPES = array(
784 'application/ld+json',
785 'application/json',
786 'importmap',
787 'speculationrules',
788 'text/template',
789 'text/x-template',
790 'text/xspeed-delayed',
791 // A consent manager parks a blocked third-party script here and
792 // swaps the type back only once the visitor has agreed. Whatever we
793 // do to such a tag we do on behalf of a decision the visitor has not
794 // made yet, so the only correct move is to leave it alone. (#274)
795 'text/plain',
796 );
797
798 /**
799 * The `type` attribute, quoted OR unquoted, anchored to attribute
800 * position — a required leading whitespace, never a bare `\b`.
801 *
802 * The anchoring matters twice over. `\btype` also matches the tail of
803 * any hyphenated `data-…-type` attribute (a `-` is a non-word char, so
804 * the boundary sits inside the name — the same #273 class as `src`),
805 * and it matches a `type=` sitting INSIDE another attribute's value
806 * (`onload="this.type='done'"`). Requiring whitespace before the name
807 * rules both out: attributes are whitespace-separated, while `.type`
808 * and `-type` never are. The unquoted branch exists because
809 * `type=text/javascript` is valid HTML: a quoted-only pattern left it
810 * standing, the parking type appended after it lost the
811 * first-occurrence race, and the snippet executed immediately AND
812 * replayed on interaction — every vendor event fired twice.
813 */
814 private const TYPE_ATTR_RE = '#\stype\s*=\s*(?:(["\'])(.*?)\1|([^\s>]+))#is';
815
816 /**
817 * `type` values a parked tag need not remember: the replay default is
818 * already JavaScript, so stashing these would only fatten the markup.
819 */
820 private const DEFAULT_JS_TYPES = array(
821 'text/javascript',
822 'application/javascript',
823 );
824
825 /**
826 * Read a tag's `type` attribute value, lowercased and trimmed.
827 *
828 * @param string $haystack Full tag or its attribute string.
829 * @return string '' when no type attribute is present.
830 */
831 private static function extract_type( string $haystack ): string {
832 if ( ! preg_match( self::TYPE_ATTR_RE, $haystack, $m ) ) {
833 return '';
834 }
835 $value = ( isset( $m[3] ) && '' !== $m[3] ) ? $m[3] : $m[2];
836 return strtolower( trim( $value ) );
837 }
838
839 /**
840 * Rewrite an inline tag's attribute string for parking: strip its own
841 * `type`, stash a non-default one in `data-xs-type` (the bootstrap
842 * restores it on replay, so a parked `type="module"` comes back as a
843 * module rather than a classic script — #274), and append the parking
844 * marker pair.
845 *
846 * @param string $attrs Raw attribute string (everything between
847 * `<script` and `>`).
848 */
849 private static function park_type_attrs( string $attrs ): string {
850 $orig = self::extract_type( $attrs );
851 $attrs = (string) preg_replace( self::TYPE_ATTR_RE, '', $attrs );
852 $stash = '';
853 if ( '' !== $orig && ! in_array( $orig, self::DEFAULT_JS_TYPES, true ) ) {
854 // MIME-ish charset only — a type value is never markup, and this
855 // string is re-emitted inside a double-quoted attribute.
856 $orig = (string) preg_replace( '#[^a-z0-9/+.\-]#', '', $orig );
857 if ( '' !== $orig ) {
858 $stash = ' data-xs-type="' . $orig . '"';
859 }
860 }
861 return $attrs . $stash . ' type="text/xspeed-delayed" data-xs-delay="1"';
862 }
863
864 /**
865 * URL fragments that must keep a live src no matter what. The enqueue
866 * path guards these by handle (ALWAYS_EXCLUDED_HANDLES), but a buffer
867 * pass only ever sees a URL, so the same protection is re-expressed
868 * here. Without this the admin bundle could be delayed on a frontend
869 * render and the dashboard would not mount.
870 */
871 private const ALWAYS_EXCLUDED_SRC = array(
872 '/plugins/xspeed/assets/',
873 '/wp-includes/js/dist/hooks',
874 '/wp-includes/js/dist/i18n',
875 );
876
877 /**
878 * Delay `<script src>` tags that never passed through wp_enqueue_script.
879 *
880 * `delay_script_tag()` hooks `script_loader_tag`, so it only ever sees
881 * enqueued scripts. Analytics, pixels, chat widgets and most third-party
882 * embeds are printed straight into `wp_head` / `wp_footer` as literal
883 * markup, bypassing that filter entirely — and those are exactly the
884 * scripts most worth delaying. On the site that surfaced this, 39
885 * enqueued scripts were correctly delayed while one un-enqueued
886 * analytics tag still downloaded 441 KB: 98% of the page's JS payload.
887 *
888 * Runs on the finished page buffer via `xspeed_cache_final_html`, so the
889 * rewrite is baked into the cached HTML and replays on every static hit
890 * (where PHP never boots). Deliberately conservative — it rewrites only
891 * `src`, leaves inline code to the enqueue path, and skips any tag whose
892 * `type` marks it as data rather than code.
893 *
894 * @param string $html Complete page HTML.
895 */
896 public static function delay_raw_script_tags( $html ): string {
897 if ( ! is_string( $html ) || '' === $html ) {
898 return (string) $html;
899 }
900 if ( self::skip_in_non_frontend_context() ) {
901 return $html;
902 }
903 $opts = self::opts();
904 if ( empty( $opts['delay_js'] ) ) {
905 return $html;
906 }
907
908 return (string) preg_replace_callback(
909 '#<script\b[^>]*>#i',
910 static function ( array $m ): string {
911 $tag = $m[0];
912
913 // Already handled by the enqueue-path filter.
914 if ( false !== stripos( $tag, 'data-xs-delay' ) || false !== stripos( $tag, 'data-xs-src' ) ) {
915 return $tag;
916 }
917
918 // The author asked every optimizer to leave this tag alone.
919 // Checked before src/type: it needs neither, so an
920 // un-enqueued banner printed straight into wp_head is
921 // covered the same as an enqueued one.
922 //
923 // Both checks, because they disagree on purpose and either
924 // saying "leave it" is the safe answer. tag_opts_out() is
925 // dev's (#456) and also drives the late re-check at #469;
926 // carries_optimizer_opt_out() reads attribute NAMES across
927 // every opening tag, so it is not fooled by a marker sitting
928 // inside a quoted value or an inline body, and it knows the
929 // other optimizers' markers.
930 if ( self::tag_opts_out( $tag ) || self::carries_optimizer_opt_out( $tag ) ) {
931 return $tag;
932 }
933
934 // No src → inline code. The enqueue path owns those; a
935 // buffer rewrite here would have to reason about execution
936 // order it cannot see.
937 // `(?<![-\w])` not `\b` — see the note on the enqueue-path
938 // rewrite above. With `\b`, a tag whose ONLY url lives in
939 // `data-cmplz-src` (a consent-blocked script, no real src at
940 // all) read as an external script here, and the rewrite
941 // below then mangled that attribute. (#273)
942 if ( ! preg_match( '#(?<![-\w])src\s*=\s*(["\'])(.*?)\1#is', $tag, $src_m ) ) {
943 return $tag;
944 }
945 $src = $src_m[2];
946
947 // Data, not code.
948 if ( in_array( self::extract_type( $tag ), self::NON_EXECUTABLE_TYPES, true ) ) {
949 return $tag;
950 }
951
952 foreach ( self::ALWAYS_EXCLUDED_SRC as $needle ) {
953 if ( false !== stripos( $src, $needle ) ) {
954 return $tag;
955 }
956 }
957
958 // An ENQUEUED script reaches this sweep too: the enqueue-path
959 // filter leaves an EXCLUDED tag unmarked, and unmarked is all
960 // this pass can see. Judging it on its URL alone re-delays the
961 // very script the exclusion protected — and a handle is not
962 // generally in its own URL, which is the shape Complianz
963 // (`cmplz-cookiebanner`), NotificationX (`notificationx-public`)
964 // and jQuery (`jquery-core`) all have. A site with jquery-core
965 // excluded still shipped jQuery delayed, and every inline
966 // `jQuery(...)` on the page threw "jQuery is not defined".
967 // WordPress prints `id="<handle>-js"` on every enqueued
968 // script, so the handle is right there in the tag. (#275)
969 //
970 // The lookbehind matters: `data-id="cmplz-cookiebanner-js"` is
971 // somebody's own attribute, not the handle, and reading it as
972 // one would shield a script nobody excluded.
973 //
974 // Merge note for #374: if a URL->handle map built from
975 // wp_scripts() lands first, resolve through that and keep
976 // this as the FALLBACK rather than replacing it. The map is
977 // keyed on the REGISTERED src, and Minifier::rewrite_script()
978 // rewrites a local script's URL to a hashed /cache/xspeed/min/
979 // path at output time -- which is why remember_original_src()
980 // exists. So with minify_js on, the map misses every minified
981 // script and an excluded one would be re-delayed here. The
982 // `id` survives that rewrite.
983 $tag_handle = '';
984 if ( preg_match( '#(?<![-\w])id\s*=\s*(["\'])(.*?)\1#is', $tag, $id_m ) ) {
985 // WP appends `-js`; anything else is somebody's own id and
986 // is still worth matching literally.
987 $tag_handle = (string) preg_replace( '/-js$/', '', trim( $id_m[2] ) );
988 }
989
990 if ( self::is_excluded_script( $tag_handle, $src, true ) ) {
991 return $tag;
992 }
993 // The handle is passed to the target test as well, so naming a
994 // handle in the delay list behaves the same here as it does on
995 // the enqueue path. The two layers disagreeing on what a target
996 // means is what made this look like a matching quirk rather
997 // than a whole layer ignoring the list.
998 if ( ! self::is_delay_target( $tag_handle, $src ) ) {
999 return $tag;
1000 }
1001 // Mirror of the enqueue-path guard: a handle that inline code
1002 // reads stays eager unless the user named it. wp_scripts()
1003 // is still populated at xspeed_cache_final_html time on a
1004 // MISS, so the registry walk is consultable here too; an
1005 // unrecoverable handle ('') simply never matches the set.
1006 if ( '' !== $tag_handle
1007 && isset( self::inline_bound_handles()[ $tag_handle ] )
1008 && ! self::is_user_named_target( $tag_handle, $src ) ) {
1009 return $tag;
1010 }
1011
1012 return (string) preg_replace(
1013 '#(?<![-\w])src\s*=\s*(["\'][^"\']*["\'])#i',
1014 'data-xs-src=$1 data-xs-delay="1"',
1015 $tag,
1016 1
1017 );
1018 },
1019 $html
1020 );
1021 }
1022
1023 /**
1024 * Delay inline vendor snippets that reference a known third-party host.
1025 *
1026 * The pass above rewrites `src` and deliberately leaves inline code
1027 * alone — but the OFFICIAL install for Clarity, GA, GTM and the Meta
1028 * pixel is an inline loader (`(function(c,l,a,r,i,t,y){…t.src=…})`)
1029 * with no `src` attribute at all. That snippet executes on every page
1030 * load, fetches the vendor bundle inside the measurement window, and
1031 * puts the one host whose Cache-Control the site cannot set straight
1032 * into the cache-policy and TBT audits. Delaying the enqueue path and
1033 * the raw-src path while this runs untouched is delaying everything
1034 * except the tag the feature exists for.
1035 *
1036 * The judgment call is the same one KNOWN_THIRD_PARTY_SRC already
1037 * makes: an inline body that names one of those hosts is that vendor's
1038 * loader or its config — never something first-party code holds a
1039 * synchronous reference to. The body is the haystack for the user's
1040 * exclusion and target lists too, so the same fragment that protects a
1041 * `src` tag protects its inline install.
1042 *
1043 * `document.write` bodies are skipped outright: replayed after the
1044 * parser has closed the document, a delayed write would replace the
1045 * page rather than add to it.
1046 *
1047 * @param string $html Complete page HTML.
1048 */
1049 public static function delay_inline_snippets( $html ): string {
1050 if ( ! is_string( $html ) || '' === $html ) {
1051 return (string) $html;
1052 }
1053 if ( self::skip_in_non_frontend_context() ) {
1054 return $html;
1055 }
1056 $opts = self::opts();
1057 if ( empty( $opts['delay_js'] ) ) {
1058 return $html;
1059 }
1060
1061 $handle_delayed = self::handle_delay_outcomes( $html );
1062
1063 $out = preg_replace_callback(
1064 '#<script\b([^>]*)>(.*?)</script>#is',
1065 static function ( array $m ) use ( $handle_delayed ): string {
1066 list( $whole, $attrs, $body ) = $m;
1067
1068 if ( '' === trim( $body ) ) {
1069 return $whole;
1070 }
1071
1072 // The tag itself asked to be left alone. (#456)
1073 if ( self::tag_opts_out( $attrs ) ) {
1074 return $whole;
1075 }
1076
1077 // Our own replay bootstrap. Its body quotes the delay
1078 // machinery's own strings, so a pathological user target
1079 // fragment could match it — and a parked bootstrap means
1080 // nothing on the page ever replays.
1081 if ( false !== stripos( $attrs, 'xspeed-delay-bootstrap' ) ) {
1082 return $whole;
1083 }
1084
1085 // Already marked, or a real src= — the src passes own those.
1086 // `(?<![-\w])` for the same reason as above: `data-cmplz-src`
1087 // must not read as a src. (#273)
1088 if ( false !== stripos( $attrs, 'data-xs-delay' ) || false !== stripos( $attrs, 'data-xs-src' ) ) {
1089 return $whole;
1090 }
1091 if ( preg_match( '#(?<![-\w])src\s*=\s*(["\']).*?\1#is', $attrs ) ) {
1092 return $whole;
1093 }
1094
1095 // Data, a module map, or a consent manager's parked tag.
1096 if ( in_array( self::extract_type( $attrs ), self::NON_EXECUTABLE_TYPES, true ) ) {
1097 return $whole;
1098 }
1099
1100 // A delayed document.write replays after the document has
1101 // closed and replaces the page. Never delay one.
1102 if ( false !== stripos( $body, 'document.write' ) ) {
1103 return $whole;
1104 }
1105
1106 // Our own inline scripts, by the id they are printed with.
1107 // The src passes get this for free from the handle prefix,
1108 // but here the handle is '' — and the facade observer's body
1109 // names youtube/vimeo, so a user target like "youtube" would
1110 // park the very script that makes those embeds cheap.
1111 if ( preg_match( '#(?<![-\w])id\s*=\s*(["\'])xspeed-#i', $attrs ) ) {
1112 return $whole;
1113 }
1114
1115 // A handle's own inline blocks follow the handle, not only their
1116 // body text. The body rule below decided them alone, so a
1117 // `-js-extra` whose data named a target was parked while its
1118 // script, kept eager by a URL exclusion, ran first and read an
1119 // undefined global. (#549)
1120 //
1121 // Ahead of the exclusion list on purpose. The handle's own tag
1122 // was already weighed against it by handle and URL; a body
1123 // match here would keep the after-code of a delayed script
1124 // eager, running it before the script it calls.
1125 if ( preg_match( '#(?<![-\w])id\s*=\s*(["\'])(.+?)-js-(extra|before|after)\1#i', $attrs, $own ) ) {
1126 $part = strtolower( $own[3] );
1127 // wp_localize_script data. Early is always safe: it only
1128 // assigns, and its script cannot run before it.
1129 if ( 'extra' === $part ) {
1130 return $whole;
1131 }
1132 if ( isset( $handle_delayed[ $own[2] ] ) ) {
1133 if ( $handle_delayed[ $own[2] ] ) {
1134 return '<script' . self::park_type_attrs( $attrs ) . '>' . $body . '</script>';
1135 }
1136 // The script runs at load, so its `before` code must too.
1137 // Its `after` code still runs after it if parked, so that
1138 // one is left to the body rule, like any vendor loader.
1139 if ( 'before' === $part ) {
1140 return $whole;
1141 }
1142 }
1143 }
1144
1145 // The body stands in for the URL in the lists the src passes
1146 // consult — but NOT via is_delay_target(), whose empty-list
1147 // default is "delay everything". That default is right for a
1148 // tag with a URL and catastrophic here: it would park every
1149 // inline script on the page. Inline code is delayed only on a
1150 // positive identification — the body names a known vendor
1151 // host, or a fragment the user targeted — and the exclusion
1152 // list still wins first. A target naming a consent manager
1153 // lifts the floor here the same way it does for a src tag;
1154 // without it the same entry delayed the file and left the
1155 // vendor's inline code eager.
1156 if ( self::is_excluded_script( '', $body, true ) ) {
1157 return $whole;
1158 }
1159
1160 if ( ! self::matches_known_third_party( $body ) && ! self::matches_user_targets( $body ) ) {
1161 return $whole;
1162 }
1163
1164 // Replace — not append — any existing type. Attributes keep
1165 // their FIRST occurrence in HTML, so appending the parking
1166 // type after the snippet's own `type="text/javascript"`
1167 // would leave the original executable. A non-default type is
1168 // stashed in data-xs-type for the bootstrap to restore.
1169 return '<script' . self::park_type_attrs( $attrs ) . '>' . $body . '</script>';
1170 },
1171 $html
1172 );
1173 // A PCRE failure (backtrack limit on a huge inline body) returns
1174 // null — and casting that to '' would serve AND cache a blank page.
1175 // The unrewritten original is always the safe fallback.
1176 return null === $out ? $html : $out;
1177 }
1178
1179 /**
1180 * Whether each enqueued handle's external tag ended up delayed.
1181 *
1182 * Read from the finished HTML rather than recorded as tags are filtered:
1183 * this runs after every pass that can delay a tag (script_loader_tag, the
1184 * late opt-out revert, the raw-tag sweep), so the page itself is the only
1185 * complete answer.
1186 *
1187 * @param string $html Complete page HTML.
1188 * @return array<string,bool> Handle => delayed.
1189 */
1190 private static function handle_delay_outcomes( string $html ): array {
1191 if ( ! preg_match_all( '#<script\b((?:"[^"]*"|\'[^\']*\'|[^>"\'])*)>#i', $html, $m ) ) {
1192 return array();
1193 }
1194 $out = array();
1195 foreach ( $m[1] as $attrs ) {
1196 if ( ! preg_match( '#(?<![-\w])id\s*=\s*(["\'])(.+?)-js\1#i', $attrs, $id ) ) {
1197 continue;
1198 }
1199 $out[ $id[2] ] = false !== stripos( $attrs, 'data-xs-delay' ) || false !== stripos( $attrs, 'data-xs-src' );
1200 }
1201 return $out;
1202 }
1203
1204 /**
1205 * Inline bootstrap that flips delayed scripts on the first user
1206 * interaction. Printed once on wp_footer priority 1000.
1207 */
1208 public static function print_delay_bootstrap(): void {
1209 if ( self::skip_in_non_frontend_context() ) {
1210 return;
1211 }
1212 if ( self::$delay_bootstrap_printed ) {
1213 return;
1214 }
1215 self::$delay_bootstrap_printed = true;
1216
1217 // Failsafe timer for visitors who never interact. 0 disables it
1218 // entirely (interaction-only), which is what lab tools measure
1219 // best: a timer that fires inside Lighthouse's / GTmetrix's
1220 // measurement window loads the "delayed" scripts anyway and
1221 // inflates the reported TTI, so the delay looks ineffective.
1222 $opts = self::opts();
1223 $timeout = isset( $opts['delay_js_timeout'] ) ? (int) $opts['delay_js_timeout'] : 8000;
1224 $timeout = max( 0, min( 60000, $timeout ) );
1225
1226 // The script is includes/js/delay-bootstrap.js, which also carries
1227 // the design notes for the lifecycle replay (#494). `npm run build`
1228 // minifies it into assets/delay-bootstrap.min.js; the timeout goes in
1229 // place of its one placeholder.
1230 //
1231 // The tag goes out through wp_print_inline_script_tag(), like Free's
1232 // other inline scripts, so a CSP plugin's wp_inline_script_attributes
1233 // filter can give it a nonce. Printed bare, a nonce CSP blocked it
1234 // and nothing was ever replayed.
1235 $js = str_replace( 'XSPEED_DELAY_TIMEOUT', (string) $timeout, self::delay_bootstrap_js() );
1236 wp_print_inline_script_tag( $js, array( 'id' => 'xspeed-delay-bootstrap' ) );
1237 }
1238
1239 /** The delay bootstrap's code, read once per request. */
1240 private static $delay_bootstrap_js = null;
1241
1242 /**
1243 * The built delay bootstrap, without the line that records its source.
1244 * If the build is missing, the readable source is valid JS too, only
1245 * larger: printing nothing would leave every delayed script parked for
1246 * good, because the tags are already rewritten by the time this runs.
1247 */
1248 private static function delay_bootstrap_js(): string {
1249 if ( null !== self::$delay_bootstrap_js ) {
1250 return self::$delay_bootstrap_js;
1251 }
1252 $root = dirname( __DIR__ );
1253 $built = $root . '/assets/delay-bootstrap.min.js';
1254 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a local plugin file, not a remote URL.
1255 $js = is_readable( $built ) ? (string) file_get_contents( $built ) : '';
1256 if ( '' !== $js ) {
1257 $js = (string) preg_replace( '#\A/\*[^\n]*\*/\n#', '', $js );
1258 } else {
1259 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a local plugin file, not a remote URL.
1260 $js = (string) file_get_contents( $root . '/includes/js/delay-bootstrap.js' );
1261 }
1262 self::$delay_bootstrap_js = trim( $js );
1263 return self::$delay_bootstrap_js;
1264 }
1265
1266 /**
1267 * Filter: `style_loader_tag` — wrap stylesheets in the
1268 * print → onload="all" pattern so they download non-blocking.
1269 * Pairs with critical CSS workflows. Adds a <noscript> fallback so
1270 * users with JS disabled still get styles applied (via media="all").
1271 *
1272 * @param string $tag
1273 * @param string $handle
1274 */
1275 public static function async_style_tag( $tag, $handle ): string {
1276 if ( ! is_string( $tag ) || '' === $tag ) {
1277 return (string) $tag;
1278 }
1279 if ( self::skip_in_non_frontend_context() ) {
1280 return $tag;
1281 }
1282 // Only operate on <link rel=stylesheet> with a media attribute
1283 // we can swap. Skip anything custom (preload, etc.) — we don't
1284 // want to fight with explicit author intent.
1285 if ( false === stripos( $tag, 'rel=\'stylesheet\'' ) && false === stripos( $tag, 'rel="stylesheet"' ) ) {
1286 return $tag;
1287 }
1288 // No critical CSS for this page: every stylesheet stays blocking.
1289 //
1290 // Deferring a stylesheet only helps when something already styles the
1291 // first screen. Without that, the page paints unstyled and then jumps
1292 // when the sheets arrive. Measured on the Templately Astoria pages
1293 // (Elementor and a block theme): CLS 0.43-1.27 and 15-43 points lower
1294 // on 7 of 8 pages than the same settings without async CSS. The guards
1295 // below narrow the damage; this one removes it. WP Rocket, LiteSpeed,
1296 // Jetpack Boost and FlyingPress likewise never defer CSS without
1297 // critical CSS. (#588)
1298 if ( ! self::page_has_critical_css() ) {
1299 return $tag;
1300 }
1301 // The stylesheets that lay the page out stay render-blocking.
1302 //
1303 // This transform moves a sheet to AFTER first paint. That is the
1304 // point of it — but a sheet the layout depends on is then missing
1305 // from the only paint the visitor sees, and the page renders as
1306 // unstyled HTML (bulleted nav, underlined links) until the swap
1307 // runs. The pattern is only safe when something already styles the
1308 // above-the-fold area, i.e. critical CSS — which Free does not
1309 // generate. Deferring EVERY sheet on a site without it guarantees
1310 // the flash rather than risking it: on the reported Kadence site
1311 // all 17 stylesheets were deferred and none was render-blocking,
1312 // so there was nothing left to paint the page with. (#269)
1313 if ( self::is_layout_critical_style( $handle ) ) {
1314 return $tag;
1315 }
1316 // A JS-measured layout on this page makes deferral unsafe for EVERY
1317 // sheet, not just the theme's.
1318 //
1319 // Masonry, isotope, packery and the slider libraries lay elements out
1320 // by MEASURING them and then writing absolute positions. Deferring the
1321 // stylesheet that sizes those elements means the script measures them
1322 // unstyled — zero or full-width — computes positions from those wrong
1323 // numbers, and commits them. The CSS arriving a moment later cannot
1324 // undo it: the script has already run and does not re-measure. The
1325 // result is a permanently broken grid (items overlapping, or stranded
1326 // with a large gap), which is worse than the flash this feature's
1327 // other guard prevents, because it never resolves itself.
1328 //
1329 // This is checked per PAGE rather than per handle deliberately. The
1330 // script that measures is rarely the one whose handle matches the
1331 // sheet — Kadence's gallery is styled by
1332 // `kadence-blocks-advancedgallery` but laid out by core's `masonry` —
1333 // so pairing handles misses it. Whether a measuring library is present
1334 // at all is the signal that generalises. (#269)
1335 if ( self::page_has_js_measured_layout() ) {
1336 return $tag;
1337 }
1338 // Avoid double-wrapping.
1339 if ( false !== stripos( $tag, 'data-xs-async' ) ) {
1340 return $tag;
1341 }
1342 // Someone else already made this sheet non-render-blocking.
1343 //
1344 // Plugins that ship their own async-CSS handling apply the same
1345 // media="print" + onload swap we do, and they run on the SAME
1346 // filter — SureCookie's consent banner does it at style_loader_tag
1347 // priority 10, ours is priority 20, so its finished tag arrives
1348 // here looking like a plain stylesheet with no marker of ours.
1349 //
1350 // Transforming it again breaks the sheet two ways: the media we'd
1351 // capture as "the original to restore" is already `print`, so we
1352 // emit onload="this.media='print'" — a swap to itself that never
1353 // activates the stylesheet — and we append a SECOND onload
1354 // attribute, of which the parser honours only the first (ours),
1355 // discarding the plugin's correct this.media='all'. The banner
1356 // then mounts unstyled, in both logged-in and logged-out states.
1357 //
1358 // An onload handler or a print media on a stylesheet link is only
1359 // ever this pattern; a genuinely print-only sheet is already off
1360 // the critical path and gains nothing from us. Either way the
1361 // right move is to leave the tag alone — the same "don't fight
1362 // explicit author intent" rule the rel= check above applies. (#216)
1363 if ( preg_match( '#\bonload\s*=#i', $tag ) ) {
1364 return $tag;
1365 }
1366 if ( preg_match( '#\bmedia\s*=\s*(["\'])\s*print\s*\1#i', $tag ) ) {
1367 return $tag;
1368 }
1369 return self::async_link_markup( $tag );
1370 }
1371
1372 /**
1373 * The one place the async-CSS output shape lives: swap the link's media
1374 * to `print`, restore the original media onload, record it in
1375 * `data-xs-async`, and re-emit the untouched tag inside `<noscript>` for
1376 * clients that never run the onload handler.
1377 *
1378 * Shared by the enqueue-path filter above and the raw-tag buffer pass
1379 * below so the two can never drift — Pro's Critical CSS recognises this
1380 * exact marker to avoid double-wrapping, and a second copy of the
1381 * pattern is how that kind of contract quietly breaks.
1382 *
1383 * Callers own every skip decision (markers, onload, non-screen media);
1384 * this helper only produces the markup.
1385 *
1386 * @param string $tag A `<link rel="stylesheet">` tag deemed safe to defer.
1387 */
1388 private static function async_link_markup( string $tag ): string {
1389 $async = (string) preg_replace_callback(
1390 '#\bmedia\s*=\s*(["\'])([^"\']*)\1#i',
1391 static function ( $m ) {
1392 $orig = $m[2];
1393 return 'media="print" onload="this.media=\'' . esc_attr( $orig ) . '\'" data-xs-async="' . esc_attr( $orig ) . '"';
1394 },
1395 $tag,
1396 1
1397 );
1398 // If no media= was present (rare), inject one.
1399 if ( $async === $tag ) {
1400 $async = (string) preg_replace(
1401 '#<link\b#i',
1402 '<link media="print" onload="this.media=\'all\'" data-xs-async="all"',
1403 $tag,
1404 1
1405 );
1406 }
1407 // Fallback for noscript users — re-emit the original tag inside <noscript>.
1408 return $async . '<noscript>' . $tag . '</noscript>';
1409 }
1410
1411 /**
1412 * Stylesheet hosts that serve FONT CSS — small, render-blocking sheets of
1413 * `@font-face` rules. The buffer pass below defers only these: a raw
1414 * cross-origin `<link>` could carry anything, and blindly deferring an
1415 * unknown vendor's layout CSS from the buffer would reintroduce the
1416 * unstyled-flash failure async_style_tag()'s guards exist to prevent.
1417 * Font CSS is the safe subset — text renders in a fallback face and swaps,
1418 * which is exactly what `font-display: swap` does on purpose.
1419 */
1420 private const FONT_CSS_HOSTS = array(
1421 'fonts.googleapis.com',
1422 'fonts.bunny.net',
1423 'use.typekit.net',
1424 'p.typekit.net',
1425 'fonts.cdnfonts.com',
1426 );
1427
1428 /**
1429 * The font-CSS host allowlist, filtered and normalised.
1430 *
1431 * @return string[] Lowercase hostnames.
1432 */
1433 private static function font_css_hosts(): array {
1434 /**
1435 * Hosts whose stylesheet links the async-CSS buffer pass rewrites to
1436 * the non-blocking print → onload pattern. Only font-CSS providers
1437 * belong here: every listed host's sheets are safe to load late
1438 * because they only add `@font-face` rules.
1439 *
1440 * @param string[] $hosts Hostnames (exact match, case-insensitive).
1441 */
1442 $hosts = (array) apply_filters( 'xspeed_async_css_font_hosts', self::FONT_CSS_HOSTS );
1443
1444 return array_map( 'strtolower', array_map( 'strval', $hosts ) );
1445 }
1446
1447 /**
1448 * Media values that never apply to a screen paint. A sheet restricted to
1449 * one of these is not render-blocking for screen, so deferring it saves
1450 * nothing — and `print` in particular is either a genuine print sheet or
1451 * somebody's finished async pattern, both of which must be left alone.
1452 */
1453 private const NON_SCREEN_MEDIA = array(
1454 'print',
1455 'speech',
1456 'aural',
1457 'braille',
1458 'embossed',
1459 'handheld',
1460 'projection',
1461 'tty',
1462 'tv',
1463 );
1464
1465 /**
1466 * Filter: `xspeed_cache_final_html` — defer RAW font-CSS stylesheet links
1467 * that never passed through wp_enqueue_style.
1468 *
1469 * `async_style_tag()` hooks `style_loader_tag`, so it only ever sees
1470 * enqueued stylesheets. Themes and font plugins print Google Fonts (and
1471 * Bunny, Typekit, CDNFonts) as literal
1472 * `<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=…">`
1473 * markup in the head — on the site that surfaced this, four such tags —
1474 * and each one stays render-blocking with no plugin lever. Unused CSS
1475 * skips cross-origin hrefs by design, so nothing else picks them up.
1476 *
1477 * Runs on the finished page buffer, so the rewrite is baked into the
1478 * cached HTML and replays on every static hit. Deliberately narrow: only
1479 * links whose host is on the font-CSS allowlist are touched — see
1480 * FONT_CSS_HOSTS. Same-origin links (no host, or the site's own) never
1481 * match the allowlist and are untouched.
1482 *
1483 * @param string $html Complete page HTML.
1484 */
1485 public static function async_raw_font_css_links( $html ): string {
1486 if ( ! is_string( $html ) || '' === $html ) {
1487 return (string) $html;
1488 }
1489 if ( self::skip_in_non_frontend_context() ) {
1490 return $html;
1491 }
1492 $opts = self::opts();
1493 if ( empty( $opts['async_css'] ) ) {
1494 return $html;
1495 }
1496
1497 // Never rewrite inside a <noscript>. That block IS the no-JS
1498 // fallback — its <link> is a plain blocking stylesheet on purpose,
1499 // and async_style_tag() itself emits one for every sheet it defers.
1500 // Rewriting it would nest <noscript> (invalid; the parser closes the
1501 // outer block at the first </noscript>) and hand no-JS visitors a
1502 // media="print" sheet whose onload never runs: no stylesheet at all.
1503 // Splitting the buffer on <noscript> spans and rewriting only the
1504 // slices between them also makes the pass idempotent against
1505 // whatever an earlier pass emitted.
1506 $parts = preg_split(
1507 '#(<noscript\b[^>]*>.*?</noscript\s*>)#is',
1508 $html,
1509 -1,
1510 PREG_SPLIT_DELIM_CAPTURE
1511 );
1512
1513 // preg_split failed (pathological buffer / backtrack limit). Without
1514 // the split we cannot tell a fallback link from a live one, so leave
1515 // the page untouched — a few blocking font sheets beat a broken
1516 // no-JS fallback.
1517 if ( ! is_array( $parts ) ) {
1518 return $html;
1519 }
1520
1521 foreach ( $parts as $i => $part ) {
1522 // Odd indices are the captured <noscript> blocks.
1523 if ( 1 === $i % 2 || '' === $part ) {
1524 continue;
1525 }
1526 $parts[ $i ] = self::async_font_links_in_slice( $part );
1527 }
1528
1529 return implode( '', $parts );
1530 }
1531
1532 /**
1533 * Rewrite the font-CSS links in one <noscript>-free slice of the buffer.
1534 *
1535 * @param string $html Slice of page HTML with no <noscript> spans.
1536 */
1537 private static function async_font_links_in_slice( string $html ): string {
1538 $hosts = self::font_css_hosts();
1539
1540 $out = preg_replace_callback(
1541 '#<link\b[^>]*>#i',
1542 static function ( array $m ) use ( $hosts ): string {
1543 $tag = $m[0];
1544
1545 // Only plain stylesheets — never preload/alternate/anything
1546 // carrying explicit author intent. `(?<![-\w])` not `\b`, so
1547 // a `data-rel=` attribute can never read as the rel — same
1548 // reason the delay passes spell src that way. (#273)
1549 if ( ! preg_match( '#(?<![-\w])rel\s*=\s*(["\']?)\s*stylesheet\s*\1#i', $tag ) ) {
1550 return $tag;
1551 }
1552
1553 // Already deferred (either marker spelling — ours and Pro's),
1554 // or explicitly opted out by the theme.
1555 foreach ( array( 'data-xs-async', 'data-xspeed-async', 'data-xspeed-keep' ) as $marker ) {
1556 if ( false !== stripos( $tag, $marker ) ) {
1557 return $tag;
1558 }
1559 }
1560
1561 // An onload handler on a stylesheet link is only ever
1562 // somebody's finished async pattern — same rule as
1563 // async_style_tag(). (#216)
1564 if ( preg_match( '#(?<![-\w])onload\s*=#i', $tag ) ) {
1565 return $tag;
1566 }
1567
1568 // A sheet that never applies on screen is not blocking paint.
1569 if ( preg_match( '#(?<![-\w])media\s*=\s*(["\'])([^"\']*)\1#i', $tag, $mm )
1570 && in_array( strtolower( trim( $mm[2] ) ), self::NON_SCREEN_MEDIA, true ) ) {
1571 return $tag;
1572 }
1573
1574 if ( ! preg_match( '#(?<![-\w])href\s*=\s*(["\'])([^"\']+)\1#i', $tag, $hm ) ) {
1575 return $tag;
1576 }
1577 // No host means a relative URL — same-origin, and the enqueue
1578 // path's business if it is anybody's.
1579 $host = strtolower( (string) wp_parse_url( $hm[2], PHP_URL_HOST ) );
1580 if ( '' === $host || ! in_array( $host, $hosts, true ) ) {
1581 return $tag;
1582 }
1583
1584 return self::async_link_markup( $tag );
1585 },
1586 $html
1587 );
1588
1589 // A PCRE failure returns null — the unrewritten slice is the safe
1590 // fallback, never an empty page.
1591 return null === $out ? $html : $out;
1592 }
1593
1594 /**
1595 * Whether a stylesheet handle carries the page's layout, and so must
1596 * keep blocking the first paint.
1597 *
1598 * Two families qualify:
1599 *
1600 * - The ACTIVE THEME's own sheets. A theme stylesheet is the page's
1601 * layout by definition; without it the document paints as unstyled
1602 * HTML. Resolved from the live theme's stem (`kadence` →
1603 * `kadence-global`, `kadence-header`, …) plus the handles WordPress
1604 * itself registers for a theme, so this holds for any theme rather
1605 * than a hard-coded list.
1606 * - WordPress' own BLOCK and layout sheets (`wp-block-library`,
1607 * `global-styles`, `classic-theme-styles`). These style block
1608 * content on the front end and are as structural as the theme's.
1609 * - A page builder's GRID sheets: the rows, columns, sections and
1610 * containers everything else sits in (`kadence-blocks-rowlayout`,
1611 * `kadence-blocks-column`, `elementor-frontend`, `elementor-post-N`).
1612 * On a builder page these lay out the hero, not the theme. Deferred,
1613 * the hero painted as one stacked column and then snapped into its
1614 * grid: CLS 0.665 on desktop, from one row.
1615 *
1616 * Everything else — plugin sheets, icon fonts, buttons, forms, the
1617 * builder's per-widget sheets, the long tail that makes async CSS worth
1618 * having — is still deferred, so the optimization keeps most of its
1619 * benefit.
1620 *
1621 * A site WITH critical CSS can defer these too; that is what the
1622 * `xspeed_async_css_layout_critical` filter is for.
1623 *
1624 * Pure aside from the theme lookup — unit-tested via the filter.
1625 *
1626 * @param string $handle Stylesheet handle from `style_loader_tag`.
1627 */
1628 public static function is_layout_critical_style( string $handle ): bool {
1629 $handle = strtolower( $handle );
1630
1631 // Core's front-end block + global styles.
1632 $core = array(
1633 'wp-block-library',
1634 'wp-block-library-theme',
1635 'global-styles',
1636 'classic-theme-styles',
1637 );
1638 $critical = in_array( $handle, $core, true ) || self::is_builder_grid_style( $handle );
1639
1640 // The active theme's own sheets.
1641 //
1642 // Matched on the theme stem, but NOT as a bare prefix: a plugin from
1643 // the same vendor shares it (the Kadence theme is `kadence`, while
1644 // `kadence-blocks-image` and `kadence-fonts-gfonts` come from the
1645 // Kadence Blocks PLUGIN and a webfont loader). Treating every such
1646 // sheet as layout-critical would leave almost nothing deferred and
1647 // quietly undo the feature; the builder's grid sheets are caught
1648 // above by what they do, not whose they are. So the stem must be
1649 // followed by a recognised theme-area segment, which is how themes
1650 // name their split sheets.
1651 if ( ! $critical && function_exists( 'get_template' ) ) {
1652 $areas = array(
1653 'style',
1654 'global',
1655 'header',
1656 'content',
1657 'footer',
1658 'main',
1659 'layout',
1660 'base',
1661 'core',
1662 'theme',
1663 'woocommerce',
1664 );
1665 foreach ( array( get_template(), get_stylesheet() ) as $stem ) {
1666 $stem = strtolower( (string) $stem );
1667 if ( '' === $stem ) {
1668 continue;
1669 }
1670 if ( $handle === $stem ) {
1671 $critical = true;
1672 break;
1673 }
1674 foreach ( $areas as $area ) {
1675 if ( $handle === $stem . '-' . $area ) {
1676 $critical = true;
1677 break 2;
1678 }
1679 }
1680 }
1681 }
1682
1683 /**
1684 * Whether this stylesheet must keep blocking the first paint.
1685 *
1686 * Return false for a handle to let async CSS defer it anyway — the
1687 * right call on a site that ships critical CSS. Return true to
1688 * protect an additional sheet the layout depends on.
1689 *
1690 * @param bool $critical Whether the sheet is treated as layout-critical.
1691 * @param string $handle The stylesheet handle.
1692 */
1693 return (bool) apply_filters( 'xspeed_async_css_layout_critical', $critical, $handle );
1694 }
1695
1696 /**
1697 * Whether a handle is a page builder's grid sheet.
1698 *
1699 * Matched on the last segment of the handle, so a builder that names its
1700 * row sheet `acme-blocks-row-layout` is covered without being listed. The
1701 * segments are the ones that only ever carry structure; a button, image
1702 * or form sheet styles an element inside the grid, and the grid holds its
1703 * place while that sheet loads.
1704 *
1705 * @param string $handle Lowercase stylesheet handle.
1706 */
1707 private static function is_builder_grid_style( string $handle ): bool {
1708 if ( preg_match( '#(?:^|-)(?:rowlayout|row-layout|column|columns|container|section|grid)$#', $handle ) ) {
1709 return true;
1710 }
1711 // Builders whose grid lives in a sheet named after the builder or the
1712 // post, not after a structural element.
1713 return (bool) preg_match( '#^(?:elementor-frontend|elementor-post-\d+|fl-builder-layout(?:-\d+)?|generateblocks)$#', $handle );
1714 }
1715
1716 /**
1717 * Filter: `style_loader_src` + `script_loader_src` — strip the
1718 * ?ver=X.Y query string that WP appends for cache busting. Some
1719 * CDNs / reverse proxies cache better when the URL has no query.
1720 *
1721 * Skip URLs whose query carries non-ver params — those might be
1722 * intentional (e.g. a CDN providing per-image transforms).
1723 *
1724 * `ver` is load-bearing on one class of asset: a file a plugin
1725 * REGENERATES IN PLACE. Complianz rewrites
1726 * uploads/complianz/css/banner-1-optin.css whenever the banner is
1727 * edited, Beaver Builder rewrites uploads/bb-plugin/cache/<post>-layout.css
1728 * on every layout save, Elementor uploads/elementor/css/post-<id>.css on
1729 * publish. The path never changes, so `?ver=<timestamp|hash>` is the only
1730 * thing telling a browser — or our own Browser Cache `immutable` rule — to
1731 * refetch. Strip it and the old styling is served until the browser cache
1732 * gives up, which for us is a year. So anything under the uploads root
1733 * keeps its version.
1734 *
1735 * Release assets under plugins/, themes/ and core are still stripped, but
1736 * not because they are safe: an update overwrites the same path there too,
1737 * and only `?ver=` changed. The difference is frequency, not mechanism — a
1738 * plugin update lands rarely and is expected to, a banner edit is a setting
1739 * the user just changed and expects to see. Stripping is the feature the
1740 * toggle is for; with Browser Cache on it is what the user is buying, and
1741 * `docs/user/minification.md` states the cost. (#276)
1742 *
1743 * @param string $src
1744 */
1745 public static function strip_version_query( $src ): string {
1746 if ( ! is_string( $src ) || '' === $src ) {
1747 return (string) $src;
1748 }
1749 if ( self::skip_in_non_frontend_context() ) {
1750 return $src;
1751 }
1752 $parts = wp_parse_url( $src );
1753 if ( ! is_array( $parts ) || empty( $parts['query'] ) ) {
1754 return $src;
1755 }
1756 parse_str( $parts['query'], $query );
1757 if ( ! is_array( $query ) || ! array_key_exists( 'ver', $query ) ) {
1758 return $src;
1759 }
1760
1761 $strip = ! self::is_regenerated_asset( $parts );
1762
1763 /**
1764 * Whether Remove Query Strings drops `?ver` from this asset URL.
1765 *
1766 * False by default under the uploads root, where page builders and
1767 * consent plugins rewrite generated CSS/JS in place and `ver` is its
1768 * only cache-buster. Return false to protect a generator that writes
1769 * somewhere else, true to force stripping.
1770 *
1771 * @param bool $strip Whether `ver` will be removed.
1772 * @param string $src The asset URL as enqueued.
1773 */
1774 if ( ! apply_filters( 'xspeed_strip_asset_version', $strip, $src ) ) {
1775 return $src;
1776 }
1777
1778 // Only strip 'ver' — keep anything else the asset URL needs.
1779 unset( $query['ver'] );
1780 $new_query = http_build_query( $query );
1781
1782 // Rebuild the authority only when the source had one. An enqueued
1783 // src is not always absolute: `//cdn.example/x.css` says "the
1784 // page's own scheme", and defaulting that to http:// is mixed
1785 // content an https page blocks outright; `/wp-includes/x.js` has no
1786 // host at all, and pasting one in produced `http:///wp-includes/…`,
1787 // which resolves nowhere.
1788 $new_url = '';
1789 if ( isset( $parts['host'] ) && '' !== $parts['host'] ) {
1790 $new_url = isset( $parts['scheme'] ) ? $parts['scheme'] . '://' : '//';
1791 $new_url .= $parts['host'];
1792 if ( isset( $parts['port'] ) ) {
1793 $new_url .= ':' . $parts['port'];
1794 }
1795 }
1796 $new_url .= $parts['path'] ?? '';
1797 if ( '' !== $new_query ) {
1798 $new_url .= '?' . $new_query;
1799 }
1800 if ( ! empty( $parts['fragment'] ) ) {
1801 $new_url .= '#' . $parts['fragment'];
1802 }
1803 return $new_url;
1804 }
1805
1806 /**
1807 * Memoised uploads root, see uploads_base(). Cleared by reset_state().
1808 *
1809 * @var array{host:string,path:string}|null
1810 */
1811 private static $uploads_base = null;
1812
1813 /**
1814 * The uploads root as a URL host + PATH, read from wp_get_upload_dir()
1815 * rather than hardcoded so a moved uploads dir, the `UPLOADS` constant and
1816 * the legacy multisite `/files/` layout all work.
1817 *
1818 * On multisite wp_get_upload_dir() answers with the per-site
1819 * `…/uploads/sites/<id>`. Generated assets live under the network root
1820 * too, so the suffix comes off and the whole tree matches.
1821 *
1822 * @return array{host:string,path:string}
1823 */
1824 private static function uploads_base(): array {
1825 if ( null !== self::$uploads_base ) {
1826 return self::$uploads_base;
1827 }
1828 $base = '';
1829 if ( function_exists( 'wp_get_upload_dir' ) ) {
1830 $dir = wp_get_upload_dir();
1831 $base = is_array( $dir ) && isset( $dir['baseurl'] ) ? (string) $dir['baseurl'] : '';
1832 }
1833 $host = '';
1834 $path = '';
1835 if ( '' !== $base ) {
1836 $host = strtolower( (string) wp_parse_url( $base, PHP_URL_HOST ) );
1837 $path = (string) wp_parse_url( $base, PHP_URL_PATH );
1838 }
1839 $path = (string) preg_replace( '#/sites/\d+/?$#', '', rtrim( $path, '/' ) );
1840 if ( '' === $path && '' === $host ) {
1841 // Unreadable. An empty prefix would match every asset on the
1842 // site, so fall back to where uploads normally is.
1843 $path = '/wp-content/uploads';
1844 }
1845 self::$uploads_base = array(
1846 'host' => $host,
1847 'path' => $path,
1848 );
1849 return self::$uploads_base;
1850 }
1851
1852 /**
1853 * Does this URL sit under the uploads root — i.e. is it a file some plugin
1854 * generates at runtime and rewrites in place?
1855 *
1856 * @param array<string,mixed> $parts wp_parse_url() output for the asset.
1857 */
1858 private static function is_regenerated_asset( array $parts ): bool {
1859 $base = self::uploads_base();
1860
1861 if ( '' !== $base['path'] ) {
1862 // Path only, never host: a pull-zone CDN, a protocol-relative URL
1863 // and an http/https flip all leave the path alone.
1864 $path = (string) ( $parts['path'] ?? '' );
1865 return '' !== $path && 0 === strpos( $path, $base['path'] . '/' );
1866 }
1867
1868 // Uploads AT the root of their own domain — an offload plugin
1869 // pointing `upload_url_path` at https://cdn.example.com. There is no
1870 // prefix left to test, and testing the path anyway would have read
1871 // every generated file on that CDN as an ordinary release asset and
1872 // stripped the one thing telling a browser it had changed. The host
1873 // is the whole answer here: everything served from it is an upload.
1874 $host = strtolower( (string) ( $parts['host'] ?? '' ) );
1875 return '' !== $host && $host === $base['host'];
1876 }
1877
1878 /**
1879 * Defensive context guard for filter callbacks. Mirrors the registration-
1880 * time bail in Minifier::__construct() so a late context flip (admin page
1881 * render kicked off mid-request, REST_REQUEST set after plugins_loaded,
1882 * etc.) doesn't let frontend tag rewrites leak into wp-admin / AJAX /
1883 * REST / cron responses.
1884 *
1885 * Specifically prevents the React admin bundle's <script> tag from being
1886 * deferred or src-swapped to data-xs-src — which would stop the dashboard
1887 * from booting and make toggles appear unchecked until first interaction.
1888 */
1889 private static function skip_in_non_frontend_context(): bool {
1890 if ( is_admin() ) {
1891 return true;
1892 }
1893 if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
1894 return true;
1895 }
1896 if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
1897 return true;
1898 }
1899 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
1900 return true;
1901 }
1902 return false;
1903 }
1904
1905 /**
1906 * Built-in exclusion list — always skipped regardless of user settings.
1907 * Covers our own admin bundle and the WP script-modules it depends on,
1908 * so that even if the registration-time admin guard is somehow bypassed,
1909 * the dashboard's React app can still boot.
1910 */
1911 private const ALWAYS_EXCLUDED_HANDLES = array(
1912 'xspeed-admin',
1913 'wp-hooks',
1914 'wp-i18n',
1915 'wp-url',
1916 'wp-api-fetch',
1917 );
1918
1919 /**
1920 * Consent managers are never deferred, and never delayed by a broad
1921 * setting — only a delay_js_targets entry that NAMES the vendor lifts
1922 * the floor (see user_named_consent_manager()); the
1923 * `xspeed_js_exclusion_floor` filter remains the code-level override.
1924 *
1925 * A consent banner is drawn by JavaScript, and it is the one thing on
1926 * the page that has to appear before anything else happens. Delay it
1927 * and a visitor who lands, reads and leaves without touching the page
1928 * is never asked — on an opt-in configuration the site then ran
1929 * without ever offering the choice.
1930 *
1931 * The editable list cannot carry this. A stored value replaces the
1932 * schema default outright (Settings_Manager::get()), so widening that
1933 * default would reach fresh installs only, and clearing the textarea
1934 * would drop the protection again. Same floor pattern as
1935 * Server_Rules::COOKIE_FLOOR. Trim or extend it through
1936 * `xspeed_js_exclusion_floor`.
1937 *
1938 * A URL token cannot survive a rewrite of that URL: Minify JS rewrites a
1939 * local script to a hashed /cache/xspeed/min/ path, and Combine JS folds
1940 * it into a bundle. On the enqueue path original_src() gives the pre-minify
1941 * URL back, but the buffer sweep has only the tag -- so with Minify JS on
1942 * and the `id` stripped, a banner shipped un-minified is not recognised.
1943 * Documented in docs/user/minification.md rather than papered over.
1944 *
1945 * Each entry goes through target_matches(): an exact handle OR a
1946 * case-insensitive URL substring. Both passes can match either — the
1947 * enqueue path is handed the handle, and the buffer sweep reads it back
1948 * out of the tag's `id`. A URL token additionally covers a banner that
1949 * was never enqueued at all, which is how Cookiebot prints itself. (#275)
1950 */
1951 private const CONSENT_MANAGER_FLOOR = array(
1952 // Prefer a plugin-directory or vendor-host URL token over a handle.
1953 // A handle is only readable on the enqueue path and, on the buffer
1954 // sweep, only if the tag still carries the `id` WordPress prints —
1955 // which another plugin can strip. A URL token matches on both passes
1956 // and covers a banner that was never enqueued at all. (#275 QA)
1957
1958 // CookieYes / GDPR Cookie Consent. Handle and plugin directory are
1959 // the same string, so this covers both paths.
1960 'cookie-law-info',
1961 // Complianz: the plugin directory, covering -gdpr and -gdpr-premium.
1962 // Was the `cmplz-cookiebanner` handle, which needed the `id` tag.
1963 'complianz',
1964 // NotificationX runs its GDPR cookie notice off the same handle as
1965 // every other notification, so excluding it excludes them all. That
1966 // is what the plugin's own team asked for. Directory token covers
1967 // the Pro build too; was the `notificationx-public` handle.
1968 'notificationx',
1969 // Cookiebot prints its loader straight into wp_head, so only the
1970 // buffer sweep ever sees it. This is the token Cookiebot's own WP
1971 // Rocket and LiteSpeed integrations exclude.
1972 'consent.cookiebot.com',
1973 // Cookie Notice — named in the original report and one of the most
1974 // installed consent plugins. Its banner is enqueued from
1975 // /plugins/cookie-notice/js/front.min.js.
1976 'cookie-notice',
1977 // Cookie Notice in Cookie Compliance mode prints a different loader,
1978 // whose host is overridable via CN_APP_WIDGET_URL — so key on the
1979 // filename, not the CDN host.
1980 'hu-banner',
1981 // Moove GDPR Cookie Compliance. Directory token: its handle
1982 // (`moove_gdpr_frontend`) does not appear in its own URL.
1983 'gdpr-cookie-compliance',
1984 // Termly's resource blocker, which also covers the legacy embed.
1985 'app.termly.io',
1986 // Usercentrics, reached three ways: Cookiebot's UC mode
1987 // (web.cmp.usercentrics.eu), Termageddon (app.usercentrics.eu) and
1988 // the privacy proxy.
1989 'usercentrics.eu',
1990 // Iubenda: both the consent solution and the consent database SDK.
1991 'cdn.iubenda.com',
1992 // OneTrust. Pasted snippet rather than a wordpress.org plugin, so
1993 // this is the SDK host rather than a verified plugin path.
1994 'cdn.cookielaw.org',
1995 // Borlabs is commercial and renames its files per release; the
1996 // vendor's own guidance is that this string stays in every path.
1997 'borlabs-cookie',
1998 // Real Cookie Banner, free and pro. Its anti-adblock mode serves the
1999 // banner from an anonymised path that no URL token can match — use
2000 // `xspeed_js_exclusion_floor` to add the handle on such a site.
2001 'real-cookie-banner',
2002 // SureCookie.
2003 'surecookie',
2004 );
2005
2006 /**
2007 * What a site owner types to name each floor entry, and what the admin
2008 * shows them. Keyed by CONSENT_MANAGER_FLOOR token; a test holds the two
2009 * in step.
2010 *
2011 * The keyword is the part of the token a person would actually write
2012 * (`cookiebot`, not `consent.cookiebot.com`), and it is always a substring
2013 * of the token, so an entry that names the vendor this way still matches
2014 * the vendor's URL. A brand name that appears nowhere in the URL
2015 * (`cookieyes`, `onetrust`, `cmplz`) is deliberately not a keyword: it
2016 * could never match the tag, so offering it would promise a lift that
2017 * cannot happen. The exact handle always works as well.
2018 *
2019 * Cookiebot in Usercentrics CMP mode loads from web.cmp.usercentrics.eu,
2020 * so the floor catches it as Usercentrics and `usercentrics` names it,
2021 * not `cookiebot`.
2022 */
2023 private const CONSENT_MANAGER_NAMES = array(
2024 'cookie-law-info' => array( 'label' => 'CookieYes', 'keyword' => 'cookie-law-info' ),
2025 'complianz' => array( 'label' => 'Complianz', 'keyword' => 'complianz' ),
2026 'notificationx' => array( 'label' => 'NotificationX', 'keyword' => 'notificationx' ),
2027 'consent.cookiebot.com' => array( 'label' => 'Cookiebot', 'keyword' => 'cookiebot' ),
2028 'cookie-notice' => array( 'label' => 'Cookie Notice', 'keyword' => 'cookie-notice' ),
2029 'hu-banner' => array( 'label' => 'Cookie Notice (Cookie Compliance)', 'keyword' => 'hu-banner' ),
2030 'gdpr-cookie-compliance' => array( 'label' => 'GDPR Cookie Compliance (Moove)', 'keyword' => 'gdpr-cookie-compliance' ),
2031 'app.termly.io' => array( 'label' => 'Termly', 'keyword' => 'termly' ),
2032 'usercentrics.eu' => array( 'label' => 'Usercentrics', 'keyword' => 'usercentrics' ),
2033 'cdn.iubenda.com' => array( 'label' => 'Iubenda', 'keyword' => 'iubenda' ),
2034 'cdn.cookielaw.org' => array( 'label' => 'OneTrust', 'keyword' => 'cookielaw' ),
2035 'borlabs-cookie' => array( 'label' => 'Borlabs Cookie', 'keyword' => 'borlabs' ),
2036 'real-cookie-banner' => array( 'label' => 'Real Cookie Banner', 'keyword' => 'real-cookie-banner' ),
2037 'surecookie' => array( 'label' => 'SureCookie', 'keyword' => 'surecookie' ),
2038 );
2039
2040 /**
2041 * "Label (keyword)" for every built-in consent manager, for the admin.
2042 *
2043 * Reads the built-in list, not the filtered floor: the admin describes
2044 * what ships, and a site that trimmed the floor in code knows it did.
2045 *
2046 * @return string[]
2047 */
2048 public static function consent_manager_labels(): array {
2049 $out = array();
2050 foreach ( self::CONSENT_MANAGER_FLOOR as $token ) {
2051 $name = self::CONSENT_MANAGER_NAMES[ $token ] ?? array(
2052 'label' => $token,
2053 'keyword' => $token,
2054 );
2055 $out[] = $name['label'] . ' (' . $name['keyword'] . ')';
2056 }
2057 return $out;
2058 }
2059
2060 /**
2061 * Per-request memo for exclusion_floor(). Null = not resolved.
2062 *
2063 * @var string[]|null
2064 */
2065 private static $exclusion_floor = null;
2066
2067 /**
2068 * The built-in exclusion floor, after the site has had its say.
2069 *
2070 * @return string[]
2071 */
2072 private static function exclusion_floor(): array {
2073 if ( null === self::$exclusion_floor ) {
2074 /**
2075 * Scripts that are never deferred or delayed, whatever the
2076 * user's exclusion list holds. Each entry is an exact script
2077 * handle or a case-insensitive URL substring.
2078 *
2079 * Return the array minus a token to let Delay JS postpone that
2080 * consent manager on purpose; add one to protect another script.
2081 *
2082 * @param string[] $floor Built-in floor.
2083 */
2084 $floor = apply_filters( 'xspeed_js_exclusion_floor', self::CONSENT_MANAGER_FLOOR );
2085 self::$exclusion_floor = array_values(
2086 array_filter( array_map( 'strval', (array) $floor ), static fn( $t ) => '' !== $t )
2087 );
2088 }
2089 return self::$exclusion_floor;
2090 }
2091
2092 /**
2093 * @param string $handle Script handle ('' on the buffer sweep
2094 * when no id survived).
2095 * @param string $src Script URL.
2096 * @param bool $named_lifts_floor Delay paths only: a delay_js_targets
2097 * entry that NAMES the consent manager
2098 * passes the floor (see
2099 * user_named_consent_manager()). Typing a
2100 * consent manager's name into an
2101 * allow-list is the site owner taking the
2102 * consent-timing decision back — GDPR is
2103 * theirs to weigh, not ours; the floor
2104 * only exists so Delay JS can't hide a
2105 * banner NOBODY pointed at. Their own
2106 * exclusion list, ALWAYS_EXCLUDED_HANDLES
2107 * and our beacons still win: on a
2108 * conflict between the user's two lists,
2109 * protection beats postponement.
2110 */
2111 private static function is_excluded_script( string $handle, string $src, bool $named_lifts_floor = false ): bool {
2112 if ( in_array( $handle, self::ALWAYS_EXCLUDED_HANDLES, true ) ) {
2113 return true;
2114 }
2115 // Never defer or delay our own scripts. The fold and RUM beacons
2116 // measure the FIRST paint — delayed to first interaction they
2117 // measure a scrolled page or nothing, so fold quorum never fills
2118 // and full CSS deferral never licenses. Found live: delay_js with
2119 // empty targets delayed the fold beacon itself, and the site sat
2120 // at zero fold reports for hours while its stylesheets stayed
2121 // render-blocking. Prefix, not a handle list, so a Pro module's
2122 // beacon added later cannot re-open the hole.
2123 if ( 0 === strpos( $handle, 'xspeed-' ) ) {
2124 return true;
2125 }
2126 // Ahead of the user list, and ahead of the empty-list early return
2127 // below: an install that saved the Minify panel before this shipped
2128 // has a stored list that knows nothing about consent managers, and
2129 // one that cleared the textarea has no list at all. Neither may
2130 // hide the banner. (#275)
2131 foreach ( self::exclusion_floor() as $needle ) {
2132 if ( self::target_matches( $needle, $handle, $src ) ) {
2133 // `continue`, not `break`: a second floor token matching the
2134 // same tag has to be named too, or a filter-added token
2135 // would be lifted by an entry that names only the first.
2136 if ( $named_lifts_floor && self::user_named_consent_manager( $needle, $handle, $src ) ) {
2137 continue;
2138 }
2139 return true;
2140 }
2141 }
2142 $opts = self::opts();
2143 $excluded = is_array( $opts['defer_js_excluded'] ?? null ) ? $opts['defer_js_excluded'] : array();
2144 if ( empty( $excluded ) ) {
2145 return false;
2146 }
2147 foreach ( $excluded as $needle ) {
2148 // Matched against the pre-minify URL too: an exclusion that
2149 // stops matching is worse than a delay target that does — the
2150 // script the user explicitly protected gets deferred anyway.
2151 if ( self::target_matches( (string) $needle, $handle, $src ) ) {
2152 return true;
2153 }
2154 }
2155 return false;
2156 }
2157
2158 /**
2159 * Include-list targeting for delay (issue #36): when delay_js_targets
2160 * is non-empty, ONLY matching scripts are delayed — a heavy
2161 * third-party embed can be postponed without delaying the whole
2162 * page's JS. Empty targets = historical behavior (delay everything
2163 * minus exclusions). Same matching semantics as the exclusion list:
2164 * exact handle match OR case-insensitive URL substring.
2165 */
2166 /**
2167 * Whether the user's delay_js_targets list matches this haystack.
2168 *
2169 * The inline-snippet pass needs the target list WITHOUT
2170 * is_delay_target()'s empty-list-means-everything default — an inline
2171 * body is only ever delayed on a positive match.
2172 *
2173 * @param string $haystack Script body (or URL) to match fragments against.
2174 */
2175 private static function matches_user_targets( string $haystack ): bool {
2176 $opts = self::opts();
2177 $targets = is_array( $opts['delay_js_targets'] ?? null ) ? $opts['delay_js_targets'] : array();
2178 foreach ( $targets as $needle ) {
2179 $needle = (string) $needle;
2180 if ( '' !== $needle && false !== stripos( $haystack, $needle ) ) {
2181 return true;
2182 }
2183 }
2184 return false;
2185 }
2186
2187 /**
2188 * Whether the user EXPLICITLY named this script in delay_js_targets.
2189 *
2190 * Unlike is_delay_target() this never treats an empty list as
2191 * everything and never falls back to the vendor list — it answers
2192 * only "did the user deliberately point at this handle/URL?", which
2193 * is what lets an explicit entry override the inline-bound guard.
2194 *
2195 * @param string $handle Script handle.
2196 * @param string $src Script URL.
2197 */
2198 private static function is_user_named_target( string $handle, string $src ): bool {
2199 $opts = self::opts();
2200 $targets = is_array( $opts['delay_js_targets'] ?? null ) ? $opts['delay_js_targets'] : array();
2201 foreach ( $targets as $needle ) {
2202 $needle = (string) $needle;
2203 if ( '' !== $needle && self::target_matches( $needle, $handle, $src ) ) {
2204 return true;
2205 }
2206 }
2207 return false;
2208 }
2209
2210 /**
2211 * Whether a delay_js_targets entry NAMES the consent manager whose floor
2212 * token matched this tag, and so lifts the floor for it.
2213 *
2214 * is_user_named_target() is not enough here: it asks whether any entry
2215 * matches the tag, and a delay target is a URL substring. `/plugins/`,
2216 * `.js`, `min.js`, `frontend` or the site's own host each match every
2217 * consent banner on the page, so one broad entry switched the floor off
2218 * for all of them and brought #275 back. An entry names the vendor when
2219 * it is the exact handle, or when it contains the vendor's keyword (or
2220 * its floor token) and still matches the tag, so `notificationx` and
2221 * `/plugins/notificationx/` lift NotificationX, `termly` cannot lift
2222 * Cookiebot, and `/plugins/` lifts nothing.
2223 *
2224 * A token added through `xspeed_js_exclusion_floor` has no keyword, so
2225 * only an entry containing that token, or the exact handle, names it.
2226 *
2227 * @param string $floor_token The floor entry that matched this tag.
2228 * @param string $handle Script handle ('' when unknown).
2229 * @param string $src Script URL, or the body on the inline pass.
2230 */
2231 private static function user_named_consent_manager( string $floor_token, string $handle, string $src ): bool {
2232 $opts = self::opts();
2233 $targets = is_array( $opts['delay_js_targets'] ?? null ) ? $opts['delay_js_targets'] : array();
2234 $names = array( $floor_token );
2235 if ( isset( self::CONSENT_MANAGER_NAMES[ $floor_token ] ) ) {
2236 $names[] = self::CONSENT_MANAGER_NAMES[ $floor_token ]['keyword'];
2237 }
2238 foreach ( $targets as $needle ) {
2239 $needle = trim( (string) $needle );
2240 if ( '' === $needle ) {
2241 continue;
2242 }
2243 $names_it = '' !== $handle && $handle === $needle;
2244 foreach ( $names as $name ) {
2245 if ( false !== stripos( $needle, $name ) ) {
2246 $names_it = true;
2247 break;
2248 }
2249 }
2250 if ( $names_it && self::target_matches( $needle, $handle, $src ) ) {
2251 return true;
2252 }
2253 }
2254 return false;
2255 }
2256
2257 /** Both toggles on: delay_js and its carry-the-inline-snippets mode. */
2258 private static function smart_delay_enabled(): bool {
2259 $opts = self::opts();
2260 return ! empty( $opts['delay_js'] ) && ! empty( $opts['delay_js_smart'] );
2261 }
2262
2263 /**
2264 * Would Smart Delay postpone this handle's tag?
2265 *
2266 * The snippet-parking filter runs when WordPress prints a handle's
2267 * `before` snippet — BEFORE script_loader_tag sees the tag itself — so
2268 * the decision cannot be read back from what happened to the tag; both
2269 * sides evaluate this same predicate. It mirrors the handle/src checks
2270 * of delay_script_tag() only: the tag-level outs there (an optimizer
2271 * opt-out attribute, a non-executable type) are invisible here, so a
2272 * tag that keeps itself eager through one of those can still have its
2273 * snippets parked. That parks an init until first interaction rather
2274 * than throwing, and Smart Delay is opt-in — acceptable, and documented
2275 * on the setting.
2276 */
2277 private static function smart_delays_handle( string $handle ): bool {
2278 if ( '' === $handle ) {
2279 return false;
2280 }
2281 $src = self::original_src( $handle );
2282 if ( self::is_excluded_script( $handle, $src, true ) ) {
2283 return false;
2284 }
2285 return self::is_delay_target( $handle, $src );
2286 }
2287
2288 /**
2289 * Park a delayed handle's own before/after snippet, in Smart Delay mode.
2290 *
2291 * Runs on `wp_inline_script_attributes`, which fires for every inline
2292 * script WordPress prints itself — so it works on pages the HTML buffer
2293 * never filters (a BYPASS route like /cart), where the handle's tag is
2294 * still delayed by script_loader_tag. `-js-extra` stays eager on
2295 * purpose: it is data assignments, harmless early and sometimes read by
2296 * eager code.
2297 *
2298 * @param mixed $attributes Inline script attributes.
2299 * @param string $javascript The snippet body.
2300 * @return mixed
2301 */
2302 public static function park_smart_inline( $attributes, $javascript = '' ) {
2303 if ( ! is_array( $attributes ) || ! self::smart_delay_enabled() || self::skip_in_non_frontend_context() ) {
2304 return $attributes;
2305 }
2306 $id = isset( $attributes['id'] ) ? (string) $attributes['id'] : '';
2307 if ( ! preg_match( '#^(.+)-js-(?:before|after)$#', $id, $m ) ) {
2308 return $attributes;
2309 }
2310 if ( ! self::smart_delays_handle( $m[1] ) ) {
2311 return $attributes;
2312 }
2313 $type = isset( $attributes['type'] ) ? (string) $attributes['type'] : '';
2314 if ( in_array( $type, self::NON_EXECUTABLE_TYPES, true ) ) {
2315 return $attributes; // data, or parked by someone else on purpose.
2316 }
2317 // A delayed document.write replays after the document has closed
2318 // and replaces the page. Same rule as delay_inline_snippets().
2319 if ( false !== stripos( (string) $javascript, 'document.write' ) ) {
2320 return $attributes;
2321 }
2322 if ( '' !== $type && ! in_array( $type, self::DEFAULT_JS_TYPES, true ) ) {
2323 $stash = (string) preg_replace( '#[^a-z0-9/+.\-]#', '', $type );
2324 if ( '' !== $stash ) {
2325 $attributes['data-xs-type'] = $stash;
2326 }
2327 }
2328 $attributes['type'] = 'text/xspeed-delayed';
2329 $attributes['data-xs-delay'] = '1';
2330 return $attributes;
2331 }
2332
2333 private static function is_delay_target( string $handle, string $src ): bool {
2334 $opts = self::opts();
2335 $targets = is_array( $opts['delay_js_targets'] ?? null ) ? $opts['delay_js_targets'] : array();
2336 $targets = array_filter( array_map( 'strval', $targets ), static fn( $t ) => '' !== $t );
2337 if ( empty( $targets ) ) {
2338 return true;
2339 }
2340 foreach ( $targets as $needle ) {
2341 if ( self::target_matches( $needle, $handle, $src ) ) {
2342 return true;
2343 }
2344 }
2345 // The user's list is an ALLOW-list, so a target they never thought to
2346 // add is not delayed — and the scripts worth delaying are third-party
2347 // tags nobody enumerates by hand. Falling back to the built-in vendor
2348 // list means a site that lists one heavy embed still gets the obvious
2349 // analytics and widget tags postponed, instead of silently keeping
2350 // them on the main thread. (A user who wants one of these to run
2351 // early excludes it; the exclusion list is checked before this.)
2352 return self::matches_known_third_party( $src );
2353 }
2354
2355 /**
2356 * Whether a URL belongs to a third-party tag that is safe to postpone.
2357 *
2358 * These are analytics, tag managers, chat widgets, review embeds, session
2359 * recorders and error trackers: scripts that never paint anything above
2360 * the fold and that no first-party code holds a synchronous reference to.
2361 * They are also the scripts that dominate a real page's blocking time —
2362 * on embedpress.com one chat widget alone accounted for ~450ms of TBT and
2363 * a 22-point score swing between runs, purely on whether it happened to
2364 * arrive inside the measurement window.
2365 *
2366 * Matched on URL only, never on handle: these tags are printed straight
2367 * into wp_head / wp_footer by their vendors' snippets and usually have no
2368 * WordPress handle at all. Host fragments rather than whole domains, so a
2369 * regional or versioned CDN path still matches.
2370 *
2371 * Deliberately NOT here: anything from the site's own origin, jQuery, or
2372 * any wp-* core script. Those carry inline consumers, and delaying them
2373 * is what breaks pages — see inline_bound_handles().
2374 */
2375 private const KNOWN_THIRD_PARTY_SRC = array(
2376 // Tag managers and analytics.
2377 'googletagmanager.com',
2378 'google-analytics.com',
2379 'analytics.google.com',
2380 '/gtag/js',
2381 'gtm4wp',
2382 'plausible.io',
2383 'matomo',
2384 'segment.com/analytics.js',
2385 'stats.wp.com',
2386 // Advertising and conversion pixels.
2387 'connect.facebook.net',
2388 'fbevents.js',
2389 'ads-twitter.com',
2390 'snap.licdn.com',
2391 'analytics.tiktok.com',
2392 'googleadservices.com',
2393 'doubleclick.net',
2394 // Session recording and heatmaps.
2395 'hotjar.com',
2396 'clarity.ms',
2397 'mouseflow.com',
2398 'fullstory.com',
2399 'luckyorange',
2400 // Chat and support widgets.
2401 'client.crisp.chat',
2402 'widget.intercom.io',
2403 'js.driftt.com',
2404 'tawk.to',
2405 'livechatinc.com',
2406 'zdassets.com',
2407 'helpscout.net',
2408 // Reviews, social proof and marketing.
2409 'tp.widget.bootstrap',
2410 'trustpilot.com',
2411 'static.klaviyo.com',
2412 'js.hs-scripts.com',
2413 'list-manage.com',
2414 'sumo.com',
2415 // Error and performance monitoring.
2416 'sentry-cdn.com',
2417 'browser.sentry',
2418 'bugsnag.com',
2419 'newrelic.com',
2420 );
2421
2422 /**
2423 * Match a script URL against the built-in third-party list.
2424 *
2425 * @param string $src Script source URL.
2426 */
2427 private static function matches_known_third_party( string $src ): bool {
2428 if ( '' === $src ) {
2429 return false;
2430 }
2431
2432 $known = self::KNOWN_THIRD_PARTY_SRC;
2433
2434 /**
2435 * URL fragments the delay pass treats as safe-to-postpone third-party
2436 * tags when the user's target list does not match.
2437 *
2438 * Append a vendor this list does not know yet, or remove one the site
2439 * genuinely needs early. Entries are case-insensitive substrings of
2440 * the script URL.
2441 *
2442 * @param string[] $known Built-in fragments.
2443 * @param string $src The script URL being tested.
2444 */
2445 $known = (array) apply_filters( 'xspeed_delay_known_third_party', $known, $src );
2446
2447 foreach ( $known as $needle ) {
2448 $needle = (string) $needle;
2449 if ( '' !== $needle && false !== stripos( $src, $needle ) ) {
2450 return true;
2451 }
2452 }
2453 return false;
2454 }
2455
2456 private static function opts(): array {
2457 if ( null === self::$opts ) {
2458 self::$opts = Settings_Manager::get( 'minify' );
2459 }
2460 return self::$opts;
2461 }
2462
2463 /**
2464 * Test-only — clear cached opts + bootstrap-printed flag.
2465 */
2466 public static function reset_state(): void {
2467 self::$opts = null;
2468 self::$uploads_base = null;
2469 self::$delay_bootstrap_printed = false;
2470 self::$js_measured_layout = null;
2471 self::$has_critical_css = null;
2472 self::$exclusion_floor = null;
2473 self::$inline_bound_handles = null;
2474 self::$pristine_tag = array();
2475 self::$our_late_attrs = array();
2476 }
2477
2478 /**
2479 * Per-request memo for inline_bound_handles(). Null = not resolved.
2480 *
2481 * @var array<string,true>|null
2482 */
2483 private static $inline_bound_handles = null;
2484
2485 /**
2486 * Handles that cannot be deferred because inline code depends on them.
2487 *
2488 * #234 fixed the case where a handle carries its OWN inline block: the
2489 * tag WordPress hands the filter is `before_inline + external +
2490 * after_inline`, so defer goes on the external <script> and order holds.
2491 * That leaves the cross-handle case, which is the one that actually
2492 * breaks sites: `wp_add_inline_script( 'foo', … )` prints a bare inline
2493 * block that runs at parse time and calls into whatever `foo` — or any
2494 * of foo's DEPENDENCIES — defined. Inline scripts can never be deferred
2495 * (the HTML spec ignores the attribute), so deferring anything they read
2496 * from inverts the order WordPress guarantees and throws on a global
2497 * that is not there yet.
2498 *
2499 * jQuery is the canonical victim: one `wp_add_inline_script( 'jquery',
2500 * 'jQuery(function($){…})' )` anywhere on the page makes `jquery-core`
2501 * undeferrable, and every hand-maintained exclusion list in the wild
2502 * exists to say so. The registry already knows it, so read it instead of
2503 * asking the user.
2504 *
2505 * Walks each handle carrying `after`/`before` inline data and marks the
2506 * handle plus its transitive dependency chain. Cycles are guarded by the
2507 * seen-map, so a self- or mutually-referential deps array terminates.
2508 *
2509 * Pure aside from the global registry read; memoised per request and
2510 * cleared by reset_state().
2511 *
2512 * @return array<string,true> Handle => true, for O(1) lookup.
2513 */
2514 public static function inline_bound_handles(): array {
2515 if ( null !== self::$inline_bound_handles ) {
2516 return self::$inline_bound_handles;
2517 }
2518
2519 $bound = array();
2520 if ( function_exists( 'wp_scripts' ) ) {
2521 $scripts = wp_scripts();
2522 if ( $scripts instanceof \WP_Scripts ) {
2523 foreach ( array_keys( (array) $scripts->registered ) as $handle ) {
2524 $handle = (string) $handle;
2525 if ( ! self::handle_carries_inline( $scripts, $handle ) ) {
2526 continue;
2527 }
2528 self::mark_with_deps( $scripts, $handle, $bound );
2529 }
2530 }
2531 }
2532
2533 /**
2534 * Handles auto-excluded from defer because inline code reads them.
2535 *
2536 * Return a handle => true map. Add an entry to protect a script whose
2537 * inline consumer this cannot see (one printed directly by a theme
2538 * rather than through wp_add_inline_script), or remove one to defer a
2539 * handle whose inline block is known not to touch it.
2540 *
2541 * @param array<string,true> $bound Detected handles.
2542 */
2543 $bound = (array) apply_filters( 'xspeed_defer_inline_bound_handles', $bound );
2544
2545 self::$inline_bound_handles = $bound;
2546
2547 return self::$inline_bound_handles;
2548 }
2549
2550 /**
2551 * Whether a handle must be kept out of a combined bundle.
2552 *
2553 * Combining re-homes a script's code under a different handle, so every
2554 * protection keyed to the ORIGINAL handle or URL stops matching: the
2555 * user's `defer_js_excluded` entry, and the inline-bound set above. The
2556 * combiner already refuses a handle carrying its own inline data, which
2557 * is why the gap is invisible until you look for it — a DEPENDENCY of an
2558 * inline consumer carries none of its own, so `jquery-core` lands in the
2559 * bundle while the exclusion list still reads as though it were honoured.
2560 *
2561 * Returning true here is enough on its own: the combiner drops any
2562 * dependent of an uncombinable handle transitively, so the whole chain
2563 * stays in the queue where WordPress prints it in the right order.
2564 *
2565 * @param string $handle Script handle.
2566 * @param string $src Registered source URL.
2567 */
2568 public static function is_protected_from_bundling( string $handle, string $src ): bool {
2569 if ( self::is_excluded_script( $handle, $src ) ) {
2570 return true;
2571 }
2572 return isset( self::inline_bound_handles()[ $handle ] );
2573 }
2574
2575 /**
2576 * Whether a handle has inline JS attached in either position.
2577 *
2578 * `get_data()` returns the raw value, which is an array of code chunks
2579 * for `after` and a string for `before`; both are falsy when absent, and
2580 * an empty chunk array must not count as inline code.
2581 *
2582 * @param \WP_Scripts $scripts Registry.
2583 * @param string $handle Handle to inspect.
2584 */
2585 private static function handle_carries_inline( \WP_Scripts $scripts, string $handle ): bool {
2586 foreach ( array( 'after', 'before' ) as $position ) {
2587 $data = $scripts->get_data( $handle, $position );
2588 if ( is_array( $data ) ) {
2589 foreach ( $data as $chunk ) {
2590 if ( '' !== trim( (string) $chunk ) ) {
2591 return true;
2592 }
2593 }
2594 continue;
2595 }
2596 if ( '' !== trim( (string) $data ) ) {
2597 return true;
2598 }
2599 }
2600 return false;
2601 }
2602
2603 /**
2604 * Mark a handle and everything it depends on, transitively.
2605 *
2606 * @param \WP_Scripts $scripts Registry.
2607 * @param string $handle Handle to mark.
2608 * @param array<string,true> $seen Accumulator, by reference.
2609 */
2610 private static function mark_with_deps( \WP_Scripts $scripts, string $handle, array &$seen ): void {
2611 if ( isset( $seen[ $handle ] ) ) {
2612 return;
2613 }
2614 $seen[ $handle ] = true;
2615 if ( ! isset( $scripts->registered[ $handle ]->deps ) ) {
2616 return;
2617 }
2618 foreach ( (array) $scripts->registered[ $handle ]->deps as $dep ) {
2619 self::mark_with_deps( $scripts, (string) $dep, $seen );
2620 }
2621 }
2622
2623 /**
2624 * Per-request memo for page_has_js_measured_layout(). Null = not resolved.
2625 *
2626 * @var bool|null
2627 */
2628 private static $js_measured_layout = null;
2629
2630 /**
2631 * Per-request memo for page_has_critical_css(). Null = not resolved.
2632 *
2633 * @var bool|null
2634 */
2635 private static $has_critical_css = null;
2636
2637 /**
2638 * Does something inline critical CSS for the page being served?
2639 *
2640 * Free generates none, so the answer comes from the filter: an extension
2641 * that inlines critical CSS for this page returns true, and a site whose
2642 * theme ships its own can too. Resolved once per request, because every
2643 * stylesheet tag asks.
2644 */
2645 public static function page_has_critical_css(): bool {
2646 if ( null === self::$has_critical_css ) {
2647 /**
2648 * Whether the page being served has critical CSS inlined in its head.
2649 *
2650 * Async CSS defers stylesheets only when this is true; without
2651 * critical CSS it leaves them render-blocking, because deferring
2652 * them makes the page paint unstyled and shift. Return true when
2653 * something inlines critical CSS for this page, or to keep deferring
2654 * without it.
2655 *
2656 * @param bool $has_critical_css Default false.
2657 */
2658 self::$has_critical_css = (bool) apply_filters( 'xspeed_async_css_page_has_critical_css', false );
2659 }
2660 return self::$has_critical_css;
2661 }
2662
2663 /**
2664 * Scripts that lay out the page by measuring the DOM.
2665 *
2666 * Each of these reads element sizes and then writes positions. If the CSS
2667 * that sizes those elements has not applied when the script runs, it
2668 * measures the wrong values and commits a broken layout that no later
2669 * stylesheet can correct.
2670 *
2671 * Matched as a substring of the registered handle, so a plugin shipping
2672 * `acme-masonry` or `masonry-init` is covered without naming it here.
2673 *
2674 * @return string[]
2675 */
2676 private static function js_layout_script_markers(): array {
2677 return array(
2678 'masonry',
2679 'isotope',
2680 'packery',
2681 'salvattore',
2682 'justified-gallery',
2683 'slick',
2684 'splide',
2685 'swiper',
2686 'flickity',
2687 'owl-carousel',
2688 'matchheight',
2689 );
2690 }
2691
2692 /**
2693 * True when a script that measures the DOM to build a layout is enqueued
2694 * for this request.
2695 *
2696 * Reads the enqueue registry rather than the finished HTML, because this
2697 * runs on `style_loader_tag` — while the head is being printed, before any
2698 * body markup exists to scan. Both the queue and each queued handle's
2699 * dependencies are checked: core registers `masonry` as a DEPENDENCY of a
2700 * plugin's init script, so it is frequently absent from the queue itself.
2701 *
2702 * Pure aside from the global registry read; the result is memoised per
2703 * request and cleared by reset_state().
2704 */
2705 public static function page_has_js_measured_layout(): bool {
2706 if ( null !== self::$js_measured_layout ) {
2707 return self::$js_measured_layout;
2708 }
2709
2710 $found = false;
2711 if ( function_exists( 'wp_scripts' ) ) {
2712 $scripts = wp_scripts();
2713 if ( $scripts instanceof \WP_Scripts ) {
2714 $handles = (array) $scripts->queue;
2715 // Pull in dependencies — `masonry` usually arrives that way.
2716 foreach ( (array) $scripts->queue as $queued ) {
2717 if ( isset( $scripts->registered[ $queued ]->deps ) ) {
2718 $handles = array_merge( $handles, (array) $scripts->registered[ $queued ]->deps );
2719 }
2720 }
2721 $markers = self::js_layout_script_markers();
2722 foreach ( $handles as $handle ) {
2723 $handle = strtolower( (string) $handle );
2724 foreach ( $markers as $marker ) {
2725 if ( false !== strpos( $handle, $marker ) ) {
2726 $found = true;
2727 break 2;
2728 }
2729 }
2730 }
2731 }
2732 }
2733
2734 /**
2735 * Whether this request renders a JS-measured layout, making async CSS
2736 * unsafe for the whole page.
2737 *
2738 * Return false to defer anyway (a site that ships critical CSS, or one
2739 * whose grid is pure CSS), or true to protect a library not detected
2740 * by handle.
2741 *
2742 * @param bool $found Whether a measuring script was detected.
2743 */
2744 self::$js_measured_layout = (bool) apply_filters( 'xspeed_async_css_js_measured_layout', $found );
2745
2746 return self::$js_measured_layout;
2747 }
2748 }
2749