Add the Material 3 Expressive foundation
tests / lint (push) Successful in 1m0s
tests / feature (8.4) (push) Successful in 1m0s
tests / feature (8.5) (push) Successful in 1m1s
tests / browser (safari, webkit) (push) Successful in 1m59s
tests / browser (chrome, chromium) (push) Successful in 1m49s
tests / browser (firefox, firefox) (push) Successful in 1m54s
tests / lint (push) Successful in 1m0s
tests / feature (8.4) (push) Successful in 1m0s
tests / feature (8.5) (push) Successful in 1m1s
tests / browser (safari, webkit) (push) Successful in 1m59s
tests / browser (chrome, chromium) (push) Successful in 1m49s
tests / browser (firefox, firefox) (push) Successful in 1m54s
Colour, shape, type, elevation and motion as tokens and Tailwind utilities; `php artisan material:scheme`, which generates an app's colour roles with Google's material-color-utilities (spec 2025); the theme head script with light, dark and system and its Alpine store; Google Sans Flex; every Material Symbol (4,135, outlined and filled) drawn by <x-icon> without blade-icons; all 35 M3 Expressive shapes, ported from androidx, as <x-shape>; the x-figure directive; the Toasts concern; DesignGuard for applications' tests; and a showcase with every token, both themes side by side and an icon search. Colour utilities are `@theme inline`, so a section with its own data-theme repaints; without it they resolve once on :root. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
co-authored by
Claude Opus 5
parent
9b53891a8e
commit
b48e879254
@@ -0,0 +1,259 @@
|
||||
<?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 — plus whatever an
|
||||
* application bans on top (roles its own design rules leave out, patterns it has retired).
|
||||
*
|
||||
* expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
|
||||
* ->forbidColours(['tertiary', 'primary-container'])
|
||||
* ->violations())->toBe([]);
|
||||
*
|
||||
* Each violation is "path:line what", with the path relative to the base path. 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+)$/';
|
||||
|
||||
/** @var list<array{pattern: string, reason: string}> */
|
||||
protected array $forbidden = [];
|
||||
|
||||
/** @var list<string> */
|
||||
protected array $forbiddenColours = [];
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (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->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-])/';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
protected function relative(string $path): string
|
||||
{
|
||||
$base = rtrim(base_path(), '/').'/';
|
||||
|
||||
return str_starts_with($path, $base) ? substr($path, strlen($base)) : $path;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user