Files
livewire-material/src/Testing/DesignGuard.php
T
Andreas Reinhold / reiniandClaude Opus 5 471d927e64 Count a row written by hand in the design guard's stylesheet checks
`data-md-list-row` on an application's own `<li>`, `<div>`, `<tr>` or
`<x-row>` draws its hover, focus and press state layer, its cursor and
its `data-md-selected` fill from list-item.css, but missingStylesheets()
and unusedStylesheets() read only component tags and `->links()`.
ReStride had the stylesheet only because table.css imports it: had its
last `<x-table>` gone, every hand-made row would have lost its states
and its fill without a finding, and importing list-item.css for the
rows directly was reported unused.

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 08:05:33 +02:00

1882 lines
94 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
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;
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:
*
* (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, a 1.x ink, white or black (`text-meta`, `bg-white`,
* `text-on-surface/60`) or on a colour the application's own Tailwind theme named
* (`bg-brand`, `text-sport-run/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.
* (ii) `missingStylesheets($cssEntry)`: a package component tag used in a view — unprefixed,
* under the configured prefix, or `<x-livewire-material::…>` — whose stylesheet the
* entry's relative `@import` graph does not reach (followed through every package file's
* own imports; a package name or URL the entry also imports is skipped, never fatal),
* `->links()` needing `pagination.css`, and a hook an application's view writes on markup
* of its own whose rules a component's stylesheet holds — `data-md-list-row` on anything
* but `<x-card>` needing `list-item.css` (`HOOK_STYLESHEETS`); each names the missing
* `@import` line once, at its first use. A tag the application shadows with its own
* component of the same name is reported instead — the application's component wins in
* Blade, so the package's stylesheet is moot. `unusedStylesheets($cssEntry)` reports the
* other way: a package stylesheet the entry imports directly that no scanned view needs,
* not even through another needed stylesheet's imports (an entry importing `all.css` is
* left alone).
* (iii) every `.css` file among the scanned paths, outside the package and excluding the
* generated `material-scheme.css`: a literal colour, radius, shadow, font size, weight,
* line height, letter spacing, easing or duration, and a media query at a width other
* 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.
*
* A Markdown mail component — a view under a path `mail.markdown.paths` names, Laravel's
* `resources/views/vendor/mail` by default — is drawn by the mail theme, not by the application's
* CSS entry, and the theme's own classes (`table`, `button`, `panel`, `break-all`) share a
* Tailwind utility's name. So family (i) and the breakpoint, scale, palette and colour-value
* checks skip those views, while their icon names, the directives in their component tags and
* the application's own bans are still read; and a stylesheet under that path, a mail theme that
* has to write literal values because mail clients read no custom property, is neither check
* (iii)'s nor a source of exempt classes.
*
* expect(DesignGuard::scan([resource_path('views'), resource_path('js'), resource_path('css'), app_path()])
* ->missingStylesheets(resource_path('css/app.css'))
* ->forbidColours(['tertiary'])
* ->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.
*/
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 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. 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',
];
/** 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',
'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 = [
// 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`'],
// 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 written on an M3 role, a 1.x ink name, 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 = '/^(?<utility>bg|text|border(?:-(?:[trblxyse]|bs|be))?|divide|ring(?:-offset)?|outline|fill|stroke|decoration|accent|caret|placeholder|shadow|from|via|to)-'
.'(?<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|body|meta|quiet|structure|chrome|divider|white|black|current|transparent|inherit|initial)'
.'(?:\/(?<opacity>\d{1,3}|\[[^\]]*\]|\(--[\w-]+\)))?$/';
/**
* 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 = '/^(?<utility>bg|text|border(?:-(?:[trblxyse]|bs|be))?|divide|ring(?:-offset)?|outline|fill|stroke|decoration|accent|caret|placeholder|shadow|from|via|to)-'
.'(?<name>[a-z][a-z\d]*(?:-[a-z\d]+)*)(?:\/(?<opacity>\d{1,3}|\[[^\]]*\]|\(--[\w-]+\)))?$/';
/**
* The hooks an application writes on markup of its own, not through a component tag, whose
* rules live in a component's stylesheet, and that stylesheet (check ii). `data-md-list-row`
* on an `<li>`, a `<div>`, a `<tr>` or a layout component such as `<x-row>` draws its hover,
* focus and press state layer and its `data-md-selected` fill from list-item.css, which no tag
* asks for when the page renders no `<x-list-item>` and no `<x-table>`. `except` names the
* package tags that draw the hook themselves: `<x-card data-md-list-row>` is card.css's row.
*
* Nothing else that list-rows.js, the skill or the README has an application write needs a
* stylesheet of its own: `data-md-list-open` and a row's `data-md-selected` are drawn only
* through the row, a selected row in `<x-table>` by table.css, `data-md-dragged` by
* foundation.css's `md-state-layer` (and card.css on `<x-card>`), and `data-md-field-control`
* by field.css inside `<x-field>`.
*/
protected const array HOOK_STYLESHEETS = [
'data-md-list-row' => ['stylesheet' => 'components/list-item.css', 'except' => ['card']],
];
/** 1.x's semantic ink names, and the M3 role each resolved to (UPGRADE.md § 4). */
protected const array INK_ALIASES = [
'body' => 'on-surface-variant',
'meta' => 'on-surface-variant',
'quiet' => 'outline',
'structure' => 'outline-variant',
'chrome' => 'outline-variant',
'divider' => 'outline-variant',
];
/** An ink role, and the `md-ink-*` class (text.css) that sets it on plain text. */
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 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';
/** 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' => ['kind' => 'font', 'hint' => 'use `font: var(--md-sys-typescale-*)` with its `-tracking`, or an `md-type-*` class'],
'font-size' => ['kind' => 'font size', 'hint' => 'set the whole style with `font: var(--md-sys-typescale-*)` and its `-tracking`, or an `md-type-*` class'],
'font-weight' => ['kind' => 'font weight', 'hint' => 'use an `md-type-emphasized-*` class, or `var(--md-ref-typeface-weight-regular|medium|bold)`'],
'line-height' => ['kind' => 'line height', 'hint' => 'set the whole style with `font: var(--md-sys-typescale-*)`, or an `md-type-*` class'],
'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 = [];
/** @var list<string> */
protected array $forbiddenColours = [];
protected ?string $cssEntry = null;
protected ?string $unusedEntry = null;
/** @var array<string, true>|null */
protected ?array $applicationClassesCache = null;
/**
* @param list<string> $paths
*/
final public function __construct(protected array $paths) {}
/**
* @param string|list<string> $paths Files or directories.
*/
public static function scan(string|array $paths): static
{
return new static((array) $paths);
}
/**
* Roles the application's own rules leave out, e.g. ['tertiary', 'primary-container'].
* 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.
*
* @param list<string> $roles
*/
public function forbidColours(array $roles): static
{
$this->forbiddenColours = [...$this->forbiddenColours, ...$roles];
return $this;
}
/**
* Any further pattern, matched line by line.
*/
public function forbid(string $pattern, string $reason): static
{
$this->forbidden[] = ['pattern' => $pattern, 'reason' => $reason];
return $this;
}
/**
* Check (ii): every package component tag used in a scanned view — unprefixed, under the
* configured prefix, or `<x-livewire-material::…>` — every `->links()` call, and every hook in
* `HOOK_STYLESHEETS` a view outside the package writes on its own markup (a row's
* `data-md-list-row`), against `$cssEntry`'s `@import` graph (followed through each package
* file's own imports). A missing one names the `@import` line to add; a tag the application
* shadows with its own component of the same name is reported instead, since the package's
* stylesheet is then moot. Each missing stylesheet and each shadowed tag is reported once, at
* its first use. Only the imports are read here: the literal values in the application's own
* CSS are check (iii)'s, which reads the `.css` files `scan()` is given, entry or not — so an
* application part-way through its migration can check its imports before its stylesheets
* are on tokens. The classes the entry's own imports declare do join check (i)'s exemptions.
*/
public function missingStylesheets(string $cssEntry): static
{
$this->cssEntry = $cssEntry;
$this->applicationClassesCache = null;
return $this;
}
/**
* Check (ii) the other way round: each package stylesheet `$cssEntry` imports directly that no
* scanned Blade view needs — no package tag it renders, no `->links()`, no hook it writes by
* hand (`HOOK_STYLESHEETS`), and not reached through the imports of a stylesheet that is
* needed — named at its `@import` line, to remove. A component whose last use left the views
* otherwise keeps its CSS in every page. `foundation.css` is always needed; an entry that
* imports `all.css` has chosen everything and is not read.
*/
public function unusedStylesheets(string $cssEntry): static
{
$this->unusedEntry = $cssEntry;
return $this;
}
/**
* @return list<string>
*/
public function violations(): array
{
$violations = [];
$resolved = $this->cssEntry !== null
? array_fill_keys(Stylesheets::resolvedFiles([$this->cssEntry]), true)
: [];
$reported = [];
$needed = [];
if ($this->cssEntry !== null && ! is_file($this->cssEntry)) {
$violations[] = "{$this->cssEntry}:1 the CSS entry `missingStylesheets()` names does not exist";
} elseif ($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),
);
}
}
$mailPaths = $this->mailComponentPaths();
foreach ($this->files() as $file) {
if (str_ends_with($file->getFilename(), '.css')) {
continue; // A directly-scanned CSS file is check (iii)'s alone; these checks read Blade, PHP and JS.
}
$contents = (string) file_get_contents($file->getPathname());
$where = $this->relative($file->getPathname());
$isBlade = str_ends_with($file->getFilename(), '.blade.php');
$readsClasses = ! $this->isUnder($file->getPathname(), $mailPaths);
$readsHooks = ! $this->isUnder($file->getPathname(), array_filter([static::packageViewRoot()]));
if ($isBlade) {
$contents = $this->withoutBladeComments($contents);
}
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}";
}
}
}
if ($isBlade) {
foreach ($this->iconNames($contents) as [$line, $name]) {
$violations[] = "{$where}:{$line} unknown Material Symbol `{$name}`";
}
foreach ($this->directivesInComponentTags($contents) as [$line, $directive]) {
$violations[] = "{$where}:{$line} Blade directive `{$directive}` inside a component tag, where it does not compile — ".$this->directiveInTag($directive);
}
foreach ($this->forbiddenRoleProps($contents) as [$line, $what]) {
$violations[] = "{$where}:{$line} {$what}";
}
if ($this->cssEntry !== null) {
foreach ($this->missingStylesheetViolations($contents, $resolved, $reported, $readsHooks) as [$line, $what]) {
$violations[] = "{$where}:{$line} {$what}";
}
}
if ($this->unusedEntry !== null) {
$needed = [...$needed, ...$this->neededStylesheets($contents, $readsHooks)];
}
}
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) {
$violations[] = "{$where}:{$line} {$what}";
}
foreach ($this->forbidden as $rule) {
if (preg_match($rule['pattern'], $text) === 1) {
$violations[] = "{$where}:{$line} {$rule['reason']}";
}
}
}
}
if ($this->unusedEntry !== null) {
$violations = [...$violations, ...$this->unusedStylesheetViolations($needed)];
}
return $this->sortViolations([...$violations, ...$this->applicationCssViolations()]);
}
/**
* The package stylesheets a Blade view's own tags, `->links()` calls and, unless `$readsHooks`
* is false (a package view), the hooks it writes by hand need, before their imports are
* followed.
*
* @return list<string>
*/
protected function neededStylesheets(string $contents, bool $readsHooks = true): array
{
$needed = [];
foreach ($this->packageTagUsages($contents) as [, $name, $spelling]) {
if ($spelling === 'plain' && $this->shadowedByApplication($name)) {
continue;
}
if (($stylesheet = static::packageStylesheetFor($name)) !== null) {
$needed[] = $stylesheet;
}
}
if ($readsHooks) {
$needed = [...$needed, ...array_column($this->hookUsages($contents), 2)];
}
if ($this->paginationUsages($contents) !== []) {
$needed[] = static::packageCssRoot().'/components/pagination.css';
}
return $needed;
}
/**
* @param list<string> $needed
* @return list<string>
*/
protected function unusedStylesheetViolations(array $needed): array
{
$entry = (string) $this->unusedEntry;
$real = realpath($entry);
if ($real === false || ! is_file($real)) {
return ["{$entry}:1 the CSS entry `unusedStylesheets()` names does not exist"];
}
$root = static::packageCssRoot();
// Comments out, line count kept; the import strings themselves stay readable.
$css = (string) preg_replace_callback('~/\*.*?\*/~s', fn (array $comment): string => str_repeat("\n", substr_count($comment[0], "\n")), (string) file_get_contents($real));
$imports = [];
preg_match_all('/@import\s+(?:url\(\s*)?([\'"])([^\'"]+)\1/i', $css, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
foreach ($matches as $match) {
$file = realpath(dirname($real).'/'.$match[2][0]);
if ($file === false || ! static::isPackageFile($file)) {
continue;
}
if ($file === $root.'/all.css') {
return [];
}
$imports[] = [substr_count(substr($css, 0, $match[0][1]), "\n") + 1, $file];
}
$reached = array_fill_keys(Stylesheets::resolvedFiles([...array_unique($needed), $root.'/foundation.css']), true);
$violations = [];
foreach ($imports as [$line, $file]) {
if (! isset($reached[$file])) {
$violations[] = sprintf(
'%s:%d `%s` is imported, but no scanned view renders a component that needs it — remove `@import \'%s\';`',
$this->relative($real),
$line,
static::packageRelativeName($file),
$this->importLineFrom($real, $file),
);
}
}
return $violations;
}
/**
* `$violations` ("path:line message", `relative()`'s shape) sorted by path, then line
* (numerically, so line 9 sits before line 10), then message — whichever check produced each
* one, so two checks that land on the same file read in one deterministic order instead of
* each check's own pass order (colours before shadows before media queries, line order inside
* `missingStylesheets()`'s own pass, and so on).
*
* @param list<string> $violations
* @return list<string>
*/
protected function sortViolations(array $violations): array
{
$parsed = array_map(function (string $violation): array {
preg_match('/^(.*):(\d+) (.*)$/s', $violation, $match);
return [$match[1] ?? $violation, isset($match[2]) ? (int) $match[2] : 0, $match[3] ?? '', $violation];
}, $violations);
usort($parsed, fn (array $a, array $b): int => $a[0] <=> $b[0] ?: $a[1] <=> $b[1] ?: $a[2] <=> $b[2]);
return array_column($parsed, 3);
}
/**
* @return iterable<SplFileInfo>
*/
protected function files(): iterable
{
foreach ($this->paths as $path) {
if (is_file($path)) {
yield new SplFileInfo($path);
continue;
}
if (is_dir($path)) {
yield from Finder::create()->files()->in($path)->name(['*.php', '*.js', '*.ts'])->sortByName();
}
}
}
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.
*
* @return list<string>
*/
protected function forbiddenRoles(string $text): array
{
$found = [];
foreach ($this->forbiddenColours as $role) {
$names = ['--md-sys-color-(?:on-)?'.preg_quote($role, '/').'(?:-container)?'];
foreach ([$role, "on-{$role}", "{$role}-container", "on-{$role}-container"] as $variant) {
if (isset(self::INK_ROLE[$variant])) {
$names[] = preg_quote(self::INK_ROLE[$variant], '/');
}
}
preg_match_all('/(?<![\w-])(?:'.implode('|', $names).')(?![\w-])/', $text, $matches);
foreach ($matches[0] as $written) {
$found[] = "`{$written}`: role `{$role}` is not part of this application's palette";
}
}
return $found;
}
/**
* Every component `color` or `tone` prop in `$contents` naming a role `forbidColours()` left
* out, literal (`color="tertiary"`) or any string a bound one can take
* (`:tone="$failed ? 'error' : 'info'"`).
*
* @return list<array{0: int, 1: string}>
*/
protected function forbiddenRoleProps(string $contents): array
{
if ($this->forbiddenColours === []) {
return [];
}
preg_match_all('/<x-[\w.:-]+((?:[^>"]|"[^"]*")*)>/s', $contents, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
$found = [];
foreach ($tags as $tag) {
preg_match_all('/\s(?<bound>:?)(?:color|tone)="(?<value>[^"]*)"/', $tag[1][0], $props, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
foreach ($props as $prop) {
$values = $prop['bound'][0] === ':'
? (preg_match_all("/'([^']*)'/", $prop['value'][0], $strings) ? $strings[1] : [])
: [$prop['value'][0]];
foreach ($values as $value) {
foreach ($this->forbiddenColours as $role) {
if (preg_match('/^(?:on-)?'.preg_quote($role, '/').'(?:-container)?$/', $value) === 1) {
$found[] = [
substr_count(substr($contents, 0, $tag[1][1] + $prop[0][1]), "\n") + 1,
'`'.trim($prop[0][0])."`: role `{$role}` is not part of this application's palette",
];
}
}
}
}
}
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 = self::WINDOW_PX[$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('/^(?: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 (($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 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",
};
}
/**
* 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 = self::INK_ALIASES[$m['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
* 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) {
$masked = $this->maskedCss((string) file_get_contents($file));
$css = $this->withoutTokenFunctions($masked);
$where = $this->relative($file);
foreach (explode("\n", $masked) as $index => $text) {
foreach ($this->forbiddenRoles($text) as $what) {
$violations[] = "{$where}:".($index + 1)." {$what}";
}
}
foreach ([
...$this->literalColours($css),
...$this->literalDeclarations($css, $masked),
...$this->offScaleMediaQueries($css),
] as [$line, $what]) {
$violations[] = "{$where}:{$line} {$what}";
}
}
return $violations;
}
/**
* Every literal colour in a declaration's value: a hex code, a colour function, or a named
* colour other than `transparent`/`currentColor`, anywhere in the value (`border: 1px solid
* white` as much as `color: white`). Only values are read, so a selector like `.red-banner` or
* an id like `#add` never matches.
*
* @return list<array{0: int, 1: string}>
*/
protected function literalColours(string $css): array
{
$found = [];
foreach ($this->declarations($css) as [, $value, $offset]) {
preg_match_all('/(?<![\w#-])(?:#[0-9a-fA-F]{3,8}(?![\w-])|(?:rgba?|hsla?|hwb|oklch|oklab|lch|lab|color)\([^)]*\)|(?:'.self::CSS_NAMED_COLOURS.')(?![\w-]))/i', $value, $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[0] as [$colour, $at]) {
$found[] = [
substr_count(substr($css, 0, $offset + $at), "\n") + 1,
"literal colour `{$colour}` — use `var(--md-sys-color-*)`",
];
}
}
return $found;
}
/**
* Every declaration in `$css` — a property, custom properties included, and its value up to
* the `;` or `}` that ends it — with the value's byte offset. A selector (`a:hover {`) or an
* at-rule condition (`@media (prefers-color-scheme: dark) {`) ends in `{` and is never one.
*
* @return list<array{0: string, 1: string, 2: int}>
*/
protected function declarations(string $css): array
{
preg_match_all('/(?<![\w-])(?<property>--[\w-]+|-?[a-zA-Z][\w-]*)\s*:\s*(?<value>[^;{}]*)(?=[;}])/', $css, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
return array_map(fn (array $match): array => [strtolower($match['property'][0]), $match['value'][0], $match['value'][1]], $matches);
}
/**
* 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). A
* custom property is never one of them, whatever its name says. `$css` has its token functions
* blanked; a message quotes the declaration as written in `$source`, the same CSS before that
* (blanking keeps every offset).
*
* @return list<array{0: int, 1: string}>
*/
protected function literalDeclarations(string $css, ?string $source = null): array
{
$found = [];
foreach ($this->declarations($css) as [$property, $blanked, $offset]) {
$value = trim($blanked);
$written = trim(substr($source ?? $css, $offset, strlen($blanked)));
$line = substr_count(substr($css, 0, $offset), "\n") + 1;
$isRing = $property === 'box-shadow' && $this->isSafeBoxShadowRing($value);
if (isset(self::LITERAL_PROPERTIES[$property]) && ! $this->isSafeLiteralValue($value) && ! $isRing) {
$config = self::LITERAL_PROPERTIES[$property];
$found[] = [$line, "literal {$config['kind']} `{$property}: {$written}` — {$config['hint']}"];
}
if (! in_array($property, ['transition', 'animation'], true)) {
continue;
}
// `linear` is M3's own easing for continuous motion (an indeterminate progress
// indicator's rotation, foundations-supplement.md § Motion) — legitimate literal CSS,
// unlike `ease`/`ease-in`/`ease-out`/`ease-in-out`, a spring's own shape and never
// written by hand, or `cubic-bezier()`/`steps()`, always a hand-rolled curve.
if (preg_match('/cubic-bezier\(|steps\(|(?<![\w-])(?:ease(?:-in-out|-in|-out)?|step-start|step-end)(?![\w-])/i', $value) === 1) {
$found[] = [$line, "literal easing in `{$property}: {$written}` — use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`"];
}
preg_match_all('/(?<![\w.-])(\d*\.?\d+)m?s(?![\w-])/i', $value, $durations);
if (array_filter($durations[1], fn (string $number): bool => (float) $number > 0) !== []) {
$found[] = [$line, "literal duration in `{$property}: {$written}` — use `var(--md-sys-motion-…-duration)`, paired with its easing"];
}
}
return $found;
}
/**
* A value `withoutTokenFunctions()` left with nothing but the empty shell of a token function
* — whitespace where the call's own argument list used to be, the call's own name and
* parentheses still standing — is exactly as fine as one masked away entirely. A list of values
* (`transition-duration: var(…), var(…)`) is fine when every item in it is.
*/
protected function isSafeLiteralValue(string $value): bool
{
$value = (string) preg_replace('/\b(?:var|calc|min|max|clamp)\(\s*\)/i', '', $value);
foreach (explode(',', $value) as $item) {
$item = trim($item);
if ($item !== '' && ! in_array(strtolower($item), ['0', '0px', '0s', '0ms', 'none', 'inherit', 'initial', 'unset', 'revert', 'normal', 'auto'], true)) {
return false;
}
}
return true;
}
/**
* A `box-shadow` shaped like an inset or outline-style ring in a colour role — `[inset] 0 0 0
* <n>px` in a `var(--md-sys-color-*)`, or one mixed toward transparent for a disabled ring
* (`color-mix(in srgb, var(--md-sys-color-*) <n>, transparent)`) — the shape the package's own
* stylesheets (and their stylesheet tests) use for a day's or a year's "current" outline, a
* focused field's edge, a selected chip's border: legitimate M3 CSS, not a hand-made shadow.
* `$value` has already had every `var()`/`calc()` call's own arguments blanked (see
* `withoutTokenFunctions()`), so the colour itself is read here only as an empty shell.
*/
protected function isSafeBoxShadowRing(string $value): bool
{
return preg_match(
'/^(?:inset\s+)?0\s+0\s+0\s+\d+(?:\.\d+)?px\s+(?:var\([ \t]*\)|color-mix\(in srgb,\s*var\([ \t]*\)[^,]*,\s*transparent\s*\))$/',
trim($value),
) === 1;
}
/**
* Every width in an `@media` condition that is not one of M3's four breakpoints written in
* px: `(width >= 840px)`, `(min-width: 840px)` and `(600px <= width < 840px)` pass, `(width >
* 839px)`, `(max-width: 839.98px)` and anything in `rem`/`em` do not. A height, a
* `prefers-*` feature and an `@container` query are not breakpoints and are not read.
*
* @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('/\(([^()]*)\)/', $prelude, $features, PREG_OFFSET_CAPTURE);
foreach ($features[1] as [$feature, $featureOffset]) {
if (preg_match('/(?<![\w-])(?:min-|max-)?(?:device-)?width(?![\w-])/i', $feature) !== 1) {
continue;
}
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/i', $feature, $widths, PREG_OFFSET_CAPTURE);
foreach ($widths[0] as $i => [$whole, $widthOffset]) {
$number = (float) $widths[1][$i][0];
if (strtolower($widths[2][$i][0]) === 'px' && in_array($number, [600.0, 840.0, 1200.0, 1600.0], true)) {
continue;
}
$line = substr_count(substr($css, 0, $preludeOffset + $featureOffset + $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) with `>=` or `<`"];
}
}
}
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.
*
* @param list<string> $names
*/
protected function alternation(array $names): string
{
usort($names, fn (string $a, string $b): int => strlen($b) <=> strlen($a));
return implode('|', array_map(fn (string $name): string => preg_quote($name, '/'), $names));
}
/**
* Blade comments blanked out, their line breaks kept so line numbers still match.
*/
protected function withoutBladeComments(string $contents): string
{
return (string) preg_replace_callback(
'/\{\{--.*?--\}\}/s',
fn (array $match): string => str_repeat("\n", substr_count($match[0], "\n")),
$contents,
);
}
/**
* `$css` with every `/* … *\/` comment, the inside of every quoted string and of every
* unquoted `url(…)` blanked out, line breaks kept — so neither a class check nor a value check
* reads `content: ".flex"`, a font name or a data URI's `fill='white'`.
*/
protected function maskedCss(string $css): string
{
return (string) preg_replace_callback(
'/\/\*.*?\*\/|"(?:[^"\\\n]|\\.)*"|\'(?:[^\'\\\n]|\\.)*\'|(?<=url\()[^)\'"]*(?=\))/is',
fn (array $match): string => str_starts_with($match[0], '/*')
? str_repeat("\n", substr_count($match[0], "\n"))
: (string) preg_replace('/[^\n]/', ' ', $match[0]),
$css,
);
}
/**
* `$css` with the argument list of every `var(…)` call, and of every `calc()`, `min()`,
* `max()` or `clamp()` built on one, blanked out (nested parentheses tracked) — what lets
* check (iii) treat any value built from a token as fine, whatever literal numbers or colours
* it wraps, a `var()` fallback included.
*/
protected function withoutTokenFunctions(string $css): string
{
return $this->maskFunctionCalls($this->maskFunctionCalls($css, 'var'), '(?:calc|min|max|clamp)', true);
}
/**
* `$css` with the argument list of every `$name(…)` call blanked out — or, with `$withVar`,
* of only those whose arguments use a `var()`, so `calc(var(--x) + 4px)` is a token's value
* and `calc(12px + 2px)` or `clamp(1rem, 2vw, 2rem)` stays a literal one.
*/
protected function maskFunctionCalls(string $css, string $name, bool $withVar = false): 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);
if ($withVar && preg_match('/(?<![\w-])var\(/i', $inner) !== 1) {
$offset = $open + 1;
continue;
}
$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="…"`, 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}>
*/
protected function literalClasses(string $contents): array
{
$lists = [];
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="(?<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) {
$alpine = $binding['alpine'][0] !== null;
[$body, $offset] = $binding['alpine'][0] !== null ? $binding['alpine'] : ($binding['php'][0] !== null ? $binding['php'] : $binding['pair']);
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;
}
$lists[] = [$list, $offset + $inner];
}
}
$found = [];
usort($lists, fn (array $a, array $b): int => $a[1] <=> $b[1]);
foreach ($lists as [$list, $offset]) {
$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 | 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];
}
}
}
return $found;
}
/**
* Literal symbol names that do not exist: `<x-icon name="…">`, and an `icon="…"` or
* `icon-right="…"` on any component.
*
* @return list<array{0: int, 1: string}>
*/
protected function iconNames(string $contents): array
{
static $symbols = null;
$symbols ??= array_flip(SvgFile::symbolNames());
$iconTag = 'x-'.config('livewire-material.prefix', '').'icon';
preg_match_all('/<(x-[\w.:-]+)((?:[^>"]|"[^"]*")*)>/s', $contents, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
$unknown = [];
foreach ($tags as $tag) {
$attributes = $tag[1][0] === $iconTag ? 'name|icon|icon-right' : 'icon|icon-right';
preg_match_all('/\s(?:'.$attributes.')="([^"]*)"/', $tag[2][0], $values, PREG_OFFSET_CAPTURE);
foreach ($values[1] as [$name, $offset]) {
if ($name === '' || preg_match('/[{$@]/', $name) === 1 || isset($symbols[$name])) {
continue;
}
$unknown[] = [substr_count(substr($contents, 0, $tag[2][1] + $offset), "\n") + 1, $name];
}
}
return $unknown;
}
/**
* What a component tag takes in place of a directive: an attribute expression, which Blade
* compiles inside the tag.
*/
protected function directiveInTag(string $directive): string
{
return match ($directive) {
'@js' => 'use `{{ \\Illuminate\\Support\\Js::from(…) }}`',
'@json' => 'use `{{ json_encode(…) }}`',
'@class' => 'use `:class="\\Illuminate\\Support\\Arr::toCssClasses([…])"`',
'@style' => 'use `:style="\\Illuminate\\Support\\Arr::toCssStyles([…])"`',
'@entangle' => "use `\$wire.entangle('…')` in the Alpine expression",
'@disabled', '@checked', '@selected', '@readonly', '@required' => 'use `:'.substr($directive, 1).'="…"`',
default => 'use a `:prop` binding or `{{ }}`, or move it to a plain element inside the slot',
};
}
/**
* Blade compiles a component tag before its directives, so `<x-icon @class([...])>` or
* `x-show="ok(@js($v))"` on a component reaches the browser as literal text. Use `:class`
* and `{{ }}` there instead.
*
* @return list<array{0: int, 1: string}>
*/
protected function directivesInComponentTags(string $contents): array
{
preg_match_all('/<x-[\w.:-]+((?:[^>"]|"[^"]*")*)>/s', $contents, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
$found = [];
foreach ($tags as $tag) {
preg_match_all('/(?<![\w@])@(class|style|js|json|if|unless|isset|foreach|disabled|checked|selected|readonly|required|entangle)\b/', $tag[1][0], $directives, PREG_OFFSET_CAPTURE);
foreach ($directives[0] as [$directive, $offset]) {
$found[] = [substr_count(substr($contents, 0, $tag[1][1] + $offset), "\n") + 1, $directive];
}
}
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.
*
* @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')) {
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, found the way Blade itself
* looks before it reaches this package's anonymous path: an alias registered with
* `Blade::component()`, a class under the application's `View\\Components` namespace (its own
* root namespace, as Blade guesses it), or an anonymous
* `resources/views/components/<name>.blade.php` (the base view finder's `components.<name>`).
*/
protected function shadowedByApplication(string $name): bool
{
if (view()->exists('components.'.$name)) {
return true;
}
$blade = app('blade.compiler');
$compiler = new ComponentTagCompiler($blade->getClassComponentAliases(), $blade->getClassComponentNamespaces(), $blade);
try {
$class = $compiler->guessClassName($name);
} catch (RuntimeException) {
return false; // No application namespace to guess a class in.
}
return isset($blade->getClassComponentAliases()[$name])
|| $compiler->findClassByComponent($name) !== null
|| class_exists($class)
|| class_exists($class.'\\'.Str::afterLast($class, '\\'));
}
/**
* 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]);
}
/**
* Every hook in `HOOK_STYLESHEETS` a Blade view writes, each as [line, hook, stylesheet]: an
* attribute of a plain element or of a component tag other than those `except` names (blanked
* out first), or a name a PHP array or a script sets. A selector (`[data-md-list-row]`), a name
* in backticks and a longer name that starts with the hook write no hook.
*
* @return list<array{0: int, 1: string, 2: string}>
*/
protected function hookUsages(string $contents): array
{
$found = [];
foreach (self::HOOK_STYLESHEETS as $hook => ['stylesheet' => $stylesheet, 'except' => $except]) {
if (! str_contains($contents, $hook)) {
continue;
}
$markup = $this->withoutPackageTags($contents, $except);
preg_match_all('/(?<![\w`\[-])'.preg_quote($hook, '/').'(?![\w-])/', $markup, $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[0] as [, $offset]) {
$found[] = [substr_count(substr($markup, 0, $offset), "\n") + 1, $hook, static::packageCssRoot().'/'.$stylesheet];
}
}
return $found;
}
/**
* `$contents` with the opening tag of each package component `$names` names — plain, under the
* configured prefix or `<x-livewire-material::…>` — blanked out, line breaks kept. A quoted
* attribute value or a `{{ … }}` inside the tag may hold a `>`.
*
* @param list<string> $names
*/
protected function withoutPackageTags(string $contents, array $names): string
{
if ($names === []) {
return $contents;
}
$prefix = (string) config('livewire-material.prefix', '');
$namespaces = $prefix !== '' ? ['livewire-material', $prefix] : ['livewire-material'];
return (string) preg_replace_callback(
'/<x-(?:(?:'.$this->alternation($namespaces).')::)?(?:'.$this->alternation($names).')(?=[\s\/>])(?:\{\{.*?\}\}|"[^"]*"|\'[^\']*\'|[^>"\'])*+>?/s',
fn (array $tag): string => (string) preg_replace('/[^\n]/', ' ', $tag[0]),
$contents,
);
}
/**
* Check (ii)'s findings for one file: a package tag whose stylesheet `$resolved` (every file
* `Stylesheets::resolvedFiles()` reached from the CSS entry, keyed by real path) does not
* contain, a tag the application shadows, a hook written by hand whose stylesheet it does not
* contain (unless `$readsHooks` is false, for a package view), and a `->links()` needing
* `pagination.css` — each only the first time `$reported` (shared across every file of one
* `violations()` run) sees it, at the earliest line in the file that needs it.
*
* @param array<string, true> $resolved
* @param array<string, true> $reported
* @return list<array{0: int, 1: string}>
*/
protected function missingStylesheetViolations(string $contents, array $resolved, array &$reported, bool $readsHooks = true): array
{
$found = [];
$needs = [];
foreach ($this->packageTagUsages($contents) as [$line, $name, $spelling]) {
if ($spelling === 'plain' && $this->shadowedByApplication($name)) {
if (! isset($reported["shadow:{$name}"])) {
$reported["shadow:{$name}"] = true;
$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;
}
if (($stylesheet = static::packageStylesheetFor($name)) !== null) {
$needs[] = [$line, "`<x-{$name}>`", $stylesheet];
}
}
if ($readsHooks) {
foreach ($this->hookUsages($contents) as [$line, $hook, $stylesheet]) {
$needs[] = [$line, "`{$hook}`", $stylesheet];
}
}
foreach ($this->paginationUsages($contents) as $line) {
$needs[] = [$line, '`->links()`', static::packageCssRoot().'/components/pagination.css'];
}
usort($needs, fn (array $a, array $b): int => $a[0] <=> $b[0]);
foreach ($needs as [$line, $what, $stylesheet]) {
if (! isset($resolved[$stylesheet]) && ! isset($reported[$stylesheet])) {
$reported[$stylesheet] = true;
$found[] = [$line, sprintf(
"%s needs `%s`, missing from %s — add `@import '%s';`",
$what,
static::packageRelativeName($stylesheet),
$this->relative($this->cssEntry),
$this->importLine($stylesheet),
)];
}
}
return $found;
}
/**
* Every `.css` file this guard reads for check (iii): the ones `scan()`'s paths hold, outside
* the package, the generated `material-scheme.css` and a mail theme under a mail component path
* excluded. The CSS entry's imports are not followed here — a vendor stylesheet it pulls in is
* not the application's to put on tokens.
*
* @return list<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();
}
}
}
$mailPaths = $this->mailComponentPaths();
return array_values(array_unique(array_filter(
$files,
fn (string $file): bool => $file !== '' && ! static::isPackageFile($file) && ! $this->isGeneratedScheme($file) && ! $this->isUnder($file, $mailPaths),
)));
}
/**
* The Markdown mail component paths Laravel renders mail from (`mail.markdown.paths`), as real
* paths; one that does not exist is left out. See the class header.
*
* @return list<string>
*/
protected function mailComponentPaths(): array
{
return array_values(array_filter(array_map(
fn (mixed $path): string => is_string($path) ? (string) realpath($path) : '',
(array) config('mail.markdown.paths', []),
)));
}
/**
* @param list<string> $directories Real paths.
*/
protected function isUnder(string $file, array $directories): bool
{
$file = realpath($file) ?: $file;
foreach ($directories as $directory) {
if (str_starts_with($file, rtrim($directory, '/').'/')) {
return true;
}
}
return false;
}
/**
* Every class an application's own stylesheets select on — the scanned ones and whatever the
* CSS entry imports outside the package — check (i)'s exemption list, so a class it defines is
* 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.
*
* @return array<string, true>
*/
protected function applicationClasses(): array
{
if ($this->applicationClassesCache !== null) {
return $this->applicationClassesCache;
}
$classes = [];
$files = $this->applicationStylesheets();
if ($this->cssEntry !== null) {
$files = array_unique([...$files, ...array_filter(
Stylesheets::resolvedFiles([$this->cssEntry]),
fn (string $file): bool => ! static::isPackageFile($file),
)]);
}
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;
}
}
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');
}
/**
* This package's own `resources/views`: its components, showcase, pagination and error pages.
* A hook written there is a tag's own, so check (ii) reads hooks only outside it.
*/
protected static function packageViewRoot(): string
{
return (string) realpath(dirname(__DIR__, 2).'/resources/views');
}
protected static function isPackageTag(string $name): bool
{
$root = static::packageComponentViewRoot();
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
{
return $this->importLineFrom((string) $this->cssEntry, $file);
}
/** `importLine()` for an entry other than `missingStylesheets()`'s. */
protected function importLineFrom(string $entry, string $file): string
{
$entryDir = dirname((string) (realpath($entry) ?: $entry));
$vendor = base_path('vendor/nonameweb/livewire-material/resources/css');
// A Composer path repository may symlink the package: the import still goes through vendor/.
if (realpath($vendor) === static::packageCssRoot() && str_starts_with($file, static::packageCssRoot().'/')) {
$file = $vendor.substr($file, strlen(static::packageCssRoot()));
}
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(), '/').'/';
return str_starts_with($path, $base) ? substr($path, strlen($base)) : $path;
}
}