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 +250 -19 1.1.41.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.
@@ -91,20 +100,51 @@
91 100 // minify_html (same filter, default priority) and is baked into
92 101 // the cache file, so it replays on static hits where PHP never
93 102 // boots.
94 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 );
95 108 }
96 109 if ( ! empty( $opts['async_css'] ) ) {
97 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 );
98 118 }
99 119
100 - // Phase 4.1b — combine engine. Hook late so every plugin /
101 - // theme has finished enqueueing by the time we walk the queue.
102 - // 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 + */
103 138 if ( ! empty( $opts['combine_css'] ) ) {
104 - 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();
105 141 }
106 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.
107 147 add_action( 'wp_enqueue_scripts', array( Asset_Combiner::class, 'combine_scripts' ), 999 );
108 148 }
109 149 }
110 150
@@ -161,10 +201,18 @@
161 201 $pattern = '#<(pre|textarea|script|style)\b[^>]*>.*?</\1>#is';
162 202 $html = preg_replace_callback(
163 203 $pattern,
164 204 function ( $m ) use ( &$placeholders ) {
165 - $key = '__XSPEED_PH_' . count( $placeholders ) . '__';
166 - $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] ) );
167 215 return $key;
168 216 },
169 217 $html
170 218 );
@@ -243,10 +291,12 @@
243 291 if ( false !== strpos( $src, '.min.' ) ) {
244 292 return $src;
245 293 }
246 294
247 - // Skip anything we already produced. The Asset_Combiner writes a
248 - // 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
249 299 // as `xspeed-combined-css`; the per-file minifier used to re-minify
250 300 // that combined output into a SECOND file (min/<hash2>.css) with its
251 301 // own mtime-derived hash. The served HTML then pinned that second
252 302 // hash, so a purge/regeneration (which changes the combined file's
@@ -343,18 +393,169 @@
343 393 * literal `{` / `}` / `[` / `]` that throw off the count by the same
344 394 * amount in both bodies (since they survive minification as-is), so
345 395 * the equality check is robust to that noise.
346 396 */
347 - private static function balanced( string $source, string $minified ): bool {
348 - $pairs = array( '(', ')', '{', '}', '[', ']', '`' );
349 - foreach ( $pairs as $token ) {
350 - 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 ) ) {
351 516 return false;
352 517 }
353 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 + }
354 526 return true;
355 527 }
356 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 +
357 558 /**
358 559 * Resolve a local asset URL to a filesystem path using a strict allowlist
359 560 * of "URL prefix → filesystem prefix" pairs registered with WordPress.
360 561 *
@@ -396,19 +597,40 @@
396 597 array( content_url(), WP_CONTENT_DIR ),
397 598 array( includes_url(), ABSPATH . WPINC ),
398 599 );
399 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 +
400 616 foreach ( $candidates as $pair ) {
401 617 list( $url_base, $path_base ) = $pair;
402 618 if ( ! $url_base || ! $path_base ) {
403 619 continue;
404 620 }
405 - $url_base = rtrim( $url_base, '/' );
406 - 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 ) {
407 624 continue;
408 625 }
409 626
410 - $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 ) ), '/' );
411 633 $candidate = trailingslashit( $path_base ) . $relative;
412 634
413 635 $real_base = realpath( $path_base );
414 636 $real = realpath( $candidate );
@@ -436,10 +658,16 @@
436 658 Cache::write_silence( $dir );
437 659 }
438 660 }
439 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 + */
440 668 public static function purge_minified() {
441 - self::rmtree_files( self::min_dir() );
669 + return self::rmtree_files( self::min_dir() );
442 670 }
443 671
444 672 /**
445 673 * Recursively delete every file under $dir (and the emptied
@@ -448,18 +676,21 @@
448 676 * min/combined/ were never cleared — a purge left a stale
449 677 * combined-<hash>.css the regenerated page no longer referenced.
450 678 * (FBS-83114 / FBS-83116)
451 679 */
452 - private static function rmtree_files( string $dir ): void {
680 + private static function rmtree_files( string $dir ): int {
453 681 if ( ! is_dir( $dir ) ) {
454 - return;
682 + return 0;
455 683 }
684 + $removed = 0;
456 685 foreach ( (array) glob( $dir . '/*' ) as $path ) {
457 686 if ( is_dir( $path ) ) {
458 - self::rmtree_files( $path );
687 + $removed += self::rmtree_files( $path );
459 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.
460 689 continue;
461 690 }
462 691 wp_delete_file( $path );
692 + ++$removed;
463 693 }
694 + return $removed;
464 695 }
465 696 }