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

350 lines 10.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 * Filter: `script_loader_tag` — add defer="defer" to non-excluded
41 * scripts. WordPress passes the full <script> tag string, the
42 * handle, and the src. We bail when:
43 * - the user excluded this handle / src substring,
44 * - the tag already has defer or async (don't double-set),
45 * - the tag has no src (inline scripts can't be deferred — would
46 * execute synchronously regardless).
47 *
48 * @param string $tag
49 * @param string $handle
50 * @param string $src
51 */
52 public static function defer_script_tag( $tag, $handle, $src ): string {
53 if ( ! is_string( $tag ) || '' === $tag ) {
54 return (string) $tag;
55 }
56 // Self-guard: even though Minifier::__construct() bails on admin/
57 // AJAX/REST/cron at registration, a late context switch (e.g. a
58 // custom wp_print_scripts() call inside an admin page render) can
59 // leave the filter attached. Skipping here keeps the React admin
60 // bundle's <script> tag intact so the dashboard mounts.
61 if ( self::skip_in_non_frontend_context() ) {
62 return $tag;
63 }
64 if ( '' === (string) $src ) {
65 return $tag;
66 }
67 if ( self::is_excluded_script( (string) $handle, (string) $src ) ) {
68 return $tag;
69 }
70 if ( false !== stripos( $tag, ' defer' ) || false !== stripos( $tag, ' async' ) ) {
71 return $tag;
72 }
73 return (string) preg_replace( '#<script\b#i', '<script defer="defer"', $tag, 1 );
74 }
75
76 /**
77 * Filter: `script_loader_tag` — rewrite src= to data-xs-src= so the
78 * browser ignores it until the bootstrap (printed once on
79 * wp_footer) swaps it back on first user interaction. Same
80 * exclusion rules as defer. Inline scripts (no src) are also
81 * deferred until the first interaction.
82 *
83 * @param string $tag
84 * @param string $handle
85 * @param string $src
86 */
87 public static function delay_script_tag( $tag, $handle, $src ): string {
88 if ( ! is_string( $tag ) || '' === $tag ) {
89 return (string) $tag;
90 }
91 if ( self::skip_in_non_frontend_context() ) {
92 return $tag;
93 }
94 if ( self::is_excluded_script( (string) $handle, (string) $src ) ) {
95 return $tag;
96 }
97 if ( ! self::is_delay_target( (string) $handle, (string) $src ) ) {
98 return $tag;
99 }
100 // src= variant: swap src → data-xs-src and add data-xs-delay marker.
101 if ( '' !== (string) $src ) {
102 return (string) preg_replace(
103 '#\bsrc\s*=\s*(["\'][^"\']*["\'])#i',
104 'data-xs-src=$1 data-xs-delay="1"',
105 $tag,
106 1
107 );
108 }
109 // Inline script: change type to text/plain so the browser
110 // doesn't execute, mark for bootstrap rewriter.
111 return (string) preg_replace(
112 '#<script\b([^>]*)>#i',
113 '<script$1 type="text/xspeed-delayed" data-xs-delay="1">',
114 $tag,
115 1
116 );
117 }
118
119 /**
120 * Inline bootstrap that flips delayed scripts on the first user
121 * interaction. Printed once on wp_footer priority 1000.
122 */
123 public static function print_delay_bootstrap(): void {
124 if ( self::skip_in_non_frontend_context() ) {
125 return;
126 }
127 if ( self::$delay_bootstrap_printed ) {
128 return;
129 }
130 self::$delay_bootstrap_printed = true;
131 // Tiny vanilla bootstrap; keep it self-contained so the page
132 // has no JS dependencies before the first interaction.
133 ?>
134 <script id="xspeed-delay-bootstrap">
135 (function(){
136 var events=['mousemove','keydown','touchstart','scroll','wheel'];
137 var fired=false;
138 function load(){
139 if(fired)return;fired=true;
140 events.forEach(function(e){window.removeEventListener(e,load,{passive:true,capture:true});});
141 var delayed=document.querySelectorAll('script[data-xs-delay]');
142 delayed.forEach(function(s){
143 var n=document.createElement('script');
144 Array.prototype.slice.call(s.attributes).forEach(function(a){
145 if(a.name==='data-xs-src'){n.setAttribute('src',a.value);return;}
146 if(a.name==='data-xs-delay'||a.name==='type')return;
147 n.setAttribute(a.name,a.value);
148 });
149 if(!s.hasAttribute('data-xs-src')){n.text=s.text;}
150 s.parentNode.replaceChild(n,s);
151 });
152 }
153 events.forEach(function(e){window.addEventListener(e,load,{passive:true,capture:true});});
154 setTimeout(load,8000);
155 })();
156 </script>
157 <?php
158 }
159
160 /**
161 * Filter: `style_loader_tag` — wrap stylesheets in the
162 * print → onload="all" pattern so they download non-blocking.
163 * Pairs with critical CSS workflows. Adds a <noscript> fallback so
164 * users with JS disabled still get styles applied (via media="all").
165 *
166 * @param string $tag
167 * @param string $handle
168 */
169 public static function async_style_tag( $tag, $handle ): string {
170 if ( ! is_string( $tag ) || '' === $tag ) {
171 return (string) $tag;
172 }
173 if ( self::skip_in_non_frontend_context() ) {
174 return $tag;
175 }
176 // Only operate on <link rel=stylesheet> with a media attribute
177 // we can swap. Skip anything custom (preload, etc.) — we don't
178 // want to fight with explicit author intent.
179 if ( false === stripos( $tag, 'rel=\'stylesheet\'' ) && false === stripos( $tag, 'rel="stylesheet"' ) ) {
180 return $tag;
181 }
182 // Avoid double-wrapping.
183 if ( false !== stripos( $tag, 'data-xs-async' ) ) {
184 return $tag;
185 }
186 $async = (string) preg_replace_callback(
187 '#\bmedia\s*=\s*(["\'])([^"\']*)\1#i',
188 static function ( $m ) {
189 $orig = $m[2];
190 return 'media="print" onload="this.media=\'' . esc_attr( $orig ) . '\'" data-xs-async="' . esc_attr( $orig ) . '"';
191 },
192 $tag,
193 1
194 );
195 // If no media= was present (rare), inject one.
196 if ( $async === $tag ) {
197 $async = (string) preg_replace(
198 '#<link\b#i',
199 '<link media="print" onload="this.media=\'all\'" data-xs-async="all"',
200 $tag,
201 1
202 );
203 }
204 // Fallback for noscript users — re-emit the original tag inside <noscript>.
205 return $async . '<noscript>' . $tag . '</noscript>';
206 }
207
208 /**
209 * Filter: `style_loader_src` + `script_loader_src` — strip the
210 * ?ver=X.Y query string that WP appends for cache busting. Some
211 * CDNs / reverse proxies cache better when the URL has no query.
212 *
213 * Skip URLs whose query carries non-ver params — those might be
214 * intentional (e.g. a CDN providing per-image transforms).
215 *
216 * @param string $src
217 */
218 public static function strip_version_query( $src ): string {
219 if ( ! is_string( $src ) || '' === $src ) {
220 return (string) $src;
221 }
222 if ( self::skip_in_non_frontend_context() ) {
223 return $src;
224 }
225 $parts = wp_parse_url( $src );
226 if ( ! is_array( $parts ) || empty( $parts['query'] ) ) {
227 return $src;
228 }
229 parse_str( $parts['query'], $query );
230 if ( ! is_array( $query ) ) {
231 return $src;
232 }
233 // Only strip 'ver' — keep anything else the asset URL needs.
234 unset( $query['ver'] );
235 $new_query = http_build_query( $query );
236 $new_url = ( $parts['scheme'] ?? 'http' ) . '://' . ( $parts['host'] ?? '' );
237 if ( isset( $parts['port'] ) ) {
238 $new_url .= ':' . $parts['port'];
239 }
240 $new_url .= $parts['path'] ?? '';
241 if ( '' !== $new_query ) {
242 $new_url .= '?' . $new_query;
243 }
244 if ( ! empty( $parts['fragment'] ) ) {
245 $new_url .= '#' . $parts['fragment'];
246 }
247 return $new_url;
248 }
249
250 /**
251 * Defensive context guard for filter callbacks. Mirrors the registration-
252 * time bail in Minifier::__construct() so a late context flip (admin page
253 * render kicked off mid-request, REST_REQUEST set after plugins_loaded,
254 * etc.) doesn't let frontend tag rewrites leak into wp-admin / AJAX /
255 * REST / cron responses.
256 *
257 * Specifically prevents the React admin bundle's <script> tag from being
258 * deferred or src-swapped to data-xs-src — which would stop the dashboard
259 * from booting and make toggles appear unchecked until first interaction.
260 */
261 private static function skip_in_non_frontend_context(): bool {
262 if ( is_admin() ) {
263 return true;
264 }
265 if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
266 return true;
267 }
268 if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
269 return true;
270 }
271 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
272 return true;
273 }
274 return false;
275 }
276
277 /**
278 * Built-in exclusion list — always skipped regardless of user settings.
279 * Covers our own admin bundle and the WP script-modules it depends on,
280 * so that even if the registration-time admin guard is somehow bypassed,
281 * the dashboard's React app can still boot.
282 */
283 private const ALWAYS_EXCLUDED_HANDLES = array(
284 'xspeed-admin',
285 'wp-hooks',
286 'wp-i18n',
287 'wp-url',
288 'wp-api-fetch',
289 );
290
291 private static function is_excluded_script( string $handle, string $src ): bool {
292 if ( in_array( $handle, self::ALWAYS_EXCLUDED_HANDLES, true ) ) {
293 return true;
294 }
295 $opts = self::opts();
296 $excluded = is_array( $opts['defer_js_excluded'] ?? null ) ? $opts['defer_js_excluded'] : array();
297 if ( empty( $excluded ) ) {
298 return false;
299 }
300 foreach ( $excluded as $needle ) {
301 $needle = (string) $needle;
302 if ( '' === $needle ) {
303 continue;
304 }
305 if ( $handle === $needle || false !== stripos( $src, $needle ) ) {
306 return true;
307 }
308 }
309 return false;
310 }
311
312 /**
313 * Include-list targeting for delay (issue #36): when delay_js_targets
314 * is non-empty, ONLY matching scripts are delayed — a heavy
315 * third-party embed can be postponed without delaying the whole
316 * page's JS. Empty targets = historical behavior (delay everything
317 * minus exclusions). Same matching semantics as the exclusion list:
318 * exact handle match OR case-insensitive URL substring.
319 */
320 private static function is_delay_target( string $handle, string $src ): bool {
321 $opts = self::opts();
322 $targets = is_array( $opts['delay_js_targets'] ?? null ) ? $opts['delay_js_targets'] : array();
323 $targets = array_filter( array_map( 'strval', $targets ), static fn( $t ) => '' !== $t );
324 if ( empty( $targets ) ) {
325 return true;
326 }
327 foreach ( $targets as $needle ) {
328 if ( $handle === $needle || ( '' !== $src && false !== stripos( $src, $needle ) ) ) {
329 return true;
330 }
331 }
332 return false;
333 }
334
335 private static function opts(): array {
336 if ( null === self::$opts ) {
337 self::$opts = Settings_Manager::get( 'minify' );
338 }
339 return self::$opts;
340 }
341
342 /**
343 * Test-only — clear cached opts + bootstrap-printed flag.
344 */
345 public static function reset_state(): void {
346 self::$opts = null;
347 self::$delay_bootstrap_printed = false;
348 }
349 }
350