Files
livewire-material/tests/Feature/BoostVocabularyTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 fb7007c976
tests / feature (8.4) (push) Successful in 2m0s
tests / feature (8.5) (push) Successful in 2m0s
tests / browser (chrome, chromium) (push) Failing after 8m3s
tests / browser (firefox, firefox) (push) Failing after 12m58s
tests / browser (safari, webkit) (push) Failing after 13m8s
Take Tailwind out of the package, and its detection out of the guard
Tailwind left the stack in 2.0.0, but the package still carried about 330
mentions of it. What the guard's Tailwind detection protected — a class
that compiles to nothing — is now protected by a check that does not care
where a dead class came from.

DesignGuard: about 500 lines of Tailwind tables, scales, palettes and
"2.0.0 replacement" hints give way to one check — a class a view or PHP
file writes that neither the application's stylesheets nor the package's
own declare. It catches a utility of any framework, a typo and a class
whose rules were deleted alike, so it also found two classes ReStride
draws nothing with. A stylesheet has to be in reach for it: the `.css`
files among the scanned paths, or what the `missingStylesheets()` entry
imports. The class reader no longer mistakes an array index for a class
list (`$block['base']`), and it reads the array a class helper is given,
where it read nothing before.

The package's own three Tailwind self-guards go with it. Only their one
unique check stays, as a test of its own: every `matchMedia` width in
resources/js is an M3 breakpoint.

The pagination views are `material.blade.php` and
`simple-material.blade.php`; only Laravel's and Livewire's default theme
names ever made them `tailwind`. The provider sets `Paginator`'s default
views and switches `livewire.pagination_theme` to `material` when it is
still Livewire's own default, so no application can forget the config; a
theme an application chose, and a component's own `$paginationTheme` or
`paginationView()`, still win.

The rest is prose: the layer-order guidance for an application that still
builds Tailwind, the Tailwind wording in the README, the Boost guidelines
and the development skill, and about 25 "this used to be a Tailwind
utility" comments, along with every "plan step NN" pointer into a
gitignored folder. The reset keeps its credit, and NOTICE now carries it
too.

Feature suite 1159 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:07:39 +02:00

124 lines
4.9 KiB
PHP

<?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
/**
* The texts Boost copies into an application: the two guidelines every session reads, and the two
* skills. A fenced Blade, HTML, PHP or JS block goes through the design guard whole, so none may
* write a class no stylesheet declares; in prose, only an inline span that reads as an `md-*`
* class list is checked (`boostSpanIsClassList()`), so a name from another vocabulary — a prop, a
* role, a config key — is never held to that standard.
*/
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',
];
/**
* 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 and an inline rule into a stylesheet, and every inline span that
* reads as a class list (`md-ink-variant md-truncate`) 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;
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 ($fence === 'css') {
$cssLine = $line;
} elseif (in_array($fence, ['blade', 'html', 'php', 'js'], true)) {
$viewLine = $line;
}
} else {
preg_match_all('/`([^`]+)`/', $line, $spans);
// A span that is a rule of its own (`.sport-run-label { … }`) declares the class
// the example beside it writes, so it goes on the stylesheet side of the pair.
[$rules, $spans] = collect($spans[1])->partition(fn (string $span): bool => preg_match('/^\s*\.[\w-]+[^{]*\{[^}]*\}\s*$/', $span) === 1);
$cssLine = $rules->implode(' ');
$viewLine = $spans
->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)];
}
/**
* Whether an inline span reads as a class list: every token in it, and there has to be at least
* one, has to be an `md-*` class — the only vocabulary this check holds prose to. A single bare
* word (`outline`, `link`, `hidden`), a property or prop name (`border-color`,
* `placeholder-value`), a colour role (`outline-variant`), a CSS function (`var(…)`) and a shell
* command (`php artisan …`) all fail that test on their own, with no list of names to keep here.
*/
function boostSpanIsClassList(string $span): bool
{
$tokens = preg_split('/\s+/', trim($span));
foreach ($tokens as $token) {
// Every class the package declares is `md-*`, so that prefix is what tells a class list
// from the prop values, slot names and config keys the prose lists the same way
// (`top-start`, `rail-header`, `livewire-material.profiles`). A class of an application's
// own that an example names is nobody's to declare here.
if (preg_match('/^(?:[a-z][\w-]*:)*md-[a-z\d-]+$/', $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([]);
});