Share one view class rule between the stylesheet tests
Plan step 36 review. The brief and step 34 let a package view write the
md-* text classes beside the three interaction classes, but both
stylesheet tests allowed only the latter, and each kept its own copy of
the check. tests/Support/ViewClasses.php is now the one rule: the
interaction classes, the text classes read from text.css, and a caller's
class handed on whole. It also closes the holes the copies left: a
single-quoted :class, an echo mixed into a class list, a hint-, icon- or
box-class literal, a 'class' => entry that is not a plain literal, and
->class('…') as a string.
InputStylesheetsTest's exemptions narrow too: the autofill transition is
skipped in field.css alone; an orientation query may name any px height
but never a width off a breakpoint; a box-shadow must be none, an
elevation level, or a 0 0 0 ring in a colour role.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
d46b07c13c
commit
a3eef657cc
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Tests\Support;
|
||||
|
||||
/**
|
||||
* The classes a package view rewritten without Tailwind may write (plan step 36; the brief's
|
||||
* "Interaction is the shared classes"): the three interaction classes from
|
||||
* foundation/interaction.css, the fixed text classes resources/css/text.css defines (plan step 34,
|
||||
* "The component views use the same set"), 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user