PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 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 All 35 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.4.0, at includes/class-css-combine-buffer.php

749 lines 28.6 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 // Nowhere to write means nothing to link: a <link> to a file that was
281 // never written unstyles the page.
282 if ( ! Minifier::min_dir_writable() ) {
283 return null;
284 }
285 // Named by content and by site. See Asset_Combiner::combined_key().
286 $parts = array();
287 foreach ( $run['urls'] as $path ) {
288 $parts[] = array(
289 'path' => $path,
290 'url' => self::path_to_url( $path ),
291 );
292 }
293 $key = Asset_Combiner::combined_key( $parts, 'css' );
294 $dir = Asset_Combiner::cache_dir();
295 $file = $dir . '/combined-' . $key . '.css';
296 $url = Asset_Combiner::cache_url() . '/combined-' . $key . '.css';
297
298 if ( ! file_exists( $file ) ) {
299 $css = '';
300 foreach ( $run['urls'] as $path ) {
301 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading a local stylesheet during page render; WP_Filesystem needs admin context.
302 $body = (string) @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable sheet is skipped, not fatal.
303 if ( '' === $body ) {
304 continue;
305 }
306 $src = self::path_to_url( $path );
307 $body = self::strip_file_prelude( $body );
308 $body = Asset_Combiner::resolve_imports( $body, $src, 0 );
309 $body = Asset_Combiner::rewrite_url_paths( $body, $src );
310 // Close any comment this file left open BEFORE it can reach the
311 // join. The `/* xspeed */` marker used to absorb this by
312 // accident — an unterminated `/*` swallowed the marker instead
313 // of the next stylesheet — but that made a debugging comment
314 // load-bearing, and it stopped working the moment the join was
315 // minified (issue #331). Neutralising it at the source is what
316 // actually holds.
317 $body = self::close_open_comment( $body );
318 // The marker stays: it is the separator that keeps a file
319 // ending mid-declaration from fusing its last selector onto the
320 // next file's first one. The minifier strips it from the
321 // artifact, so it costs nothing in the shipped bytes.
322 $css .= "/* xspeed */\n" . $body . "\n";
323 }
324 if ( '' === trim( $css ) ) {
325 return null;
326 }
327 $css = self::hoist_imports( $css );
328
329 // Minify AFTER hoisting: @import rules are only legal at the top
330 // of a stylesheet, so hoist_imports() has to see the un-minified
331 // text first. Minifying the join is what issue #331 was about —
332 // the inputs arrive minified but the concatenation did not, and
333 // Minifier::rewrite_style() deliberately skips anything under
334 // /cache/xspeed/, so this file was the end of the line. One
335 // failing file drops Lighthouse's near-binary `unminified-css`
336 // audit to 0.5, and the only offender on the page was ours.
337 $css = Asset_Combiner::minify_css_body( $css );
338 if ( ! is_dir( $dir ) ) {
339 wp_mkdir_p( $dir );
340 }
341 // Atomic: a concurrent render sees the whole file or none of it.
342 // A failed write links nothing rather than a file that is not there.
343 if ( ! Asset_Manifest::write_atomic( $file, $css ) && ! file_exists( $file ) ) {
344 return null;
345 }
346 }
347
348 // Carry Async CSS across the merge (issue #330). Every sheet in the
349 // run was async'd — that is a condition of grouping them, enforced in
350 // runs() — so the combined file has to be non-render-blocking too, or
351 // combining would quietly cancel the other feature instead of the
352 // other way round. Same media="print" + onload swap async_style_tag
353 // emits, applied once to the one <link> that replaces them all.
354 if ( ! empty( $run['async'] ) ) {
355 $restore = 'all' === $run['media'] ? 'all' : $run['media'];
356 // The <noscript> fallback is rebuilt for the combined file, so a
357 // visitor without JS still gets the CSS — one request now instead
358 // of one per original sheet.
359 $async_markup = '<link rel="stylesheet" href="%1$s" data-optimized="1" media="print" onload="this.media=\'%2$s\'" data-xs-async="%2$s" />'
360 . '<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.
361 return sprintf( $async_markup, esc_url( $url ), esc_attr( $restore ) );
362 }
363
364 $media = 'all' === $run['media'] ? '' : sprintf( ' media="%s"', esc_attr( $run['media'] ) );
365
366 // data-optimized marks it ours, so a second pass — or another
367 // optimizer honoring the same convention — leaves it alone.
368 // 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.
369 return sprintf(
370 '<link rel="stylesheet" href="%s" data-optimized="1"%s />',
371 esc_url( $url ),
372 $media
373 );
374 }
375
376 /* ------------------------------------------------------------------ */
377 /* Parsing helpers */
378 /* ------------------------------------------------------------------ */
379
380 /**
381 * Replace conditional comments and inline <style> with NUL padding of the
382 * same length, so offsets still line up with the original string.
383 */
384 private static function mask( string $head ): string {
385 return (string) preg_replace_callback(
386 // <noscript> is masked for the same reason as the rest: the <link>
387 // inside it is a FALLBACK, not a sheet the document loads. Async
388 // CSS emits one after every sheet it defers, so leaving them
389 // visible both doubled the link count and broke every run into
390 // single sheets — which is why combining silently stopped the
391 // moment async_css was switched on (#330). The combined <link>
392 // gets its own fallback rebuilt in merge_run().
393 '#<!--\[if.*?\[endif\]-->|<style\b[^>]*>.*?</style>|<noscript\b[^>]*>.*?</noscript>|<!--.*?-->#is',
394 static function ( $m ) {
395 // <noscript> is padded with SPACES, not NULs. Both are hidden
396 // from the link scanner, but the gap test below treats a NUL as
397 // "something load-bearing sits between these two sheets" and
398 // ends the run. An Async CSS fallback is not an override — it
399 // is a copy of the sheet we just read — so it must not break
400 // contiguity, or every async'd sheet ends up alone in its own
401 // run and nothing ever merges (#330).
402 if ( 0 === stripos( $m[0], '<noscript' ) ) {
403 return str_repeat( ' ', strlen( $m[0] ) );
404 }
405 return str_repeat( "\0", strlen( $m[0] ) );
406 },
407 $head
408 );
409 }
410
411 private static function is_stylesheet( string $tag ): bool {
412 return (bool) preg_match( '#\brel\s*=\s*["\']?stylesheet["\']?#i', $tag );
413 }
414
415 private static function attr( string $tag, string $name ): string {
416 if ( preg_match( '#\b' . preg_quote( $name, '#' ) . '\s*=\s*["\']([^"\']*)["\']#i', $tag, $m ) ) {
417 return trim( $m[1] );
418 }
419 return '';
420 }
421
422 /**
423 * The media this sheet really applies to.
424 *
425 * '' and 'screen' both mean the on-screen document.
426 *
427 * An async'd sheet is the special case (issue #330). Async CSS rewrites
428 * `media="all"` to `media="print"` and restores the original from an
429 * onload handler, parking it in `data-xs-async`. Reading the literal
430 * `media` attribute therefore filed every async'd sheet into a `print`
431 * bucket of its own, no run ever reached MIN_RUN, and combining silently
432 * stopped: the reported page went from 5 stylesheets to 22 while
433 * `get_settings` still reported `combine_css: true` — two features that
434 * the UI presents as independent, one quietly cancelling the other.
435 *
436 * `data-xs-async` holds the media the sheet will have a moment after
437 * load, which is the one that decides whether two sheets belong together.
438 *
439 * Two spellings, one meaning. Free's Async CSS parks the media in
440 * `data-xs-async`; Pro's Critical CSS defers the remaining sheets itself
441 * and parks it in `data-xspeed-async`. Reading only Free's spelling filed
442 * every Pro-deferred sheet as genuine `media="print"`, merged them into a
443 * print-only bundle and dropped the swap — a bare, unstyled page from two
444 * switches (#335 review, issue 1). Neither side owns the attribute name,
445 * so both are read here.
446 */
447 private static function media_of( string $tag ): string {
448 $async = strtolower( self::async_media( $tag ) );
449 if ( '' !== $async ) {
450 return ( 'screen' === $async ) ? 'all' : $async;
451 }
452 $media = strtolower( self::attr( $tag, 'media' ) );
453 return ( '' === $media || 'screen' === $media ) ? 'all' : $media;
454 }
455
456 /**
457 * The media parked on an async'd sheet, whichever attribute holds it.
458 *
459 * @return string '' when the sheet is not async'd.
460 */
461 private static function async_media( string $tag ): string {
462 foreach ( self::async_attrs() as $attr ) {
463 $value = self::attr( $tag, $attr );
464 if ( '' !== $value ) {
465 return $value;
466 }
467 }
468
469 // No attribute of ours, but the swap handler is the technique itself
470 // and says the same thing: this sheet is parked under `print` and
471 // becomes something else on load. Any plugin using the standard
472 // print/swap idiom is read correctly rather than merged into a
473 // print-only bundle and stripped of its handler (#335 review, issue 3).
474 //
475 // The assignment can sit anywhere in the handler. loadCSS spells it
476 // `this.onload=null;this.media='all'`, and matching only a handler
477 // that STARTS with `this.media` filed two such sheets as real print
478 // sheets: merged into a `media="print"` bundle with no onload, they
479 // never applied on screen. (#560)
480 //
481 // The attribute is decoded first: a tag built with esc_attr() carries
482 // `this.media=&#039;all&#039;`. An assignment whose value is not a
483 // literal (`this.media=this.dataset.media`) is still a swap; it
484 // restores to the screen, so it is read as `all`.
485 if ( preg_match( '#(?<![-\w])onload\s*=\s*(?:"([^"]*)"|\'([^\']*)\')#i', $tag, $h ) ) {
486 $handler = html_entity_decode( '' !== $h[1] ? $h[1] : ( $h[2] ?? '' ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
487 if ( preg_match( '#\bthis\.media\s*=\s*(["\'])([^"\']*)\1#i', $handler, $m ) ) {
488 return $m[2];
489 }
490 if ( preg_match( '#\bthis\.media\s*=(?!=)#i', $handler ) ) {
491 return 'all';
492 }
493 }
494
495 return '';
496 }
497
498 /**
499 * Attributes that park a sheet's real media while it loads.
500 *
501 * `data-xs-async` is Free's; `data-xspeed-async` is Pro's Critical CSS.
502 * Filterable so a third deferring layer can declare itself rather than
503 * being merged into a print-only bundle.
504 *
505 * @return string[]
506 */
507 private static function async_attrs(): array {
508 $attrs = apply_filters( 'xspeed_async_css_attributes', array( 'data-xs-async', 'data-xspeed-async' ) );
509 return array_filter( array_map( 'strval', (array) $attrs ) );
510 }
511
512 /** True when an async layer — Free's or Pro's — has already transformed this link. */
513 private static function is_async_style( string $tag ): bool {
514 foreach ( self::async_attrs() as $attr ) {
515 if ( preg_match( '#\b' . preg_quote( $attr, '#' ) . '\s*=#i', $tag ) ) {
516 return true;
517 }
518 }
519 return '' !== self::async_media( $tag );
520 }
521
522 private static function opted_out( string $tag ): bool {
523 return (bool) preg_match( '#\bdata-(no-optimize|optimized)\b#i', $tag );
524 }
525
526 /** @return string[] */
527 private static function excludes(): array {
528 /**
529 * Filter: xspeed_combine_css_excludes
530 *
531 * Substrings matched against each stylesheet URL. A sheet that matches
532 * keeps its own <link> and breaks the run around it, so the cascade
533 * either side of it is untouched.
534 *
535 * @param string[] $excludes Substrings to leave alone.
536 */
537 $list = apply_filters( 'xspeed_combine_css_excludes', array() );
538 return is_array( $list ) ? array_filter( array_map( 'strval', $list ) ) : array();
539 }
540
541 /** @param string[] $excludes */
542 private static function excluded( string $url, array $excludes ): bool {
543 foreach ( $excludes as $needle ) {
544 if ( '' !== $needle && false !== strpos( $url, $needle ) ) {
545 return true;
546 }
547 }
548 return false;
549 }
550
551 /**
552 * Absolute filesystem path for a same-origin stylesheet URL, or null when
553 * it is external, unreadable, or not a file we own.
554 */
555 private static function local_path( string $url ): ?string {
556 $url = trim( html_entity_decode( $url, ENT_QUOTES ) );
557 if ( '' === $url || 0 === strpos( $url, 'data:' ) ) {
558 return null;
559 }
560 $clean = strtok( $url, '?' );
561 if ( false === $clean ) {
562 return null;
563 }
564 $info = Asset_Combiner::local_info( Asset_Combiner::to_absolute_url( $clean ) );
565 return is_array( $info ) && ! empty( $info['path'] ) ? (string) $info['path'] : null;
566 }
567
568 /** Inverse of local_path, for @import + url() resolution. */
569 private static function path_to_url( string $path ): string {
570 $root = defined( 'ABSPATH' ) ? rtrim( ABSPATH, '/' ) : '';
571 if ( '' !== $root && 0 === strpos( $path, $root ) ) {
572 return rtrim( home_url(), '/' ) . str_replace( $root, '', $path );
573 }
574 return $path;
575 }
576
577 /**
578 * Move any surviving `@import` to the top of the combined file.
579 *
580 * `resolve_imports()` inlines every import it can resolve, but a REMOTE
581 * one (a Google Fonts URL, a CDN stylesheet) cannot be inlined and is
582 * deliberately left in place. Standalone that is correct. In a combined
583 * file it lands mid-stream, and the CSS spec only honours `@import` before
584 * any style rule — so the browser silently drops it and that stylesheet
585 * never loads at all.
586 *
587 * Hoisting keeps them working. It does change their position relative to
588 * the merged rules, but an import that is ignored outright is strictly
589 * worse than one that loads early: ignored means the font or vendor sheet
590 * is simply absent.
591 */
592 private static function hoist_imports( string $css ): string {
593 if ( false === stripos( $css, '@import' ) ) {
594 return $css;
595 }
596
597 $imports = array();
598 $body = (string) preg_replace_callback(
599 // A semicolon inside the rule does NOT end it. `[^;]+` stopped at
600 // the first one, and a Google Fonts v2 URL puts semicolons in the
601 // query string — `?family=Open+Sans:wght@400;500;600;700` is the
602 // markup Google's own embed code hands you. The rule was cut in
603 // half: a truncated @import got hoisted and the remainder was left
604 // as loose garbage, so the browser dropped the import and the
605 // webfont never loaded. (#277)
606 //
607 // So consume the parts an @import is actually made of — quoted
608 // strings, url(...) including its own contents, and the media
609 // query — and only then take the terminating `;`. An unterminated
610 // @import at EOF is matched too, since browsers accept it.
611 // The alternation covers, in order: a quoted string, a url(...)
612 // with its contents, ANY other parenthesised group (a media
613 // query's `(min-width:600px)`), and finally any character that is
614 // none of those and not the terminator.
615 '#@import\s+(?:"[^"]*"|\'[^\']*\'|url\(\s*(?:"[^"]*"|\'[^\']*\'|[^)]*)\s*\)|\([^)]*\)|[^;\'"()])+\s*;?#i',
616 static function ( $m ) use ( &$imports ) {
617 $rule = trim( (string) $m[0] );
618 // Normalise a missing terminator so the hoisted block is valid
619 // even when the source relied on EOF to end the rule.
620 if ( '' !== $rule && ';' !== substr( $rule, -1 ) ) {
621 $rule .= ';';
622 }
623 $imports[] = $rule;
624 return '';
625 },
626 $css
627 );
628
629 if ( empty( $imports ) ) {
630 return $css;
631 }
632 // Preserve source order, and drop duplicates — the same font import
633 // appearing in three merged sheets should be fetched once.
634 return implode( "\n", array_unique( $imports ) ) . "\n" . $body;
635 }
636
637 /**
638 * Drop the bytes that are only legal at the START of a stylesheet.
639 *
640 * A UTF-8 BOM and an `@charset` rule are both position-sensitive: a
641 * browser strips a LEADING BOM and honours a FIRST-LINE `@charset`, but
642 * either one appearing mid-file is just a stray token — and it invalidates
643 * the rule immediately after it.
644 *
645 * Kadence ships `woocommerce.min.css` with a BOM (`ef bb bf`). Standalone
646 * that is fine. Concatenated third into a combined file it killed the rule
647 * that followed — `.kadence-shop-top-row`, the flex container for the
648 * WooCommerce shop toolbar — so "Showing all 4 results", the sorting
649 * dropdown and the grid/list toggles collapsed into three stacked rows on
650 * /shop, while every other page looked fine. (QA on #195)
651 *
652 * The combined file needs no `@charset` of its own: it is served with a
653 * `Content-Type: text/css` charset from the webserver, which outranks an
654 * in-file rule.
655 */
656 /**
657 * Close a comment the stylesheet left open.
658 *
659 * A `/*` with no closing `*​/` comments out everything after it. In a
660 * combined file that is every subsequent stylesheet — one malformed vendor
661 * file silently blanks the rest of the page's CSS.
662 *
663 * String literals are skipped, so `content: "/*"` is not mistaken for an
664 * opener. Pure — unit-tested.
665 */
666 public static function close_open_comment( string $css ): string {
667 $len = strlen( $css );
668 $in_string = '';
669 $i = 0;
670
671 while ( $i < $len ) {
672 $ch = $css[ $i ];
673
674 if ( '' !== $in_string ) {
675 if ( '\\' === $ch ) {
676 $i += 2;
677 continue;
678 }
679 if ( $ch === $in_string ) {
680 $in_string = '';
681 }
682 ++$i;
683 continue;
684 }
685
686 if ( '"' === $ch || "'" === $ch ) {
687 $in_string = $ch;
688 ++$i;
689 continue;
690 }
691
692 if ( '/' === $ch && $i + 1 < $len && '*' === $css[ $i + 1 ] ) {
693 $close = strpos( $css, '*/', $i + 2 );
694 if ( false === $close ) {
695 // Unterminated: close it at the end of this file so the
696 // next one in the bundle is still parsed.
697 return $css . '*/';
698 }
699 $i = $close + 2;
700 continue;
701 }
702
703 ++$i;
704 }
705
706 return $css;
707 }
708
709 private static function strip_file_prelude( string $css ): string {
710 // BOM first — an @charset can sit behind one.
711 if ( 0 === strncmp( $css, "\xEF\xBB\xBF", 3 ) ) {
712 $css = substr( $css, 3 );
713 }
714 // Only a LEADING @charset is meaningful, so only that one is dropped;
715 // the string "@charset" inside a rule or comment is left alone.
716 return (string) preg_replace( '/^\s*@charset\s+["\'][^"\']*["\']\s*;/i', '', $css );
717 }
718
719 /** str_replace, but only the first occurrence. */
720 /**
721 * Remove the `<noscript>` fallback that Async CSS emitted for one sheet.
722 *
723 * Matched by the sheet's own href so only its fallback goes — a page can
724 * carry many, and an unrelated one must survive. Whitespace between the
725 * link and its noscript is tolerated; anything else means this is not the
726 * pair we think it is, and nothing is removed.
727 */
728 private static function drop_noscript_for( string $head, string $tag ): string {
729 $href = self::attr( $tag, 'href' );
730 if ( '' === $href ) {
731 return $head;
732 }
733 return (string) preg_replace(
734 '#<noscript\b[^>]*>\s*<link\b[^>]*' . preg_quote( $href, '#' ) . '[^>]*>\s*</noscript>#i',
735 '',
736 $head,
737 1
738 );
739 }
740
741 private static function replace_once( string $haystack, string $needle, string $replace ): string {
742 $pos = strpos( $haystack, $needle );
743 if ( false === $pos ) {
744 return $haystack;
745 }
746 return substr_replace( $haystack, $replace, $pos, strlen( $needle ) );
747 }
748 }
749