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

324 lines 9.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 // src= variant: swap src → data-xs-src and add data-xs-delay marker.
98 if ( '' !== (string) $src ) {
99 return (string) preg_replace(
100 '#\bsrc\s*=\s*(["\'][^"\']*["\'])#i',
101 'data-xs-src=$1 data-xs-delay="1"',
102 $tag,
103 1
104 );
105 }
106 // Inline script: change type to text/plain so the browser
107 // doesn't execute, mark for bootstrap rewriter.
108 return (string) preg_replace(
109 '#<script\b([^>]*)>#i',
110 '<script$1 type="text/xspeed-delayed" data-xs-delay="1">',
111 $tag,
112 1
113 );
114 }
115
116 /**
117 * Inline bootstrap that flips delayed scripts on the first user
118 * interaction. Printed once on wp_footer priority 1000.
119 */
120 public static function print_delay_bootstrap(): void {
121 if ( self::skip_in_non_frontend_context() ) {
122 return;
123 }
124 if ( self::$delay_bootstrap_printed ) {
125 return;
126 }
127 self::$delay_bootstrap_printed = true;
128 // Tiny vanilla bootstrap; keep it self-contained so the page
129 // has no JS dependencies before the first interaction.
130 ?>
131 <script id="xspeed-delay-bootstrap">
132 (function(){
133 var events=['mousemove','keydown','touchstart','scroll','wheel'];
134 var fired=false;
135 function load(){
136 if(fired)return;fired=true;
137 events.forEach(function(e){window.removeEventListener(e,load,{passive:true,capture:true});});
138 var delayed=document.querySelectorAll('script[data-xs-delay]');
139 delayed.forEach(function(s){
140 var n=document.createElement('script');
141 Array.prototype.slice.call(s.attributes).forEach(function(a){
142 if(a.name==='data-xs-src'){n.setAttribute('src',a.value);return;}
143 if(a.name==='data-xs-delay'||a.name==='type')return;
144 n.setAttribute(a.name,a.value);
145 });
146 if(!s.hasAttribute('data-xs-src')){n.text=s.text;}
147 s.parentNode.replaceChild(n,s);
148 });
149 }
150 events.forEach(function(e){window.addEventListener(e,load,{passive:true,capture:true});});
151 setTimeout(load,8000);
152 })();
153 </script>
154 <?php
155 }
156
157 /**
158 * Filter: `style_loader_tag` — wrap stylesheets in the
159 * print → onload="all" pattern so they download non-blocking.
160 * Pairs with critical CSS workflows. Adds a <noscript> fallback so
161 * users with JS disabled still get styles applied (via media="all").
162 *
163 * @param string $tag
164 * @param string $handle
165 */
166 public static function async_style_tag( $tag, $handle ): string {
167 if ( ! is_string( $tag ) || '' === $tag ) {
168 return (string) $tag;
169 }
170 if ( self::skip_in_non_frontend_context() ) {
171 return $tag;
172 }
173 // Only operate on <link rel=stylesheet> with a media attribute
174 // we can swap. Skip anything custom (preload, etc.) — we don't
175 // want to fight with explicit author intent.
176 if ( false === stripos( $tag, 'rel=\'stylesheet\'' ) && false === stripos( $tag, 'rel="stylesheet"' ) ) {
177 return $tag;
178 }
179 // Avoid double-wrapping.
180 if ( false !== stripos( $tag, 'data-xs-async' ) ) {
181 return $tag;
182 }
183 $async = (string) preg_replace_callback(
184 '#\bmedia\s*=\s*(["\'])([^"\']*)\1#i',
185 static function ( $m ) {
186 $orig = $m[2];
187 return 'media="print" onload="this.media=\'' . esc_attr( $orig ) . '\'" data-xs-async="' . esc_attr( $orig ) . '"';
188 },
189 $tag,
190 1
191 );
192 // If no media= was present (rare), inject one.
193 if ( $async === $tag ) {
194 $async = (string) preg_replace(
195 '#<link\b#i',
196 '<link media="print" onload="this.media=\'all\'" data-xs-async="all"',
197 $tag,
198 1
199 );
200 }
201 // Fallback for noscript users — re-emit the original tag inside <noscript>.
202 return $async . '<noscript>' . $tag . '</noscript>';
203 }
204
205 /**
206 * Filter: `style_loader_src` + `script_loader_src` — strip the
207 * ?ver=X.Y query string that WP appends for cache busting. Some
208 * CDNs / reverse proxies cache better when the URL has no query.
209 *
210 * Skip URLs whose query carries non-ver params — those might be
211 * intentional (e.g. a CDN providing per-image transforms).
212 *
213 * @param string $src
214 */
215 public static function strip_version_query( $src ): string {
216 if ( ! is_string( $src ) || '' === $src ) {
217 return (string) $src;
218 }
219 if ( self::skip_in_non_frontend_context() ) {
220 return $src;
221 }
222 $parts = wp_parse_url( $src );
223 if ( ! is_array( $parts ) || empty( $parts['query'] ) ) {
224 return $src;
225 }
226 parse_str( $parts['query'], $query );
227 if ( ! is_array( $query ) ) {
228 return $src;
229 }
230 // Only strip 'ver' — keep anything else the asset URL needs.
231 unset( $query['ver'] );
232 $new_query = http_build_query( $query );
233 $new_url = ( $parts['scheme'] ?? 'http' ) . '://' . ( $parts['host'] ?? '' );
234 if ( isset( $parts['port'] ) ) {
235 $new_url .= ':' . $parts['port'];
236 }
237 $new_url .= $parts['path'] ?? '';
238 if ( '' !== $new_query ) {
239 $new_url .= '?' . $new_query;
240 }
241 if ( ! empty( $parts['fragment'] ) ) {
242 $new_url .= '#' . $parts['fragment'];
243 }
244 return $new_url;
245 }
246
247 /**
248 * Defensive context guard for filter callbacks. Mirrors the registration-
249 * time bail in Minifier::__construct() so a late context flip (admin page
250 * render kicked off mid-request, REST_REQUEST set after plugins_loaded,
251 * etc.) doesn't let frontend tag rewrites leak into wp-admin / AJAX /
252 * REST / cron responses.
253 *
254 * Specifically prevents the React admin bundle's <script> tag from being
255 * deferred or src-swapped to data-xs-src — which would stop the dashboard
256 * from booting and make toggles appear unchecked until first interaction.
257 */
258 private static function skip_in_non_frontend_context(): bool {
259 if ( is_admin() ) {
260 return true;
261 }
262 if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
263 return true;
264 }
265 if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
266 return true;
267 }
268 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
269 return true;
270 }
271 return false;
272 }
273
274 /**
275 * Built-in exclusion list — always skipped regardless of user settings.
276 * Covers our own admin bundle and the WP script-modules it depends on,
277 * so that even if the registration-time admin guard is somehow bypassed,
278 * the dashboard's React app can still boot.
279 */
280 private const ALWAYS_EXCLUDED_HANDLES = array(
281 'xspeed-admin',
282 'wp-hooks',
283 'wp-i18n',
284 'wp-url',
285 'wp-api-fetch',
286 );
287
288 private static function is_excluded_script( string $handle, string $src ): bool {
289 if ( in_array( $handle, self::ALWAYS_EXCLUDED_HANDLES, true ) ) {
290 return true;
291 }
292 $opts = self::opts();
293 $excluded = is_array( $opts['defer_js_excluded'] ?? null ) ? $opts['defer_js_excluded'] : array();
294 if ( empty( $excluded ) ) {
295 return false;
296 }
297 foreach ( $excluded as $needle ) {
298 $needle = (string) $needle;
299 if ( '' === $needle ) {
300 continue;
301 }
302 if ( $handle === $needle || false !== stripos( $src, $needle ) ) {
303 return true;
304 }
305 }
306 return false;
307 }
308
309 private static function opts(): array {
310 if ( null === self::$opts ) {
311 self::$opts = Settings_Manager::get( 'minify' );
312 }
313 return self::$opts;
314 }
315
316 /**
317 * Test-only — clear cached opts + bootstrap-printed flag.
318 */
319 public static function reset_state(): void {
320 self::$opts = null;
321 self::$delay_bootstrap_printed = false;
322 }
323 }
324