PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
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 1.1.2 All 35 releases
xspeed / includes / class-asset-combiner.php

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

1,055 lines 41.0 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 the cache key from the version, home_url() and each part's
13 * content key (combined_key()). When the combined file already
14 * exists for that key, skip generation.
15 * 3. Otherwise: read each source body, resolve recursive @import
16 * statements (depth-limited), rewrite url(...) paths to absolute,
17 * concat with a small `/* xspeed: HANDLE *​/` header per chunk for
18 * debug-traceability, write to XSPEED_CACHE_DIR/min/combined/.
19 * 4. Register the combined file as a single new handle
20 * `xspeed-combined-css` and re-add it to the queue. The original
21 * handles stay registered (so other plugins that look them up
22 * still find their metadata) but are pulled from the queue —
23 * they won't print <link> tags.
24 *
25 * JS path is the same, minus @import (no JS analogue) and url()
26 * rewriting (JS strings are too varied to safely rewrite). External
27 * + async + deferred scripts (deferred via WP_Scripts->add_data
28 * 'strategy' OR the script_loader_tag filter from Minify_Filters)
29 * stay un-combined.
30 *
31 * Cache lives in {$min_dir}/combined/ — separate from the per-file
32 * minify cache so purge can target them independently if needed.
33 *
34 * @package XSpeed
35 */
36
37 declare(strict_types=1);
38
39 namespace XSpeed;
40
41 defined( 'ABSPATH' ) || exit;
42
43 final class Asset_Combiner {
44
45 public const MAX_IMPORT_DEPTH = 3;
46
47 /**
48 * Path to the combine cache dir. Created on first write.
49 */
50 public static function cache_dir(): string {
51 return trailingslashit( XSPEED_CACHE_DIR ) . 'min/combined';
52 }
53
54 /**
55 * URL prefix matching cache_dir(). Built from content_url, not by
56 * string-replacing filesystem paths (see class-minifier.php for the
57 * same rationale).
58 *
59 * The scheme is forced to match the page's — `content_url()` derives
60 * its scheme from `is_ssl()`, which returns false behind a TLS-
61 * terminating reverse proxy / load balancer (common on managed hosts),
62 * so it can hand back an `http://` URL on an `https` page. The browser
63 * then blocks the combined stylesheet as mixed content and the whole
64 * page renders unstyled. Re-scheme the URL to the site's actual scheme
65 * so the <link> always matches the page. (FBS-83633)
66 */
67 public static function cache_url(): string {
68 $url = trailingslashit( content_url( 'cache/xspeed' ) ) . 'min/combined';
69 // Match the site's registered scheme (home_url), NOT is_ssl() —
70 // which set_url_scheme() would consult with no explicit scheme, and
71 // which is the very signal that misreports behind a proxy.
72 $scheme = wp_parse_url( home_url(), PHP_URL_SCHEME ) ?: 'https';
73 return set_url_scheme( $url, $scheme );
74 }
75
76 /**
77 * Combine local enqueued styles into one file.
78 *
79 * @deprecated Superseded by Css_Combine_Buffer, which combines the
80 * finished HTML instead of the enqueue queue. This path is no longer
81 * hooked: whatever it wrote at priority 999, WordPress edited afterwards —
82 * core's wp_maybe_inline_styles() inlines any queued handle with a `path`
83 * and blanks its src, which discarded the combined URL and took the sheets
84 * this method had already blanked with it. See Css_Combine_Buffer's header
85 * for the live trace. (#195)
86 *
87 * Kept callable because tests/e2e/48- and 49- drive it directly to pin the
88 * FBS-83114/83116/83633/83653 regressions. Remove once those specs are
89 * ported onto the buffer engine.
90 */
91 public static function combine_styles(): void {
92 global $wp_styles;
93 if ( ! $wp_styles instanceof \WP_Styles || empty( $wp_styles->queue ) ) {
94 return;
95 }
96
97 // Group combinable handles by media type. Historically every sheet
98 // whose media wasn't all/screen was dropped from combining — but on
99 // page-builder sites (Elementor + Essential Addons + BetterDocs) a large
100 // share of the stylesheets carry responsive/print media, so dropping
101 // them starved the `all` bucket below the 2-handle floor and the whole
102 // combine step silently no-op'd (the page shipped 60 separate <link>s
103 // even with combine_css ON). Instead we bucket PER media type and emit
104 // one combined file per group with the correct `media` attribute, so
105 // nothing is dropped and the combinable majority always merges. (FBS-83653)
106 $buckets = self::collect_local_handles( $wp_styles );
107 foreach ( $buckets as $media => $bucket ) {
108 if ( count( $bucket ) < 2 ) {
109 continue; // nothing to gain from combining a single file in this group.
110 }
111 self::combine_media_group( $wp_styles, $media, $bucket );
112 }
113 }
114
115 /**
116 * Combine one media group's handles into a single stylesheet and wire it
117 * onto the group's carrier handle.
118 *
119 * @param string $media The media attribute for this group ('all', 'print', …).
120 * @param array<string,array<mixed>> $bucket handle => info map.
121 */
122 private static function combine_media_group( \WP_Styles $wp_styles, string $media, array $bucket ): void {
123 if ( ! Minifier::min_dir_writable() ) {
124 return;
125 }
126 $key = self::cache_key( $bucket, 'css' );
127 $dir = self::cache_dir();
128 $out_file = $dir . '/combined-' . $key . '.css';
129 $out_url = self::cache_url() . '/combined-' . $key . '.css';
130
131 if ( ! file_exists( $out_file ) ) {
132 self::ensure_dir( $dir );
133 $contents = '';
134 foreach ( $bucket as $handle => $info ) {
135 $body = self::read_local_file( $info['path'] );
136 if ( '' === $body ) {
137 continue;
138 }
139 $body = self::resolve_imports( $body, $info['url'], 0 );
140 $body = self::rewrite_url_paths( $body, $info['url'] );
141 // No per-handle banner comment: it is a debugging aid with no
142 // runtime value, and the minifier below preserves a comment
143 // that opens a chunk, so each one survived as a `/* xspeed */`
144 // stub that Lighthouse still counts as removable bytes.
145 // The handle list lives in the cache key, not in the payload.
146 $contents .= $body . "\n";
147 }
148
149 // Minify the JOIN, not just the parts (issue #331).
150 //
151 // Every input arrives here already minified, but the join itself
152 // is not: a `/* xspeed: <handle> */` banner per part, a newline
153 // after each, and whatever non-bang comments the sources kept.
154 // Nothing downstream removes them — Minifier::rewrite_style()
155 // deliberately skips anything under /cache/xspeed/ (re-minifying
156 // our own output produced a second hash whose URL 404'd after a
157 // purge), so this file was the end of the line and shipped as-is.
158 //
159 // The visible cost was small (~2 KB) but the scoring cost was not:
160 // Lighthouse's `unminified-css` is near-binary, so one failing
161 // file drops the audit to 0.5 — and the only offender on the page
162 // was the artifact we generated, which then docked the site on
163 // xSpeed Scan's own A2 check while the UI reported minify as on.
164 $contents = self::minify_css_body( $contents );
165
166 // Atomic: a concurrent render sees the whole file or none of it.
167 if ( ! Asset_Manifest::write_atomic( $out_file, $contents ) && ! file_exists( $out_file ) ) {
168 return;
169 }
170 } else {
171 self::mark_in_use( $out_file );
172 }
173
174 // Point the FIRST combined handle at the combined file and blank the
175 // rest. This is deliberate — we do NOT enqueue a fresh
176 // `xspeed-combined-css` handle, because WordPress would print it at
177 // the tail of the queue, AFTER any non-combinable stylesheets
178 // (media-query sheets like woocommerce-smallscreen, wc-blocks-*,
179 // external fonts) that originally sat between/after the combined
180 // handles. That reorders the cascade and breaks layout — e.g. the
181 // WooCommerce/Astra grid + sidebar widths get overridden by rules
182 // that should have lower priority. By reusing the first combined
183 // handle's own queue slot for the combined <link>, the merged CSS
184 // prints exactly where the earliest source stylesheet used to be,
185 // preserving cascade order. (FBS-83114/83116)
186 //
187 // The remaining combined handles keep their registration + queue
188 // membership (src blanked) so their wp_add_inline_style() data still
189 // prints — WordPress only emits inline data for handles still in the
190 // print queue, and some themes (Astra) attach that dynamic CSS on a
191 // hook LATER than this priority-999 pass, so we can't harvest it now.
192 // Dropping it is what made "combine CSS break the site".
193 // The carrier is the FIRST bucket handle that WordPress hasn't already
194 // printed. A block theme (Twenty Twenty-Five, etc.) prints some of its
195 // per-block style handles BEFORE this priority-999 pass, marking them
196 // `done`; pointing a done handle at the combined file emits no <link>
197 // at all — the merged CSS silently vanishes and the whole site renders
198 // unstyled. Skipping done handles guarantees the carrier still prints.
199 // If every bucket handle is already done, register a dedicated combined
200 // handle so the CSS is never lost (cascade tail is far better than no
201 // styles). (FBS-83633)
202 $done = (array) $wp_styles->done;
203 $carrier_set = false;
204 foreach ( $bucket as $handle => $info ) {
205 $reg = $wp_styles->registered[ $handle ] ?? null;
206 if ( ! $reg instanceof \_WP_Dependency ) {
207 continue;
208 }
209 if ( ! $carrier_set && ! in_array( $handle, $done, true ) ) {
210 // Carry the combined file on this (not-yet-printed) handle's slot.
211 $reg->src = $out_url;
212 $reg->ver = $key;
213 $reg->args = $media;
214 $carrier_set = true;
215 } else {
216 // Inline-only carrier: no <link>, keep inline CSS printable.
217 $reg->src = false;
218 $reg->ver = null;
219 }
220 }
221
222 // Fallback: every bucket handle was already printed, so no carrier
223 // could emit the combined <link>. Register + enqueue a dedicated
224 // handle so the merged CSS still loads (appended at the tail — not
225 // cascade-ideal, but infinitely better than a fully unstyled page).
226 if ( ! $carrier_set ) {
227 $combined_handle = 'xspeed-combined-css-' . $media;
228 wp_register_style( $combined_handle, $out_url, array(), $key, $media );
229 wp_enqueue_style( $combined_handle );
230 }
231 }
232
233 /**
234 * Combine local enqueued scripts into one file.
235 */
236 public static function combine_scripts(): void {
237 global $wp_scripts;
238 if ( ! $wp_scripts instanceof \WP_Scripts || empty( $wp_scripts->queue ) ) {
239 return;
240 }
241
242 $bucket = self::collect_local_script_handles( $wp_scripts );
243 if ( count( $bucket ) < 2 ) {
244 return;
245 }
246
247 // Split by print group — head (0) and footer (1) get their own bundle.
248 //
249 // Carrying everything on ONE carrier meant the whole bucket inherited
250 // that handle's placement, and the first handle in dependency order is
251 // almost always jquery-core, which WordPress registers with no group
252 // data at all — i.e. the HEAD. Every footer script absorbed alongside
253 // it was therefore hoisted into the head and executed as one
254 // synchronous blob before first paint: correctness-safer than the old
255 // forced footer, but render-blocking, and the exact inverse of what a
256 // speed plugin should ship. Bucketing by group is the same move
257 // combine_styles() already makes for media types. (#289, PR #290 review)
258 foreach ( self::split_by_group( $wp_scripts, $bucket ) as $group => $group_bucket ) {
259 if ( count( $group_bucket ) < 2 ) {
260 continue; // nothing to gain from combining a single file.
261 }
262 self::combine_script_group( $wp_scripts, (int) $group, $group_bucket );
263 }
264 }
265
266 /**
267 * Partition a bucket into WordPress's print groups: 0 = head, 1 = footer.
268 *
269 * Reads $wp_scripts->groups, NOT the declared `extra['group']`, because
270 * the declared value is not authoritative: WordPress promotes a
271 * footer-registered dependency of a head script into the head. all_deps()
272 * populates the effective values and prints nothing, so resolving them
273 * here keeps our split consistent with what WordPress would have done on
274 * its own. (PR #290 review)
275 *
276 * @param array<string,array<mixed>> $bucket handle => info map.
277 * @return array<int,array<string,array<mixed>>> group => bucket.
278 */
279 private static function split_by_group( \WP_Scripts $wp_scripts, array $bucket ): array {
280 // Resolve effective groups for everything queued. Safe to call at
281 // wp_enqueue_scripts: it walks dependencies and fills ->groups
282 // without emitting a single tag.
283 $wp_scripts->all_deps( $wp_scripts->queue, false );
284
285 $groups = array();
286 foreach ( $bucket as $handle => $info ) {
287 $group = isset( $wp_scripts->groups[ $handle ] ) ? (int) $wp_scripts->groups[ $handle ] : 0;
288 $groups[ $group ][ $handle ] = $info;
289 }
290
291 return $groups;
292 }
293
294 /**
295 * Build and attach one combined file for a single print group.
296 *
297 * @param int $group 0 = head, 1 = footer.
298 * @param array<string,array<mixed>> $bucket handle => info map for this group.
299 */
300 private static function combine_script_group( \WP_Scripts $wp_scripts, int $group, array $bucket ): void {
301 if ( ! Minifier::min_dir_writable() ) {
302 return;
303 }
304 $key = self::cache_key( $bucket, 'js' );
305 $dir = self::cache_dir();
306 // Group in the filename so a head and a footer bundle can never
307 // collide on one cache key.
308 $out_file = $dir . '/combined-g' . $group . '-' . $key . '.js';
309 $out_url = self::cache_url() . '/combined-g' . $group . '-' . $key . '.js';
310
311 if ( ! file_exists( $out_file ) ) {
312 self::ensure_dir( $dir );
313 $contents = '';
314 foreach ( $bucket as $handle => $info ) {
315 $body = self::read_local_file( $info['path'] );
316 if ( '' === $body ) {
317 continue;
318 }
319 $contents .= "/* xspeed: $handle */\n" . $body . "\n;\n";
320 }
321 if ( ! Asset_Manifest::write_atomic( $out_file, $contents ) && ! file_exists( $out_file ) ) {
322 return;
323 }
324 } else {
325 self::mark_in_use( $out_file );
326 }
327
328 self::attach_to_carrier( $wp_scripts, $bucket, $out_url, $key );
329 }
330
331 /**
332 * carrier handle => the handles whose src it now serves.
333 *
334 * @var array<string,string[]>
335 */
336 private static $carriers = array();
337
338 /** Whether the print-time sweep is hooked. */
339 private static $late_sweep_hooked = false;
340
341 /** Payload fingerprints already re-homed, so a second sweep is a no-op. */
342 private static $rehomed = array();
343
344 /**
345 * Point the combined file at the FIRST not-yet-printed bucket handle and
346 * blank the rest, instead of dequeuing everything and appending a fresh
347 * handle.
348 *
349 * The old approach registered `xspeed-combined-js` with `array()` deps and
350 * a hard-coded `$in_footer = true`, then dequeued the originals. Three
351 * things went wrong with that:
352 *
353 * 1. No dependency edges. The bundle declared no relationship to the
354 * handles that stayed in the queue (external, async/deferred,
355 * localized), so WordPress was free to print it in any order relative
356 * to them.
357 * 2. Forced to the footer. Every head script in the bucket was relocated
358 * behind any inline <script> in the head or body that expected it.
359 * 3. dequeue() leaves a handle REGISTERED and re-enqueueable, so anything
360 * enqueuing it later printed it a second time — while its code was
361 * already inside the bundle.
362 *
363 * Together those produce the reported break: `jquery-core` gets absorbed
364 * into a footer bundle, something still prints `jquery.min.js` in the
365 * head, and the second jQuery replaces the first — discarding every plugin
366 * the bundle had attached to it. `jQuery.fn.waypoint` becomes undefined
367 * even though the library loaded, and Elementor's module layer initialises
368 * twice. Measured on a fixture of that stack: five handles present both
369 * inside the bundle and as their own tag. (#289)
370 *
371 * Carrying the file on an existing handle fixes all three at once — the
372 * merged script keeps that handle's queue position, its dependency edges
373 * and its head/footer placement, and nothing is dequeued so nothing can be
374 * re-enqueued behind our back. This is what combine_styles() has always
375 * done; the JS path never got it.
376 *
377 * @param array<string,array<mixed>> $bucket handle => info map, in dependency order.
378 * @param string $out_url URL of the combined file.
379 * @param string $key Cache key, used as the version.
380 */
381 private static function attach_to_carrier( \WP_Scripts $wp_scripts, array $bucket, string $out_url, string $key ): void {
382 $done = (array) $wp_scripts->done;
383 $carrier_set = false;
384 $carrier = '';
385 $absorbed = array();
386
387 foreach ( $bucket as $handle => $info ) {
388 $reg = $wp_scripts->registered[ $handle ] ?? null;
389 if ( ! $reg instanceof \_WP_Dependency ) {
390 continue;
391 }
392
393 if ( ! $carrier_set && ! in_array( $handle, $done, true ) ) {
394 // Carry the bundle on this handle's slot. Its deps, its queue
395 // position and its in_footer flag all stay exactly as the
396 // enqueuing plugin set them.
397 $reg->src = $out_url;
398 $reg->ver = $key;
399 $carrier = $handle;
400 $carrier_set = true;
401 continue;
402 }
403
404 // Every other absorbed handle keeps its registration and its queue
405 // membership — only the src is blanked, so no second <script src>
406 // is emitted while any wp_add_inline_script() / wp_localize_script()
407 // data attached to it still prints. Dequeuing instead would drop
408 // that data on the floor and leave the handle re-enqueueable.
409 $reg->src = false;
410 $reg->ver = null;
411 $absorbed[] = $handle;
412 }
413
414 // No carrier means every handle in this bucket had ALREADY printed —
415 // so its code has already executed in the browser.
416 //
417 // Emitting the bundle anyway would re-run all of it, including a
418 // second jQuery: precisely the double-execution this method exists to
419 // prevent, and deterministic rather than occasional. The CSS path can
420 // afford its equivalent fallback because a duplicate stylesheet is
421 // merely redundant; a duplicate script re-initialises everything.
422 //
423 // So we do nothing: the page keeps the individual files it already
424 // printed — no combining benefit for this bucket, but correct.
425 // (PR #290 review)
426 if ( ! $carrier_set ) {
427 return;
428 }
429
430 // Remember what this carrier swallowed, so a payload attached to an
431 // absorbed handle AFTER we ran can still be re-homed onto the bundle
432 // at print time. See sweep_late_inline() for why that is needed.
433 self::$carriers[ $carrier ] = $absorbed;
434
435 if ( ! self::$late_sweep_hooked ) {
436 self::$late_sweep_hooked = true;
437 // Priority 0 on both print hooks: ahead of WP emitting the queue,
438 // and ahead of Defer_Js rewriting the tags it is about to print.
439 add_action( 'wp_print_scripts', array( __CLASS__, 'sweep_late_inline' ), 0 );
440 add_action( 'wp_print_footer_scripts', array( __CLASS__, 'sweep_late_inline' ), 0 );
441 }
442 }
443
444 /**
445 * Re-home inline payloads that arrived after the bundle was built.
446 *
447 * combine_scripts() runs on wp_enqueue_scripts, and combinable_script_info()
448 * refuses any handle that ALREADY carries inline data — so at that moment a
449 * page builder has attached nothing. Elementor adds elementorFrontendConfig
450 * from Frontend::wp_footer(), thousands of hook-ticks later, onto a handle
451 * whose src we have since blanked.
452 *
453 * That payload is not lost: blanking `src` (rather than dequeuing) leaves
454 * the handle registered, so WordPress still prints it. But it prints at the
455 * ABSORBED handle's queue position, which is behind the carrier — and a
456 * `before` payload exists precisely to run ahead of the code that reads it.
457 * The config therefore landed after the bundle that consumes it, and the
458 * script initialised against an undefined global.
459 *
460 * Moving a late `before` payload onto the carrier restores that contract.
461 * `after` payloads are left alone: their position behind the code is
462 * already correct wherever they print.
463 *
464 * Idempotent by fingerprint, so running on both print hooks is safe. (#246)
465 */
466 public static function sweep_late_inline(): void {
467 global $wp_scripts;
468 if ( ! $wp_scripts instanceof \WP_Scripts || empty( self::$carriers ) ) {
469 return;
470 }
471
472 foreach ( self::$carriers as $carrier => $absorbed ) {
473 if ( ! isset( $wp_scripts->registered[ $carrier ] ) ) {
474 continue;
475 }
476 foreach ( $absorbed as $handle ) {
477 $reg = $wp_scripts->registered[ $handle ] ?? null;
478 if ( ! $reg instanceof \_WP_Dependency || empty( $reg->extra['before'] ) ) {
479 continue;
480 }
481 if ( ! is_array( $reg->extra['before'] ) ) {
482 continue;
483 }
484
485 foreach ( $reg->extra['before'] as $payload ) {
486 // WP seeds `before` with a leading empty string; skip it
487 // rather than emitting a blank <script>.
488 if ( ! is_string( $payload ) || '' === trim( $payload ) ) {
489 continue;
490 }
491 $fingerprint = md5( $payload );
492 if ( isset( self::$rehomed[ $fingerprint ] ) ) {
493 continue;
494 }
495 self::$rehomed[ $fingerprint ] = true;
496 wp_add_inline_script( $carrier, $payload, 'before' );
497 }
498
499 // Clear the source so the payload is not ALSO printed at the
500 // absorbed handle's own position, after the bundle.
501 unset( $reg->extra['before'] );
502 }
503 }
504 }
505
506 /**
507 * Walk WP_Styles->queue, return the handles whose src is a local file we
508 * can safely combine, grouped BY media type so each media gets its own
509 * combined file. Shape:
510 * [ media => [ handle => [ 'url' => …, 'path' => …, 'mtime' => int, 'src' => … ] ] ].
511 * '' and 'screen' media fold into the 'all' group.
512 */
513 private static function collect_local_handles( \WP_Styles $wp_styles ): array {
514 $groups = array();
515 foreach ( $wp_styles->queue as $handle ) {
516 if ( ! isset( $wp_styles->registered[ $handle ] ) ) {
517 continue;
518 }
519 $reg = $wp_styles->registered[ $handle ];
520 $src = (string) ( $reg->src ?? '' );
521 if ( '' === $src ) {
522 continue;
523 }
524 // Leave WordPress core block styles alone. Block themes (Twenty
525 // Twenty-*, and any FSE theme) load per-block CSS conditionally and
526 // print/track these handles through their own separated-styles
527 // pipeline, often BEFORE this pass. Pulling them into a combined
528 // file fights that pipeline and leaves the page unstyled. These are
529 // already tiny + conditionally loaded, so there's little to gain.
530 // Matches `wp-block-*` handles and any src under wp-includes/blocks/
531 // or the block-library dist dir. (FBS-83633)
532 if (
533 0 === strpos( $handle, 'wp-block-' )
534 || false !== strpos( $src, '/wp-includes/blocks/' )
535 || false !== strpos( $src, '/block-library/' )
536 ) {
537 continue;
538 }
539 $abs = self::to_absolute_url( $src );
540 $info = self::local_info( $abs );
541 if ( null === $info ) {
542 continue; // external or unresolvable — leave in queue.
543 }
544 // Bucket by media type. '' and 'screen' fold into 'all' (both mean
545 // "the on-screen document"); every other media value (print,
546 // max-width queries, …) gets its own group so we can emit one
547 // combined file per media with the right attribute — instead of
548 // dropping non-'all' sheets and starving the combinable bucket on
549 // builder sites. (FBS-83653)
550 $media = (string) ( $reg->args ?? 'all' );
551 if ( '' === $media || 'screen' === $media ) {
552 $media = 'all';
553 }
554 $groups[ $media ][ $handle ] = $info + array( 'src' => $src );
555 }
556 return $groups;
557 }
558
559 private static function collect_local_script_handles( \WP_Scripts $wp_scripts ): array {
560 // `queue` holds only what was explicitly enqueued — never the
561 // dependencies WP resolves at print time. Walking it alone combined
562 // `admin-bar` while silently dropping its `hoverintent-js` dep, so the
563 // bundle called a function that was never in it:
564 // "hoverintent is not a function", and every admin-bar hover menu died
565 // for logged-in visitors. Expand deps first, then emit in dependency
566 // order. (#204)
567 $expanded = self::expand_with_deps( $wp_scripts );
568
569 $out = array();
570 foreach ( $expanded as $handle ) {
571 $info = self::combinable_script_info( $wp_scripts, $handle );
572 if ( null === $info ) {
573 continue;
574 }
575 $out[ $handle ] = $info;
576 }
577
578 // A script whose dependency could NOT be combined (inline data,
579 // async/defer, external CDN) has to stay in the queue itself —
580 // otherwise combining it drops the same dependency a second way.
581 return self::drop_dependents_of_missing( $wp_scripts, $out );
582 }
583
584 /**
585 * The queue plus every registered dependency it pulls in, in dependency
586 * order (a handle always follows everything it depends on).
587 *
588 * Depth-first post-order over `WP_Scripts::$registered[$handle]->deps`.
589 * `$seen` guards a malformed cyclic registration — a cycle can't be
590 * ordered, so the handle is emitted once and the walk unwinds rather than
591 * recursing forever. (#204)
592 *
593 * @param \WP_Scripts $wp_scripts Script registry.
594 * @return string[] Handles, dependencies first.
595 */
596 private static function expand_with_deps( \WP_Scripts $wp_scripts ): array {
597 $ordered = array();
598 $state = array(); // handle => 1 visiting, 2 done.
599
600 $visit = static function ( string $handle ) use ( &$visit, &$ordered, &$state, $wp_scripts ): void {
601 if ( isset( $state[ $handle ] ) ) {
602 return; // already emitted, or we're inside a cycle.
603 }
604 $state[ $handle ] = 1;
605 if ( isset( $wp_scripts->registered[ $handle ] ) ) {
606 foreach ( (array) $wp_scripts->registered[ $handle ]->deps as $dep ) {
607 $visit( (string) $dep );
608 }
609 }
610 $state[ $handle ] = 2;
611 $ordered[] = $handle;
612 };
613
614 foreach ( $wp_scripts->queue as $handle ) {
615 $visit( (string) $handle );
616 }
617
618 return $ordered;
619 }
620
621 /**
622 * Info for a handle that can safely go in the combined bundle, or null
623 * when it must be left in the queue.
624 *
625 * @param \WP_Scripts $wp_scripts Script registry.
626 * @param string $handle Script handle.
627 * @return array<string,mixed>|null
628 */
629 private static function combinable_script_info( \WP_Scripts $wp_scripts, string $handle ): ?array {
630 if ( ! isset( $wp_scripts->registered[ $handle ] ) ) {
631 return null;
632 }
633 $reg = $wp_scripts->registered[ $handle ];
634 $src = (string) ( $reg->src ?? '' );
635 if ( '' === $src ) {
636 // A dependency-only alias (e.g. `jquery`) carries no file of its
637 // own; nothing to concatenate, and its own deps were already
638 // walked, so it isn't a blocker.
639 return null;
640 }
641 // Skip scripts that carry inline-after data (they expect
642 // to run at their original spot).
643 if ( ! empty( $reg->extra['after'] ) || ! empty( $reg->extra['before'] ) || ! empty( $reg->extra['data'] ) ) {
644 return null;
645 }
646 // A handle the user protected from defer/delay, or one Defer JS
647 // auto-protects because inline code reads it, must not be absorbed
648 // either. Combining moves the code into a bundle printed under a
649 // DIFFERENT handle, so the exclusion the user wrote — matched by
650 // handle or URL — stops matching anything and the protection is
651 // silently gone. jquery-core is the case that bites: the check above
652 // only catches a handle carrying its OWN inline data, while a
653 // dependency of an inline consumer carries none, so it lands in the
654 // bundle and the exclusion list reads as if it were still honoured.
655 if ( \XSpeed\Minify_Filters::is_protected_from_bundling( $handle, $src ) ) {
656 return null;
657 }
658 // Skip async / defer-via-strategy.
659 $strategy = $reg->extra['strategy'] ?? '';
660 if ( 'async' === $strategy || 'defer' === $strategy ) {
661 return null;
662 }
663 $abs = self::to_absolute_url( $src );
664 $info = self::local_info( $abs );
665 if ( null === $info ) {
666 return null;
667 }
668 return $info + array( 'src' => $src );
669 }
670
671 /**
672 * Remove any handle whose dependency isn't in the bucket, transitively.
673 *
674 * Combining a script but not its dependency is exactly the #204 failure:
675 * the bundle runs code whose prerequisite never loaded. When a dep can't
676 * be combined — it carries inline data, is async/defer, or lives on a CDN
677 * — the safe move is to leave the dependent in the queue too, where WP
678 * prints both in the right order.
679 *
680 * A handle with no `src` (a pure alias like `jquery`) is not a blocker:
681 * it contributes no code, and its own deps were expanded separately.
682 *
683 * @param \WP_Scripts $wp_scripts Script registry.
684 * @param array<string,mixed> $bucket handle => info, dependency-ordered.
685 * @return array<string,mixed> Filtered bucket, order preserved.
686 */
687 private static function drop_dependents_of_missing( \WP_Scripts $wp_scripts, array $bucket ): array {
688 // Iterate to a fixed point: dropping A can orphan B that depends on A.
689 do {
690 $dropped = false;
691 foreach ( $bucket as $handle => $info ) {
692 if ( ! isset( $wp_scripts->registered[ $handle ] ) ) {
693 continue;
694 }
695 foreach ( (array) $wp_scripts->registered[ $handle ]->deps as $dep ) {
696 $dep = (string) $dep;
697 if ( isset( $bucket[ $dep ] ) ) {
698 continue; // dep is coming along.
699 }
700 $dep_reg = $wp_scripts->registered[ $dep ] ?? null;
701 if ( $dep_reg && '' === (string) ( $dep_reg->src ?? '' ) ) {
702 continue; // alias handle, contributes no code.
703 }
704 unset( $bucket[ $handle ] );
705 $dropped = true;
706 break;
707 }
708 }
709 } while ( $dropped );
710
711 return $bucket;
712 }
713
714 /**
715 * Convert a possibly-relative `src` into an absolute URL.
716 *
717 * Public because Css_Combine_Buffer resolves the same URLs from parsed
718 * HTML rather than from the enqueue queue; the logic is identical and a
719 * second copy would drift. (#195)
720 */
721 public static function to_absolute_url( string $src ): string {
722 if ( '' === $src ) {
723 return '';
724 }
725 if ( 0 === strpos( $src, '//' ) ) {
726 return ( is_ssl() ? 'https:' : 'http:' ) . $src;
727 }
728 if ( 0 === strpos( $src, '/' ) ) {
729 $home = home_url();
730 $home = (string) preg_replace( '#/$#', '', $home );
731 return $home . $src;
732 }
733 return $src;
734 }
735
736 /**
737 * Resolve an absolute URL to a local filesystem path + mtime, or
738 * return null if the URL isn't on this site / outside web root.
739 *
740 * @return array{url:string,path:string,mtime:int}|null
741 */
742 public static function local_info( string $url ): ?array {
743 if ( '' === $url ) {
744 return null;
745 }
746 $home = home_url();
747 if ( 0 !== strpos( $url, $home ) ) {
748 return null;
749 }
750 // Strip query / fragment for filesystem lookup; keep them in
751 // the URL we hash against.
752 $clean = strtok( $url, '?' );
753 if ( ! is_string( $clean ) ) {
754 return null;
755 }
756 $path = ABSPATH . ltrim( str_replace( $home, '', $clean ), '/' );
757 if ( ! file_exists( $path ) || ! is_readable( $path ) ) {
758 return null;
759 }
760 return array(
761 'url' => $url,
762 'path' => $path,
763 'mtime' => (int) filemtime( $path ),
764 );
765 }
766
767 /**
768 * Minify a concatenated CSS body in memory (issue #331).
769 *
770 * In memory on purpose: the parts have already had their `url(...)`
771 * references rewritten by rewrite_url_paths() against each source's own
772 * location, so handing the text to the file-based minifier — which
773 * rebases relative URLs against the target path — would rewrite them a
774 * second time and break every font and background image in the bundle.
775 *
776 * Fails open. A minifier exception, or output that came back empty or
777 * implausibly short, returns the original text: shipping a slightly
778 * larger stylesheet is a rounding error, shipping a truncated one
779 * unstyles the site. Same reasoning as Minifier::minify_file()'s own
780 * guard against mid-template-literal truncation.
781 */
782 public static function minify_css_body( string $css ): string {
783 if ( '' === trim( $css ) || ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
784 return $css;
785 }
786
787 try {
788 $minifier = new \MatthiasMullie\Minify\CSS();
789 $minifier->add( $css );
790 $out = (string) $minifier->minify();
791 } catch ( \Throwable $e ) {
792 return $css;
793 }
794
795 // A minifier that returns nothing, or that claims a >95% saving on
796 // already-minified inputs, has failed rather than succeeded.
797 if ( '' === trim( $out ) || strlen( $out ) < ( strlen( $css ) / 20 ) ) {
798 return $css;
799 }
800
801 return $out;
802 }
803
804 /**
805 * Cache key for a queue-built bundle. See combined_key().
806 *
807 * @param array<string,array<mixed>> $bucket handle => info map.
808 * @param string $kind 'css' or 'js'.
809 */
810 private static function cache_key( array $bucket, string $kind ): string {
811 $parts = array();
812 foreach ( $bucket as $handle => $info ) {
813 $parts[] = array(
814 'id' => (string) $handle,
815 'path' => (string) ( $info['path'] ?? '' ),
816 'url' => (string) ( $info['url'] ?? '' ),
817 );
818 }
819 return self::combined_key( $parts, $kind );
820 }
821
822 /**
823 * Name for a combined file, derived from what goes into it.
824 *
825 * md5 of the plugin version, this site's home_url(), and per part its
826 * path and content key. The old key was each part's path and mtime,
827 * which had two faults:
828 *
829 * - It followed mtime, not bytes, so an in-place edit that kept the
830 * mtime (or an edit to an @import child) served stale CSS, and a
831 * touch with no change orphaned every cached page.
832 * - It left out the site. The combined body rewrites every url() to an
833 * ABSOLUTE URL built from home_url(), and min/combined/ is shared by
834 * every blog on a network, so the first subsite to render a set of
835 * sheets wrote its own origin into the file every other subsite then
836 * served. home_url() is taken whole, path included, because a
837 * subdirectory subsite's URLs carry its path.
838 *
839 * A part that is already one of our minified files is named by content,
840 * so its basename is its content key and costs no IO. Any other part gets
841 * its key from Asset_Manifest, which also follows the @import children
842 * the combiner inlines.
843 *
844 * @param array<int,array{id?:string,path:string,url?:string}> $parts In output order.
845 * @param string $kind 'css' or 'js'.
846 */
847 public static function combined_key( array $parts, string $kind ): string {
848 $signature = array(
849 defined( 'XSPEED_VERSION' ) ? (string) XSPEED_VERSION : '',
850 Asset_Manifest::SCHEMA,
851 rtrim( (string) home_url(), '/' ),
852 $kind,
853 );
854 foreach ( $parts as $part ) {
855 $path = (string) ( $part['path'] ?? '' );
856 $signature[] = array(
857 (string) ( $part['id'] ?? '' ),
858 $path,
859 self::part_content_key( $path, (string) ( $part['url'] ?? '' ), $kind ),
860 );
861 }
862 return md5( (string) wp_json_encode( $signature ) );
863 }
864
865 /**
866 * Content key for one part of a combined file.
867 *
868 * @param string $path Absolute path of the part.
869 * @param string $url URL the combiner resolves the part's imports against.
870 * @param string $kind 'css' or 'js'.
871 */
872 private static function part_content_key( string $path, string $url, string $kind ): string {
873 if ( '' === $path ) {
874 return '';
875 }
876 // Our own minified output, min/<content key>.<ext>: the name is the key.
877 $min_root = rtrim( Minifier::min_dir(), '/' ) . '/';
878 if ( 0 === strpos( $path, $min_root ) && false === strpos( substr( $path, strlen( $min_root ) ), '/' ) ) {
879 return basename( $path );
880 }
881 $key = Asset_Manifest::key_for(
882 'part-' . $kind,
883 $path,
884 static function () use ( $kind, $url ): array {
885 return 'css' === $kind && '' !== $url ? Asset_Manifest::import_dependencies( $url ) : array();
886 }
887 );
888 return null === $key ? 'unreadable' : $key;
889 }
890
891 private static function read_local_file( string $path ): string {
892 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem unavailable on frontend; we already validated existence + readability.
893 $body = file_get_contents( $path );
894 return is_string( $body ) ? $body : '';
895 }
896
897 /**
898 * Recursively inline `@import url(...)` (and `@import "...";`)
899 * statements. Cycles detected via depth limit; cross-origin imports
900 * are left alone.
901 */
902 public static function resolve_imports( string $css, string $base_url, int $depth ): string {
903 if ( $depth > self::MAX_IMPORT_DEPTH ) {
904 return $css;
905 }
906 return (string) preg_replace_callback(
907 '#@import\s+(?:url\s*\(\s*)?["\']?([^"\')]+)["\']?\s*\)?\s*([^;]*);#i',
908 static function ( $m ) use ( $base_url, $depth ) {
909 $target = trim( (string) $m[1] );
910 $media = trim( (string) $m[2] );
911 $abs = self::resolve_relative( $target, $base_url );
912 $info = self::local_info( $abs );
913 if ( null === $info ) {
914 return $m[0]; // external or unresolvable; leave as-is.
915 }
916 $body = self::read_local_file( $info['path'] );
917 if ( '' === $body ) {
918 return $m[0];
919 }
920 $body = self::rewrite_url_paths( $body, $info['url'] );
921 $body = self::resolve_imports( $body, $info['url'], $depth + 1 );
922 if ( '' !== $media ) {
923 return '@media ' . $media . " {\n" . $body . "\n}\n";
924 }
925 return $body;
926 },
927 $css
928 );
929 }
930
931 /**
932 * Rewrite every `url(...)` whose argument is a relative path so it
933 * becomes absolute (resolved against the source file's URL). The
934 * combined file lives at a different location, so relative paths
935 * would otherwise break.
936 *
937 * Skips: absolute URLs (http://, https://, //), data: URIs,
938 * `#fragment-only`, blob:, javascript: (which shouldn't appear in
939 * CSS but won't crash).
940 */
941 public static function rewrite_url_paths( string $css, string $base_url ): string {
942 return (string) preg_replace_callback(
943 '#url\(\s*(["\']?)([^"\')]+)\1\s*\)#i',
944 static function ( $m ) use ( $base_url ) {
945 $quote = $m[1];
946 $raw = trim( (string) $m[2] );
947 if ( '' === $raw ) {
948 return $m[0];
949 }
950 if (
951 0 === strpos( $raw, 'data:' )
952 || 0 === strpos( $raw, 'blob:' )
953 || 0 === strpos( $raw, '#' )
954 || 0 === strpos( $raw, 'http://' )
955 || 0 === strpos( $raw, 'https://' )
956 || 0 === strpos( $raw, '//' )
957 ) {
958 return $m[0];
959 }
960 $abs = self::resolve_relative( $raw, $base_url );
961 return 'url(' . $quote . $abs . $quote . ')';
962 },
963 $css
964 );
965 }
966
967 /**
968 * Resolve a relative URL (no scheme, no leading /) against a base
969 * URL. Public so tests can exercise it directly.
970 */
971 public static function resolve_relative( string $target, string $base_url ): string {
972 // Order matters — '//' is a prefix of '/' so the protocol-relative
973 // check must happen BEFORE the leading-slash anchor.
974 if ( 0 === strpos( $target, '//' ) ) {
975 return ( is_ssl() ? 'https:' : 'http:' ) . $target;
976 }
977 if ( 0 === strpos( $target, '/' ) ) {
978 $parts = wp_parse_url( $base_url );
979 if ( ! is_array( $parts ) ) {
980 return $target;
981 }
982 $origin = ( $parts['scheme'] ?? 'http' ) . '://' . ( $parts['host'] ?? '' );
983 if ( isset( $parts['port'] ) ) {
984 $origin .= ':' . $parts['port'];
985 }
986 return $origin . $target;
987 }
988 if ( 0 === strpos( $target, 'http://' ) || 0 === strpos( $target, 'https://' ) ) {
989 return $target;
990 }
991 // Relative. Strip filename from base, resolve.
992 $base_path = (string) wp_parse_url( $base_url, PHP_URL_PATH );
993 $base_dir = rtrim( str_replace( basename( $base_path ), '', $base_path ), '/' );
994 $parts = wp_parse_url( $base_url );
995 $origin = ( $parts['scheme'] ?? 'http' ) . '://' . ( $parts['host'] ?? '' );
996 if ( isset( $parts['port'] ) ) {
997 $origin .= ':' . $parts['port'];
998 }
999 // Collapse ../
1000 $joined = $base_dir . '/' . $target;
1001 $segments = array();
1002 foreach ( explode( '/', $joined ) as $seg ) {
1003 if ( '' === $seg || '.' === $seg ) {
1004 continue;
1005 }
1006 if ( '..' === $seg ) {
1007 array_pop( $segments );
1008 continue;
1009 }
1010 $segments[] = $seg;
1011 }
1012 return $origin . '/' . implode( '/', $segments );
1013 }
1014
1015 private static function ensure_dir( string $dir ): void {
1016 if ( ! is_dir( $dir ) ) {
1017 wp_mkdir_p( $dir );
1018 }
1019 $silence = $dir . '/index.php';
1020 if ( ! file_exists( $silence ) ) {
1021 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- bootstrap-time helper, WP_Filesystem unavailable.
1022 file_put_contents( $silence, "<?php\n// Silence is golden.\n" );
1023 }
1024 }
1025
1026 /**
1027 * Record that a combined file is STILL IN USE, by refreshing its mtime.
1028 *
1029 * The combiner only writes a file when it does not already exist, so a
1030 * stylesheet in continuous use kept its original mtime forever. Cache GC
1031 * collects `min/` on a 30-day max-age measured from mtime, so it read a
1032 * file served on every page load as "untouched for a month" and deleted
1033 * it — leaving every cached page pointing at a 404. (#190)
1034 *
1035 * This is the cheap half of the fix: it keeps a live asset LOOKING young,
1036 * which is what the age heuristic needed all along. The real guarantee is
1037 * `Cache_GC`'s reachability check — an asset a cached page references is
1038 * never collected whatever its age — because mtime cannot help an asset
1039 * whose page is a static HIT that never runs PHP.
1040 *
1041 * Rate-limited to once a day per file: this runs on every render, and a
1042 * touch() per request would be pointless filesystem traffic when the
1043 * threshold is measured in days.
1044 */
1045 private static function mark_in_use( string $file ): void {
1046 $now = time();
1047 $mtime = @filemtime( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a racing purge can unlink between the exists check and here; false is handled.
1048 if ( false === $mtime || ( $now - $mtime ) < DAY_IN_SECONDS ) {
1049 return;
1050 }
1051 // 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.
1052 @touch( $file, $now ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort liveness hint; a failure is not worth an error on a page render.
1053 }
1054 }
1055