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
+36 -272
View File
@@ -4,84 +4,25 @@ use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
/**
* `Stylesheets::bundle()` (plan step 37) is what the showcase and the error page's fallback serve
* without the application's own Vite build, so it has to do in PHP what Vite's bundled
* postcss-import already does for a normal build: inline every `@import` once, first occurrence
* kept, and leave a `url()` reachable from wherever the bundle ends up.
* The stylesheets the package serves on its own — the showcase's and the error page's, outside an
* application's Vite build — are prebuilt into resources/dist/ by `npm run build:stylesheets`
* (bin/stylesheets.mjs), with Vite's own postcss-import: every `@import` inlined once, first
* occurrence kept. This file pins that the committed files are current, the Vite deduplication they
* rely on, and `Stylesheets::resolvedFiles()`, the import graph `DesignGuard` reads.
*
* This lives apart from StylesheetsTest.php, which is about the *shape* of the source tree (every
* file's own header, layer statement and imports) — a concern that holds whether or not anything
* ever bundles them. This file is about the bundler itself: what `bundle()` produces, and, at the
* end, a real Vite build to pin the claim it is modelled on.
*
* Every helper here is named `stylesheetsBundle*` rather than reusing StylesheetsTest.php's
* `stylesheet*` names — the two files may run in the same Pest process, and a duplicate top-level
* function name is a fatal error, the same reason the four component-group stylesheet test files
* each name their own `assert…TokensAndPxBreakpoints()` uniquely.
* file's own header, layer statement and imports). `stylesheetsBundlePath()` is named apart from
* StylesheetsTest.php's `stylesheetPath()` for the same reason: the two files may run in the same
* Pest process, and a duplicate top-level function name is a fatal error.
*/
function stylesheetsBundlePath(string $path = ''): string
{
return (string) realpath(__DIR__.'/../../resources/css'.($path === '' ? '' : '/'.$path));
return (string) realpath(ComponentStylesheet::cssPath($path));
}
/**
* `$css` with every comment and quoted string blanked out — the same idea StylesheetsTest.php's
* `stylesheetWithoutComments()` uses, kept local here for the reason above.
*/
function stylesheetsBundleWithoutComments(string $css): string
{
return (string) preg_replace('~("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')|/\*.*?\*/~s', '$1', $css);
}
/**
* The first line of a stylesheet's header comment, the sentence naming what it draws — unique per
* file (checked below), untouched by `bundle()` since it only ever rewrites an `@import` or a
* `url()`, never a comment. A reliable per-file fingerprint in `bundle()`'s output, which is not
* minified.
*/
function stylesheetsBundleMarker(string $file): string
{
$lines = explode("\n", File::get($file));
return trim((string) preg_replace('/^\s*\*\s?/', '', $lines[1] ?? ''));
}
/**
* @return list<string>
*/
function stylesheetsBundleComponentAndLayoutFiles(): array
{
return collect(File::files(stylesheetsBundlePath('components')))
->merge(File::files(stylesheetsBundlePath('layout')))
->map(fn (SplFileInfo $file): string => (string) $file->getRealPath())
->values()
->all();
}
it('bundles all.css with every import resolved, foundation first, every component and layout file once', function () {
$bundled = Stylesheets::bundle([stylesheetsBundlePath('all.css')]);
expect(stylesheetsBundleWithoutComments($bundled))->not->toContain('@import');
$files = stylesheetsBundleComponentAndLayoutFiles();
$markers = collect($files)->mapWithKeys(fn (string $file): array => [$file => stylesheetsBundleMarker($file)]);
expect($markers)->toHaveCount(count($files))
->and($markers->unique()->count())->toBe($markers->count(), 'two files share a header marker');
foreach ($markers as $file => $marker) {
expect(substr_count($bundled, $marker))->toBe(1, basename($file).' should appear exactly once in the bundle');
}
$foundationPosition = strpos($bundled, stylesheetsBundleMarker(stylesheetsBundlePath('foundation.css')));
$earliestOtherPosition = $markers->map(fn (string $marker): int => (int) strpos($bundled, $marker))->min();
expect($foundationPosition)->toBeInt()
->and($foundationPosition)->toBeLessThan($earliestOtherPosition);
});
it('resolves the files an entry imports, transitively, without bundling any content', function () {
$files = Stylesheets::resolvedFiles([stylesheetsBundlePath('components/split-button.css')]);
@@ -93,7 +34,7 @@ it('resolves the files an entry imports, transitively, without bundling any cont
);
});
it('resolves past what an application entry imports and bundle() refuses, without throwing', function () {
it('resolves past what an application entry imports and only a Vite build resolves, without throwing', function () {
$dir = sys_get_temp_dir().'/livewire-material-resolved-'.Str::random(8);
File::makeDirectory($dir);
File::put($dir.'/theme.css', '.theme {}');
@@ -114,131 +55,6 @@ it('resolves past what an application entry imports and bundle() refuses, withou
expect($files)->toBe([realpath(sys_get_temp_dir()).'/'.basename($dir).'/app.css', realpath(sys_get_temp_dir()).'/'.basename($dir).'/theme.css']);
});
it('keeps a file two others import once, at its first position', function () {
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
File::makeDirectory($dir);
File::put("{$dir}/shared.css", "@layer material.reset;\n.shared { color: green; }\n");
File::put("{$dir}/a.css", "@import './shared.css';\n.a { color: red; }\n");
File::put("{$dir}/b.css", "@import './shared.css';\n.b { color: blue; }\n");
try {
$bundled = Stylesheets::bundle(["{$dir}/a.css", "{$dir}/b.css"]);
expect(substr_count($bundled, '.shared { color: green; }'))->toBe(1)
->and(strpos($bundled, '.shared'))->toBeLessThan(strpos($bundled, '.a { color: red; }'))
->and($bundled)->toContain('.b { color: blue; }')
->and(stylesheetsBundleWithoutComments($bundled))->not->toContain('@import');
} finally {
File::deleteDirectory($dir);
}
});
it('inlines every plain import form once, and nothing written in a comment or a string', function () {
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
File::makeDirectory("{$dir}/components", recursive: true);
foreach (['double', 'quoted-url', 'bare-url'] as $name) {
// A leading byte-order mark and @charset mean nothing past a stylesheet's first byte.
File::put("{$dir}/components/{$name}.css", "\u{FEFF}@charset \"UTF-8\";\n.{$name} {}\n");
}
File::put("{$dir}/entry.css", <<<'CSS'
/* An example in a header: @import './components/never.css'; url(never.png) — it's fine. */
@layer a, b;
@import "./components/double.css";
@import url('./components/quoted-url.css');
@IMPORT url( ./components/bare-url.css );
@import './components/../components/double.css';
.entry::before { content: "@import './components/never.css'; url(never.png)"; }
CSS);
try {
$bundled = Stylesheets::bundle(["{$dir}/entry.css"]);
expect(substr_count($bundled, '.double {}'))->toBe(1)
->and($bundled)->toContain('.quoted-url {}')
->toContain('.bare-url {}')
->toContain("/* An example in a header: @import './components/never.css'; url(never.png) — it's fine. */")
->toContain(".entry::before { content: \"@import './components/never.css'; url(never.png)\"; }")
->not->toContain('@charset')
->not->toContain("\u{FEFF}")
// The two left are the comment's and the string's.
->and(substr_count(strtolower($bundled), '@import'))->toBe(2);
} finally {
File::deleteDirectory($dir);
}
});
it('throws, naming the file, for an import the bundle could not inline without changing its meaning', function (string $css, string $reason) {
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
File::makeDirectory($dir);
File::put("{$dir}/x.css", '.x {}');
File::put("{$dir}/entry.css", $css);
try {
Stylesheets::bundle(["{$dir}/entry.css"]);
$this->fail("Stylesheets::bundle() should have thrown for {$css}");
} catch (RuntimeException $exception) {
expect($exception->getMessage())->toContain('entry.css')->toContain($reason);
} finally {
File::deleteDirectory($dir);
}
})->with([
'a layer() condition' => ["@import './x.css' layer(x);", 'without layer()'],
'a supports() condition' => ["@import './x.css' supports(display: grid);", 'without layer()'],
'a media condition' => ['@import url(./x.css) screen;', 'without layer()'],
'an absolute URL' => ["@import 'https://example.com/x.css';", 'absolute URL'],
'an absolute path' => ["@import '/x.css';", 'absolute URL'],
'a package specifier' => ["@import 'tailwindcss';", '"tailwindcss", which does not exist'],
'an import after a rule' => [".a {}\n@import './x.css';", 'after a rule'],
'an import inside a block' => ["@layer b { @import './x.css'; }", 'inside a block'],
]);
it('rewrites a relative url() against the entry file\'s directory, or against $base when given', function () {
// Mirrors resources/css/'s own shape: an entry (all.css) sitting where the bundle is "from",
// a component one level under it (components/menu.css), and an asset one level above that
// again (resources/fonts/, resources/svg/, siblings of resources/css/) — so a url() two
// directories away from the file that wrote it lands one directory away from the entry.
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
File::makeDirectory("{$dir}/css/components", recursive: true);
File::makeDirectory("{$dir}/fonts");
File::put("{$dir}/css/entry.css", "@import './components/widget.css';\n");
File::put("{$dir}/css/components/widget.css", <<<'CSS'
@font-face { src: url('../../fonts/widget.woff2') format('woff2'); }
.widget { background: url("icon.svg"), url(https://example.com/a.png), url(#gradient), url(data:image/png;base64,AA==); }
CSS);
try {
$bundled = Stylesheets::bundle(["{$dir}/css/entry.css"]);
// Resolved against the entry's own directory (css/) when no $base is given: the font
// climbs out of css/ to the sibling fonts/ directory, the same shape resources/css/ has
// relative to resources/fonts/; the icon, sitting beside widget.css, stays inside components/.
expect($bundled)->toContain("url('../fonts/widget.woff2')")
->toContain('url("components/icon.svg")')
// An absolute URL, a # fragment and a data: URI are left exactly as written.
->toContain('url(https://example.com/a.png)')
->toContain('url(#gradient)')
->toContain('url(data:image/png;base64,AA==)');
// $base stands for wherever the entry itself (the thing "resources/css/" is here) is
// served from: the icon, which never left that directory, lands right under $base, while
// the font's one directory climbed above the entry the same way it did with no $base at
// all, so it climbs one directory above $base too — off the end of "assets", not inside it.
Stylesheets::resetCache();
$withBase = Stylesheets::bundle(["{$dir}/css/entry.css"], 'https://cdn.example.com/assets');
expect($withBase)->toContain("url('https://cdn.example.com/fonts/widget.woff2')")
->toContain('url("https://cdn.example.com/assets/components/icon.svg")')
->toContain('url(https://example.com/a.png)');
} finally {
File::deleteDirectory($dir);
}
});
/**
* Deduplication keeps a file at its first position, so an override that ties a rule it imports on
* specificity (the navigation rail's header FAB over fab.css was one) still wins only while every
@@ -252,7 +68,7 @@ it('keeps the package\'s import graph free of cycles, so a stylesheet always lan
$walk = function (string $file, array $stack) use (&$walk, &$edges): void {
expect(in_array($file, $stack, true))->toBeFalse('import cycle: '.implode(' → ', array_map(basename(...), [...$stack, $file])));
preg_match_all("/@import\\s+'([^']+)'/", stylesheetsBundleWithoutComments(File::get($file)), $matches);
preg_match_all("/@import\\s+'([^']+)'/", ComponentStylesheet::withoutComments(File::get($file)), $matches);
foreach ($matches[1] as $import) {
$edges++;
@@ -265,84 +81,32 @@ it('keeps the package\'s import graph free of cycles, so a stylesheet always lan
expect($edges)->toBeGreaterThan(100);
});
it('does not loop on a cycle, and keeps both files\' rules', function () {
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
File::makeDirectory($dir);
/**
* resources/dist/ is committed so applications need no Node; a stylesheet change without
* `npm run build:stylesheets` would leave the showcase and the error pages serving the old rules.
*/
it('keeps the prebuilt stylesheets in resources/dist/ in step with resources/css/', function () {
$node = config('livewire-material.node', 'node');
File::put("{$dir}/a.css", "@import './b.css';\n.a {}\n");
File::put("{$dir}/b.css", "@import './a.css';\n.b {}\n");
try {
$bundled = Stylesheets::bundle(["{$dir}/a.css"]);
expect($bundled)->toContain('.a {}')->toContain('.b {}');
} finally {
File::deleteDirectory($dir);
if (Process::run([$node, '--version'])->failed() || ! is_dir(__DIR__.'/../../node_modules/vite')) {
$this->markTestSkipped("Node ({$node}) or Vite is missing; `npm run build:stylesheets` builds resources/dist/.");
}
});
it('throws naming the importer when an import does not exist', function () {
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
File::makeDirectory($dir);
File::put("{$dir}/broken.css", "@import './missing.css';\n");
$outDir = sys_get_temp_dir().'/livewire-material-dist-'.Str::random(8);
try {
Stylesheets::bundle(["{$dir}/broken.css"]);
$result = Process::run([$node, __DIR__.'/../../bin/stylesheets.mjs', $outDir]);
$this->fail('Stylesheets::bundle() should have thrown.');
} catch (RuntimeException $exception) {
expect($exception->getMessage())->toContain('broken.css')->toContain('missing.css');
expect($result->successful())->toBeTrue($result->errorOutput());
foreach (File::files($outDir) as $built) {
$committed = __DIR__.'/../../resources/dist/'.$built->getFilename();
expect(is_file($committed) ? File::get($committed) : null)
->toBe($built->getContents(), "resources/dist/{$built->getFilename()} is stale: run `npm run build:stylesheets`");
}
} finally {
File::deleteDirectory($dir);
}
});
it('caches per file list and mtime, and resetCache() forces a fresh read', function () {
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
File::makeDirectory($dir);
File::put("{$dir}/cached.css", '.before {}');
touch("{$dir}/cached.css", 1_700_000_000);
try {
expect(Stylesheets::bundle(["{$dir}/cached.css"]))->toBe('.before {}');
// The mtime is unchanged, so the cache serves the old content even though the file itself
// now holds something else.
File::put("{$dir}/cached.css", '.changed-but-same-mtime {}');
touch("{$dir}/cached.css", 1_700_000_000);
expect(Stylesheets::bundle(["{$dir}/cached.css"]))
->toBe('.before {}', 'an unchanged mtime should still be served from the cache');
Stylesheets::resetCache();
expect(Stylesheets::bundle(["{$dir}/cached.css"]))
->toBe('.changed-but-same-mtime {}', 'resetCache() should force a fresh read');
// A later mtime invalidates the cache on its own, with no resetCache() needed.
File::put("{$dir}/cached.css", '.after {}');
touch("{$dir}/cached.css", 1_700_000_100);
expect(Stylesheets::bundle(["{$dir}/cached.css"]))
->toBe('.after {}', 'a changed mtime should invalidate the cache on its own');
// So does a later mtime on a file the bundle reached only through an @import — a changed
// button.css must not leave a cached all.css bundle standing.
File::put("{$dir}/entry.css", "@import './cached.css';\n");
touch("{$dir}/entry.css", 1_700_000_000);
expect(Stylesheets::bundle(["{$dir}/entry.css"]))->toContain('.after {}');
File::put("{$dir}/cached.css", '.nested-change {}');
touch("{$dir}/cached.css", 1_700_000_200);
expect(Stylesheets::bundle(["{$dir}/entry.css"]))
->toContain('.nested-change {}')
->and(Stylesheets::bundle(["{$dir}/entry.css"], ''))->toContain('.nested-change {}');
} finally {
Stylesheets::resetCache();
File::deleteDirectory($dir);
File::deleteDirectory($outDir);
}
});
@@ -398,11 +162,11 @@ function stylesheetsBundleLeafRules(string $css): array
}
/**
* The Vite deduplication `bundle()` is modelled on, pinned against the real bundler
* (tests/Fixtures/dedup.vite.config.mjs, no plugin in the graph): `all.css`, and
* tests/Fixtures/dedup-app.css, an entry shaped like an application's — outside the package, the
* foundation, then split button, app bar, modal and pagination, which each import button.css, and
* button.css once more.
* The Vite deduplication the prebuilt stylesheets and every application's build rely on, pinned
* against the real bundler (tests/Fixtures/dedup.vite.config.mjs, no plugin in the graph):
* `all.css`, and tests/Fixtures/dedup-app.css, an entry shaped like an application's — outside
* the package, the foundation, then split button, app bar, modal and pagination, which each
* import button.css, and button.css once more.
*
* The built CSS is minified, so this compares rules rather than files: no innermost rule may
* appear twice under the same at-rules. A build that inlined every occurrence of a shared