| @@ -100,11 +100,22 @@ | ||
| 100 | 100 | // minify_html (same filter, default priority) and is baked into |
| 101 | 101 | // the cache file, so it replays on static hits where PHP never |
| 102 | 102 | // boots. |
| 103 | 103 | add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'delay_raw_script_tags' ), 20 ); |
| 104 | + // The vendors' own install snippets are INLINE (no src at all), | |
| 105 | + // so the src sweep above never sees them; this one parks an | |
| 106 | + // inline body that names a known third-party host. | |
| 107 | + add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'delay_inline_snippets' ), 21 ); | |
| 104 | 108 | } |
| 105 | 109 | if ( ! empty( $opts['async_css'] ) ) { |
| 106 | 110 | add_filter( 'style_loader_tag', array( Minify_Filters::class, 'async_style_tag' ), 20, 2 ); |
| 111 | + // style_loader_tag only fires for wp_enqueue_style()'d sheets. | |
| 112 | + // Themes print Google/Bunny/Typekit font CSS as literal <link> | |
| 113 | + // markup in the head, so those sheets never reach the filter and | |
| 114 | + // stay render-blocking — sweep the finished buffer for the known | |
| 115 | + // font-CSS hosts. Baked into the cache file, so it replays on | |
| 116 | + // static hits where PHP never boots. | |
| 117 | + add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'async_raw_font_css_links' ), 22 ); | |
| 107 | 118 | } |
| 108 | 119 | |
| 109 | 120 | /* |
| 110 | 121 | * CSS combining runs on the FINISHED HTML, not the enqueue queue. |
| @@ -515,15 +526,34 @@ | ||
| 515 | 526 | return true; |
| 516 | 527 | } |
| 517 | 528 | |
| 518 | 529 | private static function balanced( string $source, string $minified ): bool { |
| 519 | - $pairs = array( '(', ')', '{', '}', '[', ']', '`' ); | |
| 520 | - foreach ( $pairs as $token ) { | |
| 521 | - if ( substr_count( $source, $token ) !== substr_count( $minified, $token ) ) { | |
| 522 | - return false; | |
| 523 | - } | |
| 524 | - } | |
| 525 | - return true; | |
| 530 | + unset( $source ); | |
| 531 | + | |
| 532 | + // Judge the OUTPUT, not the difference between input and output. | |
| 533 | + // | |
| 534 | + // This used to compare token counts across the pair, on the stated | |
| 535 | + // assumption that "literal braces inside strings survive minification | |
| 536 | + // unchanged, so they cancel out". Comments do not: stripping them is | |
| 537 | + // the minifier's whole job, and every brace, bracket and backtick | |
| 538 | + // inside one disappears with it. So any file whose comments contain a | |
| 539 | + // delimiter — a commented-out block, a URL in a docblock, an SVG in a | |
| 540 | + // note — failed the check and silently shipped unminified. | |
| 541 | + // | |
| 542 | + // It is not a rare shape. EmbedPress's front.js counts 372 braces | |
| 543 | + // against 368, 61 brackets against 58 and 110 backticks against 102 | |
| 544 | + // purely from comment removal, so 67 KB shipped raw where 46 KB was | |
| 545 | + // correct — and `node --check` confirms that rejected output parses | |
| 546 | + // fine. A guard that refuses valid work is not conservative, it is | |
| 547 | + // broken: it costs bytes on every request and reports nothing. | |
| 548 | + // | |
| 549 | + // What the guard is FOR still stands (#2): matthiasmullie/minify can | |
| 550 | + // truncate inside a template literal on complex modern JS and return a | |
| 551 | + // body that looks minified but is structurally broken. That failure is | |
| 552 | + // visible in the output alone — an unterminated literal leaves an odd | |
| 553 | + // backtick count and unmatched braces — which is exactly what | |
| 554 | + // self_consistent() measures, without the false positives. | |
| 555 | + return self::self_consistent( $minified ); | |
| 526 | 556 | } |
| 527 | 557 | |
| 528 | 558 | /** |
| 529 | 559 | * Resolve a local asset URL to a filesystem path using a strict allowlist |
| @@ -567,19 +597,40 @@ | ||
| 567 | 597 | array( content_url(), WP_CONTENT_DIR ), |
| 568 | 598 | array( includes_url(), ABSPATH . WPINC ), |
| 569 | 599 | ); |
| 570 | 600 | |
| 601 | + // The host check above normalised the HOST but not the SCHEME, and the | |
| 602 | + // prefix match below is a plain string compare — so an https asset URL | |
| 603 | + // never matched an http base and the file silently shipped unminified. | |
| 604 | + // That is not a corner case: WP_CONTENT_URL is derived from a stored | |
| 605 | + // option, `plugins_url()` from another, and a site moved to https | |
| 606 | + // without rewriting every row (or one behind a TLS-terminating proxy | |
| 607 | + // where `is_ssl()` reads false) serves https pages off http-rooted | |
| 608 | + // bases all day. Comparing scheme-less is the whole fix; the host | |
| 609 | + // equality test already did the security work of refusing anything | |
| 610 | + // off-site, and this runs after it. | |
| 611 | + $strip_scheme = static function ( string $value ): string { | |
| 612 | + return (string) preg_replace( '#^https?://#i', '//', $value ); | |
| 613 | + }; | |
| 614 | + $clean_match = $strip_scheme( $clean ); | |
| 615 | + | |
| 571 | 616 | foreach ( $candidates as $pair ) { |
| 572 | 617 | list( $url_base, $path_base ) = $pair; |
| 573 | 618 | if ( ! $url_base || ! $path_base ) { |
| 574 | 619 | continue; |
| 575 | 620 | } |
| 576 | - $url_base = rtrim( $url_base, '/' ); | |
| 577 | - if ( 0 !== strpos( $clean, $url_base . '/' ) && $clean !== $url_base ) { | |
| 621 | + $url_base = rtrim( $url_base, '/' ); | |
| 622 | + $base_match = $strip_scheme( $url_base ); | |
| 623 | + if ( 0 !== strpos( $clean_match, $base_match . '/' ) && $clean_match !== $base_match ) { | |
| 578 | 624 | continue; |
| 579 | 625 | } |
| 580 | 626 | |
| 581 | - $relative = ltrim( substr( $clean, strlen( $url_base ) ), '/' ); | |
| 627 | + // Slice the scheme-less pair, not the original. `https://…` and | |
| 628 | + // `http://…` differ by one byte, so an offset taken from the base | |
| 629 | + // as written would cut one character short of (or past) the path | |
| 630 | + // when the two schemes disagree — which is the case this fix | |
| 631 | + // exists for. | |
| 632 | + $relative = ltrim( substr( $clean_match, strlen( $base_match ) ), '/' ); | |
| 582 | 633 | $candidate = trailingslashit( $path_base ) . $relative; |
| 583 | 634 | |
| 584 | 635 | $real_base = realpath( $path_base ); |
| 585 | 636 | $real = realpath( $candidate ); |
| @@ -607,10 +658,16 @@ | ||
| 607 | 658 | Cache::write_silence( $dir ); |
| 608 | 659 | } |
| 609 | 660 | } |
| 610 | 661 | |
| 662 | + /** | |
| 663 | + * Clear every minified / combined asset. | |
| 664 | + * | |
| 665 | + * @return int Files removed. Most callers are `add_action` callbacks and | |
| 666 | + * ignore it; `wp xspeed purge` reports it as a line item. | |
| 667 | + */ | |
| 611 | 668 | public static function purge_minified() { |
| 612 | - self::rmtree_files( self::min_dir() ); | |
| 669 | + return self::rmtree_files( self::min_dir() ); | |
| 613 | 670 | } |
| 614 | 671 | |
| 615 | 672 | /** |
| 616 | 673 | * Recursively delete every file under $dir (and the emptied |
| @@ -619,18 +676,21 @@ | ||
| 619 | 676 | * min/combined/ were never cleared — a purge left a stale |
| 620 | 677 | * combined-<hash>.css the regenerated page no longer referenced. |
| 621 | 678 | * (FBS-83114 / FBS-83116) |
| 622 | 679 | */ |
| 623 | - private static function rmtree_files( string $dir ): void { | |
| 680 | + private static function rmtree_files( string $dir ): int { | |
| 624 | 681 | if ( ! is_dir( $dir ) ) { |
| 625 | - return; | |
| 682 | + return 0; | |
| 626 | 683 | } |
| 684 | + $removed = 0; | |
| 627 | 685 | foreach ( (array) glob( $dir . '/*' ) as $path ) { |
| 628 | 686 | if ( is_dir( $path ) ) { |
| 629 | - self::rmtree_files( $path ); | |
| 687 | + $removed += self::rmtree_files( $path ); | |
| 630 | 688 | @rmdir( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup of our own cache subdir; WP_Filesystem is unavailable on the frontend purge path. |
| 631 | 689 | continue; |
| 632 | 690 | } |
| 633 | 691 | wp_delete_file( $path ); |
| 692 | + ++$removed; | |
| 634 | 693 | } |
| 694 | + return $removed; | |
| 635 | 695 | } |
| 636 | 696 | } |