| @@ -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. |
| @@ -83,20 +92,59 @@ | ||
| 83 | 92 | // plain defer — when both are on, delay wins (the bootstrap |
| 84 | 93 | // will re-attach as a regular <script> on interaction). |
| 85 | 94 | add_filter( 'script_loader_tag', array( Minify_Filters::class, 'delay_script_tag' ), 30, 3 ); |
| 86 | 95 | add_action( 'wp_footer', array( Minify_Filters::class, 'print_delay_bootstrap' ), 1000 ); |
| 96 | + // script_loader_tag only fires for wp_enqueue_script()'d assets. | |
| 97 | + // Analytics / pixel / chat-widget tags printed straight into | |
| 98 | + // wp_head bypass it, and those are usually the heaviest scripts | |
| 99 | + // on the page — so sweep the finished buffer too. Runs before | |
| 100 | + // minify_html (same filter, default priority) and is baked into | |
| 101 | + // the cache file, so it replays on static hits where PHP never | |
| 102 | + // boots. | |
| 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 ); | |
| 87 | 108 | } |
| 88 | 109 | if ( ! empty( $opts['async_css'] ) ) { |
| 89 | 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 ); | |
| 90 | 118 | } |
| 91 | 119 | |
| 92 | - // Phase 4.1b — combine engine. Hook late so every plugin / | |
| 93 | - // theme has finished enqueueing by the time we walk the queue. | |
| 94 | - // Priority 999 mirrors the WP-Optimize / Rocket convention. | |
| 120 | + /* | |
| 121 | + * CSS combining runs on the FINISHED HTML, not the enqueue queue. | |
| 122 | + * | |
| 123 | + * The queue-walking version could not be made correct: whatever it | |
| 124 | + * wrote at priority 999, WordPress edited afterwards. Core's | |
| 125 | + * wp_maybe_inline_styles() inlines any queued handle carrying a `path` | |
| 126 | + * and sets src=false on it, which silently threw away the combined URL | |
| 127 | + * and took the sheets we had blanked with it — six stylesheets became | |
| 128 | + * one and the site rendered unstyled. See Css_Combine_Buffer's header | |
| 129 | + * for the full trace. (#195) | |
| 130 | + * | |
| 131 | + * Two entry points, because the page cache's filter is not always | |
| 132 | + * available: `xspeed_cache_final_html` fires only on a cacheable MISS, | |
| 133 | + * so on a site with the cache off — or on an excluded URL like /cart — | |
| 134 | + * combining would silently stop working. Css_Combine_Buffer::boot() | |
| 135 | + * opens its own buffer in exactly those cases and no-ops otherwise, so | |
| 136 | + * the page is transformed once either way. | |
| 137 | + */ | |
| 95 | 138 | if ( ! empty( $opts['combine_css'] ) ) { |
| 96 | - add_action( 'wp_enqueue_scripts', array( Asset_Combiner::class, 'combine_styles' ), 999 ); | |
| 139 | + add_filter( 'xspeed_cache_final_html', array( Css_Combine_Buffer::class, 'process' ), 5 ); | |
| 140 | + Css_Combine_Buffer::boot(); | |
| 97 | 141 | } |
| 98 | 142 | if ( ! empty( $opts['combine_js'] ) ) { |
| 143 | + // JS stays on the enqueue path for now: dependency order, | |
| 144 | + // async/defer and wp_add_inline_script make it a different | |
| 145 | + // problem, and the reported break is CSS-only. Moving it is worth | |
| 146 | + // its own change rather than doubling the blast radius here. | |
| 99 | 147 | add_action( 'wp_enqueue_scripts', array( Asset_Combiner::class, 'combine_scripts' ), 999 ); |
| 100 | 148 | } |
| 101 | 149 | } |
| 102 | 150 | |
| @@ -120,11 +168,33 @@ | ||
| 120 | 168 | private static function is_inline( string $tag ): bool { |
| 121 | 169 | return in_array( strtolower( $tag ), self::INLINE_TAGS, true ); |
| 122 | 170 | } |
| 123 | 171 | |
| 172 | + /** | |
| 173 | + * Why minification is being skipped, when it is. '' when it will run. | |
| 174 | + * | |
| 175 | + * minify_html can read "on" in every settings surface while producing | |
| 176 | + * byte-identical HTML, because the guard below silently returns the | |
| 177 | + * input. Field report: a live site showed `minify_html: on` with 3,856 | |
| 178 | + * indented lines in the delivered HTML and nothing anywhere explaining | |
| 179 | + * the contradiction — the setting looked broken rather than suppressed. | |
| 180 | + * Callers that report status MUST consult this so the refusal is | |
| 181 | + * visible. (Same class as Cache::static_rewrite_block_reason().) | |
| 182 | + * | |
| 183 | + * @return string 'wp_debug', 'filter', or ''. | |
| 184 | + */ | |
| 185 | + public static function skip_reason(): string { | |
| 186 | + $debug_skip = defined( 'WP_DEBUG' ) && WP_DEBUG; | |
| 187 | + if ( ! apply_filters( 'xspeed_skip_minify', $debug_skip ) ) { | |
| 188 | + return ''; | |
| 189 | + } | |
| 190 | + // Distinguish the built-in WP_DEBUG rule from a third party | |
| 191 | + // filtering the escape hatch — the fixes are different. | |
| 192 | + return $debug_skip ? 'wp_debug' : 'filter'; | |
| 193 | + } | |
| 194 | + | |
| 124 | 195 | public static function minify_html( $html ) { |
| 125 | - $debug_skip = defined( 'WP_DEBUG' ) && WP_DEBUG; | |
| 126 | - if ( apply_filters( 'xspeed_skip_minify', $debug_skip ) ) { | |
| 196 | + if ( '' !== self::skip_reason() ) { | |
| 127 | 197 | return $html; |
| 128 | 198 | } |
| 129 | 199 | |
| 130 | 200 | $placeholders = array(); |
| @@ -131,10 +201,18 @@ | ||
| 131 | 201 | $pattern = '#<(pre|textarea|script|style)\b[^>]*>.*?</\1>#is'; |
| 132 | 202 | $html = preg_replace_callback( |
| 133 | 203 | $pattern, |
| 134 | 204 | function ( $m ) use ( &$placeholders ) { |
| 135 | - $key = '__XSPEED_PH_' . count( $placeholders ) . '__'; | |
| 136 | - $placeholders[ $key ] = $m[0]; | |
| 205 | + $key = '__XSPEED_PH_' . count( $placeholders ) . '__'; | |
| 206 | + // The placeholder pass exists to protect content whose | |
| 207 | + // whitespace is significant (<pre>, <textarea>) and to keep | |
| 208 | + // the tag-boundary regex off script bodies. <style> and | |
| 209 | + // <script> were grouped in with them, so protection became a | |
| 210 | + // permanent exemption: on builder sites where most CSS is | |
| 211 | + // inline, a page with minify ON shipped fully indented. Minify | |
| 212 | + // the BODY here, before it's stashed, so the outer passes | |
| 213 | + // still never see it. (#2) | |
| 214 | + $placeholders[ $key ] = self::minify_inline_block( $m[0], strtolower( $m[1] ) ); | |
| 137 | 215 | return $key; |
| 138 | 216 | }, |
| 139 | 217 | $html |
| 140 | 218 | ); |
| @@ -181,10 +259,21 @@ | ||
| 181 | 259 | return self::rewrite_asset( $src, 'css' ); |
| 182 | 260 | } |
| 183 | 261 | |
| 184 | 262 | public static function rewrite_script( $src, $handle ) { |
| 185 | - unset( $handle ); | |
| 186 | - return self::rewrite_asset( $src, 'js' ); | |
| 263 | + $rewritten = self::rewrite_asset( $src, 'js' ); | |
| 264 | + | |
| 265 | + // Remember the pre-minify URL for this handle. script_loader_tag | |
| 266 | + // runs later and only ever sees the rewritten src (a hashed | |
| 267 | + // /cache/xspeed/min/<key>.js path), so a user's URL-substring | |
| 268 | + // delay/exclusion target would never match once minification is | |
| 269 | + // on. Minify_Filters::original_src() gives those checks the URL | |
| 270 | + // the user actually wrote their target against. (FBS field report) | |
| 271 | + if ( is_string( $handle ) && '' !== $handle && is_string( $src ) && $src !== $rewritten ) { | |
| 272 | + Minify_Filters::remember_original_src( $handle, $src ); | |
| 273 | + } | |
| 274 | + | |
| 275 | + return $rewritten; | |
| 187 | 276 | } |
| 188 | 277 | |
| 189 | 278 | /** |
| 190 | 279 | * Replace a local CSS/JS URL with a cached, minified equivalent. |
| @@ -202,10 +291,12 @@ | ||
| 202 | 291 | if ( false !== strpos( $src, '.min.' ) ) { |
| 203 | 292 | return $src; |
| 204 | 293 | } |
| 205 | 294 | |
| 206 | - // Skip anything we already produced. The Asset_Combiner writes a | |
| 207 | - // pre-minified combined-<hash>.css under min/combined/ and enqueues it | |
| 295 | + // Skip anything we already produced. The Asset_Combiner minifies the | |
| 296 | + // combined body itself before writing combined-<hash>.css under | |
| 297 | + // min/combined/ (issue #331 — that used to be asserted here but was | |
| 298 | + // not actually true, so the artifact shipped unminified), and enqueues it | |
| 208 | 299 | // as `xspeed-combined-css`; the per-file minifier used to re-minify |
| 209 | 300 | // that combined output into a SECOND file (min/<hash2>.css) with its |
| 210 | 301 | // own mtime-derived hash. The served HTML then pinned that second |
| 211 | 302 | // hash, so a purge/regeneration (which changes the combined file's |
| @@ -302,18 +393,169 @@ | ||
| 302 | 393 | * literal `{` / `}` / `[` / `]` that throw off the count by the same |
| 303 | 394 | * amount in both bodies (since they survive minification as-is), so |
| 304 | 395 | * the equality check is robust to that noise. |
| 305 | 396 | */ |
| 306 | - private static function balanced( string $source, string $minified ): bool { | |
| 307 | - $pairs = array( '(', ')', '{', '}', '[', ']', '`' ); | |
| 308 | - foreach ( $pairs as $token ) { | |
| 309 | - if ( substr_count( $source, $token ) !== substr_count( $minified, $token ) ) { | |
| 397 | + /** | |
| 398 | + * Minify the body of one captured inline block, or return it untouched. | |
| 399 | + * | |
| 400 | + * Only `<style>` and JavaScript `<script>` bodies are eligible: | |
| 401 | + * | |
| 402 | + * - `<pre>` / `<textarea>` — whitespace is rendered, never touch it. | |
| 403 | + * - `<script>` with a non-JS `type` — `application/ld+json`, | |
| 404 | + * `text/template`, `text/x-handlebars` and anything unrecognised are | |
| 405 | + * data or markup, not code. Minifying JSON-LD would corrupt structured | |
| 406 | + * data; minifying a template would eat the markup it holds. An unknown | |
| 407 | + * type is treated as non-JS on purpose: guessing wrong breaks the page, | |
| 408 | + * while skipping only forgoes a few bytes. | |
| 409 | + * - `<script src="...">` — the body is empty; the file path already goes | |
| 410 | + * through minify_file(). | |
| 411 | + * | |
| 412 | + * Every result is checked with balanced(), the same structural guard the | |
| 413 | + * file path uses, so a body the library truncates is shipped as-is rather | |
| 414 | + * than broken. (#2) | |
| 415 | + * | |
| 416 | + * @param string $block Full matched tag, opening tag through closing tag. | |
| 417 | + * @param string $tag Lowercased tag name. | |
| 418 | + * @return string Minified block, or $block unchanged. | |
| 419 | + */ | |
| 420 | + private static function minify_inline_block( string $block, string $tag ): string { | |
| 421 | + if ( 'style' !== $tag && 'script' !== $tag ) { | |
| 422 | + return $block; // pre / textarea — significant whitespace. | |
| 423 | + } | |
| 424 | + if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) { | |
| 425 | + return $block; | |
| 426 | + } | |
| 427 | + | |
| 428 | + // Split into opening tag / body / closing tag. Anything that doesn't | |
| 429 | + // match this shape isn't something we should be rewriting. | |
| 430 | + if ( ! preg_match( '#^(<' . $tag . '\b[^>]*>)(.*)(</' . $tag . '\s*>)$#is', $block, $parts ) ) { | |
| 431 | + return $block; | |
| 432 | + } | |
| 433 | + list( , $open, $body, $close ) = $parts; | |
| 434 | + | |
| 435 | + if ( '' === trim( $body ) ) { | |
| 436 | + return $block; | |
| 437 | + } | |
| 438 | + | |
| 439 | + // Refuse a body that is already structurally broken. balanced() only | |
| 440 | + // compares source against minified, so it passes when BOTH are equally | |
| 441 | + // unbalanced — `function x( {` minifies to `function x({`, same counts, | |
| 442 | + // guard satisfied, broken code reformatted. Rewriting a body we can't | |
| 443 | + // parse risks turning a page that happens to work into one that does | |
| 444 | + // not, for no gain. (#2 AC: a syntactically broken block is left | |
| 445 | + // untouched.) | |
| 446 | + if ( ! self::self_consistent( $body ) ) { | |
| 447 | + return $block; | |
| 448 | + } | |
| 449 | + | |
| 450 | + if ( 'script' === $tag ) { | |
| 451 | + // An external script has no body worth minifying. | |
| 452 | + if ( preg_match( '#\bsrc\s*=#i', $open ) ) { | |
| 453 | + return $block; | |
| 454 | + } | |
| 455 | + // No type, or an explicitly JavaScript type, is code. Everything | |
| 456 | + // else is data/markup — see the docblock. | |
| 457 | + $js_types = array( | |
| 458 | + 'text/javascript', | |
| 459 | + 'application/javascript', | |
| 460 | + 'application/ecmascript', | |
| 461 | + 'text/ecmascript', | |
| 462 | + 'module', | |
| 463 | + ); | |
| 464 | + if ( preg_match( '#\btype\s*=\s*["\']?([^"\'\s>]+)#i', $open, $type_match ) ) { | |
| 465 | + if ( ! in_array( strtolower( trim( $type_match[1] ) ), $js_types, true ) ) { | |
| 466 | + return $block; | |
| 467 | + } | |
| 468 | + } | |
| 469 | + } | |
| 470 | + | |
| 471 | + try { | |
| 472 | + $minifier = 'style' === $tag | |
| 473 | + ? new \MatthiasMullie\Minify\CSS() | |
| 474 | + : new \MatthiasMullie\Minify\JS(); | |
| 475 | + $minifier->add( $body ); | |
| 476 | + $minified = $minifier->minify(); | |
| 477 | + } catch ( \Throwable $e ) { | |
| 478 | + return $block; | |
| 479 | + } | |
| 480 | + | |
| 481 | + // A minifier that returns nothing for a non-empty body has failed, not | |
| 482 | + // succeeded — shipping '' would silently delete the rule set. | |
| 483 | + if ( ! is_string( $minified ) || '' === trim( $minified ) ) { | |
| 484 | + return $block; | |
| 485 | + } | |
| 486 | + if ( ! self::balanced( $body, $minified ) ) { | |
| 487 | + return $block; | |
| 488 | + } | |
| 489 | + | |
| 490 | + return $open . $minified . $close; | |
| 491 | + } | |
| 492 | + | |
| 493 | + /** | |
| 494 | + * Does a body's own paired delimiters balance? | |
| 495 | + * | |
| 496 | + * balanced() is a RELATIVE check — source against minified — so it cannot | |
| 497 | + * see input that was already broken: an unbalanced body minifies to an | |
| 498 | + * equally unbalanced one and the counts still agree. This is the absolute | |
| 499 | + * check, applied to the source alone before we touch it. | |
| 500 | + * | |
| 501 | + * Deliberately naive: it counts tokens without parsing, so a brace inside | |
| 502 | + * a string or comment skews it. That only ever makes it MORE conservative — | |
| 503 | + * a false negative skips minification, which costs bytes, while a false | |
| 504 | + * positive would ship broken code. (#2) | |
| 505 | + * | |
| 506 | + * @param string $body Inline block body. | |
| 507 | + */ | |
| 508 | + private static function self_consistent( string $body ): bool { | |
| 509 | + $pairs = array( | |
| 510 | + '{' => '}', | |
| 511 | + '(' => ')', | |
| 512 | + '[' => ']', | |
| 513 | + ); | |
| 514 | + foreach ( $pairs as $open => $close ) { | |
| 515 | + if ( substr_count( $body, $open ) !== substr_count( $body, $close ) ) { | |
| 310 | 516 | return false; |
| 311 | 517 | } |
| 312 | 518 | } |
| 519 | + // Backticks and quotes pair with themselves, so an odd count means an | |
| 520 | + // unterminated literal. | |
| 521 | + foreach ( array( '`' ) as $token ) { | |
| 522 | + if ( 0 !== substr_count( $body, $token ) % 2 ) { | |
| 523 | + return false; | |
| 524 | + } | |
| 525 | + } | |
| 313 | 526 | return true; |
| 314 | 527 | } |
| 315 | 528 | |
| 529 | + private static function balanced( string $source, string $minified ): bool { | |
| 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 ); | |
| 556 | + } | |
| 557 | + | |
| 316 | 558 | /** |
| 317 | 559 | * Resolve a local asset URL to a filesystem path using a strict allowlist |
| 318 | 560 | * of "URL prefix → filesystem prefix" pairs registered with WordPress. |
| 319 | 561 | * |
| @@ -355,19 +597,40 @@ | ||
| 355 | 597 | array( content_url(), WP_CONTENT_DIR ), |
| 356 | 598 | array( includes_url(), ABSPATH . WPINC ), |
| 357 | 599 | ); |
| 358 | 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 | + | |
| 359 | 616 | foreach ( $candidates as $pair ) { |
| 360 | 617 | list( $url_base, $path_base ) = $pair; |
| 361 | 618 | if ( ! $url_base || ! $path_base ) { |
| 362 | 619 | continue; |
| 363 | 620 | } |
| 364 | - $url_base = rtrim( $url_base, '/' ); | |
| 365 | - 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 ) { | |
| 366 | 624 | continue; |
| 367 | 625 | } |
| 368 | 626 | |
| 369 | - $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 ) ), '/' ); | |
| 370 | 633 | $candidate = trailingslashit( $path_base ) . $relative; |
| 371 | 634 | |
| 372 | 635 | $real_base = realpath( $path_base ); |
| 373 | 636 | $real = realpath( $candidate ); |
| @@ -395,10 +658,16 @@ | ||
| 395 | 658 | Cache::write_silence( $dir ); |
| 396 | 659 | } |
| 397 | 660 | } |
| 398 | 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 | + */ | |
| 399 | 668 | public static function purge_minified() { |
| 400 | - self::rmtree_files( self::min_dir() ); | |
| 669 | + return self::rmtree_files( self::min_dir() ); | |
| 401 | 670 | } |
| 402 | 671 | |
| 403 | 672 | /** |
| 404 | 673 | * Recursively delete every file under $dir (and the emptied |
| @@ -407,18 +676,21 @@ | ||
| 407 | 676 | * min/combined/ were never cleared — a purge left a stale |
| 408 | 677 | * combined-<hash>.css the regenerated page no longer referenced. |
| 409 | 678 | * (FBS-83114 / FBS-83116) |
| 410 | 679 | */ |
| 411 | - private static function rmtree_files( string $dir ): void { | |
| 680 | + private static function rmtree_files( string $dir ): int { | |
| 412 | 681 | if ( ! is_dir( $dir ) ) { |
| 413 | - return; | |
| 682 | + return 0; | |
| 414 | 683 | } |
| 684 | + $removed = 0; | |
| 415 | 685 | foreach ( (array) glob( $dir . '/*' ) as $path ) { |
| 416 | 686 | if ( is_dir( $path ) ) { |
| 417 | - self::rmtree_files( $path ); | |
| 687 | + $removed += self::rmtree_files( $path ); | |
| 418 | 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. |
| 419 | 689 | continue; |
| 420 | 690 | } |
| 421 | 691 | wp_delete_file( $path ); |
| 692 | + ++$removed; | |
| 422 | 693 | } |
| 694 | + return $removed; | |
| 423 | 695 | } |
| 424 | 696 | } |