Count a row written by hand in the design guard's stylesheet checks

`data-md-list-row` on an application's own `<li>`, `<div>`, `<tr>` or
`<x-row>` draws its hover, focus and press state layer, its cursor and
its `data-md-selected` fill from list-item.css, but missingStylesheets()
and unusedStylesheets() read only component tags and `->links()`.
ReStride had the stylesheet only because table.css imports it: had its
last `<x-table>` gone, every hand-made row would have lost its states
and its fill without a finding, and importing list-item.css for the
rows directly was reported unused.

A Blade view outside the package's own `resources/views` that writes a
hook in the new `HOOK_STYLESHEETS` now needs that hook's stylesheet in
both checks, named at the earliest line that needs it. The one entry is
`data-md-list-row` → `components/list-item.css`, except on `<x-card>`
(any spelling), whose row card.css draws; the card's opening tag is
blanked out before the hook is read, and a selector such as
`[data-md-list-row]` or a name in backticks writes no row. Nothing else
list-rows.js, the skill or the README has an application write needs a
stylesheet of its own: `data-md-list-open` and a row's
`data-md-selected` are drawn only through the row, a selected row in
`<x-table>` by table.css, `data-md-dragged` by foundation.css's
`md-state-layer` or card.css, `data-md-field-control` by field.css
inside `<x-field>`. The constant's docblock says so, and the guard's
docblocks, the README, the skill and UPGRADE.md describe the check.

