| @@ -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. |