PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.8
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.8
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / class-asset-combiner.php

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

670 lines 25.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Asset_Combiner — concatenates enqueued local CSS / JS into a single
4 * combined file per type. Hooked from LegacyMinifier when the
5 * `combine_css` / `combine_js` toggles are on.
6 *
7 * Algorithm (CSS):
8 * 1. wp_enqueue_scripts @ 999 — walk WP_Styles->queue, partition into
9 * local + external. External (full http(s):// to other origins,
10 * data: URIs, protocol-relative pointing elsewhere) stay enqueued
11 * as-is; local handles get pulled out of the queue.
12 * 2. Build cache key = md5(JSON({handle => [src, mtime]})). When the
13 * combined file already exists for that key, skip generation.
14 * 3. Otherwise: read each source body, resolve recursive @import
15 * statements (depth-limited), rewrite url(...) paths to absolute,
16 * concat with a small `/* xspeed: HANDLE *​/` header per chunk for
17 * debug-traceability, write to XSPEED_CACHE_DIR/min/combined/.
18 * 4. Register the combined file as a single new handle
19 * `xspeed-combined-css` and re-add it to the queue. The original
20 * handles stay registered (so other plugins that look them up
21 * still find their metadata) but are pulled from the queue —
22 * they won't print <link> tags.
23 *
24 * JS path is the same, minus @import (no JS analogue) and url()
25 * rewriting (JS strings are too varied to safely rewrite). External
26 * + async + deferred scripts (deferred via WP_Scripts->add_data
27 * 'strategy' OR the script_loader_tag filter from Minify_Filters)
28 * stay un-combined.
29 *
30 * Cache lives in {$min_dir}/combined/ — separate from the per-file
31 * minify cache so purge can target them independently if needed.
32 *
33 * @package XSpeed
34 */
35
36 declare(strict_types=1);
37
38 namespace XSpeed;
39
40 defined( 'ABSPATH' ) || exit;
41
42 final class Asset_Combiner {
43
44 public const MAX_IMPORT_DEPTH = 3;
45
46 /**
47 * Path to the combine cache dir. Created on first write.
48 */
49 public static function cache_dir(): string {
50 return trailingslashit( XSPEED_CACHE_DIR ) . 'min/combined';
51 }
52
53 /**
54 * URL prefix matching cache_dir(). Built from content_url, not by
55 * string-replacing filesystem paths (see class-minifier.php for the
56 * same rationale).
57 *
58 * 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 $key = self::cache_key( $bucket );
221 $dir = self::cache_dir();
222 $out_file = $dir . '/combined-' . $key . '.js';
223 $out_url = self::cache_url() . '/combined-' . $key . '.js';
224
225 if ( ! file_exists( $out_file ) ) {
226 self::ensure_dir( $dir );
227 $contents = '';
228 foreach ( $bucket as $handle => $info ) {
229 $body = self::read_local_file( $info['path'] );
230 if ( '' === $body ) {
231 continue;
232 }
233 $contents .= "/* xspeed: $handle */\n" . $body . "\n;\n";
234 }
235 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem unavailable on frontend.
236 file_put_contents( $out_file, $contents, LOCK_EX );
237 } else {
238 self::mark_in_use( $out_file );
239 }
240
241 foreach ( $bucket as $handle => $info ) {
242 $wp_scripts->dequeue( $handle );
243 }
244 $combined_handle = 'xspeed-combined-js';
245 wp_register_script( $combined_handle, $out_url, array(), $key, true );
246 wp_enqueue_script( $combined_handle );
247 }
248
249 /**
250 * Walk WP_Styles->queue, return the handles whose src is a local file we
251 * can safely combine, grouped BY media type so each media gets its own
252 * combined file. Shape:
253 * [ media => [ handle => [ 'url' => …, 'path' => …, 'mtime' => int, 'src' => … ] ] ].
254 * '' and 'screen' media fold into the 'all' group.
255 */
256 private static function collect_local_handles( \WP_Styles $wp_styles ): array {
257 $groups = array();
258 foreach ( $wp_styles->queue as $handle ) {
259 if ( ! isset( $wp_styles->registered[ $handle ] ) ) {
260 continue;
261 }
262 $reg = $wp_styles->registered[ $handle ];
263 $src = (string) ( $reg->src ?? '' );
264 if ( '' === $src ) {
265 continue;
266 }
267 // Leave WordPress core block styles alone. Block themes (Twenty
268 // Twenty-*, and any FSE theme) load per-block CSS conditionally and
269 // print/track these handles through their own separated-styles
270 // pipeline, often BEFORE this pass. Pulling them into a combined
271 // file fights that pipeline and leaves the page unstyled. These are
272 // already tiny + conditionally loaded, so there's little to gain.
273 // Matches `wp-block-*` handles and any src under wp-includes/blocks/
274 // or the block-library dist dir. (FBS-83633)
275 if (
276 0 === strpos( $handle, 'wp-block-' )
277 || false !== strpos( $src, '/wp-includes/blocks/' )
278 || false !== strpos( $src, '/block-library/' )
279 ) {
280 continue;
281 }
282 $abs = self::to_absolute_url( $src );
283 $info = self::local_info( $abs );
284 if ( null === $info ) {
285 continue; // external or unresolvable — leave in queue.
286 }
287 // Bucket by media type. '' and 'screen' fold into 'all' (both mean
288 // "the on-screen document"); every other media value (print,
289 // max-width queries, …) gets its own group so we can emit one
290 // combined file per media with the right attribute — instead of
291 // dropping non-'all' sheets and starving the combinable bucket on
292 // builder sites. (FBS-83653)
293 $media = (string) ( $reg->args ?? 'all' );
294 if ( '' === $media || 'screen' === $media ) {
295 $media = 'all';
296 }
297 $groups[ $media ][ $handle ] = $info + array( 'src' => $src );
298 }
299 return $groups;
300 }
301
302 private static function collect_local_script_handles( \WP_Scripts $wp_scripts ): array {
303 // `queue` holds only what was explicitly enqueued — never the
304 // dependencies WP resolves at print time. Walking it alone combined
305 // `admin-bar` while silently dropping its `hoverintent-js` dep, so the
306 // bundle called a function that was never in it:
307 // "hoverintent is not a function", and every admin-bar hover menu died
308 // for logged-in visitors. Expand deps first, then emit in dependency
309 // order. (#204)
310 $expanded = self::expand_with_deps( $wp_scripts );
311
312 $out = array();
313 foreach ( $expanded as $handle ) {
314 $info = self::combinable_script_info( $wp_scripts, $handle );
315 if ( null === $info ) {
316 continue;
317 }
318 $out[ $handle ] = $info;
319 }
320
321 // A script whose dependency could NOT be combined (inline data,
322 // async/defer, external CDN) has to stay in the queue itself —
323 // otherwise combining it drops the same dependency a second way.
324 return self::drop_dependents_of_missing( $wp_scripts, $out );
325 }
326
327 /**
328 * The queue plus every registered dependency it pulls in, in dependency
329 * order (a handle always follows everything it depends on).
330 *
331 * Depth-first post-order over `WP_Scripts::$registered[$handle]->deps`.
332 * `$seen` guards a malformed cyclic registration — a cycle can't be
333 * ordered, so the handle is emitted once and the walk unwinds rather than
334 * recursing forever. (#204)
335 *
336 * @param \WP_Scripts $wp_scripts Script registry.
337 * @return string[] Handles, dependencies first.
338 */
339 private static function expand_with_deps( \WP_Scripts $wp_scripts ): array {
340 $ordered = array();
341 $state = array(); // handle => 1 visiting, 2 done.
342
343 $visit = static function ( string $handle ) use ( &$visit, &$ordered, &$state, $wp_scripts ): void {
344 if ( isset( $state[ $handle ] ) ) {
345 return; // already emitted, or we're inside a cycle.
346 }
347 $state[ $handle ] = 1;
348 if ( isset( $wp_scripts->registered[ $handle ] ) ) {
349 foreach ( (array) $wp_scripts->registered[ $handle ]->deps as $dep ) {
350 $visit( (string) $dep );
351 }
352 }
353 $state[ $handle ] = 2;
354 $ordered[] = $handle;
355 };
356
357 foreach ( $wp_scripts->queue as $handle ) {
358 $visit( (string) $handle );
359 }
360
361 return $ordered;
362 }
363
364 /**
365 * Info for a handle that can safely go in the combined bundle, or null
366 * when it must be left in the queue.
367 *
368 * @param \WP_Scripts $wp_scripts Script registry.
369 * @param string $handle Script handle.
370 * @return array<string,mixed>|null
371 */
372 private static function combinable_script_info( \WP_Scripts $wp_scripts, string $handle ): ?array {
373 if ( ! isset( $wp_scripts->registered[ $handle ] ) ) {
374 return null;
375 }
376 $reg = $wp_scripts->registered[ $handle ];
377 $src = (string) ( $reg->src ?? '' );
378 if ( '' === $src ) {
379 // A dependency-only alias (e.g. `jquery`) carries no file of its
380 // own; nothing to concatenate, and its own deps were already
381 // walked, so it isn't a blocker.
382 return null;
383 }
384 // Skip scripts that carry inline-after data (they expect
385 // to run at their original spot).
386 if ( ! empty( $reg->extra['after'] ) || ! empty( $reg->extra['before'] ) || ! empty( $reg->extra['data'] ) ) {
387 return null;
388 }
389 // Skip async / defer-via-strategy.
390 $strategy = $reg->extra['strategy'] ?? '';
391 if ( 'async' === $strategy || 'defer' === $strategy ) {
392 return null;
393 }
394 $abs = self::to_absolute_url( $src );
395 $info = self::local_info( $abs );
396 if ( null === $info ) {
397 return null;
398 }
399 return $info + array( 'src' => $src );
400 }
401
402 /**
403 * Remove any handle whose dependency isn't in the bucket, transitively.
404 *
405 * Combining a script but not its dependency is exactly the #204 failure:
406 * the bundle runs code whose prerequisite never loaded. When a dep can't
407 * be combined — it carries inline data, is async/defer, or lives on a CDN
408 * — the safe move is to leave the dependent in the queue too, where WP
409 * prints both in the right order.
410 *
411 * A handle with no `src` (a pure alias like `jquery`) is not a blocker:
412 * it contributes no code, and its own deps were expanded separately.
413 *
414 * @param \WP_Scripts $wp_scripts Script registry.
415 * @param array<string,mixed> $bucket handle => info, dependency-ordered.
416 * @return array<string,mixed> Filtered bucket, order preserved.
417 */
418 private static function drop_dependents_of_missing( \WP_Scripts $wp_scripts, array $bucket ): array {
419 // Iterate to a fixed point: dropping A can orphan B that depends on A.
420 do {
421 $dropped = false;
422 foreach ( $bucket as $handle => $info ) {
423 if ( ! isset( $wp_scripts->registered[ $handle ] ) ) {
424 continue;
425 }
426 foreach ( (array) $wp_scripts->registered[ $handle ]->deps as $dep ) {
427 $dep = (string) $dep;
428 if ( isset( $bucket[ $dep ] ) ) {
429 continue; // dep is coming along.
430 }
431 $dep_reg = $wp_scripts->registered[ $dep ] ?? null;
432 if ( $dep_reg && '' === (string) ( $dep_reg->src ?? '' ) ) {
433 continue; // alias handle, contributes no code.
434 }
435 unset( $bucket[ $handle ] );
436 $dropped = true;
437 break;
438 }
439 }
440 } while ( $dropped );
441
442 return $bucket;
443 }
444
445 /**
446 * Convert a possibly-relative `src` into an absolute URL.
447 *
448 * Public because Css_Combine_Buffer resolves the same URLs from parsed
449 * HTML rather than from the enqueue queue; the logic is identical and a
450 * second copy would drift. (#195)
451 */
452 public static function to_absolute_url( string $src ): string {
453 if ( '' === $src ) {
454 return '';
455 }
456 if ( 0 === strpos( $src, '//' ) ) {
457 return ( is_ssl() ? 'https:' : 'http:' ) . $src;
458 }
459 if ( 0 === strpos( $src, '/' ) ) {
460 $home = home_url();
461 $home = (string) preg_replace( '#/$#', '', $home );
462 return $home . $src;
463 }
464 return $src;
465 }
466
467 /**
468 * Resolve an absolute URL to a local filesystem path + mtime, or
469 * return null if the URL isn't on this site / outside web root.
470 *
471 * @return array{url:string,path:string,mtime:int}|null
472 */
473 public static function local_info( string $url ): ?array {
474 if ( '' === $url ) {
475 return null;
476 }
477 $home = home_url();
478 if ( 0 !== strpos( $url, $home ) ) {
479 return null;
480 }
481 // Strip query / fragment for filesystem lookup; keep them in
482 // the URL we hash against.
483 $clean = strtok( $url, '?' );
484 if ( ! is_string( $clean ) ) {
485 return null;
486 }
487 $path = ABSPATH . ltrim( str_replace( $home, '', $clean ), '/' );
488 if ( ! file_exists( $path ) || ! is_readable( $path ) ) {
489 return null;
490 }
491 return array(
492 'url' => $url,
493 'path' => $path,
494 'mtime' => (int) filemtime( $path ),
495 );
496 }
497
498 private static function cache_key( array $bucket ): string {
499 $signature = array();
500 foreach ( $bucket as $handle => $info ) {
501 $signature[ $handle ] = array( $info['src'] ?? '', $info['mtime'] ?? 0 );
502 }
503 return md5( wp_json_encode( $signature ) );
504 }
505
506 private static function read_local_file( string $path ): string {
507 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem unavailable on frontend; we already validated existence + readability.
508 $body = file_get_contents( $path );
509 return is_string( $body ) ? $body : '';
510 }
511
512 /**
513 * Recursively inline `@import url(...)` (and `@import "...";`)
514 * statements. Cycles detected via depth limit; cross-origin imports
515 * are left alone.
516 */
517 public static function resolve_imports( string $css, string $base_url, int $depth ): string {
518 if ( $depth > self::MAX_IMPORT_DEPTH ) {
519 return $css;
520 }
521 return (string) preg_replace_callback(
522 '#@import\s+(?:url\s*\(\s*)?["\']?([^"\')]+)["\']?\s*\)?\s*([^;]*);#i',
523 static function ( $m ) use ( $base_url, $depth ) {
524 $target = trim( (string) $m[1] );
525 $media = trim( (string) $m[2] );
526 $abs = self::resolve_relative( $target, $base_url );
527 $info = self::local_info( $abs );
528 if ( null === $info ) {
529 return $m[0]; // external or unresolvable; leave as-is.
530 }
531 $body = self::read_local_file( $info['path'] );
532 if ( '' === $body ) {
533 return $m[0];
534 }
535 $body = self::rewrite_url_paths( $body, $info['url'] );
536 $body = self::resolve_imports( $body, $info['url'], $depth + 1 );
537 if ( '' !== $media ) {
538 return '@media ' . $media . " {\n" . $body . "\n}\n";
539 }
540 return $body;
541 },
542 $css
543 );
544 }
545
546 /**
547 * Rewrite every `url(...)` whose argument is a relative path so it
548 * becomes absolute (resolved against the source file's URL). The
549 * combined file lives at a different location, so relative paths
550 * would otherwise break.
551 *
552 * Skips: absolute URLs (http://, https://, //), data: URIs,
553 * `#fragment-only`, blob:, javascript: (which shouldn't appear in
554 * CSS but won't crash).
555 */
556 public static function rewrite_url_paths( string $css, string $base_url ): string {
557 return (string) preg_replace_callback(
558 '#url\(\s*(["\']?)([^"\')]+)\1\s*\)#i',
559 static function ( $m ) use ( $base_url ) {
560 $quote = $m[1];
561 $raw = trim( (string) $m[2] );
562 if ( '' === $raw ) {
563 return $m[0];
564 }
565 if (
566 0 === strpos( $raw, 'data:' )
567 || 0 === strpos( $raw, 'blob:' )
568 || 0 === strpos( $raw, '#' )
569 || 0 === strpos( $raw, 'http://' )
570 || 0 === strpos( $raw, 'https://' )
571 || 0 === strpos( $raw, '//' )
572 ) {
573 return $m[0];
574 }
575 $abs = self::resolve_relative( $raw, $base_url );
576 return 'url(' . $quote . $abs . $quote . ')';
577 },
578 $css
579 );
580 }
581
582 /**
583 * Resolve a relative URL (no scheme, no leading /) against a base
584 * URL. Public so tests can exercise it directly.
585 */
586 public static function resolve_relative( string $target, string $base_url ): string {
587 // Order matters — '//' is a prefix of '/' so the protocol-relative
588 // check must happen BEFORE the leading-slash anchor.
589 if ( 0 === strpos( $target, '//' ) ) {
590 return ( is_ssl() ? 'https:' : 'http:' ) . $target;
591 }
592 if ( 0 === strpos( $target, '/' ) ) {
593 $parts = wp_parse_url( $base_url );
594 if ( ! is_array( $parts ) ) {
595 return $target;
596 }
597 $origin = ( $parts['scheme'] ?? 'http' ) . '://' . ( $parts['host'] ?? '' );
598 if ( isset( $parts['port'] ) ) {
599 $origin .= ':' . $parts['port'];
600 }
601 return $origin . $target;
602 }
603 if ( 0 === strpos( $target, 'http://' ) || 0 === strpos( $target, 'https://' ) ) {
604 return $target;
605 }
606 // Relative. Strip filename from base, resolve.
607 $base_path = (string) wp_parse_url( $base_url, PHP_URL_PATH );
608 $base_dir = rtrim( str_replace( basename( $base_path ), '', $base_path ), '/' );
609 $parts = wp_parse_url( $base_url );
610 $origin = ( $parts['scheme'] ?? 'http' ) . '://' . ( $parts['host'] ?? '' );
611 if ( isset( $parts['port'] ) ) {
612 $origin .= ':' . $parts['port'];
613 }
614 // Collapse ../
615 $joined = $base_dir . '/' . $target;
616 $segments = array();
617 foreach ( explode( '/', $joined ) as $seg ) {
618 if ( '' === $seg || '.' === $seg ) {
619 continue;
620 }
621 if ( '..' === $seg ) {
622 array_pop( $segments );
623 continue;
624 }
625 $segments[] = $seg;
626 }
627 return $origin . '/' . implode( '/', $segments );
628 }
629
630 private static function ensure_dir( string $dir ): void {
631 if ( ! is_dir( $dir ) ) {
632 wp_mkdir_p( $dir );
633 }
634 $silence = $dir . '/index.php';
635 if ( ! file_exists( $silence ) ) {
636 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- bootstrap-time helper, WP_Filesystem unavailable.
637 file_put_contents( $silence, "<?php\n// Silence is golden.\n" );
638 }
639 }
640
641 /**
642 * Record that a combined file is STILL IN USE, by refreshing its mtime.
643 *
644 * The combiner only writes a file when it does not already exist, so a
645 * stylesheet in continuous use kept its original mtime forever. Cache GC
646 * collects `min/` on a 30-day max-age measured from mtime, so it read a
647 * file served on every page load as "untouched for a month" and deleted
648 * it — leaving every cached page pointing at a 404. (#190)
649 *
650 * This is the cheap half of the fix: it keeps a live asset LOOKING young,
651 * which is what the age heuristic needed all along. The real guarantee is
652 * `Cache_GC`'s reachability check — an asset a cached page references is
653 * never collected whatever its age — because mtime cannot help an asset
654 * whose page is a static HIT that never runs PHP.
655 *
656 * Rate-limited to once a day per file: this runs on every render, and a
657 * touch() per request would be pointless filesystem traffic when the
658 * threshold is measured in days.
659 */
660 private static function mark_in_use( string $file ): void {
661 $now = time();
662 $mtime = @filemtime( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a racing purge can unlink between the exists check and here; false is handled.
663 if ( false === $mtime || ( $now - $mtime ) < DAY_IN_SECONDS ) {
664 return;
665 }
666 // 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.
667 @touch( $file, $now ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort liveness hint; a failure is not worth an error on a page render.
668 }
669 }
670