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
+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
* 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
* 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
* 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
@@ -262,6 +264,8 @@ class DesignGuard
protected ?string $cssEntry = null;
protected ?string $unusedEntry = null;
/** @var array<string, true>|null */
protected ?array $applicationClassesCache = null;
@@ -325,6 +329,20 @@ class DesignGuard
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>
*/
@@ -336,6 +354,7 @@ class DesignGuard
: [];
$reported = [];
$needed = [];
if ($this->cssEntry !== null && ! is_file($this->cssEntry)) {
$violations[] = "{$this->cssEntry}:1 the CSS entry `missingStylesheets()` names does not exist";
@@ -390,6 +409,10 @@ class DesignGuard
$violations[] = "{$where}:{$line} {$what}";
}
}
if ($this->unusedEntry !== null) {
$needed = [...$needed, ...$this->neededStylesheets($contents)];
}
}
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()]);
}
/**
* 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
* (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
{
$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');
// A Composer path repository may symlink the package: the import still goes through vendor/.