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
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>
149 lines
5.7 KiB
PHP
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;
|
|
}
|
|
}
|