PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 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 All 36 releases
← All changes | includes/class-minifier.php +220 -39 1.3.1 → 1.4.2 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
@@ -104,9 +120,31 @@
104 120 // The vendors' own install snippets are INLINE (no src at all),
105 121 // so the src sweep above never sees them; this one parks an
106 122 // inline body that names a known third-party host.
107 123 add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'delay_inline_snippets' ), 21 );
124 + // Elementor's entrance-animation elements are hidden until its
125 + // frontend script runs; when that script is delayed, show them.
126 + add_action( 'wp_head', array( Minify_Filters::class, 'print_entrance_animation_reveal' ), 1 );
108 127 }
128 +
129 + // Everything above honors data-no-optimize / data-no-minify, but
130 + // only sees markers stamped before priority 30. Borlabs Cookie
131 + // stamps at 100, so its consent config was minified AND delayed
132 + // despite carrying both markers. Snapshot the tag before our
133 + // transforms (9) and hand the original back if a marker turns up
134 + // after them (1000, past Borlabs' own 100 and 999). Registered
135 + // whenever any of the three is on,
136 + // since each one is individually enough to damage a marked
137 + // script. (#469)
138 + if ( ! empty( $opts['minify_js'] ) || ! empty( $opts['defer_js'] ) || ! empty( $opts['delay_js'] ) ) {
139 + add_filter( 'script_loader_tag', array( Minify_Filters::class, 'snapshot_tag' ), 9, 3 );
140 + add_filter(
141 + 'script_loader_tag',
142 + array( Minify_Filters::class, 'revert_late_marked_tag' ),
143 + Minify_Filters::late_opt_out_priority(),
144 + 3
145 + );
146 + }
109 147 if ( ! empty( $opts['async_css'] ) ) {
110 148 add_filter( 'style_loader_tag', array( Minify_Filters::class, 'async_style_tag' ), 20, 2 );
111 149 // style_loader_tag only fires for wp_enqueue_style()'d sheets.
112 150 // Themes print Google/Bunny/Typekit font CSS as literal <link>
@@ -114,8 +152,12 @@
114 152 // stay render-blocking — sweep the finished buffer for the known
115 153 // font-CSS hosts. Baked into the cache file, so it replays on
116 154 // static hits where PHP never boots.
117 155 add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'async_raw_font_css_links' ), 22 );
156 + // Holds CSS transitions while async stylesheets replace the
157 + // critical CSS. Runs after every script pass so its own
158 + // inline script is never deferred or delayed.
159 + add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'hold_transitions_for_async_css' ), 26 );
118 160 }
119 161
120 162 /*
121 163 * CSS combining runs on the FINISHED HTML, not the enqueue queue.
@@ -312,18 +354,37 @@
312 354 if ( ! $path || ! is_readable( $path ) ) {
313 355 return $src;
314 356 }
315 357
316 - // Build a cache filename keyed on path + mtime so edits invalidate.
317 - $mtime = filemtime( $path );
318 - $key = md5( $path . '|' . $mtime );
319 - $cache = self::cache_path( $key, $type );
358 + // Nowhere to write means nothing to serve. Without this gate an
359 + // unwritable min/ cost a full minification on EVERY render, each one
360 + // thrown away when the write failed.
361 + if ( ! self::min_dir_writable() ) {
362 + return $src;
363 + }
320 364
321 - if ( ! file_exists( $cache ) ) {
322 - $ok = self::minify_file( $path, $cache, $type );
323 - if ( ! $ok ) {
324 - return $src;
365 + // The file is named after its minified CONTENT, found through a
366 + // per-source manifest that is validated by stat() alone on the hot
367 + // path. See Asset_Manifest for the rules. The old name was
368 + // md5(path|mtime): an in-place edit that kept the mtime served new
369 + // bytes under a URL cached for a year, an @import child's edit
370 + // changed nothing, and a touch with no change orphaned every cached
371 + // page that linked the old name.
372 + $key = Asset_Manifest::key_for(
373 + $type,
374 + $path,
375 + static function ( string $source ) use ( $type ): array {
376 + return 'css' === $type ? Asset_Manifest::css_dependencies( $source ) : array();
377 + },
378 + static function ( string $source ) use ( $type ) {
379 + return self::build( $source, $type );
380 + },
381 + static function ( string $key ) use ( $type ): bool {
382 + return file_exists( self::cache_path( $key, $type ) );
325 383 }
384 + );
385 + if ( null === $key ) {
386 + return $src;
326 387 }
327 388
328 389 // Return a URL to the cached file. Built from known constants — never
329 390 // from str_replace on a filesystem path (which would assume the FS
@@ -330,53 +391,110 @@
330 391 // layout mirrors the URL layout).
331 392 return self::min_url() . '/' . $key . '.' . $type;
332 393 }
333 394
334 - private static function minify_file( $source_path, $target_path, $type ) {
335 - if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
336 - return false;
395 + /**
396 + * Whether min/ can be written, asked once per request.
397 + *
398 + * @var bool|null
399 + */
400 + private static $writable = null;
401 +
402 + /**
403 + * Minifications run by this process.
404 + *
405 + * @var int
406 + */
407 + private static $builds = 0;
408 +
409 + /** Forget the per-request answers. Tests only. */
410 + public static function reset_request_state(): void {
411 + self::$writable = null;
412 + self::$builds = 0;
413 + }
414 +
415 + /** How many sources this process has minified. Tests and diagnostics. */
416 + public static function builds(): int {
417 + return self::$builds;
418 + }
419 +
420 + /** Can minified files be written this request? */
421 + public static function min_dir_writable(): bool {
422 + if ( null === self::$writable ) {
423 + $dir = self::min_dir();
424 + self::ensure_dir( $dir );
425 + self::$writable = is_dir( $dir ) && wp_is_writable( $dir );
337 426 }
427 + return self::$writable;
428 + }
338 429
339 - // Path-traversal guard: refuse to write anywhere outside our cache
340 - // dir, even if a malicious filter ever produced a poisoned key.
341 - $cache_root = self::min_dir();
342 - self::ensure_dir( $cache_root );
343 - $real_root = realpath( $cache_root );
344 - $real_dir = realpath( dirname( $target_path ) );
345 - if ( ! $real_root || ! $real_dir || 0 !== strpos( $real_dir, $real_root ) ) {
346 - return false;
430 + /**
431 + * Minify a source into min/<md5 of output>.<type> and return the key.
432 + *
433 + * The output is built in memory and published with an atomic rename, so a
434 + * concurrent render never links a half-written file. When a file with the
435 + * same content already exists nothing is written: same bytes, same name.
436 + *
437 + * @param string $source Absolute source path.
438 + * @param string $type 'css' or 'js'.
439 + * @return string|null|false Key; null when the source cannot be minified;
440 + * false when the output could not be written.
441 + */
442 + private static function build( string $source, string $type ) {
443 + ++self::$builds;
444 + $minified = self::minify_to_string( $source, $type );
445 + if ( null === $minified ) {
446 + return null;
347 447 }
448 + $key = md5( $minified );
449 + $target = self::cache_path( $key, $type );
450 + if ( file_exists( $target ) ) {
451 + return $key;
452 + }
453 + return Asset_Manifest::write_atomic( $target, $minified ) ? $key : false;
454 + }
348 455
456 + /**
457 + * Minified bytes of a source, or null on failure.
458 + *
459 + * @param string $source_path Absolute source path.
460 + * @param string $type 'css' or 'js'.
461 + */
462 + private static function minify_to_string( string $source_path, string $type ): ?string {
463 + if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
464 + return null;
465 + }
466 +
349 467 try {
350 468 if ( 'css' === $type ) {
351 - // Passing the TARGET path makes matthiasmullie/minify rebase every
352 - // relative url(...) / @import against the minified file's location.
353 - // Without it, a stylesheet moved from e.g.
354 - // .../font-awesome/css/all.css to cache/xspeed/min/<key>.css keeps
355 - // its original url(../webfonts/…) — which then resolves against the
356 - // cache dir and 404s (missing FontAwesome/eicons/WooCommerce fonts).
469 + // execute() with a path inside min/ rebases every relative
470 + // url(...) / @import against the minified file's location,
471 + // which is what minify( $target ) did before without writing.
472 + // Every output lives in the same directory, so any name there
473 + // rebases identically. Without the rebase a stylesheet moved
474 + // from e.g. .../font-awesome/css/all.css to
475 + // cache/xspeed/min/<key>.css keeps its original
476 + // url(../webfonts/…), which then resolves against the cache
477 + // dir and 404s (missing FontAwesome/eicons/WooCommerce fonts).
357 478 $minifier = new \MatthiasMullie\Minify\CSS( $source_path );
358 - $minified = $minifier->minify( $target_path );
359 - return '' !== $minified && file_exists( $target_path );
479 + $minified = (string) $minifier->execute( self::min_dir() . '/rebase.css' );
480 + return '' !== $minified ? $minified : null;
360 481 }
361 482
362 483 $minifier = new \MatthiasMullie\Minify\JS( $source_path );
363 - $minified = $minifier->minify();
484 + $minified = (string) $minifier->minify();
364 485
365 486 // Sanity check: paren/brace/bracket/backtick balance must be preserved.
366 487 // matthiasmullie/minify can silently truncate mid-template-literal on
367 488 // complex modern JS — bail rather than ship a broken file.
368 - // 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.
489 + // 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.
369 490 $source = file_get_contents( $source_path );
370 - if ( false === $source || ! self::balanced( $source, $minified ) ) {
371 - return false;
491 + if ( false === $source || '' === $minified || ! self::balanced( $source, $minified ) ) {
492 + return null;
372 493 }
373 -
374 - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context; minification runs on frontend page renders.
375 - $bytes = file_put_contents( $target_path, $minified );
376 - return false !== $bytes && file_exists( $target_path );
494 + return $minified;
377 495 } catch ( \Throwable $e ) {
378 - return false;
496 + return null;
379 497 }
380 498 }
381 499
382 500 /**
@@ -406,9 +524,9 @@
406 524 * data; minifying a template would eat the markup it holds. An unknown
407 525 * type is treated as non-JS on purpose: guessing wrong breaks the page,
408 526 * while skipping only forgoes a few bytes.
409 527 * - `<script src="...">` — the body is empty; the file path already goes
410 - * through minify_file().
528 + * through rewrite_asset().
411 529 *
412 530 * Every result is checked with balanced(), the same structural guard the
413 531 * file path uses, so a body the library truncates is shipped as-is rather
414 532 * than broken. (#2)
@@ -435,8 +553,14 @@
435 553 if ( '' === trim( $body ) ) {
436 554 return $block;
437 555 }
438 556
557 + // The tag asked to be left alone (data-no-optimize / data-no-minify —
558 + // the convention consent managers print on their config scripts). (#456)
559 + if ( Minify_Filters::tag_opts_out( $open ) ) {
560 + return $block;
561 + }
562 +
439 563 // Refuse a body that is already structurally broken. balanced() only
440 564 // compares source against minified, so it passes when BOTH are equally
441 565 // unbalanced — `function x( {` minifies to `function x({`, same counts,
442 566 // guard satisfied, broken code reformatted. Rewriting a body we can't
@@ -659,15 +783,72 @@
659 783 }
660 784 }
661 785
662 786 /**
663 - * Clear every minified / combined asset.
787 + * Delete every minified and combined asset, network-wide.
664 788 *
789 + * Output files are named by content, so an ordinary purge has no reason
790 + * to delete them: a page rendered after it links the same names. Only a
791 + * network purge, an update, an explicit assets purge and deactivation
792 + * come here. Deleting is still a race against renders in flight, which
793 + * may already hold the names of files about to vanish; the purge stamp
794 + * bumped here lets the page cache refuse to store those renders.
795 + *
665 796 * @return int Files removed. Most callers are `add_action` callbacks and
666 797 * ignore it; `wp xspeed purge` reports it as a line item.
667 798 */
668 799 public static function purge_minified() {
669 - return self::rmtree_files( self::min_dir() );
800 + $removed = self::rmtree_files( self::min_dir() );
801 + if ( $removed > 0 ) {
802 + self::bump_purge_stamp();
803 + }
804 + return $removed;
805 + }
806 +
807 + /**
808 + * Delete one blog's manifests, leaving every output file in place.
809 + *
810 + * What a subsite's assets purge can safely do on a network whose blogs
811 + * share min/: the next render of each source re-reads its bytes and
812 + * rebuilds only if they changed, and no other blog's cached page loses a
813 + * file it links. Outputs nothing links any more are left to Cache_GC.
814 + *
815 + * @param int $blog_id Blog whose manifests to drop.
816 + * @return int Manifests removed.
817 + */
818 + public static function purge_manifests( int $blog_id ): int {
819 + $dir = Asset_Manifest::dir( $blog_id );
820 + $removed = self::rmtree_files( $dir );
821 + @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.
822 + return $removed;
823 + }
824 +
825 + /** File holding the purge stamp. Network-wide, like min/ itself. */
826 + public static function purge_stamp_path(): string {
827 + return rtrim( (string) XSPEED_CACHE_DIR, '/' ) . '/min-purge.stamp';
828 + }
829 +
830 + /**
831 + * Token that changes every time purge_minified() deletes something.
832 + *
833 + * The page cache reads it when a render starts and again before storing
834 + * the page. A different value means files the page may link were deleted
835 + * in between, so the page is served but not cached.
836 + *
837 + * @return string '' when no purge has ever deleted anything.
838 + */
839 + public static function purge_stamp(): string {
840 + $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.
841 + return is_string( $stamp ) ? trim( $stamp ) : '';
842 + }
843 +
844 + /** Replace the purge stamp with a new random token. */
845 + private static function bump_purge_stamp(): void {
846 + $path = self::purge_stamp_path();
847 + if ( ! is_dir( dirname( $path ) ) ) {
848 + return;
849 + }
850 + Asset_Manifest::write_atomic( $path, bin2hex( random_bytes( 8 ) ) );
670 851 }
671 852
672 853 /**
673 854 * Recursively delete every file under $dir (and the emptied