queue at priority 999 and rewrote handles: point one handle at the
* merged file, blank the rest. That cannot be made correct, because WordPress
* keeps editing the queue after we are done.
*
* The reported break (#195, WooCommerce + Kadence) was not a flaw in our
* bucketing or carrier choice. Traced on a live install:
*
* prio 998 kadence-global src='.../global.min.css'
* prio 999 kadence-global src=false <- us, blanking a non-carrier
*
* ...and then core's `wp_maybe_inline_styles()` runs. It inlines any queued
* handle carrying a `path` data key and sets `src = false` on it
* (wp-includes/script-loader.php:3188). Our carrier was
* `classic-theme-styles`, which core registers WITH a path — so core read that
* handle's ORIGINAL file, inlined it, and discarded the combined URL we had
* just written there. The merged never printed and the five sheets we
* had blanked were gone. Six stylesheets became one, and the site rendered
* unstyled.
*
* No carrier-selection rule survives that: core rewrites the handle after us.
* So combining moves to the finished HTML, where what we read is what shipped.
* This is the layer LiteSpeed combines at, for the same reason.
*
* What that buys, beyond fixing the break:
*
* - Document order is visible, so the cascade can be preserved exactly.
* - Sheets printed by plugins outside the queue are seen (they were
* invisible to a queue walker, and got duplicated).
* - `data-no-optimize` / `data-optimized` opt-outs work, matching what
* LiteSpeed and Autoptimize already honor.
* - The swap path is a pure string transform, so it is unit-testable —
* the enqueue version needed a full WP bootstrap and never had a test.
*
* The cascade rule: only CONTIGUOUS runs of same-media local sheets merge. A
* sheet we cannot combine (external, opted out, excluded) ends the run, and
* everything after it starts a new one. Nothing is ever hoisted past anything
* else, which is the property the old combiner could not offer.
*
* @package XSpeed
*/
declare(strict_types=1);
namespace XSpeed;
defined( 'ABSPATH' ) || exit;
final class Css_Combine_Buffer {
/** Minimum sheets in a run before merging is worth a request. */
private const MIN_RUN = 2;
/** Our own output-buffer nesting level, when we had to open one. */
private static ?int $buffer_level = null;
/** Set once we have transformed a page, so we never do it twice. */
private static bool $done = false;
/**
* Make sure SOMETHING will hand us the finished HTML.
*
* `xspeed_cache_final_html` is the preferred route — the page cache
* already buffers, so we transform once and the result is baked into the
* cache file. But that filter fires only on a cacheable MISS. With the
* page cache off, or on an excluded URL (`/cart`, `/checkout` — precisely
* where a WooCommerce layout break hurts most), it never fires at all and
* combining would silently stop working.
*
* So: open our own buffer when the cache is not going to give us one, and
* no-op when it is. `$done` guarantees a page is transformed once whichever
* path gets there first.
*/
public static function boot(): void {
add_action(
'template_redirect',
static function (): void {
if ( is_admin() || wp_doing_ajax() || wp_doing_cron()
|| ( defined( 'REST_REQUEST' ) && REST_REQUEST )
|| ( defined( 'WP_CLI' ) && WP_CLI )
|| ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) {
return;
}
// The page cache is buffering and will call us through its
// filter; a second buffer would just copy the page again.
if ( class_exists( '\\XSpeed\\Cache' ) && Cache::is_buffering() ) {
return;
}
ob_start( array( __CLASS__, 'filter_buffer' ) );
self::$buffer_level = ob_get_level();
add_action( 'shutdown', array( __CLASS__, 'close_buffer' ), 0 );
},
1
);
}
/** ob_start() callback — transform once, pass everything else through. */
public static function filter_buffer( string $buffer ): string {
return self::process( $buffer );
}
/**
* Clear the once-per-request guard.
*
* Only tests need this: a request is a fresh process, but a test run
* exercises many documents through one loaded class.
*/
public static function reset(): void {
self::$done = false;
}
/** Flush only the buffer we opened. */
public static function close_buffer(): void {
if ( null !== self::$buffer_level && ob_get_level() >= self::$buffer_level ) {
ob_end_flush();
self::$buffer_level = null;
}
}
/**
* Combine stylesheet links in a finished HTML document.
*
* Returns the input unchanged when there is nothing to gain, so a caller
* can hand us any page unconditionally.
*
* @param string $html Complete page HTML.
*/
public static function process( string $html ): string {
if ( '' === $html || false === stripos( $html, ' is in scope. A in the body is either a late
// plugin injection or markup we do not own, and moving it changes
// paint order for something that already chose to be there.
$head_end = stripos( $html, '' );
if ( false === $head_end ) {
return $html;
}
$head = substr( $html, 0, $head_end );
$runs = self::runs( $head );
if ( empty( $runs ) ) {
return $html;
}
$new_head = $head;
foreach ( $runs as $run ) {
$merged = self::merge_run( $run );
if ( null === $merged ) {
continue;
}
// Replace the FIRST tag of the run with the combined link and drop
// the rest. Reusing the first slot is what keeps the merged CSS
// exactly where the earliest sheet was, preserving the cascade.
$first = true;
foreach ( $run['tags'] as $tag ) {
$new_head = self::replace_once( $new_head, $tag, $first ? $merged : '' );
$first = false;
}
}
if ( $new_head === $head ) {
return $html;
}
self::$done = true;
return $new_head . substr( $html, $head_end );
}
/**
* Split the head into contiguous runs of combinable same-media sheets.
*
* @return array
*/
private static function runs( string $head ): array {
// Blank out conditional comments and inline |#is',
static fn( $m ) => str_repeat( "\0", strlen( $m[0] ) ),
$head
);
}
private static function is_stylesheet( string $tag ): bool {
return (bool) preg_match( '#\brel\s*=\s*["\']?stylesheet["\']?#i', $tag );
}
private static function attr( string $tag, string $name ): string {
if ( preg_match( '#\b' . preg_quote( $name, '#' ) . '\s*=\s*["\']([^"\']*)["\']#i', $tag, $m ) ) {
return trim( $m[1] );
}
return '';
}
/** '' and 'screen' both mean the on-screen document. */
private static function media_of( string $tag ): string {
$media = strtolower( self::attr( $tag, 'media' ) );
return ( '' === $media || 'screen' === $media ) ? 'all' : $media;
}
private static function opted_out( string $tag ): bool {
return (bool) preg_match( '#\bdata-(no-optimize|optimized)\b#i', $tag );
}
/** @return string[] */
private static function excludes(): array {
/**
* Filter: xspeed_combine_css_excludes
*
* Substrings matched against each stylesheet URL. A sheet that matches
* keeps its own and breaks the run around it, so the cascade
* either side of it is untouched.
*
* @param string[] $excludes Substrings to leave alone.
*/
$list = apply_filters( 'xspeed_combine_css_excludes', array() );
return is_array( $list ) ? array_filter( array_map( 'strval', $list ) ) : array();
}
/** @param string[] $excludes */
private static function excluded( string $url, array $excludes ): bool {
foreach ( $excludes as $needle ) {
if ( '' !== $needle && false !== strpos( $url, $needle ) ) {
return true;
}
}
return false;
}
/**
* Absolute filesystem path for a same-origin stylesheet URL, or null when
* it is external, unreadable, or not a file we own.
*/
private static function local_path( string $url ): ?string {
$url = trim( html_entity_decode( $url, ENT_QUOTES ) );
if ( '' === $url || 0 === strpos( $url, 'data:' ) ) {
return null;
}
$clean = strtok( $url, '?' );
if ( false === $clean ) {
return null;
}
$info = Asset_Combiner::local_info( Asset_Combiner::to_absolute_url( $clean ) );
return is_array( $info ) && ! empty( $info['path'] ) ? (string) $info['path'] : null;
}
/** Inverse of local_path, for @import + url() resolution. */
private static function path_to_url( string $path ): string {
$root = defined( 'ABSPATH' ) ? rtrim( ABSPATH, '/' ) : '';
if ( '' !== $root && 0 === strpos( $path, $root ) ) {
return rtrim( home_url(), '/' ) . str_replace( $root, '', $path );
}
return $path;
}
/**
* Move any surviving `@import` to the top of the combined file.
*
* `resolve_imports()` inlines every import it can resolve, but a REMOTE
* one (a Google Fonts URL, a CDN stylesheet) cannot be inlined and is
* deliberately left in place. Standalone that is correct. In a combined
* file it lands mid-stream, and the CSS spec only honours `@import` before
* any style rule — so the browser silently drops it and that stylesheet
* never loads at all.
*
* Hoisting keeps them working. It does change their position relative to
* the merged rules, but an import that is ignored outright is strictly
* worse than one that loads early: ignored means the font or vendor sheet
* is simply absent.
*/
private static function hoist_imports( string $css ): string {
if ( false === stripos( $css, '@import' ) ) {
return $css;
}
$imports = array();
$body = (string) preg_replace_callback(
'#@import\s+[^;]+;#i',
static function ( $m ) use ( &$imports ) {
$imports[] = trim( (string) $m[0] );
return '';
},
$css
);
if ( empty( $imports ) ) {
return $css;
}
// Preserve source order, and drop duplicates — the same font import
// appearing in three merged sheets should be fetched once.
return implode( "\n", array_unique( $imports ) ) . "\n" . $body;
}
/**
* Drop the bytes that are only legal at the START of a stylesheet.
*
* A UTF-8 BOM and an `@charset` rule are both position-sensitive: a
* browser strips a LEADING BOM and honours a FIRST-LINE `@charset`, but
* either one appearing mid-file is just a stray token — and it invalidates
* the rule immediately after it.
*
* Kadence ships `woocommerce.min.css` with a BOM (`ef bb bf`). Standalone
* that is fine. Concatenated third into a combined file it killed the rule
* that followed — `.kadence-shop-top-row`, the flex container for the
* WooCommerce shop toolbar — so "Showing all 4 results", the sorting
* dropdown and the grid/list toggles collapsed into three stacked rows on
* /shop, while every other page looked fine. (QA on #195)
*
* The combined file needs no `@charset` of its own: it is served with a
* `Content-Type: text/css` charset from the webserver, which outranks an
* in-file rule.
*/
private static function strip_file_prelude( string $css ): string {
// BOM first — an @charset can sit behind one.
if ( 0 === strncmp( $css, "\xEF\xBB\xBF", 3 ) ) {
$css = substr( $css, 3 );
}
// Only a LEADING @charset is meaningful, so only that one is dropped;
// the string "@charset" inside a rule or comment is left alone.
return (string) preg_replace( '/^\s*@charset\s+["\'][^"\']*["\']\s*;/i', '', $css );
}
/** str_replace, but only the first occurrence. */
private static function replace_once( string $haystack, string $needle, string $replace ): string {
$pos = strpos( $haystack, $needle );
if ( false === $pos ) {
return $haystack;
}
return substr_replace( $haystack, $replace, $pos, strlen( $needle ) );
}
}