PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
1.4.1 1.4.0 1.3.7 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 All 35 releases
← All changes | includes/class-minifier.php +285 -51 1.2.4 → 1.4.1 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.
@@ -91,8 +98,17 @@
91 98 // Delay applies a transform that's mutually exclusive with
92 99 // plain defer — when both are on, delay wins (the bootstrap
93 100 // will re-attach as a regular <script> on interaction).
94 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 );
108 + // A snippet never stays parked behind a live script. Runs after
109 + // the late opt-out revert, which can un-delay the tag.
110 + add_filter( 'script_loader_tag', array( Minify_Filters::class, 'unpark_orphaned_smart_inline' ), Minify_Filters::late_opt_out_priority() + 1, 3 );
95 111 add_action( 'wp_footer', array( Minify_Filters::class, 'print_delay_bootstrap' ), 1000 );
96 112 // script_loader_tag only fires for wp_enqueue_script()'d assets.
97 113 // Analytics / pixel / chat-widget tags printed straight into
98 114 // wp_head bypass it, and those are usually the heaviest scripts
@@ -100,11 +116,41 @@
100 116 // minify_html (same filter, default priority) and is baked into
101 117 // the cache file, so it replays on static hits where PHP never
102 118 // boots.
103 119 add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'delay_raw_script_tags' ), 20 );
120 + // The vendors' own install snippets are INLINE (no src at all),
121 + // so the src sweep above never sees them; this one parks an
122 + // inline body that names a known third-party host.
123 + add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'delay_inline_snippets' ), 21 );
104 124 }
125 +
126 + // Everything above honors data-no-optimize / data-no-minify, but
127 + // only sees markers stamped before priority 30. Borlabs Cookie
128 + // stamps at 100, so its consent config was minified AND delayed
129 + // despite carrying both markers. Snapshot the tag before our
130 + // transforms (9) and hand the original back if a marker turns up
131 + // after them (1000, past Borlabs' own 100 and 999). Registered
132 + // whenever any of the three is on,
133 + // since each one is individually enough to damage a marked
134 + // script. (#469)
135 + if ( ! empty( $opts['minify_js'] ) || ! empty( $opts['defer_js'] ) || ! empty( $opts['delay_js'] ) ) {
136 + add_filter( 'script_loader_tag', array( Minify_Filters::class, 'snapshot_tag' ), 9, 3 );
137 + add_filter(
138 + 'script_loader_tag',
139 + array( Minify_Filters::class, 'revert_late_marked_tag' ),
140 + Minify_Filters::late_opt_out_priority(),
141 + 3
142 + );
143 + }
105 144 if ( ! empty( $opts['async_css'] ) ) {
106 145 add_filter( 'style_loader_tag', array( Minify_Filters::class, 'async_style_tag' ), 20, 2 );
146 + // style_loader_tag only fires for wp_enqueue_style()'d sheets.
147 + // Themes print Google/Bunny/Typekit font CSS as literal <link>
148 + // markup in the head, so those sheets never reach the filter and
149 + // stay render-blocking — sweep the finished buffer for the known
150 + // font-CSS hosts. Baked into the cache file, so it replays on
151 + // static hits where PHP never boots.
152 + add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'async_raw_font_css_links' ), 22 );
107 153 }
108 154
109 155 /*
110 156 * CSS combining runs on the FINISHED HTML, not the enqueue queue.
@@ -301,18 +347,37 @@
301 347 if ( ! $path || ! is_readable( $path ) ) {
302 348 return $src;
303 349 }
304 350
305 - // Build a cache filename keyed on path + mtime so edits invalidate.
306 - $mtime = filemtime( $path );
307 - $key = md5( $path . '|' . $mtime );
308 - $cache = self::cache_path( $key, $type );
351 + // Nowhere to write means nothing to serve. Without this gate an
352 + // unwritable min/ cost a full minification on EVERY render, each one
353 + // thrown away when the write failed.
354 + if ( ! self::min_dir_writable() ) {
355 + return $src;
356 + }
309 357
310 - if ( ! file_exists( $cache ) ) {
311 - $ok = self::minify_file( $path, $cache, $type );
312 - if ( ! $ok ) {
313 - return $src;
358 + // The file is named after its minified CONTENT, found through a
359 + // per-source manifest that is validated by stat() alone on the hot
360 + // path. See Asset_Manifest for the rules. The old name was
361 + // md5(path|mtime): an in-place edit that kept the mtime served new
362 + // bytes under a URL cached for a year, an @import child's edit
363 + // changed nothing, and a touch with no change orphaned every cached
364 + // page that linked the old name.
365 + $key = Asset_Manifest::key_for(
366 + $type,
367 + $path,
368 + static function ( string $source ) use ( $type ): array {
369 + return 'css' === $type ? Asset_Manifest::css_dependencies( $source ) : array();
370 + },
371 + static function ( string $source ) use ( $type ) {
372 + return self::build( $source, $type );
373 + },
374 + static function ( string $key ) use ( $type ): bool {
375 + return file_exists( self::cache_path( $key, $type ) );
314 376 }
377 + );
378 + if ( null === $key ) {
379 + return $src;
315 380 }
316 381
317 382 // Return a URL to the cached file. Built from known constants — never
318 383 // from str_replace on a filesystem path (which would assume the FS
@@ -319,53 +384,110 @@
319 384 // layout mirrors the URL layout).
320 385 return self::min_url() . '/' . $key . '.' . $type;
321 386 }
322 387
323 - private static function minify_file( $source_path, $target_path, $type ) {
324 - if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
325 - return false;
388 + /**
389 + * Whether min/ can be written, asked once per request.
390 + *
391 + * @var bool|null
392 + */
393 + private static $writable = null;
394 +
395 + /**
396 + * Minifications run by this process.
397 + *
398 + * @var int
399 + */
400 + private static $builds = 0;
401 +
402 + /** Forget the per-request answers. Tests only. */
403 + public static function reset_request_state(): void {
404 + self::$writable = null;
405 + self::$builds = 0;
406 + }
407 +
408 + /** How many sources this process has minified. Tests and diagnostics. */
409 + public static function builds(): int {
410 + return self::$builds;
411 + }
412 +
413 + /** Can minified files be written this request? */
414 + public static function min_dir_writable(): bool {
415 + if ( null === self::$writable ) {
416 + $dir = self::min_dir();
417 + self::ensure_dir( $dir );
418 + self::$writable = is_dir( $dir ) && wp_is_writable( $dir );
326 419 }
420 + return self::$writable;
421 + }
327 422
328 - // Path-traversal guard: refuse to write anywhere outside our cache
329 - // dir, even if a malicious filter ever produced a poisoned key.
330 - $cache_root = self::min_dir();
331 - self::ensure_dir( $cache_root );
332 - $real_root = realpath( $cache_root );
333 - $real_dir = realpath( dirname( $target_path ) );
334 - if ( ! $real_root || ! $real_dir || 0 !== strpos( $real_dir, $real_root ) ) {
335 - return false;
423 + /**
424 + * Minify a source into min/<md5 of output>.<type> and return the key.
425 + *
426 + * The output is built in memory and published with an atomic rename, so a
427 + * concurrent render never links a half-written file. When a file with the
428 + * same content already exists nothing is written: same bytes, same name.
429 + *
430 + * @param string $source Absolute source path.
431 + * @param string $type 'css' or 'js'.
432 + * @return string|null|false Key; null when the source cannot be minified;
433 + * false when the output could not be written.
434 + */
435 + private static function build( string $source, string $type ) {
436 + ++self::$builds;
437 + $minified = self::minify_to_string( $source, $type );
438 + if ( null === $minified ) {
439 + return null;
336 440 }
441 + $key = md5( $minified );
442 + $target = self::cache_path( $key, $type );
443 + if ( file_exists( $target ) ) {
444 + return $key;
445 + }
446 + return Asset_Manifest::write_atomic( $target, $minified ) ? $key : false;
447 + }
337 448
449 + /**
450 + * Minified bytes of a source, or null on failure.
451 + *
452 + * @param string $source_path Absolute source path.
453 + * @param string $type 'css' or 'js'.
454 + */
455 + private static function minify_to_string( string $source_path, string $type ): ?string {
456 + if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
457 + return null;
458 + }
459 +
338 460 try {
339 461 if ( 'css' === $type ) {
340 - // Passing the TARGET path makes matthiasmullie/minify rebase every
341 - // relative url(...) / @import against the minified file's location.
342 - // Without it, a stylesheet moved from e.g.
343 - // .../font-awesome/css/all.css to cache/xspeed/min/<key>.css keeps
344 - // its original url(../webfonts/…) — which then resolves against the
345 - // cache dir and 404s (missing FontAwesome/eicons/WooCommerce fonts).
462 + // execute() with a path inside min/ rebases every relative
463 + // url(...) / @import against the minified file's location,
464 + // which is what minify( $target ) did before without writing.
465 + // Every output lives in the same directory, so any name there
466 + // rebases identically. Without the rebase a stylesheet moved
467 + // from e.g. .../font-awesome/css/all.css to
468 + // cache/xspeed/min/<key>.css keeps its original
469 + // url(../webfonts/…), which then resolves against the cache
470 + // dir and 404s (missing FontAwesome/eicons/WooCommerce fonts).
346 471 $minifier = new \MatthiasMullie\Minify\CSS( $source_path );
347 - $minified = $minifier->minify( $target_path );
348 - return '' !== $minified && file_exists( $target_path );
472 + $minified = (string) $minifier->execute( self::min_dir() . '/rebase.css' );
473 + return '' !== $minified ? $minified : null;
349 474 }
350 475
351 476 $minifier = new \MatthiasMullie\Minify\JS( $source_path );
352 - $minified = $minifier->minify();
477 + $minified = (string) $minifier->minify();
353 478
354 479 // Sanity check: paren/brace/bracket/backtick balance must be preserved.
355 480 // matthiasmullie/minify can silently truncate mid-template-literal on
356 481 // complex modern JS — bail rather than ship a broken file.
357 - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem requires admin context; minification runs on frontend page renders. Source already validated as readable on line 121.
482 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem requires admin context; minification runs on frontend page renders. Source already validated as readable by the caller.
358 483 $source = file_get_contents( $source_path );
359 - if ( false === $source || ! self::balanced( $source, $minified ) ) {
360 - return false;
484 + if ( false === $source || '' === $minified || ! self::balanced( $source, $minified ) ) {
485 + return null;
361 486 }
362 -
363 - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context; minification runs on frontend page renders.
364 - $bytes = file_put_contents( $target_path, $minified );
365 - return false !== $bytes && file_exists( $target_path );
487 + return $minified;
366 488 } catch ( \Throwable $e ) {
367 - return false;
489 + return null;
368 490 }
369 491 }
370 492
371 493 /**
@@ -395,9 +517,9 @@
395 517 * data; minifying a template would eat the markup it holds. An unknown
396 518 * type is treated as non-JS on purpose: guessing wrong breaks the page,
397 519 * while skipping only forgoes a few bytes.
398 520 * - `<script src="...">` — the body is empty; the file path already goes
399 - * through minify_file().
521 + * through rewrite_asset().
400 522 *
401 523 * Every result is checked with balanced(), the same structural guard the
402 524 * file path uses, so a body the library truncates is shipped as-is rather
403 525 * than broken. (#2)
@@ -424,8 +546,14 @@
424 546 if ( '' === trim( $body ) ) {
425 547 return $block;
426 548 }
427 549
550 + // The tag asked to be left alone (data-no-optimize / data-no-minify —
551 + // the convention consent managers print on their config scripts). (#456)
552 + if ( Minify_Filters::tag_opts_out( $open ) ) {
553 + return $block;
554 + }
555 +
428 556 // Refuse a body that is already structurally broken. balanced() only
429 557 // compares source against minified, so it passes when BOTH are equally
430 558 // unbalanced — `function x( {` minifies to `function x({`, same counts,
431 559 // guard satisfied, broken code reformatted. Rewriting a body we can't
@@ -515,15 +643,34 @@
515 643 return true;
516 644 }
517 645
518 646 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;
647 + unset( $source );
648 +
649 + // Judge the OUTPUT, not the difference between input and output.
650 + //
651 + // This used to compare token counts across the pair, on the stated
652 + // assumption that "literal braces inside strings survive minification
653 + // unchanged, so they cancel out". Comments do not: stripping them is
654 + // the minifier's whole job, and every brace, bracket and backtick
655 + // inside one disappears with it. So any file whose comments contain a
656 + // delimiter — a commented-out block, a URL in a docblock, an SVG in a
657 + // note — failed the check and silently shipped unminified.
658 + //
659 + // It is not a rare shape. EmbedPress's front.js counts 372 braces
660 + // against 368, 61 brackets against 58 and 110 backticks against 102
661 + // purely from comment removal, so 67 KB shipped raw where 46 KB was
662 + // correct — and `node --check` confirms that rejected output parses
663 + // fine. A guard that refuses valid work is not conservative, it is
664 + // broken: it costs bytes on every request and reports nothing.
665 + //
666 + // What the guard is FOR still stands (#2): matthiasmullie/minify can
667 + // truncate inside a template literal on complex modern JS and return a
668 + // body that looks minified but is structurally broken. That failure is
669 + // visible in the output alone — an unterminated literal leaves an odd
670 + // backtick count and unmatched braces — which is exactly what
671 + // self_consistent() measures, without the false positives.
672 + return self::self_consistent( $minified );
526 673 }
527 674
528 675 /**
529 676 * Resolve a local asset URL to a filesystem path using a strict allowlist
@@ -567,19 +714,40 @@
567 714 array( content_url(), WP_CONTENT_DIR ),
568 715 array( includes_url(), ABSPATH . WPINC ),
569 716 );
570 717
718 + // The host check above normalised the HOST but not the SCHEME, and the
719 + // prefix match below is a plain string compare — so an https asset URL
720 + // never matched an http base and the file silently shipped unminified.
721 + // That is not a corner case: WP_CONTENT_URL is derived from a stored
722 + // option, `plugins_url()` from another, and a site moved to https
723 + // without rewriting every row (or one behind a TLS-terminating proxy
724 + // where `is_ssl()` reads false) serves https pages off http-rooted
725 + // bases all day. Comparing scheme-less is the whole fix; the host
726 + // equality test already did the security work of refusing anything
727 + // off-site, and this runs after it.
728 + $strip_scheme = static function ( string $value ): string {
729 + return (string) preg_replace( '#^https?://#i', '//', $value );
730 + };
731 + $clean_match = $strip_scheme( $clean );
732 +
571 733 foreach ( $candidates as $pair ) {
572 734 list( $url_base, $path_base ) = $pair;
573 735 if ( ! $url_base || ! $path_base ) {
574 736 continue;
575 737 }
576 - $url_base = rtrim( $url_base, '/' );
577 - if ( 0 !== strpos( $clean, $url_base . '/' ) && $clean !== $url_base ) {
738 + $url_base = rtrim( $url_base, '/' );
739 + $base_match = $strip_scheme( $url_base );
740 + if ( 0 !== strpos( $clean_match, $base_match . '/' ) && $clean_match !== $base_match ) {
578 741 continue;
579 742 }
580 743
581 - $relative = ltrim( substr( $clean, strlen( $url_base ) ), '/' );
744 + // Slice the scheme-less pair, not the original. `https://…` and
745 + // `http://…` differ by one byte, so an offset taken from the base
746 + // as written would cut one character short of (or past) the path
747 + // when the two schemes disagree — which is the case this fix
748 + // exists for.
749 + $relative = ltrim( substr( $clean_match, strlen( $base_match ) ), '/' );
582 750 $candidate = trailingslashit( $path_base ) . $relative;
583 751
584 752 $real_base = realpath( $path_base );
585 753 $real = realpath( $candidate );
@@ -607,13 +775,76 @@
607 775 Cache::write_silence( $dir );
608 776 }
609 777 }
610 778
779 + /**
780 + * Delete every minified and combined asset, network-wide.
781 + *
782 + * Output files are named by content, so an ordinary purge has no reason
783 + * to delete them: a page rendered after it links the same names. Only a
784 + * network purge, an update, an explicit assets purge and deactivation
785 + * come here. Deleting is still a race against renders in flight, which
786 + * may already hold the names of files about to vanish; the purge stamp
787 + * bumped here lets the page cache refuse to store those renders.
788 + *
789 + * @return int Files removed. Most callers are `add_action` callbacks and
790 + * ignore it; `wp xspeed purge` reports it as a line item.
791 + */
611 792 public static function purge_minified() {
612 - self::rmtree_files( self::min_dir() );
793 + $removed = self::rmtree_files( self::min_dir() );
794 + if ( $removed > 0 ) {
795 + self::bump_purge_stamp();
796 + }
797 + return $removed;
613 798 }
614 799
615 800 /**
801 + * Delete one blog's manifests, leaving every output file in place.
802 + *
803 + * What a subsite's assets purge can safely do on a network whose blogs
804 + * share min/: the next render of each source re-reads its bytes and
805 + * rebuilds only if they changed, and no other blog's cached page loses a
806 + * file it links. Outputs nothing links any more are left to Cache_GC.
807 + *
808 + * @param int $blog_id Blog whose manifests to drop.
809 + * @return int Manifests removed.
810 + */
811 + public static function purge_manifests( int $blog_id ): int {
812 + $dir = Asset_Manifest::dir( $blog_id );
813 + $removed = self::rmtree_files( $dir );
814 + @rmdir( $dir ); // 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.
815 + return $removed;
816 + }
817 +
818 + /** File holding the purge stamp. Network-wide, like min/ itself. */
819 + public static function purge_stamp_path(): string {
820 + return rtrim( (string) XSPEED_CACHE_DIR, '/' ) . '/min-purge.stamp';
821 + }
822 +
823 + /**
824 + * Token that changes every time purge_minified() deletes something.
825 + *
826 + * The page cache reads it when a render starts and again before storing
827 + * the page. A different value means files the page may link were deleted
828 + * in between, so the page is served but not cached.
829 + *
830 + * @return string '' when no purge has ever deleted anything.
831 + */
832 + public static function purge_stamp(): string {
833 + $stamp = @file_get_contents( self::purge_stamp_path() ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- absent until the first purge; tiny cache sidecar read on the front end, where WP_Filesystem is unavailable.
834 + return is_string( $stamp ) ? trim( $stamp ) : '';
835 + }
836 +
837 + /** Replace the purge stamp with a new random token. */
838 + private static function bump_purge_stamp(): void {
839 + $path = self::purge_stamp_path();
840 + if ( ! is_dir( dirname( $path ) ) ) {
841 + return;
842 + }
843 + Asset_Manifest::write_atomic( $path, bin2hex( random_bytes( 8 ) ) );
844 + }
845 +
846 + /**
616 847 * Recursively delete every file under $dir (and the emptied
617 848 * subdirectories), keeping $dir itself. The previous glob('$dir/*')
618 849 * was non-recursive and no-ops on directories, so combined assets in
619 850 * min/combined/ were never cleared — a purge left a stale
@@ -619,18 +850,21 @@
619 850 * min/combined/ were never cleared — a purge left a stale
620 851 * combined-<hash>.css the regenerated page no longer referenced.
621 852 * (FBS-83114 / FBS-83116)
622 853 */
623 - private static function rmtree_files( string $dir ): void {
854 + private static function rmtree_files( string $dir ): int {
624 855 if ( ! is_dir( $dir ) ) {
625 - return;
856 + return 0;
626 857 }
858 + $removed = 0;
627 859 foreach ( (array) glob( $dir . '/*' ) as $path ) {
628 860 if ( is_dir( $path ) ) {
629 - self::rmtree_files( $path );
861 + $removed += self::rmtree_files( $path );
630 862 @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 863 continue;
632 864 }
633 865 wp_delete_file( $path );
866 + ++$removed;
634 867 }
868 + return $removed;
635 869 }
636 870 }