diff --git a/README.md b/README.md index cc30bcac..f5ec6432 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ it('uses only what compiles', function () { }); ``` -The guard reads every path it is given and fails, with `path:line` and the 2.0.0 replacement, on any Tailwind utility or variant still in a view, PHP or JS file, a colour of the application's own Tailwind theme (`bg-brand`) included — none compiles, since the application carries no Tailwind — and on 1.x's own utilities (`type-body-md`, `text-meta`, `rounded-corner-lg`, `focus-ring`): a layout component and prop (`flex gap-4` → ``), an `md-*` class (`truncate` → `md-truncate`), or a token for the application's own CSS (`rounded-lg` → `var(--md-sys-shape-corner-lg)`). A class the application's own stylesheets declare is exempt. In the `.css` files it is given (`material-scheme.css` skipped) it fails on a literal colour, radius, shadow, font, easing or duration, and on a media query off M3's 600/840/1200/1600px; a `var()`, or a `calc()`/`min()`/`max()`/`clamp()` built on one, is fine. It also fails on unknown Material Symbol names and Blade directives written inside component tags. +The guard reads every path it is given and fails, with `path:line` and the 2.0.0 replacement, on any Tailwind utility or variant still in a view, PHP or JS file, a colour of the application's own Tailwind theme (`bg-brand`) included — none compiles, since the application carries no Tailwind — and on 1.x's own utilities (`type-body-md`, `text-meta`, `rounded-corner-lg`, `focus-ring`): a layout component and prop (`flex gap-4` → ``), an `md-*` class (`truncate` → `md-truncate`), or a token for the application's own CSS (`rounded-lg` → `var(--md-sys-shape-corner-lg)`). A class the application's own stylesheets declare is exempt. In the `.css` files it is given (`material-scheme.css` skipped) it fails on a literal colour, radius, shadow, font, easing or duration, and on a media query off M3's 600/840/1200/1600px; a `var()`, or a `calc()`/`min()`/`max()`/`clamp()` built on one, is fine. It also fails on unknown Material Symbol names and Blade directives written inside component tags. Markdown mail components (under `mail.markdown.paths`, `resources/views/vendor/mail` by default) are the mail theme's to style: their classes are not read as Tailwind, and a mail theme stylesheet there is neither checked for literals nor a source of exempt classes. `missingStylesheets($cssEntry)` checks the entry's relative `@import` graph, followed through every package file's own imports, against the package tags the views render (unprefixed, under the configured prefix, or ``) and `->links()`, and names each missing `@import` line once; a tag the application shadows with its own component is reported instead. It reads imports only, so leave `resource_path('css')` out of `scan()` while the stylesheets still hold literals. `forbidColours([...])` names roles the application leaves out, and fails wherever one (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 any pattern of your own, line by line. diff --git a/UPGRADE.md b/UPGRADE.md index 6a26187d..51b1fe60 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -9,6 +9,13 @@ it in the layout, `toggle()` opens and closes the modal rail (`show()`, `hide()`) instead of changing a choice nothing draws there. A shortcut that pressed the rail's menu button to get this can call `$store.rail.toggle()` again. +- **`DesignGuard`** no longer reads a Markdown mail component's classes as Tailwind: a view under + `mail.markdown.paths` (`resources/views/vendor/mail` by default) is drawn by the mail theme, whose + `table`, `button` and `panel` share Tailwind's names, so only its icon names, the directives in + its component tags and the application's own bans are checked there. A mail theme stylesheet + under that path is no longer checked for literal values, and its classes no longer exempt the + same names in the application's other views. A filter an application wrote around such a + finding can go. - **`DesignGuard`** reports a colour utility on a colour the application's own Tailwind theme named (`bg-brand`, `text-sport-run`, `from-brand`, `border-l-zone-4`, `bg-brand/8`) in a class list, with `var(--…)` of the application's own as its replacement, unless the application's diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 9a486c64..ffea37a6 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -1207,6 +1207,8 @@ 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. +Markdown mail components — whatever lies under `config('mail.markdown.paths')`, `resources/views/vendor/mail` by default — are drawn by the mail theme, not by your CSS entry, and its classes (`table`, `button`, `panel`, `break-all`) share Tailwind's names. So the guard skips the class checks there (it still reads their icon names, the directives in their component tags and your own bans), and a mail theme stylesheet under that path, which has to write literal values, is neither checked for literals nor a source of exempt classes. + `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 ``) 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 diff --git a/src/Testing/DesignGuard.php b/src/Testing/DesignGuard.php index c26f2e2a..8126783a 100644 --- a/src/Testing/DesignGuard.php +++ b/src/Testing/DesignGuard.php @@ -47,6 +47,15 @@ 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. * + * A Markdown mail component — a view under a path `mail.markdown.paths` names, Laravel's + * `resources/views/vendor/mail` by default — is drawn by the mail theme, not by the application's + * CSS entry, and the theme's own classes (`table`, `button`, `panel`, `break-all`) share a + * Tailwind utility's name. So family (i) and the breakpoint, scale, palette and colour-value + * checks skip those views, while their icon names, the directives in their component tags and + * the application's own bans are still read; and a stylesheet under that path, a mail theme that + * has to write literal values because mail clients read no custom property, is neither check + * (iii)'s nor a source of exempt classes. + * * expect(DesignGuard::scan([resource_path('views'), resource_path('js'), resource_path('css'), app_path()]) * ->missingStylesheets(resource_path('css/app.css')) * ->forbidColours(['tertiary']) @@ -385,6 +394,8 @@ class DesignGuard } } + $mailPaths = $this->mailComponentPaths(); + foreach ($this->files() as $file) { if (str_ends_with($file->getFilename(), '.css')) { continue; // A directly-scanned CSS file is check (iii)'s alone; these checks read Blade, PHP and JS. @@ -393,12 +404,13 @@ class DesignGuard $contents = (string) file_get_contents($file->getPathname()); $where = $this->relative($file->getPathname()); $isBlade = str_ends_with($file->getFilename(), '.blade.php'); + $readsClasses = ! $this->isUnder($file->getPathname(), $mailPaths); if ($isBlade) { $contents = $this->withoutBladeComments($contents); } - if (str_ends_with($file->getFilename(), '.php')) { + if ($readsClasses && str_ends_with($file->getFilename(), '.php')) { foreach ($this->literalClasses($contents) as [$line, $class]) { if (($hint = $this->tailwindFamilyHint($class)) !== null) { $violations[] = "{$where}:{$line} {$hint}"; @@ -433,13 +445,13 @@ class DesignGuard foreach (explode("\n", $contents) as $index => $text) { $line = $index + 1; - if (preg_match_all($this->colourPattern(), $text, $matches)) { + if ($readsClasses && preg_match_all($this->colourPattern(), $text, $matches)) { foreach ($matches[0] as $class) { $violations[] = "{$where}:{$line} Tailwind palette colour `{$class}` compiles to nothing — M3 paints with roles: an `md-ink-*` class, or `var(--md-sys-color-*)` in your own CSS"; } } - foreach ([...$this->offTheTokens($text), ...$this->forbiddenRoles($text)] as $what) { + foreach ([...($readsClasses ? $this->offTheTokens($text) : []), ...$this->forbiddenRoles($text)] as $what) { $violations[] = "{$where}:{$line} {$what}"; } @@ -1546,8 +1558,9 @@ class DesignGuard /** * 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. + * the package, the generated `material-scheme.css` and a mail theme under a mail component path + * 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 */ @@ -1571,12 +1584,44 @@ class DesignGuard } } + $mailPaths = $this->mailComponentPaths(); + return array_values(array_unique(array_filter( $files, - fn (string $file): bool => $file !== '' && ! static::isPackageFile($file) && ! $this->isGeneratedScheme($file), + fn (string $file): bool => $file !== '' && ! static::isPackageFile($file) && ! $this->isGeneratedScheme($file) && ! $this->isUnder($file, $mailPaths), ))); } + /** + * The Markdown mail component paths Laravel renders mail from (`mail.markdown.paths`), as real + * paths; one that does not exist is left out. See the class header. + * + * @return list + */ + protected function mailComponentPaths(): array + { + return array_values(array_filter(array_map( + fn (mixed $path): string => is_string($path) ? (string) realpath($path) : '', + (array) config('mail.markdown.paths', []), + ))); + } + + /** + * @param list $directories Real paths. + */ + protected function isUnder(string $file, array $directories): bool + { + $file = realpath($file) ?: $file; + + foreach ($directories as $directory) { + if (str_starts_with($file, rtrim($directory, '/').'/')) { + return true; + } + } + + return false; + } + /** * 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 diff --git a/tests/Feature/DesignGuardTest.php b/tests/Feature/DesignGuardTest.php index 2333002c..f397b417 100644 --- a/tests/Feature/DesignGuardTest.php +++ b/tests/Feature/DesignGuardTest.php @@ -336,6 +336,35 @@ it('reports a colour utility on the application\'s own theme colour, and a Tailw ]); }); +it('leaves a Markdown mail component\'s classes and its theme to the mail theme, but still reads its icons and directives', function () { + $mail = realpath(GUARD_FIXTURES.'/mail/views'); + $table = 'Tailwind display utility `table` compiles to nothing — write the `display` rule in your own CSS'; + $icon = 'mail/views/vendor/mail/html/sessions.blade.php:7 unknown Material Symbol `not_a_symbol`'; + $directive = 'mail/views/vendor/mail/html/sessions.blade.php:8 Blade directive `@class` inside a component tag, where it does not compile — use `:class="\\Illuminate\\Support\\Arr::toCssClasses([…])"`'; + + config(['mail.markdown.paths' => [$mail.'/vendor/mail']]); + + // The theme's `.table` exempts nothing outside the mail components, and its literals are its own. + expect(fixtureRelative(DesignGuard::scan($mail)->violations()))->toBe([ + "mail/views/page.blade.php:1 {$table}", + $icon, + $directive, + ]); + + // Outside a mail component path the same files read as the application's own: the theme's + // classes as Tailwind, its literals as check (iii)'s, and its `.table` exempting `table`. + config(['mail.markdown.paths' => []]); + + expect(fixtureRelative(DesignGuard::scan($mail)->violations()))->toBe([ + 'mail/views/vendor/mail/html/sessions.blade.php:3 value outside the M3 scale `text-sm` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together', + 'mail/views/vendor/mail/html/sessions.blade.php:4 Tailwind text utility `break-all` compiles to nothing — write the rule in your own CSS', + $icon, + $directive, + 'mail/views/vendor/mail/html/themes/default.css:2 literal colour `#3d4852` — use `var(--md-sys-color-*)`', + 'mail/views/vendor/mail/html/themes/default.css:3 literal font size `font-size: 14px` — set the whole style with `font: var(--md-sys-typescale-*)` and its `-tracking`, or an `md-type-*` class', + ]); +}); + it('reports an arbitrary [] value once, whatever utility it modifies, and strips a trailing !', function () { $violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/tailwind/arbitrary.blade.php'))->violations()); diff --git a/tests/Fixtures/design-guard/mail/views/page.blade.php b/tests/Fixtures/design-guard/mail/views/page.blade.php new file mode 100644 index 00000000..1975c574 --- /dev/null +++ b/tests/Fixtures/design-guard/mail/views/page.blade.php @@ -0,0 +1 @@ +
diff --git a/tests/Fixtures/design-guard/mail/views/vendor/mail/html/sessions.blade.php b/tests/Fixtures/design-guard/mail/views/vendor/mail/html/sessions.blade.php new file mode 100644 index 00000000..85547622 --- /dev/null +++ b/tests/Fixtures/design-guard/mail/views/vendor/mail/html/sessions.blade.php @@ -0,0 +1,8 @@ +@props(['sessions', 'url']) +
+ + + +
+Open the week +A panel diff --git a/tests/Fixtures/design-guard/mail/views/vendor/mail/html/themes/default.css b/tests/Fixtures/design-guard/mail/views/vendor/mail/html/themes/default.css new file mode 100644 index 00000000..584335e0 --- /dev/null +++ b/tests/Fixtures/design-guard/mail/views/vendor/mail/html/themes/default.css @@ -0,0 +1,4 @@ +.table th { + color: #3d4852; + font-size: 14px; +}