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
← All changes | includes/class-minifier.php +599 -55 1.0.4 → 1.4.2 View file →
@@ -21,9 +21,9 @@
21 21 * Absolute path to the minified-cache directory. Always derived from
22 22 * XSPEED_CACHE_DIR (the plugin's own cache root) — never assembled from
23 23 * arbitrary URL fragments.
24 24 */
25 - private static function min_dir() {
25 + public static function min_dir() {
26 26 return trailingslashit( XSPEED_CACHE_DIR ) . self::MIN_SUBDIR;
27 27 }
28 28
29 29 /**
@@ -37,9 +37,16 @@
37 37 // XSPEED_CACHE_DIR lives under wp-content (defined in xspeed.php as
38 38 // WP_CONTENT_DIR . '/cache/xspeed'), so the URL is content_url() +
39 39 // the known suffix. We do not derive URLs from arbitrary filesystem
40 40 // paths anywhere in this plugin.
41 - return trailingslashit( content_url( 'cache/xspeed' ) ) . self::MIN_SUBDIR;
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 );
42 49 }
43 50
44 51 public function __construct() {
45 52 // Only run on the frontend — never minify wp-admin, AJAX, REST or cron
@@ -48,8 +55,17 @@
48 55 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
49 56 return;
50 57 }
51 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 +
52 68 // Settings now live in the per-module option (xspeed_module_minify),
53 69 // owned by XSpeed\Modules\Minify\MinifyModule. We read through
54 70 // Settings_Manager so schema-validated values are returned even
55 71 // if the option was hand-edited.
@@ -59,8 +75,15 @@
59 75 add_filter( 'style_loader_src', array( __CLASS__, 'rewrite_style' ), 10, 2 );
60 76 }
61 77 if ( ! empty( $opts['minify_js'] ) ) {
62 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 );
63 86 }
64 87
65 88 // Phase 4.1a — filter-only "smarter minifier" features. Each is
66 89 // gated on its own toggle so users can enable any subset.
@@ -75,28 +98,145 @@
75 98 // Delay applies a transform that's mutually exclusive with
76 99 // plain defer — when both are on, delay wins (the bootstrap
77 100 // will re-attach as a regular <script> on interaction).
78 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 );
79 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 );
80 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 + }
81 147 if ( ! empty( $opts['async_css'] ) ) {
82 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 );
83 160 }
84 161
85 - // Phase 4.1b — combine engine. Hook late so every plugin /
86 - // theme has finished enqueueing by the time we walk the queue.
87 - // Priority 999 mirrors the WP-Optimize / Rocket convention.
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 + */
88 180 if ( ! empty( $opts['combine_css'] ) ) {
89 - add_action( 'wp_enqueue_scripts', array( Asset_Combiner::class, 'combine_styles' ), 999 );
181 + add_filter( 'xspeed_cache_final_html', array( Css_Combine_Buffer::class, 'process' ), 5 );
182 + Css_Combine_Buffer::boot();
90 183 }
91 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.
92 189 add_action( 'wp_enqueue_scripts', array( Asset_Combiner::class, 'combine_scripts' ), 999 );
93 190 }
94 191 }
95 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 +
96 237 public static function minify_html( $html ) {
97 - $debug_skip = defined( 'WP_DEBUG' ) && WP_DEBUG;
98 - if ( apply_filters( 'xspeed_skip_minify', $debug_skip ) ) {
238 + if ( '' !== self::skip_reason() ) {
99 239 return $html;
100 240 }
101 241
102 242 $placeholders = array();
@@ -103,10 +243,18 @@
103 243 $pattern = '#<(pre|textarea|script|style)\b[^>]*>.*?</\1>#is';
104 244 $html = preg_replace_callback(
105 245 $pattern,
106 246 function ( $m ) use ( &$placeholders ) {
107 - $key = '__XSPEED_PH_' . count( $placeholders ) . '__';
108 - $placeholders[ $key ] = $m[0];
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] ) );
109 257 return $key;
110 258 },
111 259 $html
112 260 );
@@ -112,9 +260,34 @@
112 260 );
113 261
114 262 $html = preg_replace( '/<!--(?!\[if).*?-->/s', '', $html );
115 263 $html = preg_replace( '/\s+/', ' ', $html );
116 - $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 + );
117 290 $html = trim( $html );
118 291
119 292 foreach ( $placeholders as $key => $original ) {
120 293 $html = str_replace( $key, $original, $html );
@@ -128,10 +301,21 @@
128 301 return self::rewrite_asset( $src, 'css' );
129 302 }
130 303
131 304 public static function rewrite_script( $src, $handle ) {
132 - unset( $handle );
133 - return self::rewrite_asset( $src, 'js' );
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;
134 318 }
135 319
136 320 /**
137 321 * Replace a local CSS/JS URL with a cached, minified equivalent.
@@ -149,8 +333,23 @@
149 333 if ( false !== strpos( $src, '.min.' ) ) {
150 334 return $src;
151 335 }
152 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 +
153 352 // Resolve to a local path; bail if external or unresolvable.
154 353 $path = self::url_to_path( $src );
155 354 if ( ! $path || ! is_readable( $path ) ) {
156 355 return $src;
@@ -155,18 +354,37 @@
155 354 if ( ! $path || ! is_readable( $path ) ) {
156 355 return $src;
157 356 }
158 357
159 - // Build a cache filename keyed on path + mtime so edits invalidate.
160 - $mtime = filemtime( $path );
161 - $key = md5( $path . '|' . $mtime );
162 - $cache = self::cache_path( $key, $type );
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 + }
163 364
164 - if ( ! file_exists( $cache ) ) {
165 - $ok = self::minify_file( $path, $cache, $type );
166 - if ( ! $ok ) {
167 - return $src;
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 ) );
168 383 }
384 + );
385 + if ( null === $key ) {
386 + return $src;
169 387 }
170 388
171 389 // Return a URL to the cached file. Built from known constants — never
172 390 // from str_replace on a filesystem path (which would assume the FS
@@ -173,44 +391,110 @@
173 391 // layout mirrors the URL layout).
174 392 return self::min_url() . '/' . $key . '.' . $type;
175 393 }
176 394
177 - private static function minify_file( $source_path, $target_path, $type ) {
178 - if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
179 - return false;
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 );
180 426 }
427 + return self::$writable;
428 + }
181 429
182 - // Path-traversal guard: refuse to write anywhere outside our cache
183 - // dir, even if a malicious filter ever produced a poisoned key.
184 - $cache_root = self::min_dir();
185 - self::ensure_dir( $cache_root );
186 - $real_root = realpath( $cache_root );
187 - $real_dir = realpath( dirname( $target_path ) );
188 - if ( ! $real_root || ! $real_dir || 0 !== strpos( $real_dir, $real_root ) ) {
189 - return false;
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;
190 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 + }
191 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 +
192 467 try {
193 - $minifier = ( 'css' === $type )
194 - ? new \MatthiasMullie\Minify\CSS( $source_path )
195 - : new \MatthiasMullie\Minify\JS( $source_path );
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 + }
196 482
197 - $minified = $minifier->minify();
483 + $minifier = new \MatthiasMullie\Minify\JS( $source_path );
484 + $minified = (string) $minifier->minify();
198 485
199 486 // Sanity check: paren/brace/bracket/backtick balance must be preserved.
200 487 // matthiasmullie/minify can silently truncate mid-template-literal on
201 488 // complex modern JS — bail rather than ship a broken file.
202 - // 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.
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.
203 490 $source = file_get_contents( $source_path );
204 - if ( false === $source || ! self::balanced( $source, $minified ) ) {
205 - return false;
491 + if ( false === $source || '' === $minified || ! self::balanced( $source, $minified ) ) {
492 + return null;
206 493 }
207 -
208 - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context; minification runs on frontend page renders.
209 - $bytes = file_put_contents( $target_path, $minified );
210 - return false !== $bytes && file_exists( $target_path );
494 + return $minified;
211 495 } catch ( \Throwable $e ) {
212 - return false;
496 + return null;
213 497 }
214 498 }
215 499
216 500 /**
@@ -227,18 +511,175 @@
227 511 * literal `{` / `}` / `[` / `]` that throw off the count by the same
228 512 * amount in both bodies (since they survive minification as-is), so
229 513 * the equality check is robust to that noise.
230 514 */
231 - private static function balanced( string $source, string $minified ): bool {
232 - $pairs = array( '(', ')', '{', '}', '[', ']', '`' );
233 - foreach ( $pairs as $token ) {
234 - if ( substr_count( $source, $token ) !== substr_count( $minified, $token ) ) {
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 ) ) {
235 640 return false;
236 641 }
237 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 + }
238 650 return true;
239 651 }
240 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 +
241 682 /**
242 683 * Resolve a local asset URL to a filesystem path using a strict allowlist
243 684 * of "URL prefix → filesystem prefix" pairs registered with WordPress.
244 685 *
@@ -280,19 +721,40 @@
280 721 array( content_url(), WP_CONTENT_DIR ),
281 722 array( includes_url(), ABSPATH . WPINC ),
282 723 );
283 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 +
284 740 foreach ( $candidates as $pair ) {
285 741 list( $url_base, $path_base ) = $pair;
286 742 if ( ! $url_base || ! $path_base ) {
287 743 continue;
288 744 }
289 - $url_base = rtrim( $url_base, '/' );
290 - if ( 0 !== strpos( $clean, $url_base . '/' ) && $clean !== $url_base ) {
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 ) {
291 748 continue;
292 749 }
293 750
294 - $relative = ltrim( substr( $clean, strlen( $url_base ) ), '/' );
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 ) ), '/' );
295 757 $candidate = trailingslashit( $path_base ) . $relative;
296 758
297 759 $real_base = realpath( $path_base );
298 760 $real = realpath( $candidate );
@@ -320,14 +782,96 @@
320 782 Cache::write_silence( $dir );
321 783 }
322 784 }
323 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 + */
324 799 public static function purge_minified() {
325 - $dir = self::min_dir();
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 {
326 862 if ( ! is_dir( $dir ) ) {
327 - return;
863 + return 0;
328 864 }
329 - foreach ( glob( $dir . '/*' ) as $file ) {
330 - wp_delete_file( $file );
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;
331 874 }
875 + return $removed;
332 876 }
333 877 }