PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.8
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.8
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.1.8, at includes/class-minify-filters.php

592 lines 20.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 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 // src= variant: swap src → data-xs-src and add data-xs-delay marker.
185 if ( '' !== (string) $src ) {
186 // Anchor on the opening <script …> tag that carries the src.
187 // Matching a bare `src=` across the whole string would rewrite
188 // the first occurrence anywhere — including inside a `before`
189 // inline block, where JS like `el.src = "…"` becomes the
190 // syntax error `el.data-xs-src="…" data-xs-delay="1"` and the
191 // real external script is left undelayed. $tag is the
192 // concatenation of before_inline + external + after_inline,
193 // so that is a routine shape, not a corner case. (#234)
194 return (string) preg_replace(
195 '#(<script\b[^>]*?)\bsrc\s*=\s*(["\'][^"\']*["\'])#i',
196 '$1data-xs-src=$2 data-xs-delay="1"',
197 $tag,
198 1
199 );
200 }
201 // Inline script: change type to text/plain so the browser
202 // doesn't execute, mark for bootstrap rewriter.
203 return (string) preg_replace(
204 '#<script\b([^>]*)>#i',
205 '<script$1 type="text/xspeed-delayed" data-xs-delay="1">',
206 $tag,
207 1
208 );
209 }
210
211 /**
212 * Script types the buffer pass must never touch. `<script>` carries
213 * data as often as it carries code: JSON-LD feeds structured-data
214 * consumers, importmaps must resolve before any module runs, and our
215 * own delayed-inline marker is already handled by the bootstrap.
216 * Rewriting any of these breaks the page or its metadata.
217 */
218 private const NON_EXECUTABLE_TYPES = array(
219 'application/ld+json',
220 'application/json',
221 'importmap',
222 'speculationrules',
223 'text/template',
224 'text/x-template',
225 'text/xspeed-delayed',
226 );
227
228 /**
229 * URL fragments that must keep a live src no matter what. The enqueue
230 * path guards these by handle (ALWAYS_EXCLUDED_HANDLES), but a buffer
231 * pass only ever sees a URL, so the same protection is re-expressed
232 * here. Without this the admin bundle could be delayed on a frontend
233 * render and the dashboard would not mount.
234 */
235 private const ALWAYS_EXCLUDED_SRC = array(
236 '/plugins/xspeed/assets/',
237 '/wp-includes/js/dist/hooks',
238 '/wp-includes/js/dist/i18n',
239 );
240
241 /**
242 * Delay `<script src>` tags that never passed through wp_enqueue_script.
243 *
244 * `delay_script_tag()` hooks `script_loader_tag`, so it only ever sees
245 * enqueued scripts. Analytics, pixels, chat widgets and most third-party
246 * embeds are printed straight into `wp_head` / `wp_footer` as literal
247 * markup, bypassing that filter entirely — and those are exactly the
248 * scripts most worth delaying. On the site that surfaced this, 39
249 * enqueued scripts were correctly delayed while one un-enqueued
250 * analytics tag still downloaded 441 KB: 98% of the page's JS payload.
251 *
252 * Runs on the finished page buffer via `xspeed_cache_final_html`, so the
253 * rewrite is baked into the cached HTML and replays on every static hit
254 * (where PHP never boots). Deliberately conservative — it rewrites only
255 * `src`, leaves inline code to the enqueue path, and skips any tag whose
256 * `type` marks it as data rather than code.
257 *
258 * @param string $html Complete page HTML.
259 */
260 public static function delay_raw_script_tags( $html ): string {
261 if ( ! is_string( $html ) || '' === $html ) {
262 return (string) $html;
263 }
264 if ( self::skip_in_non_frontend_context() ) {
265 return $html;
266 }
267 $opts = self::opts();
268 if ( empty( $opts['delay_js'] ) ) {
269 return $html;
270 }
271
272 return (string) preg_replace_callback(
273 '#<script\b[^>]*>#i',
274 static function ( array $m ): string {
275 $tag = $m[0];
276
277 // Already handled by the enqueue-path filter.
278 if ( false !== stripos( $tag, 'data-xs-delay' ) || false !== stripos( $tag, 'data-xs-src' ) ) {
279 return $tag;
280 }
281
282 // No src → inline code. The enqueue path owns those; a
283 // buffer rewrite here would have to reason about execution
284 // order it cannot see.
285 if ( ! preg_match( '#\bsrc\s*=\s*(["\'])(.*?)\1#is', $tag, $src_m ) ) {
286 return $tag;
287 }
288 $src = $src_m[2];
289
290 // Data, not code.
291 if ( preg_match( '#\btype\s*=\s*(["\'])(.*?)\1#is', $tag, $type_m ) ) {
292 $type = strtolower( trim( $type_m[2] ) );
293 if ( in_array( $type, self::NON_EXECUTABLE_TYPES, true ) ) {
294 return $tag;
295 }
296 }
297
298 foreach ( self::ALWAYS_EXCLUDED_SRC as $needle ) {
299 if ( false !== stripos( $src, $needle ) ) {
300 return $tag;
301 }
302 }
303
304 // Buffer-pass tags have no handle — match on URL only.
305 if ( self::is_excluded_script( '', $src ) ) {
306 return $tag;
307 }
308 if ( ! self::is_delay_target( '', $src ) ) {
309 return $tag;
310 }
311
312 return (string) preg_replace(
313 '#\bsrc\s*=\s*(["\'][^"\']*["\'])#i',
314 'data-xs-src=$1 data-xs-delay="1"',
315 $tag,
316 1
317 );
318 },
319 $html
320 );
321 }
322
323 /**
324 * Inline bootstrap that flips delayed scripts on the first user
325 * interaction. Printed once on wp_footer priority 1000.
326 */
327 public static function print_delay_bootstrap(): void {
328 if ( self::skip_in_non_frontend_context() ) {
329 return;
330 }
331 if ( self::$delay_bootstrap_printed ) {
332 return;
333 }
334 self::$delay_bootstrap_printed = true;
335
336 // Failsafe timer for visitors who never interact. 0 disables it
337 // entirely (interaction-only), which is what lab tools measure
338 // best: a timer that fires inside Lighthouse's / GTmetrix's
339 // measurement window loads the "delayed" scripts anyway and
340 // inflates the reported TTI, so the delay looks ineffective.
341 $opts = self::opts();
342 $timeout = isset( $opts['delay_js_timeout'] ) ? (int) $opts['delay_js_timeout'] : 8000;
343 $timeout = max( 0, min( 60000, $timeout ) );
344
345 // Tiny vanilla bootstrap; keep it self-contained so the page
346 // has no JS dependencies before the first interaction.
347 ?>
348 <script id="xspeed-delay-bootstrap">
349 (function(){
350 var events=['mousemove','keydown','touchstart','scroll','wheel'];
351 var fired=false;
352 function load(){
353 if(fired)return;fired=true;
354 events.forEach(function(e){window.removeEventListener(e,load,{passive:true,capture:true});});
355 var delayed=document.querySelectorAll('script[data-xs-delay]');
356 delayed.forEach(function(s){
357 var n=document.createElement('script');
358 Array.prototype.slice.call(s.attributes).forEach(function(a){
359 if(a.name==='data-xs-src'){n.setAttribute('src',a.value);return;}
360 if(a.name==='data-xs-delay'||a.name==='type')return;
361 n.setAttribute(a.name,a.value);
362 });
363 if(!s.hasAttribute('data-xs-src')){n.text=s.text;}
364 s.parentNode.replaceChild(n,s);
365 });
366 }
367 events.forEach(function(e){window.addEventListener(e,load,{passive:true,capture:true});});
368 <?php if ( $timeout > 0 ) : ?>
369 setTimeout(load,<?php echo (int) $timeout; ?>);
370 <?php endif; ?>
371 })();
372 </script>
373 <?php
374 }
375
376 /**
377 * Filter: `style_loader_tag` — wrap stylesheets in the
378 * print → onload="all" pattern so they download non-blocking.
379 * Pairs with critical CSS workflows. Adds a <noscript> fallback so
380 * users with JS disabled still get styles applied (via media="all").
381 *
382 * @param string $tag
383 * @param string $handle
384 */
385 public static function async_style_tag( $tag, $handle ): string {
386 if ( ! is_string( $tag ) || '' === $tag ) {
387 return (string) $tag;
388 }
389 if ( self::skip_in_non_frontend_context() ) {
390 return $tag;
391 }
392 // Only operate on <link rel=stylesheet> with a media attribute
393 // we can swap. Skip anything custom (preload, etc.) — we don't
394 // want to fight with explicit author intent.
395 if ( false === stripos( $tag, 'rel=\'stylesheet\'' ) && false === stripos( $tag, 'rel="stylesheet"' ) ) {
396 return $tag;
397 }
398 // Avoid double-wrapping.
399 if ( false !== stripos( $tag, 'data-xs-async' ) ) {
400 return $tag;
401 }
402 // Someone else already made this sheet non-render-blocking.
403 //
404 // Plugins that ship their own async-CSS handling apply the same
405 // media="print" + onload swap we do, and they run on the SAME
406 // filter — SureCookie's consent banner does it at style_loader_tag
407 // priority 10, ours is priority 20, so its finished tag arrives
408 // here looking like a plain stylesheet with no marker of ours.
409 //
410 // Transforming it again breaks the sheet two ways: the media we'd
411 // capture as "the original to restore" is already `print`, so we
412 // emit onload="this.media='print'" — a swap to itself that never
413 // activates the stylesheet — and we append a SECOND onload
414 // attribute, of which the parser honours only the first (ours),
415 // discarding the plugin's correct this.media='all'. The banner
416 // then mounts unstyled, in both logged-in and logged-out states.
417 //
418 // An onload handler or a print media on a stylesheet link is only
419 // ever this pattern; a genuinely print-only sheet is already off
420 // the critical path and gains nothing from us. Either way the
421 // right move is to leave the tag alone — the same "don't fight
422 // explicit author intent" rule the rel= check above applies. (#216)
423 if ( preg_match( '#\bonload\s*=#i', $tag ) ) {
424 return $tag;
425 }
426 if ( preg_match( '#\bmedia\s*=\s*(["\'])\s*print\s*\1#i', $tag ) ) {
427 return $tag;
428 }
429 $async = (string) preg_replace_callback(
430 '#\bmedia\s*=\s*(["\'])([^"\']*)\1#i',
431 static function ( $m ) {
432 $orig = $m[2];
433 return 'media="print" onload="this.media=\'' . esc_attr( $orig ) . '\'" data-xs-async="' . esc_attr( $orig ) . '"';
434 },
435 $tag,
436 1
437 );
438 // If no media= was present (rare), inject one.
439 if ( $async === $tag ) {
440 $async = (string) preg_replace(
441 '#<link\b#i',
442 '<link media="print" onload="this.media=\'all\'" data-xs-async="all"',
443 $tag,
444 1
445 );
446 }
447 // Fallback for noscript users — re-emit the original tag inside <noscript>.
448 return $async . '<noscript>' . $tag . '</noscript>';
449 }
450
451 /**
452 * Filter: `style_loader_src` + `script_loader_src` — strip the
453 * ?ver=X.Y query string that WP appends for cache busting. Some
454 * CDNs / reverse proxies cache better when the URL has no query.
455 *
456 * Skip URLs whose query carries non-ver params — those might be
457 * intentional (e.g. a CDN providing per-image transforms).
458 *
459 * @param string $src
460 */
461 public static function strip_version_query( $src ): string {
462 if ( ! is_string( $src ) || '' === $src ) {
463 return (string) $src;
464 }
465 if ( self::skip_in_non_frontend_context() ) {
466 return $src;
467 }
468 $parts = wp_parse_url( $src );
469 if ( ! is_array( $parts ) || empty( $parts['query'] ) ) {
470 return $src;
471 }
472 parse_str( $parts['query'], $query );
473 if ( ! is_array( $query ) ) {
474 return $src;
475 }
476 // Only strip 'ver' — keep anything else the asset URL needs.
477 unset( $query['ver'] );
478 $new_query = http_build_query( $query );
479 $new_url = ( $parts['scheme'] ?? 'http' ) . '://' . ( $parts['host'] ?? '' );
480 if ( isset( $parts['port'] ) ) {
481 $new_url .= ':' . $parts['port'];
482 }
483 $new_url .= $parts['path'] ?? '';
484 if ( '' !== $new_query ) {
485 $new_url .= '?' . $new_query;
486 }
487 if ( ! empty( $parts['fragment'] ) ) {
488 $new_url .= '#' . $parts['fragment'];
489 }
490 return $new_url;
491 }
492
493 /**
494 * Defensive context guard for filter callbacks. Mirrors the registration-
495 * time bail in Minifier::__construct() so a late context flip (admin page
496 * render kicked off mid-request, REST_REQUEST set after plugins_loaded,
497 * etc.) doesn't let frontend tag rewrites leak into wp-admin / AJAX /
498 * REST / cron responses.
499 *
500 * Specifically prevents the React admin bundle's <script> tag from being
501 * deferred or src-swapped to data-xs-src — which would stop the dashboard
502 * from booting and make toggles appear unchecked until first interaction.
503 */
504 private static function skip_in_non_frontend_context(): bool {
505 if ( is_admin() ) {
506 return true;
507 }
508 if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
509 return true;
510 }
511 if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
512 return true;
513 }
514 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
515 return true;
516 }
517 return false;
518 }
519
520 /**
521 * Built-in exclusion list — always skipped regardless of user settings.
522 * Covers our own admin bundle and the WP script-modules it depends on,
523 * so that even if the registration-time admin guard is somehow bypassed,
524 * the dashboard's React app can still boot.
525 */
526 private const ALWAYS_EXCLUDED_HANDLES = array(
527 'xspeed-admin',
528 'wp-hooks',
529 'wp-i18n',
530 'wp-url',
531 'wp-api-fetch',
532 );
533
534 private static function is_excluded_script( string $handle, string $src ): bool {
535 if ( in_array( $handle, self::ALWAYS_EXCLUDED_HANDLES, true ) ) {
536 return true;
537 }
538 $opts = self::opts();
539 $excluded = is_array( $opts['defer_js_excluded'] ?? null ) ? $opts['defer_js_excluded'] : array();
540 if ( empty( $excluded ) ) {
541 return false;
542 }
543 foreach ( $excluded as $needle ) {
544 // Matched against the pre-minify URL too: an exclusion that
545 // stops matching is worse than a delay target that does — the
546 // script the user explicitly protected gets deferred anyway.
547 if ( self::target_matches( (string) $needle, $handle, $src ) ) {
548 return true;
549 }
550 }
551 return false;
552 }
553
554 /**
555 * Include-list targeting for delay (issue #36): when delay_js_targets
556 * is non-empty, ONLY matching scripts are delayed — a heavy
557 * third-party embed can be postponed without delaying the whole
558 * page's JS. Empty targets = historical behavior (delay everything
559 * minus exclusions). Same matching semantics as the exclusion list:
560 * exact handle match OR case-insensitive URL substring.
561 */
562 private static function is_delay_target( string $handle, string $src ): bool {
563 $opts = self::opts();
564 $targets = is_array( $opts['delay_js_targets'] ?? null ) ? $opts['delay_js_targets'] : array();
565 $targets = array_filter( array_map( 'strval', $targets ), static fn( $t ) => '' !== $t );
566 if ( empty( $targets ) ) {
567 return true;
568 }
569 foreach ( $targets as $needle ) {
570 if ( self::target_matches( $needle, $handle, $src ) ) {
571 return true;
572 }
573 }
574 return false;
575 }
576
577 private static function opts(): array {
578 if ( null === self::$opts ) {
579 self::$opts = Settings_Manager::get( 'minify' );
580 }
581 return self::$opts;
582 }
583
584 /**
585 * Test-only — clear cached opts + bootstrap-printed flag.
586 */
587 public static function reset_state(): void {
588 self::$opts = null;
589 self::$delay_bootstrap_printed = false;
590 }
591 }
592