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
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||||
|
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The actions and communication components, rewritten without Tailwind (plan step 36): each view
|
* The actions and communication components, rewritten without Tailwind (plan step 36): each view
|
||||||
* renders `data-md-*` attributes and no class list of its own, and each has a stylesheet in
|
* renders `data-md-*` attributes and no class list of its own beyond the interaction and text
|
||||||
|
* classes (tests/Support/ViewClasses.php), and each has a stylesheet in
|
||||||
* `material.components` that imports the stylesheets of the components its view renders, writes
|
* `material.components` that imports the stylesheets of the components its view renders, writes
|
||||||
* its values from the tokens and its breakpoints as px range queries, and is imported from the
|
* its values from the tokens and its breakpoints as px range queries, and is imported from the
|
||||||
* "Actions and communication" block of components.css.
|
* "Actions and communication" block of components.css.
|
||||||
@@ -61,56 +63,8 @@ it('imports the stylesheet of every component its view renders', function (strin
|
|||||||
expect(array_values(array_diff($rendered, ComponentStylesheet::read($name)->imports())))->toBe([]);
|
expect(array_values(array_diff($rendered, ComponentStylesheet::read($name)->imports())))->toBe([]);
|
||||||
})->with('action components');
|
})->with('action components');
|
||||||
|
|
||||||
it('writes no class list into the view but the interaction classes', function (string $name) {
|
it('writes no class list into the view but the interaction and text classes', function (string $name) {
|
||||||
$view = File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php");
|
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php")))->toBe([]);
|
||||||
$allowed = ['md-state-layer', 'md-focus-ring', 'md-touch-target'];
|
|
||||||
|
|
||||||
// `@class()`, `Arr::toCssClasses` maps and Alpine's `:class` binding are never how a class
|
|
||||||
// reaches the page here; the interaction classes and a caller's own class are the only source.
|
|
||||||
expect($view)->not->toMatch('/@class\(|toCssClasses|(?:x-bind)?:class=/');
|
|
||||||
|
|
||||||
// A literal `class="…"` may hold only the interaction classes — any subset, any order, with a
|
|
||||||
// simple `@if (…) … @endif` around one token — or a caller's value forwarded whole (`hint-class`,
|
|
||||||
// `icon-class`: `class="{{ $…}}"`).
|
|
||||||
preg_match_all('/(?<![\w:-])class=(["\'])(.*?)\1/s', $view, $classAttributes, PREG_SET_ORDER);
|
|
||||||
|
|
||||||
foreach ($classAttributes as [, , $content]) {
|
|
||||||
if (preg_match('/^\{\{\s*\$/', trim($content)) === 1) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$stripped = (string) preg_replace('/\{\{.*?\}\}|@if\s*\([^()]*\)|@unless\s*\([^()]*\)|@else|@endif|@endunless/s', ' ', $content);
|
|
||||||
$tokens = array_values(array_filter(preg_split('/\s+/', trim($stripped))));
|
|
||||||
|
|
||||||
expect(array_diff($tokens, $allowed))->toBe([], "{$name}.blade.php writes class=\"{$content}\", not only the interaction classes");
|
|
||||||
}
|
|
||||||
|
|
||||||
// `$attributes->class([…])` merges the interaction classes with the caller's own, per the
|
|
||||||
// brief: "merges through $attributes->class() only where the root takes the caller's class".
|
|
||||||
// Only the array's own items are checked — a condition may nest its own brackets
|
|
||||||
// (`in_array($size, ['xs', 'sm'], true)`), which are no class list of the view's writing.
|
|
||||||
preg_match_all('/->class\(\[(.*?)\]\)/s', $view, $classCalls, PREG_SET_ORDER);
|
|
||||||
|
|
||||||
foreach ($classCalls as [, $args]) {
|
|
||||||
$depth = 0;
|
|
||||||
|
|
||||||
for ($i = 0, $length = strlen($args); $i < $length; $i++) {
|
|
||||||
$char = $args[$i];
|
|
||||||
|
|
||||||
if ($char === '[' || $char === '(') {
|
|
||||||
$depth++;
|
|
||||||
} elseif ($char === ']' || $char === ')') {
|
|
||||||
$depth--;
|
|
||||||
} elseif ($depth === 0 && $char === "'") {
|
|
||||||
$end = strpos($args, "'", $i + 1);
|
|
||||||
$item = substr($args, $i + 1, $end - $i - 1);
|
|
||||||
|
|
||||||
expect(in_array($item, $allowed, true))->toBeTrue("{$name}.blade.php: ->class() writes [{$item}], not an interaction class");
|
|
||||||
|
|
||||||
$i = $end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})->with('action components');
|
})->with('action components');
|
||||||
|
|
||||||
it('takes its values from the tokens and its breakpoints in px', function (string $name) {
|
it('takes its values from the tokens and its breakpoints in px', function (string $name) {
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||||
|
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The inputs, selection and data components, rewritten without Tailwind (plan step 36): each view
|
* The inputs, selection and data components, rewritten without Tailwind (plan step 36): each view
|
||||||
* renders `data-md-*` attributes and no class list of its own, and each has a stylesheet in
|
* renders `data-md-*` attributes and no class list of its own beyond the interaction and text
|
||||||
|
* classes (tests/Support/ViewClasses.php), and each has a stylesheet in
|
||||||
* `material.components` that imports the stylesheets of the components its view renders, writes
|
* `material.components` that imports the stylesheets of the components its view renders, writes
|
||||||
* its values from the tokens and its breakpoints as px range queries, and is imported from the
|
* its values from the tokens and its breakpoints as px range queries, and is imported from the
|
||||||
* "Inputs, selection and data" block of components.css.
|
* "Inputs, selection and data" block of components.css.
|
||||||
@@ -37,115 +39,31 @@ dataset('input components', [
|
|||||||
'timepicker',
|
'timepicker',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
|
||||||
* The interaction classes a package view may write, in a literal `class="…"`, a `'class' => '…'`
|
|
||||||
* array entry, or the items of `->class([…])` — the only classes a package view writes at all
|
|
||||||
* (plan step 36, "Interaction is the shared classes").
|
|
||||||
*
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
function inputAllowedClasses(): array
|
|
||||||
{
|
|
||||||
return ['md-state-layer', 'md-focus-ring', 'md-touch-target'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fails unless every class list the given Blade source writes is one of the allowed interaction
|
|
||||||
* classes, or a caller's own class forwarded whole — `class="{{ $var }}"` on a plain element,
|
|
||||||
* `:class="$attributes->get('class')"` forwarding it to a nested `<x-livewire-material::…>` (the
|
|
||||||
* pattern a wrapper with no root element of its own uses, since it has no HTML tag to interpolate
|
|
||||||
* the value into), or a slot's own attributes.
|
|
||||||
*/
|
|
||||||
function assertNoClassList(string $view, string $label): void
|
|
||||||
{
|
|
||||||
$allowed = inputAllowedClasses();
|
|
||||||
|
|
||||||
// `@class()`, `Arr::toCssClasses` maps and Alpine's `:class`/`x-bind:class` binding on a plain
|
|
||||||
// element are never how a class reaches the page here.
|
|
||||||
expect($view)->not->toMatch('/@class\(|toCssClasses|x-bind:class=/');
|
|
||||||
|
|
||||||
// A literal `class="…"` may hold only the interaction classes — any subset, any order, with a
|
|
||||||
// simple `@if (…) … @endif` around one token — or a caller's value forwarded whole (`hint-class`,
|
|
||||||
// `icon-class`: `class="{{ $…}}"`).
|
|
||||||
preg_match_all('/(?<![\w:-])class=(["\'])(.*?)\1/s', $view, $classAttributes, PREG_SET_ORDER);
|
|
||||||
|
|
||||||
foreach ($classAttributes as [, , $content]) {
|
|
||||||
if (preg_match('/^\{\{\s*\$/', trim($content)) === 1) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$stripped = (string) preg_replace('/\{\{.*?\}\}|@if\s*\([^()]*\)|@unless\s*\([^()]*\)|@else|@endif|@endunless/s', ' ', $content);
|
|
||||||
$tokens = array_values(array_filter(preg_split('/\s+/', trim($stripped))));
|
|
||||||
|
|
||||||
expect(array_diff($tokens, $allowed))->toBe([], "{$label} writes class=\"{$content}\", not only the interaction classes");
|
|
||||||
}
|
|
||||||
|
|
||||||
// A PHP `'class' => '…'` array entry (an attribute bag built by hand, as a native control nested
|
|
||||||
// in a component often is) follows the same rule.
|
|
||||||
preg_match_all("/'class'\s*=>\s*'([^']*)'/", $view, $classArrays, PREG_SET_ORDER);
|
|
||||||
|
|
||||||
foreach ($classArrays as [, $value]) {
|
|
||||||
$tokens = array_values(array_filter(explode(' ', $value)));
|
|
||||||
|
|
||||||
expect(array_diff($tokens, $allowed))->toBe([], "{$label}: 'class' => '{$value}' is not only the interaction classes");
|
|
||||||
}
|
|
||||||
|
|
||||||
// A bare `:class="…"` (never `x-bind:class`, excluded above) only ever forwards the caller's own
|
|
||||||
// class whole to a nested component's root, which has no HTML tag of its own to interpolate it
|
|
||||||
// into — input, password, textarea, select and file hand `class` to the field this way.
|
|
||||||
preg_match_all('/(?<!x-bind):class="(.*?)"/s', $view, $classProps, PREG_SET_ORDER);
|
|
||||||
|
|
||||||
foreach ($classProps as [, $content]) {
|
|
||||||
expect(trim($content))->toBe("\$attributes->get('class')", "{$label} writes :class=\"{$content}\", not the caller's class forwarded whole");
|
|
||||||
}
|
|
||||||
|
|
||||||
// `$attributes->class([…])` merges the interaction classes with the caller's own. Only the
|
|
||||||
// array's own items are checked — a condition may nest its own brackets, which are no class
|
|
||||||
// list of the view's writing.
|
|
||||||
preg_match_all('/->class\(\[(.*?)\]\)/s', $view, $classCalls, PREG_SET_ORDER);
|
|
||||||
|
|
||||||
foreach ($classCalls as [, $args]) {
|
|
||||||
$depth = 0;
|
|
||||||
|
|
||||||
for ($i = 0, $length = strlen($args); $i < $length; $i++) {
|
|
||||||
$char = $args[$i];
|
|
||||||
|
|
||||||
if ($char === '[' || $char === '(') {
|
|
||||||
$depth++;
|
|
||||||
} elseif ($char === ']' || $char === ')') {
|
|
||||||
$depth--;
|
|
||||||
} elseif ($depth === 0 && $char === "'") {
|
|
||||||
$end = strpos($args, "'", $i + 1);
|
|
||||||
$item = substr($args, $i + 1, $end - $i - 1);
|
|
||||||
|
|
||||||
expect(in_array($item, $allowed, true))->toBeTrue("{$label}: ->class() writes [{$item}], not an interaction class");
|
|
||||||
|
|
||||||
$i = $end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fails unless every media query in the stylesheet writes its lengths in px, at one of M3's four
|
* Fails unless every media query in the stylesheet writes its lengths in px, at one of M3's four
|
||||||
* breakpoints — except a query keyed on `orientation`, which is never a breakpoint (the timepicker's
|
* breakpoints. The one exception is a viewport *height* in a query keyed on `orientation`: the time
|
||||||
* landscape layout is orientation and viewport *height*, a device fact M3 explicitly does not make a
|
* picker lies its dial down by device orientation and height, which M3 explicitly does not make a
|
||||||
* window size class of; docs/reference/m3/components-navigation-selection-inputs.md § Time pickers/
|
* breakpoint (docs/reference/m3/components-navigation-selection-inputs.md § Time pickers/Behaviour),
|
||||||
* Behaviour) and so is free to name any px height. The px rule itself still holds: `rem` and `em`
|
* so that height may be any px. A width, in any query, is still a breakpoint. `rem` and `em` would
|
||||||
* would grow with the reader's text size, which a breakpoint or a device height never should.
|
* grow with the reader's text size, which neither should.
|
||||||
*/
|
*/
|
||||||
function assertBreakpointsInPx(ComponentStylesheet $css, string $label): void
|
function assertBreakpointsInPx(ComponentStylesheet $css, string $label): void
|
||||||
{
|
{
|
||||||
foreach ($css->mediaQueries() as $query) {
|
foreach ($css->mediaQueries() as $query) {
|
||||||
$isOrientation = str_contains($query, 'orientation:');
|
$orientation = preg_match('/\(\s*orientation\s*:/', $query) === 1;
|
||||||
|
|
||||||
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $query, $lengths, PREG_SET_ORDER);
|
preg_match_all('/\(([^()]*)\)/', $query, $features);
|
||||||
|
|
||||||
foreach ($lengths as [, $number, $unit]) {
|
foreach ($features[1] as $feature) {
|
||||||
expect($unit)->toBe('px', "{$label}: {$query}");
|
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $feature, $lengths, PREG_SET_ORDER);
|
||||||
|
$height = preg_match('/(?:^|[\s:<>=])(?:min-|max-)?height\b/', $feature) === 1 && ! str_contains($feature, 'width');
|
||||||
|
|
||||||
if (! $isOrientation) {
|
foreach ($lengths as [, $number, $unit]) {
|
||||||
expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$label}: {$query} is not at an M3 breakpoint");
|
expect($unit)->toBe('px', "{$label}: {$query}");
|
||||||
|
|
||||||
|
if (! ($orientation && $height)) {
|
||||||
|
expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$label}: {$query} is not at an M3 breakpoint");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,30 +71,34 @@ function assertBreakpointsInPx(ComponentStylesheet $css, string $label): void
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Colours are roles, shadows are elevation levels, type is a type style, motion is a spring — and
|
* Colours are roles, shadows are elevation levels, type is a type style, motion is a spring — and
|
||||||
* breakpoints are px, at one of M3's four (an orientation query aside; see assertBreakpointsInPx()).
|
* breakpoints are px, at one of M3's four (an orientation query's height aside; see
|
||||||
|
* assertBreakpointsInPx()).
|
||||||
*
|
*
|
||||||
* One named exception: field.css's autofill workaround (`transition: background-color 604800s,
|
* One named exception, in field.css alone: its autofill workaround (`transition: background-color
|
||||||
* color 604800s`) delays Chrome's autofill background for a week, which is not a spring and is not
|
* 604800s, color 604800s`) delays Chrome's autofill background for a week, which is not a spring and
|
||||||
* meant to be one — a `var(--md-sys-motion-*)` duration here would visibly repaint on load.
|
* is not meant to be one — a `var(--md-sys-motion-*)` duration there would visibly repaint on load.
|
||||||
*/
|
*/
|
||||||
function assertTokensAndPxBreakpoints(ComponentStylesheet $css, string $label): void
|
function assertTokensAndPxBreakpoints(ComponentStylesheet $css, string $label): void
|
||||||
{
|
{
|
||||||
$source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css);
|
$source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css);
|
||||||
$source = str_replace('background-color 604800s, color 604800s', '', $source);
|
|
||||||
|
if ($label === 'field.css') {
|
||||||
|
$source = str_replace('transition: background-color 604800s, color 604800s;', '', $source);
|
||||||
|
}
|
||||||
|
|
||||||
expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i')
|
expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i')
|
||||||
->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/')
|
->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/')
|
||||||
->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/');
|
->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/');
|
||||||
|
|
||||||
// A flat elevation (`var(--md-sys-elevation-N)`, `none`) or a ring built from a colour token
|
// A box-shadow is `none`, an elevation level, or a ring: `[inset] 0 0 0 <n>px` in a colour
|
||||||
// (`inset 0 0 0 1px var(--md-sys-color-…)`, a day's or a year's "current" ring) — never a
|
// role (or a role mixed toward transparent, a disabled ring) — a day's or a year's "current"
|
||||||
// literal length paired with a colour the first check would not otherwise catch on its own.
|
// outline, a focused time field's edge. Never a hand-made shadow.
|
||||||
|
$colour = 'var\(--md-sys-color-[a-z-]+\)|color-mix\(in srgb, var\(--md-sys-color-[a-z-]+\) [^;]+?, transparent\)';
|
||||||
|
|
||||||
preg_match_all('/\bbox-shadow:\s*([^;]+);/', $source, $shadows, PREG_SET_ORDER);
|
preg_match_all('/\bbox-shadow:\s*([^;]+);/', $source, $shadows, PREG_SET_ORDER);
|
||||||
|
|
||||||
foreach ($shadows as [, $value]) {
|
foreach ($shadows as [, $value]) {
|
||||||
$value = trim($value);
|
expect(trim($value))->toMatch("/^(?:none|var\\(--md-sys-elevation-[0-5]\\)|(?:inset )?0 0 0 \\d+px (?:{$colour}))$/", "{$label}: box-shadow: {$value} is not none, an elevation or a ring in a colour role");
|
||||||
|
|
||||||
expect($value === 'none' || str_contains($value, 'var(--md-'))->toBeTrue("{$label}: box-shadow: {$value} is not none or built from a token");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assertBreakpointsInPx($css, $label);
|
assertBreakpointsInPx($css, $label);
|
||||||
@@ -210,8 +132,8 @@ it('imports the stylesheet of every component its view renders', function (strin
|
|||||||
expect(array_values(array_diff($rendered, ComponentStylesheet::read($name)->imports())))->toBe([]);
|
expect(array_values(array_diff($rendered, ComponentStylesheet::read($name)->imports())))->toBe([]);
|
||||||
})->with('input components');
|
})->with('input components');
|
||||||
|
|
||||||
it('writes no class list into the view but the interaction classes', function (string $name) {
|
it('writes no class list into the view but the interaction and text classes', function (string $name) {
|
||||||
assertNoClassList(File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php"), "{$name}.blade.php");
|
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php")))->toBe([]);
|
||||||
})->with('input components');
|
})->with('input components');
|
||||||
|
|
||||||
it('takes its values from the tokens and its breakpoints in px', function (string $name) {
|
it('takes its values from the tokens and its breakpoints in px', function (string $name) {
|
||||||
@@ -273,6 +195,6 @@ it('imports the stylesheet of every component pagination\'s views render', funct
|
|||||||
expect(array_values(array_diff($rendered, ComponentStylesheet::read('pagination')->imports())))->toBe([]);
|
expect(array_values(array_diff($rendered, ComponentStylesheet::read('pagination')->imports())))->toBe([]);
|
||||||
})->with('pagination views');
|
})->with('pagination views');
|
||||||
|
|
||||||
it('writes no class list into pagination\'s views but the interaction classes', function (string $view) {
|
it('writes no class list into pagination\'s views but the interaction and text classes', function (string $view) {
|
||||||
assertNoClassList(File::get(__DIR__."/../../../resources/views/{$view}.blade.php"), "{$view}.blade.php");
|
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/{$view}.blade.php")))->toBe([]);
|
||||||
})->with('pagination views');
|
})->with('pagination views');
|
||||||
|
|||||||
@@ -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