| 1 |
<?php |
| 2 |
/** |
| 3 |
* Asset_Manifest: content keys for source files, validated by stat. |
| 4 |
* |
| 5 |
* Minified and combined files used to be named after the source's path and |
| 6 |
* mtime. That name says nothing about the bytes: |
| 7 |
* |
| 8 |
* - An edit that keeps the mtime (rsync -t, a restore, some deploy tools) |
| 9 |
* keeps the old name, so a regenerated file serves new bytes under a URL |
| 10 |
* browsers and CDNs have cached for a year. |
| 11 |
* - An edit to a file the source pulls in (an @import child, a small image |
| 12 |
* the minifier embeds as a data: URI) changes nothing in the name at all. |
| 13 |
* - A touch with no change mints a new name, so every cached page linking |
| 14 |
* the old one has to go. |
| 15 |
* |
| 16 |
* So output is named after its content instead, and this class answers |
| 17 |
* "what is the content key of this source?" without hashing on every render. |
| 18 |
* Per source it keeps a small manifest: |
| 19 |
* |
| 20 |
* { |
| 21 |
* v: XSPEED_VERSION + schema, |
| 22 |
* kind: what the key is for ('css', 'js', 'part-css', 'part-js'), |
| 23 |
* src: the source path, |
| 24 |
* sig: { path: [mtime, ctime, size, ino] | null } for the source and |
| 25 |
* every dependency, null for a candidate that does not exist yet, |
| 26 |
* src_md5: md5 over the source and dependency bytes, |
| 27 |
* key: the content key (md5 of the minified output, or src_md5), or |
| 28 |
* '' when these bytes could not be minified, |
| 29 |
* at: when the signature was taken, |
| 30 |
* } |
| 31 |
* |
| 32 |
* On render the signature is re-taken with stat() alone. When it matches and |
| 33 |
* is older than RACY_SECONDS, the stored key is trusted. Otherwise the bytes |
| 34 |
* are hashed, and the caller rebuilds only when the hash differs. That is the |
| 35 |
* git index rule, including its "racy" clause: a file changed within the same |
| 36 |
* clock tick as the signature can keep its mtime and size, so a signature |
| 37 |
* taken that close to a write is never trusted on its own. |
| 38 |
* |
| 39 |
* Manifests live per blog under `min/manifests/<blog_id>/`, so one subsite |
| 40 |
* can drop its own without touching another's. Outputs stay shared: the same |
| 41 |
* bytes have the same name whichever blog produced them. |
| 42 |
* |
| 43 |
* @package XSpeed |
| 44 |
*/ |
| 45 |
|
| 46 |
declare(strict_types=1); |
| 47 |
|
| 48 |
namespace XSpeed; |
| 49 |
|
| 50 |
defined( 'ABSPATH' ) || exit; |
| 51 |
|
| 52 |
final class Asset_Manifest { |
| 53 |
|
| 54 |
/** Bump when the manifest shape or the key derivation changes. */ |
| 55 |
public const SCHEMA = 1; |
| 56 |
|
| 57 |
/** A signature this close to a file's own timestamps is not trusted. */ |
| 58 |
public const RACY_SECONDS = 2; |
| 59 |
|
| 60 |
/** Subdirectory of min/ that holds the manifests. */ |
| 61 |
public const SUBDIR = 'manifests'; |
| 62 |
|
| 63 |
/** |
| 64 |
* Extensions matthiasmullie/minify embeds as data: URIs (CSS.php |
| 65 |
* `$importExtensions`). Mirrored rather than read from the library, which |
| 66 |
* keeps it protected. |
| 67 |
*/ |
| 68 |
private const EMBEDDABLE = array( |
| 69 |
'gif', |
| 70 |
'png', |
| 71 |
'jpe', |
| 72 |
'jpg', |
| 73 |
'jpeg', |
| 74 |
'svg', |
| 75 |
'woff', |
| 76 |
'woff2', |
| 77 |
'avif', |
| 78 |
'apng', |
| 79 |
'webp', |
| 80 |
'tif', |
| 81 |
'tiff', |
| 82 |
'xbm', |
| 83 |
); |
| 84 |
|
| 85 |
/** Deepest @import chain the dependency walk follows. */ |
| 86 |
private const MAX_WALK_DEPTH = 16; |
| 87 |
|
| 88 |
/** |
| 89 |
* Fixed "now", set only by tests to step past the racy window without |
| 90 |
* sleeping. |
| 91 |
* |
| 92 |
* @var int|null |
| 93 |
*/ |
| 94 |
private static $clock = null; |
| 95 |
|
| 96 |
/** |
| 97 |
* Freeze the clock at $now, or unfreeze it with null. Tests only. |
| 98 |
* |
| 99 |
* @param int|null $now Timestamp. |
| 100 |
*/ |
| 101 |
public static function freeze_clock( ?int $now ): void { |
| 102 |
self::$clock = $now; |
| 103 |
} |
| 104 |
|
| 105 |
/** Current time, honouring a frozen clock. */ |
| 106 |
private static function now(): int { |
| 107 |
return null === self::$clock ? time() : self::$clock; |
| 108 |
} |
| 109 |
|
| 110 |
/** Version stamp every manifest carries. */ |
| 111 |
public static function version(): string { |
| 112 |
return ( defined( 'XSPEED_VERSION' ) ? (string) XSPEED_VERSION : '0' ) . '+' . self::SCHEMA; |
| 113 |
} |
| 114 |
|
| 115 |
/** Blog whose manifests this request reads and writes. */ |
| 116 |
public static function blog_id(): int { |
| 117 |
if ( function_exists( 'is_multisite' ) && is_multisite() && function_exists( 'get_current_blog_id' ) ) { |
| 118 |
return max( 1, (int) get_current_blog_id() ); |
| 119 |
} |
| 120 |
return 1; |
| 121 |
} |
| 122 |
|
| 123 |
/** Root of every blog's manifests. */ |
| 124 |
public static function root(): string { |
| 125 |
return rtrim( (string) XSPEED_CACHE_DIR, '/' ) . '/min/' . self::SUBDIR; |
| 126 |
} |
| 127 |
|
| 128 |
/** |
| 129 |
* Manifest directory for one blog. |
| 130 |
* |
| 131 |
* @param int|null $blog_id Blog, or the current one. |
| 132 |
*/ |
| 133 |
public static function dir( ?int $blog_id = null ): string { |
| 134 |
return self::root() . '/' . ( null === $blog_id ? self::blog_id() : max( 1, $blog_id ) ); |
| 135 |
} |
| 136 |
|
| 137 |
/** |
| 138 |
* Manifest file for one source. |
| 139 |
* |
| 140 |
* @param string $kind What the key is for ('css', 'js', 'part-css', 'part-js'). |
| 141 |
* @param string $source Absolute source path. |
| 142 |
*/ |
| 143 |
public static function file( string $kind, string $source ): string { |
| 144 |
return self::dir() . '/' . md5( $kind . '|' . $source ) . '.json'; |
| 145 |
} |
| 146 |
|
| 147 |
/** |
| 148 |
* Content key for a source, rebuilding only when its bytes changed. |
| 149 |
* |
| 150 |
* @param string $kind Manifest kind ('css', 'js', 'part-css', 'part-js'). |
| 151 |
* @param string $source Absolute source path. |
| 152 |
* @param callable $find_deps fn( string $source ): string[], the dependency paths. |
| 153 |
* @param callable|null $build fn( string $source ): string|null|false. Builds |
| 154 |
* the output and returns its key; null when the |
| 155 |
* bytes cannot be built, false when the output |
| 156 |
* could not be written. Null here means the key |
| 157 |
* IS the source hash and there is nothing to build. |
| 158 |
* @param callable|null $has_output fn( string $key ): bool. Whether the output for |
| 159 |
* a key is still on disk. |
| 160 |
* @return string|null Key, or null when the source is unreadable or the build failed. |
| 161 |
*/ |
| 162 |
public static function key_for( string $kind, string $source, callable $find_deps, ?callable $build = null, ?callable $has_output = null ): ?string { |
| 163 |
$file = self::file( $kind, $source ); |
| 164 |
$stored = self::read( $file ); |
| 165 |
$now = self::now(); |
| 166 |
|
| 167 |
$output_ok = static function ( string $key ) use ( $has_output ): bool { |
| 168 |
return null === $has_output || (bool) $has_output( $key ); |
| 169 |
}; |
| 170 |
|
| 171 |
// Fast path: stat only. A recorded failure is an answer too: the same |
| 172 |
// bytes would fail the same way, so they are not rebuilt every render. |
| 173 |
if ( null !== $stored && self::is_fresh( $stored, $now ) ) { |
| 174 |
if ( '' === (string) $stored['key'] ) { |
| 175 |
return null; |
| 176 |
} |
| 177 |
if ( $output_ok( (string) $stored['key'] ) ) { |
| 178 |
return (string) $stored['key']; |
| 179 |
} |
| 180 |
} |
| 181 |
|
| 182 |
// Slow path. Stat BEFORE reading: a write that lands between the two |
| 183 |
// leaves a signature older than the bytes, so the next render sees a |
| 184 |
// mismatch and hashes again. The other order could pin new bytes |
| 185 |
// under an old signature. |
| 186 |
$paths = array_merge( array( $source ), self::unique_paths( (array) $find_deps( $source ), $source ) ); |
| 187 |
$sig = self::signature( $paths ); |
| 188 |
if ( null === $sig[ $source ] ) { |
| 189 |
return null; |
| 190 |
} |
| 191 |
$src_md5 = self::content_hash( $paths ); |
| 192 |
if ( null === $src_md5 ) { |
| 193 |
return null; |
| 194 |
} |
| 195 |
|
| 196 |
$same_bytes = null !== $stored |
| 197 |
&& self::version() === ( $stored['v'] ?? '' ) |
| 198 |
&& ( $stored['src_md5'] ?? '' ) === $src_md5; |
| 199 |
$stored_key = null === $stored ? '' : (string) ( $stored['key'] ?? '' ); |
| 200 |
|
| 201 |
if ( $same_bytes && '' !== $stored_key && $output_ok( $stored_key ) ) { |
| 202 |
$key = $stored_key; |
| 203 |
} elseif ( $same_bytes && '' === $stored_key ) { |
| 204 |
// These bytes failed before; they still would. |
| 205 |
$key = ''; |
| 206 |
} elseif ( null === $build ) { |
| 207 |
$key = $src_md5; |
| 208 |
} else { |
| 209 |
$built = $build( $source ); |
| 210 |
// false: the output could not be WRITTEN. That can clear up (a |
| 211 |
// full disk, a permission fixed), so nothing is recorded. |
| 212 |
if ( false === $built ) { |
| 213 |
return null; |
| 214 |
} |
| 215 |
// null: these bytes cannot be minified. Recorded as an empty key, |
| 216 |
// so the fast path answers "no" without minifying again until the |
| 217 |
// bytes change. |
| 218 |
$key = is_string( $built ) ? $built : ''; |
| 219 |
} |
| 220 |
|
| 221 |
$manifest = array( |
| 222 |
'v' => self::version(), |
| 223 |
'kind' => $kind, |
| 224 |
'src' => $source, |
| 225 |
'sig' => $sig, |
| 226 |
'src_md5' => $src_md5, |
| 227 |
'key' => $key, |
| 228 |
'at' => $now, |
| 229 |
); |
| 230 |
|
| 231 |
// Unchanged and still inside the racy window: rewriting would only |
| 232 |
// store another racy signature, once per render, until the window |
| 233 |
// passes. Leave it; the first render after the window writes it. |
| 234 |
$unchanged = null !== $stored |
| 235 |
&& ( $stored['sig'] ?? null ) === $sig |
| 236 |
&& ( $stored['src_md5'] ?? '' ) === $src_md5 |
| 237 |
&& ( $stored['key'] ?? '' ) === $key |
| 238 |
&& self::version() === ( $stored['v'] ?? '' ); |
| 239 |
if ( ! ( $unchanged && self::is_racy( $sig, $now ) ) ) { |
| 240 |
self::write( $file, $manifest ); |
| 241 |
} |
| 242 |
|
| 243 |
return '' === $key ? null : $key; |
| 244 |
} |
| 245 |
|
| 246 |
/** |
| 247 |
* Whether a stored manifest can be trusted without reading any bytes. |
| 248 |
* |
| 249 |
* @param array<string,mixed> $manifest Stored manifest. |
| 250 |
* @param int $now Current time. |
| 251 |
*/ |
| 252 |
public static function is_fresh( array $manifest, int $now ): bool { |
| 253 |
if ( self::version() !== ( $manifest['v'] ?? '' ) || ! isset( $manifest['key'] ) || ! is_string( $manifest['key'] ) || ! is_array( $manifest['sig'] ?? null ) ) { |
| 254 |
return false; |
| 255 |
} |
| 256 |
// A signature from the future means the clock moved back since it |
| 257 |
// was taken; nothing about it can be trusted. |
| 258 |
$at = (int) ( $manifest['at'] ?? 0 ); |
| 259 |
if ( $at > $now ) { |
| 260 |
return false; |
| 261 |
} |
| 262 |
$stored = $manifest['sig']; |
| 263 |
if ( self::signature( array_keys( $stored ) ) !== $stored ) { |
| 264 |
return false; |
| 265 |
} |
| 266 |
// Racy is judged against when the signature was TAKEN, not now: a |
| 267 |
// file written in the same tick as the stat may have changed after it |
| 268 |
// without moving mtime or size. |
| 269 |
return ! self::is_racy( $stored, $at ); |
| 270 |
} |
| 271 |
|
| 272 |
/** |
| 273 |
* Is any timestamp in the signature within RACY_SECONDS of $at? |
| 274 |
* |
| 275 |
* @param array<string,mixed> $sig Signature. |
| 276 |
* @param int $at When it was taken. |
| 277 |
*/ |
| 278 |
public static function is_racy( array $sig, int $at ): bool { |
| 279 |
foreach ( $sig as $entry ) { |
| 280 |
if ( ! is_array( $entry ) ) { |
| 281 |
continue; |
| 282 |
} |
| 283 |
$newest = max( (int) ( $entry[0] ?? 0 ), (int) ( $entry[1] ?? 0 ) ); |
| 284 |
if ( $at - $newest < self::RACY_SECONDS ) { |
| 285 |
return true; |
| 286 |
} |
| 287 |
} |
| 288 |
return false; |
| 289 |
} |
| 290 |
|
| 291 |
/** |
| 292 |
* [mtime, ctime, size, ino] per path, null for a path that is not a file. |
| 293 |
* |
| 294 |
* @param string[] $paths Paths. |
| 295 |
* @return array<string,array<int,int>|null> |
| 296 |
*/ |
| 297 |
public static function signature( array $paths ): array { |
| 298 |
$sig = array(); |
| 299 |
foreach ( $paths as $path ) { |
| 300 |
$path = (string) $path; |
| 301 |
clearstatcache( true, $path ); |
| 302 |
$stat = @stat( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a missing dependency is an expected answer (null), not an error. |
| 303 |
if ( false === $stat || ! is_file( $path ) ) { |
| 304 |
$sig[ $path ] = null; |
| 305 |
continue; |
| 306 |
} |
| 307 |
$sig[ $path ] = array( (int) $stat['mtime'], (int) $stat['ctime'], (int) $stat['size'], (int) $stat['ino'] ); |
| 308 |
} |
| 309 |
return $sig; |
| 310 |
} |
| 311 |
|
| 312 |
/** |
| 313 |
* md5 over the bytes of every path, in order. A missing dependency counts |
| 314 |
* as a fixed marker, so it appearing later changes the hash. |
| 315 |
* |
| 316 |
* @param string[] $paths Source first, then dependencies. |
| 317 |
* @return string|null Null when the source itself cannot be read. |
| 318 |
*/ |
| 319 |
public static function content_hash( array $paths ): ?string { |
| 320 |
$ctx = hash_init( 'md5' ); |
| 321 |
$first = true; |
| 322 |
foreach ( $paths as $path ) { |
| 323 |
$path = (string) $path; |
| 324 |
hash_update( $ctx, "\0" . $path . "\0" ); |
| 325 |
$ok = is_file( $path ) && is_readable( $path ) && @hash_update_file( $ctx, $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable dependency is folded into the hash as missing. |
| 326 |
if ( ! $ok ) { |
| 327 |
if ( $first ) { |
| 328 |
return null; |
| 329 |
} |
| 330 |
hash_update( $ctx, "\0missing\0" ); |
| 331 |
} |
| 332 |
$first = false; |
| 333 |
} |
| 334 |
return hash_final( $ctx ); |
| 335 |
} |
| 336 |
|
| 337 |
/** |
| 338 |
* Every file a minified stylesheet's bytes depend on, recursively. |
| 339 |
* |
| 340 |
* Mirrors matthiasmullie/minify's CSS::combineImports() and importFiles(): |
| 341 |
* |
| 342 |
* - `@import` (url() or quoted) whose path is not data:, http(s): or |
| 343 |
* root-relative and carries no query. The library inlines those. |
| 344 |
* Their own imports and embeds are followed in turn. |
| 345 |
* - `url()` with an embeddable extension (or none), of ANY size: the |
| 346 |
* library embeds only files up to 5 KB, and a file that grows past |
| 347 |
* that, or shrinks under it, changes the output too. |
| 348 |
* |
| 349 |
* Candidates that do not exist yet are kept. The library skips them, but |
| 350 |
* creating one later changes the output, so its absence is part of the |
| 351 |
* signature. |
| 352 |
* |
| 353 |
* @param string $source Absolute stylesheet path. |
| 354 |
* @return string[] Dependency paths, source excluded. |
| 355 |
*/ |
| 356 |
public static function css_dependencies( string $source ): array { |
| 357 |
$found = array(); |
| 358 |
$visited = array( $source => true ); |
| 359 |
self::walk_css( $source, $found, $visited, 0 ); |
| 360 |
unset( $found[ $source ] ); |
| 361 |
return array_keys( $found ); |
| 362 |
} |
| 363 |
|
| 364 |
/** |
| 365 |
* @param string $source Stylesheet being scanned. |
| 366 |
* @param array<string,true> $found Accumulated dependencies. |
| 367 |
* @param array<string,true> $visited Stylesheets already scanned. |
| 368 |
* @param int $depth Import depth. |
| 369 |
*/ |
| 370 |
private static function walk_css( string $source, array &$found, array &$visited, int $depth ): void { |
| 371 |
if ( $depth > self::MAX_WALK_DEPTH ) { |
| 372 |
return; |
| 373 |
} |
| 374 |
$css = @file_get_contents( $source ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- unreadable file has no dependencies; reading a local stylesheet during page render; WP_Filesystem needs admin context. |
| 375 |
if ( ! is_string( $css ) || '' === $css ) { |
| 376 |
return; |
| 377 |
} |
| 378 |
$dir = dirname( $source ); |
| 379 |
|
| 380 |
// Same two patterns as CSS::combineImports(). |
| 381 |
$import_patterns = array( |
| 382 |
'/@import\s+url\((?P<quotes>["\']?)(?P<path>.+?)(?P=quotes)\)\s*(?P<media>[^;]*)\s*;?/ix', |
| 383 |
'/@import\s+(?P<quotes>["\'])(?P<path>.+?)(?P=quotes)\s*(?P<media>[^;]*)\s*;?/ix', |
| 384 |
); |
| 385 |
foreach ( $import_patterns as $pattern ) { |
| 386 |
if ( ! preg_match_all( $pattern, $css, $matches, PREG_SET_ORDER ) ) { |
| 387 |
continue; |
| 388 |
} |
| 389 |
foreach ( $matches as $match ) { |
| 390 |
$rel = (string) $match['path']; |
| 391 |
// Same test as the library's CSS::canImportByPath. |
| 392 |
if ( 1 === preg_match( '/^(data:|https?:|\\/)/', $rel ) ) { |
| 393 |
continue; |
| 394 |
} |
| 395 |
$candidate = self::candidate( $dir, $rel ); |
| 396 |
if ( null === $candidate ) { |
| 397 |
continue; |
| 398 |
} |
| 399 |
$found[ $candidate ] = true; |
| 400 |
if ( ! isset( $visited[ $candidate ] ) && is_file( $candidate ) ) { |
| 401 |
$visited[ $candidate ] = true; |
| 402 |
self::walk_css( $candidate, $found, $visited, $depth + 1 ); |
| 403 |
} |
| 404 |
} |
| 405 |
} |
| 406 |
|
| 407 |
// Same pattern and extension rule as CSS::importFiles(). |
| 408 |
if ( preg_match_all( '/url\((["\']?)(.+?)\\1\)/i', $css, $urls, PREG_SET_ORDER ) ) { |
| 409 |
foreach ( $urls as $match ) { |
| 410 |
$rel = (string) $match[2]; |
| 411 |
if ( 0 === stripos( $rel, 'data:' ) ) { |
| 412 |
continue; |
| 413 |
} |
| 414 |
$dot = strrchr( $rel, '.' ); |
| 415 |
$extension = false === $dot ? '' : substr( $dot, 1 ); |
| 416 |
if ( '' !== $extension && ! in_array( $extension, self::EMBEDDABLE, true ) ) { |
| 417 |
continue; |
| 418 |
} |
| 419 |
$candidate = self::candidate( $dir, $rel ); |
| 420 |
if ( null !== $candidate ) { |
| 421 |
$found[ $candidate ] = true; |
| 422 |
} |
| 423 |
} |
| 424 |
} |
| 425 |
} |
| 426 |
|
| 427 |
/** |
| 428 |
* The path the library would try for a reference, or null when it would |
| 429 |
* never read one (remote, or carrying a query). Same construction as the |
| 430 |
* library: dirname(source) . '/' . reference, unnormalised. |
| 431 |
* |
| 432 |
* @param string $dir Directory of the referencing file. |
| 433 |
* @param string $rel Reference as written. |
| 434 |
*/ |
| 435 |
private static function candidate( string $dir, string $rel ): ?string { |
| 436 |
$path = $dir . '/' . $rel; |
| 437 |
if ( strlen( $path ) >= PHP_MAXPATHLEN ) { |
| 438 |
return null; |
| 439 |
} |
| 440 |
$parsed = wp_parse_url( $path ); |
| 441 |
if ( ! is_array( $parsed ) || isset( $parsed['host'] ) || isset( $parsed['query'] ) ) { |
| 442 |
return null; |
| 443 |
} |
| 444 |
return $path; |
| 445 |
} |
| 446 |
|
| 447 |
/** |
| 448 |
* Files the combiner inlines into a part through `@import`, to the same |
| 449 |
* depth it follows them (Asset_Combiner::resolve_imports()). |
| 450 |
* |
| 451 |
* Resolution mirrors the combiner: each reference is resolved against the |
| 452 |
* part's URL, and a URL under home_url() maps to ABSPATH. Candidates that |
| 453 |
* do not exist yet are kept, for the same reason as css_dependencies(). |
| 454 |
* |
| 455 |
* @param string $url Absolute URL of the part. |
| 456 |
* @return string[] Dependency paths. |
| 457 |
*/ |
| 458 |
public static function import_dependencies( string $url ): array { |
| 459 |
$found = array(); |
| 460 |
self::walk_imports( $url, $found, 0 ); |
| 461 |
return array_keys( $found ); |
| 462 |
} |
| 463 |
|
| 464 |
/** |
| 465 |
* @param string $url URL whose file is scanned. |
| 466 |
* @param array<string,true> $found Accumulated dependencies. |
| 467 |
* @param int $depth Same counter resolve_imports() uses. |
| 468 |
*/ |
| 469 |
private static function walk_imports( string $url, array &$found, int $depth ): void { |
| 470 |
if ( $depth > Asset_Combiner::MAX_IMPORT_DEPTH ) { |
| 471 |
return; |
| 472 |
} |
| 473 |
$path = self::url_to_candidate( $url ); |
| 474 |
if ( null === $path || ! is_file( $path ) ) { |
| 475 |
return; |
| 476 |
} |
| 477 |
$css = @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- unreadable file has no dependencies; reading a local stylesheet during page render; WP_Filesystem needs admin context. |
| 478 |
if ( ! is_string( $css ) || false === stripos( $css, '@import' ) ) { |
| 479 |
return; |
| 480 |
} |
| 481 |
if ( ! preg_match_all( '#@import\s+(?:url\s*\(\s*)?["\']?([^"\')]+)["\']?\s*\)?\s*([^;]*);#i', $css, $matches, PREG_SET_ORDER ) ) { |
| 482 |
return; |
| 483 |
} |
| 484 |
foreach ( $matches as $match ) { |
| 485 |
$child_url = Asset_Combiner::resolve_relative( trim( (string) $match[1] ), $url ); |
| 486 |
$child = self::url_to_candidate( $child_url ); |
| 487 |
if ( null === $child || isset( $found[ $child ] ) ) { |
| 488 |
continue; |
| 489 |
} |
| 490 |
$found[ $child ] = true; |
| 491 |
self::walk_imports( $child_url, $found, $depth + 1 ); |
| 492 |
} |
| 493 |
} |
| 494 |
|
| 495 |
/** |
| 496 |
* Asset_Combiner::local_info()'s mapping without its existence check. |
| 497 |
* |
| 498 |
* @param string $url Absolute URL. |
| 499 |
*/ |
| 500 |
private static function url_to_candidate( string $url ): ?string { |
| 501 |
$home = home_url(); |
| 502 |
if ( '' === $url || 0 !== strpos( $url, $home ) ) { |
| 503 |
return null; |
| 504 |
} |
| 505 |
$clean = strtok( $url, '?' ); |
| 506 |
if ( ! is_string( $clean ) ) { |
| 507 |
return null; |
| 508 |
} |
| 509 |
return ABSPATH . ltrim( str_replace( $home, '', $clean ), '/' ); |
| 510 |
} |
| 511 |
|
| 512 |
/** |
| 513 |
* Read a manifest, or null when absent or unreadable. |
| 514 |
* |
| 515 |
* @param string $file Manifest path. |
| 516 |
* @return array<string,mixed>|null |
| 517 |
*/ |
| 518 |
public static function read( string $file ): ?array { |
| 519 |
$raw = @file_get_contents( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a missing manifest is the normal first-render answer; our own cache sidecar, read on the front end where WP_Filesystem is unavailable. |
| 520 |
if ( ! is_string( $raw ) || '' === $raw ) { |
| 521 |
return null; |
| 522 |
} |
| 523 |
$decoded = json_decode( $raw, true ); |
| 524 |
return is_array( $decoded ) ? $decoded : null; |
| 525 |
} |
| 526 |
|
| 527 |
/** |
| 528 |
* Write a manifest atomically. |
| 529 |
* |
| 530 |
* @param string $file Manifest path. |
| 531 |
* @param array<string,mixed> $manifest Contents. |
| 532 |
*/ |
| 533 |
public static function write( string $file, array $manifest ): bool { |
| 534 |
$dir = dirname( $file ); |
| 535 |
if ( ! is_dir( $dir ) ) { |
| 536 |
wp_mkdir_p( $dir ); |
| 537 |
} |
| 538 |
$json = wp_json_encode( $manifest ); |
| 539 |
return is_string( $json ) && self::write_atomic( $file, $json ); |
| 540 |
} |
| 541 |
|
| 542 |
/** |
| 543 |
* Write bytes so a reader sees either nothing or the whole file. |
| 544 |
* |
| 545 |
* Writes a temp file in the SAME directory (rename() is only atomic |
| 546 |
* within one filesystem), checks every byte landed, then renames it over |
| 547 |
* the target. A full disk or a quota truncates the temp file, which is |
| 548 |
* then discarded rather than published. |
| 549 |
* |
| 550 |
* @param string $file Target path. |
| 551 |
* @param string $bytes Contents. |
| 552 |
*/ |
| 553 |
public static function write_atomic( string $file, string $bytes ): bool { |
| 554 |
$dir = dirname( $file ); |
| 555 |
if ( ! is_dir( $dir ) ) { |
| 556 |
return false; |
| 557 |
} |
| 558 |
$tmp = $file . '.' . bin2hex( random_bytes( 6 ) ) . '.tmp'; |
| 559 |
$written = @file_put_contents( $tmp, $bytes ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- failure is reported through the return value; WP_Filesystem requires admin context; this runs on front-end renders. |
| 560 |
if ( strlen( $bytes ) !== $written ) { |
| 561 |
@unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink -- discarding our own partial temp file. |
| 562 |
return false; |
| 563 |
} |
| 564 |
if ( ! @rename( $tmp, $file ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename -- failure is reported through the return value; atomic publish of our own cache file; WP_Filesystem::move() is not atomic and needs admin context. |
| 565 |
@unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink -- discarding our own temp file. |
| 566 |
return false; |
| 567 |
} |
| 568 |
return true; |
| 569 |
} |
| 570 |
|
| 571 |
/** |
| 572 |
* Dependencies in first-seen order, without the source or duplicates. |
| 573 |
* |
| 574 |
* @param array<int,mixed> $deps Dependency paths. |
| 575 |
* @param string $source Source path. |
| 576 |
* @return string[] |
| 577 |
*/ |
| 578 |
private static function unique_paths( array $deps, string $source ): array { |
| 579 |
$out = array(); |
| 580 |
foreach ( $deps as $dep ) { |
| 581 |
$dep = (string) $dep; |
| 582 |
if ( '' !== $dep && $dep !== $source ) { |
| 583 |
$out[ $dep ] = true; |
| 584 |
} |
| 585 |
} |
| 586 |
return array_keys( $out ); |
| 587 |
} |
| 588 |
} |
| 589 |
|