PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.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-minifier.php

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

710 lines 28.8 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 // A page-builder editing screen is a front-end URL, so none of the
60 // guards above catch it. Optimizing it breaks the editor outright --
61 // Combine JS reorders the builder's own dependency graph and the
62 // toolbar never renders. There is no speed to win on a logged-in,
63 // uncacheable editing request anyway. (#281)
64 if ( Builder_Editor::is_active() ) {
65 return;
66 }
67
68 // Settings now live in the per-module option (xspeed_module_minify),
69 // owned by XSpeed\Modules\Minify\MinifyModule. We read through
70 // Settings_Manager so schema-validated values are returned even
71 // if the option was hand-edited.
72 $opts = Settings_Manager::get( 'minify' );
73
74 if ( ! empty( $opts['minify_css'] ) ) {
75 add_filter( 'style_loader_src', array( __CLASS__, 'rewrite_style' ), 10, 2 );
76 }
77 if ( ! empty( $opts['minify_js'] ) ) {
78 add_filter( 'script_loader_src', array( __CLASS__, 'rewrite_script' ), 10, 2 );
79 // The src rewrite above runs before any plugin's own
80 // `script_loader_tag` filter can stamp data-no-minify /
81 // data-no-optimize onto the tag, so the marker arrives too late
82 // to prevent it. Priority 15: after third-party tag filters at
83 // the default 10 have printed their markers, before our defer
84 // (20) and delay (30) look at the tag. (#456)
85 add_filter( 'script_loader_tag', array( Minify_Filters::class, 'restore_marked_script_src' ), 15, 3 );
86 }
87
88 // Phase 4.1a — filter-only "smarter minifier" features. Each is
89 // gated on its own toggle so users can enable any subset.
90 if ( ! empty( $opts['remove_query_strings'] ) ) {
91 add_filter( 'style_loader_src', array( Minify_Filters::class, 'strip_version_query' ), 20 );
92 add_filter( 'script_loader_src', array( Minify_Filters::class, 'strip_version_query' ), 20 );
93 }
94 if ( ! empty( $opts['defer_js'] ) ) {
95 add_filter( 'script_loader_tag', array( Minify_Filters::class, 'defer_script_tag' ), 20, 3 );
96 }
97 if ( ! empty( $opts['delay_js'] ) ) {
98 // Delay applies a transform that's mutually exclusive with
99 // plain defer — when both are on, delay wins (the bootstrap
100 // will re-attach as a regular <script> on interaction).
101 add_filter( 'script_loader_tag', array( Minify_Filters::class, 'delay_script_tag' ), 30, 3 );
102 add_action( 'wp_footer', array( Minify_Filters::class, 'print_delay_bootstrap' ), 1000 );
103 // script_loader_tag only fires for wp_enqueue_script()'d assets.
104 // Analytics / pixel / chat-widget tags printed straight into
105 // wp_head bypass it, and those are usually the heaviest scripts
106 // on the page — so sweep the finished buffer too. Runs before
107 // minify_html (same filter, default priority) and is baked into
108 // the cache file, so it replays on static hits where PHP never
109 // boots.
110 add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'delay_raw_script_tags' ), 20 );
111 // The vendors' own install snippets are INLINE (no src at all),
112 // so the src sweep above never sees them; this one parks an
113 // inline body that names a known third-party host.
114 add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'delay_inline_snippets' ), 21 );
115 }
116 if ( ! empty( $opts['async_css'] ) ) {
117 add_filter( 'style_loader_tag', array( Minify_Filters::class, 'async_style_tag' ), 20, 2 );
118 // style_loader_tag only fires for wp_enqueue_style()'d sheets.
119 // Themes print Google/Bunny/Typekit font CSS as literal <link>
120 // markup in the head, so those sheets never reach the filter and
121 // stay render-blocking — sweep the finished buffer for the known
122 // font-CSS hosts. Baked into the cache file, so it replays on
123 // static hits where PHP never boots.
124 add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'async_raw_font_css_links' ), 22 );
125 }
126
127 /*
128 * CSS combining runs on the FINISHED HTML, not the enqueue queue.
129 *
130 * The queue-walking version could not be made correct: whatever it
131 * wrote at priority 999, WordPress edited afterwards. Core's
132 * wp_maybe_inline_styles() inlines any queued handle carrying a `path`
133 * and sets src=false on it, which silently threw away the combined URL
134 * and took the sheets we had blanked with it — six stylesheets became
135 * one and the site rendered unstyled. See Css_Combine_Buffer's header
136 * for the full trace. (#195)
137 *
138 * Two entry points, because the page cache's filter is not always
139 * available: `xspeed_cache_final_html` fires only on a cacheable MISS,
140 * so on a site with the cache off — or on an excluded URL like /cart —
141 * combining would silently stop working. Css_Combine_Buffer::boot()
142 * opens its own buffer in exactly those cases and no-ops otherwise, so
143 * the page is transformed once either way.
144 */
145 if ( ! empty( $opts['combine_css'] ) ) {
146 add_filter( 'xspeed_cache_final_html', array( Css_Combine_Buffer::class, 'process' ), 5 );
147 Css_Combine_Buffer::boot();
148 }
149 if ( ! empty( $opts['combine_js'] ) ) {
150 // JS stays on the enqueue path for now: dependency order,
151 // async/defer and wp_add_inline_script make it a different
152 // problem, and the reported break is CSS-only. Moving it is worth
153 // its own change rather than doubling the blast radius here.
154 add_action( 'wp_enqueue_scripts', array( Asset_Combiner::class, 'combine_scripts' ), 999 );
155 }
156 }
157
158 /**
159 * HTML elements that participate in an inline formatting context, where
160 * whitespace between two of them renders as a visible space.
161 *
162 * Deliberately excludes <br> (nothing to separate) and replaced/embedded
163 * inline elements that sit alone. Anything not listed is treated as block
164 * level, where inter-tag whitespace collapses to nothing and is safe to
165 * strip. (FBS-84090)
166 */
167 private const INLINE_TAGS = array(
168 'a', 'abbr', 'b', 'bdi', 'bdo', 'cite', 'code', 'data', 'del', 'dfn',
169 'em', 'i', 'ins', 'kbd', 'label', 'mark', 'q', 'rp', 'rt', 'ruby',
170 's', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'time', 'u',
171 'var', 'wbr', 'img', 'button', 'select', 'output',
172 );
173
174 /** True when $tag renders inline, so whitespace beside it is visible. */
175 private static function is_inline( string $tag ): bool {
176 return in_array( strtolower( $tag ), self::INLINE_TAGS, true );
177 }
178
179 /**
180 * Why minification is being skipped, when it is. '' when it will run.
181 *
182 * minify_html can read "on" in every settings surface while producing
183 * byte-identical HTML, because the guard below silently returns the
184 * input. Field report: a live site showed `minify_html: on` with 3,856
185 * indented lines in the delivered HTML and nothing anywhere explaining
186 * the contradiction — the setting looked broken rather than suppressed.
187 * Callers that report status MUST consult this so the refusal is
188 * visible. (Same class as Cache::static_rewrite_block_reason().)
189 *
190 * @return string 'wp_debug', 'filter', or ''.
191 */
192 public static function skip_reason(): string {
193 $debug_skip = defined( 'WP_DEBUG' ) && WP_DEBUG;
194 if ( ! apply_filters( 'xspeed_skip_minify', $debug_skip ) ) {
195 return '';
196 }
197 // Distinguish the built-in WP_DEBUG rule from a third party
198 // filtering the escape hatch — the fixes are different.
199 return $debug_skip ? 'wp_debug' : 'filter';
200 }
201
202 public static function minify_html( $html ) {
203 if ( '' !== self::skip_reason() ) {
204 return $html;
205 }
206
207 $placeholders = array();
208 $pattern = '#<(pre|textarea|script|style)\b[^>]*>.*?</\1>#is';
209 $html = preg_replace_callback(
210 $pattern,
211 function ( $m ) use ( &$placeholders ) {
212 $key = '__XSPEED_PH_' . count( $placeholders ) . '__';
213 // The placeholder pass exists to protect content whose
214 // whitespace is significant (<pre>, <textarea>) and to keep
215 // the tag-boundary regex off script bodies. <style> and
216 // <script> were grouped in with them, so protection became a
217 // permanent exemption: on builder sites where most CSS is
218 // inline, a page with minify ON shipped fully indented. Minify
219 // the BODY here, before it's stashed, so the outer passes
220 // still never see it. (#2)
221 $placeholders[ $key ] = self::minify_inline_block( $m[0], strtolower( $m[1] ) );
222 return $key;
223 },
224 $html
225 );
226
227 $html = preg_replace( '/<!--(?!\[if).*?-->/s', '', $html );
228 $html = preg_replace( '/\s+/', ' ', $html );
229
230 /*
231 * Collapse whitespace BETWEEN TAGS — but never where it is visible.
232 *
233 * Whitespace separating two INLINE elements is a real, rendered space:
234 * WooCommerce emits `</del> <ins>` for a sale price, and that single
235 * character is the gap between "$32.50" and "$29.50". Stripping it
236 * printed "$32.50$29.50" run together, and only with cache on — the
237 * un-minified page was fine. (FBS-84090)
238 *
239 * So the strip only applies when at least one side is a BLOCK-level
240 * (or non-rendered) tag, where the whitespace collapses away anyway.
241 * Inline-to-inline boundaries keep their single space.
242 */
243 $html = preg_replace_callback(
244 // left tag name (may be a closing tag) … whitespace … right tag name
245 '#</?([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>\s+<(/?)([a-zA-Z][a-zA-Z0-9-]*)#',
246 static function ( $m ) {
247 // Keep the space only when BOTH sides are inline elements —
248 // that is the one case where it is actually rendered.
249 $keep = self::is_inline( $m[1] ) && self::is_inline( $m[3] );
250 $open = substr( $m[0], 0, strrpos( $m[0], '<' ) ); // through the left tag's '>'
251 return rtrim( $open ) . ( $keep ? ' ' : '' ) . '<' . $m[2] . $m[3];
252 },
253 $html
254 );
255 $html = trim( $html );
256
257 foreach ( $placeholders as $key => $original ) {
258 $html = str_replace( $key, $original, $html );
259 }
260
261 return $html;
262 }
263
264 public static function rewrite_style( $src, $handle ) {
265 unset( $handle );
266 return self::rewrite_asset( $src, 'css' );
267 }
268
269 public static function rewrite_script( $src, $handle ) {
270 $rewritten = self::rewrite_asset( $src, 'js' );
271
272 // Remember the pre-minify URL for this handle. script_loader_tag
273 // runs later and only ever sees the rewritten src (a hashed
274 // /cache/xspeed/min/<key>.js path), so a user's URL-substring
275 // delay/exclusion target would never match once minification is
276 // on. Minify_Filters::original_src() gives those checks the URL
277 // the user actually wrote their target against. (FBS field report)
278 if ( is_string( $handle ) && '' !== $handle && is_string( $src ) && $src !== $rewritten ) {
279 Minify_Filters::remember_original_src( $handle, $src );
280 }
281
282 return $rewritten;
283 }
284
285 /**
286 * Replace a local CSS/JS URL with a cached, minified equivalent.
287 *
288 * @param string $src Original asset URL.
289 * @param string $type 'css' or 'js'.
290 * @return string Possibly rewritten URL.
291 */
292 private static function rewrite_asset( $src, $type ) {
293 if ( ! is_string( $src ) || '' === $src ) {
294 return $src;
295 }
296
297 // Skip already-minified files.
298 if ( false !== strpos( $src, '.min.' ) ) {
299 return $src;
300 }
301
302 // Skip anything we already produced. The Asset_Combiner minifies the
303 // combined body itself before writing combined-<hash>.css under
304 // min/combined/ (issue #331 — that used to be asserted here but was
305 // not actually true, so the artifact shipped unminified), and enqueues it
306 // as `xspeed-combined-css`; the per-file minifier used to re-minify
307 // that combined output into a SECOND file (min/<hash2>.css) with its
308 // own mtime-derived hash. The served HTML then pinned that second
309 // hash, so a purge/regeneration (which changes the combined file's
310 // mtime -> a new hash2) left the cached page pointing at a file that
311 // no longer existed -> 404 -> unstyled/broken frontend. Leaving our
312 // own cache output untouched keeps a single, stable URL end-to-end.
313 if ( false !== strpos( $src, '/cache/xspeed/' ) ) {
314 return $src;
315 }
316
317 // Resolve to a local path; bail if external or unresolvable.
318 $path = self::url_to_path( $src );
319 if ( ! $path || ! is_readable( $path ) ) {
320 return $src;
321 }
322
323 // Build a cache filename keyed on path + mtime so edits invalidate.
324 $mtime = filemtime( $path );
325 $key = md5( $path . '|' . $mtime );
326 $cache = self::cache_path( $key, $type );
327
328 if ( ! file_exists( $cache ) ) {
329 $ok = self::minify_file( $path, $cache, $type );
330 if ( ! $ok ) {
331 return $src;
332 }
333 }
334
335 // Return a URL to the cached file. Built from known constants — never
336 // from str_replace on a filesystem path (which would assume the FS
337 // layout mirrors the URL layout).
338 return self::min_url() . '/' . $key . '.' . $type;
339 }
340
341 private static function minify_file( $source_path, $target_path, $type ) {
342 if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
343 return false;
344 }
345
346 // Path-traversal guard: refuse to write anywhere outside our cache
347 // dir, even if a malicious filter ever produced a poisoned key.
348 $cache_root = self::min_dir();
349 self::ensure_dir( $cache_root );
350 $real_root = realpath( $cache_root );
351 $real_dir = realpath( dirname( $target_path ) );
352 if ( ! $real_root || ! $real_dir || 0 !== strpos( $real_dir, $real_root ) ) {
353 return false;
354 }
355
356 try {
357 if ( 'css' === $type ) {
358 // Passing the TARGET path makes matthiasmullie/minify rebase every
359 // relative url(...) / @import against the minified file's location.
360 // Without it, a stylesheet moved from e.g.
361 // .../font-awesome/css/all.css to cache/xspeed/min/<key>.css keeps
362 // its original url(../webfonts/…) — which then resolves against the
363 // cache dir and 404s (missing FontAwesome/eicons/WooCommerce fonts).
364 $minifier = new \MatthiasMullie\Minify\CSS( $source_path );
365 $minified = $minifier->minify( $target_path );
366 return '' !== $minified && file_exists( $target_path );
367 }
368
369 $minifier = new \MatthiasMullie\Minify\JS( $source_path );
370 $minified = $minifier->minify();
371
372 // Sanity check: paren/brace/bracket/backtick balance must be preserved.
373 // matthiasmullie/minify can silently truncate mid-template-literal on
374 // complex modern JS — bail rather than ship a broken file.
375 // 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.
376 $source = file_get_contents( $source_path );
377 if ( false === $source || ! self::balanced( $source, $minified ) ) {
378 return false;
379 }
380
381 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context; minification runs on frontend page renders.
382 $bytes = file_put_contents( $target_path, $minified );
383 return false !== $bytes && file_exists( $target_path );
384 } catch ( \Throwable $e ) {
385 return false;
386 }
387 }
388
389 /**
390 * Cheap structural sanity check between source + minified bodies.
391 *
392 * Counts paired-delimiter tokens (parens, braces, brackets, backticks)
393 * in each and bails when the counts disagree — matthiasmullie/minify
394 * has been observed to silently truncate inside template literals on
395 * complex modern JS (see commit history), shipping a body that LOOKS
396 * minified but is structurally broken and crashes the page at parse.
397 *
398 * Backticks are paired (open + close = same token), so the count
399 * itself must match exactly. Strings inside the source can contain
400 * literal `{` / `}` / `[` / `]` that throw off the count by the same
401 * amount in both bodies (since they survive minification as-is), so
402 * the equality check is robust to that noise.
403 */
404 /**
405 * Minify the body of one captured inline block, or return it untouched.
406 *
407 * Only `<style>` and JavaScript `<script>` bodies are eligible:
408 *
409 * - `<pre>` / `<textarea>` — whitespace is rendered, never touch it.
410 * - `<script>` with a non-JS `type` — `application/ld+json`,
411 * `text/template`, `text/x-handlebars` and anything unrecognised are
412 * data or markup, not code. Minifying JSON-LD would corrupt structured
413 * data; minifying a template would eat the markup it holds. An unknown
414 * type is treated as non-JS on purpose: guessing wrong breaks the page,
415 * while skipping only forgoes a few bytes.
416 * - `<script src="...">` — the body is empty; the file path already goes
417 * through minify_file().
418 *
419 * Every result is checked with balanced(), the same structural guard the
420 * file path uses, so a body the library truncates is shipped as-is rather
421 * than broken. (#2)
422 *
423 * @param string $block Full matched tag, opening tag through closing tag.
424 * @param string $tag Lowercased tag name.
425 * @return string Minified block, or $block unchanged.
426 */
427 private static function minify_inline_block( string $block, string $tag ): string {
428 if ( 'style' !== $tag && 'script' !== $tag ) {
429 return $block; // pre / textarea — significant whitespace.
430 }
431 if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
432 return $block;
433 }
434
435 // Split into opening tag / body / closing tag. Anything that doesn't
436 // match this shape isn't something we should be rewriting.
437 if ( ! preg_match( '#^(<' . $tag . '\b[^>]*>)(.*)(</' . $tag . '\s*>)$#is', $block, $parts ) ) {
438 return $block;
439 }
440 list( , $open, $body, $close ) = $parts;
441
442 if ( '' === trim( $body ) ) {
443 return $block;
444 }
445
446 // The tag asked to be left alone (data-no-optimize / data-no-minify —
447 // the convention consent managers print on their config scripts). (#456)
448 if ( Minify_Filters::tag_opts_out( $open ) ) {
449 return $block;
450 }
451
452 // Refuse a body that is already structurally broken. balanced() only
453 // compares source against minified, so it passes when BOTH are equally
454 // unbalanced — `function x( {` minifies to `function x({`, same counts,
455 // guard satisfied, broken code reformatted. Rewriting a body we can't
456 // parse risks turning a page that happens to work into one that does
457 // not, for no gain. (#2 AC: a syntactically broken block is left
458 // untouched.)
459 if ( ! self::self_consistent( $body ) ) {
460 return $block;
461 }
462
463 if ( 'script' === $tag ) {
464 // An external script has no body worth minifying.
465 if ( preg_match( '#\bsrc\s*=#i', $open ) ) {
466 return $block;
467 }
468 // No type, or an explicitly JavaScript type, is code. Everything
469 // else is data/markup — see the docblock.
470 $js_types = array(
471 'text/javascript',
472 'application/javascript',
473 'application/ecmascript',
474 'text/ecmascript',
475 'module',
476 );
477 if ( preg_match( '#\btype\s*=\s*["\']?([^"\'\s>]+)#i', $open, $type_match ) ) {
478 if ( ! in_array( strtolower( trim( $type_match[1] ) ), $js_types, true ) ) {
479 return $block;
480 }
481 }
482 }
483
484 try {
485 $minifier = 'style' === $tag
486 ? new \MatthiasMullie\Minify\CSS()
487 : new \MatthiasMullie\Minify\JS();
488 $minifier->add( $body );
489 $minified = $minifier->minify();
490 } catch ( \Throwable $e ) {
491 return $block;
492 }
493
494 // A minifier that returns nothing for a non-empty body has failed, not
495 // succeeded — shipping '' would silently delete the rule set.
496 if ( ! is_string( $minified ) || '' === trim( $minified ) ) {
497 return $block;
498 }
499 if ( ! self::balanced( $body, $minified ) ) {
500 return $block;
501 }
502
503 return $open . $minified . $close;
504 }
505
506 /**
507 * Does a body's own paired delimiters balance?
508 *
509 * balanced() is a RELATIVE check — source against minified — so it cannot
510 * see input that was already broken: an unbalanced body minifies to an
511 * equally unbalanced one and the counts still agree. This is the absolute
512 * check, applied to the source alone before we touch it.
513 *
514 * Deliberately naive: it counts tokens without parsing, so a brace inside
515 * a string or comment skews it. That only ever makes it MORE conservative —
516 * a false negative skips minification, which costs bytes, while a false
517 * positive would ship broken code. (#2)
518 *
519 * @param string $body Inline block body.
520 */
521 private static function self_consistent( string $body ): bool {
522 $pairs = array(
523 '{' => '}',
524 '(' => ')',
525 '[' => ']',
526 );
527 foreach ( $pairs as $open => $close ) {
528 if ( substr_count( $body, $open ) !== substr_count( $body, $close ) ) {
529 return false;
530 }
531 }
532 // Backticks and quotes pair with themselves, so an odd count means an
533 // unterminated literal.
534 foreach ( array( '`' ) as $token ) {
535 if ( 0 !== substr_count( $body, $token ) % 2 ) {
536 return false;
537 }
538 }
539 return true;
540 }
541
542 private static function balanced( string $source, string $minified ): bool {
543 unset( $source );
544
545 // Judge the OUTPUT, not the difference between input and output.
546 //
547 // This used to compare token counts across the pair, on the stated
548 // assumption that "literal braces inside strings survive minification
549 // unchanged, so they cancel out". Comments do not: stripping them is
550 // the minifier's whole job, and every brace, bracket and backtick
551 // inside one disappears with it. So any file whose comments contain a
552 // delimiter — a commented-out block, a URL in a docblock, an SVG in a
553 // note — failed the check and silently shipped unminified.
554 //
555 // It is not a rare shape. EmbedPress's front.js counts 372 braces
556 // against 368, 61 brackets against 58 and 110 backticks against 102
557 // purely from comment removal, so 67 KB shipped raw where 46 KB was
558 // correct — and `node --check` confirms that rejected output parses
559 // fine. A guard that refuses valid work is not conservative, it is
560 // broken: it costs bytes on every request and reports nothing.
561 //
562 // What the guard is FOR still stands (#2): matthiasmullie/minify can
563 // truncate inside a template literal on complex modern JS and return a
564 // body that looks minified but is structurally broken. That failure is
565 // visible in the output alone — an unterminated literal leaves an odd
566 // backtick count and unmatched braces — which is exactly what
567 // self_consistent() measures, without the false positives.
568 return self::self_consistent( $minified );
569 }
570
571 /**
572 * Resolve a local asset URL to a filesystem path using a strict allowlist
573 * of "URL prefix → filesystem prefix" pairs registered with WordPress.
574 *
575 * We never assume `site_url()` maps to `ABSPATH` (the WordPress root can
576 * live above the document root in Bedrock-style installs, behind a proxy,
577 * or on multisite with mapped domains). Each branch resolves through a
578 * known WP API (plugins, themes, content, includes) and validates that
579 * `realpath()` of the result still lives under the expected base — so a
580 * crafted `..`-laden URL cannot escape into the filesystem.
581 *
582 * @param string $url Asset URL (may be protocol-relative or absolute).
583 * @return string|false Absolute filesystem path on success, false otherwise.
584 */
585 private static function url_to_path( $url ) {
586 if ( ! is_string( $url ) || '' === $url ) {
587 return false;
588 }
589
590 // Drop query string + fragment.
591 $clean = strtok( $url, '?#' );
592
593 // Normalise protocol-relative + scheme variants of the host so we
594 // match regardless of whether the asset URL came in over http/https.
595 $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
596 if ( 0 === strpos( $clean, '//' ) ) {
597 $clean = 'https:' . $clean;
598 }
599 if ( $site_host ) {
600 $asset_host = wp_parse_url( $clean, PHP_URL_HOST );
601 if ( $asset_host && $asset_host !== $site_host ) {
602 return false; // External asset — never touch.
603 }
604 }
605
606 $candidates = array(
607 array( plugins_url(), WP_PLUGIN_DIR ),
608 array( get_stylesheet_directory_uri(), get_stylesheet_directory() ),
609 array( get_template_directory_uri(), get_template_directory() ),
610 array( content_url(), WP_CONTENT_DIR ),
611 array( includes_url(), ABSPATH . WPINC ),
612 );
613
614 // The host check above normalised the HOST but not the SCHEME, and the
615 // prefix match below is a plain string compare — so an https asset URL
616 // never matched an http base and the file silently shipped unminified.
617 // That is not a corner case: WP_CONTENT_URL is derived from a stored
618 // option, `plugins_url()` from another, and a site moved to https
619 // without rewriting every row (or one behind a TLS-terminating proxy
620 // where `is_ssl()` reads false) serves https pages off http-rooted
621 // bases all day. Comparing scheme-less is the whole fix; the host
622 // equality test already did the security work of refusing anything
623 // off-site, and this runs after it.
624 $strip_scheme = static function ( string $value ): string {
625 return (string) preg_replace( '#^https?://#i', '//', $value );
626 };
627 $clean_match = $strip_scheme( $clean );
628
629 foreach ( $candidates as $pair ) {
630 list( $url_base, $path_base ) = $pair;
631 if ( ! $url_base || ! $path_base ) {
632 continue;
633 }
634 $url_base = rtrim( $url_base, '/' );
635 $base_match = $strip_scheme( $url_base );
636 if ( 0 !== strpos( $clean_match, $base_match . '/' ) && $clean_match !== $base_match ) {
637 continue;
638 }
639
640 // Slice the scheme-less pair, not the original. `https://…` and
641 // `http://…` differ by one byte, so an offset taken from the base
642 // as written would cut one character short of (or past) the path
643 // when the two schemes disagree — which is the case this fix
644 // exists for.
645 $relative = ltrim( substr( $clean_match, strlen( $base_match ) ), '/' );
646 $candidate = trailingslashit( $path_base ) . $relative;
647
648 $real_base = realpath( $path_base );
649 $real = realpath( $candidate );
650 if ( ! $real_base || ! $real ) {
651 return false;
652 }
653 // Guard against `..`-traversal: resolved path must stay inside
654 // the registered base.
655 if ( 0 !== strpos( $real, $real_base ) ) {
656 return false;
657 }
658 return $real;
659 }
660
661 return false;
662 }
663
664 private static function cache_path( $key, $type ) {
665 return self::min_dir() . '/' . $key . '.' . $type;
666 }
667
668 private static function ensure_dir( $dir ) {
669 if ( ! file_exists( $dir ) ) {
670 wp_mkdir_p( $dir );
671 Cache::write_silence( $dir );
672 }
673 }
674
675 /**
676 * Clear every minified / combined asset.
677 *
678 * @return int Files removed. Most callers are `add_action` callbacks and
679 * ignore it; `wp xspeed purge` reports it as a line item.
680 */
681 public static function purge_minified() {
682 return self::rmtree_files( self::min_dir() );
683 }
684
685 /**
686 * Recursively delete every file under $dir (and the emptied
687 * subdirectories), keeping $dir itself. The previous glob('$dir/*')
688 * was non-recursive and no-ops on directories, so combined assets in
689 * min/combined/ were never cleared — a purge left a stale
690 * combined-<hash>.css the regenerated page no longer referenced.
691 * (FBS-83114 / FBS-83116)
692 */
693 private static function rmtree_files( string $dir ): int {
694 if ( ! is_dir( $dir ) ) {
695 return 0;
696 }
697 $removed = 0;
698 foreach ( (array) glob( $dir . '/*' ) as $path ) {
699 if ( is_dir( $path ) ) {
700 $removed += self::rmtree_files( $path );
701 @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.
702 continue;
703 }
704 wp_delete_file( $path );
705 ++$removed;
706 }
707 return $removed;
708 }
709 }
710