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.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 +295 -23 1.1.21.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,20 +92,59 @@
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
@@ -120,11 +168,33 @@
120 168 private static function is_inline( string $tag ): bool {
121 169 return in_array( strtolower( $tag ), self::INLINE_TAGS, true );
122 170 }
123 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 +
124 195 public static function minify_html( $html ) {
125 - $debug_skip = defined( 'WP_DEBUG' ) && WP_DEBUG;
126 - if ( apply_filters( 'xspeed_skip_minify', $debug_skip ) ) {
196 + if ( '' !== self::skip_reason() ) {
127 197 return $html;
128 198 }
129 199
130 200 $placeholders = array();
@@ -131,10 +201,18 @@
131 201 $pattern = '#<(pre|textarea|script|style)\b[^>]*>.*?</\1>#is';
132 202 $html = preg_replace_callback(
133 203 $pattern,
134 204 function ( $m ) use ( &$placeholders ) {
135 - $key = '__XSPEED_PH_' . count( $placeholders ) . '__';
136 - $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] ) );
137 215 return $key;
138 216 },
139 217 $html
140 218 );
@@ -181,10 +259,21 @@
181 259 return self::rewrite_asset( $src, 'css' );
182 260 }
183 261
184 262 public static function rewrite_script( $src, $handle ) {
185 - unset( $handle );
186 - 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;
187 276 }
188 277
189 278 /**
190 279 * Replace a local CSS/JS URL with a cached, minified equivalent.
@@ -202,10 +291,12 @@
202 291 if ( false !== strpos( $src, '.min.' ) ) {
203 292 return $src;
204 293 }
205 294
206 - // Skip anything we already produced. The Asset_Combiner writes a
207 - // 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
208 299 // as `xspeed-combined-css`; the per-file minifier used to re-minify
209 300 // that combined output into a SECOND file (min/<hash2>.css) with its
210 301 // own mtime-derived hash. The served HTML then pinned that second
211 302 // hash, so a purge/regeneration (which changes the combined file's
@@ -302,18 +393,169 @@
302 393 * literal `{` / `}` / `[` / `]` that throw off the count by the same
303 394 * amount in both bodies (since they survive minification as-is), so
304 395 * the equality check is robust to that noise.
305 396 */
306 - private static function balanced( string $source, string $minified ): bool {
307 - $pairs = array( '(', ')', '{', '}', '[', ']', '`' );
308 - foreach ( $pairs as $token ) {
309 - 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 ) ) {
310 516 return false;
311 517 }
312 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 + }
313 526 return true;
314 527 }
315 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 +
316 558 /**
317 559 * Resolve a local asset URL to a filesystem path using a strict allowlist
318 560 * of "URL prefix → filesystem prefix" pairs registered with WordPress.
319 561 *
@@ -355,19 +597,40 @@
355 597 array( content_url(), WP_CONTENT_DIR ),
356 598 array( includes_url(), ABSPATH . WPINC ),
357 599 );
358 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 +
359 616 foreach ( $candidates as $pair ) {
360 617 list( $url_base, $path_base ) = $pair;
361 618 if ( ! $url_base || ! $path_base ) {
362 619 continue;
363 620 }
364 - $url_base = rtrim( $url_base, '/' );
365 - 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 ) {
366 624 continue;
367 625 }
368 626
369 - $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 ) ), '/' );
370 633 $candidate = trailingslashit( $path_base ) . $relative;
371 634
372 635 $real_base = realpath( $path_base );
373 636 $real = realpath( $candidate );
@@ -395,10 +658,16 @@
395 658 Cache::write_silence( $dir );
396 659 }
397 660 }
398 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 + */
399 668 public static function purge_minified() {
400 - self::rmtree_files( self::min_dir() );
669 + return self::rmtree_files( self::min_dir() );
401 670 }
402 671
403 672 /**
404 673 * Recursively delete every file under $dir (and the emptied
@@ -407,18 +676,21 @@
407 676 * min/combined/ were never cleared — a purge left a stale
408 677 * combined-<hash>.css the regenerated page no longer referenced.
409 678 * (FBS-83114 / FBS-83116)
410 679 */
411 - private static function rmtree_files( string $dir ): void {
680 + private static function rmtree_files( string $dir ): int {
412 681 if ( ! is_dir( $dir ) ) {
413 - return;
682 + return 0;
414 683 }
684 + $removed = 0;
415 685 foreach ( (array) glob( $dir . '/*' ) as $path ) {
416 686 if ( is_dir( $path ) ) {
417 - self::rmtree_files( $path );
687 + $removed += self::rmtree_files( $path );
418 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.
419 689 continue;
420 690 }
421 691 wp_delete_file( $path );
692 + ++$removed;
422 693 }
694 + return $removed;
423 695 }
424 696 }