| @@ -37,9 +37,16 @@ | ||
| 37 | 37 | // XSPEED_CACHE_DIR lives under wp-content (defined in xspeed.php as |
| 38 | 38 | // WP_CONTENT_DIR . '/cache/xspeed'), so the URL is content_url() + |
| 39 | 39 | // the known suffix. We do not derive URLs from arbitrary filesystem |
| 40 | 40 | // paths anywhere in this plugin. |
| 41 | - return trailingslashit( content_url( 'cache/xspeed' ) ) . self::MIN_SUBDIR; | |
| 41 | + $url = trailingslashit( content_url( 'cache/xspeed' ) ) . self::MIN_SUBDIR; | |
| 42 | + // Force the site's scheme: content_url() derives its scheme from | |
| 43 | + // is_ssl(), which is false behind a TLS-terminating reverse proxy, so | |
| 44 | + // it can emit an http:// URL on an https page — the browser then blocks | |
| 45 | + // the minified stylesheet as mixed content and the page renders | |
| 46 | + // unstyled. Match home_url()'s registered scheme instead. (FBS-83633) | |
| 47 | + $scheme = wp_parse_url( home_url(), PHP_URL_SCHEME ) ?: 'https'; | |
| 48 | + return set_url_scheme( $url, $scheme ); | |
| 42 | 49 | } |
| 43 | 50 | |
| 44 | 51 | public function __construct() { |
| 45 | 52 | // Only run on the frontend — never minify wp-admin, AJAX, REST or cron |
| @@ -48,8 +55,17 @@ | ||
| 48 | 55 | if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { |
| 49 | 56 | return; |
| 50 | 57 | } |
| 51 | 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 | + | |
| 52 | 68 | // Settings now live in the per-module option (xspeed_module_minify), |
| 53 | 69 | // owned by XSpeed\Modules\Minify\MinifyModule. We read through |
| 54 | 70 | // Settings_Manager so schema-validated values are returned even |
| 55 | 71 | // if the option was hand-edited. |
| @@ -59,8 +75,15 @@ | ||
| 59 | 75 | add_filter( 'style_loader_src', array( __CLASS__, 'rewrite_style' ), 10, 2 ); |
| 60 | 76 | } |
| 61 | 77 | if ( ! empty( $opts['minify_js'] ) ) { |
| 62 | 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 ); | |
| 63 | 86 | } |
| 64 | 87 | |
| 65 | 88 | // Phase 4.1a — filter-only "smarter minifier" features. Each is |
| 66 | 89 | // gated on its own toggle so users can enable any subset. |
| @@ -76,27 +99,109 @@ | ||
| 76 | 99 | // plain defer — when both are on, delay wins (the bootstrap |
| 77 | 100 | // will re-attach as a regular <script> on interaction). |
| 78 | 101 | add_filter( 'script_loader_tag', array( Minify_Filters::class, 'delay_script_tag' ), 30, 3 ); |
| 79 | 102 | add_action( 'wp_footer', array( Minify_Filters::class, 'print_delay_bootstrap' ), 1000 ); |
| 103 | + // script_loader_tag only fires for wp_enqueue_script()'d assets. | |
| 104 | + // Analytics / pixel / chat-widget tags printed straight into | |
| 105 | + // wp_head bypass it, and those are usually the heaviest scripts | |
| 106 | + // on the page — so sweep the finished buffer too. Runs before | |
| 107 | + // minify_html (same filter, default priority) and is baked into | |
| 108 | + // the cache file, so it replays on static hits where PHP never | |
| 109 | + // boots. | |
| 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 ); | |
| 80 | 115 | } |
| 81 | 116 | if ( ! empty( $opts['async_css'] ) ) { |
| 82 | 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 ); | |
| 83 | 125 | } |
| 84 | 126 | |
| 85 | - // Phase 4.1b — combine engine. Hook late so every plugin / | |
| 86 | - // theme has finished enqueueing by the time we walk the queue. | |
| 87 | - // Priority 999 mirrors the WP-Optimize / Rocket convention. | |
| 127 | + /* | |
| 128 | + * CSS combining runs on the FINISHED HTML, not the enqueue queue. | |
| 129 | + * | |
| 130 | + * The queue-walking version could not be made correct: whatever it | |
| 131 | + * wrote at priority 999, WordPress edited afterwards. Core's | |
| 132 | + * wp_maybe_inline_styles() inlines any queued handle carrying a `path` | |
| 133 | + * and sets src=false on it, which silently threw away the combined URL | |
| 134 | + * and took the sheets we had blanked with it — six stylesheets became | |
| 135 | + * one and the site rendered unstyled. See Css_Combine_Buffer's header | |
| 136 | + * for the full trace. (#195) | |
| 137 | + * | |
| 138 | + * Two entry points, because the page cache's filter is not always | |
| 139 | + * available: `xspeed_cache_final_html` fires only on a cacheable MISS, | |
| 140 | + * so on a site with the cache off — or on an excluded URL like /cart — | |
| 141 | + * combining would silently stop working. Css_Combine_Buffer::boot() | |
| 142 | + * opens its own buffer in exactly those cases and no-ops otherwise, so | |
| 143 | + * the page is transformed once either way. | |
| 144 | + */ | |
| 88 | 145 | if ( ! empty( $opts['combine_css'] ) ) { |
| 89 | - add_action( 'wp_enqueue_scripts', array( Asset_Combiner::class, 'combine_styles' ), 999 ); | |
| 146 | + add_filter( 'xspeed_cache_final_html', array( Css_Combine_Buffer::class, 'process' ), 5 ); | |
| 147 | + Css_Combine_Buffer::boot(); | |
| 90 | 148 | } |
| 91 | 149 | if ( ! empty( $opts['combine_js'] ) ) { |
| 150 | + // JS stays on the enqueue path for now: dependency order, | |
| 151 | + // async/defer and wp_add_inline_script make it a different | |
| 152 | + // problem, and the reported break is CSS-only. Moving it is worth | |
| 153 | + // its own change rather than doubling the blast radius here. | |
| 92 | 154 | add_action( 'wp_enqueue_scripts', array( Asset_Combiner::class, 'combine_scripts' ), 999 ); |
| 93 | 155 | } |
| 94 | 156 | } |
| 95 | 157 | |
| 158 | + /** | |
| 159 | + * HTML elements that participate in an inline formatting context, where | |
| 160 | + * whitespace between two of them renders as a visible space. | |
| 161 | + * | |
| 162 | + * Deliberately excludes <br> (nothing to separate) and replaced/embedded | |
| 163 | + * inline elements that sit alone. Anything not listed is treated as block | |
| 164 | + * level, where inter-tag whitespace collapses to nothing and is safe to | |
| 165 | + * strip. (FBS-84090) | |
| 166 | + */ | |
| 167 | + private const INLINE_TAGS = array( | |
| 168 | + 'a', 'abbr', 'b', 'bdi', 'bdo', 'cite', 'code', 'data', 'del', 'dfn', | |
| 169 | + 'em', 'i', 'ins', 'kbd', 'label', 'mark', 'q', 'rp', 'rt', 'ruby', | |
| 170 | + 's', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'time', 'u', | |
| 171 | + 'var', 'wbr', 'img', 'button', 'select', 'output', | |
| 172 | + ); | |
| 173 | + | |
| 174 | + /** True when $tag renders inline, so whitespace beside it is visible. */ | |
| 175 | + private static function is_inline( string $tag ): bool { | |
| 176 | + return in_array( strtolower( $tag ), self::INLINE_TAGS, true ); | |
| 177 | + } | |
| 178 | + | |
| 179 | + /** | |
| 180 | + * Why minification is being skipped, when it is. '' when it will run. | |
| 181 | + * | |
| 182 | + * minify_html can read "on" in every settings surface while producing | |
| 183 | + * byte-identical HTML, because the guard below silently returns the | |
| 184 | + * input. Field report: a live site showed `minify_html: on` with 3,856 | |
| 185 | + * indented lines in the delivered HTML and nothing anywhere explaining | |
| 186 | + * the contradiction — the setting looked broken rather than suppressed. | |
| 187 | + * Callers that report status MUST consult this so the refusal is | |
| 188 | + * visible. (Same class as Cache::static_rewrite_block_reason().) | |
| 189 | + * | |
| 190 | + * @return string 'wp_debug', 'filter', or ''. | |
| 191 | + */ | |
| 192 | + public static function skip_reason(): string { | |
| 193 | + $debug_skip = defined( 'WP_DEBUG' ) && WP_DEBUG; | |
| 194 | + if ( ! apply_filters( 'xspeed_skip_minify', $debug_skip ) ) { | |
| 195 | + return ''; | |
| 196 | + } | |
| 197 | + // Distinguish the built-in WP_DEBUG rule from a third party | |
| 198 | + // filtering the escape hatch — the fixes are different. | |
| 199 | + return $debug_skip ? 'wp_debug' : 'filter'; | |
| 200 | + } | |
| 201 | + | |
| 96 | 202 | public static function minify_html( $html ) { |
| 97 | - $debug_skip = defined( 'WP_DEBUG' ) && WP_DEBUG; | |
| 98 | - if ( apply_filters( 'xspeed_skip_minify', $debug_skip ) ) { | |
| 203 | + if ( '' !== self::skip_reason() ) { | |
| 99 | 204 | return $html; |
| 100 | 205 | } |
| 101 | 206 | |
| 102 | 207 | $placeholders = array(); |
| @@ -103,10 +208,18 @@ | ||
| 103 | 208 | $pattern = '#<(pre|textarea|script|style)\b[^>]*>.*?</\1>#is'; |
| 104 | 209 | $html = preg_replace_callback( |
| 105 | 210 | $pattern, |
| 106 | 211 | function ( $m ) use ( &$placeholders ) { |
| 107 | - $key = '__XSPEED_PH_' . count( $placeholders ) . '__'; | |
| 108 | - $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] ) ); | |
| 109 | 222 | return $key; |
| 110 | 223 | }, |
| 111 | 224 | $html |
| 112 | 225 | ); |
| @@ -112,9 +225,34 @@ | ||
| 112 | 225 | ); |
| 113 | 226 | |
| 114 | 227 | $html = preg_replace( '/<!--(?!\[if).*?-->/s', '', $html ); |
| 115 | 228 | $html = preg_replace( '/\s+/', ' ', $html ); |
| 116 | - $html = preg_replace( '/>\s+</', '><', $html ); | |
| 229 | + | |
| 230 | + /* | |
| 231 | + * Collapse whitespace BETWEEN TAGS — but never where it is visible. | |
| 232 | + * | |
| 233 | + * Whitespace separating two INLINE elements is a real, rendered space: | |
| 234 | + * WooCommerce emits `</del> <ins>` for a sale price, and that single | |
| 235 | + * character is the gap between "$32.50" and "$29.50". Stripping it | |
| 236 | + * printed "$32.50$29.50" run together, and only with cache on — the | |
| 237 | + * un-minified page was fine. (FBS-84090) | |
| 238 | + * | |
| 239 | + * So the strip only applies when at least one side is a BLOCK-level | |
| 240 | + * (or non-rendered) tag, where the whitespace collapses away anyway. | |
| 241 | + * Inline-to-inline boundaries keep their single space. | |
| 242 | + */ | |
| 243 | + $html = preg_replace_callback( | |
| 244 | + // left tag name (may be a closing tag) … whitespace … right tag name | |
| 245 | + '#</?([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>\s+<(/?)([a-zA-Z][a-zA-Z0-9-]*)#', | |
| 246 | + static function ( $m ) { | |
| 247 | + // Keep the space only when BOTH sides are inline elements — | |
| 248 | + // that is the one case where it is actually rendered. | |
| 249 | + $keep = self::is_inline( $m[1] ) && self::is_inline( $m[3] ); | |
| 250 | + $open = substr( $m[0], 0, strrpos( $m[0], '<' ) ); // through the left tag's '>' | |
| 251 | + return rtrim( $open ) . ( $keep ? ' ' : '' ) . '<' . $m[2] . $m[3]; | |
| 252 | + }, | |
| 253 | + $html | |
| 254 | + ); | |
| 117 | 255 | $html = trim( $html ); |
| 118 | 256 | |
| 119 | 257 | foreach ( $placeholders as $key => $original ) { |
| 120 | 258 | $html = str_replace( $key, $original, $html ); |
| @@ -128,10 +266,21 @@ | ||
| 128 | 266 | return self::rewrite_asset( $src, 'css' ); |
| 129 | 267 | } |
| 130 | 268 | |
| 131 | 269 | public static function rewrite_script( $src, $handle ) { |
| 132 | - unset( $handle ); | |
| 133 | - return self::rewrite_asset( $src, 'js' ); | |
| 270 | + $rewritten = self::rewrite_asset( $src, 'js' ); | |
| 271 | + | |
| 272 | + // Remember the pre-minify URL for this handle. script_loader_tag | |
| 273 | + // runs later and only ever sees the rewritten src (a hashed | |
| 274 | + // /cache/xspeed/min/<key>.js path), so a user's URL-substring | |
| 275 | + // delay/exclusion target would never match once minification is | |
| 276 | + // on. Minify_Filters::original_src() gives those checks the URL | |
| 277 | + // the user actually wrote their target against. (FBS field report) | |
| 278 | + if ( is_string( $handle ) && '' !== $handle && is_string( $src ) && $src !== $rewritten ) { | |
| 279 | + Minify_Filters::remember_original_src( $handle, $src ); | |
| 280 | + } | |
| 281 | + | |
| 282 | + return $rewritten; | |
| 134 | 283 | } |
| 135 | 284 | |
| 136 | 285 | /** |
| 137 | 286 | * Replace a local CSS/JS URL with a cached, minified equivalent. |
| @@ -149,10 +298,12 @@ | ||
| 149 | 298 | if ( false !== strpos( $src, '.min.' ) ) { |
| 150 | 299 | return $src; |
| 151 | 300 | } |
| 152 | 301 | |
| 153 | - // Skip anything we already produced. The Asset_Combiner writes a | |
| 154 | - // 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 | |
| 155 | 306 | // as `xspeed-combined-css`; the per-file minifier used to re-minify |
| 156 | 307 | // that combined output into a SECOND file (min/<hash2>.css) with its |
| 157 | 308 | // own mtime-derived hash. The served HTML then pinned that second |
| 158 | 309 | // hash, so a purge/regeneration (which changes the combined file's |
| @@ -249,18 +400,175 @@ | ||
| 249 | 400 | * literal `{` / `}` / `[` / `]` that throw off the count by the same |
| 250 | 401 | * amount in both bodies (since they survive minification as-is), so |
| 251 | 402 | * the equality check is robust to that noise. |
| 252 | 403 | */ |
| 253 | - private static function balanced( string $source, string $minified ): bool { | |
| 254 | - $pairs = array( '(', ')', '{', '}', '[', ']', '`' ); | |
| 255 | - foreach ( $pairs as $token ) { | |
| 256 | - 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 ) ) { | |
| 257 | 529 | return false; |
| 258 | 530 | } |
| 259 | 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 | + } | |
| 260 | 539 | return true; |
| 261 | 540 | } |
| 262 | 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 | + | |
| 263 | 571 | /** |
| 264 | 572 | * Resolve a local asset URL to a filesystem path using a strict allowlist |
| 265 | 573 | * of "URL prefix → filesystem prefix" pairs registered with WordPress. |
| 266 | 574 | * |
| @@ -302,19 +610,40 @@ | ||
| 302 | 610 | array( content_url(), WP_CONTENT_DIR ), |
| 303 | 611 | array( includes_url(), ABSPATH . WPINC ), |
| 304 | 612 | ); |
| 305 | 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 | + | |
| 306 | 629 | foreach ( $candidates as $pair ) { |
| 307 | 630 | list( $url_base, $path_base ) = $pair; |
| 308 | 631 | if ( ! $url_base || ! $path_base ) { |
| 309 | 632 | continue; |
| 310 | 633 | } |
| 311 | - $url_base = rtrim( $url_base, '/' ); | |
| 312 | - 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 ) { | |
| 313 | 637 | continue; |
| 314 | 638 | } |
| 315 | 639 | |
| 316 | - $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 ) ), '/' ); | |
| 317 | 646 | $candidate = trailingslashit( $path_base ) . $relative; |
| 318 | 647 | |
| 319 | 648 | $real_base = realpath( $path_base ); |
| 320 | 649 | $real = realpath( $candidate ); |
| @@ -342,10 +671,16 @@ | ||
| 342 | 671 | Cache::write_silence( $dir ); |
| 343 | 672 | } |
| 344 | 673 | } |
| 345 | 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 | + */ | |
| 346 | 681 | public static function purge_minified() { |
| 347 | - self::rmtree_files( self::min_dir() ); | |
| 682 | + return self::rmtree_files( self::min_dir() ); | |
| 348 | 683 | } |
| 349 | 684 | |
| 350 | 685 | /** |
| 351 | 686 | * Recursively delete every file under $dir (and the emptied |
| @@ -354,18 +689,21 @@ | ||
| 354 | 689 | * min/combined/ were never cleared — a purge left a stale |
| 355 | 690 | * combined-<hash>.css the regenerated page no longer referenced. |
| 356 | 691 | * (FBS-83114 / FBS-83116) |
| 357 | 692 | */ |
| 358 | - private static function rmtree_files( string $dir ): void { | |
| 693 | + private static function rmtree_files( string $dir ): int { | |
| 359 | 694 | if ( ! is_dir( $dir ) ) { |
| 360 | - return; | |
| 695 | + return 0; | |
| 361 | 696 | } |
| 697 | + $removed = 0; | |
| 362 | 698 | foreach ( (array) glob( $dir . '/*' ) as $path ) { |
| 363 | 699 | if ( is_dir( $path ) ) { |
| 364 | - self::rmtree_files( $path ); | |
| 700 | + $removed += self::rmtree_files( $path ); | |
| 365 | 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. |
| 366 | 702 | continue; |
| 367 | 703 | } |
| 368 | 704 | wp_delete_file( $path ); |
| 705 | + ++$removed; | |
| 369 | 706 | } |
| 707 | + return $removed; | |
| 370 | 708 | } |
| 371 | 709 | } |