| @@ -81,9 +81,12 @@ | ||
| 81 | 81 | static function (): void { |
| 82 | 82 | if ( is_admin() || wp_doing_ajax() || wp_doing_cron() |
| 83 | 83 | || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) |
| 84 | 84 | || ( defined( 'WP_CLI' ) && WP_CLI ) |
| 85 | - || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) { | |
| 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() ) { | |
| 86 | 89 | return; |
| 87 | 90 | } |
| 88 | 91 | // The page cache is buffering and will call us through its |
| 89 | 92 | // filter; a second buffer would just copy the page again. |
| @@ -167,8 +170,15 @@ | ||
| 167 | 170 | $first = true; |
| 168 | 171 | foreach ( $run['tags'] as $tag ) { |
| 169 | 172 | $new_head = self::replace_once( $new_head, $tag, $first ? $merged : '' ); |
| 170 | 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 ); | |
| 171 | 181 | } |
| 172 | 182 | } |
| 173 | 183 | |
| 174 | 184 | if ( $new_head === $head ) { |
| @@ -180,9 +190,9 @@ | ||
| 180 | 190 | |
| 181 | 191 | /** |
| 182 | 192 | * Split the head into contiguous runs of combinable same-media sheets. |
| 183 | 193 | * |
| 184 | - * @return array<int,array{media:string,tags:string[],urls:string[]}> | |
| 194 | + * @return array<int,array{media:string,async:bool,tags:string[],urls:string[]}> | |
| 185 | 195 | */ |
| 186 | 196 | private static function runs( string $head ): array { |
| 187 | 197 | // Blank out conditional comments and inline <style> so neither is |
| 188 | 198 | // parsed into, and so an inline block BREAKS a run: it may carry |
| @@ -207,8 +217,9 @@ | ||
| 207 | 217 | } |
| 208 | 218 | |
| 209 | 219 | $url = self::attr( $tag, 'href' ); |
| 210 | 220 | $media = self::media_of( $tag ); |
| 221 | + $async = self::is_async_style( $tag ); | |
| 211 | 222 | $local = '' !== $url ? self::local_path( $url ) : null; |
| 212 | 223 | |
| 213 | 224 | $combinable = null !== $local |
| 214 | 225 | && ! self::opted_out( $tag ) |
| @@ -228,9 +239,15 @@ | ||
| 228 | 239 | $open = -1; // an uncombinable sheet ends the run it sits in. |
| 229 | 240 | continue; |
| 230 | 241 | } |
| 231 | 242 | |
| 232 | - $extend = $open >= 0 && ! $gap_breaks && $runs[ $open ]['media'] === $media; | |
| 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; | |
| 233 | 250 | if ( $extend ) { |
| 234 | 251 | $runs[ $open ]['tags'][] = $tag; |
| 235 | 252 | $runs[ $open ]['urls'][] = $local; |
| 236 | 253 | continue; |
| @@ -237,8 +254,9 @@ | ||
| 237 | 254 | } |
| 238 | 255 | |
| 239 | 256 | $runs[] = array( |
| 240 | 257 | 'media' => $media, |
| 258 | + 'async' => $async, | |
| 241 | 259 | 'tags' => array( $tag ), |
| 242 | 260 | 'urls' => array( $local ), |
| 243 | 261 | ); |
| 244 | 262 | $open = count( $runs ) - 1; |
| @@ -255,9 +273,9 @@ | ||
| 255 | 273 | /** |
| 256 | 274 | * Build the combined file for one run and return its <link>, or null when |
| 257 | 275 | * nothing could be read. |
| 258 | 276 | * |
| 259 | - * @param array{media:string,tags:string[],urls:string[]} $run Run to merge. | |
| 277 | + * @param array{media:string,async:bool,tags:string[],urls:string[]} $run Run to merge. | |
| 260 | 278 | */ |
| 261 | 279 | private static function merge_run( array $run ): ?string { |
| 262 | 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. |
| 263 | 281 | $dir = Asset_Combiner::cache_dir(); |
| @@ -275,8 +293,20 @@ | ||
| 275 | 293 | $src = self::path_to_url( $path ); |
| 276 | 294 | $body = self::strip_file_prelude( $body ); |
| 277 | 295 | $body = Asset_Combiner::resolve_imports( $body, $src, 0 ); |
| 278 | 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. | |
| 279 | 309 | $css .= "/* xspeed */\n" . $body . "\n"; |
| 280 | 310 | } |
| 281 | 311 | if ( '' === trim( $css ) ) { |
| 282 | 312 | return null; |
| @@ -281,8 +311,18 @@ | ||
| 281 | 311 | if ( '' === trim( $css ) ) { |
| 282 | 312 | return null; |
| 283 | 313 | } |
| 284 | 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 ); | |
| 285 | 325 | if ( ! is_dir( $dir ) ) { |
| 286 | 326 | wp_mkdir_p( $dir ); |
| 287 | 327 | } |
| 288 | 328 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context, unavailable on the frontend. |
| @@ -288,8 +328,24 @@ | ||
| 288 | 328 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context, unavailable on the frontend. |
| 289 | 329 | file_put_contents( $file, $css, LOCK_EX ); |
| 290 | 330 | } |
| 291 | 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 | + | |
| 292 | 348 | $media = 'all' === $run['media'] ? '' : sprintf( ' media="%s"', esc_attr( $run['media'] ) ); |
| 293 | 349 | |
| 294 | 350 | // data-optimized marks it ours, so a second pass — or another |
| 295 | 351 | // optimizer honoring the same convention — leaves it alone. |
| @@ -310,10 +366,29 @@ | ||
| 310 | 366 | * same length, so offsets still line up with the original string. |
| 311 | 367 | */ |
| 312 | 368 | private static function mask( string $head ): string { |
| 313 | 369 | return (string) preg_replace_callback( |
| 314 | - '#<!--\[if.*?\[endif\]-->|<style\b[^>]*>.*?</style>|<!--.*?-->#is', | |
| 315 | - static fn( $m ) => str_repeat( "\0", strlen( $m[0] ) ), | |
| 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 | + }, | |
| 316 | 391 | $head |
| 317 | 392 | ); |
| 318 | 393 | } |
| 319 | 394 | |
| @@ -327,14 +402,91 @@ | ||
| 327 | 402 | } |
| 328 | 403 | return ''; |
| 329 | 404 | } |
| 330 | 405 | |
| 331 | - /** '' and 'screen' both mean the on-screen document. */ | |
| 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 | + */ | |
| 332 | 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 | + } | |
| 333 | 436 | $media = strtolower( self::attr( $tag, 'media' ) ); |
| 334 | 437 | return ( '' === $media || 'screen' === $media ) ? 'all' : $media; |
| 335 | 438 | } |
| 336 | 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 | + | |
| 337 | 489 | private static function opted_out( string $tag ): bool { |
| 338 | 490 | return (bool) preg_match( '#\bdata-(no-optimize|optimized)\b#i', $tag ); |
| 339 | 491 | } |
| 340 | 492 | |
| @@ -410,11 +562,33 @@ | ||
| 410 | 562 | } |
| 411 | 563 | |
| 412 | 564 | $imports = array(); |
| 413 | 565 | $body = (string) preg_replace_callback( |
| 414 | - '#@import\s+[^;]+;#i', | |
| 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', | |
| 415 | 583 | static function ( $m ) use ( &$imports ) { |
| 416 | - $imports[] = trim( (string) $m[0] ); | |
| 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; | |
| 417 | 591 | return ''; |
| 418 | 592 | }, |
| 419 | 593 | $css |
| 420 | 594 | ); |
| @@ -445,8 +619,61 @@ | ||
| 445 | 619 | * The combined file needs no `@charset` of its own: it is served with a |
| 446 | 620 | * `Content-Type: text/css` charset from the webserver, which outranks an |
| 447 | 621 | * in-file rule. |
| 448 | 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 | + | |
| 449 | 676 | private static function strip_file_prelude( string $css ): string { |
| 450 | 677 | // BOM first — an @charset can sit behind one. |
| 451 | 678 | if ( 0 === strncmp( $css, "\xEF\xBB\xBF", 3 ) ) { |
| 452 | 679 | $css = substr( $css, 3 ); |
| @@ -456,8 +683,29 @@ | ||
| 456 | 683 | return (string) preg_replace( '/^\s*@charset\s+["\'][^"\']*["\']\s*;/i', '', $css ); |
| 457 | 684 | } |
| 458 | 685 | |
| 459 | 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 | + | |
| 460 | 708 | private static function replace_once( string $haystack, string $needle, string $replace ): string { |
| 461 | 709 | $pos = strpos( $haystack, $needle ); |
| 462 | 710 | if ( false === $pos ) { |
| 463 | 711 | return $haystack; |