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

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

855 lines 30.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Minify_Filters — frontend HTML rewriters for the "smarter minifier"
4 * sub-features (Phase 4.1a): defer JS, delay JS, async CSS, remove
5 * query strings.
6 *
7 * Each method is a WordPress filter callback. None of them touch the
8 * file system — they're pure tag rewrites or src-string rewrites
9 * applied to enqueued asset URLs / tags.
10 *
11 * The heavier combine-CSS / combine-JS engine lands in Phase 4.1b
12 * with its own class; keeping the filter-only logic isolated here
13 * makes that future split clean.
14 *
15 * @package XSpeed
16 */
17
18 declare(strict_types=1);
19
20 namespace XSpeed;
21
22 defined( 'ABSPATH' ) || exit;
23
24 final class Minify_Filters {
25
26 /**
27 * Settings cache (one read per request).
28 *
29 * @var array|null
30 */
31 private static $opts = null;
32
33 /**
34 * Has the delay-JS bootstrap snippet been printed? Guards against
35 * duplicate emission in pages that hit wp_footer multiple times.
36 */
37 private static $delay_bootstrap_printed = false;
38
39 /**
40 * Pre-minify script URLs, keyed by handle.
41 *
42 * `script_loader_src` (priority 10) rewrites a local script's URL to a
43 * hashed /cache/xspeed/min/<key>.js path long before
44 * `script_loader_tag` (priority 20/30) runs, so the delay + exclusion
45 * checks only ever see the hashed URL. A user targeting a script by
46 * URL substring — the obvious thing to do, and what the UI invites —
47 * would silently stop matching the moment minification was enabled.
48 * Minifier::rewrite_script() records the original here so those
49 * checks can test both. (FBS field report against 1.1.2)
50 *
51 * @var array<string,string>
52 */
53 private static $original_src = array();
54
55 /**
56 * Record a script's URL as it was BEFORE minification rewrote it.
57 * Called from Minifier::rewrite_script().
58 *
59 * @param string $handle Script handle.
60 * @param string $src Original (pre-minify) URL.
61 */
62 public static function remember_original_src( string $handle, string $src ): void {
63 if ( '' !== $handle && '' !== $src ) {
64 self::$original_src[ $handle ] = $src;
65 }
66 }
67
68 /**
69 * The pre-minify URL for a handle, or '' when we never rewrote it
70 * (external script, minification off, or a handle we didn't touch).
71 *
72 * @param string $handle Script handle.
73 */
74 public static function original_src( string $handle ): string {
75 return isset( self::$original_src[ $handle ] ) ? self::$original_src[ $handle ] : '';
76 }
77
78 /**
79 * Reset the remembered URLs. Test-only seam.
80 */
81 public static function reset_original_src(): void {
82 self::$original_src = array();
83 }
84
85 /**
86 * Does a user-supplied target match this script?
87 *
88 * A target is either a script handle (exact) or a URL substring. The
89 * URL is checked against BOTH the current src and the pre-minify src,
90 * so a target written against the real asset path keeps working once
91 * minification starts rewriting URLs to hashed cache paths.
92 *
93 * @param string $needle Target from the user's list.
94 * @param string $handle Script handle.
95 * @param string $src Current (possibly rewritten) src.
96 */
97 private static function target_matches( string $needle, string $handle, string $src ): bool {
98 if ( '' === $needle ) {
99 return false;
100 }
101 if ( $handle === $needle ) {
102 return true;
103 }
104 if ( '' !== $src && false !== stripos( $src, $needle ) ) {
105 return true;
106 }
107 $original = self::original_src( $handle );
108 return '' !== $original && false !== stripos( $original, $needle );
109 }
110
111 /**
112 * Filter: `script_loader_tag` — add defer="defer" to non-excluded
113 * scripts. WordPress passes the full <script> tag string, the
114 * handle, and the src. We bail when:
115 * - the user excluded this handle / src substring,
116 * - the tag already has defer or async (don't double-set),
117 * - the tag has no src (inline scripts can't be deferred — would
118 * execute synchronously regardless).
119 *
120 * @param string $tag
121 * @param string $handle
122 * @param string $src
123 */
124 public static function defer_script_tag( $tag, $handle, $src ): string {
125 if ( ! is_string( $tag ) || '' === $tag ) {
126 return (string) $tag;
127 }
128 // Self-guard: even though Minifier::__construct() bails on admin/
129 // AJAX/REST/cron at registration, a late context switch (e.g. a
130 // custom wp_print_scripts() call inside an admin page render) can
131 // leave the filter attached. Skipping here keeps the React admin
132 // bundle's <script> tag intact so the dashboard mounts.
133 if ( self::skip_in_non_frontend_context() ) {
134 return $tag;
135 }
136 if ( '' === (string) $src ) {
137 return $tag;
138 }
139 if ( self::is_excluded_script( (string) $handle, (string) $src ) ) {
140 return $tag;
141 }
142 if ( false !== stripos( $tag, ' defer' ) || false !== stripos( $tag, ' async' ) ) {
143 return $tag;
144 }
145 // Target the <script> that actually carries a src, NOT simply the
146 // first one in the string. WP_Scripts::do_item() hands this filter
147 // the CONCATENATION of before_inline + external + after_inline, so
148 // for any handle carrying a `before` inline script the first
149 // `<script` is the inline block. Deferring that is a no-op (the HTML
150 // spec ignores defer on inline scripts) AND leaves the external
151 // script undeferred while its dependencies get deferred — which
152 // inverts WordPress's guaranteed execution order and throws in any
153 // dependent that touches a global its dependency defines. (#234)
154 //
155 // The lookahead scans only within the tag (`[^>]*`) for ` src=`, so
156 // an inline `<script id="…-js-before">` can never match.
157 return (string) preg_replace( '#<script\b(?=[^>]*\ssrc\s*=)#i', '<script defer="defer"', $tag, 1 );
158 }
159
160 /**
161 * Filter: `script_loader_tag` — rewrite src= to data-xs-src= so the
162 * browser ignores it until the bootstrap (printed once on
163 * wp_footer) swaps it back on first user interaction. Same
164 * exclusion rules as defer. Inline scripts (no src) are also
165 * deferred until the first interaction.
166 *
167 * @param string $tag
168 * @param string $handle
169 * @param string $src
170 */
171 public static function delay_script_tag( $tag, $handle, $src ): string {
172 if ( ! is_string( $tag ) || '' === $tag ) {
173 return (string) $tag;
174 }
175 if ( self::skip_in_non_frontend_context() ) {
176 return $tag;
177 }
178 if ( self::is_excluded_script( (string) $handle, (string) $src ) ) {
179 return $tag;
180 }
181 if ( ! self::is_delay_target( (string) $handle, (string) $src ) ) {
182 return $tag;
183 }
184 // A non-executable type means this tag is data, or is being held by
185 // somebody else on purpose. The buffer pass has always checked this;
186 // the enqueue path did not, so a consent-blocked or JSON-carrying
187 // handle could still be rewritten here. (#274)
188 if ( preg_match( '#\btype\s*=\s*(["\'])(.*?)\1#is', $tag, $type_m )
189 && in_array( strtolower( trim( $type_m[2] ) ), self::NON_EXECUTABLE_TYPES, true ) ) {
190 return $tag;
191 }
192 // src= variant: swap src → data-xs-src and add data-xs-delay marker.
193 if ( '' !== (string) $src ) {
194 // Anchor on the opening <script …> tag that carries the src.
195 // Matching a bare `src=` across the whole string would rewrite
196 // the first occurrence anywhere — including inside a `before`
197 // inline block, where JS like `el.src = "…"` becomes the
198 // syntax error `el.data-xs-src="…" data-xs-delay="1"` and the
199 // real external script is left undelayed. $tag is the
200 // concatenation of before_inline + external + after_inline,
201 // so that is a routine shape, not a corner case. (#234)
202 // `(?<![-\w])` where `\b` used to be. A hyphen is a non-word
203 // character, so `\bsrc=` also matches the TAIL of any
204 // `data-…-src=` attribute — and consent managers and other
205 // optimizers park a blocked script's real URL in exactly that
206 // shape. Complianz's `data-cmplz-src` became
207 // `data-cmplz-data-xs-src`, so after the visitor clicked Accept
208 // the plugin looked for an attribute that no longer existed and
209 // the script never loaded: analytics and pixels silently dead,
210 // no console error, nothing in the UI. Same class of bug as the
211 // image-dimension resolver in #328. (#273)
212 return (string) preg_replace(
213 '#(<script\b[^>]*?)(?<![-\w])src\s*=\s*(["\'][^"\']*["\'])#i',
214 '$1data-xs-src=$2 data-xs-delay="1"',
215 $tag,
216 1
217 );
218 }
219 // Inline script: change type to text/plain so the browser
220 // doesn't execute, mark for bootstrap rewriter.
221 return (string) preg_replace(
222 '#<script\b([^>]*)>#i',
223 '<script$1 type="text/xspeed-delayed" data-xs-delay="1">',
224 $tag,
225 1
226 );
227 }
228
229 /**
230 * Script types the buffer pass must never touch. `<script>` carries
231 * data as often as it carries code: JSON-LD feeds structured-data
232 * consumers, importmaps must resolve before any module runs, and our
233 * own delayed-inline marker is already handled by the bootstrap.
234 * Rewriting any of these breaks the page or its metadata.
235 */
236 private const NON_EXECUTABLE_TYPES = array(
237 'application/ld+json',
238 'application/json',
239 'importmap',
240 'speculationrules',
241 'text/template',
242 'text/x-template',
243 'text/xspeed-delayed',
244 // A consent manager parks a blocked third-party script here and
245 // swaps the type back only once the visitor has agreed. Whatever we
246 // do to such a tag we do on behalf of a decision the visitor has not
247 // made yet, so the only correct move is to leave it alone. (#274)
248 'text/plain',
249 );
250
251 /**
252 * URL fragments that must keep a live src no matter what. The enqueue
253 * path guards these by handle (ALWAYS_EXCLUDED_HANDLES), but a buffer
254 * pass only ever sees a URL, so the same protection is re-expressed
255 * here. Without this the admin bundle could be delayed on a frontend
256 * render and the dashboard would not mount.
257 */
258 private const ALWAYS_EXCLUDED_SRC = array(
259 '/plugins/xspeed/assets/',
260 '/wp-includes/js/dist/hooks',
261 '/wp-includes/js/dist/i18n',
262 );
263
264 /**
265 * Delay `<script src>` tags that never passed through wp_enqueue_script.
266 *
267 * `delay_script_tag()` hooks `script_loader_tag`, so it only ever sees
268 * enqueued scripts. Analytics, pixels, chat widgets and most third-party
269 * embeds are printed straight into `wp_head` / `wp_footer` as literal
270 * markup, bypassing that filter entirely — and those are exactly the
271 * scripts most worth delaying. On the site that surfaced this, 39
272 * enqueued scripts were correctly delayed while one un-enqueued
273 * analytics tag still downloaded 441 KB: 98% of the page's JS payload.
274 *
275 * Runs on the finished page buffer via `xspeed_cache_final_html`, so the
276 * rewrite is baked into the cached HTML and replays on every static hit
277 * (where PHP never boots). Deliberately conservative — it rewrites only
278 * `src`, leaves inline code to the enqueue path, and skips any tag whose
279 * `type` marks it as data rather than code.
280 *
281 * @param string $html Complete page HTML.
282 */
283 public static function delay_raw_script_tags( $html ): string {
284 if ( ! is_string( $html ) || '' === $html ) {
285 return (string) $html;
286 }
287 if ( self::skip_in_non_frontend_context() ) {
288 return $html;
289 }
290 $opts = self::opts();
291 if ( empty( $opts['delay_js'] ) ) {
292 return $html;
293 }
294
295 return (string) preg_replace_callback(
296 '#<script\b[^>]*>#i',
297 static function ( array $m ): string {
298 $tag = $m[0];
299
300 // Already handled by the enqueue-path filter.
301 if ( false !== stripos( $tag, 'data-xs-delay' ) || false !== stripos( $tag, 'data-xs-src' ) ) {
302 return $tag;
303 }
304
305 // No src → inline code. The enqueue path owns those; a
306 // buffer rewrite here would have to reason about execution
307 // order it cannot see.
308 // `(?<![-\w])` not `\b` — see the note on the enqueue-path
309 // rewrite above. With `\b`, a tag whose ONLY url lives in
310 // `data-cmplz-src` (a consent-blocked script, no real src at
311 // all) read as an external script here, and the rewrite
312 // below then mangled that attribute. (#273)
313 if ( ! preg_match( '#(?<![-\w])src\s*=\s*(["\'])(.*?)\1#is', $tag, $src_m ) ) {
314 return $tag;
315 }
316 $src = $src_m[2];
317
318 // Data, not code.
319 if ( preg_match( '#\btype\s*=\s*(["\'])(.*?)\1#is', $tag, $type_m ) ) {
320 $type = strtolower( trim( $type_m[2] ) );
321 if ( in_array( $type, self::NON_EXECUTABLE_TYPES, true ) ) {
322 return $tag;
323 }
324 }
325
326 foreach ( self::ALWAYS_EXCLUDED_SRC as $needle ) {
327 if ( false !== stripos( $src, $needle ) ) {
328 return $tag;
329 }
330 }
331
332 // Buffer-pass tags have no handle — match on URL only.
333 if ( self::is_excluded_script( '', $src ) ) {
334 return $tag;
335 }
336 if ( ! self::is_delay_target( '', $src ) ) {
337 return $tag;
338 }
339
340 return (string) preg_replace(
341 '#(?<![-\w])src\s*=\s*(["\'][^"\']*["\'])#i',
342 'data-xs-src=$1 data-xs-delay="1"',
343 $tag,
344 1
345 );
346 },
347 $html
348 );
349 }
350
351 /**
352 * Inline bootstrap that flips delayed scripts on the first user
353 * interaction. Printed once on wp_footer priority 1000.
354 */
355 public static function print_delay_bootstrap(): void {
356 if ( self::skip_in_non_frontend_context() ) {
357 return;
358 }
359 if ( self::$delay_bootstrap_printed ) {
360 return;
361 }
362 self::$delay_bootstrap_printed = true;
363
364 // Failsafe timer for visitors who never interact. 0 disables it
365 // entirely (interaction-only), which is what lab tools measure
366 // best: a timer that fires inside Lighthouse's / GTmetrix's
367 // measurement window loads the "delayed" scripts anyway and
368 // inflates the reported TTI, so the delay looks ineffective.
369 $opts = self::opts();
370 $timeout = isset( $opts['delay_js_timeout'] ) ? (int) $opts['delay_js_timeout'] : 8000;
371 $timeout = max( 0, min( 60000, $timeout ) );
372
373 // Tiny vanilla bootstrap; keep it self-contained so the page
374 // has no JS dependencies before the first interaction.
375 ?>
376 <script id="xspeed-delay-bootstrap">
377 (function(){
378 var events=['mousemove','keydown','touchstart','scroll','wheel'];
379 var fired=false;
380 function load(){
381 if(fired)return;fired=true;
382 events.forEach(function(e){window.removeEventListener(e,load,{passive:true,capture:true});});
383 var delayed=document.querySelectorAll('script[data-xs-delay]');
384 delayed.forEach(function(s){
385 var n=document.createElement('script');
386 Array.prototype.slice.call(s.attributes).forEach(function(a){
387 if(a.name==='data-xs-src'){n.setAttribute('src',a.value);return;}
388 if(a.name==='data-xs-delay')return;
389 // `type` is what a script IS, not decoration, so it is carried over
390 // — with ONE exception: our own inline parking marker, which exists
391 // only to stop the browser executing the original and must not be
392 // copied onto the replacement. Dropping type wholesale broke two
393 // things: `type="module"` became a classic script (core's Script
394 // Modules — Navigation, lightbox, Query Loop — threw "Cannot use
395 // import statement outside a module" on the default theme), and
396 // `type="text/plain"`, which is precisely how a consent manager
397 // parks a blocked third-party script, became executable again. The
398 // second is a privacy failure, not a broken feature. (#274)
399 if(a.name==='type'&&a.value==='text/xspeed-delayed')return;
400 n.setAttribute(a.name,a.value);
401 });
402 if(!s.hasAttribute('data-xs-src')){n.text=s.text;}
403 s.parentNode.replaceChild(n,s);
404 });
405 }
406 events.forEach(function(e){window.addEventListener(e,load,{passive:true,capture:true});});
407 <?php if ( $timeout > 0 ) : ?>
408 setTimeout(load,<?php echo (int) $timeout; ?>);
409 <?php endif; ?>
410 })();
411 </script>
412 <?php
413 }
414
415 /**
416 * Filter: `style_loader_tag` — wrap stylesheets in the
417 * print → onload="all" pattern so they download non-blocking.
418 * Pairs with critical CSS workflows. Adds a <noscript> fallback so
419 * users with JS disabled still get styles applied (via media="all").
420 *
421 * @param string $tag
422 * @param string $handle
423 */
424 public static function async_style_tag( $tag, $handle ): string {
425 if ( ! is_string( $tag ) || '' === $tag ) {
426 return (string) $tag;
427 }
428 if ( self::skip_in_non_frontend_context() ) {
429 return $tag;
430 }
431 // Only operate on <link rel=stylesheet> with a media attribute
432 // we can swap. Skip anything custom (preload, etc.) — we don't
433 // want to fight with explicit author intent.
434 if ( false === stripos( $tag, 'rel=\'stylesheet\'' ) && false === stripos( $tag, 'rel="stylesheet"' ) ) {
435 return $tag;
436 }
437 // The stylesheets that lay the page out stay render-blocking.
438 //
439 // This transform moves a sheet to AFTER first paint. That is the
440 // point of it — but a sheet the layout depends on is then missing
441 // from the only paint the visitor sees, and the page renders as
442 // unstyled HTML (bulleted nav, underlined links) until the swap
443 // runs. The pattern is only safe when something already styles the
444 // above-the-fold area, i.e. critical CSS — which Free does not
445 // generate. Deferring EVERY sheet on a site without it guarantees
446 // the flash rather than risking it: on the reported Kadence site
447 // all 17 stylesheets were deferred and none was render-blocking,
448 // so there was nothing left to paint the page with. (#269)
449 if ( self::is_layout_critical_style( $handle ) ) {
450 return $tag;
451 }
452 // A JS-measured layout on this page makes deferral unsafe for EVERY
453 // sheet, not just the theme's.
454 //
455 // Masonry, isotope, packery and the slider libraries lay elements out
456 // by MEASURING them and then writing absolute positions. Deferring the
457 // stylesheet that sizes those elements means the script measures them
458 // unstyled — zero or full-width — computes positions from those wrong
459 // numbers, and commits them. The CSS arriving a moment later cannot
460 // undo it: the script has already run and does not re-measure. The
461 // result is a permanently broken grid (items overlapping, or stranded
462 // with a large gap), which is worse than the flash this feature's
463 // other guard prevents, because it never resolves itself.
464 //
465 // This is checked per PAGE rather than per handle deliberately. The
466 // script that measures is rarely the one whose handle matches the
467 // sheet — Kadence's gallery is styled by
468 // `kadence-blocks-advancedgallery` but laid out by core's `masonry` —
469 // so pairing handles misses it. Whether a measuring library is present
470 // at all is the signal that generalises. (#269)
471 if ( self::page_has_js_measured_layout() ) {
472 return $tag;
473 }
474 // Avoid double-wrapping.
475 if ( false !== stripos( $tag, 'data-xs-async' ) ) {
476 return $tag;
477 }
478 // Someone else already made this sheet non-render-blocking.
479 //
480 // Plugins that ship their own async-CSS handling apply the same
481 // media="print" + onload swap we do, and they run on the SAME
482 // filter — SureCookie's consent banner does it at style_loader_tag
483 // priority 10, ours is priority 20, so its finished tag arrives
484 // here looking like a plain stylesheet with no marker of ours.
485 //
486 // Transforming it again breaks the sheet two ways: the media we'd
487 // capture as "the original to restore" is already `print`, so we
488 // emit onload="this.media='print'" — a swap to itself that never
489 // activates the stylesheet — and we append a SECOND onload
490 // attribute, of which the parser honours only the first (ours),
491 // discarding the plugin's correct this.media='all'. The banner
492 // then mounts unstyled, in both logged-in and logged-out states.
493 //
494 // An onload handler or a print media on a stylesheet link is only
495 // ever this pattern; a genuinely print-only sheet is already off
496 // the critical path and gains nothing from us. Either way the
497 // right move is to leave the tag alone — the same "don't fight
498 // explicit author intent" rule the rel= check above applies. (#216)
499 if ( preg_match( '#\bonload\s*=#i', $tag ) ) {
500 return $tag;
501 }
502 if ( preg_match( '#\bmedia\s*=\s*(["\'])\s*print\s*\1#i', $tag ) ) {
503 return $tag;
504 }
505 $async = (string) preg_replace_callback(
506 '#\bmedia\s*=\s*(["\'])([^"\']*)\1#i',
507 static function ( $m ) {
508 $orig = $m[2];
509 return 'media="print" onload="this.media=\'' . esc_attr( $orig ) . '\'" data-xs-async="' . esc_attr( $orig ) . '"';
510 },
511 $tag,
512 1
513 );
514 // If no media= was present (rare), inject one.
515 if ( $async === $tag ) {
516 $async = (string) preg_replace(
517 '#<link\b#i',
518 '<link media="print" onload="this.media=\'all\'" data-xs-async="all"',
519 $tag,
520 1
521 );
522 }
523 // Fallback for noscript users — re-emit the original tag inside <noscript>.
524 return $async . '<noscript>' . $tag . '</noscript>';
525 }
526
527 /**
528 * Whether a stylesheet handle carries the page's layout, and so must
529 * keep blocking the first paint.
530 *
531 * Two families qualify:
532 *
533 * - The ACTIVE THEME's own sheets. A theme stylesheet is the page's
534 * layout by definition; without it the document paints as unstyled
535 * HTML. Resolved from the live theme's stem (`kadence` →
536 * `kadence-global`, `kadence-header`, …) plus the handles WordPress
537 * itself registers for a theme, so this holds for any theme rather
538 * than a hard-coded list.
539 * - WordPress' own BLOCK and layout sheets (`wp-block-library`,
540 * `global-styles`, `classic-theme-styles`). These style block
541 * content on the front end and are as structural as the theme's.
542 *
543 * Everything else — plugin sheets, icon fonts, widget and page-builder
544 * add-ons, the long tail that makes async CSS worth having — is still
545 * deferred, so the optimization keeps most of its benefit.
546 *
547 * A site WITH critical CSS can defer these too; that is what the
548 * `xspeed_async_css_layout_critical` filter is for.
549 *
550 * Pure aside from the theme lookup — unit-tested via the filter.
551 *
552 * @param string $handle Stylesheet handle from `style_loader_tag`.
553 */
554 public static function is_layout_critical_style( string $handle ): bool {
555 $handle = strtolower( $handle );
556
557 // Core's front-end block + global styles.
558 $core = array(
559 'wp-block-library',
560 'wp-block-library-theme',
561 'global-styles',
562 'classic-theme-styles',
563 );
564 $critical = in_array( $handle, $core, true );
565
566 // The active theme's own sheets.
567 //
568 // Matched on the theme stem, but NOT as a bare prefix: a plugin from
569 // the same vendor shares it (the Kadence theme is `kadence`, while
570 // `kadence-blocks-rowlayout` and `kadence-fonts-gfonts` come from the
571 // Kadence Blocks PLUGIN and a webfont loader). Treating those as
572 // layout-critical would leave almost nothing deferred and quietly
573 // undo the feature. So the stem must be followed by a recognised
574 // theme-area segment, which is how themes name their split sheets.
575 if ( ! $critical && function_exists( 'get_template' ) ) {
576 $areas = array(
577 'style',
578 'global',
579 'header',
580 'content',
581 'footer',
582 'main',
583 'layout',
584 'base',
585 'core',
586 'theme',
587 'woocommerce',
588 );
589 foreach ( array( get_template(), get_stylesheet() ) as $stem ) {
590 $stem = strtolower( (string) $stem );
591 if ( '' === $stem ) {
592 continue;
593 }
594 if ( $handle === $stem ) {
595 $critical = true;
596 break;
597 }
598 foreach ( $areas as $area ) {
599 if ( $handle === $stem . '-' . $area ) {
600 $critical = true;
601 break 2;
602 }
603 }
604 }
605 }
606
607 /**
608 * Whether this stylesheet must keep blocking the first paint.
609 *
610 * Return false for a handle to let async CSS defer it anyway — the
611 * right call on a site that ships critical CSS. Return true to
612 * protect an additional sheet the layout depends on.
613 *
614 * @param bool $critical Whether the sheet is treated as layout-critical.
615 * @param string $handle The stylesheet handle.
616 */
617 return (bool) apply_filters( 'xspeed_async_css_layout_critical', $critical, $handle );
618 }
619
620 /**
621 * Filter: `style_loader_src` + `script_loader_src` — strip the
622 * ?ver=X.Y query string that WP appends for cache busting. Some
623 * CDNs / reverse proxies cache better when the URL has no query.
624 *
625 * Skip URLs whose query carries non-ver params — those might be
626 * intentional (e.g. a CDN providing per-image transforms).
627 *
628 * @param string $src
629 */
630 public static function strip_version_query( $src ): string {
631 if ( ! is_string( $src ) || '' === $src ) {
632 return (string) $src;
633 }
634 if ( self::skip_in_non_frontend_context() ) {
635 return $src;
636 }
637 $parts = wp_parse_url( $src );
638 if ( ! is_array( $parts ) || empty( $parts['query'] ) ) {
639 return $src;
640 }
641 parse_str( $parts['query'], $query );
642 if ( ! is_array( $query ) ) {
643 return $src;
644 }
645 // Only strip 'ver' — keep anything else the asset URL needs.
646 unset( $query['ver'] );
647 $new_query = http_build_query( $query );
648 $new_url = ( $parts['scheme'] ?? 'http' ) . '://' . ( $parts['host'] ?? '' );
649 if ( isset( $parts['port'] ) ) {
650 $new_url .= ':' . $parts['port'];
651 }
652 $new_url .= $parts['path'] ?? '';
653 if ( '' !== $new_query ) {
654 $new_url .= '?' . $new_query;
655 }
656 if ( ! empty( $parts['fragment'] ) ) {
657 $new_url .= '#' . $parts['fragment'];
658 }
659 return $new_url;
660 }
661
662 /**
663 * Defensive context guard for filter callbacks. Mirrors the registration-
664 * time bail in Minifier::__construct() so a late context flip (admin page
665 * render kicked off mid-request, REST_REQUEST set after plugins_loaded,
666 * etc.) doesn't let frontend tag rewrites leak into wp-admin / AJAX /
667 * REST / cron responses.
668 *
669 * Specifically prevents the React admin bundle's <script> tag from being
670 * deferred or src-swapped to data-xs-src — which would stop the dashboard
671 * from booting and make toggles appear unchecked until first interaction.
672 */
673 private static function skip_in_non_frontend_context(): bool {
674 if ( is_admin() ) {
675 return true;
676 }
677 if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
678 return true;
679 }
680 if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
681 return true;
682 }
683 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
684 return true;
685 }
686 return false;
687 }
688
689 /**
690 * Built-in exclusion list — always skipped regardless of user settings.
691 * Covers our own admin bundle and the WP script-modules it depends on,
692 * so that even if the registration-time admin guard is somehow bypassed,
693 * the dashboard's React app can still boot.
694 */
695 private const ALWAYS_EXCLUDED_HANDLES = array(
696 'xspeed-admin',
697 'wp-hooks',
698 'wp-i18n',
699 'wp-url',
700 'wp-api-fetch',
701 );
702
703 private static function is_excluded_script( string $handle, string $src ): bool {
704 if ( in_array( $handle, self::ALWAYS_EXCLUDED_HANDLES, true ) ) {
705 return true;
706 }
707 $opts = self::opts();
708 $excluded = is_array( $opts['defer_js_excluded'] ?? null ) ? $opts['defer_js_excluded'] : array();
709 if ( empty( $excluded ) ) {
710 return false;
711 }
712 foreach ( $excluded as $needle ) {
713 // Matched against the pre-minify URL too: an exclusion that
714 // stops matching is worse than a delay target that does — the
715 // script the user explicitly protected gets deferred anyway.
716 if ( self::target_matches( (string) $needle, $handle, $src ) ) {
717 return true;
718 }
719 }
720 return false;
721 }
722
723 /**
724 * Include-list targeting for delay (issue #36): when delay_js_targets
725 * is non-empty, ONLY matching scripts are delayed — a heavy
726 * third-party embed can be postponed without delaying the whole
727 * page's JS. Empty targets = historical behavior (delay everything
728 * minus exclusions). Same matching semantics as the exclusion list:
729 * exact handle match OR case-insensitive URL substring.
730 */
731 private static function is_delay_target( string $handle, string $src ): bool {
732 $opts = self::opts();
733 $targets = is_array( $opts['delay_js_targets'] ?? null ) ? $opts['delay_js_targets'] : array();
734 $targets = array_filter( array_map( 'strval', $targets ), static fn( $t ) => '' !== $t );
735 if ( empty( $targets ) ) {
736 return true;
737 }
738 foreach ( $targets as $needle ) {
739 if ( self::target_matches( $needle, $handle, $src ) ) {
740 return true;
741 }
742 }
743 return false;
744 }
745
746 private static function opts(): array {
747 if ( null === self::$opts ) {
748 self::$opts = Settings_Manager::get( 'minify' );
749 }
750 return self::$opts;
751 }
752
753 /**
754 * Test-only — clear cached opts + bootstrap-printed flag.
755 */
756 public static function reset_state(): void {
757 self::$opts = null;
758 self::$delay_bootstrap_printed = false;
759 self::$js_measured_layout = null;
760 }
761
762 /**
763 * Per-request memo for page_has_js_measured_layout(). Null = not resolved.
764 *
765 * @var bool|null
766 */
767 private static $js_measured_layout = null;
768
769 /**
770 * Scripts that lay out the page by measuring the DOM.
771 *
772 * Each of these reads element sizes and then writes positions. If the CSS
773 * that sizes those elements has not applied when the script runs, it
774 * measures the wrong values and commits a broken layout that no later
775 * stylesheet can correct.
776 *
777 * Matched as a substring of the registered handle, so a plugin shipping
778 * `acme-masonry` or `masonry-init` is covered without naming it here.
779 *
780 * @return string[]
781 */
782 private static function js_layout_script_markers(): array {
783 return array(
784 'masonry',
785 'isotope',
786 'packery',
787 'salvattore',
788 'justified-gallery',
789 'slick',
790 'splide',
791 'swiper',
792 'flickity',
793 'owl-carousel',
794 'matchheight',
795 );
796 }
797
798 /**
799 * True when a script that measures the DOM to build a layout is enqueued
800 * for this request.
801 *
802 * Reads the enqueue registry rather than the finished HTML, because this
803 * runs on `style_loader_tag` — while the head is being printed, before any
804 * body markup exists to scan. Both the queue and each queued handle's
805 * dependencies are checked: core registers `masonry` as a DEPENDENCY of a
806 * plugin's init script, so it is frequently absent from the queue itself.
807 *
808 * Pure aside from the global registry read; the result is memoised per
809 * request and cleared by reset_state().
810 */
811 public static function page_has_js_measured_layout(): bool {
812 if ( null !== self::$js_measured_layout ) {
813 return self::$js_measured_layout;
814 }
815
816 $found = false;
817 if ( function_exists( 'wp_scripts' ) ) {
818 $scripts = wp_scripts();
819 if ( $scripts instanceof \WP_Scripts ) {
820 $handles = (array) $scripts->queue;
821 // Pull in dependencies — `masonry` usually arrives that way.
822 foreach ( (array) $scripts->queue as $queued ) {
823 if ( isset( $scripts->registered[ $queued ]->deps ) ) {
824 $handles = array_merge( $handles, (array) $scripts->registered[ $queued ]->deps );
825 }
826 }
827 $markers = self::js_layout_script_markers();
828 foreach ( $handles as $handle ) {
829 $handle = strtolower( (string) $handle );
830 foreach ( $markers as $marker ) {
831 if ( false !== strpos( $handle, $marker ) ) {
832 $found = true;
833 break 2;
834 }
835 }
836 }
837 }
838 }
839
840 /**
841 * Whether this request renders a JS-measured layout, making async CSS
842 * unsafe for the whole page.
843 *
844 * Return false to defer anyway (a site that ships critical CSS, or one
845 * whose grid is pure CSS), or true to protect a library not detected
846 * by handle.
847 *
848 * @param bool $found Whether a measuring script was detected.
849 */
850 self::$js_measured_layout = (bool) apply_filters( 'xspeed_async_css_js_measured_layout', $found );
851
852 return self::$js_measured_layout;
853 }
854 }
855