PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
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 +26 -226 trunk → 1.3.3 View file →
@@ -8,11 +8,10 @@
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 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.
12 + * 2. Build cache key = md5(JSON({handle => [src, mtime]})). When the
13 + * combined file already exists for that key, skip generation.
15 14 * 3. Otherwise: read each source body, resolve recursive @import
16 15 * statements (depth-limited), rewrite url(...) paths to absolute,
17 16 * concat with a small `/* xspeed: HANDLE *​/` header per chunk for
18 17 * debug-traceability, write to XSPEED_CACHE_DIR/min/combined/.
@@ -119,12 +118,9 @@
119 118 * @param string $media The media attribute for this group ('all', 'print', …).
120 119 * @param array<string,array<mixed>> $bucket handle => info map.
121 120 */
122 121 private static function combine_media_group( \WP_Styles $wp_styles, string $media, array $bucket ): void {
123 - if ( ! Minifier::min_dir_writable() ) {
124 - return;
125 - }
126 - $key = self::cache_key( $bucket, 'css' );
122 + $key = self::cache_key( $bucket );
127 123 $dir = self::cache_dir();
128 124 $out_file = $dir . '/combined-' . $key . '.css';
129 125 $out_url = self::cache_url() . '/combined-' . $key . '.css';
130 126
@@ -162,12 +158,12 @@
162 158 // was the artifact we generated, which then docked the site on
163 159 // xSpeed Scan's own A2 check while the UI reported minify as on.
164 160 $contents = self::minify_css_body( $contents );
165 161
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 - }
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 );
170 166 } else {
171 167 self::mark_in_use( $out_file );
172 168 }
173 169
@@ -297,12 +293,9 @@
297 293 * @param int $group 0 = head, 1 = footer.
298 294 * @param array<string,array<mixed>> $bucket handle => info map for this group.
299 295 */
300 296 private static function combine_script_group( \WP_Scripts $wp_scripts, int $group, array $bucket ): void {
301 - if ( ! Minifier::min_dir_writable() ) {
302 - return;
303 - }
304 - $key = self::cache_key( $bucket, 'js' );
297 + $key = self::cache_key( $bucket );
305 298 $dir = self::cache_dir();
306 299 // Group in the filename so a head and a footer bundle can never
307 300 // collide on one cache key.
308 301 $out_file = $dir . '/combined-g' . $group . '-' . $key . '.js';
@@ -317,11 +310,10 @@
317 310 continue;
318 311 }
319 312 $contents .= "/* xspeed: $handle */\n" . $body . "\n;\n";
320 313 }
321 - if ( ! Asset_Manifest::write_atomic( $out_file, $contents ) && ! file_exists( $out_file ) ) {
322 - return;
323 - }
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 );
324 316 } else {
325 317 self::mark_in_use( $out_file );
326 318 }
327 319
@@ -733,142 +725,29 @@
733 725 return $src;
734 726 }
735 727
736 728 /**
737 - * Map a URL on this site to the file it names, whether or not the file
738 - * exists yet.
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.
739 731 *
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.
732 + * @return array{url:string,path:string,mtime:int}|null
758 733 */
759 - public static function url_to_candidate_path( string $url ): ?string {
734 + public static function local_info( string $url ): ?array {
760 735 if ( '' === $url ) {
761 736 return null;
762 737 }
763 - $clean = strtok( $url, '?#' );
764 - if ( ! is_string( $clean ) ) {
738 + $home = home_url();
739 + if ( 0 !== strpos( $url, $home ) ) {
765 740 return null;
766 741 }
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 ) ) {
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 ) ) {
770 746 return null;
771 747 }
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 ) ) {
748 + $path = ABSPATH . ltrim( str_replace( $home, '', $clean ), '/' );
749 + if ( ! file_exists( $path ) || ! is_readable( $path ) ) {
871 750 return null;
872 751 }
873 752 return array(
874 753 'url' => $url,
@@ -913,93 +792,14 @@
913 792
914 793 return $out;
915 794 }
916 795
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();
796 + private static function cache_key( array $bucket ): string {
797 + $signature = array();
925 798 foreach ( $bucket as $handle => $info ) {
926 - $parts[] = array(
927 - 'id' => (string) $handle,
928 - 'path' => (string) ( $info['path'] ?? '' ),
929 - 'url' => (string) ( $info['url'] ?? '' ),
930 - );
799 + $signature[ $handle ] = array( $info['src'] ?? '', $info['mtime'] ?? 0 );
931 800 }
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;
801 + return md5( wp_json_encode( $signature ) );
1002 802 }
1003 803
1004 804 private static function read_local_file( string $path ): string {
1005 805 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem unavailable on frontend; we already validated existence + readability.