PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 All 36 releases
xspeed / includes / class-minify-filters.php

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

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