Files
livewire-material/src/Testing/DesignGuard.php
T
Andreas Reinhold / reiniandClaude Opus 5 faf5132c7a Name the M3 replacement for every value the theme cleared
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
2026-09-14 05:32:25 +02:00

510 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace NoNameWeb\LivewireMaterial\Testing;
use 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 4088 px from M3's 600/840/1200/
* 1600, so a prefix maps to the class that carries the same intent, never to the same pixel:
* both of its phone breakpoints are M3's medium.
*/
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;
}
}