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 ) ); } }