PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.4
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 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.4, at includes/class-asset-combiner.php

509 lines 18.6 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 public static function combine_styles(): void {
79 global $wp_styles;
80 if ( ! $wp_styles instanceof \WP_Styles || empty( $wp_styles->queue ) ) {
81 return;
82 }
83
84 // Group combinable handles by media type. Historically every sheet
85 // whose media wasn't all/screen was dropped from combining — but on
86 // page-builder sites (Elementor + Essential Addons + BetterDocs) a large
87 // share of the stylesheets carry responsive/print media, so dropping
88 // them starved the `all` bucket below the 2-handle floor and the whole
89 // combine step silently no-op'd (the page shipped 60 separate <link>s
90 // even with combine_css ON). Instead we bucket PER media type and emit
91 // one combined file per group with the correct `media` attribute, so
92 // nothing is dropped and the combinable majority always merges. (FBS-83653)
93 $buckets = self::collect_local_handles( $wp_styles );
94 foreach ( $buckets as $media => $bucket ) {
95 if ( count( $bucket ) < 2 ) {
96 continue; // nothing to gain from combining a single file in this group.
97 }
98 self::combine_media_group( $wp_styles, $media, $bucket );
99 }
100 }
101
102 /**
103 * Combine one media group's handles into a single stylesheet and wire it
104 * onto the group's carrier handle.
105 *
106 * @param string $media The media attribute for this group ('all', 'print', …).
107 * @param array<string,array<mixed>> $bucket handle => info map.
108 */
109 private static function combine_media_group( \WP_Styles $wp_styles, string $media, array $bucket ): void {
110 $key = self::cache_key( $bucket );
111 $dir = self::cache_dir();
112 $out_file = $dir . '/combined-' . $key . '.css';
113 $out_url = self::cache_url() . '/combined-' . $key . '.css';
114
115 if ( ! file_exists( $out_file ) ) {
116 self::ensure_dir( $dir );
117 $contents = '';
118 foreach ( $bucket as $handle => $info ) {
119 $body = self::read_local_file( $info['path'] );
120 if ( '' === $body ) {
121 continue;
122 }
123 $body = self::resolve_imports( $body, $info['url'], 0 );
124 $body = self::rewrite_url_paths( $body, $info['url'] );
125 $contents .= "/* xspeed: $handle */\n" . $body . "\n";
126 }
127 // Atomic write: file_put_contents with LOCK_EX so concurrent
128 // renders don't race.
129 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context, unavailable on frontend.
130 file_put_contents( $out_file, $contents, LOCK_EX );
131 }
132
133 // Point the FIRST combined handle at the combined file and blank the
134 // rest. This is deliberate — we do NOT enqueue a fresh
135 // `xspeed-combined-css` handle, because WordPress would print it at
136 // the tail of the queue, AFTER any non-combinable stylesheets
137 // (media-query sheets like woocommerce-smallscreen, wc-blocks-*,
138 // external fonts) that originally sat between/after the combined
139 // handles. That reorders the cascade and breaks layout — e.g. the
140 // WooCommerce/Astra grid + sidebar widths get overridden by rules
141 // that should have lower priority. By reusing the first combined
142 // handle's own queue slot for the combined <link>, the merged CSS
143 // prints exactly where the earliest source stylesheet used to be,
144 // preserving cascade order. (FBS-83114/83116)
145 //
146 // The remaining combined handles keep their registration + queue
147 // membership (src blanked) so their wp_add_inline_style() data still
148 // prints — WordPress only emits inline data for handles still in the
149 // print queue, and some themes (Astra) attach that dynamic CSS on a
150 // hook LATER than this priority-999 pass, so we can't harvest it now.
151 // Dropping it is what made "combine CSS break the site".
152 // The carrier is the FIRST bucket handle that WordPress hasn't already
153 // printed. A block theme (Twenty Twenty-Five, etc.) prints some of its
154 // per-block style handles BEFORE this priority-999 pass, marking them
155 // `done`; pointing a done handle at the combined file emits no <link>
156 // at all — the merged CSS silently vanishes and the whole site renders
157 // unstyled. Skipping done handles guarantees the carrier still prints.
158 // If every bucket handle is already done, register a dedicated combined
159 // handle so the CSS is never lost (cascade tail is far better than no
160 // styles). (FBS-83633)
161 $done = (array) $wp_styles->done;
162 $carrier_set = false;
163 foreach ( $bucket as $handle => $info ) {
164 $reg = $wp_styles->registered[ $handle ] ?? null;
165 if ( ! $reg instanceof \_WP_Dependency ) {
166 continue;
167 }
168 if ( ! $carrier_set && ! in_array( $handle, $done, true ) ) {
169 // Carry the combined file on this (not-yet-printed) handle's slot.
170 $reg->src = $out_url;
171 $reg->ver = $key;
172 $reg->args = $media;
173 $carrier_set = true;
174 } else {
175 // Inline-only carrier: no <link>, keep inline CSS printable.
176 $reg->src = false;
177 $reg->ver = null;
178 }
179 }
180
181 // Fallback: every bucket handle was already printed, so no carrier
182 // could emit the combined <link>. Register + enqueue a dedicated
183 // handle so the merged CSS still loads (appended at the tail — not
184 // cascade-ideal, but infinitely better than a fully unstyled page).
185 if ( ! $carrier_set ) {
186 $combined_handle = 'xspeed-combined-css-' . $media;
187 wp_register_style( $combined_handle, $out_url, array(), $key, $media );
188 wp_enqueue_style( $combined_handle );
189 }
190 }
191
192 /**
193 * Combine local enqueued scripts into one file.
194 */
195 public static function combine_scripts(): void {
196 global $wp_scripts;
197 if ( ! $wp_scripts instanceof \WP_Scripts || empty( $wp_scripts->queue ) ) {
198 return;
199 }
200
201 $bucket = self::collect_local_script_handles( $wp_scripts );
202 if ( count( $bucket ) < 2 ) {
203 return;
204 }
205
206 $key = self::cache_key( $bucket );
207 $dir = self::cache_dir();
208 $out_file = $dir . '/combined-' . $key . '.js';
209 $out_url = self::cache_url() . '/combined-' . $key . '.js';
210
211 if ( ! file_exists( $out_file ) ) {
212 self::ensure_dir( $dir );
213 $contents = '';
214 foreach ( $bucket as $handle => $info ) {
215 $body = self::read_local_file( $info['path'] );
216 if ( '' === $body ) {
217 continue;
218 }
219 $contents .= "/* xspeed: $handle */\n" . $body . "\n;\n";
220 }
221 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem unavailable on frontend.
222 file_put_contents( $out_file, $contents, LOCK_EX );
223 }
224
225 foreach ( $bucket as $handle => $info ) {
226 $wp_scripts->dequeue( $handle );
227 }
228 $combined_handle = 'xspeed-combined-js';
229 wp_register_script( $combined_handle, $out_url, array(), $key, true );
230 wp_enqueue_script( $combined_handle );
231 }
232
233 /**
234 * Walk WP_Styles->queue, return the handles whose src is a local file we
235 * can safely combine, grouped BY media type so each media gets its own
236 * combined file. Shape:
237 * [ media => [ handle => [ 'url' => …, 'path' => …, 'mtime' => int, 'src' => … ] ] ].
238 * '' and 'screen' media fold into the 'all' group.
239 */
240 private static function collect_local_handles( \WP_Styles $wp_styles ): array {
241 $groups = array();
242 foreach ( $wp_styles->queue as $handle ) {
243 if ( ! isset( $wp_styles->registered[ $handle ] ) ) {
244 continue;
245 }
246 $reg = $wp_styles->registered[ $handle ];
247 $src = (string) ( $reg->src ?? '' );
248 if ( '' === $src ) {
249 continue;
250 }
251 // Leave WordPress core block styles alone. Block themes (Twenty
252 // Twenty-*, and any FSE theme) load per-block CSS conditionally and
253 // print/track these handles through their own separated-styles
254 // pipeline, often BEFORE this pass. Pulling them into a combined
255 // file fights that pipeline and leaves the page unstyled. These are
256 // already tiny + conditionally loaded, so there's little to gain.
257 // Matches `wp-block-*` handles and any src under wp-includes/blocks/
258 // or the block-library dist dir. (FBS-83633)
259 if (
260 0 === strpos( $handle, 'wp-block-' )
261 || false !== strpos( $src, '/wp-includes/blocks/' )
262 || false !== strpos( $src, '/block-library/' )
263 ) {
264 continue;
265 }
266 $abs = self::to_absolute_url( $src );
267 $info = self::local_info( $abs );
268 if ( null === $info ) {
269 continue; // external or unresolvable — leave in queue.
270 }
271 // Bucket by media type. '' and 'screen' fold into 'all' (both mean
272 // "the on-screen document"); every other media value (print,
273 // max-width queries, …) gets its own group so we can emit one
274 // combined file per media with the right attribute — instead of
275 // dropping non-'all' sheets and starving the combinable bucket on
276 // builder sites. (FBS-83653)
277 $media = (string) ( $reg->args ?? 'all' );
278 if ( '' === $media || 'screen' === $media ) {
279 $media = 'all';
280 }
281 $groups[ $media ][ $handle ] = $info + array( 'src' => $src );
282 }
283 return $groups;
284 }
285
286 private static function collect_local_script_handles( \WP_Scripts $wp_scripts ): array {
287 $out = array();
288 foreach ( $wp_scripts->queue as $handle ) {
289 if ( ! isset( $wp_scripts->registered[ $handle ] ) ) {
290 continue;
291 }
292 $reg = $wp_scripts->registered[ $handle ];
293 $src = (string) ( $reg->src ?? '' );
294 if ( '' === $src ) {
295 continue;
296 }
297 // Skip scripts that carry inline-after data (they expect
298 // to run at their original spot).
299 if ( ! empty( $reg->extra['after'] ) || ! empty( $reg->extra['before'] ) || ! empty( $reg->extra['data'] ) ) {
300 continue;
301 }
302 // Skip async / defer-via-strategy.
303 $strategy = $reg->extra['strategy'] ?? '';
304 if ( 'async' === $strategy || 'defer' === $strategy ) {
305 continue;
306 }
307 $abs = self::to_absolute_url( $src );
308 $info = self::local_info( $abs );
309 if ( null === $info ) {
310 continue;
311 }
312 $out[ $handle ] = $info + array( 'src' => $src );
313 }
314 return $out;
315 }
316
317 /**
318 * Convert a possibly-relative `src` into an absolute URL.
319 */
320 private static function to_absolute_url( string $src ): string {
321 if ( '' === $src ) {
322 return '';
323 }
324 if ( 0 === strpos( $src, '//' ) ) {
325 return ( is_ssl() ? 'https:' : 'http:' ) . $src;
326 }
327 if ( 0 === strpos( $src, '/' ) ) {
328 $home = home_url();
329 $home = (string) preg_replace( '#/$#', '', $home );
330 return $home . $src;
331 }
332 return $src;
333 }
334
335 /**
336 * Resolve an absolute URL to a local filesystem path + mtime, or
337 * return null if the URL isn't on this site / outside web root.
338 *
339 * @return array{url:string,path:string,mtime:int}|null
340 */
341 public static function local_info( string $url ): ?array {
342 if ( '' === $url ) {
343 return null;
344 }
345 $home = home_url();
346 if ( 0 !== strpos( $url, $home ) ) {
347 return null;
348 }
349 // Strip query / fragment for filesystem lookup; keep them in
350 // the URL we hash against.
351 $clean = strtok( $url, '?' );
352 if ( ! is_string( $clean ) ) {
353 return null;
354 }
355 $path = ABSPATH . ltrim( str_replace( $home, '', $clean ), '/' );
356 if ( ! file_exists( $path ) || ! is_readable( $path ) ) {
357 return null;
358 }
359 return array(
360 'url' => $url,
361 'path' => $path,
362 'mtime' => (int) filemtime( $path ),
363 );
364 }
365
366 private static function cache_key( array $bucket ): string {
367 $signature = array();
368 foreach ( $bucket as $handle => $info ) {
369 $signature[ $handle ] = array( $info['src'] ?? '', $info['mtime'] ?? 0 );
370 }
371 return md5( wp_json_encode( $signature ) );
372 }
373
374 private static function read_local_file( string $path ): string {
375 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem unavailable on frontend; we already validated existence + readability.
376 $body = file_get_contents( $path );
377 return is_string( $body ) ? $body : '';
378 }
379
380 /**
381 * Recursively inline `@import url(...)` (and `@import "...";`)
382 * statements. Cycles detected via depth limit; cross-origin imports
383 * are left alone.
384 */
385 public static function resolve_imports( string $css, string $base_url, int $depth ): string {
386 if ( $depth > self::MAX_IMPORT_DEPTH ) {
387 return $css;
388 }
389 return (string) preg_replace_callback(
390 '#@import\s+(?:url\s*\(\s*)?["\']?([^"\')]+)["\']?\s*\)?\s*([^;]*);#i',
391 static function ( $m ) use ( $base_url, $depth ) {
392 $target = trim( (string) $m[1] );
393 $media = trim( (string) $m[2] );
394 $abs = self::resolve_relative( $target, $base_url );
395 $info = self::local_info( $abs );
396 if ( null === $info ) {
397 return $m[0]; // external or unresolvable; leave as-is.
398 }
399 $body = self::read_local_file( $info['path'] );
400 if ( '' === $body ) {
401 return $m[0];
402 }
403 $body = self::rewrite_url_paths( $body, $info['url'] );
404 $body = self::resolve_imports( $body, $info['url'], $depth + 1 );
405 if ( '' !== $media ) {
406 return '@media ' . $media . " {\n" . $body . "\n}\n";
407 }
408 return $body;
409 },
410 $css
411 );
412 }
413
414 /**
415 * Rewrite every `url(...)` whose argument is a relative path so it
416 * becomes absolute (resolved against the source file's URL). The
417 * combined file lives at a different location, so relative paths
418 * would otherwise break.
419 *
420 * Skips: absolute URLs (http://, https://, //), data: URIs,
421 * `#fragment-only`, blob:, javascript: (which shouldn't appear in
422 * CSS but won't crash).
423 */
424 public static function rewrite_url_paths( string $css, string $base_url ): string {
425 return (string) preg_replace_callback(
426 '#url\(\s*(["\']?)([^"\')]+)\1\s*\)#i',
427 static function ( $m ) use ( $base_url ) {
428 $quote = $m[1];
429 $raw = trim( (string) $m[2] );
430 if ( '' === $raw ) {
431 return $m[0];
432 }
433 if (
434 0 === strpos( $raw, 'data:' )
435 || 0 === strpos( $raw, 'blob:' )
436 || 0 === strpos( $raw, '#' )
437 || 0 === strpos( $raw, 'http://' )
438 || 0 === strpos( $raw, 'https://' )
439 || 0 === strpos( $raw, '//' )
440 ) {
441 return $m[0];
442 }
443 $abs = self::resolve_relative( $raw, $base_url );
444 return 'url(' . $quote . $abs . $quote . ')';
445 },
446 $css
447 );
448 }
449
450 /**
451 * Resolve a relative URL (no scheme, no leading /) against a base
452 * URL. Public so tests can exercise it directly.
453 */
454 public static function resolve_relative( string $target, string $base_url ): string {
455 // Order matters — '//' is a prefix of '/' so the protocol-relative
456 // check must happen BEFORE the leading-slash anchor.
457 if ( 0 === strpos( $target, '//' ) ) {
458 return ( is_ssl() ? 'https:' : 'http:' ) . $target;
459 }
460 if ( 0 === strpos( $target, '/' ) ) {
461 $parts = wp_parse_url( $base_url );
462 if ( ! is_array( $parts ) ) {
463 return $target;
464 }
465 $origin = ( $parts['scheme'] ?? 'http' ) . '://' . ( $parts['host'] ?? '' );
466 if ( isset( $parts['port'] ) ) {
467 $origin .= ':' . $parts['port'];
468 }
469 return $origin . $target;
470 }
471 if ( 0 === strpos( $target, 'http://' ) || 0 === strpos( $target, 'https://' ) ) {
472 return $target;
473 }
474 // Relative. Strip filename from base, resolve.
475 $base_path = (string) wp_parse_url( $base_url, PHP_URL_PATH );
476 $base_dir = rtrim( str_replace( basename( $base_path ), '', $base_path ), '/' );
477 $parts = wp_parse_url( $base_url );
478 $origin = ( $parts['scheme'] ?? 'http' ) . '://' . ( $parts['host'] ?? '' );
479 if ( isset( $parts['port'] ) ) {
480 $origin .= ':' . $parts['port'];
481 }
482 // Collapse ../
483 $joined = $base_dir . '/' . $target;
484 $segments = array();
485 foreach ( explode( '/', $joined ) as $seg ) {
486 if ( '' === $seg || '.' === $seg ) {
487 continue;
488 }
489 if ( '..' === $seg ) {
490 array_pop( $segments );
491 continue;
492 }
493 $segments[] = $seg;
494 }
495 return $origin . '/' . implode( '/', $segments );
496 }
497
498 private static function ensure_dir( string $dir ): void {
499 if ( ! is_dir( $dir ) ) {
500 wp_mkdir_p( $dir );
501 }
502 $silence = $dir . '/index.php';
503 if ( ! file_exists( $silence ) ) {
504 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- bootstrap-time helper, WP_Filesystem unavailable.
505 file_put_contents( $silence, "<?php\n// Silence is golden.\n" );
506 }
507 }
508 }
509