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

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

558 lines 21.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Asset_Combiner — concatenates enqueued local CSS / JS into a single
4 * combined file per type. Hooked from LegacyMinifier when the
5 * `combine_css` / `combine_js` toggles are on.
6 *
7 * Algorithm (CSS):
8 * 1. wp_enqueue_scripts @ 999 — walk WP_Styles->queue, partition into
9 * local + external. External (full http(s):// to other origins,
10 * data: URIs, protocol-relative pointing elsewhere) stay enqueued
11 * as-is; local handles get pulled out of the queue.
12 * 2. Build cache key = md5(JSON({handle => [src, mtime]})). When the
13 * combined file already exists for that key, skip generation.
14 * 3. Otherwise: read each source body, resolve recursive @import
15 * statements (depth-limited), rewrite url(...) paths to absolute,
16 * concat with a small `/* xspeed: HANDLE *​/` header per chunk for
17 * debug-traceability, write to XSPEED_CACHE_DIR/min/combined/.
18 * 4. Register the combined file as a single new handle
19 * `xspeed-combined-css` and re-add it to the queue. The original
20 * handles stay registered (so other plugins that look them up
21 * still find their metadata) but are pulled from the queue —
22 * they won't print <link> tags.
23 *
24 * JS path is the same, minus @import (no JS analogue) and url()
25 * rewriting (JS strings are too varied to safely rewrite). External
26 * + async + deferred scripts (deferred via WP_Scripts->add_data
27 * 'strategy' OR the script_loader_tag filter from Minify_Filters)
28 * stay un-combined.
29 *
30 * Cache lives in {$min_dir}/combined/ — separate from the per-file
31 * minify cache so purge can target them independently if needed.
32 *
33 * @package XSpeed
34 */
35
36 declare(strict_types=1);
37
38 namespace XSpeed;
39
40 defined( 'ABSPATH' ) || exit;
41
42 final class Asset_Combiner {
43
44 public const MAX_IMPORT_DEPTH = 3;
45
46 /**
47 * Path to the combine cache dir. Created on first write.
48 */
49 public static function cache_dir(): string {
50 return trailingslashit( XSPEED_CACHE_DIR ) . 'min/combined';
51 }
52
53 /**
54 * URL prefix matching cache_dir(). Built from content_url, not by
55 * string-replacing filesystem paths (see class-minifier.php for the
56 * same rationale).
57 *
58 * The scheme is forced to match the page's — `content_url()` derives
59 * its scheme from `is_ssl()`, which returns false behind a TLS-
60 * terminating reverse proxy / load balancer (common on managed hosts),
61 * so it can hand back an `http://` URL on an `https` page. The browser
62 * then blocks the combined stylesheet as mixed content and the whole
63 * page renders unstyled. Re-scheme the URL to the site's actual scheme
64 * so the <link> always matches the page. (FBS-83633)
65 */
66 public static function cache_url(): string {
67 $url = trailingslashit( content_url( 'cache/xspeed' ) ) . 'min/combined';
68 // Match the site's registered scheme (home_url), NOT is_ssl() —
69 // which set_url_scheme() would consult with no explicit scheme, and
70 // which is the very signal that misreports behind a proxy.
71 $scheme = wp_parse_url( home_url(), PHP_URL_SCHEME ) ?: 'https';
72 return set_url_scheme( $url, $scheme );
73 }
74
75 /**
76 * Combine local enqueued styles into one file.
77 *
78 * @deprecated Superseded by Css_Combine_Buffer, which combines the
79 * finished HTML instead of the enqueue queue. This path is no longer
80 * hooked: whatever it wrote at priority 999, WordPress edited afterwards —
81 * core's wp_maybe_inline_styles() inlines any queued handle with a `path`
82 * and blanks its src, which discarded the combined URL and took the sheets
83 * this method had already blanked with it. See Css_Combine_Buffer's header
84 * for the live trace. (#195)
85 *
86 * Kept callable because tests/e2e/48- and 49- drive it directly to pin the
87 * FBS-83114/83116/83633/83653 regressions. Remove once those specs are
88 * ported onto the buffer engine.
89 */
90 public static function combine_styles(): void {
91 global $wp_styles;
92 if ( ! $wp_styles instanceof \WP_Styles || empty( $wp_styles->queue ) ) {
93 return;
94 }
95
96 // Group combinable handles by media type. Historically every sheet
97 // whose media wasn't all/screen was dropped from combining — but on
98 // page-builder sites (Elementor + Essential Addons + BetterDocs) a large
99 // share of the stylesheets carry responsive/print media, so dropping
100 // them starved the `all` bucket below the 2-handle floor and the whole
101 // combine step silently no-op'd (the page shipped 60 separate <link>s
102 // even with combine_css ON). Instead we bucket PER media type and emit
103 // one combined file per group with the correct `media` attribute, so
104 // nothing is dropped and the combinable majority always merges. (FBS-83653)
105 $buckets = self::collect_local_handles( $wp_styles );
106 foreach ( $buckets as $media => $bucket ) {
107 if ( count( $bucket ) < 2 ) {
108 continue; // nothing to gain from combining a single file in this group.
109 }
110 self::combine_media_group( $wp_styles, $media, $bucket );
111 }
112 }
113
114 /**
115 * Combine one media group's handles into a single stylesheet and wire it
116 * onto the group's carrier handle.
117 *
118 * @param string $media The media attribute for this group ('all', 'print', …).
119 * @param array<string,array<mixed>> $bucket handle => info map.
120 */
121 private static function combine_media_group( \WP_Styles $wp_styles, string $media, array $bucket ): void {
122 $key = self::cache_key( $bucket );
123 $dir = self::cache_dir();
124 $out_file = $dir . '/combined-' . $key . '.css';
125 $out_url = self::cache_url() . '/combined-' . $key . '.css';
126
127 if ( ! file_exists( $out_file ) ) {
128 self::ensure_dir( $dir );
129 $contents = '';
130 foreach ( $bucket as $handle => $info ) {
131 $body = self::read_local_file( $info['path'] );
132 if ( '' === $body ) {
133 continue;
134 }
135 $body = self::resolve_imports( $body, $info['url'], 0 );
136 $body = self::rewrite_url_paths( $body, $info['url'] );
137 $contents .= "/* xspeed: $handle */\n" . $body . "\n";
138 }
139 // Atomic write: file_put_contents with LOCK_EX so concurrent
140 // renders don't race.
141 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context, unavailable on frontend.
142 file_put_contents( $out_file, $contents, LOCK_EX );
143 } else {
144 self::mark_in_use( $out_file );
145 }
146
147 // Point the FIRST combined handle at the combined file and blank the
148 // rest. This is deliberate — we do NOT enqueue a fresh
149 // `xspeed-combined-css` handle, because WordPress would print it at
150 // the tail of the queue, AFTER any non-combinable stylesheets
151 // (media-query sheets like woocommerce-smallscreen, wc-blocks-*,
152 // external fonts) that originally sat between/after the combined
153 // handles. That reorders the cascade and breaks layout — e.g. the
154 // WooCommerce/Astra grid + sidebar widths get overridden by rules
155 // that should have lower priority. By reusing the first combined
156 // handle's own queue slot for the combined <link>, the merged CSS
157 // prints exactly where the earliest source stylesheet used to be,
158 // preserving cascade order. (FBS-83114/83116)
159 //
160 // The remaining combined handles keep their registration + queue
161 // membership (src blanked) so their wp_add_inline_style() data still
162 // prints — WordPress only emits inline data for handles still in the
163 // print queue, and some themes (Astra) attach that dynamic CSS on a
164 // hook LATER than this priority-999 pass, so we can't harvest it now.
165 // Dropping it is what made "combine CSS break the site".
166 // The carrier is the FIRST bucket handle that WordPress hasn't already
167 // printed. A block theme (Twenty Twenty-Five, etc.) prints some of its
168 // per-block style handles BEFORE this priority-999 pass, marking them
169 // `done`; pointing a done handle at the combined file emits no <link>
170 // at all — the merged CSS silently vanishes and the whole site renders
171 // unstyled. Skipping done handles guarantees the carrier still prints.
172 // If every bucket handle is already done, register a dedicated combined
173 // handle so the CSS is never lost (cascade tail is far better than no
174 // styles). (FBS-83633)
175 $done = (array) $wp_styles->done;
176 $carrier_set = false;
177 foreach ( $bucket as $handle => $info ) {
178 $reg = $wp_styles->registered[ $handle ] ?? null;
179 if ( ! $reg instanceof \_WP_Dependency ) {
180 continue;
181 }
182 if ( ! $carrier_set && ! in_array( $handle, $done, true ) ) {
183 // Carry the combined file on this (not-yet-printed) handle's slot.
184 $reg->src = $out_url;
185 $reg->ver = $key;
186 $reg->args = $media;
187 $carrier_set = true;
188 } else {
189 // Inline-only carrier: no <link>, keep inline CSS printable.
190 $reg->src = false;
191 $reg->ver = null;
192 }
193 }
194
195 // Fallback: every bucket handle was already printed, so no carrier
196 // could emit the combined <link>. Register + enqueue a dedicated
197 // handle so the merged CSS still loads (appended at the tail — not
198 // cascade-ideal, but infinitely better than a fully unstyled page).
199 if ( ! $carrier_set ) {
200 $combined_handle = 'xspeed-combined-css-' . $media;
201 wp_register_style( $combined_handle, $out_url, array(), $key, $media );
202 wp_enqueue_style( $combined_handle );
203 }
204 }
205
206 /**
207 * Combine local enqueued scripts into one file.
208 */
209 public static function combine_scripts(): void {
210 global $wp_scripts;
211 if ( ! $wp_scripts instanceof \WP_Scripts || empty( $wp_scripts->queue ) ) {
212 return;
213 }
214
215 $bucket = self::collect_local_script_handles( $wp_scripts );
216 if ( count( $bucket ) < 2 ) {
217 return;
218 }
219
220 $key = self::cache_key( $bucket );
221 $dir = self::cache_dir();
222 $out_file = $dir . '/combined-' . $key . '.js';
223 $out_url = self::cache_url() . '/combined-' . $key . '.js';
224
225 if ( ! file_exists( $out_file ) ) {
226 self::ensure_dir( $dir );
227 $contents = '';
228 foreach ( $bucket as $handle => $info ) {
229 $body = self::read_local_file( $info['path'] );
230 if ( '' === $body ) {
231 continue;
232 }
233 $contents .= "/* xspeed: $handle */\n" . $body . "\n;\n";
234 }
235 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem unavailable on frontend.
236 file_put_contents( $out_file, $contents, LOCK_EX );
237 } else {
238 self::mark_in_use( $out_file );
239 }
240
241 foreach ( $bucket as $handle => $info ) {
242 $wp_scripts->dequeue( $handle );
243 }
244 $combined_handle = 'xspeed-combined-js';
245 wp_register_script( $combined_handle, $out_url, array(), $key, true );
246 wp_enqueue_script( $combined_handle );
247 }
248
249 /**
250 * Walk WP_Styles->queue, return the handles whose src is a local file we
251 * can safely combine, grouped BY media type so each media gets its own
252 * combined file. Shape:
253 * [ media => [ handle => [ 'url' => …, 'path' => …, 'mtime' => int, 'src' => … ] ] ].
254 * '' and 'screen' media fold into the 'all' group.
255 */
256 private static function collect_local_handles( \WP_Styles $wp_styles ): array {
257 $groups = array();
258 foreach ( $wp_styles->queue as $handle ) {
259 if ( ! isset( $wp_styles->registered[ $handle ] ) ) {
260 continue;
261 }
262 $reg = $wp_styles->registered[ $handle ];
263 $src = (string) ( $reg->src ?? '' );
264 if ( '' === $src ) {
265 continue;
266 }
267 // Leave WordPress core block styles alone. Block themes (Twenty
268 // Twenty-*, and any FSE theme) load per-block CSS conditionally and
269 // print/track these handles through their own separated-styles
270 // pipeline, often BEFORE this pass. Pulling them into a combined
271 // file fights that pipeline and leaves the page unstyled. These are
272 // already tiny + conditionally loaded, so there's little to gain.
273 // Matches `wp-block-*` handles and any src under wp-includes/blocks/
274 // or the block-library dist dir. (FBS-83633)
275 if (
276 0 === strpos( $handle, 'wp-block-' )
277 || false !== strpos( $src, '/wp-includes/blocks/' )
278 || false !== strpos( $src, '/block-library/' )
279 ) {
280 continue;
281 }
282 $abs = self::to_absolute_url( $src );
283 $info = self::local_info( $abs );
284 if ( null === $info ) {
285 continue; // external or unresolvable — leave in queue.
286 }
287 // Bucket by media type. '' and 'screen' fold into 'all' (both mean
288 // "the on-screen document"); every other media value (print,
289 // max-width queries, …) gets its own group so we can emit one
290 // combined file per media with the right attribute — instead of
291 // dropping non-'all' sheets and starving the combinable bucket on
292 // builder sites. (FBS-83653)
293 $media = (string) ( $reg->args ?? 'all' );
294 if ( '' === $media || 'screen' === $media ) {
295 $media = 'all';
296 }
297 $groups[ $media ][ $handle ] = $info + array( 'src' => $src );
298 }
299 return $groups;
300 }
301
302 private static function collect_local_script_handles( \WP_Scripts $wp_scripts ): array {
303 $out = array();
304 foreach ( $wp_scripts->queue as $handle ) {
305 if ( ! isset( $wp_scripts->registered[ $handle ] ) ) {
306 continue;
307 }
308 $reg = $wp_scripts->registered[ $handle ];
309 $src = (string) ( $reg->src ?? '' );
310 if ( '' === $src ) {
311 continue;
312 }
313 // Skip scripts that carry inline-after data (they expect
314 // to run at their original spot).
315 if ( ! empty( $reg->extra['after'] ) || ! empty( $reg->extra['before'] ) || ! empty( $reg->extra['data'] ) ) {
316 continue;
317 }
318 // Skip async / defer-via-strategy.
319 $strategy = $reg->extra['strategy'] ?? '';
320 if ( 'async' === $strategy || 'defer' === $strategy ) {
321 continue;
322 }
323 $abs = self::to_absolute_url( $src );
324 $info = self::local_info( $abs );
325 if ( null === $info ) {
326 continue;
327 }
328 $out[ $handle ] = $info + array( 'src' => $src );
329 }
330 return $out;
331 }
332
333 /**
334 * Convert a possibly-relative `src` into an absolute URL.
335 *
336 * Public because Css_Combine_Buffer resolves the same URLs from parsed
337 * HTML rather than from the enqueue queue; the logic is identical and a
338 * second copy would drift. (#195)
339 */
340 public static function to_absolute_url( string $src ): string {
341 if ( '' === $src ) {
342 return '';
343 }
344 if ( 0 === strpos( $src, '//' ) ) {
345 return ( is_ssl() ? 'https:' : 'http:' ) . $src;
346 }
347 if ( 0 === strpos( $src, '/' ) ) {
348 $home = home_url();
349 $home = (string) preg_replace( '#/$#', '', $home );
350 return $home . $src;
351 }
352 return $src;
353 }
354
355 /**
356 * Resolve an absolute URL to a local filesystem path + mtime, or
357 * return null if the URL isn't on this site / outside web root.
358 *
359 * @return array{url:string,path:string,mtime:int}|null
360 */
361 public static function local_info( string $url ): ?array {
362 if ( '' === $url ) {
363 return null;
364 }
365 $home = home_url();
366 if ( 0 !== strpos( $url, $home ) ) {
367 return null;
368 }
369 // Strip query / fragment for filesystem lookup; keep them in
370 // the URL we hash against.
371 $clean = strtok( $url, '?' );
372 if ( ! is_string( $clean ) ) {
373 return null;
374 }
375 $path = ABSPATH . ltrim( str_replace( $home, '', $clean ), '/' );
376 if ( ! file_exists( $path ) || ! is_readable( $path ) ) {
377 return null;
378 }
379 return array(
380 'url' => $url,
381 'path' => $path,
382 'mtime' => (int) filemtime( $path ),
383 );
384 }
385
386 private static function cache_key( array $bucket ): string {
387 $signature = array();
388 foreach ( $bucket as $handle => $info ) {
389 $signature[ $handle ] = array( $info['src'] ?? '', $info['mtime'] ?? 0 );
390 }
391 return md5( wp_json_encode( $signature ) );
392 }
393
394 private static function read_local_file( string $path ): string {
395 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem unavailable on frontend; we already validated existence + readability.
396 $body = file_get_contents( $path );
397 return is_string( $body ) ? $body : '';
398 }
399
400 /**
401 * Recursively inline `@import url(...)` (and `@import "...";`)
402 * statements. Cycles detected via depth limit; cross-origin imports
403 * are left alone.
404 */
405 public static function resolve_imports( string $css, string $base_url, int $depth ): string {
406 if ( $depth > self::MAX_IMPORT_DEPTH ) {
407 return $css;
408 }
409 return (string) preg_replace_callback(
410 '#@import\s+(?:url\s*\(\s*)?["\']?([^"\')]+)["\']?\s*\)?\s*([^;]*);#i',
411 static function ( $m ) use ( $base_url, $depth ) {
412 $target = trim( (string) $m[1] );
413 $media = trim( (string) $m[2] );
414 $abs = self::resolve_relative( $target, $base_url );
415 $info = self::local_info( $abs );
416 if ( null === $info ) {
417 return $m[0]; // external or unresolvable; leave as-is.
418 }
419 $body = self::read_local_file( $info['path'] );
420 if ( '' === $body ) {
421 return $m[0];
422 }
423 $body = self::rewrite_url_paths( $body, $info['url'] );
424 $body = self::resolve_imports( $body, $info['url'], $depth + 1 );
425 if ( '' !== $media ) {
426 return '@media ' . $media . " {\n" . $body . "\n}\n";
427 }
428 return $body;
429 },
430 $css
431 );
432 }
433
434 /**
435 * Rewrite every `url(...)` whose argument is a relative path so it
436 * becomes absolute (resolved against the source file's URL). The
437 * combined file lives at a different location, so relative paths
438 * would otherwise break.
439 *
440 * Skips: absolute URLs (http://, https://, //), data: URIs,
441 * `#fragment-only`, blob:, javascript: (which shouldn't appear in
442 * CSS but won't crash).
443 */
444 public static function rewrite_url_paths( string $css, string $base_url ): string {
445 return (string) preg_replace_callback(
446 '#url\(\s*(["\']?)([^"\')]+)\1\s*\)#i',
447 static function ( $m ) use ( $base_url ) {
448 $quote = $m[1];
449 $raw = trim( (string) $m[2] );
450 if ( '' === $raw ) {
451 return $m[0];
452 }
453 if (
454 0 === strpos( $raw, 'data:' )
455 || 0 === strpos( $raw, 'blob:' )
456 || 0 === strpos( $raw, '#' )
457 || 0 === strpos( $raw, 'http://' )
458 || 0 === strpos( $raw, 'https://' )
459 || 0 === strpos( $raw, '//' )
460 ) {
461 return $m[0];
462 }
463 $abs = self::resolve_relative( $raw, $base_url );
464 return 'url(' . $quote . $abs . $quote . ')';
465 },
466 $css
467 );
468 }
469
470 /**
471 * Resolve a relative URL (no scheme, no leading /) against a base
472 * URL. Public so tests can exercise it directly.
473 */
474 public static function resolve_relative( string $target, string $base_url ): string {
475 // Order matters — '//' is a prefix of '/' so the protocol-relative
476 // check must happen BEFORE the leading-slash anchor.
477 if ( 0 === strpos( $target, '//' ) ) {
478 return ( is_ssl() ? 'https:' : 'http:' ) . $target;
479 }
480 if ( 0 === strpos( $target, '/' ) ) {
481 $parts = wp_parse_url( $base_url );
482 if ( ! is_array( $parts ) ) {
483 return $target;
484 }
485 $origin = ( $parts['scheme'] ?? 'http' ) . '://' . ( $parts['host'] ?? '' );
486 if ( isset( $parts['port'] ) ) {
487 $origin .= ':' . $parts['port'];
488 }
489 return $origin . $target;
490 }
491 if ( 0 === strpos( $target, 'http://' ) || 0 === strpos( $target, 'https://' ) ) {
492 return $target;
493 }
494 // Relative. Strip filename from base, resolve.
495 $base_path = (string) wp_parse_url( $base_url, PHP_URL_PATH );
496 $base_dir = rtrim( str_replace( basename( $base_path ), '', $base_path ), '/' );
497 $parts = wp_parse_url( $base_url );
498 $origin = ( $parts['scheme'] ?? 'http' ) . '://' . ( $parts['host'] ?? '' );
499 if ( isset( $parts['port'] ) ) {
500 $origin .= ':' . $parts['port'];
501 }
502 // Collapse ../
503 $joined = $base_dir . '/' . $target;
504 $segments = array();
505 foreach ( explode( '/', $joined ) as $seg ) {
506 if ( '' === $seg || '.' === $seg ) {
507 continue;
508 }
509 if ( '..' === $seg ) {
510 array_pop( $segments );
511 continue;
512 }
513 $segments[] = $seg;
514 }
515 return $origin . '/' . implode( '/', $segments );
516 }
517
518 private static function ensure_dir( string $dir ): void {
519 if ( ! is_dir( $dir ) ) {
520 wp_mkdir_p( $dir );
521 }
522 $silence = $dir . '/index.php';
523 if ( ! file_exists( $silence ) ) {
524 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- bootstrap-time helper, WP_Filesystem unavailable.
525 file_put_contents( $silence, "<?php\n// Silence is golden.\n" );
526 }
527 }
528
529 /**
530 * Record that a combined file is STILL IN USE, by refreshing its mtime.
531 *
532 * The combiner only writes a file when it does not already exist, so a
533 * stylesheet in continuous use kept its original mtime forever. Cache GC
534 * collects `min/` on a 30-day max-age measured from mtime, so it read a
535 * file served on every page load as "untouched for a month" and deleted
536 * it — leaving every cached page pointing at a 404. (#190)
537 *
538 * This is the cheap half of the fix: it keeps a live asset LOOKING young,
539 * which is what the age heuristic needed all along. The real guarantee is
540 * `Cache_GC`'s reachability check — an asset a cached page references is
541 * never collected whatever its age — because mtime cannot help an asset
542 * whose page is a static HIT that never runs PHP.
543 *
544 * Rate-limited to once a day per file: this runs on every render, and a
545 * touch() per request would be pointless filesystem traffic when the
546 * threshold is measured in days.
547 */
548 private static function mark_in_use( string $file ): void {
549 $now = time();
550 $mtime = @filemtime( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a racing purge can unlink between the exists check and here; false is handled.
551 if ( false === $mtime || ( $now - $mtime ) < DAY_IN_SECONDS ) {
552 return;
553 }
554 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- refreshing our own cache file's mtime; WP_Filesystem has no touch() and is unavailable on the frontend.
555 @touch( $file, $now ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort liveness hint; a failure is not worth an error on a page render.
556 }
557 }
558