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, "