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

403 lines 12.9 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 // Swap the queue.
100 foreach ( $bucket as $handle => $info ) {
101 $wp_styles->dequeue( $handle );
102 }
103 $combined_handle = 'xspeed-combined-css';
104 wp_register_style( $combined_handle, $out_url, array(), $key );
105 wp_enqueue_style( $combined_handle );
106 }
107
108 /**
109 * Combine local enqueued scripts into one file.
110 */
111 public static function combine_scripts(): void {
112 global $wp_scripts;
113 if ( ! $wp_scripts instanceof \WP_Scripts || empty( $wp_scripts->queue ) ) {
114 return;
115 }
116
117 $bucket = self::collect_local_script_handles( $wp_scripts );
118 if ( count( $bucket ) < 2 ) {
119 return;
120 }
121
122 $key = self::cache_key( $bucket );
123 $dir = self::cache_dir();
124 $out_file = $dir . '/combined-' . $key . '.js';
125 $out_url = self::cache_url() . '/combined-' . $key . '.js';
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 $contents .= "/* xspeed: $handle */\n" . $body . "\n;\n";
136 }
137 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem unavailable on frontend.
138 file_put_contents( $out_file, $contents, LOCK_EX );
139 }
140
141 foreach ( $bucket as $handle => $info ) {
142 $wp_scripts->dequeue( $handle );
143 }
144 $combined_handle = 'xspeed-combined-js';
145 wp_register_script( $combined_handle, $out_url, array(), $key, true );
146 wp_enqueue_script( $combined_handle );
147 }
148
149 /**
150 * Walk WP_Styles->queue, return only handles whose src is a local
151 * file we can safely combine. Keyed by handle, value is
152 * [ 'url' => absolute URL, 'path' => filesystem path, 'mtime' => int ].
153 */
154 private static function collect_local_handles( \WP_Styles $wp_styles ): array {
155 $out = array();
156 foreach ( $wp_styles->queue as $handle ) {
157 if ( ! isset( $wp_styles->registered[ $handle ] ) ) {
158 continue;
159 }
160 $reg = $wp_styles->registered[ $handle ];
161 $src = (string) ( $reg->src ?? '' );
162 if ( '' === $src ) {
163 continue;
164 }
165 $abs = self::to_absolute_url( $src );
166 $info = self::local_info( $abs );
167 if ( null === $info ) {
168 continue; // external or unresolvable — leave in queue.
169 }
170 // Skip non-default media (we'd need separate buckets — Phase 2).
171 $media = $reg->args ?? 'all';
172 if ( '' !== $media && 'all' !== $media && 'screen' !== $media ) {
173 continue;
174 }
175 $out[ $handle ] = $info + array( 'src' => $src );
176 }
177 return $out;
178 }
179
180 private static function collect_local_script_handles( \WP_Scripts $wp_scripts ): array {
181 $out = array();
182 foreach ( $wp_scripts->queue as $handle ) {
183 if ( ! isset( $wp_scripts->registered[ $handle ] ) ) {
184 continue;
185 }
186 $reg = $wp_scripts->registered[ $handle ];
187 $src = (string) ( $reg->src ?? '' );
188 if ( '' === $src ) {
189 continue;
190 }
191 // Skip scripts that carry inline-after data (they expect
192 // to run at their original spot).
193 if ( ! empty( $reg->extra['after'] ) || ! empty( $reg->extra['before'] ) || ! empty( $reg->extra['data'] ) ) {
194 continue;
195 }
196 // Skip async / defer-via-strategy.
197 $strategy = $reg->extra['strategy'] ?? '';
198 if ( 'async' === $strategy || 'defer' === $strategy ) {
199 continue;
200 }
201 $abs = self::to_absolute_url( $src );
202 $info = self::local_info( $abs );
203 if ( null === $info ) {
204 continue;
205 }
206 $out[ $handle ] = $info + array( 'src' => $src );
207 }
208 return $out;
209 }
210
211 /**
212 * Convert a possibly-relative `src` into an absolute URL.
213 */
214 private static function to_absolute_url( string $src ): string {
215 if ( '' === $src ) {
216 return '';
217 }
218 if ( 0 === strpos( $src, '//' ) ) {
219 return ( is_ssl() ? 'https:' : 'http:' ) . $src;
220 }
221 if ( 0 === strpos( $src, '/' ) ) {
222 $home = home_url();
223 $home = (string) preg_replace( '#/$#', '', $home );
224 return $home . $src;
225 }
226 return $src;
227 }
228
229 /**
230 * Resolve an absolute URL to a local filesystem path + mtime, or
231 * return null if the URL isn't on this site / outside web root.
232 *
233 * @return array{url:string,path:string,mtime:int}|null
234 */
235 public static function local_info( string $url ): ?array {
236 if ( '' === $url ) {
237 return null;
238 }
239 $home = home_url();
240 if ( 0 !== strpos( $url, $home ) ) {
241 return null;
242 }
243 // Strip query / fragment for filesystem lookup; keep them in
244 // the URL we hash against.
245 $clean = strtok( $url, '?' );
246 if ( ! is_string( $clean ) ) {
247 return null;
248 }
249 $path = ABSPATH . ltrim( str_replace( $home, '', $clean ), '/' );
250 if ( ! file_exists( $path ) || ! is_readable( $path ) ) {
251 return null;
252 }
253 return array(
254 'url' => $url,
255 'path' => $path,
256 'mtime' => (int) filemtime( $path ),
257 );
258 }
259
260 private static function cache_key( array $bucket ): string {
261 $signature = array();
262 foreach ( $bucket as $handle => $info ) {
263 $signature[ $handle ] = array( $info['src'] ?? '', $info['mtime'] ?? 0 );
264 }
265 return md5( wp_json_encode( $signature ) );
266 }
267
268 private static function read_local_file( string $path ): string {
269 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem unavailable on frontend; we already validated existence + readability.
270 $body = file_get_contents( $path );
271 return is_string( $body ) ? $body : '';
272 }
273
274 /**
275 * Recursively inline `@import url(...)` (and `@import "...";`)
276 * statements. Cycles detected via depth limit; cross-origin imports
277 * are left alone.
278 */
279 public static function resolve_imports( string $css, string $base_url, int $depth ): string {
280 if ( $depth > self::MAX_IMPORT_DEPTH ) {
281 return $css;
282 }
283 return (string) preg_replace_callback(
284 '#@import\s+(?:url\s*\(\s*)?["\']?([^"\')]+)["\']?\s*\)?\s*([^;]*);#i',
285 static function ( $m ) use ( $base_url, $depth ) {
286 $target = trim( (string) $m[1] );
287 $media = trim( (string) $m[2] );
288 $abs = self::resolve_relative( $target, $base_url );
289 $info = self::local_info( $abs );
290 if ( null === $info ) {
291 return $m[0]; // external or unresolvable; leave as-is.
292 }
293 $body = self::read_local_file( $info['path'] );
294 if ( '' === $body ) {
295 return $m[0];
296 }
297 $body = self::rewrite_url_paths( $body, $info['url'] );
298 $body = self::resolve_imports( $body, $info['url'], $depth + 1 );
299 if ( '' !== $media ) {
300 return '@media ' . $media . " {\n" . $body . "\n}\n";
301 }
302 return $body;
303 },
304 $css
305 );
306 }
307
308 /**
309 * Rewrite every `url(...)` whose argument is a relative path so it
310 * becomes absolute (resolved against the source file's URL). The
311 * combined file lives at a different location, so relative paths
312 * would otherwise break.
313 *
314 * Skips: absolute URLs (http://, https://, //), data: URIs,
315 * `#fragment-only`, blob:, javascript: (which shouldn't appear in
316 * CSS but won't crash).
317 */
318 public static function rewrite_url_paths( string $css, string $base_url ): string {
319 return (string) preg_replace_callback(
320 '#url\(\s*(["\']?)([^"\')]+)\1\s*\)#i',
321 static function ( $m ) use ( $base_url ) {
322 $quote = $m[1];
323 $raw = trim( (string) $m[2] );
324 if ( '' === $raw ) {
325 return $m[0];
326 }
327 if (
328 0 === strpos( $raw, 'data:' )
329 || 0 === strpos( $raw, 'blob:' )
330 || 0 === strpos( $raw, '#' )
331 || 0 === strpos( $raw, 'http://' )
332 || 0 === strpos( $raw, 'https://' )
333 || 0 === strpos( $raw, '//' )
334 ) {
335 return $m[0];
336 }
337 $abs = self::resolve_relative( $raw, $base_url );
338 return 'url(' . $quote . $abs . $quote . ')';
339 },
340 $css
341 );
342 }
343
344 /**
345 * Resolve a relative URL (no scheme, no leading /) against a base
346 * URL. Public so tests can exercise it directly.
347 */
348 public static function resolve_relative( string $target, string $base_url ): string {
349 // Order matters — '//' is a prefix of '/' so the protocol-relative
350 // check must happen BEFORE the leading-slash anchor.
351 if ( 0 === strpos( $target, '//' ) ) {
352 return ( is_ssl() ? 'https:' : 'http:' ) . $target;
353 }
354 if ( 0 === strpos( $target, '/' ) ) {
355 $parts = wp_parse_url( $base_url );
356 if ( ! is_array( $parts ) ) {
357 return $target;
358 }
359 $origin = ( $parts['scheme'] ?? 'http' ) . '://' . ( $parts['host'] ?? '' );
360 if ( isset( $parts['port'] ) ) {
361 $origin .= ':' . $parts['port'];
362 }
363 return $origin . $target;
364 }
365 if ( 0 === strpos( $target, 'http://' ) || 0 === strpos( $target, 'https://' ) ) {
366 return $target;
367 }
368 // Relative. Strip filename from base, resolve.
369 $base_path = (string) wp_parse_url( $base_url, PHP_URL_PATH );
370 $base_dir = rtrim( str_replace( basename( $base_path ), '', $base_path ), '/' );
371 $parts = wp_parse_url( $base_url );
372 $origin = ( $parts['scheme'] ?? 'http' ) . '://' . ( $parts['host'] ?? '' );
373 if ( isset( $parts['port'] ) ) {
374 $origin .= ':' . $parts['port'];
375 }
376 // Collapse ../
377 $joined = $base_dir . '/' . $target;
378 $segments = array();
379 foreach ( explode( '/', $joined ) as $seg ) {
380 if ( '' === $seg || '.' === $seg ) {
381 continue;
382 }
383 if ( '..' === $seg ) {
384 array_pop( $segments );
385 continue;
386 }
387 $segments[] = $seg;
388 }
389 return $origin . '/' . implode( '/', $segments );
390 }
391
392 private static function ensure_dir( string $dir ): void {
393 if ( ! is_dir( $dir ) ) {
394 wp_mkdir_p( $dir );
395 }
396 $silence = $dir . '/index.php';
397 if ( ! file_exists( $silence ) ) {
398 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- bootstrap-time helper, WP_Filesystem unavailable.
399 file_put_contents( $silence, "<?php\n// Silence is golden.\n" );
400 }
401 }
402 }
403