PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.0
1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / class-minifier.php

class-minifier.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.2.0, at includes/class-minifier.php

626 lines 24.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Asset minifier — HTML, CSS, JS.
4 *
5 * Uses matthiasmullie/minify for CSS/JS. Local enqueued assets are minified
6 * once, cached on disk, and the loader URL is rewritten to point at the
7 * cached file.
8 *
9 * @package XSpeed
10 */
11
12 namespace XSpeed;
13
14 defined( 'ABSPATH' ) || exit;
15
16 class Minifier {
17
18 const MIN_SUBDIR = 'min';
19
20 /**
21 * Absolute path to the minified-cache directory. Always derived from
22 * XSPEED_CACHE_DIR (the plugin's own cache root) — never assembled from
23 * arbitrary URL fragments.
24 */
25 public static function min_dir() {
26 return trailingslashit( XSPEED_CACHE_DIR ) . self::MIN_SUBDIR;
27 }
28
29 /**
30 * Public URL of the minified-cache directory. Built from content_url() +
31 * the known relative path, not by string-replacing WP_CONTENT_DIR out of
32 * a filesystem path (which would assume the filesystem layout matches
33 * the URL layout — it does not on Bedrock-style installs, multisite with
34 * mapped domains, or any setup with a relocated wp-content).
35 */
36 private static function min_url() {
37 // XSPEED_CACHE_DIR lives under wp-content (defined in xspeed.php as
38 // WP_CONTENT_DIR . '/cache/xspeed'), so the URL is content_url() +
39 // the known suffix. We do not derive URLs from arbitrary filesystem
40 // paths anywhere in this plugin.
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 );
49 }
50
51 public function __construct() {
52 // Only run on the frontend — never minify wp-admin, AJAX, REST or cron
53 // asset URLs. Page caching already handles the logged-in case for
54 // the HTML response; minify scope is the public frontend.
55 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
56 return;
57 }
58
59 // Settings now live in the per-module option (xspeed_module_minify),
60 // owned by XSpeed\Modules\Minify\MinifyModule. We read through
61 // Settings_Manager so schema-validated values are returned even
62 // if the option was hand-edited.
63 $opts = Settings_Manager::get( 'minify' );
64
65 if ( ! empty( $opts['minify_css'] ) ) {
66 add_filter( 'style_loader_src', array( __CLASS__, 'rewrite_style' ), 10, 2 );
67 }
68 if ( ! empty( $opts['minify_js'] ) ) {
69 add_filter( 'script_loader_src', array( __CLASS__, 'rewrite_script' ), 10, 2 );
70 }
71
72 // Phase 4.1a — filter-only "smarter minifier" features. Each is
73 // gated on its own toggle so users can enable any subset.
74 if ( ! empty( $opts['remove_query_strings'] ) ) {
75 add_filter( 'style_loader_src', array( Minify_Filters::class, 'strip_version_query' ), 20 );
76 add_filter( 'script_loader_src', array( Minify_Filters::class, 'strip_version_query' ), 20 );
77 }
78 if ( ! empty( $opts['defer_js'] ) ) {
79 add_filter( 'script_loader_tag', array( Minify_Filters::class, 'defer_script_tag' ), 20, 3 );
80 }
81 if ( ! empty( $opts['delay_js'] ) ) {
82 // Delay applies a transform that's mutually exclusive with
83 // plain defer — when both are on, delay wins (the bootstrap
84 // will re-attach as a regular <script> on interaction).
85 add_filter( 'script_loader_tag', array( Minify_Filters::class, 'delay_script_tag' ), 30, 3 );
86 add_action( 'wp_footer', array( Minify_Filters::class, 'print_delay_bootstrap' ), 1000 );
87 // script_loader_tag only fires for wp_enqueue_script()'d assets.
88 // Analytics / pixel / chat-widget tags printed straight into
89 // wp_head bypass it, and those are usually the heaviest scripts
90 // on the page — so sweep the finished buffer too. Runs before
91 // minify_html (same filter, default priority) and is baked into
92 // the cache file, so it replays on static hits where PHP never
93 // boots.
94 add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'delay_raw_script_tags' ), 20 );
95 }
96 if ( ! empty( $opts['async_css'] ) ) {
97 add_filter( 'style_loader_tag', array( Minify_Filters::class, 'async_style_tag' ), 20, 2 );
98 }
99
100 /*
101 * CSS combining runs on the FINISHED HTML, not the enqueue queue.
102 *
103 * The queue-walking version could not be made correct: whatever it
104 * wrote at priority 999, WordPress edited afterwards. Core's
105 * wp_maybe_inline_styles() inlines any queued handle carrying a `path`
106 * and sets src=false on it, which silently threw away the combined URL
107 * and took the sheets we had blanked with it — six stylesheets became
108 * one and the site rendered unstyled. See Css_Combine_Buffer's header
109 * for the full trace. (#195)
110 *
111 * Two entry points, because the page cache's filter is not always
112 * available: `xspeed_cache_final_html` fires only on a cacheable MISS,
113 * so on a site with the cache off — or on an excluded URL like /cart —
114 * combining would silently stop working. Css_Combine_Buffer::boot()
115 * opens its own buffer in exactly those cases and no-ops otherwise, so
116 * the page is transformed once either way.
117 */
118 if ( ! empty( $opts['combine_css'] ) ) {
119 add_filter( 'xspeed_cache_final_html', array( Css_Combine_Buffer::class, 'process' ), 5 );
120 Css_Combine_Buffer::boot();
121 }
122 if ( ! empty( $opts['combine_js'] ) ) {
123 // JS stays on the enqueue path for now: dependency order,
124 // async/defer and wp_add_inline_script make it a different
125 // problem, and the reported break is CSS-only. Moving it is worth
126 // its own change rather than doubling the blast radius here.
127 add_action( 'wp_enqueue_scripts', array( Asset_Combiner::class, 'combine_scripts' ), 999 );
128 }
129 }
130
131 /**
132 * HTML elements that participate in an inline formatting context, where
133 * whitespace between two of them renders as a visible space.
134 *
135 * Deliberately excludes <br> (nothing to separate) and replaced/embedded
136 * inline elements that sit alone. Anything not listed is treated as block
137 * level, where inter-tag whitespace collapses to nothing and is safe to
138 * strip. (FBS-84090)
139 */
140 private const INLINE_TAGS = array(
141 'a', 'abbr', 'b', 'bdi', 'bdo', 'cite', 'code', 'data', 'del', 'dfn',
142 'em', 'i', 'ins', 'kbd', 'label', 'mark', 'q', 'rp', 'rt', 'ruby',
143 's', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'time', 'u',
144 'var', 'wbr', 'img', 'button', 'select', 'output',
145 );
146
147 /** True when $tag renders inline, so whitespace beside it is visible. */
148 private static function is_inline( string $tag ): bool {
149 return in_array( strtolower( $tag ), self::INLINE_TAGS, true );
150 }
151
152 /**
153 * Why minification is being skipped, when it is. '' when it will run.
154 *
155 * minify_html can read "on" in every settings surface while producing
156 * byte-identical HTML, because the guard below silently returns the
157 * input. Field report: a live site showed `minify_html: on` with 3,856
158 * indented lines in the delivered HTML and nothing anywhere explaining
159 * the contradiction — the setting looked broken rather than suppressed.
160 * Callers that report status MUST consult this so the refusal is
161 * visible. (Same class as Cache::static_rewrite_block_reason().)
162 *
163 * @return string 'wp_debug', 'filter', or ''.
164 */
165 public static function skip_reason(): string {
166 $debug_skip = defined( 'WP_DEBUG' ) && WP_DEBUG;
167 if ( ! apply_filters( 'xspeed_skip_minify', $debug_skip ) ) {
168 return '';
169 }
170 // Distinguish the built-in WP_DEBUG rule from a third party
171 // filtering the escape hatch — the fixes are different.
172 return $debug_skip ? 'wp_debug' : 'filter';
173 }
174
175 public static function minify_html( $html ) {
176 if ( '' !== self::skip_reason() ) {
177 return $html;
178 }
179
180 $placeholders = array();
181 $pattern = '#<(pre|textarea|script|style)\b[^>]*>.*?</\1>#is';
182 $html = preg_replace_callback(
183 $pattern,
184 function ( $m ) use ( &$placeholders ) {
185 $key = '__XSPEED_PH_' . count( $placeholders ) . '__';
186 // The placeholder pass exists to protect content whose
187 // whitespace is significant (<pre>, <textarea>) and to keep
188 // the tag-boundary regex off script bodies. <style> and
189 // <script> were grouped in with them, so protection became a
190 // permanent exemption: on builder sites where most CSS is
191 // inline, a page with minify ON shipped fully indented. Minify
192 // the BODY here, before it's stashed, so the outer passes
193 // still never see it. (#2)
194 $placeholders[ $key ] = self::minify_inline_block( $m[0], strtolower( $m[1] ) );
195 return $key;
196 },
197 $html
198 );
199
200 $html = preg_replace( '/<!--(?!\[if).*?-->/s', '', $html );
201 $html = preg_replace( '/\s+/', ' ', $html );
202
203 /*
204 * Collapse whitespace BETWEEN TAGS — but never where it is visible.
205 *
206 * Whitespace separating two INLINE elements is a real, rendered space:
207 * WooCommerce emits `</del> <ins>` for a sale price, and that single
208 * character is the gap between "$32.50" and "$29.50". Stripping it
209 * printed "$32.50$29.50" run together, and only with cache on — the
210 * un-minified page was fine. (FBS-84090)
211 *
212 * So the strip only applies when at least one side is a BLOCK-level
213 * (or non-rendered) tag, where the whitespace collapses away anyway.
214 * Inline-to-inline boundaries keep their single space.
215 */
216 $html = preg_replace_callback(
217 // left tag name (may be a closing tag) … whitespace … right tag name
218 '#</?([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>\s+<(/?)([a-zA-Z][a-zA-Z0-9-]*)#',
219 static function ( $m ) {
220 // Keep the space only when BOTH sides are inline elements —
221 // that is the one case where it is actually rendered.
222 $keep = self::is_inline( $m[1] ) && self::is_inline( $m[3] );
223 $open = substr( $m[0], 0, strrpos( $m[0], '<' ) ); // through the left tag's '>'
224 return rtrim( $open ) . ( $keep ? ' ' : '' ) . '<' . $m[2] . $m[3];
225 },
226 $html
227 );
228 $html = trim( $html );
229
230 foreach ( $placeholders as $key => $original ) {
231 $html = str_replace( $key, $original, $html );
232 }
233
234 return $html;
235 }
236
237 public static function rewrite_style( $src, $handle ) {
238 unset( $handle );
239 return self::rewrite_asset( $src, 'css' );
240 }
241
242 public static function rewrite_script( $src, $handle ) {
243 $rewritten = self::rewrite_asset( $src, 'js' );
244
245 // Remember the pre-minify URL for this handle. script_loader_tag
246 // runs later and only ever sees the rewritten src (a hashed
247 // /cache/xspeed/min/<key>.js path), so a user's URL-substring
248 // delay/exclusion target would never match once minification is
249 // on. Minify_Filters::original_src() gives those checks the URL
250 // the user actually wrote their target against. (FBS field report)
251 if ( is_string( $handle ) && '' !== $handle && is_string( $src ) && $src !== $rewritten ) {
252 Minify_Filters::remember_original_src( $handle, $src );
253 }
254
255 return $rewritten;
256 }
257
258 /**
259 * Replace a local CSS/JS URL with a cached, minified equivalent.
260 *
261 * @param string $src Original asset URL.
262 * @param string $type 'css' or 'js'.
263 * @return string Possibly rewritten URL.
264 */
265 private static function rewrite_asset( $src, $type ) {
266 if ( ! is_string( $src ) || '' === $src ) {
267 return $src;
268 }
269
270 // Skip already-minified files.
271 if ( false !== strpos( $src, '.min.' ) ) {
272 return $src;
273 }
274
275 // Skip anything we already produced. The Asset_Combiner writes a
276 // pre-minified combined-<hash>.css under min/combined/ and enqueues it
277 // as `xspeed-combined-css`; the per-file minifier used to re-minify
278 // that combined output into a SECOND file (min/<hash2>.css) with its
279 // own mtime-derived hash. The served HTML then pinned that second
280 // hash, so a purge/regeneration (which changes the combined file's
281 // mtime -> a new hash2) left the cached page pointing at a file that
282 // no longer existed -> 404 -> unstyled/broken frontend. Leaving our
283 // own cache output untouched keeps a single, stable URL end-to-end.
284 if ( false !== strpos( $src, '/cache/xspeed/' ) ) {
285 return $src;
286 }
287
288 // Resolve to a local path; bail if external or unresolvable.
289 $path = self::url_to_path( $src );
290 if ( ! $path || ! is_readable( $path ) ) {
291 return $src;
292 }
293
294 // Build a cache filename keyed on path + mtime so edits invalidate.
295 $mtime = filemtime( $path );
296 $key = md5( $path . '|' . $mtime );
297 $cache = self::cache_path( $key, $type );
298
299 if ( ! file_exists( $cache ) ) {
300 $ok = self::minify_file( $path, $cache, $type );
301 if ( ! $ok ) {
302 return $src;
303 }
304 }
305
306 // Return a URL to the cached file. Built from known constants — never
307 // from str_replace on a filesystem path (which would assume the FS
308 // layout mirrors the URL layout).
309 return self::min_url() . '/' . $key . '.' . $type;
310 }
311
312 private static function minify_file( $source_path, $target_path, $type ) {
313 if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
314 return false;
315 }
316
317 // Path-traversal guard: refuse to write anywhere outside our cache
318 // dir, even if a malicious filter ever produced a poisoned key.
319 $cache_root = self::min_dir();
320 self::ensure_dir( $cache_root );
321 $real_root = realpath( $cache_root );
322 $real_dir = realpath( dirname( $target_path ) );
323 if ( ! $real_root || ! $real_dir || 0 !== strpos( $real_dir, $real_root ) ) {
324 return false;
325 }
326
327 try {
328 if ( 'css' === $type ) {
329 // Passing the TARGET path makes matthiasmullie/minify rebase every
330 // relative url(...) / @import against the minified file's location.
331 // Without it, a stylesheet moved from e.g.
332 // .../font-awesome/css/all.css to cache/xspeed/min/<key>.css keeps
333 // its original url(../webfonts/…) — which then resolves against the
334 // cache dir and 404s (missing FontAwesome/eicons/WooCommerce fonts).
335 $minifier = new \MatthiasMullie\Minify\CSS( $source_path );
336 $minified = $minifier->minify( $target_path );
337 return '' !== $minified && file_exists( $target_path );
338 }
339
340 $minifier = new \MatthiasMullie\Minify\JS( $source_path );
341 $minified = $minifier->minify();
342
343 // Sanity check: paren/brace/bracket/backtick balance must be preserved.
344 // matthiasmullie/minify can silently truncate mid-template-literal on
345 // complex modern JS — bail rather than ship a broken file.
346 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem requires admin context; minification runs on frontend page renders. Source already validated as readable on line 121.
347 $source = file_get_contents( $source_path );
348 if ( false === $source || ! self::balanced( $source, $minified ) ) {
349 return false;
350 }
351
352 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context; minification runs on frontend page renders.
353 $bytes = file_put_contents( $target_path, $minified );
354 return false !== $bytes && file_exists( $target_path );
355 } catch ( \Throwable $e ) {
356 return false;
357 }
358 }
359
360 /**
361 * Cheap structural sanity check between source + minified bodies.
362 *
363 * Counts paired-delimiter tokens (parens, braces, brackets, backticks)
364 * in each and bails when the counts disagree — matthiasmullie/minify
365 * has been observed to silently truncate inside template literals on
366 * complex modern JS (see commit history), shipping a body that LOOKS
367 * minified but is structurally broken and crashes the page at parse.
368 *
369 * Backticks are paired (open + close = same token), so the count
370 * itself must match exactly. Strings inside the source can contain
371 * literal `{` / `}` / `[` / `]` that throw off the count by the same
372 * amount in both bodies (since they survive minification as-is), so
373 * the equality check is robust to that noise.
374 */
375 /**
376 * Minify the body of one captured inline block, or return it untouched.
377 *
378 * Only `<style>` and JavaScript `<script>` bodies are eligible:
379 *
380 * - `<pre>` / `<textarea>` — whitespace is rendered, never touch it.
381 * - `<script>` with a non-JS `type` — `application/ld+json`,
382 * `text/template`, `text/x-handlebars` and anything unrecognised are
383 * data or markup, not code. Minifying JSON-LD would corrupt structured
384 * data; minifying a template would eat the markup it holds. An unknown
385 * type is treated as non-JS on purpose: guessing wrong breaks the page,
386 * while skipping only forgoes a few bytes.
387 * - `<script src="...">` — the body is empty; the file path already goes
388 * through minify_file().
389 *
390 * Every result is checked with balanced(), the same structural guard the
391 * file path uses, so a body the library truncates is shipped as-is rather
392 * than broken. (#2)
393 *
394 * @param string $block Full matched tag, opening tag through closing tag.
395 * @param string $tag Lowercased tag name.
396 * @return string Minified block, or $block unchanged.
397 */
398 private static function minify_inline_block( string $block, string $tag ): string {
399 if ( 'style' !== $tag && 'script' !== $tag ) {
400 return $block; // pre / textarea — significant whitespace.
401 }
402 if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
403 return $block;
404 }
405
406 // Split into opening tag / body / closing tag. Anything that doesn't
407 // match this shape isn't something we should be rewriting.
408 if ( ! preg_match( '#^(<' . $tag . '\b[^>]*>)(.*)(</' . $tag . '\s*>)$#is', $block, $parts ) ) {
409 return $block;
410 }
411 list( , $open, $body, $close ) = $parts;
412
413 if ( '' === trim( $body ) ) {
414 return $block;
415 }
416
417 // Refuse a body that is already structurally broken. balanced() only
418 // compares source against minified, so it passes when BOTH are equally
419 // unbalanced — `function x( {` minifies to `function x({`, same counts,
420 // guard satisfied, broken code reformatted. Rewriting a body we can't
421 // parse risks turning a page that happens to work into one that does
422 // not, for no gain. (#2 AC: a syntactically broken block is left
423 // untouched.)
424 if ( ! self::self_consistent( $body ) ) {
425 return $block;
426 }
427
428 if ( 'script' === $tag ) {
429 // An external script has no body worth minifying.
430 if ( preg_match( '#\bsrc\s*=#i', $open ) ) {
431 return $block;
432 }
433 // No type, or an explicitly JavaScript type, is code. Everything
434 // else is data/markup — see the docblock.
435 $js_types = array(
436 'text/javascript',
437 'application/javascript',
438 'application/ecmascript',
439 'text/ecmascript',
440 'module',
441 );
442 if ( preg_match( '#\btype\s*=\s*["\']?([^"\'\s>]+)#i', $open, $type_match ) ) {
443 if ( ! in_array( strtolower( trim( $type_match[1] ) ), $js_types, true ) ) {
444 return $block;
445 }
446 }
447 }
448
449 try {
450 $minifier = 'style' === $tag
451 ? new \MatthiasMullie\Minify\CSS()
452 : new \MatthiasMullie\Minify\JS();
453 $minifier->add( $body );
454 $minified = $minifier->minify();
455 } catch ( \Throwable $e ) {
456 return $block;
457 }
458
459 // A minifier that returns nothing for a non-empty body has failed, not
460 // succeeded — shipping '' would silently delete the rule set.
461 if ( ! is_string( $minified ) || '' === trim( $minified ) ) {
462 return $block;
463 }
464 if ( ! self::balanced( $body, $minified ) ) {
465 return $block;
466 }
467
468 return $open . $minified . $close;
469 }
470
471 /**
472 * Does a body's own paired delimiters balance?
473 *
474 * balanced() is a RELATIVE check — source against minified — so it cannot
475 * see input that was already broken: an unbalanced body minifies to an
476 * equally unbalanced one and the counts still agree. This is the absolute
477 * check, applied to the source alone before we touch it.
478 *
479 * Deliberately naive: it counts tokens without parsing, so a brace inside
480 * a string or comment skews it. That only ever makes it MORE conservative —
481 * a false negative skips minification, which costs bytes, while a false
482 * positive would ship broken code. (#2)
483 *
484 * @param string $body Inline block body.
485 */
486 private static function self_consistent( string $body ): bool {
487 $pairs = array(
488 '{' => '}',
489 '(' => ')',
490 '[' => ']',
491 );
492 foreach ( $pairs as $open => $close ) {
493 if ( substr_count( $body, $open ) !== substr_count( $body, $close ) ) {
494 return false;
495 }
496 }
497 // Backticks and quotes pair with themselves, so an odd count means an
498 // unterminated literal.
499 foreach ( array( '`' ) as $token ) {
500 if ( 0 !== substr_count( $body, $token ) % 2 ) {
501 return false;
502 }
503 }
504 return true;
505 }
506
507 private static function balanced( string $source, string $minified ): bool {
508 $pairs = array( '(', ')', '{', '}', '[', ']', '`' );
509 foreach ( $pairs as $token ) {
510 if ( substr_count( $source, $token ) !== substr_count( $minified, $token ) ) {
511 return false;
512 }
513 }
514 return true;
515 }
516
517 /**
518 * Resolve a local asset URL to a filesystem path using a strict allowlist
519 * of "URL prefix → filesystem prefix" pairs registered with WordPress.
520 *
521 * We never assume `site_url()` maps to `ABSPATH` (the WordPress root can
522 * live above the document root in Bedrock-style installs, behind a proxy,
523 * or on multisite with mapped domains). Each branch resolves through a
524 * known WP API (plugins, themes, content, includes) and validates that
525 * `realpath()` of the result still lives under the expected base — so a
526 * crafted `..`-laden URL cannot escape into the filesystem.
527 *
528 * @param string $url Asset URL (may be protocol-relative or absolute).
529 * @return string|false Absolute filesystem path on success, false otherwise.
530 */
531 private static function url_to_path( $url ) {
532 if ( ! is_string( $url ) || '' === $url ) {
533 return false;
534 }
535
536 // Drop query string + fragment.
537 $clean = strtok( $url, '?#' );
538
539 // Normalise protocol-relative + scheme variants of the host so we
540 // match regardless of whether the asset URL came in over http/https.
541 $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
542 if ( 0 === strpos( $clean, '//' ) ) {
543 $clean = 'https:' . $clean;
544 }
545 if ( $site_host ) {
546 $asset_host = wp_parse_url( $clean, PHP_URL_HOST );
547 if ( $asset_host && $asset_host !== $site_host ) {
548 return false; // External asset — never touch.
549 }
550 }
551
552 $candidates = array(
553 array( plugins_url(), WP_PLUGIN_DIR ),
554 array( get_stylesheet_directory_uri(), get_stylesheet_directory() ),
555 array( get_template_directory_uri(), get_template_directory() ),
556 array( content_url(), WP_CONTENT_DIR ),
557 array( includes_url(), ABSPATH . WPINC ),
558 );
559
560 foreach ( $candidates as $pair ) {
561 list( $url_base, $path_base ) = $pair;
562 if ( ! $url_base || ! $path_base ) {
563 continue;
564 }
565 $url_base = rtrim( $url_base, '/' );
566 if ( 0 !== strpos( $clean, $url_base . '/' ) && $clean !== $url_base ) {
567 continue;
568 }
569
570 $relative = ltrim( substr( $clean, strlen( $url_base ) ), '/' );
571 $candidate = trailingslashit( $path_base ) . $relative;
572
573 $real_base = realpath( $path_base );
574 $real = realpath( $candidate );
575 if ( ! $real_base || ! $real ) {
576 return false;
577 }
578 // Guard against `..`-traversal: resolved path must stay inside
579 // the registered base.
580 if ( 0 !== strpos( $real, $real_base ) ) {
581 return false;
582 }
583 return $real;
584 }
585
586 return false;
587 }
588
589 private static function cache_path( $key, $type ) {
590 return self::min_dir() . '/' . $key . '.' . $type;
591 }
592
593 private static function ensure_dir( $dir ) {
594 if ( ! file_exists( $dir ) ) {
595 wp_mkdir_p( $dir );
596 Cache::write_silence( $dir );
597 }
598 }
599
600 public static function purge_minified() {
601 self::rmtree_files( self::min_dir() );
602 }
603
604 /**
605 * Recursively delete every file under $dir (and the emptied
606 * subdirectories), keeping $dir itself. The previous glob('$dir/*')
607 * was non-recursive and no-ops on directories, so combined assets in
608 * min/combined/ were never cleared — a purge left a stale
609 * combined-<hash>.css the regenerated page no longer referenced.
610 * (FBS-83114 / FBS-83116)
611 */
612 private static function rmtree_files( string $dir ): void {
613 if ( ! is_dir( $dir ) ) {
614 return;
615 }
616 foreach ( (array) glob( $dir . '/*' ) as $path ) {
617 if ( is_dir( $path ) ) {
618 self::rmtree_files( $path );
619 @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.
620 continue;
621 }
622 wp_delete_file( $path );
623 }
624 }
625 }
626