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-asset-combiner.php +226 -26 1.3.3 → 1.4.2 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
@@ -725,29 +733,142 @@
725 733 return $src;
726 734 }
727 735
728 736 /**
729 - * Resolve an absolute URL to a local filesystem path + mtime, or
730 - * return null if the URL isn't on this site / outside web root.
737 + * Map a URL on this site to the file it names, whether or not the file
738 + * exists yet.
731 739 *
732 - * @return array{url:string,path:string,mtime:int}|null
740 + * The plugin, theme, uploads, content and includes bases WordPress
741 + * registers are checked first, and home_url() → ABSPATH only last. That
742 + * fallback alone assumed wp-content sits under ABSPATH, which is false on
743 + * Flywheel (/wordpress/ next to /www/wp-content), Bedrock (web/wp next to
744 + * web/app) and any WP_CONTENT_DIR define. There every stylesheet mapped to
745 + * a missing file, so Combine CSS combined nothing and said nothing. (#725)
746 + *
747 + * The URL must be on the site's host, and is then compared with each base
748 + * without its scheme, as Minifier::url_to_path() does. Behind a
749 + * TLS-terminating proxy, or after a move to https, the page, home_url()
750 + * and content_url() don't always agree on http or https, and a
751 + * subdirectory multisite's home_url() carries a path its content URLs
752 + * don't. A prefix test against home_url() refused all of those.
753 + *
754 + * `.` and `..` segments are resolved first, as a browser would, so a href
755 + * can't climb out of the base it appears to be under.
756 + *
757 + * @param string $url Absolute URL.
733 758 */
734 - public static function local_info( string $url ): ?array {
759 + public static function url_to_candidate_path( string $url ): ?string {
735 760 if ( '' === $url ) {
736 761 return null;
737 762 }
738 - $home = home_url();
739 - if ( 0 !== strpos( $url, $home ) ) {
763 + $clean = strtok( $url, '?#' );
764 + if ( ! is_string( $clean ) ) {
740 765 return null;
741 766 }
742 - // Strip query / fragment for filesystem lookup; keep them in
743 - // the URL we hash against.
744 - $clean = strtok( $url, '?' );
745 - if ( ! is_string( $clean ) ) {
767 + $host = wp_parse_url( $clean, PHP_URL_HOST );
768 + $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
769 + if ( ! is_string( $host ) || '' === $host || ! is_string( $site_host ) || strtolower( $host ) !== strtolower( $site_host ) ) {
746 770 return null;
747 771 }
748 - $path = ABSPATH . ltrim( str_replace( $home, '', $clean ), '/' );
749 - if ( ! file_exists( $path ) || ! is_readable( $path ) ) {
772 + $match = self::remove_dot_segments( self::strip_scheme( $clean ) );
773 + foreach ( self::path_bases() as $pair ) {
774 + list( $base_url, $base_dir ) = $pair;
775 + $prefix = self::strip_scheme( rtrim( (string) $base_url, '/' ) );
776 + if ( '' === $prefix || '' === (string) $base_dir || 0 !== strpos( $match, $prefix . '/' ) ) {
777 + continue;
778 + }
779 + return rtrim( (string) $base_dir, '/' ) . substr( $match, strlen( $prefix ) );
780 + }
781 + return null;
782 + }
783 +
784 + /**
785 + * The reverse of url_to_candidate_path(), for rebasing `@import` and
786 + * `url()` references found in a file we read from disk. A path under no
787 + * known base is returned as given.
788 + *
789 + * @param string $path Absolute filesystem path.
790 + */
791 + public static function path_to_url( string $path ): string {
792 + foreach ( self::path_bases() as $pair ) {
793 + list( $base_url, $base_dir ) = $pair;
794 + $dir = rtrim( (string) $base_dir, '/' );
795 + if ( '' === $dir || '' === (string) $base_url || 0 !== strpos( $path, $dir . '/' ) ) {
796 + continue;
797 + }
798 + // On home_url()'s scheme, as the rebased URL always was before
799 + // (#725). content_url() and plugins_url() follow is_ssl(), which
800 + // reads false behind a TLS-terminating proxy, and an http url()
801 + // in a combined file is blocked as mixed content on an https page.
802 + $scheme = wp_parse_url( home_url(), PHP_URL_SCHEME );
803 + $scheme = is_string( $scheme ) && '' !== $scheme ? $scheme : 'https';
804 + return $scheme . ':' . self::strip_scheme( rtrim( (string) $base_url, '/' ) ) . substr( $path, strlen( $dir ) );
805 + }
806 + return $path;
807 + }
808 +
809 + /**
810 + * URL base → directory pairs, most specific first. The last pair is the
811 + * old home_url() → ABSPATH mapping, so an install with wp-content under
812 + * ABSPATH resolves exactly as it did before.
813 + *
814 + * @return array<int,array{0:string,1:string}>
815 + */
816 + private static function path_bases(): array {
817 + $uploads = wp_get_upload_dir();
818 + return array(
819 + array( plugins_url(), WP_PLUGIN_DIR ),
820 + array( get_stylesheet_directory_uri(), get_stylesheet_directory() ),
821 + array( get_template_directory_uri(), get_template_directory() ),
822 + array( (string) ( $uploads['baseurl'] ?? '' ), (string) ( $uploads['basedir'] ?? '' ) ),
823 + array( content_url(), WP_CONTENT_DIR ),
824 + array( includes_url(), ABSPATH . WPINC ),
825 + array( home_url(), ABSPATH ),
826 + );
827 + }
828 +
829 + private static function strip_scheme( string $url ): string {
830 + return (string) preg_replace( '#^https?://#i', '//', $url );
831 + }
832 +
833 + /**
834 + * Resolve `.` and `..` in the path of a scheme-less `//host/path` URL.
835 + * `..` never climbs above the host.
836 + */
837 + private static function remove_dot_segments( string $url ): string {
838 + $slash = strpos( $url, '/', 2 );
839 + if ( false === $slash ) {
840 + return $url;
841 + }
842 + $out = array();
843 + foreach ( explode( '/', substr( $url, $slash + 1 ) ) as $seg ) {
844 + if ( '.' === $seg ) {
845 + continue;
846 + }
847 + if ( '..' === $seg ) {
848 + array_pop( $out );
849 + continue;
850 + }
851 + $out[] = $seg;
852 + }
853 + return substr( $url, 0, $slash ) . '/' . implode( '/', $out );
854 + }
855 +
856 + /**
857 + * Resolve an absolute URL to a local filesystem path + mtime, or
858 + * return null if the URL isn't on this site / outside web root.
859 + *
860 + * @return array{url:string,path:string,mtime:int}|null
861 + */
862 + public static function local_info( string $url ): ?array {
863 + // Query / fragment are stripped for the filesystem lookup and kept in
864 + // the URL we hash against.
865 + $path = self::url_to_candidate_path( $url );
866 + // Only the asset types the combiners read. Every caller wants a
867 + // stylesheet or a script, and the bytes end up in a public combined
868 + // file, so a href that names any other file (wp-config.php, say) must
869 + // not be read at all.
870 + if ( null === $path || ! preg_match( '/\.(?:css|js|mjs)$/i', $path ) || ! file_exists( $path ) || ! is_readable( $path ) ) {
750 871 return null;
751 872 }
752 873 return array(
753 874 'url' => $url,
@@ -792,14 +913,93 @@
792 913
793 914 return $out;
794 915 }
795 916
796 - private static function cache_key( array $bucket ): string {
797 - $signature = array();
917 + /**
918 + * Cache key for a queue-built bundle. See combined_key().
919 + *
920 + * @param array<string,array<mixed>> $bucket handle => info map.
921 + * @param string $kind 'css' or 'js'.
922 + */
923 + private static function cache_key( array $bucket, string $kind ): string {
924 + $parts = array();
798 925 foreach ( $bucket as $handle => $info ) {
799 - $signature[ $handle ] = array( $info['src'] ?? '', $info['mtime'] ?? 0 );
926 + $parts[] = array(
927 + 'id' => (string) $handle,
928 + 'path' => (string) ( $info['path'] ?? '' ),
929 + 'url' => (string) ( $info['url'] ?? '' ),
930 + );
800 931 }
801 - return md5( wp_json_encode( $signature ) );
932 + return self::combined_key( $parts, $kind );
933 + }
934 +
935 + /**
936 + * Name for a combined file, derived from what goes into it.
937 + *
938 + * md5 of the plugin version, this site's home_url(), and per part its
939 + * path and content key. The old key was each part's path and mtime,
940 + * which had two faults:
941 + *
942 + * - It followed mtime, not bytes, so an in-place edit that kept the
943 + * mtime (or an edit to an @import child) served stale CSS, and a
944 + * touch with no change orphaned every cached page.
945 + * - It left out the site. The combined body rewrites every url() to an
946 + * ABSOLUTE URL built from home_url(), and min/combined/ is shared by
947 + * every blog on a network, so the first subsite to render a set of
948 + * sheets wrote its own origin into the file every other subsite then
949 + * served. home_url() is taken whole, path included, because a
950 + * subdirectory subsite's URLs carry its path.
951 + *
952 + * A part that is already one of our minified files is named by content,
953 + * so its basename is its content key and costs no IO. Any other part gets
954 + * its key from Asset_Manifest, which also follows the @import children
955 + * the combiner inlines.
956 + *
957 + * @param array<int,array{id?:string,path:string,url?:string}> $parts In output order.
958 + * @param string $kind 'css' or 'js'.
959 + */
960 + public static function combined_key( array $parts, string $kind ): string {
961 + $signature = array(
962 + defined( 'XSPEED_VERSION' ) ? (string) XSPEED_VERSION : '',
963 + Asset_Manifest::SCHEMA,
964 + rtrim( (string) home_url(), '/' ),
965 + $kind,
966 + );
967 + foreach ( $parts as $part ) {
968 + $path = (string) ( $part['path'] ?? '' );
969 + $signature[] = array(
970 + (string) ( $part['id'] ?? '' ),
971 + $path,
972 + self::part_content_key( $path, (string) ( $part['url'] ?? '' ), $kind ),
973 + );
974 + }
975 + return md5( (string) wp_json_encode( $signature ) );
976 + }
977 +
978 + /**
979 + * Content key for one part of a combined file.
980 + *
981 + * @param string $path Absolute path of the part.
982 + * @param string $url URL the combiner resolves the part's imports against.
983 + * @param string $kind 'css' or 'js'.
984 + */
985 + private static function part_content_key( string $path, string $url, string $kind ): string {
986 + if ( '' === $path ) {
987 + return '';
988 + }
989 + // Our own minified output, min/<content key>.<ext>: the name is the key.
990 + $min_root = rtrim( Minifier::min_dir(), '/' ) . '/';
991 + if ( 0 === strpos( $path, $min_root ) && false === strpos( substr( $path, strlen( $min_root ) ), '/' ) ) {
992 + return basename( $path );
993 + }
994 + $key = Asset_Manifest::key_for(
995 + 'part-' . $kind,
996 + $path,
997 + static function () use ( $kind, $url ): array {
998 + return 'css' === $kind && '' !== $url ? Asset_Manifest::import_dependencies( $url ) : array();
999 + }
1000 + );
1001 + return null === $key ? 'unreadable' : $key;
802 1002 }
803 1003
804 1004 private static function read_local_file( string $path ): string {
805 1005 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem unavailable on frontend; we already validated existence + readability.