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-minifier.php

class-minifier.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.1.4, at includes/class-minifier.php

466 lines 18.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Asset minifier — HTML, CSS, JS.
4 *
5 * Uses matthiasmullie/minify for CSS/JS. Local enqueued assets are minified
6 * once, cached on disk, and the loader URL is rewritten to point at the
7 * cached file.
8 *
9 * @package XSpeed
10 */
11
12 namespace XSpeed;
13
14 defined( 'ABSPATH' ) || exit;
15
16 class Minifier {
17
18 const MIN_SUBDIR = 'min';
19
20 /**
21 * Absolute path to the minified-cache directory. Always derived from
22 * XSPEED_CACHE_DIR (the plugin's own cache root) — never assembled from
23 * arbitrary URL fragments.
24 */
25 public static function min_dir() {
26 return trailingslashit( XSPEED_CACHE_DIR ) . self::MIN_SUBDIR;
27 }
28
29 /**
30 * Public URL of the minified-cache directory. Built from content_url() +
31 * the known relative path, not by string-replacing WP_CONTENT_DIR out of
32 * a filesystem path (which would assume the filesystem layout matches
33 * the URL layout — it does not on Bedrock-style installs, multisite with
34 * mapped domains, or any setup with a relocated wp-content).
35 */
36 private static function min_url() {
37 // XSPEED_CACHE_DIR lives under wp-content (defined in xspeed.php as
38 // WP_CONTENT_DIR . '/cache/xspeed'), so the URL is content_url() +
39 // the known suffix. We do not derive URLs from arbitrary filesystem
40 // paths anywhere in this plugin.
41 $url = trailingslashit( content_url( 'cache/xspeed' ) ) . self::MIN_SUBDIR;
42 // Force the site's scheme: content_url() derives its scheme from
43 // is_ssl(), which is false behind a TLS-terminating reverse proxy, so
44 // it can emit an http:// URL on an https page — the browser then blocks
45 // the minified stylesheet as mixed content and the page renders
46 // unstyled. Match home_url()'s registered scheme instead. (FBS-83633)
47 $scheme = wp_parse_url( home_url(), PHP_URL_SCHEME ) ?: 'https';
48 return set_url_scheme( $url, $scheme );
49 }
50
51 public function __construct() {
52 // Only run on the frontend — never minify wp-admin, AJAX, REST or cron
53 // asset URLs. Page caching already handles the logged-in case for
54 // the HTML response; minify scope is the public frontend.
55 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
56 return;
57 }
58
59 // Settings now live in the per-module option (xspeed_module_minify),
60 // owned by XSpeed\Modules\Minify\MinifyModule. We read through
61 // Settings_Manager so schema-validated values are returned even
62 // if the option was hand-edited.
63 $opts = Settings_Manager::get( 'minify' );
64
65 if ( ! empty( $opts['minify_css'] ) ) {
66 add_filter( 'style_loader_src', array( __CLASS__, 'rewrite_style' ), 10, 2 );
67 }
68 if ( ! empty( $opts['minify_js'] ) ) {
69 add_filter( 'script_loader_src', array( __CLASS__, 'rewrite_script' ), 10, 2 );
70 }
71
72 // Phase 4.1a — filter-only "smarter minifier" features. Each is
73 // gated on its own toggle so users can enable any subset.
74 if ( ! empty( $opts['remove_query_strings'] ) ) {
75 add_filter( 'style_loader_src', array( Minify_Filters::class, 'strip_version_query' ), 20 );
76 add_filter( 'script_loader_src', array( Minify_Filters::class, 'strip_version_query' ), 20 );
77 }
78 if ( ! empty( $opts['defer_js'] ) ) {
79 add_filter( 'script_loader_tag', array( Minify_Filters::class, 'defer_script_tag' ), 20, 3 );
80 }
81 if ( ! empty( $opts['delay_js'] ) ) {
82 // Delay applies a transform that's mutually exclusive with
83 // plain defer — when both are on, delay wins (the bootstrap
84 // will re-attach as a regular <script> on interaction).
85 add_filter( 'script_loader_tag', array( Minify_Filters::class, 'delay_script_tag' ), 30, 3 );
86 add_action( 'wp_footer', array( Minify_Filters::class, 'print_delay_bootstrap' ), 1000 );
87 // script_loader_tag only fires for wp_enqueue_script()'d assets.
88 // Analytics / pixel / chat-widget tags printed straight into
89 // wp_head bypass it, and those are usually the heaviest scripts
90 // on the page — so sweep the finished buffer too. Runs before
91 // minify_html (same filter, default priority) and is baked into
92 // the cache file, so it replays on static hits where PHP never
93 // boots.
94 add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'delay_raw_script_tags' ), 20 );
95 }
96 if ( ! empty( $opts['async_css'] ) ) {
97 add_filter( 'style_loader_tag', array( Minify_Filters::class, 'async_style_tag' ), 20, 2 );
98 }
99
100 // Phase 4.1b — combine engine. Hook late so every plugin /
101 // theme has finished enqueueing by the time we walk the queue.
102 // Priority 999 mirrors the WP-Optimize / Rocket convention.
103 if ( ! empty( $opts['combine_css'] ) ) {
104 add_action( 'wp_enqueue_scripts', array( Asset_Combiner::class, 'combine_styles' ), 999 );
105 }
106 if ( ! empty( $opts['combine_js'] ) ) {
107 add_action( 'wp_enqueue_scripts', array( Asset_Combiner::class, 'combine_scripts' ), 999 );
108 }
109 }
110
111 /**
112 * HTML elements that participate in an inline formatting context, where
113 * whitespace between two of them renders as a visible space.
114 *
115 * Deliberately excludes <br> (nothing to separate) and replaced/embedded
116 * inline elements that sit alone. Anything not listed is treated as block
117 * level, where inter-tag whitespace collapses to nothing and is safe to
118 * strip. (FBS-84090)
119 */
120 private const INLINE_TAGS = array(
121 'a', 'abbr', 'b', 'bdi', 'bdo', 'cite', 'code', 'data', 'del', 'dfn',
122 'em', 'i', 'ins', 'kbd', 'label', 'mark', 'q', 'rp', 'rt', 'ruby',
123 's', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'time', 'u',
124 'var', 'wbr', 'img', 'button', 'select', 'output',
125 );
126
127 /** True when $tag renders inline, so whitespace beside it is visible. */
128 private static function is_inline( string $tag ): bool {
129 return in_array( strtolower( $tag ), self::INLINE_TAGS, true );
130 }
131
132 /**
133 * Why minification is being skipped, when it is. '' when it will run.
134 *
135 * minify_html can read "on" in every settings surface while producing
136 * byte-identical HTML, because the guard below silently returns the
137 * input. Field report: a live site showed `minify_html: on` with 3,856
138 * indented lines in the delivered HTML and nothing anywhere explaining
139 * the contradiction — the setting looked broken rather than suppressed.
140 * Callers that report status MUST consult this so the refusal is
141 * visible. (Same class as Cache::static_rewrite_block_reason().)
142 *
143 * @return string 'wp_debug', 'filter', or ''.
144 */
145 public static function skip_reason(): string {
146 $debug_skip = defined( 'WP_DEBUG' ) && WP_DEBUG;
147 if ( ! apply_filters( 'xspeed_skip_minify', $debug_skip ) ) {
148 return '';
149 }
150 // Distinguish the built-in WP_DEBUG rule from a third party
151 // filtering the escape hatch — the fixes are different.
152 return $debug_skip ? 'wp_debug' : 'filter';
153 }
154
155 public static function minify_html( $html ) {
156 if ( '' !== self::skip_reason() ) {
157 return $html;
158 }
159
160 $placeholders = array();
161 $pattern = '#<(pre|textarea|script|style)\b[^>]*>.*?</\1>#is';
162 $html = preg_replace_callback(
163 $pattern,
164 function ( $m ) use ( &$placeholders ) {
165 $key = '__XSPEED_PH_' . count( $placeholders ) . '__';
166 $placeholders[ $key ] = $m[0];
167 return $key;
168 },
169 $html
170 );
171
172 $html = preg_replace( '/<!--(?!\[if).*?-->/s', '', $html );
173 $html = preg_replace( '/\s+/', ' ', $html );
174
175 /*
176 * Collapse whitespace BETWEEN TAGS — but never where it is visible.
177 *
178 * Whitespace separating two INLINE elements is a real, rendered space:
179 * WooCommerce emits `</del> <ins>` for a sale price, and that single
180 * character is the gap between "$32.50" and "$29.50". Stripping it
181 * printed "$32.50$29.50" run together, and only with cache on — the
182 * un-minified page was fine. (FBS-84090)
183 *
184 * So the strip only applies when at least one side is a BLOCK-level
185 * (or non-rendered) tag, where the whitespace collapses away anyway.
186 * Inline-to-inline boundaries keep their single space.
187 */
188 $html = preg_replace_callback(
189 // left tag name (may be a closing tag) … whitespace … right tag name
190 '#</?([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>\s+<(/?)([a-zA-Z][a-zA-Z0-9-]*)#',
191 static function ( $m ) {
192 // Keep the space only when BOTH sides are inline elements —
193 // that is the one case where it is actually rendered.
194 $keep = self::is_inline( $m[1] ) && self::is_inline( $m[3] );
195 $open = substr( $m[0], 0, strrpos( $m[0], '<' ) ); // through the left tag's '>'
196 return rtrim( $open ) . ( $keep ? ' ' : '' ) . '<' . $m[2] . $m[3];
197 },
198 $html
199 );
200 $html = trim( $html );
201
202 foreach ( $placeholders as $key => $original ) {
203 $html = str_replace( $key, $original, $html );
204 }
205
206 return $html;
207 }
208
209 public static function rewrite_style( $src, $handle ) {
210 unset( $handle );
211 return self::rewrite_asset( $src, 'css' );
212 }
213
214 public static function rewrite_script( $src, $handle ) {
215 $rewritten = self::rewrite_asset( $src, 'js' );
216
217 // Remember the pre-minify URL for this handle. script_loader_tag
218 // runs later and only ever sees the rewritten src (a hashed
219 // /cache/xspeed/min/<key>.js path), so a user's URL-substring
220 // delay/exclusion target would never match once minification is
221 // on. Minify_Filters::original_src() gives those checks the URL
222 // the user actually wrote their target against. (FBS field report)
223 if ( is_string( $handle ) && '' !== $handle && is_string( $src ) && $src !== $rewritten ) {
224 Minify_Filters::remember_original_src( $handle, $src );
225 }
226
227 return $rewritten;
228 }
229
230 /**
231 * Replace a local CSS/JS URL with a cached, minified equivalent.
232 *
233 * @param string $src Original asset URL.
234 * @param string $type 'css' or 'js'.
235 * @return string Possibly rewritten URL.
236 */
237 private static function rewrite_asset( $src, $type ) {
238 if ( ! is_string( $src ) || '' === $src ) {
239 return $src;
240 }
241
242 // Skip already-minified files.
243 if ( false !== strpos( $src, '.min.' ) ) {
244 return $src;
245 }
246
247 // Skip anything we already produced. The Asset_Combiner writes a
248 // pre-minified combined-<hash>.css under min/combined/ and enqueues it
249 // as `xspeed-combined-css`; the per-file minifier used to re-minify
250 // that combined output into a SECOND file (min/<hash2>.css) with its
251 // own mtime-derived hash. The served HTML then pinned that second
252 // hash, so a purge/regeneration (which changes the combined file's
253 // mtime -> a new hash2) left the cached page pointing at a file that
254 // no longer existed -> 404 -> unstyled/broken frontend. Leaving our
255 // own cache output untouched keeps a single, stable URL end-to-end.
256 if ( false !== strpos( $src, '/cache/xspeed/' ) ) {
257 return $src;
258 }
259
260 // Resolve to a local path; bail if external or unresolvable.
261 $path = self::url_to_path( $src );
262 if ( ! $path || ! is_readable( $path ) ) {
263 return $src;
264 }
265
266 // Build a cache filename keyed on path + mtime so edits invalidate.
267 $mtime = filemtime( $path );
268 $key = md5( $path . '|' . $mtime );
269 $cache = self::cache_path( $key, $type );
270
271 if ( ! file_exists( $cache ) ) {
272 $ok = self::minify_file( $path, $cache, $type );
273 if ( ! $ok ) {
274 return $src;
275 }
276 }
277
278 // Return a URL to the cached file. Built from known constants — never
279 // from str_replace on a filesystem path (which would assume the FS
280 // layout mirrors the URL layout).
281 return self::min_url() . '/' . $key . '.' . $type;
282 }
283
284 private static function minify_file( $source_path, $target_path, $type ) {
285 if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
286 return false;
287 }
288
289 // Path-traversal guard: refuse to write anywhere outside our cache
290 // dir, even if a malicious filter ever produced a poisoned key.
291 $cache_root = self::min_dir();
292 self::ensure_dir( $cache_root );
293 $real_root = realpath( $cache_root );
294 $real_dir = realpath( dirname( $target_path ) );
295 if ( ! $real_root || ! $real_dir || 0 !== strpos( $real_dir, $real_root ) ) {
296 return false;
297 }
298
299 try {
300 if ( 'css' === $type ) {
301 // Passing the TARGET path makes matthiasmullie/minify rebase every
302 // relative url(...) / @import against the minified file's location.
303 // Without it, a stylesheet moved from e.g.
304 // .../font-awesome/css/all.css to cache/xspeed/min/<key>.css keeps
305 // its original url(../webfonts/…) — which then resolves against the
306 // cache dir and 404s (missing FontAwesome/eicons/WooCommerce fonts).
307 $minifier = new \MatthiasMullie\Minify\CSS( $source_path );
308 $minified = $minifier->minify( $target_path );
309 return '' !== $minified && file_exists( $target_path );
310 }
311
312 $minifier = new \MatthiasMullie\Minify\JS( $source_path );
313 $minified = $minifier->minify();
314
315 // Sanity check: paren/brace/bracket/backtick balance must be preserved.
316 // matthiasmullie/minify can silently truncate mid-template-literal on
317 // complex modern JS — bail rather than ship a broken file.
318 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem requires admin context; minification runs on frontend page renders. Source already validated as readable on line 121.
319 $source = file_get_contents( $source_path );
320 if ( false === $source || ! self::balanced( $source, $minified ) ) {
321 return false;
322 }
323
324 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context; minification runs on frontend page renders.
325 $bytes = file_put_contents( $target_path, $minified );
326 return false !== $bytes && file_exists( $target_path );
327 } catch ( \Throwable $e ) {
328 return false;
329 }
330 }
331
332 /**
333 * Cheap structural sanity check between source + minified bodies.
334 *
335 * Counts paired-delimiter tokens (parens, braces, brackets, backticks)
336 * in each and bails when the counts disagree — matthiasmullie/minify
337 * has been observed to silently truncate inside template literals on
338 * complex modern JS (see commit history), shipping a body that LOOKS
339 * minified but is structurally broken and crashes the page at parse.
340 *
341 * Backticks are paired (open + close = same token), so the count
342 * itself must match exactly. Strings inside the source can contain
343 * literal `{` / `}` / `[` / `]` that throw off the count by the same
344 * amount in both bodies (since they survive minification as-is), so
345 * the equality check is robust to that noise.
346 */
347 private static function balanced( string $source, string $minified ): bool {
348 $pairs = array( '(', ')', '{', '}', '[', ']', '`' );
349 foreach ( $pairs as $token ) {
350 if ( substr_count( $source, $token ) !== substr_count( $minified, $token ) ) {
351 return false;
352 }
353 }
354 return true;
355 }
356
357 /**
358 * Resolve a local asset URL to a filesystem path using a strict allowlist
359 * of "URL prefix → filesystem prefix" pairs registered with WordPress.
360 *
361 * We never assume `site_url()` maps to `ABSPATH` (the WordPress root can
362 * live above the document root in Bedrock-style installs, behind a proxy,
363 * or on multisite with mapped domains). Each branch resolves through a
364 * known WP API (plugins, themes, content, includes) and validates that
365 * `realpath()` of the result still lives under the expected base — so a
366 * crafted `..`-laden URL cannot escape into the filesystem.
367 *
368 * @param string $url Asset URL (may be protocol-relative or absolute).
369 * @return string|false Absolute filesystem path on success, false otherwise.
370 */
371 private static function url_to_path( $url ) {
372 if ( ! is_string( $url ) || '' === $url ) {
373 return false;
374 }
375
376 // Drop query string + fragment.
377 $clean = strtok( $url, '?#' );
378
379 // Normalise protocol-relative + scheme variants of the host so we
380 // match regardless of whether the asset URL came in over http/https.
381 $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
382 if ( 0 === strpos( $clean, '//' ) ) {
383 $clean = 'https:' . $clean;
384 }
385 if ( $site_host ) {
386 $asset_host = wp_parse_url( $clean, PHP_URL_HOST );
387 if ( $asset_host && $asset_host !== $site_host ) {
388 return false; // External asset — never touch.
389 }
390 }
391
392 $candidates = array(
393 array( plugins_url(), WP_PLUGIN_DIR ),
394 array( get_stylesheet_directory_uri(), get_stylesheet_directory() ),
395 array( get_template_directory_uri(), get_template_directory() ),
396 array( content_url(), WP_CONTENT_DIR ),
397 array( includes_url(), ABSPATH . WPINC ),
398 );
399
400 foreach ( $candidates as $pair ) {
401 list( $url_base, $path_base ) = $pair;
402 if ( ! $url_base || ! $path_base ) {
403 continue;
404 }
405 $url_base = rtrim( $url_base, '/' );
406 if ( 0 !== strpos( $clean, $url_base . '/' ) && $clean !== $url_base ) {
407 continue;
408 }
409
410 $relative = ltrim( substr( $clean, strlen( $url_base ) ), '/' );
411 $candidate = trailingslashit( $path_base ) . $relative;
412
413 $real_base = realpath( $path_base );
414 $real = realpath( $candidate );
415 if ( ! $real_base || ! $real ) {
416 return false;
417 }
418 // Guard against `..`-traversal: resolved path must stay inside
419 // the registered base.
420 if ( 0 !== strpos( $real, $real_base ) ) {
421 return false;
422 }
423 return $real;
424 }
425
426 return false;
427 }
428
429 private static function cache_path( $key, $type ) {
430 return self::min_dir() . '/' . $key . '.' . $type;
431 }
432
433 private static function ensure_dir( $dir ) {
434 if ( ! file_exists( $dir ) ) {
435 wp_mkdir_p( $dir );
436 Cache::write_silence( $dir );
437 }
438 }
439
440 public static function purge_minified() {
441 self::rmtree_files( self::min_dir() );
442 }
443
444 /**
445 * Recursively delete every file under $dir (and the emptied
446 * subdirectories), keeping $dir itself. The previous glob('$dir/*')
447 * was non-recursive and no-ops on directories, so combined assets in
448 * min/combined/ were never cleared — a purge left a stale
449 * combined-<hash>.css the regenerated page no longer referenced.
450 * (FBS-83114 / FBS-83116)
451 */
452 private static function rmtree_files( string $dir ): void {
453 if ( ! is_dir( $dir ) ) {
454 return;
455 }
456 foreach ( (array) glob( $dir . '/*' ) as $path ) {
457 if ( is_dir( $path ) ) {
458 self::rmtree_files( $path );
459 @rmdir( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup of our own cache subdir; WP_Filesystem is unavailable on the frontend purge path.
460 continue;
461 }
462 wp_delete_file( $path );
463 }
464 }
465 }
466