forbidColours(['tertiary', 'primary-container']) * ->forbidAbsolutes() * ->forbidOpacityInk() * ->violations())->toBe([]); * * Each violation is "path:line what", with the path relative to the base path; where there is * an M3 utility to use instead, the line names it. It reads the source, so a class assembled at * runtime (`'text-'.$tone`) is invisible to it — which is one more reason to write class names * out whole, the only form Tailwind compiles anyway. */ 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', ]; /** Tailwind's radius scale and the M3 corner that 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 that replaces each (styles §Elevation). */ protected const array ELEVATIONS = [ '2xs' => 1, 'xs' => 1, 'sm' => 1, 'md' => 2, 'lg' => 3, 'xl' => 4, '2xl' => 5, ]; /** @var list */ protected array $forbidden = []; /** @var list */ protected array $forbiddenColours = []; protected bool $forbiddenAbsolutes = false; protected bool $forbiddenOpacityInk = false; /** * @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. */ 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; } /** * @return list */ public function violations(): array { $violations = []; 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]) { if (preg_match(self::DAISY_CLASSES, $class) === 1 && preg_match(self::TAILWIND_LOOKALIKES, $class) !== 1) { $violations[] = "{$where}:{$line} daisyUI class `{$class}`"; } } 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"; } } 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; } /** * @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'] ?? ''; return "Tailwind breakpoint `{$max}{$match['name']}:`, use `{$max}".self::WINDOW_CLASSES[$match['name']].':`'; }, $matches); } /** * The radius, shadow, type-size, weight, leading, tracking, easing and duration utilities * Tailwind ships and tokens/theme.css clears. * * @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]}`, use ".$this->scaleReplacement($match), $matches, ); } /** * The M3 utility for one match of the scale pattern. A type size, a leading and a tracking * are all one type style: the `type-*` utilities set the three together, which is what keeps * a line of text on the typescale. * * @param array $match */ protected function scaleReplacement(array $match): string { return match (true) { isset($match['corner']) => '`rounded'.($match['side'] ?? '').'-corner-'.self::CORNERS[$match['corner']].'`', isset($match['elevation']) => '`shadow-elevation-'.self::ELEVATIONS[$match['elevation']].'`', isset($match['weight']) => 'a `type-emphasized-*` style', isset($match['easing']) => '`ease-standard` or an `ease-spatial-*`/`ease-effects-*` with its duration', isset($match['duration']) => '`duration-(--md-sys-motion-…-duration)` paired with its easing', default => 'a `type-*` style', }; } /** * 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; } /** * 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, ); } /** * Every class written out literally: in `class="…"`, and in the string keys and values of * `:class`, `@class([...])` and `->class([...])`. Echoes inside a list are skipped. * * @return list */ protected function literalClasses(string $contents): array { $lists = []; preg_match_all('/(?class\(\[(.*?)\]\))/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, ltrim((string) preg_replace('/^.*:/', '', $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('/(?