PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.0
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 1.1.3 1.1.4 1.1.5 1.1.6 All 31 releases
xspeed / includes / class-asset-combiner.php

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

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