| @@ -75,8 +75,15 @@ | ||
| 75 | 75 | add_filter( 'style_loader_src', array( __CLASS__, 'rewrite_style' ), 10, 2 ); |
| 76 | 76 | } |
| 77 | 77 | if ( ! empty( $opts['minify_js'] ) ) { |
| 78 | 78 | add_filter( 'script_loader_src', array( __CLASS__, 'rewrite_script' ), 10, 2 ); |
| 79 | + // The src rewrite above runs before any plugin's own | |
| 80 | + // `script_loader_tag` filter can stamp data-no-minify / | |
| 81 | + // data-no-optimize onto the tag, so the marker arrives too late | |
| 82 | + // to prevent it. Priority 15: after third-party tag filters at | |
| 83 | + // the default 10 have printed their markers, before our defer | |
| 84 | + // (20) and delay (30) look at the tag. (#456) | |
| 85 | + add_filter( 'script_loader_tag', array( Minify_Filters::class, 'restore_marked_script_src' ), 15, 3 ); | |
| 79 | 86 | } |
| 80 | 87 | |
| 81 | 88 | // Phase 4.1a — filter-only "smarter minifier" features. Each is |
| 82 | 89 | // gated on its own toggle so users can enable any subset. |
| @@ -100,11 +107,22 @@ | ||
| 100 | 107 | // minify_html (same filter, default priority) and is baked into |
| 101 | 108 | // the cache file, so it replays on static hits where PHP never |
| 102 | 109 | // boots. |
| 103 | 110 | add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'delay_raw_script_tags' ), 20 ); |
| 111 | + // The vendors' own install snippets are INLINE (no src at all), | |
| 112 | + // so the src sweep above never sees them; this one parks an | |
| 113 | + // inline body that names a known third-party host. | |
| 114 | + add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'delay_inline_snippets' ), 21 ); | |
| 104 | 115 | } |
| 105 | 116 | if ( ! empty( $opts['async_css'] ) ) { |
| 106 | 117 | add_filter( 'style_loader_tag', array( Minify_Filters::class, 'async_style_tag' ), 20, 2 ); |
| 118 | + // style_loader_tag only fires for wp_enqueue_style()'d sheets. | |
| 119 | + // Themes print Google/Bunny/Typekit font CSS as literal <link> | |
| 120 | + // markup in the head, so those sheets never reach the filter and | |
| 121 | + // stay render-blocking — sweep the finished buffer for the known | |
| 122 | + // font-CSS hosts. Baked into the cache file, so it replays on | |
| 123 | + // static hits where PHP never boots. | |
| 124 | + add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'async_raw_font_css_links' ), 22 ); | |
| 107 | 125 | } |
| 108 | 126 | |
| 109 | 127 | /* |
| 110 | 128 | * CSS combining runs on the FINISHED HTML, not the enqueue queue. |
| @@ -424,8 +442,14 @@ | ||
| 424 | 442 | if ( '' === trim( $body ) ) { |
| 425 | 443 | return $block; |
| 426 | 444 | } |
| 427 | 445 | |
| 446 | + // The tag asked to be left alone (data-no-optimize / data-no-minify — | |
| 447 | + // the convention consent managers print on their config scripts). (#456) | |
| 448 | + if ( Minify_Filters::tag_opts_out( $open ) ) { | |
| 449 | + return $block; | |
| 450 | + } | |
| 451 | + | |
| 428 | 452 | // Refuse a body that is already structurally broken. balanced() only |
| 429 | 453 | // compares source against minified, so it passes when BOTH are equally |
| 430 | 454 | // unbalanced — `function x( {` minifies to `function x({`, same counts, |
| 431 | 455 | // guard satisfied, broken code reformatted. Rewriting a body we can't |
| @@ -515,15 +539,34 @@ | ||
| 515 | 539 | return true; |
| 516 | 540 | } |
| 517 | 541 | |
| 518 | 542 | 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; | |
| 543 | + unset( $source ); | |
| 544 | + | |
| 545 | + // Judge the OUTPUT, not the difference between input and output. | |
| 546 | + // | |
| 547 | + // This used to compare token counts across the pair, on the stated | |
| 548 | + // assumption that "literal braces inside strings survive minification | |
| 549 | + // unchanged, so they cancel out". Comments do not: stripping them is | |
| 550 | + // the minifier's whole job, and every brace, bracket and backtick | |
| 551 | + // inside one disappears with it. So any file whose comments contain a | |
| 552 | + // delimiter — a commented-out block, a URL in a docblock, an SVG in a | |
| 553 | + // note — failed the check and silently shipped unminified. | |
| 554 | + // | |
| 555 | + // It is not a rare shape. EmbedPress's front.js counts 372 braces | |
| 556 | + // against 368, 61 brackets against 58 and 110 backticks against 102 | |
| 557 | + // purely from comment removal, so 67 KB shipped raw where 46 KB was | |
| 558 | + // correct — and `node --check` confirms that rejected output parses | |
| 559 | + // fine. A guard that refuses valid work is not conservative, it is | |
| 560 | + // broken: it costs bytes on every request and reports nothing. | |
| 561 | + // | |
| 562 | + // What the guard is FOR still stands (#2): matthiasmullie/minify can | |
| 563 | + // truncate inside a template literal on complex modern JS and return a | |
| 564 | + // body that looks minified but is structurally broken. That failure is | |
| 565 | + // visible in the output alone — an unterminated literal leaves an odd | |
| 566 | + // backtick count and unmatched braces — which is exactly what | |
| 567 | + // self_consistent() measures, without the false positives. | |
| 568 | + return self::self_consistent( $minified ); | |
| 526 | 569 | } |
| 527 | 570 | |
| 528 | 571 | /** |
| 529 | 572 | * Resolve a local asset URL to a filesystem path using a strict allowlist |
| @@ -567,19 +610,40 @@ | ||
| 567 | 610 | array( content_url(), WP_CONTENT_DIR ), |
| 568 | 611 | array( includes_url(), ABSPATH . WPINC ), |
| 569 | 612 | ); |
| 570 | 613 | |
| 614 | + // The host check above normalised the HOST but not the SCHEME, and the | |
| 615 | + // prefix match below is a plain string compare — so an https asset URL | |
| 616 | + // never matched an http base and the file silently shipped unminified. | |
| 617 | + // That is not a corner case: WP_CONTENT_URL is derived from a stored | |
| 618 | + // option, `plugins_url()` from another, and a site moved to https | |
| 619 | + // without rewriting every row (or one behind a TLS-terminating proxy | |
| 620 | + // where `is_ssl()` reads false) serves https pages off http-rooted | |
| 621 | + // bases all day. Comparing scheme-less is the whole fix; the host | |
| 622 | + // equality test already did the security work of refusing anything | |
| 623 | + // off-site, and this runs after it. | |
| 624 | + $strip_scheme = static function ( string $value ): string { | |
| 625 | + return (string) preg_replace( '#^https?://#i', '//', $value ); | |
| 626 | + }; | |
| 627 | + $clean_match = $strip_scheme( $clean ); | |
| 628 | + | |
| 571 | 629 | foreach ( $candidates as $pair ) { |
| 572 | 630 | list( $url_base, $path_base ) = $pair; |
| 573 | 631 | if ( ! $url_base || ! $path_base ) { |
| 574 | 632 | continue; |
| 575 | 633 | } |
| 576 | - $url_base = rtrim( $url_base, '/' ); | |
| 577 | - if ( 0 !== strpos( $clean, $url_base . '/' ) && $clean !== $url_base ) { | |
| 634 | + $url_base = rtrim( $url_base, '/' ); | |
| 635 | + $base_match = $strip_scheme( $url_base ); | |
| 636 | + if ( 0 !== strpos( $clean_match, $base_match . '/' ) && $clean_match !== $base_match ) { | |
| 578 | 637 | continue; |
| 579 | 638 | } |
| 580 | 639 | |
| 581 | - $relative = ltrim( substr( $clean, strlen( $url_base ) ), '/' ); | |
| 640 | + // Slice the scheme-less pair, not the original. `https://…` and | |
| 641 | + // `http://…` differ by one byte, so an offset taken from the base | |
| 642 | + // as written would cut one character short of (or past) the path | |
| 643 | + // when the two schemes disagree — which is the case this fix | |
| 644 | + // exists for. | |
| 645 | + $relative = ltrim( substr( $clean_match, strlen( $base_match ) ), '/' ); | |
| 582 | 646 | $candidate = trailingslashit( $path_base ) . $relative; |
| 583 | 647 | |
| 584 | 648 | $real_base = realpath( $path_base ); |
| 585 | 649 | $real = realpath( $candidate ); |
| @@ -607,10 +671,16 @@ | ||
| 607 | 671 | Cache::write_silence( $dir ); |
| 608 | 672 | } |
| 609 | 673 | } |
| 610 | 674 | |
| 675 | + /** | |
| 676 | + * Clear every minified / combined asset. | |
| 677 | + * | |
| 678 | + * @return int Files removed. Most callers are `add_action` callbacks and | |
| 679 | + * ignore it; `wp xspeed purge` reports it as a line item. | |
| 680 | + */ | |
| 611 | 681 | public static function purge_minified() { |
| 612 | - self::rmtree_files( self::min_dir() ); | |
| 682 | + return self::rmtree_files( self::min_dir() ); | |
| 613 | 683 | } |
| 614 | 684 | |
| 615 | 685 | /** |
| 616 | 686 | * Recursively delete every file under $dir (and the emptied |
| @@ -619,18 +689,21 @@ | ||
| 619 | 689 | * min/combined/ were never cleared — a purge left a stale |
| 620 | 690 | * combined-<hash>.css the regenerated page no longer referenced. |
| 621 | 691 | * (FBS-83114 / FBS-83116) |
| 622 | 692 | */ |
| 623 | - private static function rmtree_files( string $dir ): void { | |
| 693 | + private static function rmtree_files( string $dir ): int { | |
| 624 | 694 | if ( ! is_dir( $dir ) ) { |
| 625 | - return; | |
| 695 | + return 0; | |
| 626 | 696 | } |
| 697 | + $removed = 0; | |
| 627 | 698 | foreach ( (array) glob( $dir . '/*' ) as $path ) { |
| 628 | 699 | if ( is_dir( $path ) ) { |
| 629 | - self::rmtree_files( $path ); | |
| 700 | + $removed += self::rmtree_files( $path ); | |
| 630 | 701 | @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 | 702 | continue; |
| 632 | 703 | } |
| 633 | 704 | wp_delete_file( $path ); |
| 705 | + ++$removed; | |
| 634 | 706 | } |
| 707 | + return $removed; | |
| 635 | 708 | } |
| 636 | 709 | } |