` — 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 `` 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 = '/(? '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-]*-\((?--[\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 ``'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: `` sets a content column\'s measure, `` an icon\'s; anything else is a length in your own CSS'], ['/^container$/', 'Tailwind\'s `container` compiles to nothing — use ``, 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 `` or ``; 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 ``'], ['/^(?: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 ``, `` or ``, 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 = '/^(?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 = '(?:\/(?\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 .'(?(?: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.'(?[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` * on an `
  • `, a `
    `, a `` or a layout component such as `` 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 `` and no ``. `except` names the * package tags that draw the hook themselves: `` 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 `` by table.css, `data-md-dragged` by * foundation.css's `md-state-layer` (and card.css on ``), and `data-md-field-control` * by field.css inside ``. */ protected const array HOOK_STYLESHEETS = [ 'data-md-list-row' => ['stylesheet' => 'components/list-item.css', 'except' => ['card']], ]; /** 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 `` 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 */ protected array $forbidden = []; /** @var list */ protected array $forbiddenColours = []; protected ?string $cssEntry = null; protected ?string $unusedEntry = null; /** @var array|null */ protected ?array $applicationClassesCache = null; /** * @param list $paths */ final public function __construct(protected array $paths) {} /** * @param string|list $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 $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 `` — 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 */ 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::packagePath('css').'/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); // A hook written in the package's own views is a tag's own, so hooks are read only outside them. $readsHooks = ! $this->isUnder($file->getPathname(), array_filter([static::packagePath('views')])); 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 */ 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::packagePath('css').'/components/pagination.css'; } return $needed; } /** * @param list $needed * @return list */ 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::packagePath('css'); // 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 $violations * @return list */ 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 */ 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 '/(? */ 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('/(? */ protected function forbiddenRoleProps(string $contents): array { if ($this->forbiddenColours === []) { return []; } preg_match_all('/"]|"[^"]*")*)>/s', $contents, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); $found = []; foreach ($tags as $tag) { preg_match_all('/\s(?:?)(?:color|tone)="(?[^"]*)"/', $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 */ 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 */ protected function breakpointPrefixes(string $text): array { static $pattern = null; $pattern ??= '/(?max-)?(?'.$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 */ protected function outsideTheScale(string $text): array { static $pattern = null; $pattern ??= '/(?-(?:ss|se|ee|es|tl|tr|br|bl|t|r|b|l|s|e))?-(?'.$this->alternation(array_keys(self::CORNERS)).')' .'|shadow-(?'.$this->alternation(array_keys(self::ELEVATIONS)).')' .'|text-(?xs|sm|base|lg|xl|[2-9]xl)' .'|font-(?thin|extralight|light|normal|medium|semibold|bold|extrabold|black)' .'|leading-(?none|tight|snug|normal|relaxed|loose|\d+(?:\.\d+)?)' .'|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 $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 ``', 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 */ 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('/^(?\[[^\]]*\]|[^:\[]+(?:\[[^\]]*\])?):/', $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('/^\[(?-{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(--…) %, 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 `` (`gap`, `align`, `justify`, `wrap`, `stack-below`), or `` for a column", $token === 'flex-col' => "{$dead} — use `` (`gap`, `align`)", $token === 'flex-wrap' => "{$dead} — use ``", in_array($token, ['grid', 'inline-grid'], true) => "{$dead} — use `` (`:columns` per breakpoint, `gap`, `min-item`), or `` for a grid of cards", preg_match('/^grid-cols-(\d+)$/', $token, $m) === 1 => "{$dead} — use ``, 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 `` or ``", $token === 'items-baseline' => "{$dead} — use `align=\"baseline\"` on ``", preg_match('/^justify-(start|end|center|between)$/', $token, $m) === 1 => "{$dead} — use `justify=\"{$m[1]}\"` on ``", 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('/^(?-)?(?gap(?:-[xy])?|[pm][trblxyse]?|space-[xy])-(?\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} — `` 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 ``, ``, `` or ``', $m['kind'] === 'space-y' => "{$dead} — use `` with ".$choice('gap'), $m['kind'] === 'space-x' => "{$dead} — use `` with ".$choice('gap'), str_starts_with($m['kind'], 'p') => "{$dead} — use ".$choice('padding')." on ``, 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 `` or ``, a * tonal background ``, 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: `` 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}) %, 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 `` or ``, not a border utility", $utility === 'bg' && in_array($role === 'background' ? 'surface' : $role, self::SURFACE_LEVELS, true) => "{$dead} — use ``', 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 */ 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 */ protected function literalColours(string $css): array { $found = []; foreach ($this->declarations($css) as [, $value, $offset]) { preg_match_all('/(? */ protected function declarations(string $css): array { preg_match_all('/(?--[\w-]+|-?[a-zA-Z][\w-]*)\s*:\s*(?[^;{}]*)(?=[;}])/', $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 */ 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\(|(? (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 * px` in a `var(--md-sys-color-*)`, or one mixed toward transparent for a disabled ring * (`color-mix(in srgb, var(--md-sys-color-*) , 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 */ 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('/(? [$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 $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 = '/(? 0) { if ($css[$i] === '(') { $depth++; } elseif ($css[$i] === ')') { $depth--; } $i++; } $inner = substr($css, $open + 1, $i - $open - 2); if ($withVar && preg_match('/(?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 */ protected function literalClasses(string $contents): array { $lists = []; preg_match_all('/(?:(?[^"]*)"|(?:@class|->class|Arr::toCssClasses)\((?\[.*?\]|\'[^\']*\'|"[^"]*")\)|([\'"])class\3\s*=>\s*(?\'[^\']*\'|"[^"]*")/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: ``, and an `icon="…"` or * `icon-right="…"` on any component. * * @return list */ 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 `` or * `x-show="ok(@js($v))"` on a component reaches the browser as literal text. Use `:class` * and `{{ }}` there instead. * * @return list */ protected function directivesInComponentTags(string $contents): array { preg_match_all('/"]|"[^"]*")*)>/s', $contents, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); $found = []; foreach ($tags as $tag) { preg_match_all('/(?` — 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/.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). `` is not a component tag. * * @return list */ 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/.blade.php` (the base view finder's `components.`). */ 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 */ 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 */ 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('/(?` — blanked out, line breaks kept. A quoted * attribute value or a `{{ … }}` inside the tag may hold a `>`. * * @param list $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( '/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 $resolved * @param array $reported * @return list */ 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, "`` is shadowed by the application's own component of the same name — the package's `` never renders here"]; } continue; } if (($stylesheet = static::packageStylesheetFor($name)) !== null) { $needs[] = [$line, "``", $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::packagePath('css').'/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 */ 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 */ 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 $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 */ 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('/(?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::packagePath('css'); return $root !== '' && ($file === $root || str_starts_with($file, $root.'/')); } /** * A folder of this package's own `resources/` (`css`, `views`, `views/components`), from this * file's own location — works whether the class loads from `vendor/nonameweb/livewire-material` * (a consuming application) or from this repository itself. Empty when it does not exist. */ protected static function packagePath(string $folder): string { return (string) realpath(dirname(__DIR__, 2)."/resources/{$folder}"); } protected static function isPackageTag(string $name): bool { $root = static::packagePath('views/components'); return $root !== '' && is_file("{$root}/{$name}.blade.php"); } /** * The package stylesheet a component view of this name draws from — `components/.css` * or `layout/.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::packagePath('css')."/{$group}/{$name}.css"; if (is_file($file)) { return $file; } } return null; } protected static function packageRelativeName(string $file): string { $root = static::packagePath('css').'/'; 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::packagePath('css') && str_starts_with($file, static::packagePath('css').'/')) { $file = $vendor.substr($file, strlen(static::packagePath('css'))); } 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; } }