PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
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 1.2.1 All 27 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.3.0, at includes/class-css-combine-buffer.php

716 lines 27.5 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 // Combining a builder editor's CSS reorders the cascade the
87 // editor's own UI depends on. (#281)
88 || Builder_Editor::is_active() ) {
89 return;
90 }
91 // The page cache is buffering and will call us through its
92 // filter; a second buffer would just copy the page again.
93 if ( class_exists( '\\XSpeed\\Cache' ) && Cache::is_buffering() ) {
94 return;
95 }
96 ob_start( array( __CLASS__, 'filter_buffer' ) );
97 self::$buffer_level = ob_get_level();
98 add_action( 'shutdown', array( __CLASS__, 'close_buffer' ), 0 );
99 },
100 1
101 );
102 }
103
104 /** ob_start() callback — transform once, pass everything else through. */
105 public static function filter_buffer( string $buffer ): string {
106 return self::process( $buffer );
107 }
108
109 /**
110 * Clear the once-per-request guard.
111 *
112 * Only tests need this: a request is a fresh process, but a test run
113 * exercises many documents through one loaded class.
114 */
115 public static function reset(): void {
116 self::$done = false;
117 }
118
119 /** Flush only the buffer we opened. */
120 public static function close_buffer(): void {
121 if ( null !== self::$buffer_level && ob_get_level() >= self::$buffer_level ) {
122 ob_end_flush();
123 self::$buffer_level = null;
124 }
125 }
126
127 /**
128 * Combine stylesheet links in a finished HTML document.
129 *
130 * Returns the input unchanged when there is nothing to gain, so a caller
131 * can hand us any page unconditionally.
132 *
133 * @param string $html Complete page HTML.
134 */
135 public static function process( string $html ): string {
136 if ( '' === $html || false === stripos( $html, '<link' ) ) {
137 return $html;
138 }
139 // Both entry points can fire on one request (our buffer wraps the
140 // page, the cache filter also runs). Transforming twice would be
141 // harmless but wasteful — and would re-parse a document whose sheets
142 // we already marked data-optimized.
143 if ( self::$done ) {
144 return $html;
145 }
146
147 // Only <head> is in scope. A <link> in the body is either a late
148 // plugin injection or markup we do not own, and moving it changes
149 // paint order for something that already chose to be there.
150 $head_end = stripos( $html, '</head>' );
151 if ( false === $head_end ) {
152 return $html;
153 }
154 $head = substr( $html, 0, $head_end );
155
156 $runs = self::runs( $head );
157 if ( empty( $runs ) ) {
158 return $html;
159 }
160
161 $new_head = $head;
162 foreach ( $runs as $run ) {
163 $merged = self::merge_run( $run );
164 if ( null === $merged ) {
165 continue;
166 }
167 // Replace the FIRST tag of the run with the combined link and drop
168 // the rest. Reusing the first slot is what keeps the merged CSS
169 // exactly where the earliest sheet was, preserving the cascade.
170 $first = true;
171 foreach ( $run['tags'] as $tag ) {
172 $new_head = self::replace_once( $new_head, $tag, $first ? $merged : '' );
173 $first = false;
174 // Async CSS parks a <noscript> fallback immediately after each
175 // sheet it defers. The sheet it points at is now inside the
176 // combined file, so leaving the fallback behind would reload
177 // every original for no-JS visitors — the combine undone for
178 // exactly the audience least able to afford it. Drop it with
179 // its sheet; merge_run() rebuilds one for the combined <link>.
180 $new_head = self::drop_noscript_for( $new_head, $tag );
181 }
182 }
183
184 if ( $new_head === $head ) {
185 return $html;
186 }
187 self::$done = true;
188 return $new_head . substr( $html, $head_end );
189 }
190
191 /**
192 * Split the head into contiguous runs of combinable same-media sheets.
193 *
194 * @return array<int,array{media:string,async:bool,tags:string[],urls:string[]}>
195 */
196 private static function runs( string $head ): array {
197 // Blank out conditional comments and inline <style> so neither is
198 // parsed into, and so an inline block BREAKS a run: it may carry
199 // overrides that must keep their position between two sheets.
200 $scan = self::mask( $head );
201
202 if ( ! preg_match_all( '#<link\b[^>]*>#i', $scan, $m, PREG_OFFSET_CAPTURE ) ) {
203 return array();
204 }
205
206 $excludes = self::excludes();
207 $runs = array();
208 $open = -1; // index in $runs of the run still being extended.
209 $prev_end = null;
210
211 foreach ( $m[0] as $hit ) {
212 $offset = (int) $hit[1];
213 $tag = substr( $head, $offset, strlen( (string) $hit[0] ) );
214
215 if ( ! self::is_stylesheet( $tag ) ) {
216 continue;
217 }
218
219 $url = self::attr( $tag, 'href' );
220 $media = self::media_of( $tag );
221 $async = self::is_async_style( $tag );
222 $local = '' !== $url ? self::local_path( $url ) : null;
223
224 $combinable = null !== $local
225 && ! self::opted_out( $tag )
226 && ! self::excluded( $url, $excludes );
227
228 // Anything of substance BETWEEN two sheets ends the run: an inline
229 // <style> or a conditional block may carry overrides whose position
230 // relative to these sheets is load-bearing. Masked regions are NUL
231 // in $scan, so their presence is the test.
232 $gap = null === $prev_end ? '' : substr( $scan, $prev_end, $offset - $prev_end );
233 // 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.
234 $gap_breaks = '' !== $gap && ( false !== strpos( $gap, "\0" ) || '' !== trim( strip_tags( $gap ) ) );
235
236 $prev_end = $offset + strlen( $tag );
237
238 if ( ! $combinable ) {
239 $open = -1; // an uncombinable sheet ends the run it sits in.
240 continue;
241 }
242
243 // Async'd and render-blocking sheets never share a run: merging
244 // them would either make a blocking sheet non-blocking or drag an
245 // async'd one back onto the critical path. Grouping on the flag
246 // keeps each combined file honest about how it loads. (#330)
247 $extend = $open >= 0 && ! $gap_breaks
248 && $runs[ $open ]['media'] === $media
249 && $runs[ $open ]['async'] === $async;
250 if ( $extend ) {
251 $runs[ $open ]['tags'][] = $tag;
252 $runs[ $open ]['urls'][] = $local;
253 continue;
254 }
255
256 $runs[] = array(
257 'media' => $media,
258 'async' => $async,
259 'tags' => array( $tag ),
260 'urls' => array( $local ),
261 );
262 $open = count( $runs ) - 1;
263 }
264
265 return array_values(
266 array_filter(
267 $runs,
268 static fn( $r ) => count( $r['tags'] ) >= self::MIN_RUN
269 )
270 );
271 }
272
273 /**
274 * Build the combined file for one run and return its <link>, or null when
275 * nothing could be read.
276 *
277 * @param array{media:string,async:bool,tags:string[],urls:string[]} $run Run to merge.
278 */
279 private static function merge_run( array $run ): ?string {
280 $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.
281 $dir = Asset_Combiner::cache_dir();
282 $file = $dir . '/combined-' . $key . '.css';
283 $url = Asset_Combiner::cache_url() . '/combined-' . $key . '.css';
284
285 if ( ! file_exists( $file ) ) {
286 $css = '';
287 foreach ( $run['urls'] as $path ) {
288 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading a local stylesheet during page render; WP_Filesystem needs admin context.
289 $body = (string) @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable sheet is skipped, not fatal.
290 if ( '' === $body ) {
291 continue;
292 }
293 $src = self::path_to_url( $path );
294 $body = self::strip_file_prelude( $body );
295 $body = Asset_Combiner::resolve_imports( $body, $src, 0 );
296 $body = Asset_Combiner::rewrite_url_paths( $body, $src );
297 // Close any comment this file left open BEFORE it can reach the
298 // join. The `/* xspeed */` marker used to absorb this by
299 // accident — an unterminated `/*` swallowed the marker instead
300 // of the next stylesheet — but that made a debugging comment
301 // load-bearing, and it stopped working the moment the join was
302 // minified (issue #331). Neutralising it at the source is what
303 // actually holds.
304 $body = self::close_open_comment( $body );
305 // The marker stays: it is the separator that keeps a file
306 // ending mid-declaration from fusing its last selector onto the
307 // next file's first one. The minifier strips it from the
308 // artifact, so it costs nothing in the shipped bytes.
309 $css .= "/* xspeed */\n" . $body . "\n";
310 }
311 if ( '' === trim( $css ) ) {
312 return null;
313 }
314 $css = self::hoist_imports( $css );
315
316 // Minify AFTER hoisting: @import rules are only legal at the top
317 // of a stylesheet, so hoist_imports() has to see the un-minified
318 // text first. Minifying the join is what issue #331 was about —
319 // the inputs arrive minified but the concatenation did not, and
320 // Minifier::rewrite_style() deliberately skips anything under
321 // /cache/xspeed/, so this file was the end of the line. One
322 // failing file drops Lighthouse's near-binary `unminified-css`
323 // audit to 0.5, and the only offender on the page was ours.
324 $css = Asset_Combiner::minify_css_body( $css );
325 if ( ! is_dir( $dir ) ) {
326 wp_mkdir_p( $dir );
327 }
328 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context, unavailable on the frontend.
329 file_put_contents( $file, $css, LOCK_EX );
330 }
331
332 // Carry Async CSS across the merge (issue #330). Every sheet in the
333 // run was async'd — that is a condition of grouping them, enforced in
334 // runs() — so the combined file has to be non-render-blocking too, or
335 // combining would quietly cancel the other feature instead of the
336 // other way round. Same media="print" + onload swap async_style_tag
337 // emits, applied once to the one <link> that replaces them all.
338 if ( ! empty( $run['async'] ) ) {
339 $restore = 'all' === $run['media'] ? 'all' : $run['media'];
340 // The <noscript> fallback is rebuilt for the combined file, so a
341 // visitor without JS still gets the CSS — one request now instead
342 // of one per original sheet.
343 $async_markup = '<link rel="stylesheet" href="%1$s" data-optimized="1" media="print" onload="this.media=\'%2$s\'" data-xs-async="%2$s" />'
344 . '<noscript><link rel="stylesheet" href="%1$s" media="%2$s" /></noscript>'; // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet -- see the note on the non-async return below; this replaces finished-HTML <link>s.
345 return sprintf( $async_markup, esc_url( $url ), esc_attr( $restore ) );
346 }
347
348 $media = 'all' === $run['media'] ? '' : sprintf( ' media="%s"', esc_attr( $run['media'] ) );
349
350 // data-optimized marks it ours, so a second pass — or another
351 // optimizer honoring the same convention — leaves it alone.
352 // 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.
353 return sprintf(
354 '<link rel="stylesheet" href="%s" data-optimized="1"%s />',
355 esc_url( $url ),
356 $media
357 );
358 }
359
360 /* ------------------------------------------------------------------ */
361 /* Parsing helpers */
362 /* ------------------------------------------------------------------ */
363
364 /**
365 * Replace conditional comments and inline <style> with NUL padding of the
366 * same length, so offsets still line up with the original string.
367 */
368 private static function mask( string $head ): string {
369 return (string) preg_replace_callback(
370 // <noscript> is masked for the same reason as the rest: the <link>
371 // inside it is a FALLBACK, not a sheet the document loads. Async
372 // CSS emits one after every sheet it defers, so leaving them
373 // visible both doubled the link count and broke every run into
374 // single sheets — which is why combining silently stopped the
375 // moment async_css was switched on (#330). The combined <link>
376 // gets its own fallback rebuilt in merge_run().
377 '#<!--\[if.*?\[endif\]-->|<style\b[^>]*>.*?</style>|<noscript\b[^>]*>.*?</noscript>|<!--.*?-->#is',
378 static function ( $m ) {
379 // <noscript> is padded with SPACES, not NULs. Both are hidden
380 // from the link scanner, but the gap test below treats a NUL as
381 // "something load-bearing sits between these two sheets" and
382 // ends the run. An Async CSS fallback is not an override — it
383 // is a copy of the sheet we just read — so it must not break
384 // contiguity, or every async'd sheet ends up alone in its own
385 // run and nothing ever merges (#330).
386 if ( 0 === stripos( $m[0], '<noscript' ) ) {
387 return str_repeat( ' ', strlen( $m[0] ) );
388 }
389 return str_repeat( "\0", strlen( $m[0] ) );
390 },
391 $head
392 );
393 }
394
395 private static function is_stylesheet( string $tag ): bool {
396 return (bool) preg_match( '#\brel\s*=\s*["\']?stylesheet["\']?#i', $tag );
397 }
398
399 private static function attr( string $tag, string $name ): string {
400 if ( preg_match( '#\b' . preg_quote( $name, '#' ) . '\s*=\s*["\']([^"\']*)["\']#i', $tag, $m ) ) {
401 return trim( $m[1] );
402 }
403 return '';
404 }
405
406 /**
407 * The media this sheet really applies to.
408 *
409 * '' and 'screen' both mean the on-screen document.
410 *
411 * An async'd sheet is the special case (issue #330). Async CSS rewrites
412 * `media="all"` to `media="print"` and restores the original from an
413 * onload handler, parking it in `data-xs-async`. Reading the literal
414 * `media` attribute therefore filed every async'd sheet into a `print`
415 * bucket of its own, no run ever reached MIN_RUN, and combining silently
416 * stopped: the reported page went from 5 stylesheets to 22 while
417 * `get_settings` still reported `combine_css: true` — two features that
418 * the UI presents as independent, one quietly cancelling the other.
419 *
420 * `data-xs-async` holds the media the sheet will have a moment after
421 * load, which is the one that decides whether two sheets belong together.
422 *
423 * Two spellings, one meaning. Free's Async CSS parks the media in
424 * `data-xs-async`; Pro's Critical CSS defers the remaining sheets itself
425 * and parks it in `data-xspeed-async`. Reading only Free's spelling filed
426 * every Pro-deferred sheet as genuine `media="print"`, merged them into a
427 * print-only bundle and dropped the swap — a bare, unstyled page from two
428 * switches (#335 review, issue 1). Neither side owns the attribute name,
429 * so both are read here.
430 */
431 private static function media_of( string $tag ): string {
432 $async = strtolower( self::async_media( $tag ) );
433 if ( '' !== $async ) {
434 return ( 'screen' === $async ) ? 'all' : $async;
435 }
436 $media = strtolower( self::attr( $tag, 'media' ) );
437 return ( '' === $media || 'screen' === $media ) ? 'all' : $media;
438 }
439
440 /**
441 * The media parked on an async'd sheet, whichever attribute holds it.
442 *
443 * @return string '' when the sheet is not async'd.
444 */
445 private static function async_media( string $tag ): string {
446 foreach ( self::async_attrs() as $attr ) {
447 $value = self::attr( $tag, $attr );
448 if ( '' !== $value ) {
449 return $value;
450 }
451 }
452
453 // No attribute of ours, but the swap handler is the technique itself
454 // and says the same thing: this sheet is parked under `print` and
455 // becomes something else on load. Any plugin using the standard
456 // print/swap idiom is read correctly rather than merged into a
457 // print-only bundle and stripped of its handler (#335 review, issue 3).
458 if ( preg_match( '#\bonload\s*=\s*(["\'])\s*this\.media\s*=\s*(["\'])([^"\']*)\2#i', $tag, $m ) ) {
459 return $m[3];
460 }
461
462 return '';
463 }
464
465 /**
466 * Attributes that park a sheet's real media while it loads.
467 *
468 * `data-xs-async` is Free's; `data-xspeed-async` is Pro's Critical CSS.
469 * Filterable so a third deferring layer can declare itself rather than
470 * being merged into a print-only bundle.
471 *
472 * @return string[]
473 */
474 private static function async_attrs(): array {
475 $attrs = apply_filters( 'xspeed_async_css_attributes', array( 'data-xs-async', 'data-xspeed-async' ) );
476 return array_filter( array_map( 'strval', (array) $attrs ) );
477 }
478
479 /** True when an async layer — Free's or Pro's — has already transformed this link. */
480 private static function is_async_style( string $tag ): bool {
481 foreach ( self::async_attrs() as $attr ) {
482 if ( preg_match( '#\b' . preg_quote( $attr, '#' ) . '\s*=#i', $tag ) ) {
483 return true;
484 }
485 }
486 return '' !== self::async_media( $tag );
487 }
488
489 private static function opted_out( string $tag ): bool {
490 return (bool) preg_match( '#\bdata-(no-optimize|optimized)\b#i', $tag );
491 }
492
493 /** @return string[] */
494 private static function excludes(): array {
495 /**
496 * Filter: xspeed_combine_css_excludes
497 *
498 * Substrings matched against each stylesheet URL. A sheet that matches
499 * keeps its own <link> and breaks the run around it, so the cascade
500 * either side of it is untouched.
501 *
502 * @param string[] $excludes Substrings to leave alone.
503 */
504 $list = apply_filters( 'xspeed_combine_css_excludes', array() );
505 return is_array( $list ) ? array_filter( array_map( 'strval', $list ) ) : array();
506 }
507
508 /** @param string[] $excludes */
509 private static function excluded( string $url, array $excludes ): bool {
510 foreach ( $excludes as $needle ) {
511 if ( '' !== $needle && false !== strpos( $url, $needle ) ) {
512 return true;
513 }
514 }
515 return false;
516 }
517
518 /**
519 * Absolute filesystem path for a same-origin stylesheet URL, or null when
520 * it is external, unreadable, or not a file we own.
521 */
522 private static function local_path( string $url ): ?string {
523 $url = trim( html_entity_decode( $url, ENT_QUOTES ) );
524 if ( '' === $url || 0 === strpos( $url, 'data:' ) ) {
525 return null;
526 }
527 $clean = strtok( $url, '?' );
528 if ( false === $clean ) {
529 return null;
530 }
531 $info = Asset_Combiner::local_info( Asset_Combiner::to_absolute_url( $clean ) );
532 return is_array( $info ) && ! empty( $info['path'] ) ? (string) $info['path'] : null;
533 }
534
535 /** Inverse of local_path, for @import + url() resolution. */
536 private static function path_to_url( string $path ): string {
537 $root = defined( 'ABSPATH' ) ? rtrim( ABSPATH, '/' ) : '';
538 if ( '' !== $root && 0 === strpos( $path, $root ) ) {
539 return rtrim( home_url(), '/' ) . str_replace( $root, '', $path );
540 }
541 return $path;
542 }
543
544 /**
545 * Move any surviving `@import` to the top of the combined file.
546 *
547 * `resolve_imports()` inlines every import it can resolve, but a REMOTE
548 * one (a Google Fonts URL, a CDN stylesheet) cannot be inlined and is
549 * deliberately left in place. Standalone that is correct. In a combined
550 * file it lands mid-stream, and the CSS spec only honours `@import` before
551 * any style rule — so the browser silently drops it and that stylesheet
552 * never loads at all.
553 *
554 * Hoisting keeps them working. It does change their position relative to
555 * the merged rules, but an import that is ignored outright is strictly
556 * worse than one that loads early: ignored means the font or vendor sheet
557 * is simply absent.
558 */
559 private static function hoist_imports( string $css ): string {
560 if ( false === stripos( $css, '@import' ) ) {
561 return $css;
562 }
563
564 $imports = array();
565 $body = (string) preg_replace_callback(
566 // A semicolon inside the rule does NOT end it. `[^;]+` stopped at
567 // the first one, and a Google Fonts v2 URL puts semicolons in the
568 // query string — `?family=Open+Sans:wght@400;500;600;700` is the
569 // markup Google's own embed code hands you. The rule was cut in
570 // half: a truncated @import got hoisted and the remainder was left
571 // as loose garbage, so the browser dropped the import and the
572 // webfont never loaded. (#277)
573 //
574 // So consume the parts an @import is actually made of — quoted
575 // strings, url(...) including its own contents, and the media
576 // query — and only then take the terminating `;`. An unterminated
577 // @import at EOF is matched too, since browsers accept it.
578 // The alternation covers, in order: a quoted string, a url(...)
579 // with its contents, ANY other parenthesised group (a media
580 // query's `(min-width:600px)`), and finally any character that is
581 // none of those and not the terminator.
582 '#@import\s+(?:"[^"]*"|\'[^\']*\'|url\(\s*(?:"[^"]*"|\'[^\']*\'|[^)]*)\s*\)|\([^)]*\)|[^;\'"()])+\s*;?#i',
583 static function ( $m ) use ( &$imports ) {
584 $rule = trim( (string) $m[0] );
585 // Normalise a missing terminator so the hoisted block is valid
586 // even when the source relied on EOF to end the rule.
587 if ( '' !== $rule && ';' !== substr( $rule, -1 ) ) {
588 $rule .= ';';
589 }
590 $imports[] = $rule;
591 return '';
592 },
593 $css
594 );
595
596 if ( empty( $imports ) ) {
597 return $css;
598 }
599 // Preserve source order, and drop duplicates — the same font import
600 // appearing in three merged sheets should be fetched once.
601 return implode( "\n", array_unique( $imports ) ) . "\n" . $body;
602 }
603
604 /**
605 * Drop the bytes that are only legal at the START of a stylesheet.
606 *
607 * A UTF-8 BOM and an `@charset` rule are both position-sensitive: a
608 * browser strips a LEADING BOM and honours a FIRST-LINE `@charset`, but
609 * either one appearing mid-file is just a stray token — and it invalidates
610 * the rule immediately after it.
611 *
612 * Kadence ships `woocommerce.min.css` with a BOM (`ef bb bf`). Standalone
613 * that is fine. Concatenated third into a combined file it killed the rule
614 * that followed — `.kadence-shop-top-row`, the flex container for the
615 * WooCommerce shop toolbar — so "Showing all 4 results", the sorting
616 * dropdown and the grid/list toggles collapsed into three stacked rows on
617 * /shop, while every other page looked fine. (QA on #195)
618 *
619 * The combined file needs no `@charset` of its own: it is served with a
620 * `Content-Type: text/css` charset from the webserver, which outranks an
621 * in-file rule.
622 */
623 /**
624 * Close a comment the stylesheet left open.
625 *
626 * A `/*` with no closing `*​/` comments out everything after it. In a
627 * combined file that is every subsequent stylesheet — one malformed vendor
628 * file silently blanks the rest of the page's CSS.
629 *
630 * String literals are skipped, so `content: "/*"` is not mistaken for an
631 * opener. Pure — unit-tested.
632 */
633 public static function close_open_comment( string $css ): string {
634 $len = strlen( $css );
635 $in_string = '';
636 $i = 0;
637
638 while ( $i < $len ) {
639 $ch = $css[ $i ];
640
641 if ( '' !== $in_string ) {
642 if ( '\\' === $ch ) {
643 $i += 2;
644 continue;
645 }
646 if ( $ch === $in_string ) {
647 $in_string = '';
648 }
649 ++$i;
650 continue;
651 }
652
653 if ( '"' === $ch || "'" === $ch ) {
654 $in_string = $ch;
655 ++$i;
656 continue;
657 }
658
659 if ( '/' === $ch && $i + 1 < $len && '*' === $css[ $i + 1 ] ) {
660 $close = strpos( $css, '*/', $i + 2 );
661 if ( false === $close ) {
662 // Unterminated: close it at the end of this file so the
663 // next one in the bundle is still parsed.
664 return $css . '*/';
665 }
666 $i = $close + 2;
667 continue;
668 }
669
670 ++$i;
671 }
672
673 return $css;
674 }
675
676 private static function strip_file_prelude( string $css ): string {
677 // BOM first — an @charset can sit behind one.
678 if ( 0 === strncmp( $css, "\xEF\xBB\xBF", 3 ) ) {
679 $css = substr( $css, 3 );
680 }
681 // Only a LEADING @charset is meaningful, so only that one is dropped;
682 // the string "@charset" inside a rule or comment is left alone.
683 return (string) preg_replace( '/^\s*@charset\s+["\'][^"\']*["\']\s*;/i', '', $css );
684 }
685
686 /** str_replace, but only the first occurrence. */
687 /**
688 * Remove the `<noscript>` fallback that Async CSS emitted for one sheet.
689 *
690 * Matched by the sheet's own href so only its fallback goes — a page can
691 * carry many, and an unrelated one must survive. Whitespace between the
692 * link and its noscript is tolerated; anything else means this is not the
693 * pair we think it is, and nothing is removed.
694 */
695 private static function drop_noscript_for( string $head, string $tag ): string {
696 $href = self::attr( $tag, 'href' );
697 if ( '' === $href ) {
698 return $head;
699 }
700 return (string) preg_replace(
701 '#<noscript\b[^>]*>\s*<link\b[^>]*' . preg_quote( $href, '#' ) . '[^>]*>\s*</noscript>#i',
702 '',
703 $head,
704 1
705 );
706 }
707
708 private static function replace_once( string $haystack, string $needle, string $replace ): string {
709 $pos = strpos( $haystack, $needle );
710 if ( false === $pos ) {
711 return $haystack;
712 }
713 return substr_replace( $haystack, $replace, $pos, strlen( $needle ) );
714 }
715 }
716