Cut duplicated and speculative code across the package

An over-engineering audit of the whole tree, applied in five reviewed
batches. Behaviour stays the same except where UPGRADE.md says otherwise.

PHP: the showcase and error-page stylesheets are prebuilt into
resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import
(first occurrence kept, the order an application's build gives), instead
of Stylesheets::bundle() inlining imports on every request; only the
import walk DesignGuard needs stays. SchemeStylesheet::withProfiles()
replaces three copies of the scheme-plus-profiles loop, material:scheme
leaves spec and contrast checks to the node script that already made
them, and the error page's scheme cache, the hashed view namespace, the
translations path with no lang/ folder and DesignGuard's 1.x-name hints
are gone.

JS: the androidx shape port progress.js and both bin scripts each carried
lives once in resources/js/shapes.js (the generated SVGs are unchanged);
util.js holds ringIndex(), ms(), reopenGuard() and remember(), which
were written out several times; listeners are released through
AbortController; tooltip.js's hoverPopover() serves the rich tooltip too.

CSS: every rule for an element inside the navigation rail queries
`--md-navigation-rail-value` instead of repeating the seven collapsed
conditions under five media branches; badge, alert, progress, slider and
button read one non-inheriting colour-role table (components/color.css);
the dialog chrome, the submenu's popover chrome, the chip's state layer
and touch target, and the visually-hidden inputs use the shared rules
they copied; foundation/tokens.css is folded into foundation.css.

Views: Support\Field and Support\Link replace the error-key, bound-value
and link-attribute blocks copied into the fields and link components;
the timepicker period group, the menu filter and the showcase head are
partials; the datepicker's steppers and entry fields are loops; component
docblocks no longer restate SKILL.md.

Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces
four per-group files, DesignGuardTest and the layout-component tests use
datasets, browser tests share one ready() helper, CSS parsing lives in
ComponentStylesheet alone. docs/audits and the finding IDs citing it are
removed, as are pestphp/pest-plugin-laravel, the unused composer scripts
and check:font; the lint job runs in the feature job, which now installs
node packages so the prebuilt-stylesheet staleness test runs in CI.

Feature suite 1177 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-17 19:29:21 +02:00
co-authored by Claude Opus 5
parent 471d927e64
commit 247c596c3a
233 changed files with 16635 additions and 10579 deletions
+18 -125
View File
@@ -23,31 +23,6 @@ use Throwable;
*/
class ErrorPage
{
/**
* The error layout's own stylesheet, which imports button.css and shape.css (and through
* button.css, icon.css, loading.css and tooltip.css) for what the layout renders.
*/
protected const string LAYOUT = 'components/error-page.css';
/**
* The package stylesheets the fallback bundles: the foundation (every application needs it)
* and the error layout's own stylesheet — the same rules a Vite build would serve the page,
* `Stylesheets::bundle()` inlining them in PHP instead. Not `all.css`: that would bundle every
* component's rules for a page that draws three of them.
*
* @var list<string>
*/
protected const array FILES = ['foundation.css', self::LAYOUT];
/**
* The scheme half of the fallback, cached per worker like `Stylesheets::bundle()`'s own cache:
* by the scheme file's path and mtime, so a request that never regenerates it never rebuilds
* this either, and a fresh `material:scheme` run is picked up the moment its mtime changes.
*
* @var array<string, array{css: string, mtime: int|false}>
*/
protected static array $schemeCache = [];
/**
* The application's Vite tags, or null when they cannot be made. They bring the foundation,
* the tokens, the scheme and the font; the layout's own rules come from `layoutStyles()`
@@ -88,10 +63,10 @@ class ErrorPage
}
/**
* The error layout's own rules for a page with its build: `Stylesheets::bundle()` of
* `components/error-page.css` and the stylesheets it imports, inlined beside the application's
* Vite tags, which bring everything else. `bundle()` caches it for the worker by its files'
* mtimes, as it does the fallback's half.
* The error layout's own rules for a page with its build: `components/error-page.css` and the
* stylesheets it imports, prebuilt into resources/dist/error-page.css (`npm run
* build:stylesheets`) and inlined beside the application's Vite tags, which bring everything
* else.
*
* Nothing is dropped from it, because nothing in it needs a build: the package's only
* `@font-face` is `tokens/font.css`'s, which only the foundation reaches and the application's
@@ -103,115 +78,33 @@ class ErrorPage
*/
public static function layoutStyles(): HtmlString
{
return new HtmlString(Stylesheets::bundle([self::path(self::LAYOUT)]));
return new HtmlString(self::dist('error-page.css'));
}
/**
* A stylesheet for a page without its build: the foundation and the error layout's own rules
* `Stylesheets::bundle()` of `self::FILES`, the same inlining a Vite build does, so the fallback
* can never drift from `resources/css/components/error-page.css` the way a hand-written copy
* did — with every `@font-face` dropped (`withoutFontFace()`: there is no build here to serve
* the font file, and a relative `url()` a request 404s on is worse than none) and the
* application's colours appended in `material-scheme.css`'s own shape (`schemeStylesheet()`):
* the standard level, the medium and high contrast levels under `[data-contrast]`, and a block
* per colour profile under `[data-scheme]`. `<x-theme-script>` has already written
* A stylesheet for a page without its build: the foundation and the error layout's own rules,
* prebuilt into resources/dist/error-page-fallback.css — the same inlining a Vite build does,
* so the fallback can never drift from `resources/css/components/error-page.css` the way a
* hand-written copy did, and without the `@font-face` (there is no build here to serve the font
* file; the brand typeface's stack in tokens/type.css falls back to the system fonts) — with
* the application's colours appended in `material-scheme.css`'s own shape
* (`SchemeStylesheet::withProfiles()`): the standard level, the medium and high contrast levels
* under `[data-contrast]`, and a block per colour profile under `[data-scheme]`, built from
* `Scheme::forStylesheet()` on every call. `<x-theme-script>` has already written
* `data-theme`, `data-contrast` and `data-scheme` onto `<html>` by the time this tag is parsed
* (it renders first), so the browser resolves the right block on its own — nothing here decides
* an active profile in PHP.
*/
public static function fallbackStyles(): HtmlString
{
$files = array_map(self::path(...), self::FILES);
return new HtmlString(self::withoutFontFace(Stylesheets::bundle($files)).self::schemeStylesheet());
return new HtmlString(self::dist('error-page-fallback.css').SchemeStylesheet::withProfiles(...Scheme::forStylesheet()));
}
/**
* A package stylesheet's absolute path, from its path under `resources/css/`.
* A prebuilt stylesheet under resources/dist/.
*/
protected static function path(string $file): string
protected static function dist(string $file): string
{
return dirname(__DIR__, 2)."/resources/css/{$file}";
}
/**
* `$css` with every `@font-face` block dropped, wherever it sits: not a search for the block's
* text (fragile the moment a comment or a value is reworded) but a structural read — find
* `@font-face` outside comments and strings, then remove the balanced `{ … }` that follows it,
* brace for brace, however the block itself is written. Both reads go through
* `Stylesheets::mask()`, because the bundle's comments name the rule too (foundation/base.css's
* and error-page.css's headers): read as the rule, such a mention cut from inside its comment to
* the end of the next balanced block, leaving the comment open over the rules after it —
* base.css's `html` rule and icon.css's rules left the fallback that way. `tokens/font.css`'s
* block is the only one the bundle carries, and it is also the bundle's only relative `url()`
* (button.css, icon.css, shape.css and the rest reference nothing on disk), so this removal is
* also what leaves the fallback with no `url()` to a font it cannot serve. The typeface itself
* degrades on its own: `--md-ref-typeface-brand` (tokens/type.css) lists `ui-sans-serif`,
* `system-ui` and `sans-serif` right after the brand name, so with no `@font-face` to resolve
* it the browser skips straight to that system stack — nothing here has to name one.
*/
protected static function withoutFontFace(string $css): string
{
$masked = Stylesheets::mask($css);
$result = '';
$offset = 0;
$length = strlen($css);
while (($start = stripos($masked, '@font-face', $offset)) !== false) {
$result .= substr($css, $offset, $start - $offset);
$open = strpos($masked, '{', $start);
if ($open === false) {
$offset = $start + strlen('@font-face');
continue;
}
$depth = 1;
$i = $open + 1;
while ($i < $length && $depth > 0) {
$depth += match ($masked[$i]) {
'{' => 1,
'}' => -1,
default => 0,
};
$i++;
}
$offset = $i;
}
return $result.substr($css, $offset);
}
/**
* The application's colours, in `material-scheme.css`'s own selector shape
* (`SchemeStylesheet::levels()`), from `Scheme::forStylesheet()` rather than a generated file
* that may not exist yet. Cached by the scheme file's path and mtime (`self::$schemeCache`):
* `Scheme` itself reads on every call and keeps nothing, so a resolver or a regenerated file
* applies at once everywhere else it is asked — this cache is safe only because the CSS built
* here never depends on which profile is *active*, only on the file's own content.
*/
protected static function schemeStylesheet(): string
{
$path = (string) config('livewire-material.scheme');
clearstatcache(true, $path);
$mtime = is_file($path) ? filemtime($path) : false;
if (isset(self::$schemeCache[$path]) && self::$schemeCache[$path]['mtime'] === $mtime) {
return self::$schemeCache[$path]['css'];
}
$scheme = Scheme::forStylesheet($path);
$css = SchemeStylesheet::levels($scheme['scheme']);
foreach ($scheme['profiles'] as $name => $profile) {
$css .= "\n".SchemeStylesheet::levels($profile, "[data-scheme='{$name}']");
}
self::$schemeCache[$path] = ['css' => $css, 'mtime' => $mtime];
return $css;
return (string) file_get_contents(dirname(__DIR__, 2)."/resources/dist/{$file}");
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace NoNameWeb\LivewireMaterial\Support;
use Illuminate\Support\Arr;
use Livewire\Livewire;
/**
* What every bound field shares: turning a plain form's `name` into the errors key its
* validation messages live under, reading those messages out of the bag, and reading a bound
* property's value straight from the Livewire component rendering it.
*/
class Field
{
/**
* A plain form's field is named, not bound: its errors are under its name (`files[]` →
* `files`, `a[b]` → `a.b`). A bound field's key is the `wire:model` name itself.
*/
public static function key(mixed $model, mixed $name): ?string
{
return $model ?? (filled($name) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $name) : null);
}
/**
* The messages under $key, or [] with no key or no error bag. `$wildcard` also collects
* `$key.*` — a file input's items, a bound array's own entries — deduplicated, for a field
* whose errors can land on the value itself and on what it holds.
*/
public static function messages(mixed $errors, ?string $key, bool $wildcard = false): array
{
if ($key === null || $errors === null) {
return [];
}
if (! $wildcard) {
return Arr::flatten($errors->get($key));
}
return array_values(array_unique(Arr::flatten([$errors->get($key), $errors->get("{$key}.*")])));
}
/**
* The bound property's live value, straight from the rendering Livewire component instead of
* the prop it was given — so a render reflects a value the server already changed. Falls back
* to $fallback with no bound model, no live component (`x-model` alone, or a component
* rendered outside Livewire), or, with $requireFilled, a live value that is blank.
*/
public static function bound(?string $model, mixed $fallback = null, bool $requireFilled = false): mixed
{
if ($model === null || ($component = Livewire::current()) === null) {
return $fallback;
}
$value = data_get($component, $model);
return ! $requireFilled || filled($value) ? $value : $fallback;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace NoNameWeb\LivewireMaterial\Support;
use Illuminate\View\ComponentAttributeBag;
/**
* What a control that can also be a link shares: a button, a FAB, a menu item, a chip, a
* navigation item. Every reader is `null` for a control that draws no `href`, so the result only
* ever adds an attribute the caller's `merge()` would otherwise write itself.
*/
class Link
{
/**
* `href`, `target`, `rel` and `wire:navigate` for a control that may render as `<a>`.
* `$noWireNavigate` is false for the callers with no such prop of their own (a FAB has none).
*
* @return array<string, mixed>
*/
public static function attributes(bool $isLink, ?string $link, bool $external, ComponentAttributeBag $attributes, bool $noWireNavigate = false): array
{
return [
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
];
}
}
+3 -14
View File
@@ -129,20 +129,9 @@ class Scheme
public static function forStylesheet(?string $path = null): array
{
$data = static::data($path);
$profiles = [];
if (is_array($data['profiles'] ?? null)) {
foreach ($data['profiles'] as $name => $profile) {
if (! is_string($name) || preg_match('/^[a-z0-9-]+$/', $name) !== 1 || ! is_array($profile)) {
continue;
}
$profiles[$name] = [
'label' => is_string($profile['label'] ?? null) ? $profile['label'] : Str::headline($name),
...static::fullScheme($profile),
];
}
}
$profiles = collect(static::profilesFrom($data))
->map(fn (array $profile, string $name): array => ['label' => $profile['label'], ...static::fullScheme($data['profiles'][$name])])
->all();
if ($profiles === []) {
return ['scheme' => static::fullScheme($data), 'profiles' => []];
+24 -3
View File
@@ -9,9 +9,9 @@ namespace NoNameWeb\LivewireMaterial\Support;
* a caller gives one — every colour profile again under its own prefix (`[data-scheme='name']`).
*
* `SchemeCommand::handle()` writes this to the application's `material-scheme.css`; plan step 40
* moved the shape itself here so `ErrorPage::fallbackStyles()` can draw the same scheme, from
* `Scheme::forStylesheet()` rather than a generated file, without a second copy of the selectors to
* keep in step with the command's.
* moved the shape itself here so `ErrorPage::fallbackStyles()` and the showcase's stylesheet can
* draw the same scheme, from `Scheme::forStylesheet()` rather than a generated file, without a
* second copy of the selectors to keep in step with the command's.
*/
final class SchemeStylesheet
{
@@ -49,6 +49,27 @@ final class SchemeStylesheet
return $css;
}
/**
* A scheme and its colour profiles, whole: the default's `levels()` as the plain blocks, then
* every profile's again under its own `[data-scheme]`. A profile's two-attribute selectors
* outrank the plain ones, and its one-attribute selector comes after `:root`, which it only
* ties with: the order is part of the format. The descendant selectors keep a nested
* `data-theme` panel (a light card on a dark page) in the page's profile.
*
* @param array{contrast: array<string, array{light: array<string, string>, dark: array<string, string>}>, light: array<string, string>, dark: array<string, string>} $scheme
* @param array<string, array{contrast: array<string, array{light: array<string, string>, dark: array<string, string>}>, light: array<string, string>, dark: array<string, string>}> $profiles
*/
public static function withProfiles(array $scheme, array $profiles): string
{
$css = self::levels($scheme);
foreach ($profiles as $name => $profile) {
$css .= "\n".self::levels($profile, "[data-scheme='{$name}']");
}
return $css;
}
/**
* The light and the dark selectors of one block, under the given prefix. Without a prefix the
* plain blocks stand on `:root` and on the attribute alone; with one, the prefix is on `<html>`
+11 -334
View File
@@ -2,105 +2,21 @@
namespace NoNameWeb\LivewireMaterial\Support;
use RuntimeException;
/**
* Bundles a list of package stylesheets into one string, without a JavaScript build: the showcase
* and the error page's fallback (`ErrorPage::fallbackStyles()`, plan step 40) serve CSS on their
* own, outside the application's Vite build, so nothing deduplicates their `@import`s for them —
* a browser's native `@import` fetches and applies every occurrence, it does not skip a file it
* has already loaded. This class does in PHP what Vite's bundled postcss-import does in the
* application's build: it inlines every `@import`, once per file, first occurrence kept.
*
* `bundle()` walks each given file's `@import`s depth-first, in source order, and replaces each
* one with the imported file's own content (which is itself walked the same way) the first time
* that file is reached; a later `@import` of the same file, anywhere in the graph, is simply
* dropped, since the file's rules are already in the output at their first position. A file is
* one file however it is reached (`./a.css` or `../components/a.css`): identity is its real path.
* Because every package stylesheet imports what it depends on before its own rules, a file's
* rules always follow those of every file it imports, so an override that ties on specificity
* still lands after the rule it overrides.
*
* Only the form every package stylesheet writes is accepted — `@import './x.css';` or
* `@import url('./x.css');`, before any rule. Anything the output could not keep meaning the same
* throws, naming the file: a `layer()`, `supports()` or media condition (inlining would drop it),
* an absolute URL or path (a browser would ignore an `@import` in the middle of the bundle), an
* `@import` after a rule or inside a block (CSS ignores the first; the second would nest a whole
* file's layers inside another), or a target that does not exist — which is also what a package
* specifier such as `tailwindcss` is to a resolver that knows no `node_modules`. A leading
* `@charset` (and a byte-order mark) is dropped from every file, since it means nothing past the
* first byte of a stylesheet; the bundle is UTF-8. The `@layer` statement every package
* stylesheet opens with is ordinary content: a repeated `@layer` statement naming the same
* sub-layers in the same order is valid anywhere at the top level and changes nothing.
*
* A `url()` outside comments and strings — a font, an SVG mask — is rewritten when it is relative
* (an absolute URL or path, a `data:` URI or a `#` fragment is left alone): resolved against the
* file that wrote it, then re-expressed relative to the directory of `$files[0]` —
* `resources/css/` for the package's own entry points, so without `$base` a font or an SVG stays
* reachable exactly as it would from a file sitting there. `$base` is the directory or URL that
* directory is served as, and every relative `url()` is joined onto it.
*
* Bundles are cached for the life of the process, per file list and `$base`, and a cached bundle
* is served only while every file it inlined — nested imports included — keeps its modification
* time.
* The package's stylesheet graph as `DesignGuard::missingStylesheets()` and `unusedStylesheets()`
* (plan step 41) read it: which files a CSS entry reaches through its relative `@import`s. The
* stylesheets the package serves on its own are not bundled here but prebuilt, by
* `npm run build:stylesheets` (bin/stylesheets.mjs), into resources/dist/.
*/
final class Stylesheets
{
/**
* @var array<string, array{css: string, files: array<string, int|false>}>
*/
private static array $cache = [];
/**
* Every file in `$files`, with its own and every transitively imported file's content inlined
* once, first occurrence kept; relative `url()`s resolved against `$base` (or, without one,
* against the directory of `$files[0]`).
*
* @param list<string> $files
*/
public static function bundle(array $files, ?string $base = null): string
{
if ($files === []) {
return '';
}
$key = serialize([$files, $base]);
if (isset(self::$cache[$key]) && self::fresh(self::$cache[$key]['files'])) {
return self::$cache[$key]['css'];
}
$root = dirname(self::resolve($files[0]));
$seen = [];
$css = '';
foreach ($files as $file) {
$css .= self::inline(self::resolve($file), $seen, $root, $base);
}
self::$cache[$key] = ['css' => $css, 'files' => $seen];
return $css;
}
/**
* Drops every cached bundle: a test that writes its own fixtures between calls needs a fresh
* read, since the cache otherwise lives for the rest of the worker process.
*/
public static function resetCache(): void
{
self::$cache = [];
}
/**
* Every file `$files` reaches, transitively, through their relative `@import`s — real paths,
* `$files` themselves included — without concatenating anything.
* `DesignGuard::missingStylesheets()` (plan step 41) asks this of an application's CSS entry,
* which may import what `bundle()` refuses and a Vite build still resolves: a package name
* (`tailwindcss`), a URL, an import with a `layer()` or media condition. So unlike `bundle()`
* this never throws: an import naming a URL, an absolute path or a file that does not exist
* relative to its stylesheet is skipped, and a conditional import still counts as reaching
* its file.
* `$files` themselves included — without concatenating anything. An application's CSS entry
* may import what only a Vite build resolves — a package name (`tailwindcss`), a URL, an import
* with a `layer()` or media condition — so this never throws: an import naming a URL, an
* absolute path or a file that does not exist relative to its stylesheet is skipped, and a
* conditional import still counts as reaching its file.
*
* @param list<string> $files
* @return list<string>
@@ -152,88 +68,12 @@ final class Stylesheets
}
}
/**
* @param array<string, int|false> $files every file a cached bundle inlined, with its mtime then
*/
private static function fresh(array $files): bool
{
foreach ($files as $file => $mtime) {
clearstatcache(true, $file);
if ((is_file($file) ? filemtime($file) : false) !== $mtime) {
return false;
}
}
return true;
}
/**
* `$path`'s real path; `$importer` and `$target`, when given, name the stylesheet and the
* `@import` target as written, for the exception.
*/
private static function resolve(string $path, ?string $importer = null, ?string $target = null): string
{
$real = realpath($path);
if ($real === false || ! is_file($real)) {
throw new RuntimeException($importer === null
? "Stylesheets::bundle() cannot read \"{$path}\": it does not exist."
: "{$importer} imports \"{$target}\", which does not exist.");
}
return $real;
}
/**
* @param array<string, int|false> $seen every file already inlined, by real path, with its mtime
*/
private static function inline(string $file, array &$seen, string $root, ?string $base): string
{
if (array_key_exists($file, $seen)) {
return '';
}
// Marked before its imports are walked, so a cycle back to this file ends there.
$seen[$file] = filemtime($file);
$css = (string) preg_replace('/\A(?:\xEF\xBB\xBF)?@charset\s+"[^"]*"\s*;/i', '', (string) file_get_contents($file));
$dir = dirname($file);
$result = '';
$cursor = 0;
// A child's own content is already rewritten against its own directory by its own call
// below, so only this file's own text — never the spliced-in content of another file —
// is passed to rewriteUrls() here; running it again over the concatenated result would
// resolve an already-rewritten url() a second time, against the wrong directory.
foreach (self::imports($file, $css) as $import) {
$result .= self::rewriteUrls(substr($css, $cursor, $import['start'] - $cursor), $dir, $root, $base);
$result .= self::inline(self::resolve($dir.'/'.$import['target'], self::name($file), $import['target']), $seen, $root, $base);
$cursor = $import['end'];
}
return $result.self::rewriteUrls(substr($css, $cursor), $dir, $root, $base);
}
/**
* The path a stylesheet is named by in an exception: relative to the package, when it is
* inside it, so a message reads `components/button.css imports …` rather than a long
* absolute path.
*/
private static function name(string $file): string
{
$package = realpath(dirname(__DIR__, 2).'/resources/css').'/';
return str_starts_with($file, $package) ? substr($file, strlen($package)) : $file;
}
/**
* `$css` with every comment and quoted string blinded to spaces (newlines kept), same length —
* so a search on it for `@import` or `url(` never matches one written inside a comment or a
* string, while every offset still lines up with `$css` itself. Public for
* `ErrorPage::withoutFontFace()`, which searches a bundle for `@font-face` the same way.
* string, while every offset still lines up with `$css` itself.
*/
public static function mask(string $css): string
private static function mask(string $css): string
{
$masked = $css;
$length = strlen($css);
@@ -280,167 +120,4 @@ final class Stylesheets
return $masked;
}
/**
* Every `@import` statement in `$css` outside comments and strings, each with the byte range of
* the whole statement (the semicolon included) and the target it names — or an exception, for
* one the bundle could not inline without changing what it means (see the class comment).
*
* @return list<array{start: int, end: int, target: string}>
*/
private static function imports(string $file, string $css): array
{
$masked = self::mask($css);
$imports = [];
if (preg_match_all('/@import\b/i', $masked, $matches, PREG_OFFSET_CAPTURE) === 0) {
return $imports;
}
// Where the text allowed before the next @import starts: only whitespace, blinded comments,
// `@charset` and `@layer` statements and earlier @imports may precede one.
$allowedFrom = 0;
foreach ($matches[0] as [, $start]) {
$end = strpos($masked, ';', $start);
$end = $end === false ? strlen($css) : $end + 1;
$statement = trim(substr($css, $start, $end - $start));
$before = (string) preg_replace('/@(?:layer|charset)\s[^;{}]*;/i', '', substr($masked, $allowedFrom, $start - $allowedFrom));
if (trim($before) !== '') {
throw new RuntimeException(self::name($file)." writes `{$statement}` after a rule or inside a block; Stylesheets::bundle() inlines an @import only before every rule, where CSS reads it.");
}
if (preg_match('/^@import\s*(?:url\(\s*([\'"]?)([^\'"()\s]+)\1\s*\)|([\'"])(.+?)\3)\s*;?$/is', $statement, $match, PREG_UNMATCHED_AS_NULL) !== 1) {
throw new RuntimeException(self::name($file)." writes `{$statement}`; Stylesheets::bundle() inlines a plain @import only, without layer(), supports() or a media condition, which the bundle would drop.");
}
$target = $match[2] ?? $match[4] ?? '';
if (preg_match('~^(?:[a-z][a-z0-9+.-]*:|/)~i', $target) === 1) {
throw new RuntimeException(self::name($file)." imports \"{$target}\", an absolute URL or path; Stylesheets::bundle() inlines files relative to the importing stylesheet only.");
}
$imports[] = ['start' => $start, 'end' => $end, 'target' => $target];
$allowedFrom = $end;
}
return $imports;
}
/**
* Every relative `url()` in `$css` re-expressed against `$root` (or `$base`, once joined onto
* it below), resolved first against `$dir`, the directory of the file that wrote it. An
* absolute URL or path, a `data:` URI or a `#` fragment is left untouched.
*/
private static function rewriteUrls(string $css, string $dir, string $root, ?string $base): string
{
$masked = self::mask($css);
if (preg_match_all('/url\(/i', $masked, $matches, PREG_OFFSET_CAPTURE) === 0) {
return $css;
}
$result = '';
$cursor = 0;
foreach ($matches[0] as [, $start]) {
if ($start < $cursor) {
continue;
}
$close = strpos($masked, ')', $start);
if ($close === false) {
continue;
}
$call = substr($css, $start, $close - $start + 1);
if (preg_match('/^url\(\s*([\'"]?)(.*?)\1\s*\)$/is', $call, $match) !== 1 || ! self::isRelativeUrl($match[2])) {
continue;
}
$result .= substr($css, $cursor, $start - $cursor);
$absolute = self::normalise($dir.'/'.$match[2]);
$target = self::relative($root, $absolute);
$target = $base === null ? $target : self::join($base, $target);
$result .= "url({$match[1]}{$target}{$match[1]})";
$cursor = $close + 1;
}
return $result.substr($css, $cursor);
}
private static function isRelativeUrl(string $value): bool
{
if ($value === '' || str_starts_with($value, '#') || str_starts_with($value, '/')) {
return false;
}
return preg_match('/^[a-z][a-z0-9+.-]*:/i', $value) !== 1;
}
/**
* `$path`, its `.` and `..` segments resolved away (a `..` at the root of an absolute path
* stays at the root). Pure string handling — the target need not exist on disk, since a bundle
* a test builds may not ship the fonts and SVGs it points at.
*/
private static function normalise(string $path): string
{
$absolute = str_starts_with($path, '/');
$segments = [];
foreach (explode('/', $path) as $segment) {
if ($segment === '' || $segment === '.') {
continue;
}
if ($segment === '..' && $segments !== [] && end($segments) !== '..') {
array_pop($segments);
} elseif ($segment !== '..' || ! $absolute) {
$segments[] = $segment;
}
}
return ($absolute ? '/' : '').implode('/', $segments);
}
/**
* `$to`, expressed relative to the directory `$from`.
*/
private static function relative(string $from, string $to): string
{
$fromParts = array_values(array_filter(explode('/', $from), fn (string $part): bool => $part !== ''));
$toParts = array_values(array_filter(explode('/', $to), fn (string $part): bool => $part !== ''));
$i = 0;
while ($i < count($fromParts) && $i < count($toParts) && $fromParts[$i] === $toParts[$i]) {
$i++;
}
$path = implode('/', [
...array_fill(0, count($fromParts) - $i, '..'),
...array_slice($toParts, $i),
]);
return $path === '' ? '.' : $path;
}
/**
* `$relative` appended to `$base`, a directory or a URL: `..` in `$relative` climbs out of
* `$base`'s own path, never past a URL's scheme and host.
*/
private static function join(string $base, string $relative): string
{
$base = rtrim($base, '/');
if (preg_match('~^([a-z][a-z0-9+.-]*://[^/]*)(/.*)?$~i', $base, $match) === 1) {
return $match[1].self::normalise(($match[2] ?? '').'/'.$relative);
}
return self::normalise($base.'/'.$relative);
}
}