PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.8
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.8
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.0.8, at includes/class-asset-combiner.php

433 lines 14.3 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 public static function cache_url(): string {
59 return trailingslashit( content_url( 'cache/xspeed' ) ) . 'min/combined';
60 }
61
62 /**
63 * Combine local enqueued styles into one file.
64 */
65 public static function combine_styles(): void {
66 global $wp_styles;
67 if ( ! $wp_styles instanceof \WP_Styles || empty( $wp_styles->queue ) ) {
68 return;
69 }
70
71 $bucket = self::collect_local_handles( $wp_styles );
72 if ( count( $bucket ) < 2 ) {
73 return; // nothing to gain from combining a single file.
74 }
75
76 $key = self::cache_key( $bucket );
77 $dir = self::cache_dir();
78 $out_file = $dir . '/combined-' . $key . '.css';
79 $out_url = self::cache_url() . '/combined-' . $key . '.css';
80
81 if ( ! file_exists( $out_file ) ) {
82 self::ensure_dir( $dir );
83 $contents = '';
84 foreach ( $bucket as $handle => $info ) {
85 $body = self::read_local_file( $info['path'] );
86 if ( '' === $body ) {
87 continue;
88 }
89 $body = self::resolve_imports( $body, $info['url'], 0 );
90 $body = self::rewrite_url_paths( $body, $info['url'] );
91 $contents .= "/* xspeed: $handle */\n" . $body . "\n";
92 }
93 // Atomic write: file_put_contents with LOCK_EX so concurrent
94 // renders don't race.
95 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context, unavailable on frontend.
96 file_put_contents( $out_file, $contents, LOCK_EX );
97 }
98
99 // Point the FIRST combined handle at the combined file and blank the
100 // rest. This is deliberate — we do NOT enqueue a fresh
101 // `xspeed-combined-css` handle, because WordPress would print it at
102 // the tail of the queue, AFTER any non-combinable stylesheets
103 // (media-query sheets like woocommerce-smallscreen, wc-blocks-*,
104 // external fonts) that originally sat between/after the combined
105 // handles. That reorders the cascade and breaks layout — e.g. the
106 // WooCommerce/Astra grid + sidebar widths get overridden by rules
107 // that should have lower priority. By reusing the first combined
108 // handle's own queue slot for the combined <link>, the merged CSS
109 // prints exactly where the earliest source stylesheet used to be,
110 // preserving cascade order. (FBS-83114/83116)
111 //
112 // The remaining combined handles keep their registration + queue
113 // membership (src blanked) so their wp_add_inline_style() data still
114 // prints — WordPress only emits inline data for handles still in the
115 // print queue, and some themes (Astra) attach that dynamic CSS on a
116 // hook LATER than this priority-999 pass, so we can't harvest it now.
117 // Dropping it is what made "combine CSS break the site".
118 $first = true;
119 foreach ( $bucket as $handle => $info ) {
120 $reg = $wp_styles->registered[ $handle ] ?? null;
121 if ( ! $reg instanceof \_WP_Dependency ) {
122 continue;
123 }
124 if ( $first ) {
125 // Carry the combined file on the first handle's slot.
126 $reg->src = $out_url;
127 $reg->ver = $key;
128 $reg->args = 'all';
129 $first = false;
130 } else {
131 // Inline-only carrier: no <link>, keep inline CSS printable.
132 $reg->src = false;
133 $reg->ver = null;
134 }
135 }
136 }
137
138 /**
139 * Combine local enqueued scripts into one file.
140 */
141 public static function combine_scripts(): void {
142 global $wp_scripts;
143 if ( ! $wp_scripts instanceof \WP_Scripts || empty( $wp_scripts->queue ) ) {
144 return;
145 }
146
147 $bucket = self::collect_local_script_handles( $wp_scripts );
148 if ( count( $bucket ) < 2 ) {
149 return;
150 }
151
152 $key = self::cache_key( $bucket );
153 $dir = self::cache_dir();
154 $out_file = $dir . '/combined-' . $key . '.js';
155 $out_url = self::cache_url() . '/combined-' . $key . '.js';
156
157 if ( ! file_exists( $out_file ) ) {
158 self::ensure_dir( $dir );
159 $contents = '';
160 foreach ( $bucket as $handle => $info ) {
161 $body = self::read_local_file( $info['path'] );
162 if ( '' === $body ) {
163 continue;
164 }
165 $contents .= "/* xspeed: $handle */\n" . $body . "\n;\n";
166 }
167 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem unavailable on frontend.
168 file_put_contents( $out_file, $contents, LOCK_EX );
169 }
170
171 foreach ( $bucket as $handle => $info ) {
172 $wp_scripts->dequeue( $handle );
173 }
174 $combined_handle = 'xspeed-combined-js';
175 wp_register_script( $combined_handle, $out_url, array(), $key, true );
176 wp_enqueue_script( $combined_handle );
177 }
178
179 /**
180 * Walk WP_Styles->queue, return only handles whose src is a local
181 * file we can safely combine. Keyed by handle, value is
182 * [ 'url' => absolute URL, 'path' => filesystem path, 'mtime' => int ].
183 */
184 private static function collect_local_handles( \WP_Styles $wp_styles ): array {
185 $out = array();
186 foreach ( $wp_styles->queue as $handle ) {
187 if ( ! isset( $wp_styles->registered[ $handle ] ) ) {
188 continue;
189 }
190 $reg = $wp_styles->registered[ $handle ];
191 $src = (string) ( $reg->src ?? '' );
192 if ( '' === $src ) {
193 continue;
194 }
195 $abs = self::to_absolute_url( $src );
196 $info = self::local_info( $abs );
197 if ( null === $info ) {
198 continue; // external or unresolvable — leave in queue.
199 }
200 // Skip non-default media (we'd need separate buckets — Phase 2).
201 $media = $reg->args ?? 'all';
202 if ( '' !== $media && 'all' !== $media && 'screen' !== $media ) {
203 continue;
204 }
205 $out[ $handle ] = $info + array( 'src' => $src );
206 }
207 return $out;
208 }
209
210 private static function collect_local_script_handles( \WP_Scripts $wp_scripts ): array {
211 $out = array();
212 foreach ( $wp_scripts->queue as $handle ) {
213 if ( ! isset( $wp_scripts->registered[ $handle ] ) ) {
214 continue;
215 }
216 $reg = $wp_scripts->registered[ $handle ];
217 $src = (string) ( $reg->src ?? '' );
218 if ( '' === $src ) {
219 continue;
220 }
221 // Skip scripts that carry inline-after data (they expect
222 // to run at their original spot).
223 if ( ! empty( $reg->extra['after'] ) || ! empty( $reg->extra['before'] ) || ! empty( $reg->extra['data'] ) ) {
224 continue;
225 }
226 // Skip async / defer-via-strategy.
227 $strategy = $reg->extra['strategy'] ?? '';
228 if ( 'async' === $strategy || 'defer' === $strategy ) {
229 continue;
230 }
231 $abs = self::to_absolute_url( $src );
232 $info = self::local_info( $abs );
233 if ( null === $info ) {
234 continue;
235 }
236 $out[ $handle ] = $info + array( 'src' => $src );
237 }
238 return $out;
239 }
240
241 /**
242 * Convert a possibly-relative `src` into an absolute URL.
243 */
244 private static function to_absolute_url( string $src ): string {
245 if ( '' === $src ) {
246 return '';
247 }
248 if ( 0 === strpos( $src, '//' ) ) {
249 return ( is_ssl() ? 'https:' : 'http:' ) . $src;
250 }
251 if ( 0 === strpos( $src, '/' ) ) {
252 $home = home_url();
253 $home = (string) preg_replace( '#/$#', '', $home );
254 return $home . $src;
255 }
256 return $src;
257 }
258
259 /**
260 * Resolve an absolute URL to a local filesystem path + mtime, or
261 * return null if the URL isn't on this site / outside web root.
262 *
263 * @return array{url:string,path:string,mtime:int}|null
264 */
265 public static function local_info( string $url ): ?array {
266 if ( '' === $url ) {
267 return null;
268 }
269 $home = home_url();
270 if ( 0 !== strpos( $url, $home ) ) {
271 return null;
272 }
273 // Strip query / fragment for filesystem lookup; keep them in
274 // the URL we hash against.
275 $clean = strtok( $url, '?' );
276 if ( ! is_string( $clean ) ) {
277 return null;
278 }
279 $path = ABSPATH . ltrim( str_replace( $home, '', $clean ), '/' );
280 if ( ! file_exists( $path ) || ! is_readable( $path ) ) {
281 return null;
282 }
283 return array(
284 'url' => $url,
285 'path' => $path,
286 'mtime' => (int) filemtime( $path ),
287 );
288 }
289
290 private static function cache_key( array $bucket ): string {
291 $signature = array();
292 foreach ( $bucket as $handle => $info ) {
293 $signature[ $handle ] = array( $info['src'] ?? '', $info['mtime'] ?? 0 );
294 }
295 return md5( wp_json_encode( $signature ) );
296 }
297
298 private static function read_local_file( string $path ): string {
299 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem unavailable on frontend; we already validated existence + readability.
300 $body = file_get_contents( $path );
301 return is_string( $body ) ? $body : '';
302 }
303
304 /**
305 * Recursively inline `@import url(...)` (and `@import "...";`)
306 * statements. Cycles detected via depth limit; cross-origin imports
307 * are left alone.
308 */
309 public static function resolve_imports( string $css, string $base_url, int $depth ): string {
310 if ( $depth > self::MAX_IMPORT_DEPTH ) {
311 return $css;
312 }
313 return (string) preg_replace_callback(
314 '#@import\s+(?:url\s*\(\s*)?["\']?([^"\')]+)["\']?\s*\)?\s*([^;]*);#i',
315 static function ( $m ) use ( $base_url, $depth ) {
316 $target = trim( (string) $m[1] );
317 $media = trim( (string) $m[2] );
318 $abs = self::resolve_relative( $target, $base_url );
319 $info = self::local_info( $abs );
320 if ( null === $info ) {
321 return $m[0]; // external or unresolvable; leave as-is.
322 }
323 $body = self::read_local_file( $info['path'] );
324 if ( '' === $body ) {
325 return $m[0];
326 }
327 $body = self::rewrite_url_paths( $body, $info['url'] );
328 $body = self::resolve_imports( $body, $info['url'], $depth + 1 );
329 if ( '' !== $media ) {
330 return '@media ' . $media . " {\n" . $body . "\n}\n";
331 }
332 return $body;
333 },
334 $css
335 );
336 }
337
338 /**
339 * Rewrite every `url(...)` whose argument is a relative path so it
340 * becomes absolute (resolved against the source file's URL). The
341 * combined file lives at a different location, so relative paths
342 * would otherwise break.
343 *
344 * Skips: absolute URLs (http://, https://, //), data: URIs,
345 * `#fragment-only`, blob:, javascript: (which shouldn't appear in
346 * CSS but won't crash).
347 */
348 public static function rewrite_url_paths( string $css, string $base_url ): string {
349 return (string) preg_replace_callback(
350 '#url\(\s*(["\']?)([^"\')]+)\1\s*\)#i',
351 static function ( $m ) use ( $base_url ) {
352 $quote = $m[1];
353 $raw = trim( (string) $m[2] );
354 if ( '' === $raw ) {
355 return $m[0];
356 }
357 if (
358 0 === strpos( $raw, 'data:' )
359 || 0 === strpos( $raw, 'blob:' )
360 || 0 === strpos( $raw, '#' )
361 || 0 === strpos( $raw, 'http://' )
362 || 0 === strpos( $raw, 'https://' )
363 || 0 === strpos( $raw, '//' )
364 ) {
365 return $m[0];
366 }
367 $abs = self::resolve_relative( $raw, $base_url );
368 return 'url(' . $quote . $abs . $quote . ')';
369 },
370 $css
371 );
372 }
373
374 /**
375 * Resolve a relative URL (no scheme, no leading /) against a base
376 * URL. Public so tests can exercise it directly.
377 */
378 public static function resolve_relative( string $target, string $base_url ): string {
379 // Order matters — '//' is a prefix of '/' so the protocol-relative
380 // check must happen BEFORE the leading-slash anchor.
381 if ( 0 === strpos( $target, '//' ) ) {
382 return ( is_ssl() ? 'https:' : 'http:' ) . $target;
383 }
384 if ( 0 === strpos( $target, '/' ) ) {
385 $parts = wp_parse_url( $base_url );
386 if ( ! is_array( $parts ) ) {
387 return $target;
388 }
389 $origin = ( $parts['scheme'] ?? 'http' ) . '://' . ( $parts['host'] ?? '' );
390 if ( isset( $parts['port'] ) ) {
391 $origin .= ':' . $parts['port'];
392 }
393 return $origin . $target;
394 }
395 if ( 0 === strpos( $target, 'http://' ) || 0 === strpos( $target, 'https://' ) ) {
396 return $target;
397 }
398 // Relative. Strip filename from base, resolve.
399 $base_path = (string) wp_parse_url( $base_url, PHP_URL_PATH );
400 $base_dir = rtrim( str_replace( basename( $base_path ), '', $base_path ), '/' );
401 $parts = wp_parse_url( $base_url );
402 $origin = ( $parts['scheme'] ?? 'http' ) . '://' . ( $parts['host'] ?? '' );
403 if ( isset( $parts['port'] ) ) {
404 $origin .= ':' . $parts['port'];
405 }
406 // Collapse ../
407 $joined = $base_dir . '/' . $target;
408 $segments = array();
409 foreach ( explode( '/', $joined ) as $seg ) {
410 if ( '' === $seg || '.' === $seg ) {
411 continue;
412 }
413 if ( '..' === $seg ) {
414 array_pop( $segments );
415 continue;
416 }
417 $segments[] = $seg;
418 }
419 return $origin . '/' . implode( '/', $segments );
420 }
421
422 private static function ensure_dir( string $dir ): void {
423 if ( ! is_dir( $dir ) ) {
424 wp_mkdir_p( $dir );
425 }
426 $silence = $dir . '/index.php';
427 if ( ! file_exists( $silence ) ) {
428 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- bootstrap-time helper, WP_Filesystem unavailable.
429 file_put_contents( $silence, "<?php\n// Silence is golden.\n" );
430 }
431 }
432 }
433