Report the stylesheets an entry imports that no view needs any more

missingStylesheets() names an import the views need, but nothing named
one they had stopped needing: when SealShare's last card left its views,
card.css stayed in every page until a reviewer noticed by hand.
unusedStylesheets($cssEntry) reads the entry's direct package imports
and reports each one no scanned view needs, counting what a needed
stylesheet imports itself and foundation.css always; an entry that
imports all.css is left alone. Opt-in, beside missingStylesheets()
(plan step 46).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-15 14:31:01 +02:00
co-authored by Claude Opus 5
parent ff5349759b
commit 05f115ab36
5 changed files with 137 additions and 4 deletions
+2 -1
View File
@@ -170,7 +170,8 @@ the contrast toggle (`<x-theme-toggle mode="contrast">`) and the Standard motion
- **Retargeted:** `forbidColours([...])` keeps its signature and reports a left-out role where 2.0.0 - **Retargeted:** `forbidColours([...])` keeps its signature and reports a left-out role where 2.0.0
writes one: `var(--md-sys-color-…)` in CSS or an inline `style`, an `md-ink-*` class, a writes one: `var(--md-sys-color-…)` in CSS or an inline `style`, an `md-ink-*` class, a
component's `color`/`tone` prop. component's `color`/`tone` prop.
- **New:** `missingStylesheets($cssEntry)` names each `@import` the views need; a `.css` file passed - **New:** `missingStylesheets($cssEntry)` names each `@import` the views need, and
`unusedStylesheets($cssEntry)` each one they no longer do; a `.css` file passed
to `scan()` is checked for literal values and off-scale media queries. to `scan()` is checked for literal values and off-scale media queries.
Browser tests that assert widths switch at 640/1024/1280 now Browser tests that assert widths switch at 640/1024/1280 now
@@ -1118,6 +1118,7 @@ use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
it('uses only what compiles', function () { it('uses only what compiles', function () {
expect(DesignGuard::scan([resource_path('views'), resource_path('js'), resource_path('css'), app_path()]) expect(DesignGuard::scan([resource_path('views'), resource_path('js'), resource_path('css'), app_path()])
->missingStylesheets(resource_path('css/app.css')) ->missingStylesheets(resource_path('css/app.css'))
->unusedStylesheets(resource_path('css/app.css'))
->forbidColours(['tertiary']) // roles this application's rules leave out ->forbidColours(['tertiary']) // roles this application's rules leave out
->violations())->toBe([]); ->violations())->toBe([]);
}); });
@@ -1151,7 +1152,7 @@ It reads every path it is given and fails with `path:line` on unknown symbol nam
In the `.css` files it is given (`material-scheme.css` skipped) it fails on a literal colour, radius, shadow, font, font size, weight, line height, letter spacing, easing or duration, and on a media query width off 600/840/1200/1600px; a `var()`, or a `calc()`/`min()`/`max()`/`clamp()` built on one, is fine. In the `.css` files it is given (`material-scheme.css` skipped) it fails on a literal colour, radius, shadow, font, font size, weight, line height, letter spacing, easing or duration, and on a media query width off 600/840/1200/1600px; a `var()`, or a `calc()`/`min()`/`max()`/`clamp()` built on one, is fine.
`missingStylesheets($cssEntry)` follows the entry's relative `@import`s through every package file's own imports (`split-button.css` counts `button.css` and `menu.css` too; `tailwindcss` or a font URL is skipped) and checks them against the package tags a scanned view renders (plain, prefixed or `<x-livewire-material::…>`) and `->links()`; each missing stylesheet names its `@import` line once, and a tag the application shadows with its own component is reported instead — the application's component wins in Blade. It reads imports only: leave `resource_path('css')` out of `scan()` until the stylesheets are on tokens. `forbidColours([...])` fails wherever a left-out role (with its `on-` and container roles) is still written: `var(--md-sys-color-…)` in CSS or an inline `style`, its `md-ink-*` class, or a component's `color`/`tone` prop. `forbid($pattern, $reason)` adds a pattern of your own. `missingStylesheets($cssEntry)` follows the entry's relative `@import`s through every package file's own imports (`split-button.css` counts `button.css` and `menu.css` too; `tailwindcss` or a font URL is skipped) and checks them against the package tags a scanned view renders (plain, prefixed or `<x-livewire-material::…>`) and `->links()`; each missing stylesheet names its `@import` line once, and a tag the application shadows with its own component is reported instead — the application's component wins in Blade. It reads imports only: leave `resource_path('css')` out of `scan()` until the stylesheets are on tokens. `unusedStylesheets($cssEntry)` is the other way round: a package stylesheet the entry imports directly that no scanned view needs, even through a needed stylesheet's own imports, named at its `@import` line to remove (an entry importing `all.css` is left alone). `forbidColours([...])` fails wherever a left-out role (with its `on-` and container roles) is still written: `var(--md-sys-color-…)` in CSS or an inline `style`, its `md-ink-*` class, or a component's `color`/`tone` prop. `forbid($pattern, $reason)` adds a pattern of your own.
## Conventions ## Conventions
+114 -2
View File
@@ -31,7 +31,9 @@ use Symfony\Component\Finder\Finder;
* `->links()` needing `pagination.css`; each names the missing `@import` line once, at its * `->links()` needing `pagination.css`; each names the missing `@import` line once, at its
* first use. A tag the application shadows with its own component of the same name is * first use. A tag the application shadows with its own component of the same name is
* reported instead the application's component wins in Blade, so the package's * reported instead the application's component wins in Blade, so the package's
* stylesheet is moot. * stylesheet is moot. `unusedStylesheets($cssEntry)` reports the other way: a package
* stylesheet the entry imports directly that no scanned view needs, not even through
* another needed stylesheet's imports (an entry importing `all.css` is left alone).
* (iii) every `.css` file among the scanned paths, outside the package and excluding the * (iii) every `.css` file among the scanned paths, outside the package and excluding the
* generated `material-scheme.css`: a literal colour, radius, shadow, font size, weight, * generated `material-scheme.css`: a literal colour, radius, shadow, font size, weight,
* line height, letter spacing, easing or duration, and a media query at a width other * line height, letter spacing, easing or duration, and a media query at a width other
@@ -262,6 +264,8 @@ class DesignGuard
protected ?string $cssEntry = null; protected ?string $cssEntry = null;
protected ?string $unusedEntry = null;
/** @var array<string, true>|null */ /** @var array<string, true>|null */
protected ?array $applicationClassesCache = null; protected ?array $applicationClassesCache = null;
@@ -325,6 +329,20 @@ class DesignGuard
return $this; return $this;
} }
/**
* Check (ii) the other way round: each package stylesheet `$cssEntry` imports directly that no
* scanned Blade view needs no package tag it renders, no `->links()`, and not reached through
* the imports of a stylesheet that is needed named at its `@import` line, to remove. A
* component whose last use left the views otherwise keeps its CSS in every page. `foundation.css`
* is always needed; an entry that imports `all.css` has chosen everything and is not read.
*/
public function unusedStylesheets(string $cssEntry): static
{
$this->unusedEntry = $cssEntry;
return $this;
}
/** /**
* @return list<string> * @return list<string>
*/ */
@@ -336,6 +354,7 @@ class DesignGuard
: []; : [];
$reported = []; $reported = [];
$needed = [];
if ($this->cssEntry !== null && ! is_file($this->cssEntry)) { if ($this->cssEntry !== null && ! is_file($this->cssEntry)) {
$violations[] = "{$this->cssEntry}:1 the CSS entry `missingStylesheets()` names does not exist"; $violations[] = "{$this->cssEntry}:1 the CSS entry `missingStylesheets()` names does not exist";
@@ -390,6 +409,10 @@ class DesignGuard
$violations[] = "{$where}:{$line} {$what}"; $violations[] = "{$where}:{$line} {$what}";
} }
} }
if ($this->unusedEntry !== null) {
$needed = [...$needed, ...$this->neededStylesheets($contents)];
}
} }
foreach (explode("\n", $contents) as $index => $text) { foreach (explode("\n", $contents) as $index => $text) {
@@ -413,9 +436,92 @@ class DesignGuard
} }
} }
if ($this->unusedEntry !== null) {
$violations = [...$violations, ...$this->unusedStylesheetViolations($needed)];
}
return $this->sortViolations([...$violations, ...$this->applicationCssViolations()]); return $this->sortViolations([...$violations, ...$this->applicationCssViolations()]);
} }
/**
* The package stylesheets a Blade view's own tags and `->links()` calls need, before their
* imports are followed.
*
* @return list<string>
*/
protected function neededStylesheets(string $contents): array
{
$needed = [];
foreach ($this->packageTagUsages($contents) as [, $name, $spelling]) {
if ($spelling === 'plain' && $this->shadowedByApplication($name)) {
continue;
}
if (($stylesheet = static::packageStylesheetFor($name)) !== null) {
$needed[] = $stylesheet;
}
}
if ($this->paginationUsages($contents) !== []) {
$needed[] = static::packageCssRoot().'/components/pagination.css';
}
return $needed;
}
/**
* @param list<string> $needed
* @return list<string>
*/
protected function unusedStylesheetViolations(array $needed): array
{
$entry = (string) $this->unusedEntry;
$real = realpath($entry);
if ($real === false || ! is_file($real)) {
return ["{$entry}:1 the CSS entry `unusedStylesheets()` names does not exist"];
}
$root = static::packageCssRoot();
// Comments out, line count kept; the import strings themselves stay readable.
$css = (string) preg_replace_callback('~/\*.*?\*/~s', fn (array $comment): string => str_repeat("\n", substr_count($comment[0], "\n")), (string) file_get_contents($real));
$imports = [];
preg_match_all('/@import\s+(?:url\(\s*)?([\'"])([^\'"]+)\1/i', $css, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
foreach ($matches as $match) {
$file = realpath(dirname($real).'/'.$match[2][0]);
if ($file === false || ! static::isPackageFile($file)) {
continue;
}
if ($file === $root.'/all.css') {
return [];
}
$imports[] = [substr_count(substr($css, 0, $match[0][1]), "\n") + 1, $file];
}
$reached = array_fill_keys(Stylesheets::resolvedFiles([...array_unique($needed), $root.'/foundation.css']), true);
$violations = [];
foreach ($imports as [$line, $file]) {
if (! isset($reached[$file])) {
$violations[] = sprintf(
'%s:%d `%s` is imported, but no scanned view renders a component that needs it — remove `@import \'%s\';`',
$this->relative($real),
$line,
static::packageRelativeName($file),
$this->importLineFrom($real, $file),
);
}
}
return $violations;
}
/** /**
* `$violations` ("path:line message", `relative()`'s shape) sorted by path, then line * `$violations` ("path:line message", `relative()`'s shape) sorted by path, then line
* (numerically, so line 9 sits before line 10), then message whichever check produced each * (numerically, so line 9 sits before line 10), then message whichever check produced each
@@ -1559,7 +1665,13 @@ class DesignGuard
*/ */
protected function importLine(string $file): string protected function importLine(string $file): string
{ {
$entryDir = dirname((string) (realpath($this->cssEntry) ?: $this->cssEntry)); return $this->importLineFrom((string) $this->cssEntry, $file);
}
/** `importLine()` for an entry other than `missingStylesheets()`'s. */
protected function importLineFrom(string $entry, string $file): string
{
$entryDir = dirname((string) (realpath($entry) ?: $entry));
$vendor = base_path('vendor/nonameweb/livewire-material/resources/css'); $vendor = base_path('vendor/nonameweb/livewire-material/resources/css');
// A Composer path repository may symlink the package: the import still goes through vendor/. // A Composer path repository may symlink the package: the import still goes through vendor/.
+14
View File
@@ -420,6 +420,20 @@ it('counts what an imported package stylesheet already brings in, through its ow
expect($violations)->toBe([]); expect($violations)->toBe([]);
}); });
it('names each stylesheet the entry imports that no scanned view needs, and leaves all.css alone', function () {
// dependencies.blade.php renders <x-button>, <x-menu> and <x-split-button>: menu.css is needed
// (and reached through split-button.css too); card.css and stack.css serve nothing on the page.
$entry = realpath(GUARD_FIXTURES.'/stylesheets/unused.css');
expect(fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views/dependencies.blade.php'))->unusedStylesheets($entry)->violations()))->toBe([
"stylesheets/unused.css:4 `components/card.css` is imported, but no scanned view renders a component that needs it — remove `@import '../../../../resources/css/components/card.css';`",
"stylesheets/unused.css:5 `layout/stack.css` is imported, but no scanned view renders a component that needs it — remove `@import '../../../../resources/css/layout/stack.css';`",
])
// missingStylesheets() alone never reports an unused import.
->and(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views/dependencies.blade.php'))->missingStylesheets($entry)->violations())->toBe([])
->and(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views/dependencies.blade.php'))->unusedStylesheets(realpath(GUARD_FIXTURES.'/stylesheets/all.css'))->violations())->toBe([]);
});
it('needs nothing more once the entry imports all.css', function () { it('needs nothing more once the entry imports all.css', function () {
$violations = DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views')) $violations = DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views'))
->missingStylesheets(realpath(GUARD_FIXTURES.'/stylesheets/all.css')) ->missingStylesheets(realpath(GUARD_FIXTURES.'/stylesheets/all.css'))
@@ -0,0 +1,5 @@
@import '../../../../resources/css/foundation.css';
@import '../../../../resources/css/components/split-button.css';
@import '../../../../resources/css/components/menu.css';
@import '../../../../resources/css/components/card.css';
@import '../../../../resources/css/layout/stack.css';