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

260 lines 7.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 if ( '' === (string) $src ) {
57 return $tag;
58 }
59 if ( self::is_excluded_script( (string) $handle, (string) $src ) ) {
60 return $tag;
61 }
62 if ( false !== stripos( $tag, ' defer' ) || false !== stripos( $tag, ' async' ) ) {
63 return $tag;
64 }
65 return (string) preg_replace( '#<script\b#i', '<script defer="defer"', $tag, 1 );
66 }
67
68 /**
69 * Filter: `script_loader_tag` — rewrite src= to data-xs-src= so the
70 * browser ignores it until the bootstrap (printed once on
71 * wp_footer) swaps it back on first user interaction. Same
72 * exclusion rules as defer. Inline scripts (no src) are also
73 * deferred until the first interaction.
74 *
75 * @param string $tag
76 * @param string $handle
77 * @param string $src
78 */
79 public static function delay_script_tag( $tag, $handle, $src ): string {
80 if ( ! is_string( $tag ) || '' === $tag ) {
81 return (string) $tag;
82 }
83 if ( self::is_excluded_script( (string) $handle, (string) $src ) ) {
84 return $tag;
85 }
86 // src= variant: swap src → data-xs-src and add data-xs-delay marker.
87 if ( '' !== (string) $src ) {
88 return (string) preg_replace(
89 '#\bsrc\s*=\s*(["\'][^"\']*["\'])#i',
90 'data-xs-src=$1 data-xs-delay="1"',
91 $tag,
92 1
93 );
94 }
95 // Inline script: change type to text/plain so the browser
96 // doesn't execute, mark for bootstrap rewriter.
97 return (string) preg_replace(
98 '#<script\b([^>]*)>#i',
99 '<script$1 type="text/xspeed-delayed" data-xs-delay="1">',
100 $tag,
101 1
102 );
103 }
104
105 /**
106 * Inline bootstrap that flips delayed scripts on the first user
107 * interaction. Printed once on wp_footer priority 1000.
108 */
109 public static function print_delay_bootstrap(): void {
110 if ( self::$delay_bootstrap_printed ) {
111 return;
112 }
113 self::$delay_bootstrap_printed = true;
114 // Tiny vanilla bootstrap; keep it self-contained so the page
115 // has no JS dependencies before the first interaction.
116 ?>
117 <script id="xspeed-delay-bootstrap">
118 (function(){
119 var events=['mousemove','keydown','touchstart','scroll','wheel'];
120 var fired=false;
121 function load(){
122 if(fired)return;fired=true;
123 events.forEach(function(e){window.removeEventListener(e,load,{passive:true,capture:true});});
124 var delayed=document.querySelectorAll('script[data-xs-delay]');
125 delayed.forEach(function(s){
126 var n=document.createElement('script');
127 Array.prototype.slice.call(s.attributes).forEach(function(a){
128 if(a.name==='data-xs-src'){n.setAttribute('src',a.value);return;}
129 if(a.name==='data-xs-delay'||a.name==='type')return;
130 n.setAttribute(a.name,a.value);
131 });
132 if(!s.hasAttribute('data-xs-src')){n.text=s.text;}
133 s.parentNode.replaceChild(n,s);
134 });
135 }
136 events.forEach(function(e){window.addEventListener(e,load,{passive:true,capture:true});});
137 setTimeout(load,8000);
138 })();
139 </script>
140 <?php
141 }
142
143 /**
144 * Filter: `style_loader_tag` — wrap stylesheets in the
145 * print → onload="all" pattern so they download non-blocking.
146 * Pairs with critical CSS workflows. Adds a <noscript> fallback so
147 * users with JS disabled still get styles applied (via media="all").
148 *
149 * @param string $tag
150 * @param string $handle
151 */
152 public static function async_style_tag( $tag, $handle ): string {
153 if ( ! is_string( $tag ) || '' === $tag ) {
154 return (string) $tag;
155 }
156 // Only operate on <link rel=stylesheet> with a media attribute
157 // we can swap. Skip anything custom (preload, etc.) — we don't
158 // want to fight with explicit author intent.
159 if ( false === stripos( $tag, 'rel=\'stylesheet\'' ) && false === stripos( $tag, 'rel="stylesheet"' ) ) {
160 return $tag;
161 }
162 // Avoid double-wrapping.
163 if ( false !== stripos( $tag, 'data-xs-async' ) ) {
164 return $tag;
165 }
166 $async = (string) preg_replace_callback(
167 '#\bmedia\s*=\s*(["\'])([^"\']*)\1#i',
168 static function ( $m ) {
169 $orig = $m[2];
170 return 'media="print" onload="this.media=\'' . esc_attr( $orig ) . '\'" data-xs-async="' . esc_attr( $orig ) . '"';
171 },
172 $tag,
173 1
174 );
175 // If no media= was present (rare), inject one.
176 if ( $async === $tag ) {
177 $async = (string) preg_replace(
178 '#<link\b#i',
179 '<link media="print" onload="this.media=\'all\'" data-xs-async="all"',
180 $tag,
181 1
182 );
183 }
184 // Fallback for noscript users — re-emit the original tag inside <noscript>.
185 return $async . '<noscript>' . $tag . '</noscript>';
186 }
187
188 /**
189 * Filter: `style_loader_src` + `script_loader_src` — strip the
190 * ?ver=X.Y query string that WP appends for cache busting. Some
191 * CDNs / reverse proxies cache better when the URL has no query.
192 *
193 * Skip URLs whose query carries non-ver params — those might be
194 * intentional (e.g. a CDN providing per-image transforms).
195 *
196 * @param string $src
197 */
198 public static function strip_version_query( $src ): string {
199 if ( ! is_string( $src ) || '' === $src ) {
200 return (string) $src;
201 }
202 $parts = wp_parse_url( $src );
203 if ( ! is_array( $parts ) || empty( $parts['query'] ) ) {
204 return $src;
205 }
206 parse_str( $parts['query'], $query );
207 if ( ! is_array( $query ) ) {
208 return $src;
209 }
210 // Only strip 'ver' — keep anything else the asset URL needs.
211 unset( $query['ver'] );
212 $new_query = http_build_query( $query );
213 $new_url = ( $parts['scheme'] ?? 'http' ) . '://' . ( $parts['host'] ?? '' );
214 if ( isset( $parts['port'] ) ) {
215 $new_url .= ':' . $parts['port'];
216 }
217 $new_url .= $parts['path'] ?? '';
218 if ( '' !== $new_query ) {
219 $new_url .= '?' . $new_query;
220 }
221 if ( ! empty( $parts['fragment'] ) ) {
222 $new_url .= '#' . $parts['fragment'];
223 }
224 return $new_url;
225 }
226
227 private static function is_excluded_script( string $handle, string $src ): bool {
228 $opts = self::opts();
229 $excluded = is_array( $opts['defer_js_excluded'] ?? null ) ? $opts['defer_js_excluded'] : array();
230 if ( empty( $excluded ) ) {
231 return false;
232 }
233 foreach ( $excluded as $needle ) {
234 $needle = (string) $needle;
235 if ( '' === $needle ) {
236 continue;
237 }
238 if ( $handle === $needle || false !== stripos( $src, $needle ) ) {
239 return true;
240 }
241 }
242 return false;
243 }
244
245 private static function opts(): array {
246 if ( null === self::$opts ) {
247 self::$opts = Settings_Manager::get( 'minify' );
248 }
249 return self::$opts;
250 }
251
252 /**
253 * Test-only — clear cached opts + bootstrap-printed flag.
254 */
255 public static function reset_state(): void {
256 self::$opts = null;
257 self::$delay_bootstrap_printed = false;
258 }
259 }
260