Close the design guard's family-table gaps on real application markup
Plan step 41 review: run over SealShare's and ReStride's views, family (i) missed about a quarter of the class tokens, most of them 1.x's own utilities (type-body-md, text-meta, rounded-corner-lg, focus-ring, link) and common Tailwind families (relative, inset-0, z-10, overflow-hidden, border, opacity-50, shrink-0, -mx-4, cursor-pointer, font-mono). Each now has a hint, and the ones with a layout component name its prop: gap-4 is gap="space200" (16px), a step between two of M3's names both, items-center align="center", grid-cols-3 :columns="3"; variants say which selector or media query replaces them. Shapes are tightened to real Tailwind values, so an application class such as content-header or p-card is not matched. Class lists are also read from wire:*.class, x-transition:*, unquoted :class keys, double-quoted PHP strings, 'class' => '…' and plain PHP files; a string compared in a condition (view === 'grid') is skipped, and each token carries its own line in a multi-line list. 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
d5972b8ddb
commit
9595722f35
+224
-67
@@ -3,6 +3,7 @@
|
||||
namespace NoNameWeb\LivewireMaterial\Testing;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use NoNameWeb\LivewireMaterial\Support\Layout;
|
||||
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
|
||||
use NoNameWeb\LivewireMaterial\Support\SvgFile;
|
||||
use SplFileInfo;
|
||||
@@ -14,12 +15,13 @@ use Symfony\Component\Finder\Finder;
|
||||
*
|
||||
* (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-*`
|
||||
* layout, spacing, sizing, position, border, effect, interactivity, text or display
|
||||
* utility, a colour utility on a role, a 1.x ink, white or black (`text-meta`, `bg-white`,
|
||||
* `text-on-surface/60`), a 1.x package utility (`type-body-md`, `focus-ring`,
|
||||
* `rounded-corner-lg`), a variant, or an arbitrary `[…]` value or property — each with its
|
||||
* 2.0.0 replacement: a layout component and prop (`gap-4` → `gap="space200"`), 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`).
|
||||
* 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
|
||||
@@ -46,16 +48,16 @@ use Symfony\Component\Finder\Finder;
|
||||
* ->violations())->toBe([]);
|
||||
*
|
||||
* 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.
|
||||
* kept low two ways: family (i)'s bare-word checks match only a class already isolated from a
|
||||
* class list in a `.php` file (`class="…"`, `wire:loading.class`, `x-transition:enter`, `:class`,
|
||||
* `@class`, `->class()`, `Arr::toCssClasses()`, `'class' => '…'`), never a word scanned across a
|
||||
* whole line — which keeps "this creates a grid of cards" from matching `grid` — and skip a string
|
||||
* a condition compares (`view === 'grid'`); a line-by-line match (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: a bare-word utility
|
||||
* (`flex`, `hidden`) anywhere but inside a class list, and an application's own Tailwind theme
|
||||
* names (`bg-brand`), are invisible to this guard.
|
||||
*/
|
||||
class DesignGuard
|
||||
{
|
||||
@@ -116,33 +118,59 @@ class DesignGuard
|
||||
];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Every other Tailwind utility family an application's views wrote, as [pattern, hint] pairs
|
||||
* tried in order on a class-list token (so a bare word like `flex` is only ever matched there).
|
||||
* `%s` in a hint is the token. The spacing, container and text families with a finer hint are
|
||||
* handled before this table (see `tailwindFamilyHint()`); the breakpoint, scale and palette
|
||||
* families are the line-by-line checks'.
|
||||
*/
|
||||
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.\/]+)$/';
|
||||
protected const array FAMILIES = [
|
||||
// 1.x's own utilities, which compiled through the package's Tailwind theme until 2.0.0.
|
||||
['/^(?:focus-ring|state-layer|touch-target|link)$/', '1.x utility `%s` compiles to nothing — use `md-%s` (interaction.css)'],
|
||||
['/^rounded(?:-(?:ss|se|ee|es|tl|tr|br|bl|t|r|b|l|s|e))?-corner-(?<corner>[a-z-]+)$/', '1.x utility `%s` compiles to nothing — use `var(--md-sys-shape-corner-{corner})` in your own CSS, or `<x-surface corner="{corner}">`'],
|
||||
['/^shadow-elevation-(?<level>[0-5])$/', '1.x utility `%s` compiles to nothing — use `var(--md-sys-elevation-{level})` in your own CSS'],
|
||||
['/^ease-(?<easing>(?:spatial|effects)-(?:fast|default|slow))$/', '1.x utility `%s` compiles to nothing — pair `var(--md-sys-motion-{easing})` with `var(--md-sys-motion-{easing}-duration)` in your own `transition`'],
|
||||
['/^ease-(?<easing>standard|emphasized)(?<phase>-accelerate|-decelerate)?$/', '1.x utility `%s` compiles to nothing — use `var(--md-sys-motion-easing-{easing}{phase})` in your own `transition`'],
|
||||
['/^[a-z][a-z-]*-\((?<property>--[\w-]+)\)$/', 'Tailwind custom-property utility `%s` compiles to nothing — write `var({property})` in your own CSS'],
|
||||
|
||||
/** Tailwind's padding, margin and gap-adjacent space-between utilities. */
|
||||
protected const string SPACING_UTILITY = '/^(?:[pm][trblxyse]?|space-[xy])-[\w.\/-]+$/';
|
||||
['/^(?:static|fixed|absolute|relative|sticky)$/', "Tailwind position utility `%s` compiles to nothing — write `position` in your own CSS (a FAB goes in `<x-scaffold>`'s `fab` slot)"],
|
||||
['/^-?(?:inset(?:-[xyse])?|top|right|bottom|left|start|end)-(?:\d+(?:\.\d+)?|px|full|auto|\d+\/\d+)$/', 'Tailwind inset utility `%s` compiles to nothing — write the offset in your own CSS, from `var(--md-sys-measurement-space*)` where it is a spacing step'],
|
||||
['/^-?z-(?:\d+|auto)$/', 'Tailwind z-index utility `%s` compiles to nothing — write `z-index` in your own CSS'],
|
||||
['/^(?:overflow|overscroll)(?:-[xy])?-(?:auto|hidden|clip|visible|scroll|contain|none)$/', 'Tailwind overflow utility `%s` compiles to nothing — write `overflow` in your own CSS'],
|
||||
['/^(?:flex-(?:1|auto|initial|none|row-reverse|col-reverse|nowrap|wrap-reverse)|grow(?:-\d+)?|shrink(?:-\d+)?|basis-[\w.\/]+|order-(?:\d+|first|last|none)|contents|grid-(?:rows|cols)-(?:none|subgrid|\d+)|(?:col|row)-(?:span-(?:\d+|full)|start-\d+|end-\d+|auto)|grid-flow-[\w-]+|auto-(?:cols|rows)-[\w]+|justify-(?:around|evenly|stretch|normal|items-[\w-]+|self-[\w-]+)|(?:content|place-content|place-items|place-self|self)-(?:start|end|center|between|around|evenly|stretch|baseline|normal|auto|none))$/', "Tailwind flex/grid item utility `%s` compiles to nothing — the layout components arrange their children; an item's own `flex`, `order` or `grid-column` is a rule in your own CSS"],
|
||||
['/^(?:min-|max-)?(?:w|h|size)-(?:\d+(?:\.\d+)?|\d+\/\d+|px|full|screen|auto|min|max|fit|dvh|svh|lvh|dvw|svw|lvw|lh|prose|none|3xs|2xs|xs|sm|md|lg|xl|[2-7]xl|screen-(?:sm|md|lg|xl|2xl))$/', 'Tailwind sizing utility `%s` compiles to nothing — M3 keeps no size scale: `<x-pane width>` sets a content column\'s measure, `<x-icon size>` an icon\'s; anything else is a length in your own CSS'],
|
||||
['/^container$/', 'Tailwind\'s `container` compiles to nothing — use `<x-pane width>`, which sets M3\'s margins and a measure'],
|
||||
['/^(?:table(?:-[a-z-]+)?|flow-root|list-item|inline-table)$/', 'Tailwind display utility `%s` compiles to nothing — write the `display` rule in your own CSS'],
|
||||
['/^(?:border(?:-[trblxyse])?(?:-\d+)?|border-(?:solid|dashed|dotted|double|hidden|none)|divide-[xy](?:-\d+|-reverse)?|divide-(?:solid|dashed|dotted|double|none))$/', 'Tailwind border utility `%s` compiles to nothing — a line is `<x-divider>` or `<x-surface outlined>`; any other border is your own CSS, in `var(--md-sys-color-outline-variant)`'],
|
||||
['/^rounded(?:-(?:ss|se|ee|es|tl|tr|br|bl|t|r|b|l|s|e))?$/', 'Tailwind radius utility `%s` compiles to nothing — use `var(--md-sys-shape-corner-xs)` in your own CSS, or `<x-surface corner="xs">`'],
|
||||
['/^(?:shadow|shadow-none|inset-shadow(?:-[\w]+)?|drop-shadow(?:-[\w]+)?)$/', 'Tailwind shadow utility `%s` compiles to nothing — use `var(--md-sys-elevation-*)` in your own CSS'],
|
||||
['/^(?:outline(?:-none|-hidden|-\d+|-offset-\d+|-dashed|-dotted|-double|-solid)?|ring(?:-\d+|-inset)?|ring-offset-\d+)$/', 'Tailwind outline utility `%s` compiles to nothing — M3\'s focus indicator is `md-focus-ring` (interaction.css); any other outline is your own CSS'],
|
||||
['/^(?:transition(?:-(?:all|colors|opacity|shadow|transform|none|discrete))?|animate-[\w-]+)$/', 'Tailwind motion utility `%s` compiles to nothing — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition` or `animation`'],
|
||||
['/^opacity-\d+$/', 'Tailwind opacity utility `%s` compiles to nothing — write `opacity` in your own CSS (M3\'s disabled content is 38 %%)'],
|
||||
['/^(?:transform(?:-none|-gpu|-cpu)?|-?(?:scale|rotate|skew-[xy]|translate-[xy]|scale-[xy])-[\w.\/]+|origin-[\w-]+|will-change-[\w-]+|blur(?:-\w+)?|backdrop-[\w-]+|mix-blend-[\w-]+|isolate|isolation-auto)$/', 'Tailwind effect utility `%s` compiles to nothing — write the rule in your own CSS'],
|
||||
['/^(?:cursor-[\w-]+|pointer-events-(?:none|auto)|select-(?:none|text|all|auto)|touch-[\w-]+|resize(?:-[xy]|-none)?|appearance-(?:none|auto)|scroll-(?:smooth|auto)|-?scroll-[mp][trblxyse]?-[\w.]+|snap-[\w-]+)$/', 'Tailwind interactivity utility `%s` compiles to nothing — write the rule in your own CSS'],
|
||||
['/^(?:aspect-(?:auto|square|video|\d+\/\d+)|object-(?:contain|cover|fill|none|scale-down|top|bottom|center|left|right|left-top|left-bottom|right-top|right-bottom))$/', 'Tailwind media utility `%s` compiles to nothing — write `aspect-ratio`/`object-fit` in your own CSS'],
|
||||
['/^font-(?:sans|serif)$/', 'Tailwind\'s `%s` compiles to nothing — the foundation already sets the brand typeface; any other `font-family` is your own CSS'],
|
||||
['/^(?:underline|no-underline)$/', 'Tailwind\'s `%s` compiles to nothing — `md-link` draws a link (interaction.css); any other decoration is your own CSS'],
|
||||
['/^(?:uppercase|lowercase|capitalize|normal-case|italic|not-italic|overline|line-through|underline-offset-\w+|decoration-(?:\d+|solid|double|dotted|dashed|wavy|auto|from-font|clone|slice)|font-mono|antialiased|subpixel-antialiased|whitespace-(?:normal|pre|pre-line|pre-wrap|break-spaces)|break-(?:words|all|keep|normal)|wrap-(?:break-word|anywhere|normal)|text-(?:wrap|balance|pretty|ellipsis|clip|justify)|line-clamp-(?:\d+|none)|not-sr-only|list-(?:disc|decimal|none|inside|outside)|align-(?:baseline|top|middle|bottom|text-top|text-bottom|sub|super)|indent-[\w.]+|hyphens-(?:none|manual|auto)|(?:normal|lining|oldstyle|proportional)-nums|ordinal|slashed-zero|(?:diagonal|stacked)-fractions)$/', 'Tailwind text utility `%s` compiles to nothing — write the rule in your own CSS'],
|
||||
['/^bg-(?:(?:gradient-to|linear-to)-[a-z]+|radial|conic|cover|contain|auto|center|top|bottom|left|right|no-repeat|repeat(?:-[xy])?|fixed|local|scroll|none|clip-[a-z]+|origin-[a-z]+)$/', 'Tailwind background utility `%s` compiles to nothing — write the rule in your own CSS'],
|
||||
];
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
/** Bare display utilities, matched only as a whole class-list token. */
|
||||
protected const array DISPLAY_UTILITY = ['block', 'inline-block', 'inline', 'invisible', 'visible'];
|
||||
|
||||
/** Text-layout utilities with a direct `md-*` replacement (text.css). */
|
||||
/** Text utilities with a direct `md-*` replacement (text.css). */
|
||||
protected const array TEXT_LAYOUT_UTILITY = [
|
||||
'text-left' => 'md-text-start',
|
||||
'text-start' => 'md-text-start',
|
||||
'text-center' => 'md-text-center',
|
||||
'text-right' => 'md-text-end',
|
||||
'text-end' => 'md-text-end',
|
||||
'truncate' => 'md-truncate',
|
||||
'line-clamp-2' => 'md-line-clamp-2',
|
||||
'line-clamp-3' => 'md-line-clamp-3',
|
||||
'whitespace-nowrap' => 'md-nowrap',
|
||||
'text-nowrap' => 'md-nowrap',
|
||||
'sr-only' => 'md-visually-hidden',
|
||||
'tabular-nums' => 'md-tabular',
|
||||
];
|
||||
@@ -314,13 +342,17 @@ class DesignGuard
|
||||
|
||||
if ($isBlade) {
|
||||
$contents = $this->withoutBladeComments($contents);
|
||||
}
|
||||
|
||||
if (str_ends_with($file->getFilename(), '.php')) {
|
||||
foreach ($this->literalClasses($contents) as [$line, $class]) {
|
||||
if (($hint = $this->tailwindFamilyHint($class)) !== null) {
|
||||
$violations[] = "{$where}:{$line} {$hint}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($isBlade) {
|
||||
foreach ($this->iconNames($contents) as [$line, $name]) {
|
||||
$violations[] = "{$where}:{$line} unknown Material Symbol `{$name}`";
|
||||
}
|
||||
@@ -563,10 +595,11 @@ class DesignGuard
|
||||
* 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).
|
||||
* text classes and the shared interaction hooks), the application's own CSS declares it
|
||||
* (`applicationClasses()`), or a line-by-line check reports it already (a breakpoint prefix, a
|
||||
* cleared scale step, an arbitrary or palette colour). 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
|
||||
{
|
||||
@@ -574,41 +607,40 @@ class DesignGuard
|
||||
return null;
|
||||
}
|
||||
|
||||
$token = rtrim($token, '!');
|
||||
$token = trim($token, '!');
|
||||
|
||||
if (preg_match(self::ARBITRARY_COLOUR, $token) === 1) {
|
||||
return null; // colourValues() already reports this, line by line.
|
||||
if (preg_match(self::ARBITRARY_COLOUR, $token) === 1 || preg_match($this->colourPattern(), $token) === 1 || $this->outsideTheScale($token) !== []) {
|
||||
return null; // colourValues(), the palette check and outsideTheScale() already report this, line by line.
|
||||
}
|
||||
|
||||
if (str_contains($token, ':')) {
|
||||
$lead = explode(':', $token, 2)[0];
|
||||
$bare = str_starts_with($lead, 'max-') ? substr($lead, 4) : $lead;
|
||||
if (preg_match('/^(?<lead>\[[^\]]*\]|[^:\[]+(?:\[[^\]]*\])?):/', $token, $variant) === 1) {
|
||||
$bare = str_starts_with($variant['lead'], 'max-') ? substr($variant['lead'], 4) : $variant['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";
|
||||
return "Tailwind variant class `{$token}` compiles to nothing — ".$this->variantHint($variant['lead']);
|
||||
}
|
||||
|
||||
if (preg_match('/\[[^\]]*\]/', $token) === 1) {
|
||||
if (preg_match('/^(?:type-(?:emphasized-)?(?:display|headline|title|body|label)-(?:lg|md|sm))$/', $token) === 1) {
|
||||
return "1.x utility `{$token}` compiles to nothing — use `md-{$token}` (text.css)";
|
||||
}
|
||||
|
||||
if (preg_match('/^\[(?<property>-{0,2}[a-z][\w-]*):[^\]]+\]$/', $token, $arbitrary) === 1) {
|
||||
return "Tailwind arbitrary property `{$token}` compiles to nothing — write `{$arbitrary['property']}` in your own CSS";
|
||||
}
|
||||
|
||||
if (preg_match('/^-?[a-z][\w-]*-\[[^\]]*\]$/', $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 (($hint = $this->containerHint($token) ?? $this->spacingHint($token)) !== null) {
|
||||
return $hint;
|
||||
}
|
||||
|
||||
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";
|
||||
return "Tailwind's `hidden` compiles to nothing — use a layout component's `hide-below`/`hide-from` prop, the `hidden` attribute (the reset keeps it hidden), or `x-show`";
|
||||
}
|
||||
|
||||
if (in_array($token, self::DISPLAY_UTILITY, true)) {
|
||||
@@ -619,7 +651,119 @@ class DesignGuard
|
||||
return "Tailwind's `{$token}` compiles to nothing — use `".self::TEXT_LAYOUT_UTILITY[$token].'` (text.css)';
|
||||
}
|
||||
|
||||
return $this->colourUtilityHint($token);
|
||||
if (($hint = $this->colourUtilityHint($token)) !== null) {
|
||||
return $hint;
|
||||
}
|
||||
|
||||
foreach (self::FAMILIES as [$pattern, $hint]) {
|
||||
if (preg_match($pattern, $token, $match) === 1) {
|
||||
return (string) preg_replace_callback(
|
||||
'/\{(\w+)\}/',
|
||||
fn (array $name): string => (string) ($match[$name[1]] ?? ''),
|
||||
str_replace('%%', '%', str_replace('%s', $token, $hint)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 2.0.0 replacement for a Tailwind variant, by its first segment: a state, the theme, a
|
||||
* structural pseudo-class, a direction, an M3 breakpoint variant 1.x compiled, or an
|
||||
* arbitrary selector.
|
||||
*/
|
||||
protected function variantHint(string $lead): string
|
||||
{
|
||||
$m3 = ['medium' => 600, 'expanded' => 840, 'large' => 1200, 'extra-large' => 1600];
|
||||
$bare = str_starts_with($lead, 'max-') ? substr($lead, 4) : $lead;
|
||||
|
||||
return match (true) {
|
||||
isset($m3[$bare]) => '`@media (width '.($bare === $lead ? '>=' : '<')." {$m3[$bare]}px)` in your own CSS, or a layout component's `hide-below`/`hide-from`/`stack-below` prop",
|
||||
$lead === 'dark' => "the roles already switch with the theme; a dark-only rule is `[data-theme='dark'] …` in your own CSS",
|
||||
$lead === 'rail-collapsed' => "`:root[data-rail='collapsed'] …` in your own CSS",
|
||||
in_array($lead, ['motion-reduce', 'motion-safe'], true) => '`@media (prefers-reduced-motion: reduce)` in your own CSS',
|
||||
in_array($lead, ['rtl', 'ltr'], true) => "`:is([dir='rtl'], [dir='rtl'] *)` in your own CSS",
|
||||
$lead === 'print' => '`@media print` in your own CSS',
|
||||
preg_match('/^(?:hover|focus|focus-visible|focus-within|active|pressed)$/', $lead) === 1 => "M3's hover, focus and press states are `md-state-layer` and `md-focus-ring` (interaction.css); any other state rule is `:hover`/`:focus-visible` in your own CSS",
|
||||
preg_match('/^(?:group|peer|has|not|in)(?:-|\/|$)/', $lead) === 1 => '`:has()` or a descendant selector in your own CSS',
|
||||
preg_match('/^(?:aria|data)-/', $lead) === 1 => 'an `[aria-…]` or `[data-…]` attribute selector in your own CSS',
|
||||
preg_match('/^(?:before|after|placeholder|file|marker|selection|backdrop|first-line|first-letter)$/', $lead) === 1 => 'a pseudo-element rule in your own CSS',
|
||||
str_starts_with($lead, '[') => 'write the arbitrary selector as a rule in your own CSS',
|
||||
default => 'write the state or condition as a selector or `@media` rule in your own CSS',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The layout component and prop for a Tailwind flex or grid container utility, or null.
|
||||
*/
|
||||
protected function containerHint(string $token): ?string
|
||||
{
|
||||
$dead = "Tailwind layout utility `{$token}` compiles to nothing";
|
||||
|
||||
return match (true) {
|
||||
in_array($token, ['flex', 'inline-flex', 'flex-row'], true) => "{$dead} — use `<x-row>` (`gap`, `align`, `justify`, `wrap`, `stack-below`), or `<x-stack>` for a column",
|
||||
$token === 'flex-col' => "{$dead} — use `<x-stack>` (`gap`, `align`)",
|
||||
$token === 'flex-wrap' => "{$dead} — use `<x-row wrap>`",
|
||||
in_array($token, ['grid', 'inline-grid'], true) => "{$dead} — use `<x-grid>` (`:columns` per breakpoint, `gap`, `min-item`), or `<x-feed>` for a grid of cards",
|
||||
preg_match('/^grid-cols-(\d+)$/', $token, $m) === 1 => "{$dead} — use `<x-grid :columns=\"{$m[1]}\">`, or a per-breakpoint map (`:columns=\"['compact' => 1, 'medium' => {$m[1]}]\"`)",
|
||||
preg_match('/^items-(start|end|center|stretch)$/', $token, $m) === 1 => "{$dead} — use `align=\"{$m[1]}\"` on `<x-row>` or `<x-stack>`",
|
||||
$token === 'items-baseline' => "{$dead} — use `align=\"baseline\"` on `<x-row>`",
|
||||
preg_match('/^justify-(start|end|center|between)$/', $token, $m) === 1 => "{$dead} — use `justify=\"{$m[1]}\"` on `<x-row>`",
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The M3 spacing step for a Tailwind gap, padding, margin or space-between utility, or null.
|
||||
* Tailwind's spacing unit is 4px (`gap-4` is 16px, `space200`); a value between two of M3's
|
||||
* steps names both neighbours.
|
||||
*/
|
||||
protected function spacingHint(string $token): ?string
|
||||
{
|
||||
if (preg_match('/^(?<negative>-)?(?<kind>gap(?:-[xy])?|[pm][trblxyse]?|space-[xy])-(?<value>\d+(?:\.\d+)?|px|auto|reverse)$/', $token, $m) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dead = "Tailwind spacing utility `{$token}` compiles to nothing";
|
||||
|
||||
if ($m['value'] === 'auto') {
|
||||
return in_array($m['kind'], ['m', 'mx'], true)
|
||||
? "{$dead} — `<x-pane width>` centres its content; otherwise `margin-inline: auto` in your own CSS"
|
||||
: "{$dead} — an `auto` margin is a rule in your own CSS";
|
||||
}
|
||||
|
||||
if ($m['value'] === 'reverse') {
|
||||
return "{$dead} — a reversed row is a `flex-direction` rule in your own CSS";
|
||||
}
|
||||
|
||||
$px = $m['value'] === 'px' ? 1.0 : (float) $m['value'] * 4;
|
||||
$steps = array_combine(Layout::SPACING, array_map(
|
||||
fn (string $step): float => (float) substr($step, 5) / 100 * 8,
|
||||
Layout::SPACING,
|
||||
));
|
||||
$exact = array_search($px, $steps, true);
|
||||
$below = array_key_last(array_filter($steps, fn (float $step): bool => $step < $px));
|
||||
$above = array_key_first(array_filter($steps, fn (float $step): bool => $step > $px));
|
||||
$pxText = rtrim(rtrim(number_format($px, 2, '.', ''), '0'), '.').'px';
|
||||
|
||||
$choice = fn (string $attribute): string => $exact !== false
|
||||
? "`{$attribute}=\"{$exact}\"` ({$pxText})"
|
||||
: implode(' or ', array_map(
|
||||
fn (string $step): string => "`{$attribute}=\"{$step}\"` (".rtrim(rtrim(number_format($steps[$step], 2, '.', ''), '0'), '.').'px)',
|
||||
array_values(array_filter([$below, $above])),
|
||||
))." — {$pxText} is not an M3 spacing step";
|
||||
$token = $exact !== false ? "`var(--md-sys-measurement-{$exact})`" : '`var(--md-sys-measurement-space*)`';
|
||||
|
||||
return match (true) {
|
||||
$px === 0.0 => "{$dead} — leave it out: the reset zeroes margins and padding",
|
||||
$m['negative'] === '-' => "{$dead} — a negative margin is `calc(-1 * ".trim($token, '`').')` in your own CSS',
|
||||
str_starts_with($m['kind'], 'gap') => "{$dead} — use ".$choice('gap').' on `<x-row>`, `<x-stack>`, `<x-grid>` or `<x-feed>`',
|
||||
$m['kind'] === 'space-y' => "{$dead} — use `<x-stack>` with ".$choice('gap'),
|
||||
$m['kind'] === 'space-x' => "{$dead} — use `<x-row>` with ".$choice('gap'),
|
||||
str_starts_with($m['kind'], 'p') => "{$dead} — use ".$choice('padding')." on `<x-surface>`, or {$token} in your own CSS",
|
||||
default => "{$dead} — space between siblings is a layout component's ".$choice('gap')."; any other margin is {$token} in your own CSS",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -897,10 +1041,14 @@ class DesignGuard
|
||||
}
|
||||
|
||||
/**
|
||||
* Every class written out literally: in `class="…"`, and in the string keys and values of
|
||||
* `: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, since `tailwindFamilyHint()` reports the prefix itself.
|
||||
* Every class written out literally: in `class="…"`, Livewire's `wire:loading.class="…"` and
|
||||
* Alpine's `x-transition:enter="…"` lists, and in the strings of `:class`/`x-bind:class` (its
|
||||
* object keys too, quoted or not), `@class([...])`, `->class(...)`, `Arr::toCssClasses([...])`
|
||||
* and a `'class' => '…'` pair. A string compared in a condition (`view === 'grid'`,
|
||||
* `$status === 'hidden'`) or passed to a call (`isActive('grid')`) is not a class; echoes
|
||||
* inside a list are skipped. A token is returned exactly as written, variant prefix (`sm:`,
|
||||
* `hover:`) and all, since `tailwindFamilyHint()` reports the prefix itself, with the line it
|
||||
* sits on.
|
||||
*
|
||||
* @return list<array{0: int, 1: string}>
|
||||
*/
|
||||
@@ -908,33 +1056,42 @@ class DesignGuard
|
||||
{
|
||||
$lists = [];
|
||||
|
||||
preg_match_all('/(?<![\w:.-])class="([^"]*)"/', $contents, $attributes, PREG_OFFSET_CAPTURE);
|
||||
preg_match_all('/(?:(?<![\w:.-])class|wire:[\w.-]*\.class(?:\.remove)?|x-transition:(?:enter|leave)(?:-start|-end)?)="([^"]*)"/', $contents, $attributes, PREG_OFFSET_CAPTURE);
|
||||
|
||||
foreach ($attributes[1] as [$list, $offset]) {
|
||||
$lists[] = [$list, $offset];
|
||||
}
|
||||
|
||||
preg_match_all('/(?::class="([^"]*)"|@class\(\[(.*?)\]\)|->class\(\[(.*?)\]\)|Arr::toCssClasses\(\[(.*?)\]\))/s', $contents, $bindings, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
|
||||
preg_match_all('/:class="(?<alpine>[^"]*)"|(?:@class|->class|Arr::toCssClasses)\((?<php>\[.*?\]|\'[^\']*\'|"[^"]*")\)|([\'"])class\3\s*=>\s*(?<pair>\'[^\']*\'|"[^"]*")/s', $contents, $bindings, PREG_OFFSET_CAPTURE | PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL);
|
||||
|
||||
foreach ($bindings as $binding) {
|
||||
[$body, $offset] = array_values(array_filter(array_slice($binding, 1), fn (array $group): bool => $group[1] !== -1))[0] ?? ['', 0];
|
||||
$alpine = $binding['alpine'][0] !== null;
|
||||
[$body, $offset] = $binding['alpine'][0] !== null ? $binding['alpine'] : ($binding['php'][0] !== null ? $binding['php'] : $binding['pair']);
|
||||
|
||||
preg_match_all("/'([^']*)'/", $body, $strings, PREG_OFFSET_CAPTURE);
|
||||
preg_match_all($alpine ? "/'([^']*)'|(?<=[{,])\\s*([A-Za-z_][\\w]*)\\s*:/" : "/'([^']*)'|\"([^\"]*)\"/", $body, $strings, PREG_OFFSET_CAPTURE | PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL);
|
||||
|
||||
foreach ($strings as $string) {
|
||||
[$list, $inner] = $string[1][0] !== null ? $string[1] : $string[2];
|
||||
$before = substr($body, 0, $string[0][1]);
|
||||
$after = substr($body, $string[0][1] + strlen($string[0][0]));
|
||||
|
||||
if (preg_match('/(?:[=!]==?|[<>]=?|\(|\?\?)\s*$/', $before) === 1 || preg_match('/^\s*(?:[=!]==?|[<>]=?)/', $after) === 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($strings[1] as [$list, $inner]) {
|
||||
$lists[] = [$list, $offset + $inner];
|
||||
}
|
||||
}
|
||||
|
||||
$found = [];
|
||||
usort($lists, fn (array $a, array $b): int => $a[1] <=> $b[1]);
|
||||
|
||||
foreach ($lists as [$list, $offset]) {
|
||||
$line = substr_count(substr($contents, 0, $offset), "\n") + 1;
|
||||
$list = (string) preg_replace('/\{\{.*?\}\}|\{!!.*?!!\}/', ' ', $list);
|
||||
$list = (string) preg_replace_callback('/\{\{.*?\}\}|\{!!.*?!!\}/s', fn (array $echo): string => (string) preg_replace('/[^\n]/', ' ', $echo[0]), $list);
|
||||
|
||||
foreach (preg_split('/\s+/', $list, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $token) {
|
||||
if (preg_match('/[$@(){}]/', $token) !== 1) {
|
||||
$found[] = [$line, $token];
|
||||
foreach (preg_split('/\s+/', $list, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_OFFSET_CAPTURE) ?: [] as [$token, $at]) {
|
||||
if (preg_match('/[$@{}\\\\]/', $token) !== 1 && substr_count($token, '(') === substr_count($token, ')')) {
|
||||
$found[] = [substr_count(substr($contents, 0, $offset + $at), "\n") + 1, $token];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user