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

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

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