PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.6
1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / class-css-combine-buffer.php

class-css-combine-buffer.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.1.6, at includes/class-css-combine-buffer.php

468 lines 17.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * CSS combining on the finished HTML.
4 *
5 * The enqueue-stage combiner (Asset_Combiner::combine_styles) walked
6 * WP_Styles->queue at priority 999 and rewrote handles: point one handle at the
7 * merged file, blank the rest. That cannot be made correct, because WordPress
8 * keeps editing the queue after we are done.
9 *
10 * The reported break (#195, WooCommerce + Kadence) was not a flaw in our
11 * bucketing or carrier choice. Traced on a live install:
12 *
13 * prio 998 kadence-global src='.../global.min.css'
14 * prio 999 kadence-global src=false <- us, blanking a non-carrier
15 *
16 * ...and then core's `wp_maybe_inline_styles()` runs. It inlines any queued
17 * handle carrying a `path` data key and sets `src = false` on it
18 * (wp-includes/script-loader.php:3188). Our carrier was
19 * `classic-theme-styles`, which core registers WITH a path — so core read that
20 * handle's ORIGINAL file, inlined it, and discarded the combined URL we had
21 * just written there. The merged <link> never printed and the five sheets we
22 * had blanked were gone. Six stylesheets became one, and the site rendered
23 * unstyled.
24 *
25 * No carrier-selection rule survives that: core rewrites the handle after us.
26 * So combining moves to the finished HTML, where what we read is what shipped.
27 * This is the layer LiteSpeed combines at, for the same reason.
28 *
29 * What that buys, beyond fixing the break:
30 *
31 * - Document order is visible, so the cascade can be preserved exactly.
32 * - Sheets printed by plugins outside the queue are seen (they were
33 * invisible to a queue walker, and got duplicated).
34 * - `data-no-optimize` / `data-optimized` opt-outs work, matching what
35 * LiteSpeed and Autoptimize already honor.
36 * - The swap path is a pure string transform, so it is unit-testable —
37 * the enqueue version needed a full WP bootstrap and never had a test.
38 *
39 * The cascade rule: only CONTIGUOUS runs of same-media local sheets merge. A
40 * sheet we cannot combine (external, opted out, excluded) ends the run, and
41 * everything after it starts a new one. Nothing is ever hoisted past anything
42 * else, which is the property the old combiner could not offer.
43 *
44 * @package XSpeed
45 */
46
47 declare(strict_types=1);
48
49 namespace XSpeed;
50
51 defined( 'ABSPATH' ) || exit;
52
53 final class Css_Combine_Buffer {
54
55 /** Minimum sheets in a run before merging is worth a request. */
56 private const MIN_RUN = 2;
57
58 /** Our own output-buffer nesting level, when we had to open one. */
59 private static ?int $buffer_level = null;
60
61 /** Set once we have transformed a page, so we never do it twice. */
62 private static bool $done = false;
63
64 /**
65 * Make sure SOMETHING will hand us the finished HTML.
66 *
67 * `xspeed_cache_final_html` is the preferred route — the page cache
68 * already buffers, so we transform once and the result is baked into the
69 * cache file. But that filter fires only on a cacheable MISS. With the
70 * page cache off, or on an excluded URL (`/cart`, `/checkout` — precisely
71 * where a WooCommerce layout break hurts most), it never fires at all and
72 * combining would silently stop working.
73 *
74 * So: open our own buffer when the cache is not going to give us one, and
75 * no-op when it is. `$done` guarantees a page is transformed once whichever
76 * path gets there first.
77 */
78 public static function boot(): void {
79 add_action(
80 'template_redirect',
81 static function (): void {
82 if ( is_admin() || wp_doing_ajax() || wp_doing_cron()
83 || ( defined( 'REST_REQUEST' ) && REST_REQUEST )
84 || ( defined( 'WP_CLI' ) && WP_CLI )
85 || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) {
86 return;
87 }
88 // The page cache is buffering and will call us through its
89 // filter; a second buffer would just copy the page again.
90 if ( class_exists( '\\XSpeed\\Cache' ) && Cache::is_buffering() ) {
91 return;
92 }
93 ob_start( array( __CLASS__, 'filter_buffer' ) );
94 self::$buffer_level = ob_get_level();
95 add_action( 'shutdown', array( __CLASS__, 'close_buffer' ), 0 );
96 },
97 1
98 );
99 }
100
101 /** ob_start() callback — transform once, pass everything else through. */
102 public static function filter_buffer( string $buffer ): string {
103 return self::process( $buffer );
104 }
105
106 /**
107 * Clear the once-per-request guard.
108 *
109 * Only tests need this: a request is a fresh process, but a test run
110 * exercises many documents through one loaded class.
111 */
112 public static function reset(): void {
113 self::$done = false;
114 }
115
116 /** Flush only the buffer we opened. */
117 public static function close_buffer(): void {
118 if ( null !== self::$buffer_level && ob_get_level() >= self::$buffer_level ) {
119 ob_end_flush();
120 self::$buffer_level = null;
121 }
122 }
123
124 /**
125 * Combine stylesheet links in a finished HTML document.
126 *
127 * Returns the input unchanged when there is nothing to gain, so a caller
128 * can hand us any page unconditionally.
129 *
130 * @param string $html Complete page HTML.
131 */
132 public static function process( string $html ): string {
133 if ( '' === $html || false === stripos( $html, '<link' ) ) {
134 return $html;
135 }
136 // Both entry points can fire on one request (our buffer wraps the
137 // page, the cache filter also runs). Transforming twice would be
138 // harmless but wasteful — and would re-parse a document whose sheets
139 // we already marked data-optimized.
140 if ( self::$done ) {
141 return $html;
142 }
143
144 // Only <head> is in scope. A <link> in the body is either a late
145 // plugin injection or markup we do not own, and moving it changes
146 // paint order for something that already chose to be there.
147 $head_end = stripos( $html, '</head>' );
148 if ( false === $head_end ) {
149 return $html;
150 }
151 $head = substr( $html, 0, $head_end );
152
153 $runs = self::runs( $head );
154 if ( empty( $runs ) ) {
155 return $html;
156 }
157
158 $new_head = $head;
159 foreach ( $runs as $run ) {
160 $merged = self::merge_run( $run );
161 if ( null === $merged ) {
162 continue;
163 }
164 // Replace the FIRST tag of the run with the combined link and drop
165 // the rest. Reusing the first slot is what keeps the merged CSS
166 // exactly where the earliest sheet was, preserving the cascade.
167 $first = true;
168 foreach ( $run['tags'] as $tag ) {
169 $new_head = self::replace_once( $new_head, $tag, $first ? $merged : '' );
170 $first = false;
171 }
172 }
173
174 if ( $new_head === $head ) {
175 return $html;
176 }
177 self::$done = true;
178 return $new_head . substr( $html, $head_end );
179 }
180
181 /**
182 * Split the head into contiguous runs of combinable same-media sheets.
183 *
184 * @return array<int,array{media:string,tags:string[],urls:string[]}>
185 */
186 private static function runs( string $head ): array {
187 // Blank out conditional comments and inline <style> so neither is
188 // parsed into, and so an inline block BREAKS a run: it may carry
189 // overrides that must keep their position between two sheets.
190 $scan = self::mask( $head );
191
192 if ( ! preg_match_all( '#<link\b[^>]*>#i', $scan, $m, PREG_OFFSET_CAPTURE ) ) {
193 return array();
194 }
195
196 $excludes = self::excludes();
197 $runs = array();
198 $open = -1; // index in $runs of the run still being extended.
199 $prev_end = null;
200
201 foreach ( $m[0] as $hit ) {
202 $offset = (int) $hit[1];
203 $tag = substr( $head, $offset, strlen( (string) $hit[0] ) );
204
205 if ( ! self::is_stylesheet( $tag ) ) {
206 continue;
207 }
208
209 $url = self::attr( $tag, 'href' );
210 $media = self::media_of( $tag );
211 $local = '' !== $url ? self::local_path( $url ) : null;
212
213 $combinable = null !== $local
214 && ! self::opted_out( $tag )
215 && ! self::excluded( $url, $excludes );
216
217 // Anything of substance BETWEEN two sheets ends the run: an inline
218 // <style> or a conditional block may carry overrides whose position
219 // relative to these sheets is load-bearing. Masked regions are NUL
220 // in $scan, so their presence is the test.
221 $gap = null === $prev_end ? '' : substr( $scan, $prev_end, $offset - $prev_end );
222 // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- testing whether the gap between two <link>s holds anything at all; wp_strip_all_tags() also trims and would hide a whitespace-only gap, which is exactly the case that must NOT break a run.
223 $gap_breaks = '' !== $gap && ( false !== strpos( $gap, "\0" ) || '' !== trim( strip_tags( $gap ) ) );
224
225 $prev_end = $offset + strlen( $tag );
226
227 if ( ! $combinable ) {
228 $open = -1; // an uncombinable sheet ends the run it sits in.
229 continue;
230 }
231
232 $extend = $open >= 0 && ! $gap_breaks && $runs[ $open ]['media'] === $media;
233 if ( $extend ) {
234 $runs[ $open ]['tags'][] = $tag;
235 $runs[ $open ]['urls'][] = $local;
236 continue;
237 }
238
239 $runs[] = array(
240 'media' => $media,
241 'tags' => array( $tag ),
242 'urls' => array( $local ),
243 );
244 $open = count( $runs ) - 1;
245 }
246
247 return array_values(
248 array_filter(
249 $runs,
250 static fn( $r ) => count( $r['tags'] ) >= self::MIN_RUN
251 )
252 );
253 }
254
255 /**
256 * Build the combined file for one run and return its <link>, or null when
257 * nothing could be read.
258 *
259 * @param array{media:string,tags:string[],urls:string[]} $run Run to merge.
260 */
261 private static function merge_run( array $run ): ?string {
262 $key = md5( implode( '|', array_map( static fn( $p ) => $p . ':' . (int) @filemtime( $p ), $run['urls'] ) ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a missing file contributes 0 to the key; handled below.
263 $dir = Asset_Combiner::cache_dir();
264 $file = $dir . '/combined-' . $key . '.css';
265 $url = Asset_Combiner::cache_url() . '/combined-' . $key . '.css';
266
267 if ( ! file_exists( $file ) ) {
268 $css = '';
269 foreach ( $run['urls'] as $path ) {
270 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading a local stylesheet during page render; WP_Filesystem needs admin context.
271 $body = (string) @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable sheet is skipped, not fatal.
272 if ( '' === $body ) {
273 continue;
274 }
275 $src = self::path_to_url( $path );
276 $body = self::strip_file_prelude( $body );
277 $body = Asset_Combiner::resolve_imports( $body, $src, 0 );
278 $body = Asset_Combiner::rewrite_url_paths( $body, $src );
279 $css .= "/* xspeed */\n" . $body . "\n";
280 }
281 if ( '' === trim( $css ) ) {
282 return null;
283 }
284 $css = self::hoist_imports( $css );
285 if ( ! is_dir( $dir ) ) {
286 wp_mkdir_p( $dir );
287 }
288 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context, unavailable on the frontend.
289 file_put_contents( $file, $css, LOCK_EX );
290 }
291
292 $media = 'all' === $run['media'] ? '' : sprintf( ' media="%s"', esc_attr( $run['media'] ) );
293
294 // data-optimized marks it ours, so a second pass — or another
295 // optimizer honoring the same convention — leaves it alone.
296 // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet -- this REPLACES already-enqueued <link>s in the finished HTML; wp_enqueue_style() cannot run here (the page is rendered) and is the layer whose late rewrites caused #195.
297 return sprintf(
298 '<link rel="stylesheet" href="%s" data-optimized="1"%s />',
299 esc_url( $url ),
300 $media
301 );
302 }
303
304 /* ------------------------------------------------------------------ */
305 /* Parsing helpers */
306 /* ------------------------------------------------------------------ */
307
308 /**
309 * Replace conditional comments and inline <style> with NUL padding of the
310 * same length, so offsets still line up with the original string.
311 */
312 private static function mask( string $head ): string {
313 return (string) preg_replace_callback(
314 '#<!--\[if.*?\[endif\]-->|<style\b[^>]*>.*?</style>|<!--.*?-->#is',
315 static fn( $m ) => str_repeat( "\0", strlen( $m[0] ) ),
316 $head
317 );
318 }
319
320 private static function is_stylesheet( string $tag ): bool {
321 return (bool) preg_match( '#\brel\s*=\s*["\']?stylesheet["\']?#i', $tag );
322 }
323
324 private static function attr( string $tag, string $name ): string {
325 if ( preg_match( '#\b' . preg_quote( $name, '#' ) . '\s*=\s*["\']([^"\']*)["\']#i', $tag, $m ) ) {
326 return trim( $m[1] );
327 }
328 return '';
329 }
330
331 /** '' and 'screen' both mean the on-screen document. */
332 private static function media_of( string $tag ): string {
333 $media = strtolower( self::attr( $tag, 'media' ) );
334 return ( '' === $media || 'screen' === $media ) ? 'all' : $media;
335 }
336
337 private static function opted_out( string $tag ): bool {
338 return (bool) preg_match( '#\bdata-(no-optimize|optimized)\b#i', $tag );
339 }
340
341 /** @return string[] */
342 private static function excludes(): array {
343 /**
344 * Filter: xspeed_combine_css_excludes
345 *
346 * Substrings matched against each stylesheet URL. A sheet that matches
347 * keeps its own <link> and breaks the run around it, so the cascade
348 * either side of it is untouched.
349 *
350 * @param string[] $excludes Substrings to leave alone.
351 */
352 $list = apply_filters( 'xspeed_combine_css_excludes', array() );
353 return is_array( $list ) ? array_filter( array_map( 'strval', $list ) ) : array();
354 }
355
356 /** @param string[] $excludes */
357 private static function excluded( string $url, array $excludes ): bool {
358 foreach ( $excludes as $needle ) {
359 if ( '' !== $needle && false !== strpos( $url, $needle ) ) {
360 return true;
361 }
362 }
363 return false;
364 }
365
366 /**
367 * Absolute filesystem path for a same-origin stylesheet URL, or null when
368 * it is external, unreadable, or not a file we own.
369 */
370 private static function local_path( string $url ): ?string {
371 $url = trim( html_entity_decode( $url, ENT_QUOTES ) );
372 if ( '' === $url || 0 === strpos( $url, 'data:' ) ) {
373 return null;
374 }
375 $clean = strtok( $url, '?' );
376 if ( false === $clean ) {
377 return null;
378 }
379 $info = Asset_Combiner::local_info( Asset_Combiner::to_absolute_url( $clean ) );
380 return is_array( $info ) && ! empty( $info['path'] ) ? (string) $info['path'] : null;
381 }
382
383 /** Inverse of local_path, for @import + url() resolution. */
384 private static function path_to_url( string $path ): string {
385 $root = defined( 'ABSPATH' ) ? rtrim( ABSPATH, '/' ) : '';
386 if ( '' !== $root && 0 === strpos( $path, $root ) ) {
387 return rtrim( home_url(), '/' ) . str_replace( $root, '', $path );
388 }
389 return $path;
390 }
391
392 /**
393 * Move any surviving `@import` to the top of the combined file.
394 *
395 * `resolve_imports()` inlines every import it can resolve, but a REMOTE
396 * one (a Google Fonts URL, a CDN stylesheet) cannot be inlined and is
397 * deliberately left in place. Standalone that is correct. In a combined
398 * file it lands mid-stream, and the CSS spec only honours `@import` before
399 * any style rule — so the browser silently drops it and that stylesheet
400 * never loads at all.
401 *
402 * Hoisting keeps them working. It does change their position relative to
403 * the merged rules, but an import that is ignored outright is strictly
404 * worse than one that loads early: ignored means the font or vendor sheet
405 * is simply absent.
406 */
407 private static function hoist_imports( string $css ): string {
408 if ( false === stripos( $css, '@import' ) ) {
409 return $css;
410 }
411
412 $imports = array();
413 $body = (string) preg_replace_callback(
414 '#@import\s+[^;]+;#i',
415 static function ( $m ) use ( &$imports ) {
416 $imports[] = trim( (string) $m[0] );
417 return '';
418 },
419 $css
420 );
421
422 if ( empty( $imports ) ) {
423 return $css;
424 }
425 // Preserve source order, and drop duplicates — the same font import
426 // appearing in three merged sheets should be fetched once.
427 return implode( "\n", array_unique( $imports ) ) . "\n" . $body;
428 }
429
430 /**
431 * Drop the bytes that are only legal at the START of a stylesheet.
432 *
433 * A UTF-8 BOM and an `@charset` rule are both position-sensitive: a
434 * browser strips a LEADING BOM and honours a FIRST-LINE `@charset`, but
435 * either one appearing mid-file is just a stray token — and it invalidates
436 * the rule immediately after it.
437 *
438 * Kadence ships `woocommerce.min.css` with a BOM (`ef bb bf`). Standalone
439 * that is fine. Concatenated third into a combined file it killed the rule
440 * that followed — `.kadence-shop-top-row`, the flex container for the
441 * WooCommerce shop toolbar — so "Showing all 4 results", the sorting
442 * dropdown and the grid/list toggles collapsed into three stacked rows on
443 * /shop, while every other page looked fine. (QA on #195)
444 *
445 * The combined file needs no `@charset` of its own: it is served with a
446 * `Content-Type: text/css` charset from the webserver, which outranks an
447 * in-file rule.
448 */
449 private static function strip_file_prelude( string $css ): string {
450 // BOM first — an @charset can sit behind one.
451 if ( 0 === strncmp( $css, "\xEF\xBB\xBF", 3 ) ) {
452 $css = substr( $css, 3 );
453 }
454 // Only a LEADING @charset is meaningful, so only that one is dropped;
455 // the string "@charset" inside a rule or comment is left alone.
456 return (string) preg_replace( '/^\s*@charset\s+["\'][^"\']*["\']\s*;/i', '', $css );
457 }
458
459 /** str_replace, but only the first occurrence. */
460 private static function replace_once( string $haystack, string $needle, string $replace ): string {
461 $pos = strpos( $haystack, $needle );
462 if ( false === $pos ) {
463 return $haystack;
464 }
465 return substr_replace( $haystack, $replace, $pos, strlen( $needle ) );
466 }
467 }
468