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
← All changes | includes/class-minifier.php +455 -20 1.0.01.3.3 View file →
@@ -21,9 +21,9 @@
21 21 * Absolute path to the minified-cache directory. Always derived from
22 22 * XSPEED_CACHE_DIR (the plugin's own cache root) — never assembled from
23 23 * arbitrary URL fragments.
24 24 */
25 - private static function min_dir() {
25 + public static function min_dir() {
26 26 return trailingslashit( XSPEED_CACHE_DIR ) . self::MIN_SUBDIR;
27 27 }
28 28
29 29 /**
@@ -37,9 +37,16 @@
37 37 // XSPEED_CACHE_DIR lives under wp-content (defined in xspeed.php as
38 38 // WP_CONTENT_DIR . '/cache/xspeed'), so the URL is content_url() +
39 39 // the known suffix. We do not derive URLs from arbitrary filesystem
40 40 // paths anywhere in this plugin.
41 - return trailingslashit( content_url( 'cache/xspeed' ) ) . self::MIN_SUBDIR;
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 );
42 49 }
43 50
44 51 public function __construct() {
45 52 // Only run on the frontend — never minify wp-admin, AJAX, REST or cron
@@ -48,21 +55,153 @@
48 55 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
49 56 return;
50 57 }
51 58
52 - $opts = Settings::get();
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 + }
53 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 +
54 74 if ( ! empty( $opts['minify_css'] ) ) {
55 75 add_filter( 'style_loader_src', array( __CLASS__, 'rewrite_style' ), 10, 2 );
56 76 }
57 77 if ( ! empty( $opts['minify_js'] ) ) {
58 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 );
59 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 + }
60 156 }
61 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 +
62 202 public static function minify_html( $html ) {
63 - $debug_skip = defined( 'WP_DEBUG' ) && WP_DEBUG;
64 - if ( apply_filters( 'xspeed_skip_minify', $debug_skip ) ) {
203 + if ( '' !== self::skip_reason() ) {
65 204 return $html;
66 205 }
67 206
68 207 $placeholders = array();
@@ -69,10 +208,18 @@
69 208 $pattern = '#<(pre|textarea|script|style)\b[^>]*>.*?</\1>#is';
70 209 $html = preg_replace_callback(
71 210 $pattern,
72 211 function ( $m ) use ( &$placeholders ) {
73 - $key = '__XSPEED_PH_' . count( $placeholders ) . '__';
74 - $placeholders[ $key ] = $m[0];
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] ) );
75 222 return $key;
76 223 },
77 224 $html
78 225 );
@@ -78,9 +225,34 @@
78 225 );
79 226
80 227 $html = preg_replace( '/<!--(?!\[if).*?-->/s', '', $html );
81 228 $html = preg_replace( '/\s+/', ' ', $html );
82 - $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 + );
83 255 $html = trim( $html );
84 256
85 257 foreach ( $placeholders as $key => $original ) {
86 258 $html = str_replace( $key, $original, $html );
@@ -94,10 +266,21 @@
94 266 return self::rewrite_asset( $src, 'css' );
95 267 }
96 268
97 269 public static function rewrite_script( $src, $handle ) {
98 - unset( $handle );
99 - return self::rewrite_asset( $src, 'js' );
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;
100 283 }
101 284
102 285 /**
103 286 * Replace a local CSS/JS URL with a cached, minified equivalent.
@@ -115,8 +298,23 @@
115 298 if ( false !== strpos( $src, '.min.' ) ) {
116 299 return $src;
117 300 }
118 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 +
119 317 // Resolve to a local path; bail if external or unresolvable.
120 318 $path = self::url_to_path( $src );
121 319 if ( ! $path || ! is_readable( $path ) ) {
122 320 return $src;
@@ -155,12 +353,21 @@
155 353 return false;
156 354 }
157 355
158 356 try {
159 - $minifier = ( 'css' === $type )
160 - ? new \MatthiasMullie\Minify\CSS( $source_path )
161 - : new \MatthiasMullie\Minify\JS( $source_path );
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 + }
162 368
369 + $minifier = new \MatthiasMullie\Minify\JS( $source_path );
163 370 $minified = $minifier->minify();
164 371
165 372 // Sanity check: paren/brace/bracket/backtick balance must be preserved.
166 373 // matthiasmullie/minify can silently truncate mid-template-literal on
@@ -179,8 +386,190 @@
179 386 }
180 387 }
181 388
182 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 + /**
183 572 * Resolve a local asset URL to a filesystem path using a strict allowlist
184 573 * of "URL prefix → filesystem prefix" pairs registered with WordPress.
185 574 *
186 575 * We never assume `site_url()` maps to `ABSPATH` (the WordPress root can
@@ -221,19 +610,40 @@
221 610 array( content_url(), WP_CONTENT_DIR ),
222 611 array( includes_url(), ABSPATH . WPINC ),
223 612 );
224 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 +
225 629 foreach ( $candidates as $pair ) {
226 630 list( $url_base, $path_base ) = $pair;
227 631 if ( ! $url_base || ! $path_base ) {
228 632 continue;
229 633 }
230 - $url_base = rtrim( $url_base, '/' );
231 - if ( 0 !== strpos( $clean, $url_base . '/' ) && $clean !== $url_base ) {
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 ) {
232 637 continue;
233 638 }
234 639
235 - $relative = ltrim( substr( $clean, strlen( $url_base ) ), '/' );
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 ) ), '/' );
236 646 $candidate = trailingslashit( $path_base ) . $relative;
237 647
238 648 $real_base = realpath( $path_base );
239 649 $real = realpath( $candidate );
@@ -261,14 +671,39 @@
261 671 Cache::write_silence( $dir );
262 672 }
263 673 }
264 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 + */
265 681 public static function purge_minified() {
266 - $dir = self::min_dir();
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 {
267 694 if ( ! is_dir( $dir ) ) {
268 - return;
695 + return 0;
269 696 }
270 - foreach ( glob( $dir . '/*' ) as $file ) {
271 - wp_delete_file( $file );
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;
272 706 }
707 + return $removed;
273 708 }
274 709 }