PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.4
1.3.4 1.3.3 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 All 30 releases
xspeed / includes / class-minifier.php

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

729 lines 29.6 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 // 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
68 // Settings now live in the per-module option (xspeed_module_minify),
69 // owned by XSpeed\Modules\Minify\MinifyModule. We read through
70 // Settings_Manager so schema-validated values are returned even
71 // if the option was hand-edited.
72 $opts = Settings_Manager::get( 'minify' );
73
74 if ( ! empty( $opts['minify_css'] ) ) {
75 add_filter( 'style_loader_src', array( __CLASS__, 'rewrite_style' ), 10, 2 );
76 }
77 if ( ! empty( $opts['minify_js'] ) ) {
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 );
86 }
87
88 // Phase 4.1a — filter-only "smarter minifier" features. Each is
89 // gated on its own toggle so users can enable any subset.
90 if ( ! empty( $opts['remove_query_strings'] ) ) {
91 add_filter( 'style_loader_src', array( Minify_Filters::class, 'strip_version_query' ), 20 );
92 add_filter( 'script_loader_src', array( Minify_Filters::class, 'strip_version_query' ), 20 );
93 }
94 if ( ! empty( $opts['defer_js'] ) ) {
95 add_filter( 'script_loader_tag', array( Minify_Filters::class, 'defer_script_tag' ), 20, 3 );
96 }
97 if ( ! empty( $opts['delay_js'] ) ) {
98 // Delay applies a transform that's mutually exclusive with
99 // plain defer — when both are on, delay wins (the bootstrap
100 // will re-attach as a regular <script> on interaction).
101 add_filter( 'script_loader_tag', array( Minify_Filters::class, 'delay_script_tag' ), 30, 3 );
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 );
115 }
116
117 // Everything above honors data-no-optimize / data-no-minify, but
118 // only sees markers stamped before priority 30. Borlabs Cookie
119 // stamps at 100, so its consent config was minified AND delayed
120 // despite carrying both markers. Snapshot the tag before our
121 // transforms (9) and hand the original back if a marker turns up
122 // after them (1000, past Borlabs' own 100 and 999). Registered
123 // whenever any of the three is on,
124 // since each one is individually enough to damage a marked
125 // script. (#469)
126 if ( ! empty( $opts['minify_js'] ) || ! empty( $opts['defer_js'] ) || ! empty( $opts['delay_js'] ) ) {
127 add_filter( 'script_loader_tag', array( Minify_Filters::class, 'snapshot_tag' ), 9, 3 );
128 add_filter(
129 'script_loader_tag',
130 array( Minify_Filters::class, 'revert_late_marked_tag' ),
131 Minify_Filters::late_opt_out_priority(),
132 3
133 );
134 }
135 if ( ! empty( $opts['async_css'] ) ) {
136 add_filter( 'style_loader_tag', array( Minify_Filters::class, 'async_style_tag' ), 20, 2 );
137 // style_loader_tag only fires for wp_enqueue_style()'d sheets.
138 // Themes print Google/Bunny/Typekit font CSS as literal <link>
139 // markup in the head, so those sheets never reach the filter and
140 // stay render-blocking — sweep the finished buffer for the known
141 // font-CSS hosts. Baked into the cache file, so it replays on
142 // static hits where PHP never boots.
143 add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'async_raw_font_css_links' ), 22 );
144 }
145
146 /*
147 * CSS combining runs on the FINISHED HTML, not the enqueue queue.
148 *
149 * The queue-walking version could not be made correct: whatever it
150 * wrote at priority 999, WordPress edited afterwards. Core's
151 * wp_maybe_inline_styles() inlines any queued handle carrying a `path`
152 * and sets src=false on it, which silently threw away the combined URL
153 * and took the sheets we had blanked with it — six stylesheets became
154 * one and the site rendered unstyled. See Css_Combine_Buffer's header
155 * for the full trace. (#195)
156 *
157 * Two entry points, because the page cache's filter is not always
158 * available: `xspeed_cache_final_html` fires only on a cacheable MISS,
159 * so on a site with the cache off — or on an excluded URL like /cart —
160 * combining would silently stop working. Css_Combine_Buffer::boot()
161 * opens its own buffer in exactly those cases and no-ops otherwise, so
162 * the page is transformed once either way.
163 */
164 if ( ! empty( $opts['combine_css'] ) ) {
165 add_filter( 'xspeed_cache_final_html', array( Css_Combine_Buffer::class, 'process' ), 5 );
166 Css_Combine_Buffer::boot();
167 }
168 if ( ! empty( $opts['combine_js'] ) ) {
169 // JS stays on the enqueue path for now: dependency order,
170 // async/defer and wp_add_inline_script make it a different
171 // problem, and the reported break is CSS-only. Moving it is worth
172 // its own change rather than doubling the blast radius here.
173 add_action( 'wp_enqueue_scripts', array( Asset_Combiner::class, 'combine_scripts' ), 999 );
174 }
175 }
176
177 /**
178 * HTML elements that participate in an inline formatting context, where
179 * whitespace between two of them renders as a visible space.
180 *
181 * Deliberately excludes <br> (nothing to separate) and replaced/embedded
182 * inline elements that sit alone. Anything not listed is treated as block
183 * level, where inter-tag whitespace collapses to nothing and is safe to
184 * strip. (FBS-84090)
185 */
186 private const INLINE_TAGS = array(
187 'a', 'abbr', 'b', 'bdi', 'bdo', 'cite', 'code', 'data', 'del', 'dfn',
188 'em', 'i', 'ins', 'kbd', 'label', 'mark', 'q', 'rp', 'rt', 'ruby',
189 's', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'time', 'u',
190 'var', 'wbr', 'img', 'button', 'select', 'output',
191 );
192
193 /** True when $tag renders inline, so whitespace beside it is visible. */
194 private static function is_inline( string $tag ): bool {
195 return in_array( strtolower( $tag ), self::INLINE_TAGS, true );
196 }
197
198 /**
199 * Why minification is being skipped, when it is. '' when it will run.
200 *
201 * minify_html can read "on" in every settings surface while producing
202 * byte-identical HTML, because the guard below silently returns the
203 * input. Field report: a live site showed `minify_html: on` with 3,856
204 * indented lines in the delivered HTML and nothing anywhere explaining
205 * the contradiction — the setting looked broken rather than suppressed.
206 * Callers that report status MUST consult this so the refusal is
207 * visible. (Same class as Cache::static_rewrite_block_reason().)
208 *
209 * @return string 'wp_debug', 'filter', or ''.
210 */
211 public static function skip_reason(): string {
212 $debug_skip = defined( 'WP_DEBUG' ) && WP_DEBUG;
213 if ( ! apply_filters( 'xspeed_skip_minify', $debug_skip ) ) {
214 return '';
215 }
216 // Distinguish the built-in WP_DEBUG rule from a third party
217 // filtering the escape hatch — the fixes are different.
218 return $debug_skip ? 'wp_debug' : 'filter';
219 }
220
221 public static function minify_html( $html ) {
222 if ( '' !== self::skip_reason() ) {
223 return $html;
224 }
225
226 $placeholders = array();
227 $pattern = '#<(pre|textarea|script|style)\b[^>]*>.*?</\1>#is';
228 $html = preg_replace_callback(
229 $pattern,
230 function ( $m ) use ( &$placeholders ) {
231 $key = '__XSPEED_PH_' . count( $placeholders ) . '__';
232 // The placeholder pass exists to protect content whose
233 // whitespace is significant (<pre>, <textarea>) and to keep
234 // the tag-boundary regex off script bodies. <style> and
235 // <script> were grouped in with them, so protection became a
236 // permanent exemption: on builder sites where most CSS is
237 // inline, a page with minify ON shipped fully indented. Minify
238 // the BODY here, before it's stashed, so the outer passes
239 // still never see it. (#2)
240 $placeholders[ $key ] = self::minify_inline_block( $m[0], strtolower( $m[1] ) );
241 return $key;
242 },
243 $html
244 );
245
246 $html = preg_replace( '/<!--(?!\[if).*?-->/s', '', $html );
247 $html = preg_replace( '/\s+/', ' ', $html );
248
249 /*
250 * Collapse whitespace BETWEEN TAGS — but never where it is visible.
251 *
252 * Whitespace separating two INLINE elements is a real, rendered space:
253 * WooCommerce emits `</del> <ins>` for a sale price, and that single
254 * character is the gap between "$32.50" and "$29.50". Stripping it
255 * printed "$32.50$29.50" run together, and only with cache on — the
256 * un-minified page was fine. (FBS-84090)
257 *
258 * So the strip only applies when at least one side is a BLOCK-level
259 * (or non-rendered) tag, where the whitespace collapses away anyway.
260 * Inline-to-inline boundaries keep their single space.
261 */
262 $html = preg_replace_callback(
263 // left tag name (may be a closing tag) … whitespace … right tag name
264 '#</?([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>\s+<(/?)([a-zA-Z][a-zA-Z0-9-]*)#',
265 static function ( $m ) {
266 // Keep the space only when BOTH sides are inline elements —
267 // that is the one case where it is actually rendered.
268 $keep = self::is_inline( $m[1] ) && self::is_inline( $m[3] );
269 $open = substr( $m[0], 0, strrpos( $m[0], '<' ) ); // through the left tag's '>'
270 return rtrim( $open ) . ( $keep ? ' ' : '' ) . '<' . $m[2] . $m[3];
271 },
272 $html
273 );
274 $html = trim( $html );
275
276 foreach ( $placeholders as $key => $original ) {
277 $html = str_replace( $key, $original, $html );
278 }
279
280 return $html;
281 }
282
283 public static function rewrite_style( $src, $handle ) {
284 unset( $handle );
285 return self::rewrite_asset( $src, 'css' );
286 }
287
288 public static function rewrite_script( $src, $handle ) {
289 $rewritten = self::rewrite_asset( $src, 'js' );
290
291 // Remember the pre-minify URL for this handle. script_loader_tag
292 // runs later and only ever sees the rewritten src (a hashed
293 // /cache/xspeed/min/<key>.js path), so a user's URL-substring
294 // delay/exclusion target would never match once minification is
295 // on. Minify_Filters::original_src() gives those checks the URL
296 // the user actually wrote their target against. (FBS field report)
297 if ( is_string( $handle ) && '' !== $handle && is_string( $src ) && $src !== $rewritten ) {
298 Minify_Filters::remember_original_src( $handle, $src );
299 }
300
301 return $rewritten;
302 }
303
304 /**
305 * Replace a local CSS/JS URL with a cached, minified equivalent.
306 *
307 * @param string $src Original asset URL.
308 * @param string $type 'css' or 'js'.
309 * @return string Possibly rewritten URL.
310 */
311 private static function rewrite_asset( $src, $type ) {
312 if ( ! is_string( $src ) || '' === $src ) {
313 return $src;
314 }
315
316 // Skip already-minified files.
317 if ( false !== strpos( $src, '.min.' ) ) {
318 return $src;
319 }
320
321 // Skip anything we already produced. The Asset_Combiner minifies the
322 // combined body itself before writing combined-<hash>.css under
323 // min/combined/ (issue #331 — that used to be asserted here but was
324 // not actually true, so the artifact shipped unminified), and enqueues it
325 // as `xspeed-combined-css`; the per-file minifier used to re-minify
326 // that combined output into a SECOND file (min/<hash2>.css) with its
327 // own mtime-derived hash. The served HTML then pinned that second
328 // hash, so a purge/regeneration (which changes the combined file's
329 // mtime -> a new hash2) left the cached page pointing at a file that
330 // no longer existed -> 404 -> unstyled/broken frontend. Leaving our
331 // own cache output untouched keeps a single, stable URL end-to-end.
332 if ( false !== strpos( $src, '/cache/xspeed/' ) ) {
333 return $src;
334 }
335
336 // Resolve to a local path; bail if external or unresolvable.
337 $path = self::url_to_path( $src );
338 if ( ! $path || ! is_readable( $path ) ) {
339 return $src;
340 }
341
342 // Build a cache filename keyed on path + mtime so edits invalidate.
343 $mtime = filemtime( $path );
344 $key = md5( $path . '|' . $mtime );
345 $cache = self::cache_path( $key, $type );
346
347 if ( ! file_exists( $cache ) ) {
348 $ok = self::minify_file( $path, $cache, $type );
349 if ( ! $ok ) {
350 return $src;
351 }
352 }
353
354 // Return a URL to the cached file. Built from known constants — never
355 // from str_replace on a filesystem path (which would assume the FS
356 // layout mirrors the URL layout).
357 return self::min_url() . '/' . $key . '.' . $type;
358 }
359
360 private static function minify_file( $source_path, $target_path, $type ) {
361 if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
362 return false;
363 }
364
365 // Path-traversal guard: refuse to write anywhere outside our cache
366 // dir, even if a malicious filter ever produced a poisoned key.
367 $cache_root = self::min_dir();
368 self::ensure_dir( $cache_root );
369 $real_root = realpath( $cache_root );
370 $real_dir = realpath( dirname( $target_path ) );
371 if ( ! $real_root || ! $real_dir || 0 !== strpos( $real_dir, $real_root ) ) {
372 return false;
373 }
374
375 try {
376 if ( 'css' === $type ) {
377 // Passing the TARGET path makes matthiasmullie/minify rebase every
378 // relative url(...) / @import against the minified file's location.
379 // Without it, a stylesheet moved from e.g.
380 // .../font-awesome/css/all.css to cache/xspeed/min/<key>.css keeps
381 // its original url(../webfonts/…) — which then resolves against the
382 // cache dir and 404s (missing FontAwesome/eicons/WooCommerce fonts).
383 $minifier = new \MatthiasMullie\Minify\CSS( $source_path );
384 $minified = $minifier->minify( $target_path );
385 return '' !== $minified && file_exists( $target_path );
386 }
387
388 $minifier = new \MatthiasMullie\Minify\JS( $source_path );
389 $minified = $minifier->minify();
390
391 // Sanity check: paren/brace/bracket/backtick balance must be preserved.
392 // matthiasmullie/minify can silently truncate mid-template-literal on
393 // complex modern JS — bail rather than ship a broken file.
394 // 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.
395 $source = file_get_contents( $source_path );
396 if ( false === $source || ! self::balanced( $source, $minified ) ) {
397 return false;
398 }
399
400 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context; minification runs on frontend page renders.
401 $bytes = file_put_contents( $target_path, $minified );
402 return false !== $bytes && file_exists( $target_path );
403 } catch ( \Throwable $e ) {
404 return false;
405 }
406 }
407
408 /**
409 * Cheap structural sanity check between source + minified bodies.
410 *
411 * Counts paired-delimiter tokens (parens, braces, brackets, backticks)
412 * in each and bails when the counts disagree — matthiasmullie/minify
413 * has been observed to silently truncate inside template literals on
414 * complex modern JS (see commit history), shipping a body that LOOKS
415 * minified but is structurally broken and crashes the page at parse.
416 *
417 * Backticks are paired (open + close = same token), so the count
418 * itself must match exactly. Strings inside the source can contain
419 * literal `{` / `}` / `[` / `]` that throw off the count by the same
420 * amount in both bodies (since they survive minification as-is), so
421 * the equality check is robust to that noise.
422 */
423 /**
424 * Minify the body of one captured inline block, or return it untouched.
425 *
426 * Only `<style>` and JavaScript `<script>` bodies are eligible:
427 *
428 * - `<pre>` / `<textarea>` — whitespace is rendered, never touch it.
429 * - `<script>` with a non-JS `type` — `application/ld+json`,
430 * `text/template`, `text/x-handlebars` and anything unrecognised are
431 * data or markup, not code. Minifying JSON-LD would corrupt structured
432 * data; minifying a template would eat the markup it holds. An unknown
433 * type is treated as non-JS on purpose: guessing wrong breaks the page,
434 * while skipping only forgoes a few bytes.
435 * - `<script src="...">` — the body is empty; the file path already goes
436 * through minify_file().
437 *
438 * Every result is checked with balanced(), the same structural guard the
439 * file path uses, so a body the library truncates is shipped as-is rather
440 * than broken. (#2)
441 *
442 * @param string $block Full matched tag, opening tag through closing tag.
443 * @param string $tag Lowercased tag name.
444 * @return string Minified block, or $block unchanged.
445 */
446 private static function minify_inline_block( string $block, string $tag ): string {
447 if ( 'style' !== $tag && 'script' !== $tag ) {
448 return $block; // pre / textarea — significant whitespace.
449 }
450 if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
451 return $block;
452 }
453
454 // Split into opening tag / body / closing tag. Anything that doesn't
455 // match this shape isn't something we should be rewriting.
456 if ( ! preg_match( '#^(<' . $tag . '\b[^>]*>)(.*)(</' . $tag . '\s*>)$#is', $block, $parts ) ) {
457 return $block;
458 }
459 list( , $open, $body, $close ) = $parts;
460
461 if ( '' === trim( $body ) ) {
462 return $block;
463 }
464
465 // The tag asked to be left alone (data-no-optimize / data-no-minify —
466 // the convention consent managers print on their config scripts). (#456)
467 if ( Minify_Filters::tag_opts_out( $open ) ) {
468 return $block;
469 }
470
471 // Refuse a body that is already structurally broken. balanced() only
472 // compares source against minified, so it passes when BOTH are equally
473 // unbalanced — `function x( {` minifies to `function x({`, same counts,
474 // guard satisfied, broken code reformatted. Rewriting a body we can't
475 // parse risks turning a page that happens to work into one that does
476 // not, for no gain. (#2 AC: a syntactically broken block is left
477 // untouched.)
478 if ( ! self::self_consistent( $body ) ) {
479 return $block;
480 }
481
482 if ( 'script' === $tag ) {
483 // An external script has no body worth minifying.
484 if ( preg_match( '#\bsrc\s*=#i', $open ) ) {
485 return $block;
486 }
487 // No type, or an explicitly JavaScript type, is code. Everything
488 // else is data/markup — see the docblock.
489 $js_types = array(
490 'text/javascript',
491 'application/javascript',
492 'application/ecmascript',
493 'text/ecmascript',
494 'module',
495 );
496 if ( preg_match( '#\btype\s*=\s*["\']?([^"\'\s>]+)#i', $open, $type_match ) ) {
497 if ( ! in_array( strtolower( trim( $type_match[1] ) ), $js_types, true ) ) {
498 return $block;
499 }
500 }
501 }
502
503 try {
504 $minifier = 'style' === $tag
505 ? new \MatthiasMullie\Minify\CSS()
506 : new \MatthiasMullie\Minify\JS();
507 $minifier->add( $body );
508 $minified = $minifier->minify();
509 } catch ( \Throwable $e ) {
510 return $block;
511 }
512
513 // A minifier that returns nothing for a non-empty body has failed, not
514 // succeeded — shipping '' would silently delete the rule set.
515 if ( ! is_string( $minified ) || '' === trim( $minified ) ) {
516 return $block;
517 }
518 if ( ! self::balanced( $body, $minified ) ) {
519 return $block;
520 }
521
522 return $open . $minified . $close;
523 }
524
525 /**
526 * Does a body's own paired delimiters balance?
527 *
528 * balanced() is a RELATIVE check — source against minified — so it cannot
529 * see input that was already broken: an unbalanced body minifies to an
530 * equally unbalanced one and the counts still agree. This is the absolute
531 * check, applied to the source alone before we touch it.
532 *
533 * Deliberately naive: it counts tokens without parsing, so a brace inside
534 * a string or comment skews it. That only ever makes it MORE conservative —
535 * a false negative skips minification, which costs bytes, while a false
536 * positive would ship broken code. (#2)
537 *
538 * @param string $body Inline block body.
539 */
540 private static function self_consistent( string $body ): bool {
541 $pairs = array(
542 '{' => '}',
543 '(' => ')',
544 '[' => ']',
545 );
546 foreach ( $pairs as $open => $close ) {
547 if ( substr_count( $body, $open ) !== substr_count( $body, $close ) ) {
548 return false;
549 }
550 }
551 // Backticks and quotes pair with themselves, so an odd count means an
552 // unterminated literal.
553 foreach ( array( '`' ) as $token ) {
554 if ( 0 !== substr_count( $body, $token ) % 2 ) {
555 return false;
556 }
557 }
558 return true;
559 }
560
561 private static function balanced( string $source, string $minified ): bool {
562 unset( $source );
563
564 // Judge the OUTPUT, not the difference between input and output.
565 //
566 // This used to compare token counts across the pair, on the stated
567 // assumption that "literal braces inside strings survive minification
568 // unchanged, so they cancel out". Comments do not: stripping them is
569 // the minifier's whole job, and every brace, bracket and backtick
570 // inside one disappears with it. So any file whose comments contain a
571 // delimiter — a commented-out block, a URL in a docblock, an SVG in a
572 // note — failed the check and silently shipped unminified.
573 //
574 // It is not a rare shape. EmbedPress's front.js counts 372 braces
575 // against 368, 61 brackets against 58 and 110 backticks against 102
576 // purely from comment removal, so 67 KB shipped raw where 46 KB was
577 // correct — and `node --check` confirms that rejected output parses
578 // fine. A guard that refuses valid work is not conservative, it is
579 // broken: it costs bytes on every request and reports nothing.
580 //
581 // What the guard is FOR still stands (#2): matthiasmullie/minify can
582 // truncate inside a template literal on complex modern JS and return a
583 // body that looks minified but is structurally broken. That failure is
584 // visible in the output alone — an unterminated literal leaves an odd
585 // backtick count and unmatched braces — which is exactly what
586 // self_consistent() measures, without the false positives.
587 return self::self_consistent( $minified );
588 }
589
590 /**
591 * Resolve a local asset URL to a filesystem path using a strict allowlist
592 * of "URL prefix → filesystem prefix" pairs registered with WordPress.
593 *
594 * We never assume `site_url()` maps to `ABSPATH` (the WordPress root can
595 * live above the document root in Bedrock-style installs, behind a proxy,
596 * or on multisite with mapped domains). Each branch resolves through a
597 * known WP API (plugins, themes, content, includes) and validates that
598 * `realpath()` of the result still lives under the expected base — so a
599 * crafted `..`-laden URL cannot escape into the filesystem.
600 *
601 * @param string $url Asset URL (may be protocol-relative or absolute).
602 * @return string|false Absolute filesystem path on success, false otherwise.
603 */
604 private static function url_to_path( $url ) {
605 if ( ! is_string( $url ) || '' === $url ) {
606 return false;
607 }
608
609 // Drop query string + fragment.
610 $clean = strtok( $url, '?#' );
611
612 // Normalise protocol-relative + scheme variants of the host so we
613 // match regardless of whether the asset URL came in over http/https.
614 $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
615 if ( 0 === strpos( $clean, '//' ) ) {
616 $clean = 'https:' . $clean;
617 }
618 if ( $site_host ) {
619 $asset_host = wp_parse_url( $clean, PHP_URL_HOST );
620 if ( $asset_host && $asset_host !== $site_host ) {
621 return false; // External asset — never touch.
622 }
623 }
624
625 $candidates = array(
626 array( plugins_url(), WP_PLUGIN_DIR ),
627 array( get_stylesheet_directory_uri(), get_stylesheet_directory() ),
628 array( get_template_directory_uri(), get_template_directory() ),
629 array( content_url(), WP_CONTENT_DIR ),
630 array( includes_url(), ABSPATH . WPINC ),
631 );
632
633 // The host check above normalised the HOST but not the SCHEME, and the
634 // prefix match below is a plain string compare — so an https asset URL
635 // never matched an http base and the file silently shipped unminified.
636 // That is not a corner case: WP_CONTENT_URL is derived from a stored
637 // option, `plugins_url()` from another, and a site moved to https
638 // without rewriting every row (or one behind a TLS-terminating proxy
639 // where `is_ssl()` reads false) serves https pages off http-rooted
640 // bases all day. Comparing scheme-less is the whole fix; the host
641 // equality test already did the security work of refusing anything
642 // off-site, and this runs after it.
643 $strip_scheme = static function ( string $value ): string {
644 return (string) preg_replace( '#^https?://#i', '//', $value );
645 };
646 $clean_match = $strip_scheme( $clean );
647
648 foreach ( $candidates as $pair ) {
649 list( $url_base, $path_base ) = $pair;
650 if ( ! $url_base || ! $path_base ) {
651 continue;
652 }
653 $url_base = rtrim( $url_base, '/' );
654 $base_match = $strip_scheme( $url_base );
655 if ( 0 !== strpos( $clean_match, $base_match . '/' ) && $clean_match !== $base_match ) {
656 continue;
657 }
658
659 // Slice the scheme-less pair, not the original. `https://…` and
660 // `http://…` differ by one byte, so an offset taken from the base
661 // as written would cut one character short of (or past) the path
662 // when the two schemes disagree — which is the case this fix
663 // exists for.
664 $relative = ltrim( substr( $clean_match, strlen( $base_match ) ), '/' );
665 $candidate = trailingslashit( $path_base ) . $relative;
666
667 $real_base = realpath( $path_base );
668 $real = realpath( $candidate );
669 if ( ! $real_base || ! $real ) {
670 return false;
671 }
672 // Guard against `..`-traversal: resolved path must stay inside
673 // the registered base.
674 if ( 0 !== strpos( $real, $real_base ) ) {
675 return false;
676 }
677 return $real;
678 }
679
680 return false;
681 }
682
683 private static function cache_path( $key, $type ) {
684 return self::min_dir() . '/' . $key . '.' . $type;
685 }
686
687 private static function ensure_dir( $dir ) {
688 if ( ! file_exists( $dir ) ) {
689 wp_mkdir_p( $dir );
690 Cache::write_silence( $dir );
691 }
692 }
693
694 /**
695 * Clear every minified / combined asset.
696 *
697 * @return int Files removed. Most callers are `add_action` callbacks and
698 * ignore it; `wp xspeed purge` reports it as a line item.
699 */
700 public static function purge_minified() {
701 return self::rmtree_files( self::min_dir() );
702 }
703
704 /**
705 * Recursively delete every file under $dir (and the emptied
706 * subdirectories), keeping $dir itself. The previous glob('$dir/*')
707 * was non-recursive and no-ops on directories, so combined assets in
708 * min/combined/ were never cleared — a purge left a stale
709 * combined-<hash>.css the regenerated page no longer referenced.
710 * (FBS-83114 / FBS-83116)
711 */
712 private static function rmtree_files( string $dir ): int {
713 if ( ! is_dir( $dir ) ) {
714 return 0;
715 }
716 $removed = 0;
717 foreach ( (array) glob( $dir . '/*' ) as $path ) {
718 if ( is_dir( $path ) ) {
719 $removed += self::rmtree_files( $path );
720 @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.
721 continue;
722 }
723 wp_delete_file( $path );
724 ++$removed;
725 }
726 return $removed;
727 }
728 }
729