Keep missingStylesheets() to imports, and never crash on a foreign one
Plan step 41 review: an application's CSS entry may import what a Vite build resolves but Stylesheets::bundle() refuses (tailwindcss mid- migration, a font URL, a layer() import); resolvedFiles() threw on each, failing the application's whole design test. It now follows relative imports only and skips the rest. Each missing stylesheet, shadowed tag and ->links() is reported once, at its first use, instead of at every occurrence; a CSS entry that does not exist says so; the @import line goes through vendor/ when Composer symlinks the package. missingStylesheets() also switched on the literal-value check for the entry's whole import graph, vendor stylesheets included, with no way to check imports alone. Check (iii) now reads the .css files scan() is given, like every other check; the entry's imports still feed the class exemptions, now escaped selectors (.hover\:underline) included. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
9595722f35
commit
c0c71ef16c
+48
-11
@@ -93,26 +93,63 @@ final class Stylesheets
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file `$files` reaches, transitively, through the same `@import` resolution `bundle()`
|
||||
* walks — real paths, `$files` themselves included — without concatenating anything.
|
||||
* `DesignGuard::missingStylesheets()` (plan step 41) asks this: whether an application's CSS
|
||||
* entry's import graph already reaches a given package stylesheet, which needs the file
|
||||
* identities `bundle()` resolves, not the CSS it produces. Shares `bundle()`'s cache (and so
|
||||
* its freshness rule) under the same key, `$base` fixed to `null`, since only `bundle()` ever
|
||||
* populates it.
|
||||
* 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.
|
||||
*
|
||||
* @param list<string> $files
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function resolvedFiles(array $files): array
|
||||
{
|
||||
if ($files === []) {
|
||||
return [];
|
||||
$seen = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$real = realpath($file);
|
||||
|
||||
if ($real !== false && is_file($real)) {
|
||||
self::reach($real, $seen);
|
||||
}
|
||||
}
|
||||
|
||||
self::bundle($files);
|
||||
return array_keys($seen);
|
||||
}
|
||||
|
||||
return array_keys(self::$cache[serialize([$files, null])]['files']);
|
||||
/**
|
||||
* @param array<string, true> $seen every file already reached, by real path
|
||||
*/
|
||||
private static function reach(string $file, array &$seen): void
|
||||
{
|
||||
if (isset($seen[$file])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$seen[$file] = true;
|
||||
$css = (string) file_get_contents($file);
|
||||
$masked = self::mask($css);
|
||||
|
||||
preg_match_all('/@import\b/i', $masked, $matches, PREG_OFFSET_CAPTURE);
|
||||
|
||||
foreach ($matches[0] as [, $start]) {
|
||||
$end = strpos($masked, ';', $start);
|
||||
$statement = substr($css, $start, ($end === false ? strlen($css) : $end) - $start);
|
||||
|
||||
if (preg_match('/^@import\s*(?:url\(\s*([\'"]?)([^\'"()\s]+)\1\s*\)|([\'"])(.+?)\3)/is', $statement, $match, PREG_UNMATCHED_AS_NULL) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$target = $match[2] ?? $match[4] ?? '';
|
||||
$real = preg_match('~^(?:[a-z][a-z0-9+.-]*:|/)~i', $target) === 1 ? false : realpath(dirname($file).'/'.$target);
|
||||
|
||||
if ($real !== false && is_file($real)) {
|
||||
self::reach($real, $seen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+61
-35
@@ -24,16 +24,16 @@ use Symfony\Component\Finder\Finder;
|
||||
* stylesheets declare is exempt, and so is every `md-*` class.
|
||||
* (ii) `missingStylesheets($cssEntry)`: a package component tag used in a view — unprefixed,
|
||||
* under the configured prefix, or `<x-livewire-material::…>` — whose stylesheet the
|
||||
* entry's `@import` graph does not reach (followed through every package file's own
|
||||
* imports, `Support\Stylesheets` resolves), and `->links()` needing `pagination.css`;
|
||||
* each names the missing `@import` line to add. 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.
|
||||
* (iii) the application's own CSS (the entry and what it imports outside the package, plus any
|
||||
* `.css` file the scanned paths hold directly), 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 than
|
||||
* 600/840/1200/1600px — each with its token or breakpoint. A value inside
|
||||
* entry's relative `@import` graph does not reach (followed through every package file's
|
||||
* own imports; a package name or URL the entry also imports is skipped, never fatal), and
|
||||
* `->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.
|
||||
* (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
|
||||
* than 600/840/1200/1600px — each with its token or breakpoint. A value inside
|
||||
* `var(--md-sys-…)` or `calc()` is never flagged, whatever it contains.
|
||||
* (iv) Tailwind palette colours, icon names that are not Material Symbols, and Blade
|
||||
* directives written inside a component tag (where they do not compile) — plus whatever
|
||||
@@ -42,7 +42,7 @@ use Symfony\Component\Finder\Finder;
|
||||
* at runtime (`'text-'.$tone`) or hidden in a comment stays invisible — the same reason
|
||||
* to write class names out whole.
|
||||
*
|
||||
* expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
|
||||
* expect(DesignGuard::scan([resource_path('views'), resource_path('js'), resource_path('css'), app_path()])
|
||||
* ->missingStylesheets(resource_path('css/app.css'))
|
||||
* ->forbidColours(['tertiary'])
|
||||
* ->violations())->toBe([]);
|
||||
@@ -297,14 +297,17 @@ class DesignGuard
|
||||
* configured prefix, or `<x-livewire-material::…>` — and every `->links()` call, against
|
||||
* `$cssEntry`'s `@import` graph (followed through each package file's own imports). A missing
|
||||
* one names the `@import` line to add; a tag the application shadows with its own component
|
||||
* of the same name is reported instead, since the package's stylesheet is then moot. Also
|
||||
* turns on check (iii), the application's own CSS: `$cssEntry` and what it imports outside
|
||||
* the package become part of what that check reads, alongside any `.css` file `scan()`'s own
|
||||
* paths hold directly.
|
||||
* of the same name is reported instead, since the package's stylesheet is then moot. Each
|
||||
* missing stylesheet and each shadowed tag is reported once, at its first use. Only the
|
||||
* imports are read here: the literal values in the application's own CSS are check (iii)'s,
|
||||
* which reads the `.css` files `scan()` is given, entry or not — so an application part-way
|
||||
* through its migration can check its imports before its stylesheets are on tokens. The
|
||||
* classes the entry's own imports declare do join check (i)'s exemptions.
|
||||
*/
|
||||
public function missingStylesheets(string $cssEntry): static
|
||||
{
|
||||
$this->cssEntry = $cssEntry;
|
||||
$this->applicationClassesCache = null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
@@ -319,7 +322,11 @@ class DesignGuard
|
||||
? array_fill_keys(Stylesheets::resolvedFiles([$this->cssEntry]), true)
|
||||
: [];
|
||||
|
||||
if ($this->cssEntry !== null) {
|
||||
$reported = [];
|
||||
|
||||
if ($this->cssEntry !== null && ! is_file($this->cssEntry)) {
|
||||
$violations[] = "{$this->cssEntry}:1 the CSS entry `missingStylesheets()` names does not exist";
|
||||
} elseif ($this->cssEntry !== null) {
|
||||
$foundation = static::packageCssRoot().'/foundation.css';
|
||||
|
||||
if (! isset($resolved[$foundation])) {
|
||||
@@ -366,7 +373,7 @@ class DesignGuard
|
||||
}
|
||||
|
||||
if ($this->cssEntry !== null) {
|
||||
foreach ($this->missingStylesheetViolations($contents, $resolved) as [$line, $what]) {
|
||||
foreach ($this->missingStylesheetViolations($contents, $resolved, $reported) as [$line, $what]) {
|
||||
$violations[] = "{$where}:{$line} {$what}";
|
||||
}
|
||||
}
|
||||
@@ -1232,25 +1239,31 @@ class DesignGuard
|
||||
/**
|
||||
* Check (ii)'s findings for one file: a package tag whose stylesheet `$resolved` (every file
|
||||
* `Stylesheets::resolvedFiles()` reached from the CSS entry, keyed by real path) does not
|
||||
* contain, a tag the application shadows, and a `->links()` needing `pagination.css`.
|
||||
* contain, a tag the application shadows, and a `->links()` needing `pagination.css` — each
|
||||
* only the first time `$reported` (shared across every file of one `violations()` run) sees it.
|
||||
*
|
||||
* @param array<string, true> $resolved
|
||||
* @param array<string, true> $reported
|
||||
* @return list<array{0: int, 1: string}>
|
||||
*/
|
||||
protected function missingStylesheetViolations(string $contents, array $resolved): array
|
||||
protected function missingStylesheetViolations(string $contents, array $resolved, array &$reported): array
|
||||
{
|
||||
$found = [];
|
||||
|
||||
foreach ($this->packageTagUsages($contents) as [$line, $name, $spelling]) {
|
||||
if ($spelling === 'plain' && $this->shadowedByApplication($name)) {
|
||||
$found[] = [$line, "`<x-{$name}>` is shadowed by the application's own component of the same name — the package's `<x-{$name}>` never renders here"];
|
||||
if (! isset($reported["shadow:{$name}"])) {
|
||||
$reported["shadow:{$name}"] = true;
|
||||
$found[] = [$line, "`<x-{$name}>` is shadowed by the application's own component of the same name — the package's `<x-{$name}>` never renders here"];
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$stylesheet = static::packageStylesheetFor($name);
|
||||
|
||||
if ($stylesheet !== null && ! isset($resolved[$stylesheet])) {
|
||||
if ($stylesheet !== null && ! isset($resolved[$stylesheet]) && ! isset($reported[$stylesheet])) {
|
||||
$reported[$stylesheet] = true;
|
||||
$found[] = [$line, sprintf(
|
||||
"`<x-%s>` needs `%s`, missing from %s — add `@import '%s';`",
|
||||
$name,
|
||||
@@ -1264,7 +1277,8 @@ class DesignGuard
|
||||
$pagination = static::packageCssRoot().'/components/pagination.css';
|
||||
|
||||
foreach ($this->paginationUsages($contents) as $line) {
|
||||
if (! isset($resolved[$pagination])) {
|
||||
if (! isset($resolved[$pagination]) && ! isset($reported[$pagination])) {
|
||||
$reported[$pagination] = true;
|
||||
$found[] = [$line, sprintf(
|
||||
"`->links()` needs `components/pagination.css`, missing from %s — add `@import '%s';`",
|
||||
$this->relative($this->cssEntry),
|
||||
@@ -1277,9 +1291,9 @@ class DesignGuard
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `.css` file this guard reads for check (iii): any the scanned paths hold directly,
|
||||
* plus — once `missingStylesheets()` names a CSS entry — every file its `@import` graph
|
||||
* reaches outside the package. The generated `material-scheme.css` is excluded either way.
|
||||
* Every `.css` file this guard reads for check (iii): the ones `scan()`'s paths hold, outside
|
||||
* the package, the generated `material-scheme.css` excluded. The CSS entry's imports are not
|
||||
* followed here — a vendor stylesheet it pulls in is not the application's to put on tokens.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
@@ -1303,10 +1317,6 @@ class DesignGuard
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->cssEntry !== null) {
|
||||
array_push($files, ...Stylesheets::resolvedFiles([$this->cssEntry]));
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter(
|
||||
$files,
|
||||
fn (string $file): bool => $file !== '' && ! static::isPackageFile($file) && ! $this->isGeneratedScheme($file),
|
||||
@@ -1314,9 +1324,10 @@ class DesignGuard
|
||||
}
|
||||
|
||||
/**
|
||||
* Every class an application's own stylesheets select on — check (i)'s exemption list, so a
|
||||
* class it defines is never reported as a dead Tailwind utility just because it happens to
|
||||
* share a shape with one.
|
||||
* Every class an application's own stylesheets select on — the scanned ones and whatever the
|
||||
* CSS entry imports outside the package — check (i)'s exemption list, so a class it defines is
|
||||
* never reported as a dead Tailwind utility just because it happens to share a shape with one.
|
||||
* An escaped selector (`.sm\:flex`) counts under its unescaped name.
|
||||
*
|
||||
* @return array<string, true>
|
||||
*/
|
||||
@@ -1328,13 +1339,22 @@ class DesignGuard
|
||||
|
||||
$classes = [];
|
||||
|
||||
foreach ($this->applicationStylesheets() as $file) {
|
||||
$files = $this->applicationStylesheets();
|
||||
|
||||
if ($this->cssEntry !== null) {
|
||||
$files = array_unique([...$files, ...array_filter(
|
||||
Stylesheets::resolvedFiles([$this->cssEntry]),
|
||||
fn (string $file): bool => ! static::isPackageFile($file),
|
||||
)]);
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
$css = $this->maskedCss((string) file_get_contents($file));
|
||||
|
||||
preg_match_all('/(?<![\w.#-])\.(-?[a-zA-Z_][\w-]*)/', $css, $matches);
|
||||
preg_match_all('/(?<![\w.#\\\\-])\.(-?(?:[a-zA-Z_]|\\\\.)(?:[\w-]|\\\\.)*)/', $css, $matches);
|
||||
|
||||
foreach ($matches[1] as $class) {
|
||||
$classes[$class] = true;
|
||||
$classes[stripslashes($class)] = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1425,6 +1445,12 @@ class DesignGuard
|
||||
protected function importLine(string $file): string
|
||||
{
|
||||
$entryDir = dirname((string) (realpath($this->cssEntry) ?: $this->cssEntry));
|
||||
$vendor = base_path('vendor/nonameweb/livewire-material/resources/css');
|
||||
|
||||
// A Composer path repository may symlink the package: the import still goes through vendor/.
|
||||
if (realpath($vendor) === static::packageCssRoot() && str_starts_with($file, static::packageCssRoot().'/')) {
|
||||
$file = $vendor.substr($file, strlen(static::packageCssRoot()));
|
||||
}
|
||||
|
||||
return static::relativeImportPath($entryDir, $file);
|
||||
}
|
||||
|
||||
@@ -405,6 +405,25 @@ it('needs nothing more once the entry imports all.css', function () {
|
||||
expect($violations)->toBe([]);
|
||||
});
|
||||
|
||||
it('follows an entry that imports what only a Vite build resolves, reports each missing stylesheet once, and leaves the entry\'s literals to scan()', function () {
|
||||
$entry = realpath(GUARD_FIXTURES.'/stylesheets/foreign-imports.css');
|
||||
$mt2 = "stylesheets/views-foreign/page.blade.php:1 Tailwind spacing utility `mt-2` compiles to nothing — space between siblings is a layout component's `gap=\"space100\"` (8px); any other margin is `var(--md-sys-measurement-space100)` in your own CSS";
|
||||
$button = "stylesheets/views-foreign/page.blade.php:2 `<x-button>` needs `components/button.css`, missing from stylesheets/foreign-imports.css — add `@import '../../../../resources/css/components/button.css';`";
|
||||
|
||||
expect(fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views-foreign'))->missingStylesheets($entry)->violations()))
|
||||
->toBe([$mt2, $button])
|
||||
->and(fixtureRelative(DesignGuard::scan([realpath(GUARD_FIXTURES.'/stylesheets/views-foreign'), $entry])->missingStylesheets($entry)->violations()))
|
||||
->toBe([$mt2, $button, 'stylesheets/foreign-imports.css:7 literal colour `#ff0000` — use `var(--md-sys-color-*)`']);
|
||||
});
|
||||
|
||||
it('says so when the CSS entry does not exist', function () {
|
||||
$violations = DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views/plain.blade.php'))
|
||||
->missingStylesheets('/nowhere/app.css')
|
||||
->violations();
|
||||
|
||||
expect($violations)->toBe(['/nowhere/app.css:1 the CSS entry `missingStylesheets()` names does not exist']);
|
||||
});
|
||||
|
||||
it('requires foundation.css whatever the views hold', function () {
|
||||
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views/plain.blade.php'))
|
||||
->missingStylesheets(realpath(GUARD_FIXTURES.'/stylesheets/missing-foundation.css'))
|
||||
|
||||
@@ -93,6 +93,27 @@ 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 () {
|
||||
$dir = sys_get_temp_dir().'/livewire-material-resolved-'.Str::random(8);
|
||||
File::makeDirectory($dir);
|
||||
File::put($dir.'/theme.css', '.theme {}');
|
||||
File::put($dir.'/app.css', implode("\n", [
|
||||
"@import 'tailwindcss';",
|
||||
"@import url('https://fonts.googleapis.com/css2?family=Roboto');",
|
||||
"@import './theme.css' layer(app);",
|
||||
"@import './missing.css';",
|
||||
"@import '".stylesheetsBundlePath('components/icon.css')."';",
|
||||
]));
|
||||
|
||||
try {
|
||||
$files = Stylesheets::resolvedFiles([$dir.'/app.css']);
|
||||
} finally {
|
||||
File::deleteDirectory($dir);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
.gap-4 { gap: var(--md-sys-measurement-space200); }
|
||||
.hover\:underline:hover { text-decoration: underline; }
|
||||
@@ -0,0 +1,7 @@
|
||||
@import 'tailwindcss';
|
||||
@import url('https://fonts.googleapis.com/css2?family=Roboto');
|
||||
@import './app-theme.css' layer(app);
|
||||
@import '../../../../resources/css/foundation.css';
|
||||
@import './does-not-exist.css';
|
||||
|
||||
.entry-literal { color: #ff0000; }
|
||||
@@ -0,0 +1,4 @@
|
||||
<div class="gap-4 hover:underline mt-2">
|
||||
<x-button label="Save" />
|
||||
<x-button label="Again" />
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
<x-button label="Elsewhere" />
|
||||
Reference in New Issue
Block a user