| @@ -55,8 +55,17 @@ | ||
| 55 | 55 | if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { |
| 56 | 56 | return; |
| 57 | 57 | } |
| 58 | 58 | |
| 59 | + // A page-builder editing screen is a front-end URL, so none of the | |
| 60 | + // guards above catch it. Optimizing it breaks the editor outright -- | |
| 61 | + // Combine JS reorders the builder's own dependency graph and the | |
| 62 | + // toolbar never renders. There is no speed to win on a logged-in, | |
| 63 | + // uncacheable editing request anyway. (#281) | |
| 64 | + if ( Builder_Editor::is_active() ) { | |
| 65 | + return; | |
| 66 | + } | |
| 67 | + | |
| 59 | 68 | // Settings now live in the per-module option (xspeed_module_minify), |
| 60 | 69 | // owned by XSpeed\Modules\Minify\MinifyModule. We read through |
| 61 | 70 | // Settings_Manager so schema-validated values are returned even |
| 62 | 71 | // if the option was hand-edited. |
| @@ -66,8 +75,15 @@ | ||
| 66 | 75 | add_filter( 'style_loader_src', array( __CLASS__, 'rewrite_style' ), 10, 2 ); |
| 67 | 76 | } |
| 68 | 77 | if ( ! empty( $opts['minify_js'] ) ) { |
| 69 | 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 ); | |
| 70 | 86 | } |
| 71 | 87 | |
| 72 | 88 | // Phase 4.1a — filter-only "smarter minifier" features. Each is |
| 73 | 89 | // gated on its own toggle so users can enable any subset. |
| @@ -91,11 +107,22 @@ | ||
| 91 | 107 | // minify_html (same filter, default priority) and is baked into |
| 92 | 108 | // the cache file, so it replays on static hits where PHP never |
| 93 | 109 | // boots. |
| 94 | 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 ); | |
| 95 | 115 | } |
| 96 | 116 | if ( ! empty( $opts['async_css'] ) ) { |
| 97 | 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 ); | |
| 98 | 125 | } |
| 99 | 126 | |
| 100 | 127 | /* |
| 101 | 128 | * CSS combining runs on the FINISHED HTML, not the enqueue queue. |
| @@ -181,10 +208,18 @@ | ||
| 181 | 208 | $pattern = '#<(pre|textarea|script|style)\b[^>]*>.*?</\1>#is'; |
| 182 | 209 | $html = preg_replace_callback( |
| 183 | 210 | $pattern, |
| 184 | 211 | function ( $m ) use ( &$placeholders ) { |
| 185 | - $key = '__XSPEED_PH_' . count( $placeholders ) . '__'; | |
| 186 | - $placeholders[ $key ] = $m[0]; | |
| 212 | + $key = '__XSPEED_PH_' . count( $placeholders ) . '__'; | |
| 213 | + // The placeholder pass exists to protect content whose | |
| 214 | + // whitespace is significant (<pre>, <textarea>) and to keep | |
| 215 | + // the tag-boundary regex off script bodies. <style> and | |
| 216 | + // <script> were grouped in with them, so protection became a | |
| 217 | + // permanent exemption: on builder sites where most CSS is | |
| 218 | + // inline, a page with minify ON shipped fully indented. Minify | |
| 219 | + // the BODY here, before it's stashed, so the outer passes | |
| 220 | + // still never see it. (#2) | |
| 221 | + $placeholders[ $key ] = self::minify_inline_block( $m[0], strtolower( $m[1] ) ); | |
| 187 | 222 | return $key; |
| 188 | 223 | }, |
| 189 | 224 | $html |
| 190 | 225 | ); |
| @@ -263,10 +298,12 @@ | ||
| 263 | 298 | if ( false !== strpos( $src, '.min.' ) ) { |
| 264 | 299 | return $src; |
| 265 | 300 | } |
| 266 | 301 | |
| 267 | - // Skip anything we already produced. The Asset_Combiner writes a | |
| 268 | - // pre-minified combined-<hash>.css under min/combined/ and enqueues it | |
| 302 | + // Skip anything we already produced. The Asset_Combiner minifies the | |
| 303 | + // combined body itself before writing combined-<hash>.css under | |
| 304 | + // min/combined/ (issue #331 — that used to be asserted here but was | |
| 305 | + // not actually true, so the artifact shipped unminified), and enqueues it | |
| 269 | 306 | // as `xspeed-combined-css`; the per-file minifier used to re-minify |
| 270 | 307 | // that combined output into a SECOND file (min/<hash2>.css) with its |
| 271 | 308 | // own mtime-derived hash. The served HTML then pinned that second |
| 272 | 309 | // hash, so a purge/regeneration (which changes the combined file's |
| @@ -363,18 +400,175 @@ | ||
| 363 | 400 | * literal `{` / `}` / `[` / `]` that throw off the count by the same |
| 364 | 401 | * amount in both bodies (since they survive minification as-is), so |
| 365 | 402 | * the equality check is robust to that noise. |
| 366 | 403 | */ |
| 367 | - private static function balanced( string $source, string $minified ): bool { | |
| 368 | - $pairs = array( '(', ')', '{', '}', '[', ']', '`' ); | |
| 369 | - foreach ( $pairs as $token ) { | |
| 370 | - if ( substr_count( $source, $token ) !== substr_count( $minified, $token ) ) { | |
| 404 | + /** | |
| 405 | + * Minify the body of one captured inline block, or return it untouched. | |
| 406 | + * | |
| 407 | + * Only `<style>` and JavaScript `<script>` bodies are eligible: | |
| 408 | + * | |
| 409 | + * - `<pre>` / `<textarea>` — whitespace is rendered, never touch it. | |
| 410 | + * - `<script>` with a non-JS `type` — `application/ld+json`, | |
| 411 | + * `text/template`, `text/x-handlebars` and anything unrecognised are | |
| 412 | + * data or markup, not code. Minifying JSON-LD would corrupt structured | |
| 413 | + * data; minifying a template would eat the markup it holds. An unknown | |
| 414 | + * type is treated as non-JS on purpose: guessing wrong breaks the page, | |
| 415 | + * while skipping only forgoes a few bytes. | |
| 416 | + * - `<script src="...">` — the body is empty; the file path already goes | |
| 417 | + * through minify_file(). | |
| 418 | + * | |
| 419 | + * Every result is checked with balanced(), the same structural guard the | |
| 420 | + * file path uses, so a body the library truncates is shipped as-is rather | |
| 421 | + * than broken. (#2) | |
| 422 | + * | |
| 423 | + * @param string $block Full matched tag, opening tag through closing tag. | |
| 424 | + * @param string $tag Lowercased tag name. | |
| 425 | + * @return string Minified block, or $block unchanged. | |
| 426 | + */ | |
| 427 | + private static function minify_inline_block( string $block, string $tag ): string { | |
| 428 | + if ( 'style' !== $tag && 'script' !== $tag ) { | |
| 429 | + return $block; // pre / textarea — significant whitespace. | |
| 430 | + } | |
| 431 | + if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) { | |
| 432 | + return $block; | |
| 433 | + } | |
| 434 | + | |
| 435 | + // Split into opening tag / body / closing tag. Anything that doesn't | |
| 436 | + // match this shape isn't something we should be rewriting. | |
| 437 | + if ( ! preg_match( '#^(<' . $tag . '\b[^>]*>)(.*)(</' . $tag . '\s*>)$#is', $block, $parts ) ) { | |
| 438 | + return $block; | |
| 439 | + } | |
| 440 | + list( , $open, $body, $close ) = $parts; | |
| 441 | + | |
| 442 | + if ( '' === trim( $body ) ) { | |
| 443 | + return $block; | |
| 444 | + } | |
| 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 | + | |
| 452 | + // Refuse a body that is already structurally broken. balanced() only | |
| 453 | + // compares source against minified, so it passes when BOTH are equally | |
| 454 | + // unbalanced — `function x( {` minifies to `function x({`, same counts, | |
| 455 | + // guard satisfied, broken code reformatted. Rewriting a body we can't | |
| 456 | + // parse risks turning a page that happens to work into one that does | |
| 457 | + // not, for no gain. (#2 AC: a syntactically broken block is left | |
| 458 | + // untouched.) | |
| 459 | + if ( ! self::self_consistent( $body ) ) { | |
| 460 | + return $block; | |
| 461 | + } | |
| 462 | + | |
| 463 | + if ( 'script' === $tag ) { | |
| 464 | + // An external script has no body worth minifying. | |
| 465 | + if ( preg_match( '#\bsrc\s*=#i', $open ) ) { | |
| 466 | + return $block; | |
| 467 | + } | |
| 468 | + // No type, or an explicitly JavaScript type, is code. Everything | |
| 469 | + // else is data/markup — see the docblock. | |
| 470 | + $js_types = array( | |
| 471 | + 'text/javascript', | |
| 472 | + 'application/javascript', | |
| 473 | + 'application/ecmascript', | |
| 474 | + 'text/ecmascript', | |
| 475 | + 'module', | |
| 476 | + ); | |
| 477 | + if ( preg_match( '#\btype\s*=\s*["\']?([^"\'\s>]+)#i', $open, $type_match ) ) { | |
| 478 | + if ( ! in_array( strtolower( trim( $type_match[1] ) ), $js_types, true ) ) { | |
| 479 | + return $block; | |
| 480 | + } | |
| 481 | + } | |
| 482 | + } | |
| 483 | + | |
| 484 | + try { | |
| 485 | + $minifier = 'style' === $tag | |
| 486 | + ? new \MatthiasMullie\Minify\CSS() | |
| 487 | + : new \MatthiasMullie\Minify\JS(); | |
| 488 | + $minifier->add( $body ); | |
| 489 | + $minified = $minifier->minify(); | |
| 490 | + } catch ( \Throwable $e ) { | |
| 491 | + return $block; | |
| 492 | + } | |
| 493 | + | |
| 494 | + // A minifier that returns nothing for a non-empty body has failed, not | |
| 495 | + // succeeded — shipping '' would silently delete the rule set. | |
| 496 | + if ( ! is_string( $minified ) || '' === trim( $minified ) ) { | |
| 497 | + return $block; | |
| 498 | + } | |
| 499 | + if ( ! self::balanced( $body, $minified ) ) { | |
| 500 | + return $block; | |
| 501 | + } | |
| 502 | + | |
| 503 | + return $open . $minified . $close; | |
| 504 | + } | |
| 505 | + | |
| 506 | + /** | |
| 507 | + * Does a body's own paired delimiters balance? | |
| 508 | + * | |
| 509 | + * balanced() is a RELATIVE check — source against minified — so it cannot | |
| 510 | + * see input that was already broken: an unbalanced body minifies to an | |
| 511 | + * equally unbalanced one and the counts still agree. This is the absolute | |
| 512 | + * check, applied to the source alone before we touch it. | |
| 513 | + * | |
| 514 | + * Deliberately naive: it counts tokens without parsing, so a brace inside | |
| 515 | + * a string or comment skews it. That only ever makes it MORE conservative — | |
| 516 | + * a false negative skips minification, which costs bytes, while a false | |
| 517 | + * positive would ship broken code. (#2) | |
| 518 | + * | |
| 519 | + * @param string $body Inline block body. | |
| 520 | + */ | |
| 521 | + private static function self_consistent( string $body ): bool { | |
| 522 | + $pairs = array( | |
| 523 | + '{' => '}', | |
| 524 | + '(' => ')', | |
| 525 | + '[' => ']', | |
| 526 | + ); | |
| 527 | + foreach ( $pairs as $open => $close ) { | |
| 528 | + if ( substr_count( $body, $open ) !== substr_count( $body, $close ) ) { | |
| 371 | 529 | return false; |
| 372 | 530 | } |
| 373 | 531 | } |
| 532 | + // Backticks and quotes pair with themselves, so an odd count means an | |
| 533 | + // unterminated literal. | |
| 534 | + foreach ( array( '`' ) as $token ) { | |
| 535 | + if ( 0 !== substr_count( $body, $token ) % 2 ) { | |
| 536 | + return false; | |
| 537 | + } | |
| 538 | + } | |
| 374 | 539 | return true; |
| 375 | 540 | } |
| 376 | 541 | |
| 542 | + private static function balanced( string $source, string $minified ): bool { | |
| 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 ); | |
| 569 | + } | |
| 570 | + | |
| 377 | 571 | /** |
| 378 | 572 | * Resolve a local asset URL to a filesystem path using a strict allowlist |
| 379 | 573 | * of "URL prefix → filesystem prefix" pairs registered with WordPress. |
| 380 | 574 | * |
| @@ -416,19 +610,40 @@ | ||
| 416 | 610 | array( content_url(), WP_CONTENT_DIR ), |
| 417 | 611 | array( includes_url(), ABSPATH . WPINC ), |
| 418 | 612 | ); |
| 419 | 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 | + | |
| 420 | 629 | foreach ( $candidates as $pair ) { |
| 421 | 630 | list( $url_base, $path_base ) = $pair; |
| 422 | 631 | if ( ! $url_base || ! $path_base ) { |
| 423 | 632 | continue; |
| 424 | 633 | } |
| 425 | - $url_base = rtrim( $url_base, '/' ); | |
| 426 | - 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 ) { | |
| 427 | 637 | continue; |
| 428 | 638 | } |
| 429 | 639 | |
| 430 | - $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 ) ), '/' ); | |
| 431 | 646 | $candidate = trailingslashit( $path_base ) . $relative; |
| 432 | 647 | |
| 433 | 648 | $real_base = realpath( $path_base ); |
| 434 | 649 | $real = realpath( $candidate ); |
| @@ -456,10 +671,16 @@ | ||
| 456 | 671 | Cache::write_silence( $dir ); |
| 457 | 672 | } |
| 458 | 673 | } |
| 459 | 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 | + */ | |
| 460 | 681 | public static function purge_minified() { |
| 461 | - self::rmtree_files( self::min_dir() ); | |
| 682 | + return self::rmtree_files( self::min_dir() ); | |
| 462 | 683 | } |
| 463 | 684 | |
| 464 | 685 | /** |
| 465 | 686 | * Recursively delete every file under $dir (and the emptied |
| @@ -468,18 +689,21 @@ | ||
| 468 | 689 | * min/combined/ were never cleared — a purge left a stale |
| 469 | 690 | * combined-<hash>.css the regenerated page no longer referenced. |
| 470 | 691 | * (FBS-83114 / FBS-83116) |
| 471 | 692 | */ |
| 472 | - private static function rmtree_files( string $dir ): void { | |
| 693 | + private static function rmtree_files( string $dir ): int { | |
| 473 | 694 | if ( ! is_dir( $dir ) ) { |
| 474 | - return; | |
| 695 | + return 0; | |
| 475 | 696 | } |
| 697 | + $removed = 0; | |
| 476 | 698 | foreach ( (array) glob( $dir . '/*' ) as $path ) { |
| 477 | 699 | if ( is_dir( $path ) ) { |
| 478 | - self::rmtree_files( $path ); | |
| 700 | + $removed += self::rmtree_files( $path ); | |
| 479 | 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. |
| 480 | 702 | continue; |
| 481 | 703 | } |
| 482 | 704 | wp_delete_file( $path ); |
| 705 | + ++$removed; | |
| 483 | 706 | } |
| 707 | + return $removed; | |
| 484 | 708 | } |
| 485 | 709 | } |