PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
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 1.1.6 All 31 releases
← All changes | includes/class-minifier.php +342 -24 1.0.91.3.2 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.
@@ -83,27 +92,109 @@
83 92 // plain defer — when both are on, delay wins (the bootstrap
84 93 // will re-attach as a regular <script> on interaction).
85 94 add_filter( 'script_loader_tag', array( Minify_Filters::class, 'delay_script_tag' ), 30, 3 );
86 95 add_action( 'wp_footer', array( Minify_Filters::class, 'print_delay_bootstrap' ), 1000 );
96 + // script_loader_tag only fires for wp_enqueue_script()'d assets.
97 + // Analytics / pixel / chat-widget tags printed straight into
98 + // wp_head bypass it, and those are usually the heaviest scripts
99 + // on the page — so sweep the finished buffer too. Runs before
100 + // minify_html (same filter, default priority) and is baked into
101 + // the cache file, so it replays on static hits where PHP never
102 + // boots.
103 + add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'delay_raw_script_tags' ), 20 );
104 + // The vendors' own install snippets are INLINE (no src at all),
105 + // so the src sweep above never sees them; this one parks an
106 + // inline body that names a known third-party host.
107 + add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'delay_inline_snippets' ), 21 );
87 108 }
88 109 if ( ! empty( $opts['async_css'] ) ) {
89 110 add_filter( 'style_loader_tag', array( Minify_Filters::class, 'async_style_tag' ), 20, 2 );
111 + // style_loader_tag only fires for wp_enqueue_style()'d sheets.
112 + // Themes print Google/Bunny/Typekit font CSS as literal <link>
113 + // markup in the head, so those sheets never reach the filter and
114 + // stay render-blocking — sweep the finished buffer for the known
115 + // font-CSS hosts. Baked into the cache file, so it replays on
116 + // static hits where PHP never boots.
117 + add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'async_raw_font_css_links' ), 22 );
90 118 }
91 119
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.
120 + /*
121 + * CSS combining runs on the FINISHED HTML, not the enqueue queue.
122 + *
123 + * The queue-walking version could not be made correct: whatever it
124 + * wrote at priority 999, WordPress edited afterwards. Core's
125 + * wp_maybe_inline_styles() inlines any queued handle carrying a `path`
126 + * and sets src=false on it, which silently threw away the combined URL
127 + * and took the sheets we had blanked with it — six stylesheets became
128 + * one and the site rendered unstyled. See Css_Combine_Buffer's header
129 + * for the full trace. (#195)
130 + *
131 + * Two entry points, because the page cache's filter is not always
132 + * available: `xspeed_cache_final_html` fires only on a cacheable MISS,
133 + * so on a site with the cache off — or on an excluded URL like /cart —
134 + * combining would silently stop working. Css_Combine_Buffer::boot()
135 + * opens its own buffer in exactly those cases and no-ops otherwise, so
136 + * the page is transformed once either way.
137 + */
95 138 if ( ! empty( $opts['combine_css'] ) ) {
96 - add_action( 'wp_enqueue_scripts', array( Asset_Combiner::class, 'combine_styles' ), 999 );
139 + add_filter( 'xspeed_cache_final_html', array( Css_Combine_Buffer::class, 'process' ), 5 );
140 + Css_Combine_Buffer::boot();
97 141 }
98 142 if ( ! empty( $opts['combine_js'] ) ) {
143 + // JS stays on the enqueue path for now: dependency order,
144 + // async/defer and wp_add_inline_script make it a different
145 + // problem, and the reported break is CSS-only. Moving it is worth
146 + // its own change rather than doubling the blast radius here.
99 147 add_action( 'wp_enqueue_scripts', array( Asset_Combiner::class, 'combine_scripts' ), 999 );
100 148 }
101 149 }
102 150
151 + /**
152 + * HTML elements that participate in an inline formatting context, where
153 + * whitespace between two of them renders as a visible space.
154 + *
155 + * Deliberately excludes <br> (nothing to separate) and replaced/embedded
156 + * inline elements that sit alone. Anything not listed is treated as block
157 + * level, where inter-tag whitespace collapses to nothing and is safe to
158 + * strip. (FBS-84090)
159 + */
160 + private const INLINE_TAGS = array(
161 + 'a', 'abbr', 'b', 'bdi', 'bdo', 'cite', 'code', 'data', 'del', 'dfn',
162 + 'em', 'i', 'ins', 'kbd', 'label', 'mark', 'q', 'rp', 'rt', 'ruby',
163 + 's', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'time', 'u',
164 + 'var', 'wbr', 'img', 'button', 'select', 'output',
165 + );
166 +
167 + /** True when $tag renders inline, so whitespace beside it is visible. */
168 + private static function is_inline( string $tag ): bool {
169 + return in_array( strtolower( $tag ), self::INLINE_TAGS, true );
170 + }
171 +
172 + /**
173 + * Why minification is being skipped, when it is. '' when it will run.
174 + *
175 + * minify_html can read "on" in every settings surface while producing
176 + * byte-identical HTML, because the guard below silently returns the
177 + * input. Field report: a live site showed `minify_html: on` with 3,856
178 + * indented lines in the delivered HTML and nothing anywhere explaining
179 + * the contradiction — the setting looked broken rather than suppressed.
180 + * Callers that report status MUST consult this so the refusal is
181 + * visible. (Same class as Cache::static_rewrite_block_reason().)
182 + *
183 + * @return string 'wp_debug', 'filter', or ''.
184 + */
185 + public static function skip_reason(): string {
186 + $debug_skip = defined( 'WP_DEBUG' ) && WP_DEBUG;
187 + if ( ! apply_filters( 'xspeed_skip_minify', $debug_skip ) ) {
188 + return '';
189 + }
190 + // Distinguish the built-in WP_DEBUG rule from a third party
191 + // filtering the escape hatch — the fixes are different.
192 + return $debug_skip ? 'wp_debug' : 'filter';
193 + }
194 +
103 195 public static function minify_html( $html ) {
104 - $debug_skip = defined( 'WP_DEBUG' ) && WP_DEBUG;
105 - if ( apply_filters( 'xspeed_skip_minify', $debug_skip ) ) {
196 + if ( '' !== self::skip_reason() ) {
106 197 return $html;
107 198 }
108 199
109 200 $placeholders = array();
@@ -110,10 +201,18 @@
110 201 $pattern = '#<(pre|textarea|script|style)\b[^>]*>.*?</\1>#is';
111 202 $html = preg_replace_callback(
112 203 $pattern,
113 204 function ( $m ) use ( &$placeholders ) {
114 - $key = '__XSPEED_PH_' . count( $placeholders ) . '__';
115 - $placeholders[ $key ] = $m[0];
205 + $key = '__XSPEED_PH_' . count( $placeholders ) . '__';
206 + // The placeholder pass exists to protect content whose
207 + // whitespace is significant (<pre>, <textarea>) and to keep
208 + // the tag-boundary regex off script bodies. <style> and
209 + // <script> were grouped in with them, so protection became a
210 + // permanent exemption: on builder sites where most CSS is
211 + // inline, a page with minify ON shipped fully indented. Minify
212 + // the BODY here, before it's stashed, so the outer passes
213 + // still never see it. (#2)
214 + $placeholders[ $key ] = self::minify_inline_block( $m[0], strtolower( $m[1] ) );
116 215 return $key;
117 216 },
118 217 $html
119 218 );
@@ -119,9 +218,34 @@
119 218 );
120 219
121 220 $html = preg_replace( '/<!--(?!\[if).*?-->/s', '', $html );
122 221 $html = preg_replace( '/\s+/', ' ', $html );
123 - $html = preg_replace( '/>\s+</', '><', $html );
222 +
223 + /*
224 + * Collapse whitespace BETWEEN TAGS — but never where it is visible.
225 + *
226 + * Whitespace separating two INLINE elements is a real, rendered space:
227 + * WooCommerce emits `</del> <ins>` for a sale price, and that single
228 + * character is the gap between "$32.50" and "$29.50". Stripping it
229 + * printed "$32.50$29.50" run together, and only with cache on — the
230 + * un-minified page was fine. (FBS-84090)
231 + *
232 + * So the strip only applies when at least one side is a BLOCK-level
233 + * (or non-rendered) tag, where the whitespace collapses away anyway.
234 + * Inline-to-inline boundaries keep their single space.
235 + */
236 + $html = preg_replace_callback(
237 + // left tag name (may be a closing tag) … whitespace … right tag name
238 + '#</?([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>\s+<(/?)([a-zA-Z][a-zA-Z0-9-]*)#',
239 + static function ( $m ) {
240 + // Keep the space only when BOTH sides are inline elements —
241 + // that is the one case where it is actually rendered.
242 + $keep = self::is_inline( $m[1] ) && self::is_inline( $m[3] );
243 + $open = substr( $m[0], 0, strrpos( $m[0], '<' ) ); // through the left tag's '>'
244 + return rtrim( $open ) . ( $keep ? ' ' : '' ) . '<' . $m[2] . $m[3];
245 + },
246 + $html
247 + );
124 248 $html = trim( $html );
125 249
126 250 foreach ( $placeholders as $key => $original ) {
127 251 $html = str_replace( $key, $original, $html );
@@ -135,10 +259,21 @@
135 259 return self::rewrite_asset( $src, 'css' );
136 260 }
137 261
138 262 public static function rewrite_script( $src, $handle ) {
139 - unset( $handle );
140 - return self::rewrite_asset( $src, 'js' );
263 + $rewritten = self::rewrite_asset( $src, 'js' );
264 +
265 + // Remember the pre-minify URL for this handle. script_loader_tag
266 + // runs later and only ever sees the rewritten src (a hashed
267 + // /cache/xspeed/min/<key>.js path), so a user's URL-substring
268 + // delay/exclusion target would never match once minification is
269 + // on. Minify_Filters::original_src() gives those checks the URL
270 + // the user actually wrote their target against. (FBS field report)
271 + if ( is_string( $handle ) && '' !== $handle && is_string( $src ) && $src !== $rewritten ) {
272 + Minify_Filters::remember_original_src( $handle, $src );
273 + }
274 +
275 + return $rewritten;
141 276 }
142 277
143 278 /**
144 279 * Replace a local CSS/JS URL with a cached, minified equivalent.
@@ -156,10 +291,12 @@
156 291 if ( false !== strpos( $src, '.min.' ) ) {
157 292 return $src;
158 293 }
159 294
160 - // Skip anything we already produced. The Asset_Combiner writes a
161 - // pre-minified combined-<hash>.css under min/combined/ and enqueues it
295 + // Skip anything we already produced. The Asset_Combiner minifies the
296 + // combined body itself before writing combined-<hash>.css under
297 + // min/combined/ (issue #331 — that used to be asserted here but was
298 + // not actually true, so the artifact shipped unminified), and enqueues it
162 299 // as `xspeed-combined-css`; the per-file minifier used to re-minify
163 300 // that combined output into a SECOND file (min/<hash2>.css) with its
164 301 // own mtime-derived hash. The served HTML then pinned that second
165 302 // hash, so a purge/regeneration (which changes the combined file's
@@ -256,18 +393,169 @@
256 393 * literal `{` / `}` / `[` / `]` that throw off the count by the same
257 394 * amount in both bodies (since they survive minification as-is), so
258 395 * the equality check is robust to that noise.
259 396 */
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 ) ) {
397 + /**
398 + * Minify the body of one captured inline block, or return it untouched.
399 + *
400 + * Only `<style>` and JavaScript `<script>` bodies are eligible:
401 + *
402 + * - `<pre>` / `<textarea>` — whitespace is rendered, never touch it.
403 + * - `<script>` with a non-JS `type` — `application/ld+json`,
404 + * `text/template`, `text/x-handlebars` and anything unrecognised are
405 + * data or markup, not code. Minifying JSON-LD would corrupt structured
406 + * data; minifying a template would eat the markup it holds. An unknown
407 + * type is treated as non-JS on purpose: guessing wrong breaks the page,
408 + * while skipping only forgoes a few bytes.
409 + * - `<script src="...">` — the body is empty; the file path already goes
410 + * through minify_file().
411 + *
412 + * Every result is checked with balanced(), the same structural guard the
413 + * file path uses, so a body the library truncates is shipped as-is rather
414 + * than broken. (#2)
415 + *
416 + * @param string $block Full matched tag, opening tag through closing tag.
417 + * @param string $tag Lowercased tag name.
418 + * @return string Minified block, or $block unchanged.
419 + */
420 + private static function minify_inline_block( string $block, string $tag ): string {
421 + if ( 'style' !== $tag && 'script' !== $tag ) {
422 + return $block; // pre / textarea — significant whitespace.
423 + }
424 + if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
425 + return $block;
426 + }
427 +
428 + // Split into opening tag / body / closing tag. Anything that doesn't
429 + // match this shape isn't something we should be rewriting.
430 + if ( ! preg_match( '#^(<' . $tag . '\b[^>]*>)(.*)(</' . $tag . '\s*>)$#is', $block, $parts ) ) {
431 + return $block;
432 + }
433 + list( , $open, $body, $close ) = $parts;
434 +
435 + if ( '' === trim( $body ) ) {
436 + return $block;
437 + }
438 +
439 + // Refuse a body that is already structurally broken. balanced() only
440 + // compares source against minified, so it passes when BOTH are equally
441 + // unbalanced — `function x( {` minifies to `function x({`, same counts,
442 + // guard satisfied, broken code reformatted. Rewriting a body we can't
443 + // parse risks turning a page that happens to work into one that does
444 + // not, for no gain. (#2 AC: a syntactically broken block is left
445 + // untouched.)
446 + if ( ! self::self_consistent( $body ) ) {
447 + return $block;
448 + }
449 +
450 + if ( 'script' === $tag ) {
451 + // An external script has no body worth minifying.
452 + if ( preg_match( '#\bsrc\s*=#i', $open ) ) {
453 + return $block;
454 + }
455 + // No type, or an explicitly JavaScript type, is code. Everything
456 + // else is data/markup — see the docblock.
457 + $js_types = array(
458 + 'text/javascript',
459 + 'application/javascript',
460 + 'application/ecmascript',
461 + 'text/ecmascript',
462 + 'module',
463 + );
464 + if ( preg_match( '#\btype\s*=\s*["\']?([^"\'\s>]+)#i', $open, $type_match ) ) {
465 + if ( ! in_array( strtolower( trim( $type_match[1] ) ), $js_types, true ) ) {
466 + return $block;
467 + }
468 + }
469 + }
470 +
471 + try {
472 + $minifier = 'style' === $tag
473 + ? new \MatthiasMullie\Minify\CSS()
474 + : new \MatthiasMullie\Minify\JS();
475 + $minifier->add( $body );
476 + $minified = $minifier->minify();
477 + } catch ( \Throwable $e ) {
478 + return $block;
479 + }
480 +
481 + // A minifier that returns nothing for a non-empty body has failed, not
482 + // succeeded — shipping '' would silently delete the rule set.
483 + if ( ! is_string( $minified ) || '' === trim( $minified ) ) {
484 + return $block;
485 + }
486 + if ( ! self::balanced( $body, $minified ) ) {
487 + return $block;
488 + }
489 +
490 + return $open . $minified . $close;
491 + }
492 +
493 + /**
494 + * Does a body's own paired delimiters balance?
495 + *
496 + * balanced() is a RELATIVE check — source against minified — so it cannot
497 + * see input that was already broken: an unbalanced body minifies to an
498 + * equally unbalanced one and the counts still agree. This is the absolute
499 + * check, applied to the source alone before we touch it.
500 + *
501 + * Deliberately naive: it counts tokens without parsing, so a brace inside
502 + * a string or comment skews it. That only ever makes it MORE conservative —
503 + * a false negative skips minification, which costs bytes, while a false
504 + * positive would ship broken code. (#2)
505 + *
506 + * @param string $body Inline block body.
507 + */
508 + private static function self_consistent( string $body ): bool {
509 + $pairs = array(
510 + '{' => '}',
511 + '(' => ')',
512 + '[' => ']',
513 + );
514 + foreach ( $pairs as $open => $close ) {
515 + if ( substr_count( $body, $open ) !== substr_count( $body, $close ) ) {
264 516 return false;
265 517 }
266 518 }
519 + // Backticks and quotes pair with themselves, so an odd count means an
520 + // unterminated literal.
521 + foreach ( array( '`' ) as $token ) {
522 + if ( 0 !== substr_count( $body, $token ) % 2 ) {
523 + return false;
524 + }
525 + }
267 526 return true;
268 527 }
269 528
529 + private static function balanced( string $source, string $minified ): bool {
530 + unset( $source );
531 +
532 + // Judge the OUTPUT, not the difference between input and output.
533 + //
534 + // This used to compare token counts across the pair, on the stated
535 + // assumption that "literal braces inside strings survive minification
536 + // unchanged, so they cancel out". Comments do not: stripping them is
537 + // the minifier's whole job, and every brace, bracket and backtick
538 + // inside one disappears with it. So any file whose comments contain a
539 + // delimiter — a commented-out block, a URL in a docblock, an SVG in a
540 + // note — failed the check and silently shipped unminified.
541 + //
542 + // It is not a rare shape. EmbedPress's front.js counts 372 braces
543 + // against 368, 61 brackets against 58 and 110 backticks against 102
544 + // purely from comment removal, so 67 KB shipped raw where 46 KB was
545 + // correct — and `node --check` confirms that rejected output parses
546 + // fine. A guard that refuses valid work is not conservative, it is
547 + // broken: it costs bytes on every request and reports nothing.
548 + //
549 + // What the guard is FOR still stands (#2): matthiasmullie/minify can
550 + // truncate inside a template literal on complex modern JS and return a
551 + // body that looks minified but is structurally broken. That failure is
552 + // visible in the output alone — an unterminated literal leaves an odd
553 + // backtick count and unmatched braces — which is exactly what
554 + // self_consistent() measures, without the false positives.
555 + return self::self_consistent( $minified );
556 + }
557 +
270 558 /**
271 559 * Resolve a local asset URL to a filesystem path using a strict allowlist
272 560 * of "URL prefix → filesystem prefix" pairs registered with WordPress.
273 561 *
@@ -309,19 +597,40 @@
309 597 array( content_url(), WP_CONTENT_DIR ),
310 598 array( includes_url(), ABSPATH . WPINC ),
311 599 );
312 600
601 + // The host check above normalised the HOST but not the SCHEME, and the
602 + // prefix match below is a plain string compare — so an https asset URL
603 + // never matched an http base and the file silently shipped unminified.
604 + // That is not a corner case: WP_CONTENT_URL is derived from a stored
605 + // option, `plugins_url()` from another, and a site moved to https
606 + // without rewriting every row (or one behind a TLS-terminating proxy
607 + // where `is_ssl()` reads false) serves https pages off http-rooted
608 + // bases all day. Comparing scheme-less is the whole fix; the host
609 + // equality test already did the security work of refusing anything
610 + // off-site, and this runs after it.
611 + $strip_scheme = static function ( string $value ): string {
612 + return (string) preg_replace( '#^https?://#i', '//', $value );
613 + };
614 + $clean_match = $strip_scheme( $clean );
615 +
313 616 foreach ( $candidates as $pair ) {
314 617 list( $url_base, $path_base ) = $pair;
315 618 if ( ! $url_base || ! $path_base ) {
316 619 continue;
317 620 }
318 - $url_base = rtrim( $url_base, '/' );
319 - if ( 0 !== strpos( $clean, $url_base . '/' ) && $clean !== $url_base ) {
621 + $url_base = rtrim( $url_base, '/' );
622 + $base_match = $strip_scheme( $url_base );
623 + if ( 0 !== strpos( $clean_match, $base_match . '/' ) && $clean_match !== $base_match ) {
320 624 continue;
321 625 }
322 626
323 - $relative = ltrim( substr( $clean, strlen( $url_base ) ), '/' );
627 + // Slice the scheme-less pair, not the original. `https://…` and
628 + // `http://…` differ by one byte, so an offset taken from the base
629 + // as written would cut one character short of (or past) the path
630 + // when the two schemes disagree — which is the case this fix
631 + // exists for.
632 + $relative = ltrim( substr( $clean_match, strlen( $base_match ) ), '/' );
324 633 $candidate = trailingslashit( $path_base ) . $relative;
325 634
326 635 $real_base = realpath( $path_base );
327 636 $real = realpath( $candidate );
@@ -349,10 +658,16 @@
349 658 Cache::write_silence( $dir );
350 659 }
351 660 }
352 661
662 + /**
663 + * Clear every minified / combined asset.
664 + *
665 + * @return int Files removed. Most callers are `add_action` callbacks and
666 + * ignore it; `wp xspeed purge` reports it as a line item.
667 + */
353 668 public static function purge_minified() {
354 - self::rmtree_files( self::min_dir() );
669 + return self::rmtree_files( self::min_dir() );
355 670 }
356 671
357 672 /**
358 673 * Recursively delete every file under $dir (and the emptied
@@ -361,18 +676,21 @@
361 676 * min/combined/ were never cleared — a purge left a stale
362 677 * combined-<hash>.css the regenerated page no longer referenced.
363 678 * (FBS-83114 / FBS-83116)
364 679 */
365 - private static function rmtree_files( string $dir ): void {
680 + private static function rmtree_files( string $dir ): int {
366 681 if ( ! is_dir( $dir ) ) {
367 - return;
682 + return 0;
368 683 }
684 + $removed = 0;
369 685 foreach ( (array) glob( $dir . '/*' ) as $path ) {
370 686 if ( is_dir( $path ) ) {
371 - self::rmtree_files( $path );
687 + $removed += self::rmtree_files( $path );
372 688 @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 689 continue;
374 690 }
375 691 wp_delete_file( $path );
692 + ++$removed;
376 693 }
694 + return $removed;
377 695 }
378 696 }