PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
1.4.1 1.4.0 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 All 35 releases
xspeed / includes / class-minify-filters.php

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

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