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

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