A fixture test reads a hand-made `<li>` and `<tr>` row and a card-only
view (plain and namespaced `<x-card data-md-list-row>`, and a selector
in a script) against both checks; it fails without the change. A
second test scans the package's list-item and card views and expects
no row finding; it fails when package views are read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-17 08:05:33 +02:00
co-authored by Claude Opus 5
parent b62c83f244
commit 471d927e64
8 changed files with 208 additions and 47 deletions
+140 -44
View File
@@ -28,13 +28,16 @@ use Symfony\Component\Finder\Finder;
* (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 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. `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).
* own imports; a package name or URL the entry also imports is skipped, never fatal),
* `->links()` needing `pagination.css`, and a hook an application's view writes on markup
* of its own whose rules a component's stylesheet holds — `data-md-list-row` on anything
* but `<x-card>` needing `list-item.css` (`HOOK_STYLESHEETS`); 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. `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
@@ -225,6 +228,24 @@ class DesignGuard
protected const string THEME_COLOUR_UTILITY = '/^(?<utility>bg|text|border(?:-(?:[trblxyse]|bs|be))?|divide|ring(?:-offset)?|outline|fill|stroke|decoration|accent|caret|placeholder|shadow|from|via|to)-'
.'(?<name>[a-z][a-z\d]*(?:-[a-z\d]+)*)(?:\/(?<opacity>\d{1,3}|\[[^\]]*\]|\(--[\w-]+\)))?$/';
/**
* The hooks an application writes on markup of its own, not through a component tag, whose
* rules live in a component's stylesheet, and that stylesheet (check ii). `data-md-list-row`
* on an `<li>`, a `<div>`, a `<tr>` or a layout component such as `<x-row>` draws its hover,
* focus and press state layer and its `data-md-selected` fill from list-item.css, which no tag
* asks for when the page renders no `<x-list-item>` and no `<x-table>`. `except` names the
* package tags that draw the hook themselves: `<x-card data-md-list-row>` is card.css's row.
*
* Nothing else that list-rows.js, the skill or the README has an application write needs a
* stylesheet of its own: `data-md-list-open` and a row's `data-md-selected` are drawn only
* through the row, a selected row in `<x-table>` by table.css, `data-md-dragged` by
* foundation.css's `md-state-layer` (and card.css on `<x-card>`), and `data-md-field-control`
* by field.css inside `<x-field>`.
*/
protected const array HOOK_STYLESHEETS = [
'data-md-list-row' => ['stylesheet' => 'components/list-item.css', 'except' => ['card']],
];
/** 1.x's semantic ink names, and the M3 role each resolved to (UPGRADE.md § 4). */
protected const array INK_ALIASES = [
'body' => 'on-surface-variant',
@@ -335,15 +356,16 @@ class DesignGuard
/**
* Check (ii): every package component tag used in a scanned view — unprefixed, under the
* 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. 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.
* configured prefix, or `<x-livewire-material::…>` — every `->links()` call, and every hook in
* `HOOK_STYLESHEETS` a view outside the package writes on its own markup (a row's
* `data-md-list-row`), 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. 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
{
@@ -355,10 +377,11 @@ class DesignGuard
/**
* 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.
* scanned Blade view needs — no package tag it renders, no `->links()`, no hook it writes by
* hand (`HOOK_STYLESHEETS`), 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
{
@@ -405,6 +428,7 @@ class DesignGuard
$where = $this->relative($file->getPathname());
$isBlade = str_ends_with($file->getFilename(), '.blade.php');
$readsClasses = ! $this->isUnder($file->getPathname(), $mailPaths);
$readsHooks = ! $this->isUnder($file->getPathname(), array_filter([static::packageViewRoot()]));
if ($isBlade) {
$contents = $this->withoutBladeComments($contents);
@@ -432,13 +456,13 @@ class DesignGuard
}
if ($this->cssEntry !== null) {
foreach ($this->missingStylesheetViolations($contents, $resolved, $reported) as [$line, $what]) {
foreach ($this->missingStylesheetViolations($contents, $resolved, $reported, $readsHooks) as [$line, $what]) {
$violations[] = "{$where}:{$line} {$what}";
}
}
if ($this->unusedEntry !== null) {
$needed = [...$needed, ...$this->neededStylesheets($contents)];
$needed = [...$needed, ...$this->neededStylesheets($contents, $readsHooks)];
}
}
@@ -471,12 +495,13 @@ class DesignGuard
}
/**
* The package stylesheets a Blade view's own tags and `->links()` calls need, before their
* imports are followed.
* The package stylesheets a Blade view's own tags, `->links()` calls and, unless `$readsHooks`
* is false (a package view), the hooks it writes by hand need, before their imports are
* followed.
*
* @return list<string>
*/
protected function neededStylesheets(string $contents): array
protected function neededStylesheets(string $contents, bool $readsHooks = true): array
{
$needed = [];
@@ -490,6 +515,10 @@ class DesignGuard
}
}
if ($readsHooks) {
$needed = [...$needed, ...array_column($this->hookUsages($contents), 2)];
}
if ($this->paginationUsages($contents) !== []) {
$needed[] = static::packageCssRoot().'/components/pagination.css';
}
@@ -1502,19 +1531,74 @@ class DesignGuard
return array_map(fn (array $match): int => substr_count(substr($contents, 0, $match[1]), "\n") + 1, $matches[0]);
}
/**
* Every hook in `HOOK_STYLESHEETS` a Blade view writes, each as [line, hook, stylesheet]: an
* attribute of a plain element or of a component tag other than those `except` names (blanked
* out first), or a name a PHP array or a script sets. A selector (`[data-md-list-row]`), a name
* in backticks and a longer name that starts with the hook write no hook.
*
* @return list<array{0: int, 1: string, 2: string}>
*/
protected function hookUsages(string $contents): array
{
$found = [];
foreach (self::HOOK_STYLESHEETS as $hook => ['stylesheet' => $stylesheet, 'except' => $except]) {
if (! str_contains($contents, $hook)) {
continue;
}
$markup = $this->withoutPackageTags($contents, $except);
preg_match_all('/(?<![\w`\[-])'.preg_quote($hook, '/').'(?![\w-])/', $markup, $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[0] as [, $offset]) {
$found[] = [substr_count(substr($markup, 0, $offset), "\n") + 1, $hook, static::packageCssRoot().'/'.$stylesheet];
}
}
return $found;
}
/**
* `$contents` with the opening tag of each package component `$names` names — plain, under the
* configured prefix or `<x-livewire-material::…>` — blanked out, line breaks kept. A quoted
* attribute value or a `{{ … }}` inside the tag may hold a `>`.
*
* @param list<string> $names
*/
protected function withoutPackageTags(string $contents, array $names): string
{
if ($names === []) {
return $contents;
}
$prefix = (string) config('livewire-material.prefix', '');
$namespaces = $prefix !== '' ? ['livewire-material', $prefix] : ['livewire-material'];
return (string) preg_replace_callback(
'/<x-(?:(?:'.$this->alternation($namespaces).')::)?(?:'.$this->alternation($names).')(?=[\s\/>])(?:\{\{.*?\}\}|"[^"]*"|\'[^\']*\'|[^>"\'])*+>?/s',
fn (array $tag): string => (string) preg_replace('/[^\n]/', ' ', $tag[0]),
$contents,
);
}
/**
* 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` — each
* only the first time `$reported` (shared across every file of one `violations()` run) sees it.
* contain, a tag the application shadows, a hook written by hand whose stylesheet it does not
* contain (unless `$readsHooks` is false, for a package view), and a `->links()` needing
* `pagination.css` — each only the first time `$reported` (shared across every file of one
* `violations()` run) sees it, at the earliest line in the file that needs 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 &$reported): array
protected function missingStylesheetViolations(string $contents, array $resolved, array &$reported, bool $readsHooks = true): array
{
$found = [];
$needs = [];
foreach ($this->packageTagUsages($contents) as [$line, $name, $spelling]) {
if ($spelling === 'plain' && $this->shadowedByApplication($name)) {
@@ -1526,29 +1610,32 @@ class DesignGuard
continue;
}
$stylesheet = static::packageStylesheetFor($name);
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,
static::packageRelativeName($stylesheet),
$this->relative($this->cssEntry),
$this->importLine($stylesheet),
)];
if (($stylesheet = static::packageStylesheetFor($name)) !== null) {
$needs[] = [$line, "`<x-{$name}>`", $stylesheet];
}
}
$pagination = static::packageCssRoot().'/components/pagination.css';
if ($readsHooks) {
foreach ($this->hookUsages($contents) as [$line, $hook, $stylesheet]) {
$needs[] = [$line, "`{$hook}`", $stylesheet];
}
}
foreach ($this->paginationUsages($contents) as $line) {
if (! isset($resolved[$pagination]) && ! isset($reported[$pagination])) {
$reported[$pagination] = true;
$needs[] = [$line, '`->links()`', static::packageCssRoot().'/components/pagination.css'];
}
usort($needs, fn (array $a, array $b): int => $a[0] <=> $b[0]);
foreach ($needs as [$line, $what, $stylesheet]) {
if (! isset($resolved[$stylesheet]) && ! isset($reported[$stylesheet])) {
$reported[$stylesheet] = true;
$found[] = [$line, sprintf(
"`->links()` needs `components/pagination.css`, missing from %s — add `@import '%s';`",
"%s needs `%s`, missing from %s — add `@import '%s';`",
$what,
static::packageRelativeName($stylesheet),
$this->relative($this->cssEntry),
$this->importLine($pagination),
$this->importLine($stylesheet),
)];
}
}
@@ -1699,6 +1786,15 @@ class DesignGuard
return (string) realpath(dirname(__DIR__, 2).'/resources/views/components');
}
/**
* This package's own `resources/views`: its components, showcase, pagination and error pages.
* A hook written there is a tag's own, so check (ii) reads hooks only outside it.
*/
protected static function packageViewRoot(): string
{
return (string) realpath(dirname(__DIR__, 2).'/resources/views');
}
protected static function isPackageTag(string $name): bool
{
$root = static::packageComponentViewRoot();