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

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

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