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

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