An over-engineering audit of the whole tree, applied in five reviewed batches. Behaviour stays the same except where UPGRADE.md says otherwise. PHP: the showcase and error-page stylesheets are prebuilt into resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import (first occurrence kept, the order an application's build gives), instead of Stylesheets::bundle() inlining imports on every request; only the import walk DesignGuard needs stays. SchemeStylesheet::withProfiles() replaces three copies of the scheme-plus-profiles loop, material:scheme leaves spec and contrast checks to the node script that already made them, and the error page's scheme cache, the hashed view namespace, the translations path with no lang/ folder and DesignGuard's 1.x-name hints are gone. JS: the androidx shape port progress.js and both bin scripts each carried lives once in resources/js/shapes.js (the generated SVGs are unchanged); util.js holds ringIndex(), ms(), reopenGuard() and remember(), which were written out several times; listeners are released through AbortController; tooltip.js's hoverPopover() serves the rich tooltip too. CSS: every rule for an element inside the navigation rail queries `--md-navigation-rail-value` instead of repeating the seven collapsed conditions under five media branches; badge, alert, progress, slider and button read one non-inheriting colour-role table (components/color.css); the dialog chrome, the submenu's popover chrome, the chip's state layer and touch target, and the visually-hidden inputs use the shared rules they copied; foundation/tokens.css is folded into foundation.css. Views: Support\Field and Support\Link replace the error-key, bound-value and link-attribute blocks copied into the fields and link components; the timepicker period group, the menu filter and the showcase head are partials; the datepicker's steppers and entry fields are loops; component docblocks no longer restate SKILL.md. Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces four per-group files, DesignGuardTest and the layout-component tests use datasets, browser tests share one ready() helper, CSS parsing lives in ComponentStylesheet alone. docs/audits and the finding IDs citing it are removed, as are pestphp/pest-plugin-laravel, the unused composer scripts and check:font; the lint job runs in the feature job, which now installs node packages so the prebuilt-stylesheet staleness test runs in CI. Feature suite 1177 passed, Chrome browser suite 299 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
178 lines
6.1 KiB
PHP
178 lines
6.1 KiB
PHP
<?php
|
|
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Str;
|
|
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
|
|
|
|
/**
|
|
* The texts Boost copies into an application: the two guidelines every session reads, and the two
|
|
* skills. Each teaches the vocabulary an agent then writes, so none may name a class or variant
|
|
* the package's stylesheets do not define.
|
|
*/
|
|
const BOOST_TEXTS = [
|
|
'resources/boost/guidelines/core.blade.php',
|
|
'resources/boost/guidelines/material-3.blade.php',
|
|
'resources/boost/skills/livewire-material-development/SKILL.md',
|
|
'resources/boost/skills/material-3-design/SKILL.md',
|
|
];
|
|
|
|
/**
|
|
* The one section that names Tailwind's utilities on purpose: the design guard's table of what it
|
|
* reports, each beside its replacement.
|
|
*/
|
|
const BOOST_SECTIONS_NAMING_WHAT_FAILS = ['Testing the design'];
|
|
|
|
/**
|
|
* A text's code, line for line, as the design guard reads an application: fenced Blade, HTML, PHP
|
|
* and JS into a view, fenced CSS into a stylesheet, and every inline span that reads as a class
|
|
* list (`bg-primary`, `medium:hidden`, `md-ink-variant`) as a `class` attribute on its own line.
|
|
* Prose, props, attributes and custom properties are left out.
|
|
*
|
|
* @return array{view: string, css: string}
|
|
*/
|
|
function boostTextAsCode(string $text): array
|
|
{
|
|
$view = [];
|
|
$css = [];
|
|
$fence = null;
|
|
$skipping = false;
|
|
|
|
foreach (explode("\n", $text) as $line) {
|
|
$viewLine = '';
|
|
$cssLine = '';
|
|
|
|
if (preg_match('/^```\s*([a-z]*)/', $line, $open) === 1) {
|
|
$fence = $fence === null ? ($open[1] ?: 'text') : null;
|
|
} elseif ($fence !== null) {
|
|
if (! $skipping && $fence === 'css') {
|
|
$cssLine = $line;
|
|
} elseif (! $skipping && in_array($fence, ['blade', 'html', 'php', 'js'], true)) {
|
|
$viewLine = $line;
|
|
}
|
|
} else {
|
|
if (preg_match('/^## (.+)$/', $line, $heading) === 1) {
|
|
$skipping = in_array($heading[1], BOOST_SECTIONS_NAMING_WHAT_FAILS, true);
|
|
}
|
|
|
|
if (! $skipping) {
|
|
preg_match_all('/`([^`]+)`/', $line, $spans);
|
|
|
|
$viewLine = collect($spans[1])
|
|
->map(fn (string $span): ?string => match (true) {
|
|
str_contains($span, '<') => $span,
|
|
boostSpanIsClassList($span) => '<div class="'.$span.'"></div>',
|
|
default => null,
|
|
})
|
|
->filter()
|
|
->implode(' ');
|
|
}
|
|
}
|
|
|
|
$view[] = $viewLine;
|
|
$css[] = $cssLine;
|
|
}
|
|
|
|
return ['view' => implode("\n", $view), 'css' => implode("\n", $css)];
|
|
}
|
|
|
|
/**
|
|
* The hyphenated names the texts also write alone in a span for something that is not a class:
|
|
* every property the package's stylesheets declare (`border-color`), every component prop
|
|
* (`placeholder-value`) and every colour role (`outline-variant`). Each is shaped like a colour
|
|
* utility on a name the guard does not know, which is what an application's own theme colour
|
|
* (`bg-brand`) looks like.
|
|
*
|
|
* @return array<string, true>
|
|
*/
|
|
function boostNamesThatAreNotClasses(): array
|
|
{
|
|
static $names = null;
|
|
|
|
if ($names !== null) {
|
|
return $names;
|
|
}
|
|
|
|
$names = [];
|
|
$root = __DIR__.'/../../resources';
|
|
|
|
foreach (File::allFiles("{$root}/css") as $file) {
|
|
preg_match_all('/(?<![\w-])([a-z][a-z-]*-[a-z-]+)\s*:\s*[^;{}]*[;}]|--md-sys-color-([a-z-]+)/', $file->getContents(), $matches);
|
|
|
|
foreach ([...$matches[1], ...$matches[2]] as $name) {
|
|
$names[$name] = true;
|
|
}
|
|
}
|
|
|
|
foreach (File::files("{$root}/views/components") as $file) {
|
|
if (preg_match('/@props\(\[(.*?)\]\)/s', $file->getContents(), $props) === 1) {
|
|
preg_match_all("/^\s*'(\w+)'/m", $props[1], $keys);
|
|
|
|
foreach ($keys[1] as $key) {
|
|
$names[Str::kebab($key)] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $names;
|
|
}
|
|
|
|
/**
|
|
* Whether an inline span reads as a class list. Not: a CSS declaration (`font: var(…)`), a
|
|
* command (`php artisan …`), a single bare word, which is as often a role, prop or value
|
|
* (`outline`, `link`, `hidden`) as a utility, or a single property, prop or role name
|
|
* (`border-color`, `placeholder-value`, `outline-variant`).
|
|
*/
|
|
function boostSpanIsClassList(string $span): bool
|
|
{
|
|
$tokens = preg_split('/\s+/', trim($span));
|
|
|
|
if (count($tokens) === 1 && (preg_match('/[-:]/', $tokens[0]) !== 1 || isset(boostNamesThatAreNotClasses()[$tokens[0]]))) {
|
|
return false;
|
|
}
|
|
|
|
if (preg_match('/^(?:php|npm|npx|composer|git)\b|\b(?:var|calc|color-mix)\(|^[a-z-]+:\s/', $span) === 1) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($tokens as $token) {
|
|
if (preg_match('/^(?:--|data-|aria-|wire:|x-|@|\$)/', $token) === 1 || str_contains($token, '::')) {
|
|
return false;
|
|
}
|
|
|
|
if (preg_match('/^[a-z0-9][a-z0-9:\/.\[\]()#%_-]*$/', $token) !== 1) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
it('teaches only classes and variants the stylesheets define', function () {
|
|
$directory = sys_get_temp_dir().'/livewire-material-boost-'.bin2hex(random_bytes(4));
|
|
$files = [];
|
|
|
|
foreach (BOOST_TEXTS as $index => $path) {
|
|
$code = boostTextAsCode(File::get(__DIR__.'/../../'.$path));
|
|
|
|
File::ensureDirectoryExists($directory);
|
|
File::put($files["{$directory}/text-{$index}.blade.php"] = "{$directory}/text-{$index}.blade.php", $code['view']);
|
|
File::put($files["{$directory}/text-{$index}.css"] = "{$directory}/text-{$index}.css", $code['css']);
|
|
}
|
|
|
|
try {
|
|
$violations = DesignGuard::scan(realpath($directory))->violations();
|
|
} finally {
|
|
File::deleteDirectory($directory);
|
|
}
|
|
|
|
$named = array_map(function (string $violation) use ($directory): string {
|
|
return (string) preg_replace_callback(
|
|
'#^'.preg_quote(realpath(dirname($directory)) ?: dirname($directory), '#').'/[^/]+/text-(\d+)\.(?:blade\.php|css)#',
|
|
fn (array $match): string => BOOST_TEXTS[(int) $match[1]],
|
|
$violation,
|
|
);
|
|
}, $violations);
|
|
|
|
expect($named)->toBe([]);
|
|
});
|