PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.4
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 1.1.7 All 30 releases
← All changes | includes/class-minifier.php +106 -14 1.2.41.3.4 View file →
@@ -75,8 +75,15 @@
75 75 add_filter( 'style_loader_src', array( __CLASS__, 'rewrite_style' ), 10, 2 );
76 76 }
77 77 if ( ! empty( $opts['minify_js'] ) ) {
78 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 );
79 86 }
80 87
81 88 // Phase 4.1a — filter-only "smarter minifier" features. Each is
82 89 // gated on its own toggle so users can enable any subset.
@@ -100,11 +107,41 @@
100 107 // minify_html (same filter, default priority) and is baked into
101 108 // the cache file, so it replays on static hits where PHP never
102 109 // boots.
103 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 );
104 115 }
116 +
117 + // Everything above honors data-no-optimize / data-no-minify, but
118 + // only sees markers stamped before priority 30. Borlabs Cookie
119 + // stamps at 100, so its consent config was minified AND delayed
120 + // despite carrying both markers. Snapshot the tag before our
121 + // transforms (9) and hand the original back if a marker turns up
122 + // after them (1000, past Borlabs' own 100 and 999). Registered
123 + // whenever any of the three is on,
124 + // since each one is individually enough to damage a marked
125 + // script. (#469)
126 + if ( ! empty( $opts['minify_js'] ) || ! empty( $opts['defer_js'] ) || ! empty( $opts['delay_js'] ) ) {
127 + add_filter( 'script_loader_tag', array( Minify_Filters::class, 'snapshot_tag' ), 9, 3 );
128 + add_filter(
129 + 'script_loader_tag',
130 + array( Minify_Filters::class, 'revert_late_marked_tag' ),
131 + Minify_Filters::late_opt_out_priority(),
132 + 3
133 + );
134 + }
105 135 if ( ! empty( $opts['async_css'] ) ) {
106 136 add_filter( 'style_loader_tag', array( Minify_Filters::class, 'async_style_tag' ), 20, 2 );
137 + // style_loader_tag only fires for wp_enqueue_style()'d sheets.
138 + // Themes print Google/Bunny/Typekit font CSS as literal <link>
139 + // markup in the head, so those sheets never reach the filter and
140 + // stay render-blocking — sweep the finished buffer for the known
141 + // font-CSS hosts. Baked into the cache file, so it replays on
142 + // static hits where PHP never boots.
143 + add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'async_raw_font_css_links' ), 22 );
107 144 }
108 145
109 146 /*
110 147 * CSS combining runs on the FINISHED HTML, not the enqueue queue.
@@ -424,8 +461,14 @@
424 461 if ( '' === trim( $body ) ) {
425 462 return $block;
426 463 }
427 464
465 + // The tag asked to be left alone (data-no-optimize / data-no-minify —
466 + // the convention consent managers print on their config scripts). (#456)
467 + if ( Minify_Filters::tag_opts_out( $open ) ) {
468 + return $block;
469 + }
470 +
428 471 // Refuse a body that is already structurally broken. balanced() only
429 472 // compares source against minified, so it passes when BOTH are equally
430 473 // unbalanced — `function x( {` minifies to `function x({`, same counts,
431 474 // guard satisfied, broken code reformatted. Rewriting a body we can't
@@ -515,15 +558,34 @@
515 558 return true;
516 559 }
517 560
518 561 private static function balanced( string $source, string $minified ): bool {
519 - $pairs = array( '(', ')', '{', '}', '[', ']', '`' );
520 - foreach ( $pairs as $token ) {
521 - if ( substr_count( $source, $token ) !== substr_count( $minified, $token ) ) {
522 - return false;
523 - }
524 - }
525 - return true;
562 + unset( $source );
563 +
564 + // Judge the OUTPUT, not the difference between input and output.
565 + //
566 + // This used to compare token counts across the pair, on the stated
567 + // assumption that "literal braces inside strings survive minification
568 + // unchanged, so they cancel out". Comments do not: stripping them is
569 + // the minifier's whole job, and every brace, bracket and backtick
570 + // inside one disappears with it. So any file whose comments contain a
571 + // delimiter — a commented-out block, a URL in a docblock, an SVG in a
572 + // note — failed the check and silently shipped unminified.
573 + //
574 + // It is not a rare shape. EmbedPress's front.js counts 372 braces
575 + // against 368, 61 brackets against 58 and 110 backticks against 102
576 + // purely from comment removal, so 67 KB shipped raw where 46 KB was
577 + // correct — and `node --check` confirms that rejected output parses
578 + // fine. A guard that refuses valid work is not conservative, it is
579 + // broken: it costs bytes on every request and reports nothing.
580 + //
581 + // What the guard is FOR still stands (#2): matthiasmullie/minify can
582 + // truncate inside a template literal on complex modern JS and return a
583 + // body that looks minified but is structurally broken. That failure is
584 + // visible in the output alone — an unterminated literal leaves an odd
585 + // backtick count and unmatched braces — which is exactly what
586 + // self_consistent() measures, without the false positives.
587 + return self::self_consistent( $minified );
526 588 }
527 589
528 590 /**
529 591 * Resolve a local asset URL to a filesystem path using a strict allowlist
@@ -567,19 +629,40 @@
567 629 array( content_url(), WP_CONTENT_DIR ),
568 630 array( includes_url(), ABSPATH . WPINC ),
569 631 );
570 632
633 + // The host check above normalised the HOST but not the SCHEME, and the
634 + // prefix match below is a plain string compare — so an https asset URL
635 + // never matched an http base and the file silently shipped unminified.
636 + // That is not a corner case: WP_CONTENT_URL is derived from a stored
637 + // option, `plugins_url()` from another, and a site moved to https
638 + // without rewriting every row (or one behind a TLS-terminating proxy
639 + // where `is_ssl()` reads false) serves https pages off http-rooted
640 + // bases all day. Comparing scheme-less is the whole fix; the host
641 + // equality test already did the security work of refusing anything
642 + // off-site, and this runs after it.
643 + $strip_scheme = static function ( string $value ): string {
644 + return (string) preg_replace( '#^https?://#i', '//', $value );
645 + };
646 + $clean_match = $strip_scheme( $clean );
647 +
571 648 foreach ( $candidates as $pair ) {
572 649 list( $url_base, $path_base ) = $pair;
573 650 if ( ! $url_base || ! $path_base ) {
574 651 continue;
575 652 }
576 - $url_base = rtrim( $url_base, '/' );
577 - if ( 0 !== strpos( $clean, $url_base . '/' ) && $clean !== $url_base ) {
653 + $url_base = rtrim( $url_base, '/' );
654 + $base_match = $strip_scheme( $url_base );
655 + if ( 0 !== strpos( $clean_match, $base_match . '/' ) && $clean_match !== $base_match ) {
578 656 continue;
579 657 }
580 658
581 - $relative = ltrim( substr( $clean, strlen( $url_base ) ), '/' );
659 + // Slice the scheme-less pair, not the original. `https://…` and
660 + // `http://…` differ by one byte, so an offset taken from the base
661 + // as written would cut one character short of (or past) the path
662 + // when the two schemes disagree — which is the case this fix
663 + // exists for.
664 + $relative = ltrim( substr( $clean_match, strlen( $base_match ) ), '/' );
582 665 $candidate = trailingslashit( $path_base ) . $relative;
583 666
584 667 $real_base = realpath( $path_base );
585 668 $real = realpath( $candidate );
@@ -607,10 +690,16 @@
607 690 Cache::write_silence( $dir );
608 691 }
609 692 }
610 693
694 + /**
695 + * Clear every minified / combined asset.
696 + *
697 + * @return int Files removed. Most callers are `add_action` callbacks and
698 + * ignore it; `wp xspeed purge` reports it as a line item.
699 + */
611 700 public static function purge_minified() {
612 - self::rmtree_files( self::min_dir() );
701 + return self::rmtree_files( self::min_dir() );
613 702 }
614 703
615 704 /**
616 705 * Recursively delete every file under $dir (and the emptied
@@ -619,18 +708,21 @@
619 708 * min/combined/ were never cleared — a purge left a stale
620 709 * combined-<hash>.css the regenerated page no longer referenced.
621 710 * (FBS-83114 / FBS-83116)
622 711 */
623 - private static function rmtree_files( string $dir ): void {
712 + private static function rmtree_files( string $dir ): int {
624 713 if ( ! is_dir( $dir ) ) {
625 - return;
714 + return 0;
626 715 }
716 + $removed = 0;
627 717 foreach ( (array) glob( $dir . '/*' ) as $path ) {
628 718 if ( is_dir( $path ) ) {
629 - self::rmtree_files( $path );
719 + $removed += self::rmtree_files( $path );
630 720 @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.
631 721 continue;
632 722 }
633 723 wp_delete_file( $path );
724 + ++$removed;
634 725 }
726 + return $removed;
635 727 }
636 728 }