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

545 lines 17.4 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 $async = (string) preg_replace_callback(
383 '#\bmedia\s*=\s*(["\'])([^"\']*)\1#i',
384 static function ( $m ) {
385 $orig = $m[2];
386 return 'media="print" onload="this.media=\'' . esc_attr( $orig ) . '\'" data-xs-async="' . esc_attr( $orig ) . '"';
387 },
388 $tag,
389 1
390 );
391 // If no media= was present (rare), inject one.
392 if ( $async === $tag ) {
393 $async = (string) preg_replace(
394 '#<link\b#i',
395 '<link media="print" onload="this.media=\'all\'" data-xs-async="all"',
396 $tag,
397 1
398 );
399 }
400 // Fallback for noscript users — re-emit the original tag inside <noscript>.
401 return $async . '<noscript>' . $tag . '</noscript>';
402 }
403
404 /**
405 * Filter: `style_loader_src` + `script_loader_src` — strip the
406 * ?ver=X.Y query string that WP appends for cache busting. Some
407 * CDNs / reverse proxies cache better when the URL has no query.
408 *
409 * Skip URLs whose query carries non-ver params — those might be
410 * intentional (e.g. a CDN providing per-image transforms).
411 *
412 * @param string $src
413 */
414 public static function strip_version_query( $src ): string {
415 if ( ! is_string( $src ) || '' === $src ) {
416 return (string) $src;
417 }
418 if ( self::skip_in_non_frontend_context() ) {
419 return $src;
420 }
421 $parts = wp_parse_url( $src );
422 if ( ! is_array( $parts ) || empty( $parts['query'] ) ) {
423 return $src;
424 }
425 parse_str( $parts['query'], $query );
426 if ( ! is_array( $query ) ) {
427 return $src;
428 }
429 // Only strip 'ver' — keep anything else the asset URL needs.
430 unset( $query['ver'] );
431 $new_query = http_build_query( $query );
432 $new_url = ( $parts['scheme'] ?? 'http' ) . '://' . ( $parts['host'] ?? '' );
433 if ( isset( $parts['port'] ) ) {
434 $new_url .= ':' . $parts['port'];
435 }
436 $new_url .= $parts['path'] ?? '';
437 if ( '' !== $new_query ) {
438 $new_url .= '?' . $new_query;
439 }
440 if ( ! empty( $parts['fragment'] ) ) {
441 $new_url .= '#' . $parts['fragment'];
442 }
443 return $new_url;
444 }
445
446 /**
447 * Defensive context guard for filter callbacks. Mirrors the registration-
448 * time bail in Minifier::__construct() so a late context flip (admin page
449 * render kicked off mid-request, REST_REQUEST set after plugins_loaded,
450 * etc.) doesn't let frontend tag rewrites leak into wp-admin / AJAX /
451 * REST / cron responses.
452 *
453 * Specifically prevents the React admin bundle's <script> tag from being
454 * deferred or src-swapped to data-xs-src — which would stop the dashboard
455 * from booting and make toggles appear unchecked until first interaction.
456 */
457 private static function skip_in_non_frontend_context(): bool {
458 if ( is_admin() ) {
459 return true;
460 }
461 if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
462 return true;
463 }
464 if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
465 return true;
466 }
467 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
468 return true;
469 }
470 return false;
471 }
472
473 /**
474 * Built-in exclusion list — always skipped regardless of user settings.
475 * Covers our own admin bundle and the WP script-modules it depends on,
476 * so that even if the registration-time admin guard is somehow bypassed,
477 * the dashboard's React app can still boot.
478 */
479 private const ALWAYS_EXCLUDED_HANDLES = array(
480 'xspeed-admin',
481 'wp-hooks',
482 'wp-i18n',
483 'wp-url',
484 'wp-api-fetch',
485 );
486
487 private static function is_excluded_script( string $handle, string $src ): bool {
488 if ( in_array( $handle, self::ALWAYS_EXCLUDED_HANDLES, true ) ) {
489 return true;
490 }
491 $opts = self::opts();
492 $excluded = is_array( $opts['defer_js_excluded'] ?? null ) ? $opts['defer_js_excluded'] : array();
493 if ( empty( $excluded ) ) {
494 return false;
495 }
496 foreach ( $excluded as $needle ) {
497 // Matched against the pre-minify URL too: an exclusion that
498 // stops matching is worse than a delay target that does — the
499 // script the user explicitly protected gets deferred anyway.
500 if ( self::target_matches( (string) $needle, $handle, $src ) ) {
501 return true;
502 }
503 }
504 return false;
505 }
506
507 /**
508 * Include-list targeting for delay (issue #36): when delay_js_targets
509 * is non-empty, ONLY matching scripts are delayed — a heavy
510 * third-party embed can be postponed without delaying the whole
511 * page's JS. Empty targets = historical behavior (delay everything
512 * minus exclusions). Same matching semantics as the exclusion list:
513 * exact handle match OR case-insensitive URL substring.
514 */
515 private static function is_delay_target( string $handle, string $src ): bool {
516 $opts = self::opts();
517 $targets = is_array( $opts['delay_js_targets'] ?? null ) ? $opts['delay_js_targets'] : array();
518 $targets = array_filter( array_map( 'strval', $targets ), static fn( $t ) => '' !== $t );
519 if ( empty( $targets ) ) {
520 return true;
521 }
522 foreach ( $targets as $needle ) {
523 if ( self::target_matches( $needle, $handle, $src ) ) {
524 return true;
525 }
526 }
527 return false;
528 }
529
530 private static function opts(): array {
531 if ( null === self::$opts ) {
532 self::$opts = Settings_Manager::get( 'minify' );
533 }
534 return self::$opts;
535 }
536
537 /**
538 * Test-only — clear cached opts + bootstrap-printed flag.
539 */
540 public static function reset_state(): void {
541 self::$opts = null;
542 self::$delay_bootstrap_printed = false;
543 }
544 }
545