diff --git a/src/Support/Stylesheets.php b/src/Support/Stylesheets.php index 5f0f1cac..f4ebbda7 100644 --- a/src/Support/Stylesheets.php +++ b/src/Support/Stylesheets.php @@ -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 $files * @return list */ 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 $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); + } + } } /** diff --git a/src/Testing/DesignGuard.php b/src/Testing/DesignGuard.php index aeb7ccbb..616fa8a0 100644 --- a/src/Testing/DesignGuard.php +++ b/src/Testing/DesignGuard.php @@ -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 `` — 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 `` — 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 $resolved + * @param array $reported * @return list */ - 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, "`` is shadowed by the application's own component of the same name — the package's `` never renders here"]; + if (! isset($reported["shadow:{$name}"])) { + $reported["shadow:{$name}"] = true; + $found[] = [$line, "`` is shadowed by the application's own component of the same name — the package's `` 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( "`` 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 */ @@ -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 */ @@ -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('/(?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); } diff --git a/tests/Feature/DesignGuardTest.php b/tests/Feature/DesignGuardTest.php index e150bb1a..23f8d070 100644 --- a/tests/Feature/DesignGuardTest.php +++ b/tests/Feature/DesignGuardTest.php @@ -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 `` 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')) diff --git a/tests/Feature/StylesheetsBundleTest.php b/tests/Feature/StylesheetsBundleTest.php index bccb7e73..7cd46b8a 100644 --- a/tests/Feature/StylesheetsBundleTest.php +++ b/tests/Feature/StylesheetsBundleTest.php @@ -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); diff --git a/tests/Fixtures/design-guard/stylesheets/app-theme.css b/tests/Fixtures/design-guard/stylesheets/app-theme.css new file mode 100644 index 00000000..92efa1a1 --- /dev/null +++ b/tests/Fixtures/design-guard/stylesheets/app-theme.css @@ -0,0 +1,2 @@ +.gap-4 { gap: var(--md-sys-measurement-space200); } +.hover\:underline:hover { text-decoration: underline; } diff --git a/tests/Fixtures/design-guard/stylesheets/foreign-imports.css b/tests/Fixtures/design-guard/stylesheets/foreign-imports.css new file mode 100644 index 00000000..3ceae674 --- /dev/null +++ b/tests/Fixtures/design-guard/stylesheets/foreign-imports.css @@ -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; } diff --git a/tests/Fixtures/design-guard/stylesheets/views-foreign/page.blade.php b/tests/Fixtures/design-guard/stylesheets/views-foreign/page.blade.php new file mode 100644 index 00000000..91fd81c2 --- /dev/null +++ b/tests/Fixtures/design-guard/stylesheets/views-foreign/page.blade.php @@ -0,0 +1,4 @@ +
+ + +
diff --git a/tests/Fixtures/design-guard/stylesheets/views-foreign/second.blade.php b/tests/Fixtures/design-guard/stylesheets/views-foreign/second.blade.php new file mode 100644 index 00000000..7fbbc743 --- /dev/null +++ b/tests/Fixtures/design-guard/stylesheets/views-foreign/second.blade.php @@ -0,0 +1 @@ +