queue, partition into * local + external. External (full http(s):// to other origins, * data: URIs, protocol-relative pointing elsewhere) stay enqueued * as-is; local handles get pulled out of the queue. * 2. Build cache key = md5(JSON({handle => [src, mtime]})). When the * combined file already exists for that key, skip generation. * 3. Otherwise: read each source body, resolve recursive @import * statements (depth-limited), rewrite url(...) paths to absolute, * concat with a small `/* xspeed: HANDLE *​/` header per chunk for * debug-traceability, write to XSPEED_CACHE_DIR/min/combined/. * 4. Register the combined file as a single new handle * `xspeed-combined-css` and re-add it to the queue. The original * handles stay registered (so other plugins that look them up * still find their metadata) but are pulled from the queue — * they won't print tags. * * JS path is the same, minus @import (no JS analogue) and url() * rewriting (JS strings are too varied to safely rewrite). External * + async + deferred scripts (deferred via WP_Scripts->add_data * 'strategy' OR the script_loader_tag filter from Minify_Filters) * stay un-combined. * * Cache lives in {$min_dir}/combined/ — separate from the per-file * minify cache so purge can target them independently if needed. * * @package XSpeed */ declare(strict_types=1); namespace XSpeed; defined( 'ABSPATH' ) || exit; final class Asset_Combiner { public const MAX_IMPORT_DEPTH = 3; /** * Path to the combine cache dir. Created on first write. */ public static function cache_dir(): string { return trailingslashit( XSPEED_CACHE_DIR ) . 'min/combined'; } /** * URL prefix matching cache_dir(). Built from content_url, not by * string-replacing filesystem paths (see class-minifier.php for the * same rationale). * * The scheme is forced to match the page's — `content_url()` derives * its scheme from `is_ssl()`, which returns false behind a TLS- * terminating reverse proxy / load balancer (common on managed hosts), * so it can hand back an `http://` URL on an `https` page. The browser * then blocks the combined stylesheet as mixed content and the whole * page renders unstyled. Re-scheme the URL to the site's actual scheme * so the always matches the page. (FBS-83633) */ public static function cache_url(): string { $url = trailingslashit( content_url( 'cache/xspeed' ) ) . 'min/combined'; // Match the site's registered scheme (home_url), NOT is_ssl() — // which set_url_scheme() would consult with no explicit scheme, and // which is the very signal that misreports behind a proxy. $scheme = wp_parse_url( home_url(), PHP_URL_SCHEME ) ?: 'https'; return set_url_scheme( $url, $scheme ); } /** * Combine local enqueued styles into one file. */ public static function combine_styles(): void { global $wp_styles; if ( ! $wp_styles instanceof \WP_Styles || empty( $wp_styles->queue ) ) { return; } // Group combinable handles by media type. Historically every sheet // whose media wasn't all/screen was dropped from combining — but on // page-builder sites (Elementor + Essential Addons + BetterDocs) a large // share of the stylesheets carry responsive/print media, so dropping // them starved the `all` bucket below the 2-handle floor and the whole // combine step silently no-op'd (the page shipped 60 separate s // even with combine_css ON). Instead we bucket PER media type and emit // one combined file per group with the correct `media` attribute, so // nothing is dropped and the combinable majority always merges. (FBS-83653) $buckets = self::collect_local_handles( $wp_styles ); foreach ( $buckets as $media => $bucket ) { if ( count( $bucket ) < 2 ) { continue; // nothing to gain from combining a single file in this group. } self::combine_media_group( $wp_styles, $media, $bucket ); } } /** * Combine one media group's handles into a single stylesheet and wire it * onto the group's carrier handle. * * @param string $media The media attribute for this group ('all', 'print', …). * @param array> $bucket handle => info map. */ private static function combine_media_group( \WP_Styles $wp_styles, string $media, array $bucket ): void { $key = self::cache_key( $bucket ); $dir = self::cache_dir(); $out_file = $dir . '/combined-' . $key . '.css'; $out_url = self::cache_url() . '/combined-' . $key . '.css'; if ( ! file_exists( $out_file ) ) { self::ensure_dir( $dir ); $contents = ''; foreach ( $bucket as $handle => $info ) { $body = self::read_local_file( $info['path'] ); if ( '' === $body ) { continue; } $body = self::resolve_imports( $body, $info['url'], 0 ); $body = self::rewrite_url_paths( $body, $info['url'] ); $contents .= "/* xspeed: $handle */\n" . $body . "\n"; } // Atomic write: file_put_contents with LOCK_EX so concurrent // renders don't race. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context, unavailable on frontend. file_put_contents( $out_file, $contents, LOCK_EX ); } // Point the FIRST combined handle at the combined file and blank the // rest. This is deliberate — we do NOT enqueue a fresh // `xspeed-combined-css` handle, because WordPress would print it at // the tail of the queue, AFTER any non-combinable stylesheets // (media-query sheets like woocommerce-smallscreen, wc-blocks-*, // external fonts) that originally sat between/after the combined // handles. That reorders the cascade and breaks layout — e.g. the // WooCommerce/Astra grid + sidebar widths get overridden by rules // that should have lower priority. By reusing the first combined // handle's own queue slot for the combined , the merged CSS // prints exactly where the earliest source stylesheet used to be, // preserving cascade order. (FBS-83114/83116) // // The remaining combined handles keep their registration + queue // membership (src blanked) so their wp_add_inline_style() data still // prints — WordPress only emits inline data for handles still in the // print queue, and some themes (Astra) attach that dynamic CSS on a // hook LATER than this priority-999 pass, so we can't harvest it now. // Dropping it is what made "combine CSS break the site". // The carrier is the FIRST bucket handle that WordPress hasn't already // printed. A block theme (Twenty Twenty-Five, etc.) prints some of its // per-block style handles BEFORE this priority-999 pass, marking them // `done`; pointing a done handle at the combined file emits no // at all — the merged CSS silently vanishes and the whole site renders // unstyled. Skipping done handles guarantees the carrier still prints. // If every bucket handle is already done, register a dedicated combined // handle so the CSS is never lost (cascade tail is far better than no // styles). (FBS-83633) $done = (array) $wp_styles->done; $carrier_set = false; foreach ( $bucket as $handle => $info ) { $reg = $wp_styles->registered[ $handle ] ?? null; if ( ! $reg instanceof \_WP_Dependency ) { continue; } if ( ! $carrier_set && ! in_array( $handle, $done, true ) ) { // Carry the combined file on this (not-yet-printed) handle's slot. $reg->src = $out_url; $reg->ver = $key; $reg->args = $media; $carrier_set = true; } else { // Inline-only carrier: no , keep inline CSS printable. $reg->src = false; $reg->ver = null; } } // Fallback: every bucket handle was already printed, so no carrier // could emit the combined . Register + enqueue a dedicated // handle so the merged CSS still loads (appended at the tail — not // cascade-ideal, but infinitely better than a fully unstyled page). if ( ! $carrier_set ) { $combined_handle = 'xspeed-combined-css-' . $media; wp_register_style( $combined_handle, $out_url, array(), $key, $media ); wp_enqueue_style( $combined_handle ); } } /** * Combine local enqueued scripts into one file. */ public static function combine_scripts(): void { global $wp_scripts; if ( ! $wp_scripts instanceof \WP_Scripts || empty( $wp_scripts->queue ) ) { return; } $bucket = self::collect_local_script_handles( $wp_scripts ); if ( count( $bucket ) < 2 ) { return; } $key = self::cache_key( $bucket ); $dir = self::cache_dir(); $out_file = $dir . '/combined-' . $key . '.js'; $out_url = self::cache_url() . '/combined-' . $key . '.js'; if ( ! file_exists( $out_file ) ) { self::ensure_dir( $dir ); $contents = ''; foreach ( $bucket as $handle => $info ) { $body = self::read_local_file( $info['path'] ); if ( '' === $body ) { continue; } $contents .= "/* xspeed: $handle */\n" . $body . "\n;\n"; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem unavailable on frontend. file_put_contents( $out_file, $contents, LOCK_EX ); } foreach ( $bucket as $handle => $info ) { $wp_scripts->dequeue( $handle ); } $combined_handle = 'xspeed-combined-js'; wp_register_script( $combined_handle, $out_url, array(), $key, true ); wp_enqueue_script( $combined_handle ); } /** * Walk WP_Styles->queue, return the handles whose src is a local file we * can safely combine, grouped BY media type so each media gets its own * combined file. Shape: * [ media => [ handle => [ 'url' => …, 'path' => …, 'mtime' => int, 'src' => … ] ] ]. * '' and 'screen' media fold into the 'all' group. */ private static function collect_local_handles( \WP_Styles $wp_styles ): array { $groups = array(); foreach ( $wp_styles->queue as $handle ) { if ( ! isset( $wp_styles->registered[ $handle ] ) ) { continue; } $reg = $wp_styles->registered[ $handle ]; $src = (string) ( $reg->src ?? '' ); if ( '' === $src ) { continue; } // Leave WordPress core block styles alone. Block themes (Twenty // Twenty-*, and any FSE theme) load per-block CSS conditionally and // print/track these handles through their own separated-styles // pipeline, often BEFORE this pass. Pulling them into a combined // file fights that pipeline and leaves the page unstyled. These are // already tiny + conditionally loaded, so there's little to gain. // Matches `wp-block-*` handles and any src under wp-includes/blocks/ // or the block-library dist dir. (FBS-83633) if ( 0 === strpos( $handle, 'wp-block-' ) || false !== strpos( $src, '/wp-includes/blocks/' ) || false !== strpos( $src, '/block-library/' ) ) { continue; } $abs = self::to_absolute_url( $src ); $info = self::local_info( $abs ); if ( null === $info ) { continue; // external or unresolvable — leave in queue. } // Bucket by media type. '' and 'screen' fold into 'all' (both mean // "the on-screen document"); every other media value (print, // max-width queries, …) gets its own group so we can emit one // combined file per media with the right attribute — instead of // dropping non-'all' sheets and starving the combinable bucket on // builder sites. (FBS-83653) $media = (string) ( $reg->args ?? 'all' ); if ( '' === $media || 'screen' === $media ) { $media = 'all'; } $groups[ $media ][ $handle ] = $info + array( 'src' => $src ); } return $groups; } private static function collect_local_script_handles( \WP_Scripts $wp_scripts ): array { $out = array(); foreach ( $wp_scripts->queue as $handle ) { if ( ! isset( $wp_scripts->registered[ $handle ] ) ) { continue; } $reg = $wp_scripts->registered[ $handle ]; $src = (string) ( $reg->src ?? '' ); if ( '' === $src ) { continue; } // Skip scripts that carry inline-after data (they expect // to run at their original spot). if ( ! empty( $reg->extra['after'] ) || ! empty( $reg->extra['before'] ) || ! empty( $reg->extra['data'] ) ) { continue; } // Skip async / defer-via-strategy. $strategy = $reg->extra['strategy'] ?? ''; if ( 'async' === $strategy || 'defer' === $strategy ) { continue; } $abs = self::to_absolute_url( $src ); $info = self::local_info( $abs ); if ( null === $info ) { continue; } $out[ $handle ] = $info + array( 'src' => $src ); } return $out; } /** * Convert a possibly-relative `src` into an absolute URL. */ private static function to_absolute_url( string $src ): string { if ( '' === $src ) { return ''; } if ( 0 === strpos( $src, '//' ) ) { return ( is_ssl() ? 'https:' : 'http:' ) . $src; } if ( 0 === strpos( $src, '/' ) ) { $home = home_url(); $home = (string) preg_replace( '#/$#', '', $home ); return $home . $src; } return $src; } /** * Resolve an absolute URL to a local filesystem path + mtime, or * return null if the URL isn't on this site / outside web root. * * @return array{url:string,path:string,mtime:int}|null */ public static function local_info( string $url ): ?array { if ( '' === $url ) { return null; } $home = home_url(); if ( 0 !== strpos( $url, $home ) ) { return null; } // Strip query / fragment for filesystem lookup; keep them in // the URL we hash against. $clean = strtok( $url, '?' ); if ( ! is_string( $clean ) ) { return null; } $path = ABSPATH . ltrim( str_replace( $home, '', $clean ), '/' ); if ( ! file_exists( $path ) || ! is_readable( $path ) ) { return null; } return array( 'url' => $url, 'path' => $path, 'mtime' => (int) filemtime( $path ), ); } private static function cache_key( array $bucket ): string { $signature = array(); foreach ( $bucket as $handle => $info ) { $signature[ $handle ] = array( $info['src'] ?? '', $info['mtime'] ?? 0 ); } return md5( wp_json_encode( $signature ) ); } private static function read_local_file( string $path ): string { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem unavailable on frontend; we already validated existence + readability. $body = file_get_contents( $path ); return is_string( $body ) ? $body : ''; } /** * Recursively inline `@import url(...)` (and `@import "...";`) * statements. Cycles detected via depth limit; cross-origin imports * are left alone. */ public static function resolve_imports( string $css, string $base_url, int $depth ): string { if ( $depth > self::MAX_IMPORT_DEPTH ) { return $css; } return (string) preg_replace_callback( '#@import\s+(?:url\s*\(\s*)?["\']?([^"\')]+)["\']?\s*\)?\s*([^;]*);#i', static function ( $m ) use ( $base_url, $depth ) { $target = trim( (string) $m[1] ); $media = trim( (string) $m[2] ); $abs = self::resolve_relative( $target, $base_url ); $info = self::local_info( $abs ); if ( null === $info ) { return $m[0]; // external or unresolvable; leave as-is. } $body = self::read_local_file( $info['path'] ); if ( '' === $body ) { return $m[0]; } $body = self::rewrite_url_paths( $body, $info['url'] ); $body = self::resolve_imports( $body, $info['url'], $depth + 1 ); if ( '' !== $media ) { return '@media ' . $media . " {\n" . $body . "\n}\n"; } return $body; }, $css ); } /** * Rewrite every `url(...)` whose argument is a relative path so it * becomes absolute (resolved against the source file's URL). The * combined file lives at a different location, so relative paths * would otherwise break. * * Skips: absolute URLs (http://, https://, //), data: URIs, * `#fragment-only`, blob:, javascript: (which shouldn't appear in * CSS but won't crash). */ public static function rewrite_url_paths( string $css, string $base_url ): string { return (string) preg_replace_callback( '#url\(\s*(["\']?)([^"\')]+)\1\s*\)#i', static function ( $m ) use ( $base_url ) { $quote = $m[1]; $raw = trim( (string) $m[2] ); if ( '' === $raw ) { return $m[0]; } if ( 0 === strpos( $raw, 'data:' ) || 0 === strpos( $raw, 'blob:' ) || 0 === strpos( $raw, '#' ) || 0 === strpos( $raw, 'http://' ) || 0 === strpos( $raw, 'https://' ) || 0 === strpos( $raw, '//' ) ) { return $m[0]; } $abs = self::resolve_relative( $raw, $base_url ); return 'url(' . $quote . $abs . $quote . ')'; }, $css ); } /** * Resolve a relative URL (no scheme, no leading /) against a base * URL. Public so tests can exercise it directly. */ public static function resolve_relative( string $target, string $base_url ): string { // Order matters — '//' is a prefix of '/' so the protocol-relative // check must happen BEFORE the leading-slash anchor. if ( 0 === strpos( $target, '//' ) ) { return ( is_ssl() ? 'https:' : 'http:' ) . $target; } if ( 0 === strpos( $target, '/' ) ) { $parts = wp_parse_url( $base_url ); if ( ! is_array( $parts ) ) { return $target; } $origin = ( $parts['scheme'] ?? 'http' ) . '://' . ( $parts['host'] ?? '' ); if ( isset( $parts['port'] ) ) { $origin .= ':' . $parts['port']; } return $origin . $target; } if ( 0 === strpos( $target, 'http://' ) || 0 === strpos( $target, 'https://' ) ) { return $target; } // Relative. Strip filename from base, resolve. $base_path = (string) wp_parse_url( $base_url, PHP_URL_PATH ); $base_dir = rtrim( str_replace( basename( $base_path ), '', $base_path ), '/' ); $parts = wp_parse_url( $base_url ); $origin = ( $parts['scheme'] ?? 'http' ) . '://' . ( $parts['host'] ?? '' ); if ( isset( $parts['port'] ) ) { $origin .= ':' . $parts['port']; } // Collapse ../ $joined = $base_dir . '/' . $target; $segments = array(); foreach ( explode( '/', $joined ) as $seg ) { if ( '' === $seg || '.' === $seg ) { continue; } if ( '..' === $seg ) { array_pop( $segments ); continue; } $segments[] = $seg; } return $origin . '/' . implode( '/', $segments ); } private static function ensure_dir( string $dir ): void { if ( ! is_dir( $dir ) ) { wp_mkdir_p( $dir ); } $silence = $dir . '/index.php'; if ( ! file_exists( $silence ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- bootstrap-time helper, WP_Filesystem unavailable. file_put_contents( $silence, "