PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
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-asset-combiner.php +101 -14 1.3.1 → 1.4.0 View file →
@@ -8,10 +8,11 @@
8 8 * 1. wp_enqueue_scripts @ 999 — walk WP_Styles->queue, partition into
9 9 * local + external. External (full http(s):// to other origins,
10 10 * data: URIs, protocol-relative pointing elsewhere) stay enqueued
11 11 * as-is; local handles get pulled out of the queue.
12 - * 2. Build cache key = md5(JSON({handle => [src, mtime]})). When the
13 - * combined file already exists for that key, skip generation.
12 + * 2. Build the cache key from the version, home_url() and each part's
13 + * content key (combined_key()). When the combined file already
14 + * exists for that key, skip generation.
14 15 * 3. Otherwise: read each source body, resolve recursive @import
15 16 * statements (depth-limited), rewrite url(...) paths to absolute,
16 17 * concat with a small `/* xspeed: HANDLE *​/` header per chunk for
17 18 * debug-traceability, write to XSPEED_CACHE_DIR/min/combined/.
@@ -118,9 +119,12 @@
118 119 * @param string $media The media attribute for this group ('all', 'print', …).
119 120 * @param array<string,array<mixed>> $bucket handle => info map.
120 121 */
121 122 private static function combine_media_group( \WP_Styles $wp_styles, string $media, array $bucket ): void {
122 - $key = self::cache_key( $bucket );
123 + if ( ! Minifier::min_dir_writable() ) {
124 + return;
125 + }
126 + $key = self::cache_key( $bucket, 'css' );
123 127 $dir = self::cache_dir();
124 128 $out_file = $dir . '/combined-' . $key . '.css';
125 129 $out_url = self::cache_url() . '/combined-' . $key . '.css';
126 130
@@ -158,12 +162,12 @@
158 162 // was the artifact we generated, which then docked the site on
159 163 // xSpeed Scan's own A2 check while the UI reported minify as on.
160 164 $contents = self::minify_css_body( $contents );
161 165
162 - // Atomic write: file_put_contents with LOCK_EX so concurrent
163 - // renders don't race.
164 - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context, unavailable on frontend.
165 - file_put_contents( $out_file, $contents, LOCK_EX );
166 + // Atomic: a concurrent render sees the whole file or none of it.
167 + if ( ! Asset_Manifest::write_atomic( $out_file, $contents ) && ! file_exists( $out_file ) ) {
168 + return;
169 + }
166 170 } else {
167 171 self::mark_in_use( $out_file );
168 172 }
169 173
@@ -293,9 +297,12 @@
293 297 * @param int $group 0 = head, 1 = footer.
294 298 * @param array<string,array<mixed>> $bucket handle => info map for this group.
295 299 */
296 300 private static function combine_script_group( \WP_Scripts $wp_scripts, int $group, array $bucket ): void {
297 - $key = self::cache_key( $bucket );
301 + if ( ! Minifier::min_dir_writable() ) {
302 + return;
303 + }
304 + $key = self::cache_key( $bucket, 'js' );
298 305 $dir = self::cache_dir();
299 306 // Group in the filename so a head and a footer bundle can never
300 307 // collide on one cache key.
301 308 $out_file = $dir . '/combined-g' . $group . '-' . $key . '.js';
@@ -310,10 +317,11 @@
310 317 continue;
311 318 }
312 319 $contents .= "/* xspeed: $handle */\n" . $body . "\n;\n";
313 320 }
314 - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem unavailable on frontend.
315 - file_put_contents( $out_file, $contents, LOCK_EX );
321 + if ( ! Asset_Manifest::write_atomic( $out_file, $contents ) && ! file_exists( $out_file ) ) {
322 + return;
323 + }
316 324 } else {
317 325 self::mark_in_use( $out_file );
318 326 }
319 327
@@ -792,14 +800,93 @@
792 800
793 801 return $out;
794 802 }
795 803
796 - private static function cache_key( array $bucket ): string {
797 - $signature = array();
804 + /**
805 + * Cache key for a queue-built bundle. See combined_key().
806 + *
807 + * @param array<string,array<mixed>> $bucket handle => info map.
808 + * @param string $kind 'css' or 'js'.
809 + */
810 + private static function cache_key( array $bucket, string $kind ): string {
811 + $parts = array();
798 812 foreach ( $bucket as $handle => $info ) {
799 - $signature[ $handle ] = array( $info['src'] ?? '', $info['mtime'] ?? 0 );
813 + $parts[] = array(
814 + 'id' => (string) $handle,
815 + 'path' => (string) ( $info['path'] ?? '' ),
816 + 'url' => (string) ( $info['url'] ?? '' ),
817 + );
800 818 }
801 - return md5( wp_json_encode( $signature ) );
819 + return self::combined_key( $parts, $kind );
820 + }
821 +
822 + /**
823 + * Name for a combined file, derived from what goes into it.
824 + *
825 + * md5 of the plugin version, this site's home_url(), and per part its
826 + * path and content key. The old key was each part's path and mtime,
827 + * which had two faults:
828 + *
829 + * - It followed mtime, not bytes, so an in-place edit that kept the
830 + * mtime (or an edit to an @import child) served stale CSS, and a
831 + * touch with no change orphaned every cached page.
832 + * - It left out the site. The combined body rewrites every url() to an
833 + * ABSOLUTE URL built from home_url(), and min/combined/ is shared by
834 + * every blog on a network, so the first subsite to render a set of
835 + * sheets wrote its own origin into the file every other subsite then
836 + * served. home_url() is taken whole, path included, because a
837 + * subdirectory subsite's URLs carry its path.
838 + *
839 + * A part that is already one of our minified files is named by content,
840 + * so its basename is its content key and costs no IO. Any other part gets
841 + * its key from Asset_Manifest, which also follows the @import children
842 + * the combiner inlines.
843 + *
844 + * @param array<int,array{id?:string,path:string,url?:string}> $parts In output order.
845 + * @param string $kind 'css' or 'js'.
846 + */
847 + public static function combined_key( array $parts, string $kind ): string {
848 + $signature = array(
849 + defined( 'XSPEED_VERSION' ) ? (string) XSPEED_VERSION : '',
850 + Asset_Manifest::SCHEMA,
851 + rtrim( (string) home_url(), '/' ),
852 + $kind,
853 + );
854 + foreach ( $parts as $part ) {
855 + $path = (string) ( $part['path'] ?? '' );
856 + $signature[] = array(
857 + (string) ( $part['id'] ?? '' ),
858 + $path,
859 + self::part_content_key( $path, (string) ( $part['url'] ?? '' ), $kind ),
860 + );
861 + }
862 + return md5( (string) wp_json_encode( $signature ) );
863 + }
864 +
865 + /**
866 + * Content key for one part of a combined file.
867 + *
868 + * @param string $path Absolute path of the part.
869 + * @param string $url URL the combiner resolves the part's imports against.
870 + * @param string $kind 'css' or 'js'.
871 + */
872 + private static function part_content_key( string $path, string $url, string $kind ): string {
873 + if ( '' === $path ) {
874 + return '';
875 + }
876 + // Our own minified output, min/<content key>.<ext>: the name is the key.
877 + $min_root = rtrim( Minifier::min_dir(), '/' ) . '/';
878 + if ( 0 === strpos( $path, $min_root ) && false === strpos( substr( $path, strlen( $min_root ) ), '/' ) ) {
879 + return basename( $path );
880 + }
881 + $key = Asset_Manifest::key_for(
882 + 'part-' . $kind,
883 + $path,
884 + static function () use ( $kind, $url ): array {
885 + return 'css' === $kind && '' !== $url ? Asset_Manifest::import_dependencies( $url ) : array();
886 + }
887 + );
888 + return null === $key ? 'unreadable' : $key;
802 889 }
803 890
804 891 private static function read_local_file( string $path ): string {
805 892 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem unavailable on frontend; we already validated existence + readability.