PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 1.4.1 1.4.0 1.3.7 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 All 36 releases
xspeed / includes / class-minifier.php

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

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