` — whose stylesheet the * entry's `@import` graph does not reach (followed through every package file's own * imports, `Support\Stylesheets` resolves), and `->links()` needing `pagination.css`; * each names the missing `@import` line to add. A tag the application shadows with its * own component of the same name is reported instead — the application's component wins * in Blade, so the package's stylesheet is moot. * (iii) the application's own CSS (the entry and what it imports outside the package, plus any * `.css` file the scanned paths hold directly), excluding the generated * `material-scheme.css`: a literal colour, radius, shadow, font size, weight, line * height, letter spacing, easing or duration, and a media query at a width other than * 600/840/1200/1600px — each with its token or breakpoint. A value inside * `var(--md-sys-…)` or `calc()` is never flagged, whatever it contains. * (iv) maryUI tags, daisyUI classes, colours the theme does not declare, icon names that are * not Material Symbols, and Blade directives written inside a component tag (where they * do not compile) — plus whatever an application bans on top with `forbidColours()`, * `forbidAbsolutes()`, `forbidOpacityInk()` and `forbid()`. These, and the breakpoint, * scale and colour-value checks, read a line at a time, so a class assembled at runtime * (`'text-'.$tone`) or hidden in a comment stays invisible — the same reason to write * class names out whole. `forbidAbsolutes()` and `forbidOpacityInk()` matter mainly to an * application still migrating off Tailwind, where the package's own opacity-based * disabled state is still the only legitimate use of either. * * expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()]) * ->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 new checks — everything but the breakpoint, scale and * colour-value patterns kept from before 2.0.0 — match only a class already isolated from a * `class`/`@class`/`:class`/`x-bind:class`/`->class()`/`Arr::toCssClasses()` list in a `.blade.php` * file, never a bare word scanned across a whole line, which is what keeps an English sentence * ("this creates a grid of cards") from matching `grid`; and a line-by-line match (used for the * breakpoint, scale and colour-value families, which also have to see a class assembled as a * plain PHP or JS string, as an enum's own literal colour string does) requires the utility's actual * shape — a digit, a known scale step or a colour function — never a bare word. The trade-off: * `Arr::toCssClasses()` and `->class()` outside a `.blade.php` file, and a bare-word utility * (`flex`, `hidden`) anywhere but inside a class list, are invisible to this guard. */ class DesignGuard { protected const string UTILITY = '(?:bg|text|border(?:-[trblxyse])?|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)-(?:50|[1-9]00|950)'; protected const string DAISY_COLOURS = '(?:base-(?:100|200|300|content)|(?:primary|secondary|accent|neutral|info|success|warning|error)-content|accent|neutral)'; /** * daisyUI's component classes. Tailwind utilities that share a name — `collapse`, `table`, * `select-none`, `tab-4` — and the package's own `link` are not on it. */ protected const string DAISY_CLASSES = '/^(?:btn|badge|card|alert|modal|drawer|dropdown|menu|navbar|footer|hero|stats?|tabs?|tooltip|toast|toggle|checkbox|radio|range|rating|input|select|textarea|file-input|fieldset|label|join|kbd|loading|progress|radial-progress|skeleton|steps?|timeline|swap|indicator|avatar|divider|dock|fab|status|validator|breadcrumbs|carousel|chat|countdown|diff|stack|theme-controller)(?:-[a-z0-9-]+)?$' .'|^(?:table-(?:zebra|xs|sm|md|lg|xl|pin-rows|pin-cols)|collapse-(?:arrow|plus|title|content|open|close)|list-row|link-(?:primary|secondary|accent|neutral|info|success|warning|error))$/'; protected const string TAILWIND_LOOKALIKES = '/^(?:select-(?:none|text|all|auto)|tab-\d+)$/'; /** 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', ]; /** 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, ]; /** * Tailwind's flex, grid and gap utilities: ``, `` and `` and their * `gap`/`align`/`justify`/`columns` props replace every one of them. */ protected const string LAYOUT_UTILITY = '/^(?:flex|inline-flex|grid|inline-grid|contents|flex-(?:row|row-reverse|col|col-reverse|wrap|wrap-reverse|nowrap|1|auto|initial|none)|grid-cols-\d+|grid-rows-\d+|col-span-\d+|row-span-\d+|(?:items|justify|content|place-items|place-content|place-self|self)-[a-z]+|gap(?:-[xy])?-[\w.\/]+)$/'; /** Tailwind's padding, margin and gap-adjacent space-between utilities. */ protected const string SPACING_UTILITY = '/^(?:[pm][trblxyse]?|space-[xy])-[\w.\/-]+$/'; /** Tailwind's width and height utilities. M3 keeps no size scale of its own. */ protected const string SIZING_UTILITY = '/^(?:min-|max-)?(?:w|h|size)-[\w.\/%-]+$/'; /** * Bare display utilities: no digit or bracket sets them apart from an English word, so these * are matched only as a whole class-list token (never scanned line by line), which is what * keeps "hidden" or "block" in running text from matching. */ protected const array DISPLAY_UTILITY = ['block', 'inline-block', 'inline', 'invisible', 'visible']; /** Text-layout utilities with a direct `md-*` replacement (text.css). */ protected const array TEXT_LAYOUT_UTILITY = [ 'text-left' => 'md-text-start', 'text-center' => 'md-text-center', 'text-right' => 'md-text-end', 'truncate' => 'md-truncate', 'line-clamp-2' => 'md-line-clamp-2', 'line-clamp-3' => 'md-line-clamp-3', 'whitespace-nowrap' => 'md-nowrap', 'sr-only' => 'md-visually-hidden', 'tabular-nums' => 'md-tabular', ]; /** An ink role Tailwind's `text-*` utility named, and the `md-ink-*` class that replaces it. */ protected const array INK_ROLE = [ 'on-surface' => 'md-ink', 'on-surface-variant' => 'md-ink-variant', 'outline' => 'md-ink-quiet', 'primary' => 'md-ink-primary', 'error' => 'md-ink-error', 'success' => 'md-ink-success', 'warning' => 'md-ink-warning', 'info' => 'md-ink-info', 'inverse-on-surface' => 'md-ink-inverse', ]; /** The named colours a browser understands, other than `transparent` and `currentColor`. */ protected const string CSS_NAMED_COLOURS = 'aliceblue|antiquewhite|aqua|aquamarine|azure|beige|bisque|black|blanchedalmond|blue|blueviolet|brown|burlywood|cadetblue|chartreuse|chocolate|coral|cornflowerblue|cornsilk|crimson|cyan|darkblue|darkcyan|darkgoldenrod|darkgray|darkgreen|darkgrey|darkkhaki|darkmagenta|darkolivegreen|darkorange|darkorchid|darkred|darksalmon|darkseagreen|darkslateblue|darkslategray|darkslategrey|darkturquoise|darkviolet|deeppink|deepskyblue|dimgray|dimgrey|dodgerblue|firebrick|floralwhite|forestgreen|fuchsia|gainsboro|ghostwhite|gold|goldenrod|gray|green|greenyellow|grey|honeydew|hotpink|indianred|indigo|ivory|khaki|lavender|lavenderblush|lawngreen|lemonchiffon|lightblue|lightcoral|lightcyan|lightgoldenrodyellow|lightgray|lightgreen|lightgrey|lightpink|lightsalmon|lightseagreen|lightskyblue|lightslategray|lightslategrey|lightsteelblue|lightyellow|lime|limegreen|linen|magenta|maroon|mediumaquamarine|mediumblue|mediumorchid|mediumpurple|mediumseagreen|mediumslateblue|mediumspringgreen|mediumturquoise|mediumvioletred|midnightblue|mintcream|mistyrose|moccasin|navajowhite|navy|oldlace|olive|olivedrab|orange|orangered|orchid|palegoldenrod|palegreen|paleturquoise|palevioletred|papayawhip|peachpuff|peru|pink|plum|powderblue|purple|rebeccapurple|red|rosybrown|royalblue|saddlebrown|salmon|sandybrown|seagreen|seashell|sienna|silver|skyblue|slateblue|slategray|slategrey|snow|springgreen|steelblue|tan|teal|thistle|tomato|turquoise|violet|wheat|white|whitesmoke|yellow|yellowgreen'; /** A property whose literal value check (iii) reports, and the token that replaces it. */ protected const array LITERAL_PROPERTIES = [ 'border-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'], 'border-top-left-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'], 'border-top-right-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'], 'border-bottom-right-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'], 'border-bottom-left-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'], 'border-start-start-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'], 'border-start-end-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'], 'border-end-start-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'], 'border-end-end-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'], 'box-shadow' => ['kind' => 'shadow', 'hint' => 'use `var(--md-sys-elevation-*)`'], 'font-size' => ['kind' => 'font size', 'hint' => 'use `var(--md-sys-typescale-*)`, which sets size, line height and weight together'], 'font-weight' => ['kind' => 'font weight', 'hint' => 'use `var(--md-sys-typescale-*)` or `var(--md-sys-typescale-emphasized-*)`'], 'line-height' => ['kind' => 'line height', 'hint' => 'use `var(--md-sys-typescale-*)`, which sets it with the size'], 'letter-spacing' => ['kind' => 'letter spacing', 'hint' => 'use `var(--md-sys-typescale-*-tracking)`'], 'transition-timing-function' => ['kind' => 'easing', 'hint' => 'use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`'], 'animation-timing-function' => ['kind' => 'easing', 'hint' => 'use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`'], 'transition-duration' => ['kind' => 'duration', 'hint' => 'use `var(--md-sys-motion-…-duration)`, paired with its easing'], 'animation-duration' => ['kind' => 'duration', 'hint' => 'use `var(--md-sys-motion-…-duration)`, paired with its easing'], ]; /** @var list */ protected array $forbidden = []; /** @var list */ protected array $forbiddenColours = []; protected bool $forbiddenAbsolutes = false; protected bool $forbiddenOpacityInk = false; protected ?string $cssEntry = 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. * * @param list $roles */ public function forbidColours(array $roles): static { $this->forbiddenColours = [...$this->forbiddenColours, ...$roles]; return $this; } /** * Also ban the two absolutes. M3 paints with roles only: the white of a light page is * `surface-container-lowest`, the black of a dark one `surface-dim`, and the ink on a filled * button is its `on-` role. Off by default, because a logo, a scrim or a print stylesheet * sometimes does mean the absolute — and because, until an application finishes leaving * Tailwind, `bg-white`/`text-black` may still be sitting in an untouched view. */ public function forbidAbsolutes(): static { $this->forbiddenAbsolutes = true; return $this; } /** * Also ban opacity as emphasis on ink: M3 says secondary text is a role — on-surface-variant * for supporting text, outline for the quietest — never a faded on-surface. It reserves two * opacities for the disabled state, 38 % on content and 12 % on a container, and the * package's own disabled styles are written with those two, which is why this is off by * default; an application that draws its disabled states from the components does not need * either opacity of its own. */ public function forbidOpacityInk(): static { $this->forbiddenOpacityInk = true; 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 `` — and every `->links()` call, against * `$cssEntry`'s `@import` graph (followed through each package file's own imports). A missing * one names the `@import` line to add; a tag the application shadows with its own component * of the same name is reported instead, since the package's stylesheet is then moot. Also * turns on check (iii), the application's own CSS: `$cssEntry` and what it imports outside * the package become part of what that check reads, alongside any `.css` file `scan()`'s own * paths hold directly. */ public function missingStylesheets(string $cssEntry): static { $this->cssEntry = $cssEntry; return $this; } /** * @return list */ public function violations(): array { $violations = []; $resolved = $this->cssEntry !== null ? array_fill_keys(Stylesheets::resolvedFiles([$this->cssEntry]), true) : []; if ($this->cssEntry !== null) { $foundation = static::packageCssRoot().'/foundation.css'; if (! isset($resolved[$foundation])) { $violations[] = sprintf( '%s:1 the CSS entry never imports `foundation.css`, required by every package stylesheet — add `@import \'%s\';`', $this->relative($this->cssEntry), $this->importLine($foundation), ); } } foreach ($this->files() as $file) { $contents = (string) file_get_contents($file->getPathname()); $where = $this->relative($file->getPathname()); $isBlade = str_ends_with($file->getFilename(), '.blade.php'); if ($isBlade) { $contents = $this->withoutBladeComments($contents); foreach ($this->literalClasses($contents) as [$line, $class]) { $bare = $this->withoutVariantPrefix($class); if (preg_match(self::DAISY_CLASSES, $bare) === 1 && preg_match(self::TAILWIND_LOOKALIKES, $bare) !== 1) { $violations[] = "{$where}:{$line} daisyUI class `{$bare}`"; } if (($hint = $this->tailwindFamilyHint($class)) !== null) { $violations[] = "{$where}:{$line} {$hint}"; } } 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"; } if ($this->cssEntry !== null) { foreach ($this->missingStylesheetViolations($contents, $resolved) as [$line, $what]) { $violations[] = "{$where}:{$line} {$what}"; } } } foreach (explode("\n", $contents) as $index => $text) { $line = $index + 1; if ($isBlade && preg_match_all('/colourPattern(), $text, $matches)) { foreach ($matches[0] as $class) { $violations[] = "{$where}:{$line} colour the theme does not declare `{$class}`"; } } foreach ($this->offTheTokens($text) as $what) { $violations[] = "{$where}:{$line} {$what}"; } foreach ($this->forbidden as $rule) { if (preg_match($rule['pattern'], $text) === 1) { $violations[] = "{$where}:{$line} {$rule['reason']}"; } } } } return [...$violations, ...$this->applicationCssViolations()]; } /** * @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 { $names = [self::PALETTE, self::DAISY_COLOURS]; foreach ($this->forbiddenColours as $role) { $role = preg_quote($role, '/'); $names[] = "(?:on-)?{$role}(?:-container)?"; } return '/(? */ 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 = 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, tracking, easing and duration utilities * Tailwind shipped and M3's own scales replace. * * @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)' .'|ease-(?in-out|linear|in|out)' .'|duration-(?\d+)' .')(?![\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', isset($match['easing']) => 'pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition`', isset($match['duration']) => 'pair `var(--md-sys-motion-…-duration)` with its easing in your own `transition`', default => 'use one of the `md-type-*` classes (text.css), which set size, line height and tracking together', }; } /** * Colours written as a value rather than a role: an arbitrary one always, the two absolutes * and opacity on ink when the application asks for them. * * @return list */ protected function colourValues(string $text): array { $found = []; preg_match_all(self::ARBITRARY_COLOUR, $text, $matches); foreach ($matches[0] as $class) { $found[] = "arbitrary colour `{$class}`, use an M3 role"; } if ($this->forbiddenAbsolutes) { preg_match_all(self::ABSOLUTE_COLOUR, $text, $matches); foreach ($matches[0] as $class) { $found[] = "absolute colour `{$class}`, use an M3 role"; } } if ($this->forbiddenOpacityInk) { preg_match_all(self::OPACITY_INK, $text, $matches); foreach ($matches[0] as $class) { $found[] = "opacity on ink `{$class}`, use a role (`text-on-surface-variant`, `text-outline`)"; } } return $found; } /** * `$token` with a variant prefix — a breakpoint, a stacked pseudo-class chain — and a leading * `!` dropped, so the daisyUI check can still recognise a variant-prefixed daisy class for * what it is underneath. `tailwindFamilyHint()` reports the prefix itself (see the variant * family below), so it works from the token `literalClasses()` returns, unstripped. */ protected function withoutVariantPrefix(string $token): string { return ltrim((string) preg_replace('/^.*:/', '', $token), '!'); } /** * A Tailwind-shaped class token's 2.0.0 replacement, or null when the token is not this * guard's to report: it is not Tailwind-shaped at all (an application's own class, an ARIA or * data token, a plain word), it is on the small set nothing here ever flags (`md-*` — the * text classes and the shared interaction hooks), or the application's own CSS declares it * (`applicationClasses()`). Only ever called with a token `literalClasses()` already isolated * from a class list, which is what makes it safe to match a bare word like `flex` or `hidden` * (see the class header's false-positive note). */ protected function tailwindFamilyHint(string $token): ?string { if ($token === '' || str_starts_with($token, 'md-') || isset($this->applicationClasses()[$token])) { return null; } $token = rtrim($token, '!'); if (preg_match(self::ARBITRARY_COLOUR, $token) === 1) { return null; // colourValues() already reports this, line by line. } if (str_contains($token, ':')) { $lead = explode(':', $token, 2)[0]; $bare = str_starts_with($lead, 'max-') ? substr($lead, 4) : $lead; if (isset(self::WINDOW_CLASSES[$bare])) { return null; // breakpointPrefixes() already names the M3 breakpoint for this one. } return "Tailwind variant class `{$token}` compiles to nothing — a state is `:has()`/`aria-*`/`data-md-*` in your own CSS, a breakpoint a layout component's `hide-below`/`hide-from`/`stack-below` prop"; } if (preg_match('/\[[^\]]*\]/', $token) === 1) { return "Tailwind arbitrary value `{$token}` compiles to nothing — write the literal value in your own CSS, or use an M3 token"; } if (preg_match(self::LAYOUT_UTILITY, $token) === 1) { return "Tailwind layout utility `{$token}` compiles to nothing — use ``, `` or `` and their `gap`/`align`/`justify`/`columns` props"; } if (preg_match(self::SPACING_UTILITY, $token) === 1) { return "Tailwind spacing utility `{$token}` compiles to nothing — use a layout component's `gap`/`padding` prop (``, ``), or `var(--md-sys-measurement-space*)` in your own CSS"; } if (preg_match(self::SIZING_UTILITY, $token) === 1) { return "Tailwind sizing utility `{$token}` compiles to nothing — M3 keeps no size scale; write the literal length in your own CSS, or use ``/`` where it fits"; } if ($token === 'hidden') { return "Tailwind's `hidden` compiles to nothing — use a layout component's `hide-below`/`hide-from` prop, or the reset's `[hidden]` attribute"; } if (in_array($token, self::DISPLAY_UTILITY, true)) { return "Tailwind display utility `{$token}` compiles to nothing — write the `display` rule in your own CSS"; } if (isset(self::TEXT_LAYOUT_UTILITY[$token])) { return "Tailwind's `{$token}` compiles to nothing — use `".self::TEXT_LAYOUT_UTILITY[$token].'` (text.css)'; } if (preg_match('/^text-(?on-surface(?:-variant)?|outline|primary|error|success|warning|info|inverse-on-surface)$/', $token, $m) === 1) { return "Tailwind ink role `{$token}` compiles to nothing — use `".self::INK_ROLE[$m['role']].'` (text.css)'; } if (preg_match('/^(?:border(?:-[trblxyse])?|divide|ring)-outline(?:-variant)?$/', $token) === 1) { return "Tailwind line role `{$token}` compiles to nothing — use `` or `` for a line, not a border utility"; } if (preg_match('/^bg-(?surface(?:-dim|-bright|-container(?:-lowest|-low|-high|-highest)?)?)$/', $token, $m) === 1) { return "Tailwind surface role `{$token}` compiles to nothing — use ``"; } if (preg_match('/^(?:bg|border(?:-[trblxyse])?|ring|fill|stroke|divide|decoration|accent|caret|outline)-(?:on-)?(?:primary|secondary|tertiary|error|success|warning|info)(?:-container)?$/', $token) === 1) { return "Tailwind role utility `{$token}` compiles to nothing — use `var(--md-sys-color-*)` in your own CSS"; } return null; } /** * Check (iii): literal design values in the application's own CSS. `var(--md-sys-…)` and * `calc()` are blanked out first (see `withoutTokenFunctions()`), so a value built from tokens * is invisible to every pattern below, whatever it contains; `0`, `none` and `inherit` are * always fine. * * @return list */ protected function applicationCssViolations(): array { $violations = []; foreach ($this->applicationStylesheets() as $file) { $css = $this->withoutTokenFunctions($this->withoutCssComments((string) file_get_contents($file))); $where = $this->relative($file); foreach ([ ...$this->literalColours($css), ...$this->literalDeclarations($css), ...$this->offScaleMediaQueries($css), ] as [$line, $what]) { $violations[] = "{$where}:{$line} {$what}"; } } return $violations; } /** * Every literal colour in `$css`: a hex code, a colour function, or a named colour other than * `transparent`/`currentColor` written where a value is expected (after `:`, `,` or `(`) — * which is what keeps a selector like `.red-banner` from matching. * * @return list */ protected function literalColours(string $css): array { $pattern = '/(? [ substr_count(substr($css, 0, $match[1]), "\n") + 1, "literal colour `{$match[0]}` — use `var(--md-sys-color-*)`", ], $matches[0], ); } /** * Every literal value on a property check (iii) knows, plus a `transition`/`animation` * shorthand's embedded easing or duration (its property name alone cannot say which part of * the value is which, so both are searched for whenever either is written literally). * * @return list */ protected function literalDeclarations(string $css): array { $properties = implode('|', array_map(fn (string $p): string => preg_quote($p, '/'), array_keys(self::LITERAL_PROPERTIES))); preg_match_all('/(?'.$properties.')\s*:\s*(?[^;{}]+);/i', $css, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); $found = []; foreach ($matches as $match) { $property = strtolower($match['property'][0]); $value = trim($match['value'][0]); if ($this->isSafeLiteralValue($value)) { continue; } $line = substr_count(substr($css, 0, $match[0][1]), "\n") + 1; $config = self::LITERAL_PROPERTIES[$property]; $found[] = [$line, "literal {$config['kind']} `{$property}: {$value}` — {$config['hint']}"]; } foreach (['transition', 'animation'] as $shorthand) { preg_match_all('/(?[^;{}]+);/i', $css, $shorthandMatches, PREG_OFFSET_CAPTURE); foreach ($shorthandMatches['value'] as [$value, $offset]) { $line = substr_count(substr($css, 0, $offset), "\n") + 1; if (preg_match('/cubic-bezier\(|(? */ protected function offScaleMediaQueries(string $css): array { preg_match_all('/@media\s*([^{]*)\{/i', $css, $matches, PREG_OFFSET_CAPTURE); $found = []; foreach ($matches[1] as [$prelude, $preludeOffset]) { preg_match_all('/(\d+(?:\.\d+)?)(px|rem|em)\b/i', $prelude, $widths, PREG_OFFSET_CAPTURE); foreach ($widths[0] as $i => [$whole, $widthOffset]) { $number = (float) $widths[1][$i][0]; $unit = strtolower($widths[2][$i][0]); if ($unit === 'px' && in_array((int) $number, [600, 840, 1200, 1600], true) && $number == (int) $number) { continue; } $line = substr_count(substr($css, 0, $preludeOffset + $widthOffset), "\n") + 1; $found[] = [$line, "media query width `{$whole}` is not one of M3's breakpoints — use 600, 840, 1200 or 1600px (medium, expanded, large, extra-large)"]; } } return $found; } /** * The names as one alternation, longest first so that a prefix of another name — `xl` of * `2xl` — cannot win where the pattern is not anchored. * * @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 blanked out, line breaks kept. */ protected function withoutCssComments(string $css): string { return (string) preg_replace_callback( '/\/\*.*?\*\//s', fn (array $match): string => str_repeat("\n", substr_count($match[0], "\n")), $css, ); } /** * `$css` with the argument list of every `var(…)` and `calc(…)` call blanked out (nested * parentheses tracked, so a `calc(var(--x) + 4px)` inside a `var()` fallback stays hidden * too) — what lets check (iii) treat any value built from a token as fine, whatever literal * numbers or colours it wraps. */ protected function withoutTokenFunctions(string $css): string { return $this->maskFunctionCalls($this->maskFunctionCalls($css, 'var'), 'calc'); } protected function maskFunctionCalls(string $css, string $name): string { $pattern = '/(? 0) { if ($css[$i] === '(') { $depth++; } elseif ($css[$i] === ')') { $depth--; } $i++; } $inner = substr($css, $open + 1, $i - $open - 2); $blank = (string) preg_replace('/[^\n]/', ' ', $inner); $css = substr($css, 0, $open + 1).$blank.substr($css, $i - 1); $offset = $i; } return $css; } /** * Every class written out literally: in `class="…"`, and in the string keys and values of * `:class`/`x-bind:class`, `@class([...])`, `->class([...])` and `Arr::toCssClasses([...])`. * Echoes inside a list are skipped. A token is returned exactly as written, variant prefix * (`sm:`, `hover:`) and all — `withoutVariantPrefix()` strips one for the daisyUI check, which * needs to see through it; `tailwindFamilyHint()` needs to see it. * * @return list */ protected function literalClasses(string $contents): array { $lists = []; preg_match_all('/(?class\(\[(.*?)\]\)|Arr::toCssClasses\(\[(.*?)\]\))/s', $contents, $bindings, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); foreach ($bindings as $binding) { [$body, $offset] = array_values(array_filter(array_slice($binding, 1), fn (array $group): bool => $group[1] !== -1))[0] ?? ['', 0]; preg_match_all("/'([^']*)'/", $body, $strings, PREG_OFFSET_CAPTURE); foreach ($strings[1] as [$list, $inner]) { $lists[] = [$list, $offset + $inner]; } } $found = []; foreach ($lists as [$list, $offset]) { $line = substr_count(substr($contents, 0, $offset), "\n") + 1; $list = (string) preg_replace('/\{\{.*?\}\}|\{!!.*?!!\}/', ' ', $list); foreach (preg_split('/\s+/', $list, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $token) { if (preg_match('/[$@(){}]/', $token) !== 1) { $found[] = [$line, $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; } /** * 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, and * `` is the maryUI check's concern. * * @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') || str_starts_with($tag, 'x-mary-')) { continue; } $name = substr($tag, 2); $spelling = 'plain'; if (str_contains($name, '::')) { [$namespace, $name] = explode('::', $name, 2); if ($namespace === 'livewire-material') { $spelling = 'namespace'; } elseif ($prefix !== '' && $namespace === $prefix) { $spelling = 'prefix'; } else { continue; } } elseif (! static::isPackageTag($name)) { continue; } $found[] = [substr_count(substr($contents, 0, $offset), "\n") + 1, $name, $spelling]; } return $found; } /** * Whether the application defines its own component of this name — an anonymous * `resources/views/components/.blade.php`, found the way Blade itself falls back to it * (the base view finder's `components.`, which `resource_path('views/components')` * feeds by default), or an `App\View\Components` class. Either wins over this package's * `` in Blade's own resolution order. */ protected function shadowedByApplication(string $name): bool { return view()->exists('components.'.$name) || class_exists('App\\View\\Components\\'.Str::studly($name)); } /** * Every `->links()` call in `$contents` (Laravel's or Livewire's paginator, rendered outside * a package component), each as the line it is on. * * @return list */ protected function paginationUsages(string $contents): array { preg_match_all('/->links\s*\(/', $contents, $matches, PREG_OFFSET_CAPTURE); return array_map(fn (array $match): int => substr_count(substr($contents, 0, $match[1]), "\n") + 1, $matches[0]); } /** * Check (ii)'s findings for one file: a package tag whose stylesheet `$resolved` (every file * `Stylesheets::resolvedFiles()` reached from the CSS entry, keyed by real path) does not * contain, a tag the application shadows, and a `->links()` needing `pagination.css`. * * @param array $resolved * @return list */ protected function missingStylesheetViolations(string $contents, array $resolved): array { $found = []; foreach ($this->packageTagUsages($contents) as [$line, $name, $spelling]) { if ($spelling === 'plain' && $this->shadowedByApplication($name)) { $found[] = [$line, "`` is shadowed by the application's own component of the same name — the package's `` never renders here"]; continue; } $stylesheet = static::packageStylesheetFor($name); if ($stylesheet !== null && ! isset($resolved[$stylesheet])) { $found[] = [$line, sprintf( "`` needs `%s`, missing from %s — add `@import '%s';`", $name, static::packageRelativeName($stylesheet), $this->relative($this->cssEntry), $this->importLine($stylesheet), )]; } } $pagination = static::packageCssRoot().'/components/pagination.css'; foreach ($this->paginationUsages($contents) as $line) { if (! isset($resolved[$pagination])) { $found[] = [$line, sprintf( "`->links()` needs `components/pagination.css`, missing from %s — add `@import '%s';`", $this->relative($this->cssEntry), $this->importLine($pagination), )]; } } return $found; } /** * Every `.css` file this guard reads for check (iii): any the scanned paths hold directly, * plus — once `missingStylesheets()` names a CSS entry — every file its `@import` graph * reaches outside the package. The generated `material-scheme.css` is excluded either way. * * @return list */ protected function applicationStylesheets(): array { $files = []; foreach ($this->paths as $path) { if (is_file($path)) { if (str_ends_with($path, '.css')) { $files[] = (string) realpath($path); } continue; } if (is_dir($path)) { foreach (Finder::create()->files()->in($path)->name('*.css')->sortByName() as $file) { $files[] = $file->getRealPath(); } } } if ($this->cssEntry !== null) { array_push($files, ...Stylesheets::resolvedFiles([$this->cssEntry])); } return array_values(array_unique(array_filter( $files, fn (string $file): bool => $file !== '' && ! static::isPackageFile($file) && ! $this->isGeneratedScheme($file), ))); } /** * Every class an application's own stylesheets select on — check (i)'s exemption list, so a * class it defines is never reported as a dead Tailwind utility just because it happens to * share a shape with one. * * @return array */ protected function applicationClasses(): array { if ($this->applicationClassesCache !== null) { return $this->applicationClassesCache; } $classes = []; foreach ($this->applicationStylesheets() as $file) { $css = $this->withoutCssComments((string) file_get_contents($file)); preg_match_all('/(?applicationClassesCache = $classes; } protected function isGeneratedScheme(string $file): bool { if (basename($file) === 'material-scheme.css') { return true; } return str_contains((string) file_get_contents($file), "generated by Google's material-color-utilities"); } /** * Whether `$file` sits inside this package's own `resources/css` — the application's stylesheets * (check iii) and its exempt classes never come from the package's own rules. */ protected static function isPackageFile(string $file): bool { $root = static::packageCssRoot(); return $root !== '' && ($file === $root || str_starts_with($file, $root.'/')); } /** * This package's own `resources/css`, from this file's own location — works whether the class * loads from `vendor/nonameweb/livewire-material` (a consuming application) or from this * repository itself, the same trick `Support\Stylesheets::name()` uses. */ protected static function packageCssRoot(): string { return (string) realpath(dirname(__DIR__, 2).'/resources/css'); } /** * This package's own `resources/views/components` — every component and layout component * lives here, so a tag's own view existing there is what "is this a package tag" checks. */ protected static function packageComponentViewRoot(): string { return (string) realpath(dirname(__DIR__, 2).'/resources/views/components'); } protected static function isPackageTag(string $name): bool { $root = static::packageComponentViewRoot(); return $root !== '' && is_file("{$root}/{$name}.blade.php"); } /** * The package stylesheet a component view of this name draws from — `components/.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::packageCssRoot()."/{$group}/{$name}.css"; if (is_file($file)) { return $file; } } return null; } protected static function packageRelativeName(string $file): string { $root = static::packageCssRoot().'/'; return str_starts_with($file, $root) ? substr($file, strlen($root)) : $file; } /** * The `@import` target `$file` should be written as, from the CSS entry's own directory — * computed from the real paths, so it reads `../vendor/nonameweb/livewire-material/…` from a * consuming application and `../resources/css/…` inside this repository, whichever is real. */ protected function importLine(string $file): string { $entryDir = dirname((string) (realpath($this->cssEntry) ?: $this->cssEntry)); return static::relativeImportPath($entryDir, $file); } protected static function relativeImportPath(string $fromDir, string $toFile): string { $from = array_values(array_filter(explode('/', $fromDir), fn (string $part): bool => $part !== '')); $to = array_values(array_filter(explode('/', $toFile), fn (string $part): bool => $part !== '')); $i = 0; while ($i < count($from) && $i < count($to) && $from[$i] === $to[$i]) { $i++; } $path = implode('/', [...array_fill(0, count($from) - $i, '..'), ...array_slice($to, $i)]); return str_starts_with($path, '..') ? $path : "./{$path}"; } protected function relative(string $path): string { $base = rtrim(base_path(), '/').'/'; return str_starts_with($path, $base) ? substr($path, strlen($base)) : $path; } }