Take Tailwind out of the package, and its detection out of the guard
tests / feature (8.4) (push) Successful in 2m0s
tests / feature (8.5) (push) Successful in 2m0s
tests / browser (chrome, chromium) (push) Failing after 8m3s
tests / browser (firefox, firefox) (push) Failing after 12m58s
tests / browser (safari, webkit) (push) Failing after 13m8s
tests / feature (8.4) (push) Successful in 2m0s
tests / feature (8.5) (push) Successful in 2m0s
tests / browser (chrome, chromium) (push) Failing after 8m3s
tests / browser (firefox, firefox) (push) Failing after 12m58s
tests / browser (safari, webkit) (push) Failing after 13m8s
Tailwind left the stack in 2.0.0, but the package still carried about 330 mentions of it. What the guard's Tailwind detection protected — a class that compiles to nothing — is now protected by a check that does not care where a dead class came from. DesignGuard: about 500 lines of Tailwind tables, scales, palettes and "2.0.0 replacement" hints give way to one check — a class a view or PHP file writes that neither the application's stylesheets nor the package's own declare. It catches a utility of any framework, a typo and a class whose rules were deleted alike, so it also found two classes ReStride draws nothing with. A stylesheet has to be in reach for it: the `.css` files among the scanned paths, or what the `missingStylesheets()` entry imports. The class reader no longer mistakes an array index for a class list (`$block['base']`), and it reads the array a class helper is given, where it read nothing before. The package's own three Tailwind self-guards go with it. Only their one unique check stays, as a test of its own: every `matchMedia` width in resources/js is an M3 breakpoint. The pagination views are `material.blade.php` and `simple-material.blade.php`; only Laravel's and Livewire's default theme names ever made them `tailwind`. The provider sets `Paginator`'s default views and switches `livewire.pagination_theme` to `material` when it is still Livewire's own default, so no application can forget the config; a theme an application chose, and a component's own `$paginationTheme` or `paginationView()`, still win. The rest is prose: the layer-order guidance for an application that still builds Tailwind, the Tailwind wording in the README, the Boost guidelines and the development skill, and about 25 "this used to be a Tailwind utility" comments, along with every "plan step NN" pointer into a gitignored folder. The reset keeps its credit, and NOTICE now carries it too. Feature suite 1159 passed, Chrome browser suite 299 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
247c596c3a
commit
fb7007c976
+152
-535
@@ -4,7 +4,6 @@ namespace NoNameWeb\LivewireMaterial\Testing;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\Compilers\ComponentTagCompiler;
|
||||
use NoNameWeb\LivewireMaterial\Support\Layout;
|
||||
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
|
||||
use NoNameWeb\LivewireMaterial\Support\SvgFile;
|
||||
use RuntimeException;
|
||||
@@ -12,18 +11,16 @@ use SplFileInfo;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
* Finds what compiles to nothing in a Tailwind-free application (plan step 41), and what a
|
||||
* Tailwind migration still leaves behind:
|
||||
* Finds what compiles to nothing in an application built on this package:
|
||||
*
|
||||
* (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, position, border, effect, interactivity, text or display
|
||||
* utility, a colour utility on a role, white or black (`bg-white`, `text-on-surface/60`)
|
||||
* or on a colour the application's own Tailwind theme named (`bg-brand`,
|
||||
* `text-sport-run/60`), 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.
|
||||
* (i) any class a view or PHP file writes that no stylesheet declares — not the
|
||||
* application's own (`applicationClasses()`: the `.css` files among the scanned paths and
|
||||
* whatever the `missingStylesheets()` entry imports outside the package), not the
|
||||
* package's own (`packageClasses()`: every rule under its `resources/css`, `md-*` and the
|
||||
* showcase's classes alike). So one of the two has to be in reach: a scan of views alone
|
||||
* knows no application class and reports every one of them. A utility a migration left
|
||||
* behind, a class of a framework that is no longer built, a typo and a class whose rule
|
||||
* was deleted all read the same here: the class paints nothing.
|
||||
* (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
|
||||
@@ -42,182 +39,36 @@ use Symfony\Component\Finder\Finder;
|
||||
* 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 `var()`, or a `calc()`,
|
||||
* `min()`, `max()` or `clamp()` built on one, is never flagged, whatever else it holds.
|
||||
* (iv) Tailwind palette colours, icon names that are not Material Symbols, and Blade
|
||||
* directives written inside a component tag (where they do not compile) — plus whatever
|
||||
* an application bans on top with `forbidColours()` 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.
|
||||
* (iv) 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()` and `forbid()`, which read a line at a time, whatever it holds.
|
||||
*
|
||||
* 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.
|
||||
* CSS entry, so its classes (`table`, `button`, `panel`, `break-all`) are declared where this
|
||||
* guard never looks. Family (i) therefore skips 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 declared classes.
|
||||
*
|
||||
* expect(DesignGuard::scan([resource_path('views'), resource_path('js'), resource_path('css'), app_path()])
|
||||
* ->missingStylesheets(resource_path('css/app.css'))
|
||||
* ->forbidColours(['tertiary'])
|
||||
* ->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 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`) and a colour of the application's own Tailwind theme (`bg-brand`) are
|
||||
* invisible to this guard anywhere but inside a class list.
|
||||
* Each violation is "path:line what", the path relative to the base path. Family (i) only ever
|
||||
* reads a class list a `.php` file writes out — a `class` attribute, Livewire's
|
||||
* `wire:loading.class`, Alpine's `x-transition:enter` and `:class`, `@class`, `->class()`,
|
||||
* `Arr::toCssClasses()` and a `'class'` pair — never a word scanned across a whole line, which
|
||||
* keeps "this creates a grid of cards" from reading as a class. Inside such a list it skips a
|
||||
* string a condition compares (`view === 'grid'`) and one nested deeper than the list itself, an
|
||||
* array index or a call's argument (`$block['base']`, `in_array($size, ['xs', 'sm'])`). The
|
||||
* trade-off: a class assembled at runtime (`'text-'.$tone`) is invisible to this guard, the
|
||||
* reason to write class names out whole; and an example of markup written in a PHP comment is
|
||||
* read like the real thing, since only Blade comments are stripped.
|
||||
*/
|
||||
class DesignGuard
|
||||
{
|
||||
protected const string UTILITY = '(?:bg|text|border(?:-(?:[trblxyse]|bs|be))?|ring|ring-offset|fill|stroke|from|via|to|outline|divide|decoration|caret|accent|shadow|placeholder)';
|
||||
|
||||
protected const string PALETTE = '(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone|mauve|olive|mist|taupe)-(?:50|[1-9]00|950)';
|
||||
|
||||
/** A colour written as a value: an arbitrary hex, function or mix instead of a role. */
|
||||
protected const string ARBITRARY_COLOUR = '/(?<![\w-])'.self::UTILITY.'-\[(?:#|rgb|hsl|oklch|color-mix)[^\]\s"\']*\]?/';
|
||||
|
||||
/**
|
||||
* Tailwind's breakpoint prefixes and the M3 window size class each names. Tailwind's
|
||||
* 640/768/1024/1280/1536 are 40–88 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. 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',
|
||||
'md' => 'medium',
|
||||
'lg' => 'expanded',
|
||||
'xl' => 'large',
|
||||
'2xl' => 'extra-large',
|
||||
];
|
||||
|
||||
/** Tailwind's radius scale and the M3 corner it replaces each (styles §Shape). */
|
||||
protected const array CORNERS = [
|
||||
'none' => 'none',
|
||||
'xs' => 'xs',
|
||||
'sm' => 'sm',
|
||||
'md' => 'md',
|
||||
'lg' => 'lg',
|
||||
'xl' => 'xl',
|
||||
'2xl' => 'xxl',
|
||||
'3xl' => 'xxl',
|
||||
'4xl' => 'xxl',
|
||||
'full' => 'full',
|
||||
];
|
||||
|
||||
/** Tailwind's shadow scale and the M3 elevation level it replaces each (styles §Elevation). */
|
||||
protected const array ELEVATIONS = [
|
||||
'2xs' => 1,
|
||||
'xs' => 1,
|
||||
'sm' => 1,
|
||||
'md' => 2,
|
||||
'lg' => 3,
|
||||
'xl' => 4,
|
||||
'2xl' => 5,
|
||||
];
|
||||
|
||||
/**
|
||||
* 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 array FAMILIES = [
|
||||
// Tailwind's cleared easing and duration scale: matched only here, in a class list a
|
||||
// Blade or PHP file writes literally (never line-by-line across every file, the way
|
||||
// outsideTheScale() still reads its other scale steps) — a plain PHP or JS string such as
|
||||
// `matchMedia(…) ? 'linear' : 'ease-out'` names a real CSS keyword, not a Tailwind class,
|
||||
// and reading every line for the bare word flagged both that and the word appearing inside
|
||||
// a stylesheet test's own regex literal (`ease-in`, `ease-out`) as if it were one.
|
||||
['/^ease-(?:in-out|linear|in|out)$/', 'value outside the M3 scale `%s` — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition`'],
|
||||
['/^duration-\d+$/', 'value outside the M3 scale `%s` — pair `var(--md-sys-motion-…-duration)` with its easing 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'],
|
||||
|
||||
['/^(?: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]|bs|be))?(?:-\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|shadow-inner|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-]+|accent-auto|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'],
|
||||
['/^font-mono$/', 'Tailwind\'s `%s` compiles to nothing — put the value in `<code>`, `<kbd>` or `<samp>`, or use `md-mono` (text.css)'],
|
||||
['/^antialiased$/', 'Tailwind\'s `%s` compiles to nothing — the foundation already smooths text in grayscale (base.css); drop it'],
|
||||
['/^(?: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)|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)|text-shadow-[\w-]+(?:\/\d+)?|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]+|(?:linear|conic)-\d+|radial|conic|cover|contain|auto|center|top|bottom|left|right|(?:top|bottom)-(?:left|right)|(?:left|right)-(?:top|bottom)|no-repeat|repeat(?:-[xy]|-round|-space)?|fixed|local|scroll|none|clip-[a-z]+|origin-[a-z]+|blend-[a-z-]+)$/', 'Tailwind background utility `%s` compiles to nothing — write the rule in your own CSS'],
|
||||
['/^(?:from|via|to)-(?:\d{1,3}%|none)$/', 'Tailwind gradient utility `%s` compiles to nothing — write the gradient in your own CSS'],
|
||||
['/^(?:border-(?:collapse|separate)|border-spacing(?:-[xy])?-(?:\d+(?:\.\d+)?|px))$/', 'Tailwind table utility `%s` compiles to nothing — write the rule in your own CSS'],
|
||||
['/^(?:(?:fill|stroke)-none|stroke-\d+)$/', 'Tailwind SVG utility `%s` compiles to nothing — write `fill` or `stroke` in your own CSS'],
|
||||
];
|
||||
|
||||
/** Bare display utilities, matched only as a whole class-list token. */
|
||||
protected const array DISPLAY_UTILITY = ['block', 'inline-block', 'inline', 'invisible', 'visible'];
|
||||
|
||||
/** 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',
|
||||
];
|
||||
|
||||
/** A colour utility's prefix, up to its dash; `COLOUR_UTILITY` and `THEME_COLOUR_UTILITY` share it. */
|
||||
protected const string COLOUR_PREFIX = '/^(?<utility>bg|text|border(?:-(?:[trblxyse]|bs|be))?|divide|ring(?:-offset)?|outline|fill|stroke|decoration|accent|caret|placeholder|shadow|from|via|to)-';
|
||||
|
||||
/** A colour utility's opacity modifier (`/60`, `/[0.32]`, `/(--alpha)`), to the end of the token. */
|
||||
protected const string COLOUR_OPACITY = '(?:\/(?<opacity>\d{1,3}|\[[^\]]*\]|\(--[\w-]+\)))?$/';
|
||||
|
||||
/**
|
||||
* A colour utility written on an M3 role, or white/black/current, with an optional opacity
|
||||
* modifier (`text-on-surface/60`, `bg-scrim/[0.32]`). The roles are every `--md-sys-color-*`
|
||||
* the default scheme and elevation tokens declare.
|
||||
*/
|
||||
protected const string COLOUR_UTILITY = self::COLOUR_PREFIX
|
||||
.'(?<role>(?:on-)?(?:primary|secondary|tertiary|error|success|warning|info)(?:-container|-dim|-fixed(?:-dim|-variant)?)?'
|
||||
.'|inverse-(?:primary|surface|on-surface|error|success|warning|info)'
|
||||
.'|(?:on-)?background|(?:on-)?surface(?:-variant|-dim|-bright|-container(?:-lowest|-low|-high|-highest)?)?'
|
||||
.'|outline(?:-variant)?|scrim|shadow|white|black|current|transparent|inherit|initial)'
|
||||
.self::COLOUR_OPACITY;
|
||||
|
||||
/**
|
||||
* A colour utility on any other name (`bg-off-plan`, `text-sport-run`, `border-l-zone-4`,
|
||||
* `bg-route-reference/8`): a colour the application's own Tailwind theme named before 2.0.0,
|
||||
* which nothing declares now. Tried only after `COLOUR_UTILITY`, the palette and every family
|
||||
* in `FAMILIES`, so a Tailwind utility sharing the prefix (`text-balance`, `border-collapse`,
|
||||
* `bg-cover`, `shadow-none`) is reported as that family; the name starts with a letter, so a
|
||||
* width or a stop (`border-2`, `from-10%`) never reads as one.
|
||||
*/
|
||||
protected const string THEME_COLOUR_UTILITY = self::COLOUR_PREFIX.'(?<name>[a-z][a-z\d]*(?:-[a-z\d]+)*)'.self::COLOUR_OPACITY;
|
||||
|
||||
/**
|
||||
* 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`
|
||||
@@ -249,12 +100,6 @@ class DesignGuard
|
||||
'inverse-on-surface' => 'md-ink-inverse',
|
||||
];
|
||||
|
||||
/** The roles `<x-surface level>` takes. */
|
||||
protected const array SURFACE_LEVELS = [
|
||||
'surface', 'surface-dim', 'surface-bright', 'surface-container-lowest', 'surface-container-low',
|
||||
'surface-container', 'surface-container-high', 'surface-container-highest',
|
||||
];
|
||||
|
||||
/** 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';
|
||||
|
||||
@@ -312,8 +157,7 @@ class DesignGuard
|
||||
* Their on-roles and containers are forbidden with them, wherever 2.0.0 lets an application
|
||||
* write one: the `--md-sys-color-*` custom property (a `var()` in its CSS, an inline `style`, a
|
||||
* script reading it), the `md-ink-*` class text.css has for it, and a component's `color` or
|
||||
* `tone` prop (`color="tertiary"`, `:tone="'tertiary'"`). A Tailwind `bg-tertiary` left behind
|
||||
* is family (i)'s to report, as every colour utility is.
|
||||
* `tone` prop (`color="tertiary"`, `:tone="'tertiary'"`).
|
||||
*
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
@@ -345,7 +189,8 @@ class DesignGuard
|
||||
* 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.
|
||||
* are on tokens. The classes the entry's own imports declare do count as declared for check
|
||||
* (i), though.
|
||||
*/
|
||||
public function missingStylesheets(string $cssEntry): static
|
||||
{
|
||||
@@ -416,10 +261,8 @@ class DesignGuard
|
||||
}
|
||||
|
||||
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}";
|
||||
}
|
||||
foreach ($this->undeclaredClasses($contents) as [$line, $class]) {
|
||||
$violations[] = "{$where}:{$line} class `{$class}` is declared in no stylesheet — the package declares `md-*`, the rest is your own CSS";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,13 +293,7 @@ class DesignGuard
|
||||
foreach (explode("\n", $contents) as $index => $text) {
|
||||
$line = $index + 1;
|
||||
|
||||
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 ([...($readsClasses ? $this->offTheTokens($text) : []), ...$this->forbiddenRoles($text)] as $what) {
|
||||
foreach ($this->forbiddenRoles($text) as $what) {
|
||||
$violations[] = "{$where}:{$line} {$what}";
|
||||
}
|
||||
|
||||
@@ -600,11 +437,6 @@ class DesignGuard
|
||||
}
|
||||
}
|
||||
|
||||
protected function colourPattern(): string
|
||||
{
|
||||
return '/(?<![\w-])'.self::UTILITY.'-'.self::PALETTE.'(?![\w-])/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Every place one line names a role `forbidColours()` left out: its `--md-sys-color-*` custom
|
||||
* property, or the `md-ink-*` class text.css draws it with.
|
||||
@@ -675,320 +507,6 @@ class DesignGuard
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything on one line that names a value the theme no longer carries, each with the M3
|
||||
* 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>
|
||||
*/
|
||||
protected function offTheTokens(string $text): array
|
||||
{
|
||||
return [
|
||||
...$this->breakpointPrefixes($text),
|
||||
...$this->outsideTheScale($text),
|
||||
...$this->colourValues($text),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* `sm:`, `max-2xl:` and the rest, stacked variants included. A prefix must be followed at
|
||||
* once by the next variant or the utility itself, so a `md:` that is really an object key
|
||||
* (`md: { … }` in a script) is left alone.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
protected function breakpointPrefixes(string $text): array
|
||||
{
|
||||
static $pattern = null;
|
||||
$pattern ??= '/(?<![\w-])(?<max>max-)?(?<name>'.$this->alternation(array_keys(self::WINDOW_CLASSES)).'):(?=[a-z\d!*\[(_-])/';
|
||||
|
||||
preg_match_all($pattern, $text, $matches, PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL);
|
||||
|
||||
return array_map(function (array $match): string {
|
||||
$max = $match['max'] ?? '';
|
||||
$name = self::WINDOW_CLASSES[$match['name']];
|
||||
$px = Layout::BREAKPOINTS[$name];
|
||||
$comparison = $max !== '' ? "width < {$px}px" : "width >= {$px}px";
|
||||
|
||||
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 and tracking utilities Tailwind shipped and
|
||||
* M3's own scales replace. The easing and duration steps of the same Tailwind scale are not
|
||||
* read here — see the class header's note by the `ease-*`/`duration-*` entries of `FAMILIES`.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
protected function outsideTheScale(string $text): array
|
||||
{
|
||||
static $pattern = null;
|
||||
$pattern ??= '/(?<![\w-])(?:'
|
||||
.'rounded(?<side>-(?:ss|se|ee|es|tl|tr|br|bl|t|r|b|l|s|e))?-(?<corner>'.$this->alternation(array_keys(self::CORNERS)).')'
|
||||
.'|shadow-(?<elevation>'.$this->alternation(array_keys(self::ELEVATIONS)).')'
|
||||
.'|text-(?<size>xs|sm|base|lg|xl|[2-9]xl)'
|
||||
.'|font-(?<weight>thin|extralight|light|normal|medium|semibold|bold|extrabold|black)'
|
||||
.'|leading-(?<leading>none|tight|snug|normal|relaxed|loose|\d+(?:\.\d+)?)'
|
||||
.'|tracking-(?<tracking>tighter|tight|normal|wider|widest|wide)'
|
||||
.')(?![\w-])/';
|
||||
|
||||
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]}` — ".$this->scaleReplacement($match),
|
||||
$matches,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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']) => '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',
|
||||
default => 'use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Colours written as a value rather than a role: an arbitrary hex, function or mix.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
protected function colourValues(string $text): array
|
||||
{
|
||||
preg_match_all(self::ARBITRARY_COLOUR, $text, $matches);
|
||||
|
||||
return array_map(fn (string $class): string => "arbitrary colour `{$class}`, use an M3 role", $matches[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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), 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
|
||||
{
|
||||
if ($token === '' || str_starts_with($token, 'md-') || isset($this->applicationClasses()[$token])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$token = trim($token, '!');
|
||||
|
||||
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 (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 — ".$this->variantHint($variant['lead']);
|
||||
}
|
||||
|
||||
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 (($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, the `hidden` attribute (the reset keeps it hidden), or `x-show`";
|
||||
}
|
||||
|
||||
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 (($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 $this->themeColourHint($token);
|
||||
}
|
||||
|
||||
/**
|
||||
* The 2.0.0 replacement for a colour utility on a name neither M3 nor Tailwind has (see
|
||||
* `THEME_COLOUR_UTILITY`), or null: the application's own custom property in its own CSS, mixed
|
||||
* toward transparent where the utility carried an opacity.
|
||||
*/
|
||||
protected function themeColourHint(string $token): ?string
|
||||
{
|
||||
if (preg_match(self::THEME_COLOUR_UTILITY, $token, $m, PREG_UNMATCHED_AS_NULL) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dead = "Tailwind colour utility `{$token}` compiles to nothing — `{$m['name']}` is neither an M3 role nor a Tailwind colour, so it named a colour of the application's own theme";
|
||||
|
||||
return $m['opacity'] !== null
|
||||
? "{$dead}: use `color-mix(in srgb, var(--…) <n>%, transparent)` in your own CSS"
|
||||
: "{$dead}: use `var(--…)` in your own CSS";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, or an arbitrary selector.
|
||||
*/
|
||||
protected function variantHint(string $lead): string
|
||||
{
|
||||
$bare = str_starts_with($lead, 'max-') ? substr($lead, 4) : $lead;
|
||||
|
||||
return match (true) {
|
||||
$bare !== 'compact' && isset(Layout::BREAKPOINTS[$bare]) => '`@media (width '.($bare === $lead ? '>=' : '<').' '.Layout::BREAKPOINTS[$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",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A colour utility's 2.0.0 replacement (see `COLOUR_UTILITY`), or null for anything else: an
|
||||
* ink on plain text is its `md-ink-*` class, a line `<x-divider>` or `<x-surface outlined>`, a
|
||||
* tonal background `<x-surface level>`, white and black a role, opacity a role or the state
|
||||
* layer, and every other role its `var(--md-sys-color-*)` in the application's own CSS.
|
||||
*/
|
||||
protected function colourUtilityHint(string $token): ?string
|
||||
{
|
||||
if (preg_match(self::COLOUR_UTILITY, $token, $m, PREG_UNMATCHED_AS_NULL) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$utility = $m['utility'];
|
||||
$role = $m['role'];
|
||||
$dead = "Tailwind colour utility `{$token}` compiles to nothing";
|
||||
|
||||
return match (true) {
|
||||
in_array($role, ['white', 'black'], true) => "{$dead} — M3 paints with roles, never white or black: `<x-surface level=\"surface-container-lowest\">` or `var(--md-sys-color-surface-container-lowest)` for a white surface, an `on-` role (`md-ink`, `var(--md-sys-color-on-primary)`) for ink",
|
||||
in_array($role, ['current', 'transparent', 'inherit', 'initial'], true) => "{$dead} — write `".($role === 'current' ? 'currentColor' : $role).'` in your own CSS',
|
||||
$m['opacity'] !== null && $utility === 'text' => "{$dead} — M3's quieter text is a role, not a faded one: `md-ink-variant` or `md-ink-quiet` (text.css)",
|
||||
$m['opacity'] !== null => "{$dead} — M3's hover, focus and press overlays are `md-state-layer`; any other tint is `color-mix(in srgb, var(--md-sys-color-{$role}) <n>%, transparent)` in your own CSS",
|
||||
$utility === 'text' && isset(self::INK_ROLE[$role]) => "{$dead} — use `".self::INK_ROLE[$role].'` (text.css)',
|
||||
preg_match('/^(?:border(?:-(?:[trblxyse]|bs|be))?|divide)$/', $utility) === 1 && in_array($role, ['outline', 'outline-variant'], true) => "{$dead} — a line is `<x-divider>` or `<x-surface outlined>`, not a border utility",
|
||||
$utility === 'bg' && in_array($role === 'background' ? 'surface' : $role, self::SURFACE_LEVELS, true) => "{$dead} — use `<x-surface level=\"".($role === 'background' ? 'surface' : $role).'">`',
|
||||
default => "{$dead} — use `var(--md-sys-color-{$role})` in your own CSS",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -1285,14 +803,15 @@ class DesignGuard
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Every class written out literally: in a `class` attribute, in 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'`) is not a class, and neither is one
|
||||
* nested deeper than the list itself — an array index or a call's argument, which is where
|
||||
* `$block['base']`, `$header['class']` and `in_array($size, ['xs', 'sm'])` keep their strings;
|
||||
* echoes inside a list are skipped. A token is returned exactly as written, prefix and all,
|
||||
* with the line it sits on.
|
||||
*
|
||||
* @return list<array{0: int, 1: string}>
|
||||
*/
|
||||
@@ -1312,6 +831,13 @@ class DesignGuard
|
||||
$alpine = $binding['alpine'][0] !== null;
|
||||
[$body, $offset] = $binding['alpine'][0] !== null ? $binding['alpine'] : ($binding['php'][0] !== null ? $binding['php'] : $binding['pair']);
|
||||
|
||||
// `:class` on a component is PHP, so it may wrap the very helpers `directiveInTag()`
|
||||
// recommends: the array inside is the list, the call around it is not.
|
||||
if ($alpine && preg_match('/^\s*[^\s(]*(?:Arr::toCssClasses|->class)\(\s*(\[.*\])\s*\)\s*$/s', $body, $call, PREG_OFFSET_CAPTURE) === 1) {
|
||||
[$body, $offset] = [$call[1][0], $offset + $call[1][1]];
|
||||
$alpine = false;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -1319,7 +845,9 @@ class DesignGuard
|
||||
$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) {
|
||||
if (preg_match('/(?:[=!]==?|[<>]=?|\(|\?\?)\s*$/', $before) === 1
|
||||
|| preg_match('/^\s*(?:[=!]==?|[<>]=?)/', $after) === 1
|
||||
|| $this->readsAValue($before)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1343,6 +871,57 @@ class DesignGuard
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check (i): every class `literalClasses()` reads out of `$contents` that neither the
|
||||
* application's own stylesheets nor the package's declare — a rule for it exists nowhere, so
|
||||
* the class paints nothing, whatever it was once meant to do.
|
||||
*
|
||||
* @return list<array{0: int, 1: string}>
|
||||
*/
|
||||
protected function undeclaredClasses(string $contents): array
|
||||
{
|
||||
$found = [];
|
||||
|
||||
foreach ($this->literalClasses($contents) as [$line, $class]) {
|
||||
if ($class === '' || isset($this->applicationClasses()[$class]) || isset($this->packageClasses()[$class])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$found[] = [$line, $class];
|
||||
}
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the string that follows `$before` — the text of a class list up to it — is a value
|
||||
* the list reads rather than a class it writes: a call's argument (`in_array($size, ['xs',
|
||||
* 'sm'])`) or an array index (`$block['base']`, `$header['class']`). Both open a delimiter
|
||||
* that is still unclosed where the string sits, so this walks the text and keeps what is open:
|
||||
* a `(` right after a name is a call, one after anything else groups an expression
|
||||
* (`$cond ? 'a' : ($other ? 'b' : 'c')`), and a `[` right after a name, a `]` or a `)` reads
|
||||
* an index, while any other `[` opens an array of classes.
|
||||
*/
|
||||
protected function readsAValue(string $before): bool
|
||||
{
|
||||
$open = [];
|
||||
$length = strlen($before);
|
||||
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$previous = rtrim(substr($before, 0, $i));
|
||||
$name = $previous !== '' && preg_match('/[\w\]\)]$/', $previous) === 1;
|
||||
|
||||
match ($before[$i]) {
|
||||
'(' => $open[] = $name ? 'call' : 'group',
|
||||
'[' => $open[] = $name ? 'index' : 'array',
|
||||
')', ']' => array_pop($open),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
return in_array('call', $open, true) || in_array('index', $open, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Literal symbol names that do not exist: `<x-icon name="…">`, and an `icon="…"` or
|
||||
* `icon-right="…"` on any component.
|
||||
@@ -1686,9 +1265,7 @@ class DesignGuard
|
||||
|
||||
/**
|
||||
* Every class an application's own stylesheets select on — the scanned ones and whatever the
|
||||
* CSS entry imports outside the package — check (i)'s exemption list, so a class it defines is
|
||||
* never reported as a dead Tailwind utility just because it happens to share a shape with one.
|
||||
* An escaped selector (`.sm\:flex`) counts under its unescaped name.
|
||||
* CSS entry imports outside the package — one half of what check (i) counts as declared.
|
||||
*
|
||||
* @return array<string, true>
|
||||
*/
|
||||
@@ -1710,18 +1287,57 @@ class DesignGuard
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
$css = $this->maskedCss((string) file_get_contents($file));
|
||||
|
||||
preg_match_all('/(?<![\w.#\\\\-])\.(-?(?:[a-zA-Z_]|\\\\.)(?:[\w-]|\\\\.)*)/', $css, $matches);
|
||||
|
||||
foreach ($matches[1] as $class) {
|
||||
$classes[stripslashes($class)] = true;
|
||||
}
|
||||
$classes = [...$classes, ...$this->classSelectors((string) file_get_contents($file))];
|
||||
}
|
||||
|
||||
return $this->applicationClassesCache = $classes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every class the package's own `resources/css` declares — `md-*`, the component rules and the
|
||||
* showcase's own classes alike — the other half of check (i)'s "declared". Whether an
|
||||
* application imports the stylesheet that holds the rule is check (ii)'s question, not this
|
||||
* one's. Cached for the process: the package's CSS is the same for every scan in it.
|
||||
*
|
||||
* @return array<string, true>
|
||||
*/
|
||||
protected function packageClasses(): array
|
||||
{
|
||||
static $classes = null;
|
||||
|
||||
if ($classes !== null) {
|
||||
return $classes;
|
||||
}
|
||||
|
||||
$classes = [];
|
||||
$root = static::packagePath('css');
|
||||
|
||||
foreach ($root === '' ? [] : Finder::create()->files()->in($root)->name('*.css') as $file) {
|
||||
$classes = [...$classes, ...$this->classSelectors((string) file_get_contents($file->getPathname()))];
|
||||
}
|
||||
|
||||
return $classes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The classes `$css` selects on, by their unescaped name (`.sm\:flex` counts as `sm:flex`).
|
||||
* Comments and strings are blanked out first, so `content: ".flex"` declares nothing.
|
||||
*
|
||||
* @return array<string, true>
|
||||
*/
|
||||
protected function classSelectors(string $css): array
|
||||
{
|
||||
preg_match_all('/(?<![\w.#\\\\-])\.(-?(?:[a-zA-Z_]|\\\\.)(?:[\w-]|\\\\.)*)/', $this->maskedCss($css), $matches);
|
||||
|
||||
$classes = [];
|
||||
|
||||
foreach ($matches[1] as $class) {
|
||||
$classes[stripslashes($class)] = true;
|
||||
}
|
||||
|
||||
return $classes;
|
||||
}
|
||||
|
||||
protected function isGeneratedScheme(string $file): bool
|
||||
{
|
||||
if (basename($file) === 'material-scheme.css') {
|
||||
@@ -1733,7 +1349,8 @@ class DesignGuard
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* (check iii) never come from the package's own rules, and its own classes are
|
||||
* `packageClasses()`, read separately.
|
||||
*/
|
||||
protected static function isPackageFile(string $file): bool
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user