Rewrite the design guard's family table for a Tailwind-free application

Plan step 41(i): every Tailwind utility or variant in a Tailwind-free
application's views now gets a 2.0.0 hint instead of the old Tailwind-utility
replacement (which no longer compiles either) -- a layout component and prop,
an md-* class, or a var(--md-sys-*) token. New families cover flex/grid/gap,
spacing, sizing, display, text-layout and the M3 role utilities
(text-on-surface-variant, border-outline-variant, bg-surface-*, bg-primary);
the breakpoint and scale checks fold in with their hints rewritten the same
way. A class the application's own stylesheets declare is exempt.

literalClasses() now returns each class token unstripped of its variant
prefix (`sm:`, `hover:`) so the new checks can see it; the daisyUI check
strips it itself via the new withoutVariantPrefix() helper, as it always did
internally before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-15 08:08:08 +02:00
co-authored by Claude Sonnet 5
parent 7ec6960731
commit 08f2ae9d76
14 changed files with 984 additions and 109 deletions
+776 -38
View File
@@ -2,28 +2,63 @@
namespace NoNameWeb\LivewireMaterial\Testing;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
use NoNameWeb\LivewireMaterial\Support\SvgFile;
use SplFileInfo;
use Symfony\Component\Finder\Finder;
/**
* Finds what compiles to nothing and fails silently: maryUI tags, daisyUI classes, colours the
* theme does not declare, icon names that are not Material Symbols, Tailwind's breakpoint
* prefixes and the radius, shadow, type-size, weight, leading, tracking, easing and duration
* scales M3's own sets replace, and colours written as a literal value instead of a role — plus
* whatever an application bans on top (roles its own design rules leave out, the two absolutes,
* opacity used as emphasis, patterns it has retired).
* Finds what compiles to nothing in a Tailwind-free application (plan step 41), and what a
* Tailwind migration still leaves behind:
*
* (i) any Tailwind utility or variant in a view, PHP or JS file — a breakpoint prefix, a
* cleared scale (radius, shadow, type size/weight/leading/tracking, easing, duration), a
* layout, spacing, sizing or display utility, a text-layout utility, an M3 role utility
* (`text-on-surface-variant`, `bg-primary`), a pseudo-class variant, or an arbitrary
* `[…]` value — each with its 2.0.0 replacement: a layout component and prop, an `md-*`
* class, or a token for the application's own CSS. A class the application's own
* stylesheets declare is exempt, and so is every `md-*` class and the package's shared
* interaction hooks (`md-state-layer`, `md-focus-ring`, `md-touch-target`, `md-link`).
* (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
* `var(--md-sys-…)` or `calc()` is never flagged, whatever it contains.
* (iv) maryUI tags, daisyUI classes, colours the theme does not declare, icon names that are
* not Material Symbols, and Blade directives written inside a component tag (where they
* do not compile) — plus whatever an application bans on top with `forbidColours()`,
* `forbidAbsolutes()`, `forbidOpacityInk()` and `forbid()`. These, and the breakpoint,
* scale and colour-value checks, read a line at a time, so a class assembled at runtime
* (`'text-'.$tone`) or hidden in a comment stays invisible — the same reason to write
* class names out whole. `forbidAbsolutes()` and `forbidOpacityInk()` matter mainly to an
* application still migrating off Tailwind, where the package's own opacity-based
* disabled state is still the only legitimate use of either.
*
* expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
* ->forbidColours(['tertiary', 'primary-container'])
* ->forbidAbsolutes()
* ->forbidOpacityInk()
* ->missingStylesheets(resource_path('css/app.css'))
* ->forbidColours(['tertiary'])
* ->violations())->toBe([]);
*
* Each violation is "path:line what", with the path relative to the base path; where there is
* an M3 utility to use instead, the line names it. It reads the source, so a class assembled at
* runtime (`'text-'.$tone`) is invisible to it — which is one more reason to write class names
* out whole, the only form Tailwind compiles anyway.
* Each violation is "path:line what", the path relative to the base path. False positives are
* kept low two ways: family (i)'s new checks — everything but the breakpoint, scale and
* colour-value patterns kept from before 2.0.0 — match only a class already isolated from a
* `class`/`@class`/`:class`/`x-bind:class`/`->class()`/`Arr::toCssClasses()` list in a `.blade.php`
* file, never a bare word scanned across a whole line, which is what keeps an English sentence
* ("this creates a grid of cards") from matching `grid`; and a line-by-line match (used for the
* breakpoint, scale and colour-value families, which also have to see a class assembled as a
* plain PHP or JS string, as an enum's own literal colour string does) requires the utility's actual
* shape — a digit, a known scale step or a colour function — never a bare word. The trade-off:
* `Arr::toCssClasses()` and `->class()` outside a `.blade.php` file, and a bare-word utility
* (`flex`, `hidden`) anywhere but inside a class list, are invisible to this guard.
*/
class DesignGuard
{
@@ -52,10 +87,12 @@ class DesignGuard
protected const string OPACITY_INK = '/(?<![\w-])(?:bg|text|border(?:-[trblxyse])?)-[a-z][a-z\d-]*\/\d{1,3}(?![\w-])/';
/**
* Tailwind's breakpoint prefixes and the M3 window size class that replaces each. Tailwind's
* Tailwind's breakpoint prefixes and the M3 window size class each names. Tailwind's
* 640/768/1024/1280/1536 are 4088 px from M3's 600/840/1200/1600, so a prefix maps to the
* class that carries the same intent, never to the same pixel: both of its phone breakpoints
* are M3's medium.
* are M3's medium. Since 2.0.0 the prefix itself compiles to nothing — there is no Tailwind
* left to read it — so the hint points at the layout components' props and a plain media
* query instead of another class.
*/
protected const array WINDOW_CLASSES = [
'sm' => 'medium',
@@ -65,7 +102,15 @@ class DesignGuard
'2xl' => 'extra-large',
];
/** Tailwind's radius scale and the M3 corner that replaces each (styles §Shape). */
/** The M3 breakpoint each window size class is, in px (foundations.md § Layout). */
protected const array WINDOW_PX = [
'medium' => 600,
'expanded' => 840,
'large' => 1200,
'extra-large' => 1600,
];
/** Tailwind's radius scale and the M3 corner it replaces each (styles §Shape). */
protected const array CORNERS = [
'none' => 'none',
'xs' => 'xs',
@@ -79,7 +124,7 @@ class DesignGuard
'full' => 'full',
];
/** Tailwind's shadow scale and the M3 elevation level that replaces each (styles §Elevation). */
/** Tailwind's shadow scale and the M3 elevation level it replaces each (styles §Elevation). */
protected const array ELEVATIONS = [
'2xs' => 1,
'xs' => 1,
@@ -90,6 +135,76 @@ class DesignGuard
'2xl' => 5,
];
/**
* Tailwind's flex, grid and gap utilities: `<x-row>`, `<x-grid>` and `<x-stack>` and their
* `gap`/`align`/`justify`/`columns` props replace every one of them.
*/
protected const string LAYOUT_UTILITY = '/^(?:flex|inline-flex|grid|inline-grid|contents|flex-(?:row|row-reverse|col|col-reverse|wrap|wrap-reverse|nowrap|1|auto|initial|none)|grid-cols-\d+|grid-rows-\d+|col-span-\d+|row-span-\d+|(?:items|justify|content|place-items|place-content|place-self|self)-[a-z]+|gap(?:-[xy])?-[\w.\/]+)$/';
/** Tailwind's padding, margin and gap-adjacent space-between utilities. */
protected const string SPACING_UTILITY = '/^(?:[pm][trblxyse]?|space-[xy])-[\w.\/-]+$/';
/** Tailwind's width and height utilities. M3 keeps no size scale of its own. */
protected const string SIZING_UTILITY = '/^(?:min-|max-)?(?:w|h|size)-[\w.\/%-]+$/';
/**
* Bare display utilities: no digit or bracket sets them apart from an English word, so these
* are matched only as a whole class-list token (never scanned line by line), which is what
* keeps "hidden" or "block" in running text from matching.
*/
protected const array DISPLAY_UTILITY = ['block', 'inline-block', 'inline', 'invisible', 'visible'];
/** Text-layout utilities with a direct `md-*` replacement (text.css). */
protected const array TEXT_LAYOUT_UTILITY = [
'text-left' => 'md-text-start',
'text-center' => 'md-text-center',
'text-right' => 'md-text-end',
'truncate' => 'md-truncate',
'line-clamp-2' => 'md-line-clamp-2',
'line-clamp-3' => 'md-line-clamp-3',
'whitespace-nowrap' => 'md-nowrap',
'sr-only' => 'md-visually-hidden',
'tabular-nums' => 'md-tabular',
];
/** An ink role Tailwind's `text-*` utility named, and the `md-ink-*` class that replaces it. */
protected const array INK_ROLE = [
'on-surface' => 'md-ink',
'on-surface-variant' => 'md-ink-variant',
'outline' => 'md-ink-quiet',
'primary' => 'md-ink-primary',
'error' => 'md-ink-error',
'success' => 'md-ink-success',
'warning' => 'md-ink-warning',
'info' => 'md-ink-info',
'inverse-on-surface' => 'md-ink-inverse',
];
/** The named colours a browser understands, other than `transparent` and `currentColor`. */
protected const string CSS_NAMED_COLOURS = 'aliceblue|antiquewhite|aqua|aquamarine|azure|beige|bisque|black|blanchedalmond|blue|blueviolet|brown|burlywood|cadetblue|chartreuse|chocolate|coral|cornflowerblue|cornsilk|crimson|cyan|darkblue|darkcyan|darkgoldenrod|darkgray|darkgreen|darkgrey|darkkhaki|darkmagenta|darkolivegreen|darkorange|darkorchid|darkred|darksalmon|darkseagreen|darkslateblue|darkslategray|darkslategrey|darkturquoise|darkviolet|deeppink|deepskyblue|dimgray|dimgrey|dodgerblue|firebrick|floralwhite|forestgreen|fuchsia|gainsboro|ghostwhite|gold|goldenrod|gray|green|greenyellow|grey|honeydew|hotpink|indianred|indigo|ivory|khaki|lavender|lavenderblush|lawngreen|lemonchiffon|lightblue|lightcoral|lightcyan|lightgoldenrodyellow|lightgray|lightgreen|lightgrey|lightpink|lightsalmon|lightseagreen|lightskyblue|lightslategray|lightslategrey|lightsteelblue|lightyellow|lime|limegreen|linen|magenta|maroon|mediumaquamarine|mediumblue|mediumorchid|mediumpurple|mediumseagreen|mediumslateblue|mediumspringgreen|mediumturquoise|mediumvioletred|midnightblue|mintcream|mistyrose|moccasin|navajowhite|navy|oldlace|olive|olivedrab|orange|orangered|orchid|palegoldenrod|palegreen|paleturquoise|palevioletred|papayawhip|peachpuff|peru|pink|plum|powderblue|purple|rebeccapurple|red|rosybrown|royalblue|saddlebrown|salmon|sandybrown|seagreen|seashell|sienna|silver|skyblue|slateblue|slategray|slategrey|snow|springgreen|steelblue|tan|teal|thistle|tomato|turquoise|violet|wheat|white|whitesmoke|yellow|yellowgreen';
/** A property whose literal value check (iii) reports, and the token that replaces it. */
protected const array LITERAL_PROPERTIES = [
'border-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-top-left-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-top-right-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-bottom-right-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-bottom-left-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-start-start-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-start-end-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-end-start-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-end-end-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'box-shadow' => ['kind' => 'shadow', 'hint' => 'use `var(--md-sys-elevation-*)`'],
'font-size' => ['kind' => 'font size', 'hint' => 'use `var(--md-sys-typescale-*)`, which sets size, line height and weight together'],
'font-weight' => ['kind' => 'font weight', 'hint' => 'use `var(--md-sys-typescale-*)` or `var(--md-sys-typescale-emphasized-*)`'],
'line-height' => ['kind' => 'line height', 'hint' => 'use `var(--md-sys-typescale-*)`, which sets it with the size'],
'letter-spacing' => ['kind' => 'letter spacing', 'hint' => 'use `var(--md-sys-typescale-*-tracking)`'],
'transition-timing-function' => ['kind' => 'easing', 'hint' => 'use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`'],
'animation-timing-function' => ['kind' => 'easing', 'hint' => 'use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`'],
'transition-duration' => ['kind' => 'duration', 'hint' => 'use `var(--md-sys-motion-…-duration)`, paired with its easing'],
'animation-duration' => ['kind' => 'duration', 'hint' => 'use `var(--md-sys-motion-…-duration)`, paired with its easing'],
];
/** @var list<array{pattern: string, reason: string}> */
protected array $forbidden = [];
@@ -100,6 +215,11 @@ class DesignGuard
protected bool $forbiddenOpacityInk = false;
protected ?string $cssEntry = null;
/** @var array<string, true>|null */
protected ?array $applicationClassesCache = null;
/**
* @param list<string> $paths
*/
@@ -130,7 +250,8 @@ class DesignGuard
* Also ban the two absolutes. M3 paints with roles only: the white of a light page is
* `surface-container-lowest`, the black of a dark one `surface-dim`, and the ink on a filled
* button is its `on-` role. Off by default, because a logo, a scrim or a print stylesheet
* sometimes does mean the absolute.
* sometimes does mean the absolute — and because, until an application finishes leaving
* Tailwind, `bg-white`/`text-black` may still be sitting in an untouched view.
*/
public function forbidAbsolutes(): static
{
@@ -164,12 +285,44 @@ class DesignGuard
return $this;
}
/**
* 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. 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.
*/
public function missingStylesheets(string $cssEntry): static
{
$this->cssEntry = $cssEntry;
return $this;
}
/**
* @return list<string>
*/
public function violations(): array
{
$violations = [];
$resolved = $this->cssEntry !== null
? array_fill_keys(Stylesheets::resolvedFiles([$this->cssEntry]), true)
: [];
if ($this->cssEntry !== null) {
$foundation = static::packageCssRoot().'/foundation.css';
if (! isset($resolved[$foundation])) {
$violations[] = sprintf(
'%s:1 the CSS entry never imports `foundation.css`, required by every package stylesheet — add `@import \'%s\';`',
$this->relative($this->cssEntry),
$this->importLine($foundation),
);
}
}
foreach ($this->files() as $file) {
$contents = (string) file_get_contents($file->getPathname());
@@ -180,8 +333,14 @@ class DesignGuard
$contents = $this->withoutBladeComments($contents);
foreach ($this->literalClasses($contents) as [$line, $class]) {
if (preg_match(self::DAISY_CLASSES, $class) === 1 && preg_match(self::TAILWIND_LOOKALIKES, $class) !== 1) {
$violations[] = "{$where}:{$line} daisyUI class `{$class}`";
$bare = $this->withoutVariantPrefix($class);
if (preg_match(self::DAISY_CLASSES, $bare) === 1 && preg_match(self::TAILWIND_LOOKALIKES, $bare) !== 1) {
$violations[] = "{$where}:{$line} daisyUI class `{$bare}`";
}
if (($hint = $this->tailwindFamilyHint($class)) !== null) {
$violations[] = "{$where}:{$line} {$hint}";
}
}
@@ -192,6 +351,12 @@ class DesignGuard
foreach ($this->directivesInComponentTags($contents) as [$line, $directive]) {
$violations[] = "{$where}:{$line} Blade directive `{$directive}` inside a component tag, where it does not compile";
}
if ($this->cssEntry !== null) {
foreach ($this->missingStylesheetViolations($contents, $resolved) as [$line, $what]) {
$violations[] = "{$where}:{$line} {$what}";
}
}
}
foreach (explode("\n", $contents) as $index => $text) {
@@ -221,7 +386,7 @@ class DesignGuard
}
}
return $violations;
return [...$violations, ...$this->applicationCssViolations()];
}
/**
@@ -256,8 +421,11 @@ class DesignGuard
/**
* Everything on one line that names a value the theme no longer carries, each with the M3
* utility that replaces it. Line by line like the colour check, so a class inside a PHP or
* JS string is seen too, not only one inside a `class` attribute.
* token or component that replaces it. Line by line like the colour check, so a class inside
* a PHP or JS string is seen too, not only one inside a `class` attribute — the reach these
* three families have always needed (a plain PHP string returning a stray colour name), and the reason they
* stay a line-by-line match rather than moving to the class-token check every newer family
* uses (see the class header).
*
* @return list<string>
*/
@@ -286,14 +454,17 @@ class DesignGuard
return array_map(function (array $match): string {
$max = $match['max'] ?? '';
$name = self::WINDOW_CLASSES[$match['name']];
$px = self::WINDOW_PX[$name];
$comparison = $max !== '' ? "width < {$px}px" : "width >= {$px}px";
return "Tailwind breakpoint `{$max}{$match['name']}:`, use `{$max}".self::WINDOW_CLASSES[$match['name']].':`';
return "Tailwind breakpoint `{$max}{$match['name']}:` compiles to nothing — M3's {$name} ({$px}px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media ({$comparison})` in your own CSS";
}, $matches);
}
/**
* The radius, shadow, type-size, weight, leading, tracking, easing and duration utilities
* Tailwind ships and M3's own scales replace.
* Tailwind shipped and M3's own scales replace.
*
* @return list<string>
*/
@@ -314,27 +485,27 @@ class DesignGuard
preg_match_all($pattern, $text, $matches, PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL);
return array_map(
fn (array $match): string => "value outside the M3 scale `{$match[0]}`, use ".$this->scaleReplacement($match),
fn (array $match): string => "value outside the M3 scale `{$match[0]}` ".$this->scaleReplacement($match),
$matches,
);
}
/**
* The M3 utility for one match of the scale pattern. A type size, a leading and a tracking
* are all one type style: the `type-*` utilities set the three together, which is what keeps
* a line of text on the typescale.
* The 2.0.0 replacement for one match of the scale pattern: a token for the application's own
* CSS, or (for text) one of the `md-type-*` classes, which set size, line height, weight and
* tracking together — never a Tailwind utility, since none compiles any more.
*
* @param array<array-key, string|null> $match
*/
protected function scaleReplacement(array $match): string
{
return match (true) {
isset($match['corner']) => '`rounded'.($match['side'] ?? '').'-corner-'.self::CORNERS[$match['corner']].'`',
isset($match['elevation']) => '`shadow-elevation-'.self::ELEVATIONS[$match['elevation']].'`',
isset($match['weight']) => 'a `type-emphasized-*` style',
isset($match['easing']) => '`ease-standard` or an `ease-spatial-*`/`ease-effects-*` with its duration',
isset($match['duration']) => '`duration-(--md-sys-motion-…-duration)` paired with its easing',
default => 'a `type-*` style',
isset($match['corner']) => 'use `var(--md-sys-shape-corner-'.self::CORNERS[$match['corner']].')` in your own CSS, or `<x-surface corner="'.self::CORNERS[$match['corner']].'">`',
isset($match['elevation']) => 'use `var(--md-sys-elevation-'.self::ELEVATIONS[$match['elevation']].')` in your own CSS',
isset($match['weight']) => 'use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
isset($match['easing']) => 'pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition`',
isset($match['duration']) => 'pair `var(--md-sys-motion-…-duration)` with its easing in your own `transition`',
default => 'use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
};
}
@@ -373,6 +544,228 @@ class DesignGuard
return $found;
}
/**
* `$token` with a variant prefix — a breakpoint, a stacked pseudo-class chain — and a leading
* `!` dropped, so the daisyUI check can still recognise a variant-prefixed daisy class for
* what it is underneath. `tailwindFamilyHint()` reports the prefix itself (see the variant
* family below), so it works from the token `literalClasses()` returns, unstripped.
*/
protected function withoutVariantPrefix(string $token): string
{
return ltrim((string) preg_replace('/^.*:/', '', $token), '!');
}
/**
* A Tailwind-shaped class token's 2.0.0 replacement, or null when the token is not this
* guard's to report: it is not Tailwind-shaped at all (an application's own class, an ARIA or
* data token, a plain word), it is on the small set nothing here ever flags (`md-*` — the
* text classes and the shared interaction hooks), or the application's own CSS declares it
* (`applicationClasses()`). Only ever called with a token `literalClasses()` already isolated
* from a class list, which is what makes it safe to match a bare word like `flex` or `hidden`
* (see the class header's false-positive note).
*/
protected function tailwindFamilyHint(string $token): ?string
{
if ($token === '' || str_starts_with($token, 'md-') || isset($this->applicationClasses()[$token])) {
return null;
}
$token = rtrim($token, '!');
if (preg_match(self::ARBITRARY_COLOUR, $token) === 1) {
return null; // colourValues() already reports this, line by line.
}
if (str_contains($token, ':')) {
$lead = explode(':', $token, 2)[0];
$bare = str_starts_with($lead, 'max-') ? substr($lead, 4) : $lead;
if (isset(self::WINDOW_CLASSES[$bare])) {
return null; // breakpointPrefixes() already names the M3 breakpoint for this one.
}
return "Tailwind variant class `{$token}` compiles to nothing — a state is `:has()`/`aria-*`/`data-md-*` in your own CSS, a breakpoint a layout component's `hide-below`/`hide-from`/`stack-below` prop";
}
if (preg_match('/\[[^\]]*\]/', $token) === 1) {
return "Tailwind arbitrary value `{$token}` compiles to nothing — write the literal value in your own CSS, or use an M3 token";
}
if (preg_match(self::LAYOUT_UTILITY, $token) === 1) {
return "Tailwind layout utility `{$token}` compiles to nothing — use `<x-row>`, `<x-grid>` or `<x-stack>` and their `gap`/`align`/`justify`/`columns` props";
}
if (preg_match(self::SPACING_UTILITY, $token) === 1) {
return "Tailwind spacing utility `{$token}` compiles to nothing — use a layout component's `gap`/`padding` prop (`<x-stack gap>`, `<x-surface padding>`), or `var(--md-sys-measurement-space*)` in your own CSS";
}
if (preg_match(self::SIZING_UTILITY, $token) === 1) {
return "Tailwind sizing utility `{$token}` compiles to nothing — M3 keeps no size scale; write the literal length in your own CSS, or use `<x-pane width>`/`<x-grid min-item>` where it fits";
}
if ($token === 'hidden') {
return "Tailwind's `hidden` compiles to nothing — use a layout component's `hide-below`/`hide-from` prop, or the reset's `[hidden]` attribute";
}
if (in_array($token, self::DISPLAY_UTILITY, true)) {
return "Tailwind display utility `{$token}` compiles to nothing — write the `display` rule in your own CSS";
}
if (isset(self::TEXT_LAYOUT_UTILITY[$token])) {
return "Tailwind's `{$token}` compiles to nothing — use `".self::TEXT_LAYOUT_UTILITY[$token].'` (text.css)';
}
if (preg_match('/^text-(?<role>on-surface(?:-variant)?|outline|primary|error|success|warning|info|inverse-on-surface)$/', $token, $m) === 1) {
return "Tailwind ink role `{$token}` compiles to nothing — use `".self::INK_ROLE[$m['role']].'` (text.css)';
}
if (preg_match('/^(?:border(?:-[trblxyse])?|divide|ring)-outline(?:-variant)?$/', $token) === 1) {
return "Tailwind line role `{$token}` compiles to nothing — use `<x-divider>` or `<x-surface outlined>` for a line, not a border utility";
}
if (preg_match('/^bg-(?<role>surface(?:-dim|-bright|-container(?:-lowest|-low|-high|-highest)?)?)$/', $token, $m) === 1) {
return "Tailwind surface role `{$token}` compiles to nothing — use `<x-surface level=\"{$m['role']}\">`";
}
if (preg_match('/^(?:bg|border(?:-[trblxyse])?|ring|fill|stroke|divide|decoration|accent|caret|outline)-(?:on-)?(?:primary|secondary|tertiary|error|success|warning|info)(?:-container)?$/', $token) === 1) {
return "Tailwind role utility `{$token}` compiles to nothing — use `var(--md-sys-color-*)` in your own CSS";
}
return null;
}
/**
* Check (iii): literal design values in the application's own CSS. `var(--md-sys-…)` and
* `calc()` are blanked out first (see `withoutTokenFunctions()`), so a value built from tokens
* is invisible to every pattern below, whatever it contains; `0`, `none` and `inherit` are
* always fine.
*
* @return list<string>
*/
protected function applicationCssViolations(): array
{
$violations = [];
foreach ($this->applicationStylesheets() as $file) {
$css = $this->withoutTokenFunctions($this->withoutCssComments((string) file_get_contents($file)));
$where = $this->relative($file);
foreach ([
...$this->literalColours($css),
...$this->literalDeclarations($css),
...$this->offScaleMediaQueries($css),
] as [$line, $what]) {
$violations[] = "{$where}:{$line} {$what}";
}
}
return $violations;
}
/**
* Every literal colour in `$css`: a hex code, a colour function, or a named colour other than
* `transparent`/`currentColor` written where a value is expected (after `:`, `,` or `(`) —
* which is what keeps a selector like `.red-banner` from matching.
*
* @return list<array{0: int, 1: string}>
*/
protected function literalColours(string $css): array
{
$pattern = '/(?<![\w#-])(?:#[0-9a-fA-F]{3,8}\b|(?:rgb|rgba|hsl|hsla|oklch|oklab|lch|lab|color)\(|(?<=[:,(]\s{0,20})(?:'.self::CSS_NAMED_COLOURS.')\b(?!-))/i';
preg_match_all($pattern, $css, $matches, PREG_OFFSET_CAPTURE);
return array_map(
fn (array $match): array => [
substr_count(substr($css, 0, $match[1]), "\n") + 1,
"literal colour `{$match[0]}` — use `var(--md-sys-color-*)`",
],
$matches[0],
);
}
/**
* Every literal value on a property check (iii) knows, plus a `transition`/`animation`
* shorthand's embedded easing or duration (its property name alone cannot say which part of
* the value is which, so both are searched for whenever either is written literally).
*
* @return list<array{0: int, 1: string}>
*/
protected function literalDeclarations(string $css): array
{
$properties = implode('|', array_map(fn (string $p): string => preg_quote($p, '/'), array_keys(self::LITERAL_PROPERTIES)));
preg_match_all('/(?<property>'.$properties.')\s*:\s*(?<value>[^;{}]+);/i', $css, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
$found = [];
foreach ($matches as $match) {
$property = strtolower($match['property'][0]);
$value = trim($match['value'][0]);
if ($this->isSafeLiteralValue($value)) {
continue;
}
$line = substr_count(substr($css, 0, $match[0][1]), "\n") + 1;
$config = self::LITERAL_PROPERTIES[$property];
$found[] = [$line, "literal {$config['kind']} `{$property}: {$value}` — {$config['hint']}"];
}
foreach (['transition', 'animation'] as $shorthand) {
preg_match_all('/(?<![\w-])'.$shorthand.'\s*:\s*(?<value>[^;{}]+);/i', $css, $shorthandMatches, PREG_OFFSET_CAPTURE);
foreach ($shorthandMatches['value'] as [$value, $offset]) {
$line = substr_count(substr($css, 0, $offset), "\n") + 1;
if (preg_match('/cubic-bezier\(|(?<![\w-])(?:ease(?:-in-out|-in|-out)?|linear)(?![\w-])/i', $value) === 1) {
$found[] = [$line, "literal easing in `{$shorthand}: {$value}` — use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`"];
}
if (preg_match('/(?<![\w.-])\d+(?:\.\d+)?m?s(?![\w-])/i', $value) === 1) {
$found[] = [$line, "literal duration in `{$shorthand}: {$value}` — use `var(--md-sys-motion-…-duration)`, paired with its easing"];
}
}
}
return $found;
}
protected function isSafeLiteralValue(string $value): bool
{
return trim($value) === '' || in_array(strtolower(trim($value)), ['0', '0px', '0s', 'none', 'inherit', 'initial', 'unset', 'normal', 'auto'], true);
}
/**
* Every `@media` width in `$css` that is not one of M3's four breakpoints written in px.
*
* @return list<array{0: int, 1: string}>
*/
protected function offScaleMediaQueries(string $css): array
{
preg_match_all('/@media\s*([^{]*)\{/i', $css, $matches, PREG_OFFSET_CAPTURE);
$found = [];
foreach ($matches[1] as [$prelude, $preludeOffset]) {
preg_match_all('/(\d+(?:\.\d+)?)(px|rem|em)\b/i', $prelude, $widths, PREG_OFFSET_CAPTURE);
foreach ($widths[0] as $i => [$whole, $widthOffset]) {
$number = (float) $widths[1][$i][0];
$unit = strtolower($widths[2][$i][0]);
if ($unit === 'px' && in_array((int) $number, [600, 840, 1200, 1600], true) && $number == (int) $number) {
continue;
}
$line = substr_count(substr($css, 0, $preludeOffset + $widthOffset), "\n") + 1;
$found[] = [$line, "media query width `{$whole}` is not one of M3's breakpoints — use 600, 840, 1200 or 1600px (medium, expanded, large, extra-large)"];
}
}
return $found;
}
/**
* The names as one alternation, longest first so that a prefix of another name — `xl` of
* `2xl` — cannot win where the pattern is not anchored.
@@ -398,9 +791,65 @@ class DesignGuard
);
}
/**
* `$css` with every `/* … *\/` comment blanked out, line breaks kept.
*/
protected function withoutCssComments(string $css): string
{
return (string) preg_replace_callback(
'/\/\*.*?\*\//s',
fn (array $match): string => str_repeat("\n", substr_count($match[0], "\n")),
$css,
);
}
/**
* `$css` with the argument list of every `var(…)` and `calc(…)` call blanked out (nested
* parentheses tracked, so a `calc(var(--x) + 4px)` inside a `var()` fallback stays hidden
* too) — what lets check (iii) treat any value built from a token as fine, whatever literal
* numbers or colours it wraps.
*/
protected function withoutTokenFunctions(string $css): string
{
return $this->maskFunctionCalls($this->maskFunctionCalls($css, 'var'), 'calc');
}
protected function maskFunctionCalls(string $css, string $name): string
{
$pattern = '/(?<![\w-])'.$name.'\(/i';
$offset = 0;
while (preg_match($pattern, $css, $match, PREG_OFFSET_CAPTURE, $offset) === 1) {
$open = $match[0][1] + strlen($match[0][0]) - 1;
$depth = 1;
$i = $open + 1;
$length = strlen($css);
while ($i < $length && $depth > 0) {
if ($css[$i] === '(') {
$depth++;
} elseif ($css[$i] === ')') {
$depth--;
}
$i++;
}
$inner = substr($css, $open + 1, $i - $open - 2);
$blank = (string) preg_replace('/[^\n]/', ' ', $inner);
$css = substr($css, 0, $open + 1).$blank.substr($css, $i - 1);
$offset = $i;
}
return $css;
}
/**
* Every class written out literally: in `class="…"`, and in the string keys and values of
* `:class`, `@class([...])` and `->class([...])`. Echoes inside a list are skipped.
* `:class`/`x-bind:class`, `@class([...])`, `->class([...])` and `Arr::toCssClasses([...])`.
* Echoes inside a list are skipped. A token is returned exactly as written, variant prefix
* (`sm:`, `hover:`) and all — `withoutVariantPrefix()` strips one for the daisyUI check, which
* needs to see through it; `tailwindFamilyHint()` needs to see it.
*
* @return list<array{0: int, 1: string}>
*/
@@ -414,7 +863,7 @@ class DesignGuard
$lists[] = [$list, $offset];
}
preg_match_all('/(?::class="([^"]*)"|@class\(\[(.*?)\]\)|->class\(\[(.*?)\]\))/s', $contents, $bindings, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
preg_match_all('/(?::class="([^"]*)"|@class\(\[(.*?)\]\)|->class\(\[(.*?)\]\)|Arr::toCssClasses\(\[(.*?)\]\))/s', $contents, $bindings, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
foreach ($bindings as $binding) {
[$body, $offset] = array_values(array_filter(array_slice($binding, 1), fn (array $group): bool => $group[1] !== -1))[0] ?? ['', 0];
@@ -434,7 +883,7 @@ class DesignGuard
foreach (preg_split('/\s+/', $list, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $token) {
if (preg_match('/[$@(){}]/', $token) !== 1) {
$found[] = [$line, ltrim((string) preg_replace('/^.*:/', '', $token), '!')];
$found[] = [$line, $token];
}
}
}
@@ -500,6 +949,295 @@ class DesignGuard
return $found;
}
/**
* Every package tag used in `$contents`, in any of the three spellings — unprefixed, under
* the configured prefix, or `<x-livewire-material::…>` — each as [line, name, spelling].
* `spelling` is `plain`, `prefix` or `namespace`: only `plain` can be shadowed by an
* application component of the same name (Blade tries the application's own
* `resources/views/components/<name>.blade.php` — and a class-based one — before this
* package's registered anonymous path, whatever the configured prefix; a namespaced or
* prefixed tag always reaches the package). `<x-slot:…>` is not a component tag, and
* `<x-mary-…>` is the maryUI check's concern.
*
* @return list<array{0: int, 1: string, 2: string}>
*/
protected function packageTagUsages(string $contents): array
{
$prefix = (string) config('livewire-material.prefix', '');
preg_match_all('/<(x-[\w.:-]+)(?=[\s\/>])/', $contents, $tags, PREG_OFFSET_CAPTURE);
$found = [];
foreach ($tags[1] as [$tag, $offset]) {
if (str_starts_with($tag, 'x-slot') || str_starts_with($tag, 'x-mary-')) {
continue;
}
$name = substr($tag, 2);
$spelling = 'plain';
if (str_contains($name, '::')) {
[$namespace, $name] = explode('::', $name, 2);
if ($namespace === 'livewire-material') {
$spelling = 'namespace';
} elseif ($prefix !== '' && $namespace === $prefix) {
$spelling = 'prefix';
} else {
continue;
}
} elseif (! static::isPackageTag($name)) {
continue;
}
$found[] = [substr_count(substr($contents, 0, $offset), "\n") + 1, $name, $spelling];
}
return $found;
}
/**
* Whether the application defines its own component of this name — an anonymous
* `resources/views/components/<name>.blade.php`, found the way Blade itself falls back to it
* (the base view finder's `components.<name>`, which `resource_path('views/components')`
* feeds by default), or an `App\View\Components` class. Either wins over this package's
* `<x-{$name}>` in Blade's own resolution order.
*/
protected function shadowedByApplication(string $name): bool
{
return view()->exists('components.'.$name) || class_exists('App\\View\\Components\\'.Str::studly($name));
}
/**
* Every `->links()` call in `$contents` (Laravel's or Livewire's paginator, rendered outside
* a package component), each as the line it is on.
*
* @return list<int>
*/
protected function paginationUsages(string $contents): array
{
preg_match_all('/->links\s*\(/', $contents, $matches, PREG_OFFSET_CAPTURE);
return array_map(fn (array $match): int => substr_count(substr($contents, 0, $match[1]), "\n") + 1, $matches[0]);
}
/**
* 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`.
*
* @param array<string, true> $resolved
* @return list<array{0: int, 1: string}>
*/
protected function missingStylesheetViolations(string $contents, array $resolved): 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"];
continue;
}
$stylesheet = static::packageStylesheetFor($name);
if ($stylesheet !== null && ! isset($resolved[$stylesheet])) {
$found[] = [$line, sprintf(
"`<x-%s>` needs `%s`, missing from %s — add `@import '%s';`",
$name,
static::packageRelativeName($stylesheet),
$this->relative($this->cssEntry),
$this->importLine($stylesheet),
)];
}
}
$pagination = static::packageCssRoot().'/components/pagination.css';
foreach ($this->paginationUsages($contents) as $line) {
if (! isset($resolved[$pagination])) {
$found[] = [$line, sprintf(
"`->links()` needs `components/pagination.css`, missing from %s — add `@import '%s';`",
$this->relative($this->cssEntry),
$this->importLine($pagination),
)];
}
}
return $found;
}
/**
* 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.
*
* @return list<string>
*/
protected function applicationStylesheets(): array
{
$files = [];
foreach ($this->paths as $path) {
if (is_file($path)) {
if (str_ends_with($path, '.css')) {
$files[] = (string) realpath($path);
}
continue;
}
if (is_dir($path)) {
foreach (Finder::create()->files()->in($path)->name('*.css')->sortByName() as $file) {
$files[] = $file->getRealPath();
}
}
}
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),
)));
}
/**
* 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.
*
* @return array<string, true>
*/
protected function applicationClasses(): array
{
if ($this->applicationClassesCache !== null) {
return $this->applicationClassesCache;
}
$classes = [];
foreach ($this->applicationStylesheets() as $file) {
$css = $this->withoutCssComments((string) file_get_contents($file));
preg_match_all('/(?<![\w.#-])\.(-?[a-zA-Z_][\w-]*)/', $css, $matches);
foreach ($matches[1] as $class) {
$classes[$class] = true;
}
}
return $this->applicationClassesCache = $classes;
}
protected function isGeneratedScheme(string $file): bool
{
if (basename($file) === 'material-scheme.css') {
return true;
}
return str_contains((string) file_get_contents($file), "generated by Google's material-color-utilities");
}
/**
* Whether `$file` sits inside this package's own `resources/css` — the application's stylesheets
* (check iii) and its exempt classes never come from the package's own rules.
*/
protected static function isPackageFile(string $file): bool
{
$root = static::packageCssRoot();
return $root !== '' && ($file === $root || str_starts_with($file, $root.'/'));
}
/**
* This package's own `resources/css`, from this file's own location — works whether the class
* loads from `vendor/nonameweb/livewire-material` (a consuming application) or from this
* repository itself, the same trick `Support\Stylesheets::name()` uses.
*/
protected static function packageCssRoot(): string
{
return (string) realpath(dirname(__DIR__, 2).'/resources/css');
}
/**
* This package's own `resources/views/components` — every component and layout component
* lives here, so a tag's own view existing there is what "is this a package tag" checks.
*/
protected static function packageComponentViewRoot(): string
{
return (string) realpath(dirname(__DIR__, 2).'/resources/views/components');
}
protected static function isPackageTag(string $name): bool
{
$root = static::packageComponentViewRoot();
return $root !== '' && is_file("{$root}/{$name}.blade.php");
}
/**
* The package stylesheet a component view of this name draws from — `components/<name>.css`
* or `layout/<name>.css`, whichever exists — mapped from the files that exist rather than a
* hand-kept list. Null when the view has no stylesheet of its own (`theme-script`) or the
* name is not a package component at all.
*/
protected static function packageStylesheetFor(string $name): ?string
{
if (! static::isPackageTag($name)) {
return null;
}
foreach (['components', 'layout'] as $group) {
$file = static::packageCssRoot()."/{$group}/{$name}.css";
if (is_file($file)) {
return $file;
}
}
return null;
}
protected static function packageRelativeName(string $file): string
{
$root = static::packageCssRoot().'/';
return str_starts_with($file, $root) ? substr($file, strlen($root)) : $file;
}
/**
* The `@import` target `$file` should be written as, from the CSS entry's own directory —
* computed from the real paths, so it reads `../vendor/nonameweb/livewire-material/…` from a
* consuming application and `../resources/css/…` inside this repository, whichever is real.
*/
protected function importLine(string $file): string
{
$entryDir = dirname((string) (realpath($this->cssEntry) ?: $this->cssEntry));
return static::relativeImportPath($entryDir, $file);
}
protected static function relativeImportPath(string $fromDir, string $toFile): string
{
$from = array_values(array_filter(explode('/', $fromDir), fn (string $part): bool => $part !== ''));
$to = array_values(array_filter(explode('/', $toFile), fn (string $part): bool => $part !== ''));
$i = 0;
while ($i < count($from) && $i < count($to) && $from[$i] === $to[$i]) {
$i++;
}
$path = implode('/', [...array_fill(0, count($from) - $i, '..'), ...array_slice($to, $i)]);
return str_starts_with($path, '..') ? $path : "./{$path}";
}
protected function relative(string $path): string
{
$base = rtrim(base_path(), '/').'/';