DesignGuard gains checks for Tailwind's breakpoint prefixes, for the radius, shadow, type-size, weight, leading, tracking, easing and duration scales tokens/theme.css clears, and for a colour written as a value; each violation carries path:line, the token and the M3 utility to use instead. forbidAbsolutes() and forbidOpacityInk() are opt-in like forbidColours(), because the package's own disabled styles are drawn with M3's 38 %/12 % opacities. A fixture and a test per pattern and per hint; the package's own views are asserted against every check but the breakpoint one, which plan step 15 migrates. Plan: docs/plans/material-3-alignment.md, step 9 (core C4a, C13). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
510 lines
19 KiB
PHP
510 lines
19 KiB
PHP
<?php
|
||
|
||
namespace NoNameWeb\LivewireMaterial\Testing;
|
||
|
||
use NoNameWeb\LivewireMaterial\Support\SvgFile;
|
||
use SplFileInfo;
|
||
use Symfony\Component\Finder\Finder;
|
||
|
||
/**
|
||
* Finds what compiles to nothing and fails silently: maryUI tags, daisyUI classes, colours the
|
||
* theme does not declare, icon names that are not Material Symbols, Tailwind's breakpoint
|
||
* prefixes and the radius, shadow, type-size, weight, leading, tracking, easing and duration
|
||
* scales that tokens/theme.css clears for M3's own, and colours written as a literal value
|
||
* instead of a role — plus whatever an application bans on top (roles its own design rules
|
||
* leave out, the two absolutes, opacity used as emphasis, patterns it has retired).
|
||
*
|
||
* expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
|
||
* ->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 = '/(?<![\w-])'.self::UTILITY.'-\[(?:#|rgb|hsl|oklch|color-mix)[^\]\s"\']*\]?/';
|
||
|
||
/** The two absolutes. M3's white is a surface role, not a colour. */
|
||
protected const string ABSOLUTE_COLOUR = '/(?<![\w-])'.self::UTILITY.'-(?:white|black)(?![\w-])/';
|
||
|
||
/** Opacity used as emphasis on ink, which M3 expresses as a role. */
|
||
protected const string OPACITY_INK = '/(?<![\w-])(?:bg|text|border(?:-[trblxyse])?)-[a-z][a-z\d-]*\/\d{1,3}(?![\w-])/';
|
||
|
||
/**
|
||
* Tailwind's breakpoint prefixes, cleared by tokens/theme.css, and the M3 window size class
|
||
* that replaces each. Tailwind's 640/768/1024/1280/1536 are 40–88 px from M3's 600/840/1200/
|
||
* 1600, so a prefix maps to the class that carries the same intent, never to the same pixel:
|
||
* both of its phone breakpoints are M3's medium.
|
||
*/
|
||
protected const array WINDOW_CLASSES = [
|
||
'sm' => '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<array{pattern: string, reason: string}> */
|
||
protected array $forbidden = [];
|
||
|
||
/** @var list<string> */
|
||
protected array $forbiddenColours = [];
|
||
|
||
protected bool $forbiddenAbsolutes = false;
|
||
|
||
protected bool $forbiddenOpacityInk = false;
|
||
|
||
/**
|
||
* @param list<string> $paths
|
||
*/
|
||
final public function __construct(protected array $paths) {}
|
||
|
||
/**
|
||
* @param string|list<string> $paths Files or directories.
|
||
*/
|
||
public static function scan(string|array $paths): static
|
||
{
|
||
return new static((array) $paths);
|
||
}
|
||
|
||
/**
|
||
* Roles the application's own rules leave out, e.g. ['tertiary', 'primary-container'].
|
||
* Their on-roles and containers are forbidden with them.
|
||
*
|
||
* @param list<string> $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<string>
|
||
*/
|
||
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('/<x-mary-[\w.:-]+/', $text, $matches)) {
|
||
foreach ($matches[0] as $tag) {
|
||
$violations[] = "{$where}:{$line} maryUI component `{$tag}`";
|
||
}
|
||
}
|
||
|
||
if (preg_match_all($this->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<SplFileInfo>
|
||
*/
|
||
protected function files(): iterable
|
||
{
|
||
foreach ($this->paths as $path) {
|
||
if (is_file($path)) {
|
||
yield new SplFileInfo($path);
|
||
|
||
continue;
|
||
}
|
||
|
||
if (is_dir($path)) {
|
||
yield from Finder::create()->files()->in($path)->name(['*.php', '*.js', '*.ts'])->sortByName();
|
||
}
|
||
}
|
||
}
|
||
|
||
protected function colourPattern(): string
|
||
{
|
||
$names = [self::PALETTE, self::DAISY_COLOURS];
|
||
|
||
foreach ($this->forbiddenColours as $role) {
|
||
$role = preg_quote($role, '/');
|
||
$names[] = "(?:on-)?{$role}(?:-container)?";
|
||
}
|
||
|
||
return '/(?<![\w-])'.self::UTILITY.'-(?:'.implode('|', $names).')(?![\w-])/';
|
||
}
|
||
|
||
/**
|
||
* Everything on one line that names a value the theme no longer carries, each with the M3
|
||
* utility 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.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
protected function offTheTokens(string $text): array
|
||
{
|
||
return [
|
||
...$this->breakpointPrefixes($text),
|
||
...$this->outsideTheScale($text),
|
||
...$this->colourValues($text),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* `sm:`, `max-2xl:` and the rest, stacked variants included. A prefix must be followed at
|
||
* once by the next variant or the utility itself, so a `md:` that is really an object key
|
||
* (`md: { … }` in a script) is left alone.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
protected function breakpointPrefixes(string $text): array
|
||
{
|
||
static $pattern = null;
|
||
$pattern ??= '/(?<![\w-])(?<max>max-)?(?<name>'.$this->alternation(array_keys(self::WINDOW_CLASSES)).'):(?=[a-z\d!*\[(_-])/';
|
||
|
||
preg_match_all($pattern, $text, $matches, PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL);
|
||
|
||
return array_map(function (array $match): string {
|
||
$max = $match['max'] ?? '';
|
||
|
||
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<string>
|
||
*/
|
||
protected function outsideTheScale(string $text): array
|
||
{
|
||
static $pattern = null;
|
||
$pattern ??= '/(?<![\w-])(?:'
|
||
.'rounded(?<side>-(?:ss|se|ee|es|tl|tr|br|bl|t|r|b|l|s|e))?-(?<corner>'.$this->alternation(array_keys(self::CORNERS)).')'
|
||
.'|shadow-(?<elevation>'.$this->alternation(array_keys(self::ELEVATIONS)).')'
|
||
.'|text-(?<size>xs|sm|base|lg|xl|[2-9]xl)'
|
||
.'|font-(?<weight>thin|extralight|light|normal|medium|semibold|bold|extrabold|black)'
|
||
.'|leading-(?<leading>none|tight|snug|normal|relaxed|loose|\d+(?:\.\d+)?)'
|
||
.'|tracking-(?<tracking>tighter|tight|normal|wider|widest|wide)'
|
||
.'|ease-(?<easing>in-out|linear|in|out)'
|
||
.'|duration-(?<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<array-key, string|null> $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<string>
|
||
*/
|
||
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<string> $names
|
||
*/
|
||
protected function alternation(array $names): string
|
||
{
|
||
usort($names, fn (string $a, string $b): int => strlen($b) <=> strlen($a));
|
||
|
||
return implode('|', array_map(fn (string $name): string => preg_quote($name, '/'), $names));
|
||
}
|
||
|
||
/**
|
||
* Blade comments blanked out, their line breaks kept so line numbers still match.
|
||
*/
|
||
protected function withoutBladeComments(string $contents): string
|
||
{
|
||
return (string) preg_replace_callback(
|
||
'/\{\{--.*?--\}\}/s',
|
||
fn (array $match): string => str_repeat("\n", substr_count($match[0], "\n")),
|
||
$contents,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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<array{0: int, 1: string}>
|
||
*/
|
||
protected function literalClasses(string $contents): array
|
||
{
|
||
$lists = [];
|
||
|
||
preg_match_all('/(?<![\w:.-])class="([^"]*)"/', $contents, $attributes, PREG_OFFSET_CAPTURE);
|
||
|
||
foreach ($attributes[1] as [$list, $offset]) {
|
||
$lists[] = [$list, $offset];
|
||
}
|
||
|
||
preg_match_all('/(?::class="([^"]*)"|@class\(\[(.*?)\]\)|->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: `<x-icon name="…">`, and an `icon="…"` or
|
||
* `icon-right="…"` on any component.
|
||
*
|
||
* @return list<array{0: int, 1: string}>
|
||
*/
|
||
protected function iconNames(string $contents): array
|
||
{
|
||
static $symbols = null;
|
||
$symbols ??= array_flip(SvgFile::symbolNames());
|
||
|
||
$iconTag = 'x-'.config('livewire-material.prefix', '').'icon';
|
||
|
||
preg_match_all('/<(x-[\w.:-]+)((?:[^>"]|"[^"]*")*)>/s', $contents, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
|
||
|
||
$unknown = [];
|
||
|
||
foreach ($tags as $tag) {
|
||
$attributes = $tag[1][0] === $iconTag ? 'name|icon|icon-right' : 'icon|icon-right';
|
||
|
||
preg_match_all('/\s(?:'.$attributes.')="([^"]*)"/', $tag[2][0], $values, PREG_OFFSET_CAPTURE);
|
||
|
||
foreach ($values[1] as [$name, $offset]) {
|
||
if ($name === '' || preg_match('/[{$@]/', $name) === 1 || isset($symbols[$name])) {
|
||
continue;
|
||
}
|
||
|
||
$unknown[] = [substr_count(substr($contents, 0, $tag[2][1] + $offset), "\n") + 1, $name];
|
||
}
|
||
}
|
||
|
||
return $unknown;
|
||
}
|
||
|
||
/**
|
||
* Blade compiles a component tag before its directives, so `<x-icon @class([...])>` or
|
||
* `x-show="ok(@js($v))"` on a component reaches the browser as literal text. Use `:class`
|
||
* and `{{ }}` there instead.
|
||
*
|
||
* @return list<array{0: int, 1: string}>
|
||
*/
|
||
protected function directivesInComponentTags(string $contents): array
|
||
{
|
||
preg_match_all('/<x-[\w.:-]+((?:[^>"]|"[^"]*")*)>/s', $contents, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
|
||
|
||
$found = [];
|
||
|
||
foreach ($tags as $tag) {
|
||
preg_match_all('/(?<![\w@])@(class|style|js|json|if|unless|isset|foreach|disabled|checked|selected|readonly|required|entangle)\b/', $tag[1][0], $directives, PREG_OFFSET_CAPTURE);
|
||
|
||
foreach ($directives[0] as [$directive, $offset]) {
|
||
$found[] = [substr_count(substr($contents, 0, $tag[1][1] + $offset), "\n") + 1, $directive];
|
||
}
|
||
}
|
||
|
||
return $found;
|
||
}
|
||
|
||
protected function relative(string $path): string
|
||
{
|
||
$base = rtrim(base_path(), '/').'/';
|
||
|
||
return str_starts_with($path, $base) ? substr($path, strlen($base)) : $path;
|
||
}
|
||
}
|