Files
livewire-material/tests/Support/ViewClasses.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

149 lines
5.7 KiB
PHP

<?php
namespace NoNameWeb\LivewireMaterial\Tests\Support;
/**
* The classes a package view may write: the three interaction classes from
* foundation/interaction.css, the fixed text classes resources/css/text.css defines, and a
* caller's own class handed on whole. Everything else a view draws comes from its `data-md-*`
* hooks.
*
* One rule for every group's stylesheet test, so the groups cannot drift apart.
*/
final class ViewClasses
{
/** @var list<string> */
public const INTERACTION = ['md-state-layer', 'md-focus-ring', 'md-touch-target'];
/** @var list<string>|null */
private static ?array $text = null;
/**
* Every class a view may write.
*
* @return list<string>
*/
public static function allowed(): array
{
return [...self::INTERACTION, ...self::text()];
}
/**
* The text classes, read from the rules text.css declares, so a class added there is allowed
* here without a second list.
*
* @return list<string>
*/
public static function text(): array
{
if (self::$text === null) {
preg_match_all('/^\s*\.(md-[a-z0-9-]+)\s*\{/m', (string) file_get_contents(dirname(__DIR__, 2).'/resources/css/text.css'), $matches);
self::$text = array_values(array_unique($matches[1]));
}
return self::$text;
}
/**
* What the Blade source writes as a class beyond the allowed ones, one line per offence; empty
* when the view keeps to the rule.
*
* @return list<string>
*/
public static function violations(string $view): array
{
// A Blade comment documents a caller's usage (`icon-class="text-sport-run"`); it renders nothing.
$view = (string) preg_replace('/\{\{--.*?--\}\}/s', '', $view);
$allowed = self::allowed();
$violations = [];
$check = function (string $where, string $list) use ($allowed, &$violations): void {
$extra = array_diff(preg_split('/\s+/', trim($list), -1, PREG_SPLIT_NO_EMPTY), $allowed);
if ($extra !== []) {
$violations[] = "{$where} writes ".implode(' ', $extra);
}
};
// `@class()`, `Arr::toCssClasses` maps and Alpine's `x-bind:class` are never how a class
// reaches the page.
if (preg_match_all('/@class\(|toCssClasses|x-bind:class=/', $view, $matches) > 0) {
$violations[] = 'uses '.implode(', ', array_unique($matches[0]));
}
// A literal `class="…"` (or a nested component's `hint-class`, `icon-class`, `box-class`):
// allowed classes, with a simple `@if (…) … @endif` around one of them, or a caller's value
// forwarded whole (`class="{{ $hintClass }}"`).
preg_match_all('/(?<![\w:-])(?:hint-|icon-|box-)?class=(["\'])(.*?)\1/s', $view, $attributes, PREG_SET_ORDER);
foreach ($attributes as [, , $content]) {
if (preg_match('/^\{\{\s*\$[^{}]*\}\}$/', trim($content)) === 1) {
continue;
}
if (str_contains($content, '{{')) {
$violations[] = "class=\"{$content}\" mixes an echo into a class list";
continue;
}
$check("class=\"{$content}\"", (string) preg_replace('/@if\s*\([^()]*\)|@unless\s*\([^()]*\)|@else|@endif|@endunless/', ' ', $content));
}
// A bare `:class` on a nested component only ever forwards the caller's own class whole to
// its root — the pattern a wrapper with no element of its own uses (input, password,
// textarea, select and file hand `class` to the field this way).
preg_match_all('/(?<![\w-]):class=(["\'])(.*?)\1/s', $view, $bindings, PREG_SET_ORDER);
foreach ($bindings as [, , $content]) {
if (trim($content) !== "\$attributes->get('class')") {
$violations[] = ":class=\"{$content}\" is not the caller's class forwarded whole";
}
}
// A PHP `'class' => …` entry (an attribute bag built by hand) holds a plain string literal
// of allowed classes, never an expression that could add others.
preg_match_all('/([\'"])class\1\s*=>\s*(?:([\'"])([^\'"]*)\2(?=\s*[,\])])|([^,\]\n]*))/', $view, $entries, PREG_SET_ORDER);
foreach ($entries as $entry) {
if (($entry[4] ?? '') !== '' || ! isset($entry[3])) {
$violations[] = "'class' => ".trim($entry[4] ?? '').' is not a string literal';
continue;
}
$check("'class' => '{$entry[3]}'", $entry[3]);
}
// `->class('…')` and the items of `->class([…])`: only the array's own keys and values at
// its top level — a condition may nest brackets of its own (`in_array($size, ['xs'])`).
preg_match_all('/->class\(\s*([\'"])([^\'"]*)\1\s*\)/', $view, $strings, PREG_SET_ORDER);
foreach ($strings as [, , $list]) {
$check("->class('{$list}')", $list);
}
preg_match_all('/->class\(\[(.*?)\]\)/s', $view, $calls, PREG_SET_ORDER);
foreach ($calls as [, $arguments]) {
$depth = 0;
for ($i = 0, $length = strlen($arguments); $i < $length; $i++) {
$char = $arguments[$i];
if ($char === '[' || $char === '(') {
$depth++;
} elseif ($char === ']' || $char === ')') {
$depth--;
} elseif ($depth === 0 && $char === "'") {
$end = (int) strpos($arguments, "'", $i + 1);
$check("->class(['".substr($arguments, $i + 1, $end - $i - 1)."'])", substr($arguments, $i + 1, $end - $i - 1));
$i = $end;
}
}
}
return $violations;
}
}