PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
1.3.6 1.3.5 1.3.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 All 32 releases
← All changes | includes/class-minifier.php +380 -24 1.1.1 → 1.3.6 View file →
@@ -55,8 +55,17 @@
55 55 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
56 56 return;
57 57 }
58 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 +
59 68 // Settings now live in the per-module option (xspeed_module_minify),
60 69 // owned by XSpeed\Modules\Minify\MinifyModule. We read through
61 70 // Settings_Manager so schema-validated values are returned even
62 71 // if the option was hand-edited.
@@ -66,8 +75,15 @@
66 75 add_filter( 'style_loader_src', array( __CLASS__, 'rewrite_style' ), 10, 2 );
67 76 }
68 77 if ( ! empty( $opts['minify_js'] ) ) {
69 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 );
70 86 }
71 87
72 88 // Phase 4.1a — filter-only "smarter minifier" features. Each is
73 89 // gated on its own toggle so users can enable any subset.
@@ -82,28 +98,135 @@
82 98 // Delay applies a transform that's mutually exclusive with
83 99 // plain defer — when both are on, delay wins (the bootstrap
84 100 // will re-attach as a regular <script> on interaction).
85 101 add_filter( 'script_loader_tag', array( Minify_Filters::class, 'delay_script_tag' ), 30, 3 );
102 + // Smart Delay: a delayed handle's before/after snippets park with
103 + // it, or the inline consumer would run at parse time against a
104 + // global that now arrives on first interaction. This filter fires
105 + // for every inline script WordPress prints, so it also covers
106 + // pages the cache buffer never filters.
107 + add_filter( 'wp_inline_script_attributes', array( Minify_Filters::class, 'park_smart_inline' ), 30, 2 );
86 108 add_action( 'wp_footer', array( Minify_Filters::class, 'print_delay_bootstrap' ), 1000 );
109 + // script_loader_tag only fires for wp_enqueue_script()'d assets.
110 + // Analytics / pixel / chat-widget tags printed straight into
111 + // wp_head bypass it, and those are usually the heaviest scripts
112 + // on the page — so sweep the finished buffer too. Runs before
113 + // minify_html (same filter, default priority) and is baked into
114 + // the cache file, so it replays on static hits where PHP never
115 + // boots.
116 + add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'delay_raw_script_tags' ), 20 );
117 + // The vendors' own install snippets are INLINE (no src at all),
118 + // so the src sweep above never sees them; this one parks an
119 + // inline body that names a known third-party host.
120 + add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'delay_inline_snippets' ), 21 );
87 121 }
122 +
123 + // Everything above honors data-no-optimize / data-no-minify, but
124 + // only sees markers stamped before priority 30. Borlabs Cookie
125 + // stamps at 100, so its consent config was minified AND delayed
126 + // despite carrying both markers. Snapshot the tag before our
127 + // transforms (9) and hand the original back if a marker turns up
128 + // after them (1000, past Borlabs' own 100 and 999). Registered
129 + // whenever any of the three is on,
130 + // since each one is individually enough to damage a marked
131 + // script. (#469)
132 + if ( ! empty( $opts['minify_js'] ) || ! empty( $opts['defer_js'] ) || ! empty( $opts['delay_js'] ) ) {
133 + add_filter( 'script_loader_tag', array( Minify_Filters::class, 'snapshot_tag' ), 9, 3 );
134 + add_filter(
135 + 'script_loader_tag',
136 + array( Minify_Filters::class, 'revert_late_marked_tag' ),
137 + Minify_Filters::late_opt_out_priority(),
138 + 3
139 + );
140 + }
88 141 if ( ! empty( $opts['async_css'] ) ) {
89 142 add_filter( 'style_loader_tag', array( Minify_Filters::class, 'async_style_tag' ), 20, 2 );
143 + // style_loader_tag only fires for wp_enqueue_style()'d sheets.
144 + // Themes print Google/Bunny/Typekit font CSS as literal <link>
145 + // markup in the head, so those sheets never reach the filter and
146 + // stay render-blocking — sweep the finished buffer for the known
147 + // font-CSS hosts. Baked into the cache file, so it replays on
148 + // static hits where PHP never boots.
149 + add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'async_raw_font_css_links' ), 22 );
90 150 }
91 151
92 - // Phase 4.1b — combine engine. Hook late so every plugin /
93 - // theme has finished enqueueing by the time we walk the queue.
94 - // Priority 999 mirrors the WP-Optimize / Rocket convention.
152 + /*
153 + * CSS combining runs on the FINISHED HTML, not the enqueue queue.
154 + *
155 + * The queue-walking version could not be made correct: whatever it
156 + * wrote at priority 999, WordPress edited afterwards. Core's
157 + * wp_maybe_inline_styles() inlines any queued handle carrying a `path`
158 + * and sets src=false on it, which silently threw away the combined URL
159 + * and took the sheets we had blanked with it — six stylesheets became
160 + * one and the site rendered unstyled. See Css_Combine_Buffer's header
161 + * for the full trace. (#195)
162 + *
163 + * Two entry points, because the page cache's filter is not always
164 + * available: `xspeed_cache_final_html` fires only on a cacheable MISS,
165 + * so on a site with the cache off — or on an excluded URL like /cart —
166 + * combining would silently stop working. Css_Combine_Buffer::boot()
167 + * opens its own buffer in exactly those cases and no-ops otherwise, so
168 + * the page is transformed once either way.
169 + */
95 170 if ( ! empty( $opts['combine_css'] ) ) {
96 - add_action( 'wp_enqueue_scripts', array( Asset_Combiner::class, 'combine_styles' ), 999 );
171 + add_filter( 'xspeed_cache_final_html', array( Css_Combine_Buffer::class, 'process' ), 5 );
172 + Css_Combine_Buffer::boot();
97 173 }
98 174 if ( ! empty( $opts['combine_js'] ) ) {
175 + // JS stays on the enqueue path for now: dependency order,
176 + // async/defer and wp_add_inline_script make it a different
177 + // problem, and the reported break is CSS-only. Moving it is worth
178 + // its own change rather than doubling the blast radius here.
99 179 add_action( 'wp_enqueue_scripts', array( Asset_Combiner::class, 'combine_scripts' ), 999 );
100 180 }
101 181 }
102 182
183 + /**
184 + * HTML elements that participate in an inline formatting context, where
185 + * whitespace between two of them renders as a visible space.
186 + *
187 + * Deliberately excludes <br> (nothing to separate) and replaced/embedded
188 + * inline elements that sit alone. Anything not listed is treated as block
189 + * level, where inter-tag whitespace collapses to nothing and is safe to
190 + * strip. (FBS-84090)
191 + */
192 + private const INLINE_TAGS = array(
193 + 'a', 'abbr', 'b', 'bdi', 'bdo', 'cite', 'code', 'data', 'del', 'dfn',
194 + 'em', 'i', 'ins', 'kbd', 'label', 'mark', 'q', 'rp', 'rt', 'ruby',
195 + 's', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'time', 'u',
196 + 'var', 'wbr', 'img', 'button', 'select', 'output',
197 + );
198 +
199 + /** True when $tag renders inline, so whitespace beside it is visible. */
200 + private static function is_inline( string $tag ): bool {
201 + return in_array( strtolower( $tag ), self::INLINE_TAGS, true );
202 + }
203 +
204 + /**
205 + * Why minification is being skipped, when it is. '' when it will run.
206 + *
207 + * minify_html can read "on" in every settings surface while producing
208 + * byte-identical HTML, because the guard below silently returns the
209 + * input. Field report: a live site showed `minify_html: on` with 3,856
210 + * indented lines in the delivered HTML and nothing anywhere explaining
211 + * the contradiction — the setting looked broken rather than suppressed.
212 + * Callers that report status MUST consult this so the refusal is
213 + * visible. (Same class as Cache::static_rewrite_block_reason().)
214 + *
215 + * @return string 'wp_debug', 'filter', or ''.
216 + */
217 + public static function skip_reason(): string {
218 + $debug_skip = defined( 'WP_DEBUG' ) && WP_DEBUG;
219 + if ( ! apply_filters( 'xspeed_skip_minify', $debug_skip ) ) {
220 + return '';
221 + }
222 + // Distinguish the built-in WP_DEBUG rule from a third party
223 + // filtering the escape hatch — the fixes are different.
224 + return $debug_skip ? 'wp_debug' : 'filter';
225 + }
226 +
103 227 public static function minify_html( $html ) {
104 - $debug_skip = defined( 'WP_DEBUG' ) && WP_DEBUG;
105 - if ( apply_filters( 'xspeed_skip_minify', $debug_skip ) ) {
228 + if ( '' !== self::skip_reason() ) {
106 229 return $html;
107 230 }
108 231
109 232 $placeholders = array();
@@ -110,10 +233,18 @@
110 233 $pattern = '#<(pre|textarea|script|style)\b[^>]*>.*?</\1>#is';
111 234 $html = preg_replace_callback(
112 235 $pattern,
113 236 function ( $m ) use ( &$placeholders ) {
114 - $key = '__XSPEED_PH_' . count( $placeholders ) . '__';
115 - $placeholders[ $key ] = $m[0];
237 + $key = '__XSPEED_PH_' . count( $placeholders ) . '__';
238 + // The placeholder pass exists to protect content whose
239 + // whitespace is significant (<pre>, <textarea>) and to keep
240 + // the tag-boundary regex off script bodies. <style> and
241 + // <script> were grouped in with them, so protection became a
242 + // permanent exemption: on builder sites where most CSS is
243 + // inline, a page with minify ON shipped fully indented. Minify
244 + // the BODY here, before it's stashed, so the outer passes
245 + // still never see it. (#2)
246 + $placeholders[ $key ] = self::minify_inline_block( $m[0], strtolower( $m[1] ) );
116 247 return $key;
117 248 },
118 249 $html
119 250 );
@@ -119,9 +250,34 @@
119 250 );
120 251
121 252 $html = preg_replace( '/<!--(?!\[if).*?-->/s', '', $html );
122 253 $html = preg_replace( '/\s+/', ' ', $html );
123 - $html = preg_replace( '/>\s+</', '><', $html );
254 +
255 + /*
256 + * Collapse whitespace BETWEEN TAGS — but never where it is visible.
257 + *
258 + * Whitespace separating two INLINE elements is a real, rendered space:
259 + * WooCommerce emits `</del> <ins>` for a sale price, and that single
260 + * character is the gap between "$32.50" and "$29.50". Stripping it
261 + * printed "$32.50$29.50" run together, and only with cache on — the
262 + * un-minified page was fine. (FBS-84090)
263 + *
264 + * So the strip only applies when at least one side is a BLOCK-level
265 + * (or non-rendered) tag, where the whitespace collapses away anyway.
266 + * Inline-to-inline boundaries keep their single space.
267 + */
268 + $html = preg_replace_callback(
269 + // left tag name (may be a closing tag) … whitespace … right tag name
270 + '#</?([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>\s+<(/?)([a-zA-Z][a-zA-Z0-9-]*)#',
271 + static function ( $m ) {
272 + // Keep the space only when BOTH sides are inline elements —
273 + // that is the one case where it is actually rendered.
274 + $keep = self::is_inline( $m[1] ) && self::is_inline( $m[3] );
275 + $open = substr( $m[0], 0, strrpos( $m[0], '<' ) ); // through the left tag's '>'
276 + return rtrim( $open ) . ( $keep ? ' ' : '' ) . '<' . $m[2] . $m[3];
277 + },
278 + $html
279 + );
124 280 $html = trim( $html );
125 281
126 282 foreach ( $placeholders as $key => $original ) {
127 283 $html = str_replace( $key, $original, $html );
@@ -135,10 +291,21 @@
135 291 return self::rewrite_asset( $src, 'css' );
136 292 }
137 293
138 294 public static function rewrite_script( $src, $handle ) {
139 - unset( $handle );
140 - return self::rewrite_asset( $src, 'js' );
295 + $rewritten = self::rewrite_asset( $src, 'js' );
296 +
297 + // Remember the pre-minify URL for this handle. script_loader_tag
298 + // runs later and only ever sees the rewritten src (a hashed
299 + // /cache/xspeed/min/<key>.js path), so a user's URL-substring
300 + // delay/exclusion target would never match once minification is
301 + // on. Minify_Filters::original_src() gives those checks the URL
302 + // the user actually wrote their target against. (FBS field report)
303 + if ( is_string( $handle ) && '' !== $handle && is_string( $src ) && $src !== $rewritten ) {
304 + Minify_Filters::remember_original_src( $handle, $src );
305 + }
306 +
307 + return $rewritten;
141 308 }
142 309
143 310 /**
144 311 * Replace a local CSS/JS URL with a cached, minified equivalent.
@@ -156,10 +323,12 @@
156 323 if ( false !== strpos( $src, '.min.' ) ) {
157 324 return $src;
158 325 }
159 326
160 - // Skip anything we already produced. The Asset_Combiner writes a
161 - // pre-minified combined-<hash>.css under min/combined/ and enqueues it
327 + // Skip anything we already produced. The Asset_Combiner minifies the
328 + // combined body itself before writing combined-<hash>.css under
329 + // min/combined/ (issue #331 — that used to be asserted here but was
330 + // not actually true, so the artifact shipped unminified), and enqueues it
162 331 // as `xspeed-combined-css`; the per-file minifier used to re-minify
163 332 // that combined output into a SECOND file (min/<hash2>.css) with its
164 333 // own mtime-derived hash. The served HTML then pinned that second
165 334 // hash, so a purge/regeneration (which changes the combined file's
@@ -256,18 +425,175 @@
256 425 * literal `{` / `}` / `[` / `]` that throw off the count by the same
257 426 * amount in both bodies (since they survive minification as-is), so
258 427 * the equality check is robust to that noise.
259 428 */
260 - private static function balanced( string $source, string $minified ): bool {
261 - $pairs = array( '(', ')', '{', '}', '[', ']', '`' );
262 - foreach ( $pairs as $token ) {
263 - if ( substr_count( $source, $token ) !== substr_count( $minified, $token ) ) {
429 + /**
430 + * Minify the body of one captured inline block, or return it untouched.
431 + *
432 + * Only `<style>` and JavaScript `<script>` bodies are eligible:
433 + *
434 + * - `<pre>` / `<textarea>` — whitespace is rendered, never touch it.
435 + * - `<script>` with a non-JS `type` — `application/ld+json`,
436 + * `text/template`, `text/x-handlebars` and anything unrecognised are
437 + * data or markup, not code. Minifying JSON-LD would corrupt structured
438 + * data; minifying a template would eat the markup it holds. An unknown
439 + * type is treated as non-JS on purpose: guessing wrong breaks the page,
440 + * while skipping only forgoes a few bytes.
441 + * - `<script src="...">` — the body is empty; the file path already goes
442 + * through minify_file().
443 + *
444 + * Every result is checked with balanced(), the same structural guard the
445 + * file path uses, so a body the library truncates is shipped as-is rather
446 + * than broken. (#2)
447 + *
448 + * @param string $block Full matched tag, opening tag through closing tag.
449 + * @param string $tag Lowercased tag name.
450 + * @return string Minified block, or $block unchanged.
451 + */
452 + private static function minify_inline_block( string $block, string $tag ): string {
453 + if ( 'style' !== $tag && 'script' !== $tag ) {
454 + return $block; // pre / textarea — significant whitespace.
455 + }
456 + if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
457 + return $block;
458 + }
459 +
460 + // Split into opening tag / body / closing tag. Anything that doesn't
461 + // match this shape isn't something we should be rewriting.
462 + if ( ! preg_match( '#^(<' . $tag . '\b[^>]*>)(.*)(</' . $tag . '\s*>)$#is', $block, $parts ) ) {
463 + return $block;
464 + }
465 + list( , $open, $body, $close ) = $parts;
466 +
467 + if ( '' === trim( $body ) ) {
468 + return $block;
469 + }
470 +
471 + // The tag asked to be left alone (data-no-optimize / data-no-minify —
472 + // the convention consent managers print on their config scripts). (#456)
473 + if ( Minify_Filters::tag_opts_out( $open ) ) {
474 + return $block;
475 + }
476 +
477 + // Refuse a body that is already structurally broken. balanced() only
478 + // compares source against minified, so it passes when BOTH are equally
479 + // unbalanced — `function x( {` minifies to `function x({`, same counts,
480 + // guard satisfied, broken code reformatted. Rewriting a body we can't
481 + // parse risks turning a page that happens to work into one that does
482 + // not, for no gain. (#2 AC: a syntactically broken block is left
483 + // untouched.)
484 + if ( ! self::self_consistent( $body ) ) {
485 + return $block;
486 + }
487 +
488 + if ( 'script' === $tag ) {
489 + // An external script has no body worth minifying.
490 + if ( preg_match( '#\bsrc\s*=#i', $open ) ) {
491 + return $block;
492 + }
493 + // No type, or an explicitly JavaScript type, is code. Everything
494 + // else is data/markup — see the docblock.
495 + $js_types = array(
496 + 'text/javascript',
497 + 'application/javascript',
498 + 'application/ecmascript',
499 + 'text/ecmascript',
500 + 'module',
501 + );
502 + if ( preg_match( '#\btype\s*=\s*["\']?([^"\'\s>]+)#i', $open, $type_match ) ) {
503 + if ( ! in_array( strtolower( trim( $type_match[1] ) ), $js_types, true ) ) {
504 + return $block;
505 + }
506 + }
507 + }
508 +
509 + try {
510 + $minifier = 'style' === $tag
511 + ? new \MatthiasMullie\Minify\CSS()
512 + : new \MatthiasMullie\Minify\JS();
513 + $minifier->add( $body );
514 + $minified = $minifier->minify();
515 + } catch ( \Throwable $e ) {
516 + return $block;
517 + }
518 +
519 + // A minifier that returns nothing for a non-empty body has failed, not
520 + // succeeded — shipping '' would silently delete the rule set.
521 + if ( ! is_string( $minified ) || '' === trim( $minified ) ) {
522 + return $block;
523 + }
524 + if ( ! self::balanced( $body, $minified ) ) {
525 + return $block;
526 + }
527 +
528 + return $open . $minified . $close;
529 + }
530 +
531 + /**
532 + * Does a body's own paired delimiters balance?
533 + *
534 + * balanced() is a RELATIVE check — source against minified — so it cannot
535 + * see input that was already broken: an unbalanced body minifies to an
536 + * equally unbalanced one and the counts still agree. This is the absolute
537 + * check, applied to the source alone before we touch it.
538 + *
539 + * Deliberately naive: it counts tokens without parsing, so a brace inside
540 + * a string or comment skews it. That only ever makes it MORE conservative —
541 + * a false negative skips minification, which costs bytes, while a false
542 + * positive would ship broken code. (#2)
543 + *
544 + * @param string $body Inline block body.
545 + */
546 + private static function self_consistent( string $body ): bool {
547 + $pairs = array(
548 + '{' => '}',
549 + '(' => ')',
550 + '[' => ']',
551 + );
552 + foreach ( $pairs as $open => $close ) {
553 + if ( substr_count( $body, $open ) !== substr_count( $body, $close ) ) {
264 554 return false;
265 555 }
266 556 }
557 + // Backticks and quotes pair with themselves, so an odd count means an
558 + // unterminated literal.
559 + foreach ( array( '`' ) as $token ) {
560 + if ( 0 !== substr_count( $body, $token ) % 2 ) {
561 + return false;
562 + }
563 + }
267 564 return true;
268 565 }
269 566
567 + private static function balanced( string $source, string $minified ): bool {
568 + unset( $source );
569 +
570 + // Judge the OUTPUT, not the difference between input and output.
571 + //
572 + // This used to compare token counts across the pair, on the stated
573 + // assumption that "literal braces inside strings survive minification
574 + // unchanged, so they cancel out". Comments do not: stripping them is
575 + // the minifier's whole job, and every brace, bracket and backtick
576 + // inside one disappears with it. So any file whose comments contain a
577 + // delimiter — a commented-out block, a URL in a docblock, an SVG in a
578 + // note — failed the check and silently shipped unminified.
579 + //
580 + // It is not a rare shape. EmbedPress's front.js counts 372 braces
581 + // against 368, 61 brackets against 58 and 110 backticks against 102
582 + // purely from comment removal, so 67 KB shipped raw where 46 KB was
583 + // correct — and `node --check` confirms that rejected output parses
584 + // fine. A guard that refuses valid work is not conservative, it is
585 + // broken: it costs bytes on every request and reports nothing.
586 + //
587 + // What the guard is FOR still stands (#2): matthiasmullie/minify can
588 + // truncate inside a template literal on complex modern JS and return a
589 + // body that looks minified but is structurally broken. That failure is
590 + // visible in the output alone — an unterminated literal leaves an odd
591 + // backtick count and unmatched braces — which is exactly what
592 + // self_consistent() measures, without the false positives.
593 + return self::self_consistent( $minified );
594 + }
595 +
270 596 /**
271 597 * Resolve a local asset URL to a filesystem path using a strict allowlist
272 598 * of "URL prefix → filesystem prefix" pairs registered with WordPress.
273 599 *
@@ -309,19 +635,40 @@
309 635 array( content_url(), WP_CONTENT_DIR ),
310 636 array( includes_url(), ABSPATH . WPINC ),
311 637 );
312 638
639 + // The host check above normalised the HOST but not the SCHEME, and the
640 + // prefix match below is a plain string compare — so an https asset URL
641 + // never matched an http base and the file silently shipped unminified.
642 + // That is not a corner case: WP_CONTENT_URL is derived from a stored
643 + // option, `plugins_url()` from another, and a site moved to https
644 + // without rewriting every row (or one behind a TLS-terminating proxy
645 + // where `is_ssl()` reads false) serves https pages off http-rooted
646 + // bases all day. Comparing scheme-less is the whole fix; the host
647 + // equality test already did the security work of refusing anything
648 + // off-site, and this runs after it.
649 + $strip_scheme = static function ( string $value ): string {
650 + return (string) preg_replace( '#^https?://#i', '//', $value );
651 + };
652 + $clean_match = $strip_scheme( $clean );
653 +
313 654 foreach ( $candidates as $pair ) {
314 655 list( $url_base, $path_base ) = $pair;
315 656 if ( ! $url_base || ! $path_base ) {
316 657 continue;
317 658 }
318 - $url_base = rtrim( $url_base, '/' );
319 - if ( 0 !== strpos( $clean, $url_base . '/' ) && $clean !== $url_base ) {
659 + $url_base = rtrim( $url_base, '/' );
660 + $base_match = $strip_scheme( $url_base );
661 + if ( 0 !== strpos( $clean_match, $base_match . '/' ) && $clean_match !== $base_match ) {
320 662 continue;
321 663 }
322 664
323 - $relative = ltrim( substr( $clean, strlen( $url_base ) ), '/' );
665 + // Slice the scheme-less pair, not the original. `https://…` and
666 + // `http://…` differ by one byte, so an offset taken from the base
667 + // as written would cut one character short of (or past) the path
668 + // when the two schemes disagree — which is the case this fix
669 + // exists for.
670 + $relative = ltrim( substr( $clean_match, strlen( $base_match ) ), '/' );
324 671 $candidate = trailingslashit( $path_base ) . $relative;
325 672
326 673 $real_base = realpath( $path_base );
327 674 $real = realpath( $candidate );
@@ -349,10 +696,16 @@
349 696 Cache::write_silence( $dir );
350 697 }
351 698 }
352 699
700 + /**
701 + * Clear every minified / combined asset.
702 + *
703 + * @return int Files removed. Most callers are `add_action` callbacks and
704 + * ignore it; `wp xspeed purge` reports it as a line item.
705 + */
353 706 public static function purge_minified() {
354 - self::rmtree_files( self::min_dir() );
707 + return self::rmtree_files( self::min_dir() );
355 708 }
356 709
357 710 /**
358 711 * Recursively delete every file under $dir (and the emptied
@@ -361,18 +714,21 @@
361 714 * min/combined/ were never cleared — a purge left a stale
362 715 * combined-<hash>.css the regenerated page no longer referenced.
363 716 * (FBS-83114 / FBS-83116)
364 717 */
365 - private static function rmtree_files( string $dir ): void {
718 + private static function rmtree_files( string $dir ): int {
366 719 if ( ! is_dir( $dir ) ) {
367 - return;
720 + return 0;
368 721 }
722 + $removed = 0;
369 723 foreach ( (array) glob( $dir . '/*' ) as $path ) {
370 724 if ( is_dir( $path ) ) {
371 - self::rmtree_files( $path );
725 + $removed += self::rmtree_files( $path );
372 726 @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.
373 727 continue;
374 728 }
375 729 wp_delete_file( $path );
730 + ++$removed;
376 731 }
732 + return $removed;
377 733 }
378 734 }