Files
livewire-material/src/Testing/DesignGuard.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

1456 lines
63 KiB
PHP

<?php
namespace NoNameWeb\LivewireMaterial\Testing;
use Illuminate\Support\Str;
use Illuminate\View\Compilers\ComponentTagCompiler;
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
use NoNameWeb\LivewireMaterial\Support\SvgFile;
use RuntimeException;
use SplFileInfo;
use Symfony\Component\Finder\Finder;
/**
* Finds what compiles to nothing in an application built on this package:
*
* (i) any class a view or PHP file writes that no stylesheet declares — not the
* application's own (`applicationClasses()`: the `.css` files among the scanned paths and
* whatever the `missingStylesheets()` entry imports outside the package), not the
* package's own (`packageClasses()`: every rule under its `resources/css`, `md-*` and the
* showcase's classes alike). So one of the two has to be in reach: a scan of views alone
* knows no application class and reports every one of them. A utility a migration left
* behind, a class of a framework that is no longer built, a typo and a class whose rule
* was deleted all read the same here: the class paints nothing.
* (ii) `missingStylesheets($cssEntry)`: a package component tag used in a view — unprefixed,
* under the configured prefix, or `<x-livewire-material::…>` — whose stylesheet the
* entry's relative `@import` graph does not reach (followed through every package file's
* own imports; a package name or URL the entry also imports is skipped, never fatal),
* `->links()` needing `pagination.css`, and a hook an application's view writes on markup
* of its own whose rules a component's stylesheet holds — `data-md-list-row` on anything
* but `<x-card>` needing `list-item.css` (`HOOK_STYLESHEETS`); each names the missing
* `@import` line once, at its first use. A tag the application shadows with its own
* component of the same name is reported instead — the application's component wins in
* Blade, so the package's stylesheet is moot. `unusedStylesheets($cssEntry)` reports the
* other way: a package stylesheet the entry imports directly that no scanned view needs,
* not even through another needed stylesheet's imports (an entry importing `all.css` is
* left alone).
* (iii) every `.css` file among the scanned paths, outside the package and excluding the
* generated `material-scheme.css`: a literal colour, radius, shadow, font size, weight,
* line height, letter spacing, easing or duration, and a media query at a width other
* than 600/840/1200/1600px — each with its token or breakpoint. A `var()`, or a `calc()`,
* `min()`, `max()` or `clamp()` built on one, is never flagged, whatever else it holds.
* (iv) icon names that are not Material Symbols, and Blade directives written inside a
* component tag (where they do not compile) — plus whatever an application bans on top
* with `forbidColours()` and `forbid()`, which read a line at a time, whatever it holds.
*
* A Markdown mail component — a view under a path `mail.markdown.paths` names, Laravel's
* `resources/views/vendor/mail` by default — is drawn by the mail theme, not by the application's
* CSS entry, so its classes (`table`, `button`, `panel`, `break-all`) are declared where this
* guard never looks. Family (i) therefore skips those views, while their icon names, the
* directives in their component tags and the application's own bans are still read; and a
* stylesheet under that path, a mail theme that has to write literal values because mail clients
* read no custom property, is neither check (iii)'s nor a source of declared classes.
*
* expect(DesignGuard::scan([resource_path('views'), resource_path('js'), resource_path('css'), app_path()])
* ->missingStylesheets(resource_path('css/app.css'))
* ->forbidColours(['tertiary'])
* ->violations())->toBe([]);
*
* Each violation is "path:line what", the path relative to the base path. Family (i) only ever
* reads a class list a `.php` file writes out — a `class` attribute, Livewire's
* `wire:loading.class`, Alpine's `x-transition:enter` and `:class`, `@class`, `->class()`,
* `Arr::toCssClasses()` and a `'class'` pair — never a word scanned across a whole line, which
* keeps "this creates a grid of cards" from reading as a class. Inside such a list it skips a
* string a condition compares (`view === 'grid'`) and one nested deeper than the list itself, an
* array index or a call's argument (`$block['base']`, `in_array($size, ['xs', 'sm'])`). The
* trade-off: a class assembled at runtime (`'text-'.$tone`) is invisible to this guard, the
* reason to write class names out whole; and an example of markup written in a PHP comment is
* read like the real thing, since only Blade comments are stripped.
*/
class DesignGuard
{
/**
* The hooks an application writes on markup of its own, not through a component tag, whose
* rules live in a component's stylesheet, and that stylesheet (check ii). `data-md-list-row`
* on an `<li>`, a `<div>`, a `<tr>` or a layout component such as `<x-row>` draws its hover,
* focus and press state layer and its `data-md-selected` fill from list-item.css, which no tag
* asks for when the page renders no `<x-list-item>` and no `<x-table>`. `except` names the
* package tags that draw the hook themselves: `<x-card data-md-list-row>` is card.css's row.
*
* Nothing else that list-rows.js, the skill or the README has an application write needs a
* stylesheet of its own: `data-md-list-open` and a row's `data-md-selected` are drawn only
* through the row, a selected row in `<x-table>` by table.css, `data-md-dragged` by
* foundation.css's `md-state-layer` (and card.css on `<x-card>`), and `data-md-field-control`
* by field.css inside `<x-field>`.
*/
protected const array HOOK_STYLESHEETS = [
'data-md-list-row' => ['stylesheet' => 'components/list-item.css', 'except' => ['card']],
];
/** An ink role, and the `md-ink-*` class (text.css) that sets it on plain text. */
protected const array INK_ROLE = [
'on-surface' => 'md-ink',
'on-surface-variant' => 'md-ink-variant',
'outline' => 'md-ink-quiet',
'primary' => 'md-ink-primary',
'error' => 'md-ink-error',
'success' => 'md-ink-success',
'warning' => 'md-ink-warning',
'info' => 'md-ink-info',
'inverse-on-surface' => 'md-ink-inverse',
];
/** The named colours a browser understands, other than `transparent` and `currentColor`. */
protected const string CSS_NAMED_COLOURS = 'aliceblue|antiquewhite|aqua|aquamarine|azure|beige|bisque|black|blanchedalmond|blue|blueviolet|brown|burlywood|cadetblue|chartreuse|chocolate|coral|cornflowerblue|cornsilk|crimson|cyan|darkblue|darkcyan|darkgoldenrod|darkgray|darkgreen|darkgrey|darkkhaki|darkmagenta|darkolivegreen|darkorange|darkorchid|darkred|darksalmon|darkseagreen|darkslateblue|darkslategray|darkslategrey|darkturquoise|darkviolet|deeppink|deepskyblue|dimgray|dimgrey|dodgerblue|firebrick|floralwhite|forestgreen|fuchsia|gainsboro|ghostwhite|gold|goldenrod|gray|green|greenyellow|grey|honeydew|hotpink|indianred|indigo|ivory|khaki|lavender|lavenderblush|lawngreen|lemonchiffon|lightblue|lightcoral|lightcyan|lightgoldenrodyellow|lightgray|lightgreen|lightgrey|lightpink|lightsalmon|lightseagreen|lightskyblue|lightslategray|lightslategrey|lightsteelblue|lightyellow|lime|limegreen|linen|magenta|maroon|mediumaquamarine|mediumblue|mediumorchid|mediumpurple|mediumseagreen|mediumslateblue|mediumspringgreen|mediumturquoise|mediumvioletred|midnightblue|mintcream|mistyrose|moccasin|navajowhite|navy|oldlace|olive|olivedrab|orange|orangered|orchid|palegoldenrod|palegreen|paleturquoise|palevioletred|papayawhip|peachpuff|peru|pink|plum|powderblue|purple|rebeccapurple|red|rosybrown|royalblue|saddlebrown|salmon|sandybrown|seagreen|seashell|sienna|silver|skyblue|slateblue|slategray|slategrey|snow|springgreen|steelblue|tan|teal|thistle|tomato|turquoise|violet|wheat|white|whitesmoke|yellow|yellowgreen';
/** A property whose literal value check (iii) reports, and the token that replaces it. */
protected const array LITERAL_PROPERTIES = [
'border-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-top-left-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-top-right-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-bottom-right-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-bottom-left-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-start-start-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-start-end-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-end-start-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-end-end-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'box-shadow' => ['kind' => 'shadow', 'hint' => 'use `var(--md-sys-elevation-*)`'],
'font' => ['kind' => 'font', 'hint' => 'use `font: var(--md-sys-typescale-*)` with its `-tracking`, or an `md-type-*` class'],
'font-size' => ['kind' => 'font size', 'hint' => 'set the whole style with `font: var(--md-sys-typescale-*)` and its `-tracking`, or an `md-type-*` class'],
'font-weight' => ['kind' => 'font weight', 'hint' => 'use an `md-type-emphasized-*` class, or `var(--md-ref-typeface-weight-regular|medium|bold)`'],
'line-height' => ['kind' => 'line height', 'hint' => 'set the whole style with `font: var(--md-sys-typescale-*)`, or an `md-type-*` class'],
'letter-spacing' => ['kind' => 'letter spacing', 'hint' => 'use `var(--md-sys-typescale-*-tracking)`'],
'transition-timing-function' => ['kind' => 'easing', 'hint' => 'use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`'],
'animation-timing-function' => ['kind' => 'easing', 'hint' => 'use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`'],
'transition-duration' => ['kind' => 'duration', 'hint' => 'use `var(--md-sys-motion-…-duration)`, paired with its easing'],
'animation-duration' => ['kind' => 'duration', 'hint' => 'use `var(--md-sys-motion-…-duration)`, paired with its easing'],
];
/** @var list<array{pattern: string, reason: string}> */
protected array $forbidden = [];
/** @var list<string> */
protected array $forbiddenColours = [];
protected ?string $cssEntry = null;
protected ?string $unusedEntry = null;
/** @var array<string, true>|null */
protected ?array $applicationClassesCache = null;
/**
* @param list<string> $paths
*/
final public function __construct(protected array $paths) {}
/**
* @param string|list<string> $paths Files or directories.
*/
public static function scan(string|array $paths): static
{
return new static((array) $paths);
}
/**
* Roles the application's own rules leave out, e.g. ['tertiary', 'primary-container'].
* Their on-roles and containers are forbidden with them, wherever 2.0.0 lets an application
* write one: the `--md-sys-color-*` custom property (a `var()` in its CSS, an inline `style`, a
* script reading it), the `md-ink-*` class text.css has for it, and a component's `color` or
* `tone` prop (`color="tertiary"`, `:tone="'tertiary'"`).
*
* @param list<string> $roles
*/
public function forbidColours(array $roles): static
{
$this->forbiddenColours = [...$this->forbiddenColours, ...$roles];
return $this;
}
/**
* Any further pattern, matched line by line.
*/
public function forbid(string $pattern, string $reason): static
{
$this->forbidden[] = ['pattern' => $pattern, 'reason' => $reason];
return $this;
}
/**
* Check (ii): every package component tag used in a scanned view — unprefixed, under the
* configured prefix, or `<x-livewire-material::…>` — every `->links()` call, and every hook in
* `HOOK_STYLESHEETS` a view outside the package writes on its own markup (a row's
* `data-md-list-row`), against `$cssEntry`'s `@import` graph (followed through each package
* file's own imports). A missing one names the `@import` line to add; a tag the application
* shadows with its own component of the same name is reported instead, since the package's
* stylesheet is then moot. Each missing stylesheet and each shadowed tag is reported once, at
* its first use. Only the imports are read here: the literal values in the application's own
* CSS are check (iii)'s, which reads the `.css` files `scan()` is given, entry or not — so an
* application part-way through its migration can check its imports before its stylesheets
* are on tokens. The classes the entry's own imports declare do count as declared for check
* (i), though.
*/
public function missingStylesheets(string $cssEntry): static
{
$this->cssEntry = $cssEntry;
$this->applicationClassesCache = null;
return $this;
}
/**
* Check (ii) the other way round: each package stylesheet `$cssEntry` imports directly that no
* scanned Blade view needs — no package tag it renders, no `->links()`, no hook it writes by
* hand (`HOOK_STYLESHEETS`), and not reached through the imports of a stylesheet that is
* needed — named at its `@import` line, to remove. A component whose last use left the views
* otherwise keeps its CSS in every page. `foundation.css` is always needed; an entry that
* imports `all.css` has chosen everything and is not read.
*/
public function unusedStylesheets(string $cssEntry): static
{
$this->unusedEntry = $cssEntry;
return $this;
}
/**
* @return list<string>
*/
public function violations(): array
{
$violations = [];
$resolved = $this->cssEntry !== null
? array_fill_keys(Stylesheets::resolvedFiles([$this->cssEntry]), true)
: [];
$reported = [];
$needed = [];
if ($this->cssEntry !== null && ! is_file($this->cssEntry)) {
$violations[] = "{$this->cssEntry}:1 the CSS entry `missingStylesheets()` names does not exist";
} elseif ($this->cssEntry !== null) {
$foundation = static::packagePath('css').'/foundation.css';
if (! isset($resolved[$foundation])) {
$violations[] = sprintf(
'%s:1 the CSS entry never imports `foundation.css`, required by every package stylesheet — add `@import \'%s\';`',
$this->relative($this->cssEntry),
$this->importLine($foundation),
);
}
}
$mailPaths = $this->mailComponentPaths();
foreach ($this->files() as $file) {
if (str_ends_with($file->getFilename(), '.css')) {
continue; // A directly-scanned CSS file is check (iii)'s alone; these checks read Blade, PHP and JS.
}
$contents = (string) file_get_contents($file->getPathname());
$where = $this->relative($file->getPathname());
$isBlade = str_ends_with($file->getFilename(), '.blade.php');
$readsClasses = ! $this->isUnder($file->getPathname(), $mailPaths);
// A hook written in the package's own views is a tag's own, so hooks are read only outside them.
$readsHooks = ! $this->isUnder($file->getPathname(), array_filter([static::packagePath('views')]));
if ($isBlade) {
$contents = $this->withoutBladeComments($contents);
}
if ($readsClasses && str_ends_with($file->getFilename(), '.php')) {
foreach ($this->undeclaredClasses($contents) as [$line, $class]) {
$violations[] = "{$where}:{$line} class `{$class}` is declared in no stylesheet — the package declares `md-*`, the rest is your own CSS";
}
}
if ($isBlade) {
foreach ($this->iconNames($contents) as [$line, $name]) {
$violations[] = "{$where}:{$line} unknown Material Symbol `{$name}`";
}
foreach ($this->directivesInComponentTags($contents) as [$line, $directive]) {
$violations[] = "{$where}:{$line} Blade directive `{$directive}` inside a component tag, where it does not compile — ".$this->directiveInTag($directive);
}
foreach ($this->forbiddenRoleProps($contents) as [$line, $what]) {
$violations[] = "{$where}:{$line} {$what}";
}
if ($this->cssEntry !== null) {
foreach ($this->missingStylesheetViolations($contents, $resolved, $reported, $readsHooks) as [$line, $what]) {
$violations[] = "{$where}:{$line} {$what}";
}
}
if ($this->unusedEntry !== null) {
$needed = [...$needed, ...$this->neededStylesheets($contents, $readsHooks)];
}
}
foreach (explode("\n", $contents) as $index => $text) {
$line = $index + 1;
foreach ($this->forbiddenRoles($text) as $what) {
$violations[] = "{$where}:{$line} {$what}";
}
foreach ($this->forbidden as $rule) {
if (preg_match($rule['pattern'], $text) === 1) {
$violations[] = "{$where}:{$line} {$rule['reason']}";
}
}
}
}
if ($this->unusedEntry !== null) {
$violations = [...$violations, ...$this->unusedStylesheetViolations($needed)];
}
return $this->sortViolations([...$violations, ...$this->applicationCssViolations()]);
}
/**
* The package stylesheets a Blade view's own tags, `->links()` calls and, unless `$readsHooks`
* is false (a package view), the hooks it writes by hand need, before their imports are
* followed.
*
* @return list<string>
*/
protected function neededStylesheets(string $contents, bool $readsHooks = true): array
{
$needed = [];
foreach ($this->packageTagUsages($contents) as [, $name, $spelling]) {
if ($spelling === 'plain' && $this->shadowedByApplication($name)) {
continue;
}
if (($stylesheet = static::packageStylesheetFor($name)) !== null) {
$needed[] = $stylesheet;
}
}
if ($readsHooks) {
$needed = [...$needed, ...array_column($this->hookUsages($contents), 2)];
}
if ($this->paginationUsages($contents) !== []) {
$needed[] = static::packagePath('css').'/components/pagination.css';
}
return $needed;
}
/**
* @param list<string> $needed
* @return list<string>
*/
protected function unusedStylesheetViolations(array $needed): array
{
$entry = (string) $this->unusedEntry;
$real = realpath($entry);
if ($real === false || ! is_file($real)) {
return ["{$entry}:1 the CSS entry `unusedStylesheets()` names does not exist"];
}
$root = static::packagePath('css');
// Comments out, line count kept; the import strings themselves stay readable.
$css = (string) preg_replace_callback('~/\*.*?\*/~s', fn (array $comment): string => str_repeat("\n", substr_count($comment[0], "\n")), (string) file_get_contents($real));
$imports = [];
preg_match_all('/@import\s+(?:url\(\s*)?([\'"])([^\'"]+)\1/i', $css, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
foreach ($matches as $match) {
$file = realpath(dirname($real).'/'.$match[2][0]);
if ($file === false || ! static::isPackageFile($file)) {
continue;
}
if ($file === $root.'/all.css') {
return [];
}
$imports[] = [substr_count(substr($css, 0, $match[0][1]), "\n") + 1, $file];
}
$reached = array_fill_keys(Stylesheets::resolvedFiles([...array_unique($needed), $root.'/foundation.css']), true);
$violations = [];
foreach ($imports as [$line, $file]) {
if (! isset($reached[$file])) {
$violations[] = sprintf(
'%s:%d `%s` is imported, but no scanned view renders a component that needs it — remove `@import \'%s\';`',
$this->relative($real),
$line,
static::packageRelativeName($file),
$this->importLineFrom($real, $file),
);
}
}
return $violations;
}
/**
* `$violations` ("path:line message", `relative()`'s shape) sorted by path, then line
* (numerically, so line 9 sits before line 10), then message — whichever check produced each
* one, so two checks that land on the same file read in one deterministic order instead of
* each check's own pass order (colours before shadows before media queries, line order inside
* `missingStylesheets()`'s own pass, and so on).
*
* @param list<string> $violations
* @return list<string>
*/
protected function sortViolations(array $violations): array
{
$parsed = array_map(function (string $violation): array {
preg_match('/^(.*):(\d+) (.*)$/s', $violation, $match);
return [$match[1] ?? $violation, isset($match[2]) ? (int) $match[2] : 0, $match[3] ?? '', $violation];
}, $violations);
usort($parsed, fn (array $a, array $b): int => $a[0] <=> $b[0] ?: $a[1] <=> $b[1] ?: $a[2] <=> $b[2]);
return array_column($parsed, 3);
}
/**
* @return iterable<SplFileInfo>
*/
protected function files(): iterable
{
foreach ($this->paths as $path) {
if (is_file($path)) {
yield new SplFileInfo($path);
continue;
}
if (is_dir($path)) {
yield from Finder::create()->files()->in($path)->name(['*.php', '*.js', '*.ts'])->sortByName();
}
}
}
/**
* Every place one line names a role `forbidColours()` left out: its `--md-sys-color-*` custom
* property, or the `md-ink-*` class text.css draws it with.
*
* @return list<string>
*/
protected function forbiddenRoles(string $text): array
{
$found = [];
foreach ($this->forbiddenColours as $role) {
$names = ['--md-sys-color-(?:on-)?'.preg_quote($role, '/').'(?:-container)?'];
foreach ([$role, "on-{$role}", "{$role}-container", "on-{$role}-container"] as $variant) {
if (isset(self::INK_ROLE[$variant])) {
$names[] = preg_quote(self::INK_ROLE[$variant], '/');
}
}
preg_match_all('/(?<![\w-])(?:'.implode('|', $names).')(?![\w-])/', $text, $matches);
foreach ($matches[0] as $written) {
$found[] = "`{$written}`: role `{$role}` is not part of this application's palette";
}
}
return $found;
}
/**
* Every component `color` or `tone` prop in `$contents` naming a role `forbidColours()` left
* out, literal (`color="tertiary"`) or any string a bound one can take
* (`:tone="$failed ? 'error' : 'info'"`).
*
* @return list<array{0: int, 1: string}>
*/
protected function forbiddenRoleProps(string $contents): array
{
if ($this->forbiddenColours === []) {
return [];
}
preg_match_all('/<x-[\w.:-]+((?:[^>"]|"[^"]*")*)>/s', $contents, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
$found = [];
foreach ($tags as $tag) {
preg_match_all('/\s(?<bound>:?)(?:color|tone)="(?<value>[^"]*)"/', $tag[1][0], $props, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
foreach ($props as $prop) {
$values = $prop['bound'][0] === ':'
? (preg_match_all("/'([^']*)'/", $prop['value'][0], $strings) ? $strings[1] : [])
: [$prop['value'][0]];
foreach ($values as $value) {
foreach ($this->forbiddenColours as $role) {
if (preg_match('/^(?:on-)?'.preg_quote($role, '/').'(?:-container)?$/', $value) === 1) {
$found[] = [
substr_count(substr($contents, 0, $tag[1][1] + $prop[0][1]), "\n") + 1,
'`'.trim($prop[0][0])."`: role `{$role}` is not part of this application's palette",
];
}
}
}
}
}
return $found;
}
/**
* Check (iii): literal design values in the application's own CSS. `var(--md-sys-…)` and
* `calc()` are blanked out first (see `withoutTokenFunctions()`), so a value built from tokens
* is invisible to every pattern below, whatever it contains; `0`, `none` and `inherit` are
* always fine.
*
* @return list<string>
*/
protected function applicationCssViolations(): array
{
$violations = [];
foreach ($this->applicationStylesheets() as $file) {
$masked = $this->maskedCss((string) file_get_contents($file));
$css = $this->withoutTokenFunctions($masked);
$where = $this->relative($file);
foreach (explode("\n", $masked) as $index => $text) {
foreach ($this->forbiddenRoles($text) as $what) {
$violations[] = "{$where}:".($index + 1)." {$what}";
}
}
foreach ([
...$this->literalColours($css),
...$this->literalDeclarations($css, $masked),
...$this->offScaleMediaQueries($css),
] as [$line, $what]) {
$violations[] = "{$where}:{$line} {$what}";
}
}
return $violations;
}
/**
* Every literal colour in a declaration's value: a hex code, a colour function, or a named
* colour other than `transparent`/`currentColor`, anywhere in the value (`border: 1px solid
* white` as much as `color: white`). Only values are read, so a selector like `.red-banner` or
* an id like `#add` never matches.
*
* @return list<array{0: int, 1: string}>
*/
protected function literalColours(string $css): array
{
$found = [];
foreach ($this->declarations($css) as [, $value, $offset]) {
preg_match_all('/(?<![\w#-])(?:#[0-9a-fA-F]{3,8}(?![\w-])|(?:rgba?|hsla?|hwb|oklch|oklab|lch|lab|color)\([^)]*\)|(?:'.self::CSS_NAMED_COLOURS.')(?![\w-]))/i', $value, $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[0] as [$colour, $at]) {
$found[] = [
substr_count(substr($css, 0, $offset + $at), "\n") + 1,
"literal colour `{$colour}` — use `var(--md-sys-color-*)`",
];
}
}
return $found;
}
/**
* Every declaration in `$css` — a property, custom properties included, and its value up to
* the `;` or `}` that ends it — with the value's byte offset. A selector (`a:hover {`) or an
* at-rule condition (`@media (prefers-color-scheme: dark) {`) ends in `{` and is never one.
*
* @return list<array{0: string, 1: string, 2: int}>
*/
protected function declarations(string $css): array
{
preg_match_all('/(?<![\w-])(?<property>--[\w-]+|-?[a-zA-Z][\w-]*)\s*:\s*(?<value>[^;{}]*)(?=[;}])/', $css, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
return array_map(fn (array $match): array => [strtolower($match['property'][0]), $match['value'][0], $match['value'][1]], $matches);
}
/**
* Every literal value on a property check (iii) knows, plus a `transition`/`animation`
* shorthand's embedded easing or duration (its property name alone cannot say which part of
* the value is which, so both are searched for whenever either is written literally). A
* custom property is never one of them, whatever its name says. `$css` has its token functions
* blanked; a message quotes the declaration as written in `$source`, the same CSS before that
* (blanking keeps every offset).
*
* @return list<array{0: int, 1: string}>
*/
protected function literalDeclarations(string $css, ?string $source = null): array
{
$found = [];
foreach ($this->declarations($css) as [$property, $blanked, $offset]) {
$value = trim($blanked);
$written = trim(substr($source ?? $css, $offset, strlen($blanked)));
$line = substr_count(substr($css, 0, $offset), "\n") + 1;
$isRing = $property === 'box-shadow' && $this->isSafeBoxShadowRing($value);
if (isset(self::LITERAL_PROPERTIES[$property]) && ! $this->isSafeLiteralValue($value) && ! $isRing) {
$config = self::LITERAL_PROPERTIES[$property];
$found[] = [$line, "literal {$config['kind']} `{$property}: {$written}` — {$config['hint']}"];
}
if (! in_array($property, ['transition', 'animation'], true)) {
continue;
}
// `linear` is M3's own easing for continuous motion (an indeterminate progress
// indicator's rotation, foundations-supplement.md § Motion) — legitimate literal CSS,
// unlike `ease`/`ease-in`/`ease-out`/`ease-in-out`, a spring's own shape and never
// written by hand, or `cubic-bezier()`/`steps()`, always a hand-rolled curve.
if (preg_match('/cubic-bezier\(|steps\(|(?<![\w-])(?:ease(?:-in-out|-in|-out)?|step-start|step-end)(?![\w-])/i', $value) === 1) {
$found[] = [$line, "literal easing in `{$property}: {$written}` — use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`"];
}
preg_match_all('/(?<![\w.-])(\d*\.?\d+)m?s(?![\w-])/i', $value, $durations);
if (array_filter($durations[1], fn (string $number): bool => (float) $number > 0) !== []) {
$found[] = [$line, "literal duration in `{$property}: {$written}` — use `var(--md-sys-motion-…-duration)`, paired with its easing"];
}
}
return $found;
}
/**
* A value `withoutTokenFunctions()` left with nothing but the empty shell of a token function
* — whitespace where the call's own argument list used to be, the call's own name and
* parentheses still standing — is exactly as fine as one masked away entirely. A list of values
* (`transition-duration: var(…), var(…)`) is fine when every item in it is.
*/
protected function isSafeLiteralValue(string $value): bool
{
$value = (string) preg_replace('/\b(?:var|calc|min|max|clamp)\(\s*\)/i', '', $value);
foreach (explode(',', $value) as $item) {
$item = trim($item);
if ($item !== '' && ! in_array(strtolower($item), ['0', '0px', '0s', '0ms', 'none', 'inherit', 'initial', 'unset', 'revert', 'normal', 'auto'], true)) {
return false;
}
}
return true;
}
/**
* A `box-shadow` shaped like an inset or outline-style ring in a colour role — `[inset] 0 0 0
* <n>px` in a `var(--md-sys-color-*)`, or one mixed toward transparent for a disabled ring
* (`color-mix(in srgb, var(--md-sys-color-*) <n>, transparent)`) — the shape the package's own
* stylesheets (and their stylesheet tests) use for a day's or a year's "current" outline, a
* focused field's edge, a selected chip's border: legitimate M3 CSS, not a hand-made shadow.
* `$value` has already had every `var()`/`calc()` call's own arguments blanked (see
* `withoutTokenFunctions()`), so the colour itself is read here only as an empty shell.
*/
protected function isSafeBoxShadowRing(string $value): bool
{
return preg_match(
'/^(?:inset\s+)?0\s+0\s+0\s+\d+(?:\.\d+)?px\s+(?:var\([ \t]*\)|color-mix\(in srgb,\s*var\([ \t]*\)[^,]*,\s*transparent\s*\))$/',
trim($value),
) === 1;
}
/**
* Every width in an `@media` condition that is not one of M3's four breakpoints written in
* px: `(width >= 840px)`, `(min-width: 840px)` and `(600px <= width < 840px)` pass, `(width >
* 839px)`, `(max-width: 839.98px)` and anything in `rem`/`em` do not. A height, a
* `prefers-*` feature and an `@container` query are not breakpoints and are not read.
*
* @return list<array{0: int, 1: string}>
*/
protected function offScaleMediaQueries(string $css): array
{
preg_match_all('/@media\s*([^{]*)\{/i', $css, $matches, PREG_OFFSET_CAPTURE);
$found = [];
foreach ($matches[1] as [$prelude, $preludeOffset]) {
preg_match_all('/\(([^()]*)\)/', $prelude, $features, PREG_OFFSET_CAPTURE);
foreach ($features[1] as [$feature, $featureOffset]) {
if (preg_match('/(?<![\w-])(?:min-|max-)?(?:device-)?width(?![\w-])/i', $feature) !== 1) {
continue;
}
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/i', $feature, $widths, PREG_OFFSET_CAPTURE);
foreach ($widths[0] as $i => [$whole, $widthOffset]) {
$number = (float) $widths[1][$i][0];
if (strtolower($widths[2][$i][0]) === 'px' && in_array($number, [600.0, 840.0, 1200.0, 1600.0], true)) {
continue;
}
$line = substr_count(substr($css, 0, $preludeOffset + $featureOffset + $widthOffset), "\n") + 1;
$found[] = [$line, "media query width `{$whole}` is not one of M3's breakpoints — use 600, 840, 1200 or 1600px (medium, expanded, large, extra-large) with `>=` or `<`"];
}
}
}
return $found;
}
/**
* The names as one alternation, longest first so that a prefix of another name — `xl` of
* `2xl` — cannot win where the pattern is not anchored.
*
* @param list<string> $names
*/
protected function alternation(array $names): string
{
usort($names, fn (string $a, string $b): int => strlen($b) <=> strlen($a));
return implode('|', array_map(fn (string $name): string => preg_quote($name, '/'), $names));
}
/**
* Blade comments blanked out, their line breaks kept so line numbers still match.
*/
protected function withoutBladeComments(string $contents): string
{
return (string) preg_replace_callback(
'/\{\{--.*?--\}\}/s',
fn (array $match): string => str_repeat("\n", substr_count($match[0], "\n")),
$contents,
);
}
/**
* `$css` with every `/* … *\/` comment, the inside of every quoted string and of every
* unquoted `url(…)` blanked out, line breaks kept — so neither a class check nor a value check
* reads `content: ".flex"`, a font name or a data URI's `fill='white'`.
*/
protected function maskedCss(string $css): string
{
return (string) preg_replace_callback(
'/\/\*.*?\*\/|"(?:[^"\\\n]|\\.)*"|\'(?:[^\'\\\n]|\\.)*\'|(?<=url\()[^)\'"]*(?=\))/is',
fn (array $match): string => str_starts_with($match[0], '/*')
? str_repeat("\n", substr_count($match[0], "\n"))
: (string) preg_replace('/[^\n]/', ' ', $match[0]),
$css,
);
}
/**
* `$css` with the argument list of every `var(…)` call, and of every `calc()`, `min()`,
* `max()` or `clamp()` built on one, blanked out (nested parentheses tracked) — what lets
* check (iii) treat any value built from a token as fine, whatever literal numbers or colours
* it wraps, a `var()` fallback included.
*/
protected function withoutTokenFunctions(string $css): string
{
return $this->maskFunctionCalls($this->maskFunctionCalls($css, 'var'), '(?:calc|min|max|clamp)', true);
}
/**
* `$css` with the argument list of every `$name(…)` call blanked out — or, with `$withVar`,
* of only those whose arguments use a `var()`, so `calc(var(--x) + 4px)` is a token's value
* and `calc(12px + 2px)` or `clamp(1rem, 2vw, 2rem)` stays a literal one.
*/
protected function maskFunctionCalls(string $css, string $name, bool $withVar = false): string
{
$pattern = '/(?<![\w-])'.$name.'\(/i';
$offset = 0;
while (preg_match($pattern, $css, $match, PREG_OFFSET_CAPTURE, $offset) === 1) {
$open = $match[0][1] + strlen($match[0][0]) - 1;
$depth = 1;
$i = $open + 1;
$length = strlen($css);
while ($i < $length && $depth > 0) {
if ($css[$i] === '(') {
$depth++;
} elseif ($css[$i] === ')') {
$depth--;
}
$i++;
}
$inner = substr($css, $open + 1, $i - $open - 2);
if ($withVar && preg_match('/(?<![\w-])var\(/i', $inner) !== 1) {
$offset = $open + 1;
continue;
}
$blank = (string) preg_replace('/[^\n]/', ' ', $inner);
$css = substr($css, 0, $open + 1).$blank.substr($css, $i - 1);
$offset = $i;
}
return $css;
}
/**
* Every class written out literally: in a `class` attribute, in Livewire's
* `wire:loading.class` and Alpine's `x-transition:enter` lists, and in the strings of
* `:class`/`x-bind:class` (its object keys too, quoted or not), `@class([...])`,
* `->class(...)`, `Arr::toCssClasses([...])` and a `'class'` pair. A string compared in a
* condition (`view === 'grid'`, `$status === 'hidden'`) is not a class, and neither is one
* nested deeper than the list itself — an array index or a call's argument, which is where
* `$block['base']`, `$header['class']` and `in_array($size, ['xs', 'sm'])` keep their strings;
* echoes inside a list are skipped. A token is returned exactly as written, prefix and all,
* with the line it sits on.
*
* @return list<array{0: int, 1: string}>
*/
protected function literalClasses(string $contents): array
{
$lists = [];
preg_match_all('/(?:(?<![\w:.-])class|wire:[\w.-]*\.class(?:\.remove)?|x-transition:(?:enter|leave)(?:-start|-end)?)="([^"]*)"/', $contents, $attributes, PREG_OFFSET_CAPTURE);
foreach ($attributes[1] as [$list, $offset]) {
$lists[] = [$list, $offset];
}
preg_match_all('/:class="(?<alpine>[^"]*)"|(?:@class|->class|Arr::toCssClasses)\((?<php>\[.*?\]|\'[^\']*\'|"[^"]*")\)|([\'"])class\3\s*=>\s*(?<pair>\'[^\']*\'|"[^"]*")/s', $contents, $bindings, PREG_OFFSET_CAPTURE | PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL);
foreach ($bindings as $binding) {
$alpine = $binding['alpine'][0] !== null;
[$body, $offset] = $binding['alpine'][0] !== null ? $binding['alpine'] : ($binding['php'][0] !== null ? $binding['php'] : $binding['pair']);
// `:class` on a component is PHP, so it may wrap the very helpers `directiveInTag()`
// recommends: the array inside is the list, the call around it is not.
if ($alpine && preg_match('/^\s*[^\s(]*(?:Arr::toCssClasses|->class)\(\s*(\[.*\])\s*\)\s*$/s', $body, $call, PREG_OFFSET_CAPTURE) === 1) {
[$body, $offset] = [$call[1][0], $offset + $call[1][1]];
$alpine = false;
}
preg_match_all($alpine ? "/'([^']*)'|(?<=[{,])\\s*([A-Za-z_][\\w]*)\\s*:/" : "/'([^']*)'|\"([^\"]*)\"/", $body, $strings, PREG_OFFSET_CAPTURE | PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL);
foreach ($strings as $string) {
[$list, $inner] = $string[1][0] !== null ? $string[1] : $string[2];
$before = substr($body, 0, $string[0][1]);
$after = substr($body, $string[0][1] + strlen($string[0][0]));
if (preg_match('/(?:[=!]==?|[<>]=?|\(|\?\?)\s*$/', $before) === 1
|| preg_match('/^\s*(?:[=!]==?|[<>]=?)/', $after) === 1
|| $this->readsAValue($before)) {
continue;
}
$lists[] = [$list, $offset + $inner];
}
}
$found = [];
usort($lists, fn (array $a, array $b): int => $a[1] <=> $b[1]);
foreach ($lists as [$list, $offset]) {
$list = (string) preg_replace_callback('/\{\{.*?\}\}|\{!!.*?!!\}/s', fn (array $echo): string => (string) preg_replace('/[^\n]/', ' ', $echo[0]), $list);
foreach (preg_split('/\s+/', $list, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_OFFSET_CAPTURE) ?: [] as [$token, $at]) {
if (preg_match('/[$@{}\\\\]/', $token) !== 1 && substr_count($token, '(') === substr_count($token, ')')) {
$found[] = [substr_count(substr($contents, 0, $offset + $at), "\n") + 1, $token];
}
}
}
return $found;
}
/**
* Check (i): every class `literalClasses()` reads out of `$contents` that neither the
* application's own stylesheets nor the package's declare — a rule for it exists nowhere, so
* the class paints nothing, whatever it was once meant to do.
*
* @return list<array{0: int, 1: string}>
*/
protected function undeclaredClasses(string $contents): array
{
$found = [];
foreach ($this->literalClasses($contents) as [$line, $class]) {
if ($class === '' || isset($this->applicationClasses()[$class]) || isset($this->packageClasses()[$class])) {
continue;
}
$found[] = [$line, $class];
}
return $found;
}
/**
* Whether the string that follows `$before` — the text of a class list up to it — is a value
* the list reads rather than a class it writes: a call's argument (`in_array($size, ['xs',
* 'sm'])`) or an array index (`$block['base']`, `$header['class']`). Both open a delimiter
* that is still unclosed where the string sits, so this walks the text and keeps what is open:
* a `(` right after a name is a call, one after anything else groups an expression
* (`$cond ? 'a' : ($other ? 'b' : 'c')`), and a `[` right after a name, a `]` or a `)` reads
* an index, while any other `[` opens an array of classes.
*/
protected function readsAValue(string $before): bool
{
$open = [];
$length = strlen($before);
for ($i = 0; $i < $length; $i++) {
$previous = rtrim(substr($before, 0, $i));
$name = $previous !== '' && preg_match('/[\w\]\)]$/', $previous) === 1;
match ($before[$i]) {
'(' => $open[] = $name ? 'call' : 'group',
'[' => $open[] = $name ? 'index' : 'array',
')', ']' => array_pop($open),
default => null,
};
}
return in_array('call', $open, true) || in_array('index', $open, true);
}
/**
* Literal symbol names that do not exist: `<x-icon name="…">`, and an `icon="…"` or
* `icon-right="…"` on any component.
*
* @return list<array{0: int, 1: string}>
*/
protected function iconNames(string $contents): array
{
static $symbols = null;
$symbols ??= array_flip(SvgFile::symbolNames());
$iconTag = 'x-'.config('livewire-material.prefix', '').'icon';
preg_match_all('/<(x-[\w.:-]+)((?:[^>"]|"[^"]*")*)>/s', $contents, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
$unknown = [];
foreach ($tags as $tag) {
$attributes = $tag[1][0] === $iconTag ? 'name|icon|icon-right' : 'icon|icon-right';
preg_match_all('/\s(?:'.$attributes.')="([^"]*)"/', $tag[2][0], $values, PREG_OFFSET_CAPTURE);
foreach ($values[1] as [$name, $offset]) {
if ($name === '' || preg_match('/[{$@]/', $name) === 1 || isset($symbols[$name])) {
continue;
}
$unknown[] = [substr_count(substr($contents, 0, $tag[2][1] + $offset), "\n") + 1, $name];
}
}
return $unknown;
}
/**
* What a component tag takes in place of a directive: an attribute expression, which Blade
* compiles inside the tag.
*/
protected function directiveInTag(string $directive): string
{
return match ($directive) {
'@js' => 'use `{{ \\Illuminate\\Support\\Js::from(…) }}`',
'@json' => 'use `{{ json_encode(…) }}`',
'@class' => 'use `:class="\\Illuminate\\Support\\Arr::toCssClasses([…])"`',
'@style' => 'use `:style="\\Illuminate\\Support\\Arr::toCssStyles([…])"`',
'@entangle' => "use `\$wire.entangle('…')` in the Alpine expression",
'@disabled', '@checked', '@selected', '@readonly', '@required' => 'use `:'.substr($directive, 1).'="…"`',
default => 'use a `:prop` binding or `{{ }}`, or move it to a plain element inside the slot',
};
}
/**
* Blade compiles a component tag before its directives, so `<x-icon @class([...])>` or
* `x-show="ok(@js($v))"` on a component reaches the browser as literal text. Use `:class`
* and `{{ }}` there instead.
*
* @return list<array{0: int, 1: string}>
*/
protected function directivesInComponentTags(string $contents): array
{
preg_match_all('/<x-[\w.:-]+((?:[^>"]|"[^"]*")*)>/s', $contents, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
$found = [];
foreach ($tags as $tag) {
preg_match_all('/(?<![\w@])@(class|style|js|json|if|unless|isset|foreach|disabled|checked|selected|readonly|required|entangle)\b/', $tag[1][0], $directives, PREG_OFFSET_CAPTURE);
foreach ($directives[0] as [$directive, $offset]) {
$found[] = [substr_count(substr($contents, 0, $tag[1][1] + $offset), "\n") + 1, $directive];
}
}
return $found;
}
/**
* Every package tag used in `$contents`, in any of the three spellings — unprefixed, under
* the configured prefix, or `<x-livewire-material::…>` — each as [line, name, spelling].
* `spelling` is `plain`, `prefix` or `namespace`: only `plain` can be shadowed by an
* application component of the same name (Blade tries the application's own
* `resources/views/components/<name>.blade.php` — and a class-based one — before this
* package's registered anonymous path, whatever the configured prefix; a namespaced or
* prefixed tag always reaches the package). `<x-slot:…>` is not a component tag.
*
* @return list<array{0: int, 1: string, 2: string}>
*/
protected function packageTagUsages(string $contents): array
{
$prefix = (string) config('livewire-material.prefix', '');
preg_match_all('/<(x-[\w.:-]+)(?=[\s\/>])/', $contents, $tags, PREG_OFFSET_CAPTURE);
$found = [];
foreach ($tags[1] as [$tag, $offset]) {
if (str_starts_with($tag, 'x-slot')) {
continue;
}
$name = substr($tag, 2);
$spelling = 'plain';
if (str_contains($name, '::')) {
[$namespace, $name] = explode('::', $name, 2);
if ($namespace === 'livewire-material') {
$spelling = 'namespace';
} elseif ($prefix !== '' && $namespace === $prefix) {
$spelling = 'prefix';
} else {
continue;
}
} elseif (! static::isPackageTag($name)) {
continue;
}
$found[] = [substr_count(substr($contents, 0, $offset), "\n") + 1, $name, $spelling];
}
return $found;
}
/**
* Whether the application defines its own component of this name, found the way Blade itself
* looks before it reaches this package's anonymous path: an alias registered with
* `Blade::component()`, a class under the application's `View\\Components` namespace (its own
* root namespace, as Blade guesses it), or an anonymous
* `resources/views/components/<name>.blade.php` (the base view finder's `components.<name>`).
*/
protected function shadowedByApplication(string $name): bool
{
if (view()->exists('components.'.$name)) {
return true;
}
$blade = app('blade.compiler');
$compiler = new ComponentTagCompiler($blade->getClassComponentAliases(), $blade->getClassComponentNamespaces(), $blade);
try {
$class = $compiler->guessClassName($name);
} catch (RuntimeException) {
return false; // No application namespace to guess a class in.
}
return isset($blade->getClassComponentAliases()[$name])
|| $compiler->findClassByComponent($name) !== null
|| class_exists($class)
|| class_exists($class.'\\'.Str::afterLast($class, '\\'));
}
/**
* Every `->links()` call in `$contents` (Laravel's or Livewire's paginator, rendered outside
* a package component), each as the line it is on.
*
* @return list<int>
*/
protected function paginationUsages(string $contents): array
{
preg_match_all('/->links\s*\(/', $contents, $matches, PREG_OFFSET_CAPTURE);
return array_map(fn (array $match): int => substr_count(substr($contents, 0, $match[1]), "\n") + 1, $matches[0]);
}
/**
* Every hook in `HOOK_STYLESHEETS` a Blade view writes, each as [line, hook, stylesheet]: an
* attribute of a plain element or of a component tag other than those `except` names (blanked
* out first), or a name a PHP array or a script sets. A selector (`[data-md-list-row]`), a name
* in backticks and a longer name that starts with the hook write no hook.
*
* @return list<array{0: int, 1: string, 2: string}>
*/
protected function hookUsages(string $contents): array
{
$found = [];
foreach (self::HOOK_STYLESHEETS as $hook => ['stylesheet' => $stylesheet, 'except' => $except]) {
if (! str_contains($contents, $hook)) {
continue;
}
$markup = $this->withoutPackageTags($contents, $except);
preg_match_all('/(?<![\w`\[-])'.preg_quote($hook, '/').'(?![\w-])/', $markup, $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[0] as [, $offset]) {
$found[] = [substr_count(substr($markup, 0, $offset), "\n") + 1, $hook, static::packagePath('css').'/'.$stylesheet];
}
}
return $found;
}
/**
* `$contents` with the opening tag of each package component `$names` names — plain, under the
* configured prefix or `<x-livewire-material::…>` — blanked out, line breaks kept. A quoted
* attribute value or a `{{ … }}` inside the tag may hold a `>`.
*
* @param list<string> $names
*/
protected function withoutPackageTags(string $contents, array $names): string
{
if ($names === []) {
return $contents;
}
$prefix = (string) config('livewire-material.prefix', '');
$namespaces = $prefix !== '' ? ['livewire-material', $prefix] : ['livewire-material'];
return (string) preg_replace_callback(
'/<x-(?:(?:'.$this->alternation($namespaces).')::)?(?:'.$this->alternation($names).')(?=[\s\/>])(?:\{\{.*?\}\}|"[^"]*"|\'[^\']*\'|[^>"\'])*+>?/s',
fn (array $tag): string => (string) preg_replace('/[^\n]/', ' ', $tag[0]),
$contents,
);
}
/**
* Check (ii)'s findings for one file: a package tag whose stylesheet `$resolved` (every file
* `Stylesheets::resolvedFiles()` reached from the CSS entry, keyed by real path) does not
* contain, a tag the application shadows, a hook written by hand whose stylesheet it does not
* contain (unless `$readsHooks` is false, for a package view), and a `->links()` needing
* `pagination.css` — each only the first time `$reported` (shared across every file of one
* `violations()` run) sees it, at the earliest line in the file that needs it.
*
* @param array<string, true> $resolved
* @param array<string, true> $reported
* @return list<array{0: int, 1: string}>
*/
protected function missingStylesheetViolations(string $contents, array $resolved, array &$reported, bool $readsHooks = true): array
{
$found = [];
$needs = [];
foreach ($this->packageTagUsages($contents) as [$line, $name, $spelling]) {
if ($spelling === 'plain' && $this->shadowedByApplication($name)) {
if (! isset($reported["shadow:{$name}"])) {
$reported["shadow:{$name}"] = true;
$found[] = [$line, "`<x-{$name}>` is shadowed by the application's own component of the same name — the package's `<x-{$name}>` never renders here"];
}
continue;
}
if (($stylesheet = static::packageStylesheetFor($name)) !== null) {
$needs[] = [$line, "`<x-{$name}>`", $stylesheet];
}
}
if ($readsHooks) {
foreach ($this->hookUsages($contents) as [$line, $hook, $stylesheet]) {
$needs[] = [$line, "`{$hook}`", $stylesheet];
}
}
foreach ($this->paginationUsages($contents) as $line) {
$needs[] = [$line, '`->links()`', static::packagePath('css').'/components/pagination.css'];
}
usort($needs, fn (array $a, array $b): int => $a[0] <=> $b[0]);
foreach ($needs as [$line, $what, $stylesheet]) {
if (! isset($resolved[$stylesheet]) && ! isset($reported[$stylesheet])) {
$reported[$stylesheet] = true;
$found[] = [$line, sprintf(
"%s needs `%s`, missing from %s — add `@import '%s';`",
$what,
static::packageRelativeName($stylesheet),
$this->relative($this->cssEntry),
$this->importLine($stylesheet),
)];
}
}
return $found;
}
/**
* Every `.css` file this guard reads for check (iii): the ones `scan()`'s paths hold, outside
* the package, the generated `material-scheme.css` and a mail theme under a mail component path
* excluded. The CSS entry's imports are not followed here — a vendor stylesheet it pulls in is
* not the application's to put on tokens.
*
* @return list<string>
*/
protected function applicationStylesheets(): array
{
$files = [];
foreach ($this->paths as $path) {
if (is_file($path)) {
if (str_ends_with($path, '.css')) {
$files[] = (string) realpath($path);
}
continue;
}
if (is_dir($path)) {
foreach (Finder::create()->files()->in($path)->name('*.css')->sortByName() as $file) {
$files[] = $file->getRealPath();
}
}
}
$mailPaths = $this->mailComponentPaths();
return array_values(array_unique(array_filter(
$files,
fn (string $file): bool => $file !== '' && ! static::isPackageFile($file) && ! $this->isGeneratedScheme($file) && ! $this->isUnder($file, $mailPaths),
)));
}
/**
* The Markdown mail component paths Laravel renders mail from (`mail.markdown.paths`), as real
* paths; one that does not exist is left out. See the class header.
*
* @return list<string>
*/
protected function mailComponentPaths(): array
{
return array_values(array_filter(array_map(
fn (mixed $path): string => is_string($path) ? (string) realpath($path) : '',
(array) config('mail.markdown.paths', []),
)));
}
/**
* @param list<string> $directories Real paths.
*/
protected function isUnder(string $file, array $directories): bool
{
$file = realpath($file) ?: $file;
foreach ($directories as $directory) {
if (str_starts_with($file, rtrim($directory, '/').'/')) {
return true;
}
}
return false;
}
/**
* Every class an application's own stylesheets select on — the scanned ones and whatever the
* CSS entry imports outside the package — one half of what check (i) counts as declared.
*
* @return array<string, true>
*/
protected function applicationClasses(): array
{
if ($this->applicationClassesCache !== null) {
return $this->applicationClassesCache;
}
$classes = [];
$files = $this->applicationStylesheets();
if ($this->cssEntry !== null) {
$files = array_unique([...$files, ...array_filter(
Stylesheets::resolvedFiles([$this->cssEntry]),
fn (string $file): bool => ! static::isPackageFile($file),
)]);
}
foreach ($files as $file) {
$classes = [...$classes, ...$this->classSelectors((string) file_get_contents($file))];
}
return $this->applicationClassesCache = $classes;
}
/**
* Every class the package's own `resources/css` declares — `md-*`, the component rules and the
* showcase's own classes alike — the other half of check (i)'s "declared". Whether an
* application imports the stylesheet that holds the rule is check (ii)'s question, not this
* one's. Cached for the process: the package's CSS is the same for every scan in it.
*
* @return array<string, true>
*/
protected function packageClasses(): array
{
static $classes = null;
if ($classes !== null) {
return $classes;
}
$classes = [];
$root = static::packagePath('css');
foreach ($root === '' ? [] : Finder::create()->files()->in($root)->name('*.css') as $file) {
$classes = [...$classes, ...$this->classSelectors((string) file_get_contents($file->getPathname()))];
}
return $classes;
}
/**
* The classes `$css` selects on, by their unescaped name (`.sm\:flex` counts as `sm:flex`).
* Comments and strings are blanked out first, so `content: ".flex"` declares nothing.
*
* @return array<string, true>
*/
protected function classSelectors(string $css): array
{
preg_match_all('/(?<![\w.#\\\\-])\.(-?(?:[a-zA-Z_]|\\\\.)(?:[\w-]|\\\\.)*)/', $this->maskedCss($css), $matches);
$classes = [];
foreach ($matches[1] as $class) {
$classes[stripslashes($class)] = true;
}
return $classes;
}
protected function isGeneratedScheme(string $file): bool
{
if (basename($file) === 'material-scheme.css') {
return true;
}
return str_contains((string) file_get_contents($file), "generated by Google's material-color-utilities");
}
/**
* Whether `$file` sits inside this package's own `resources/css` — the application's stylesheets
* (check iii) never come from the package's own rules, and its own classes are
* `packageClasses()`, read separately.
*/
protected static function isPackageFile(string $file): bool
{
$root = static::packagePath('css');
return $root !== '' && ($file === $root || str_starts_with($file, $root.'/'));
}
/**
* A folder of this package's own `resources/` (`css`, `views`, `views/components`), from this
* file's own location — works whether the class loads from `vendor/nonameweb/livewire-material`
* (a consuming application) or from this repository itself. Empty when it does not exist.
*/
protected static function packagePath(string $folder): string
{
return (string) realpath(dirname(__DIR__, 2)."/resources/{$folder}");
}
protected static function isPackageTag(string $name): bool
{
$root = static::packagePath('views/components');
return $root !== '' && is_file("{$root}/{$name}.blade.php");
}
/**
* The package stylesheet a component view of this name draws from — `components/<name>.css`
* or `layout/<name>.css`, whichever exists — mapped from the files that exist rather than a
* hand-kept list. Null when the view has no stylesheet of its own (`theme-script`) or the
* name is not a package component at all.
*/
protected static function packageStylesheetFor(string $name): ?string
{
if (! static::isPackageTag($name)) {
return null;
}
foreach (['components', 'layout'] as $group) {
$file = static::packagePath('css')."/{$group}/{$name}.css";
if (is_file($file)) {
return $file;
}
}
return null;
}
protected static function packageRelativeName(string $file): string
{
$root = static::packagePath('css').'/';
return str_starts_with($file, $root) ? substr($file, strlen($root)) : $file;
}
/**
* The `@import` target `$file` should be written as, from the CSS entry's own directory —
* computed from the real paths, so it reads `../vendor/nonameweb/livewire-material/…` from a
* consuming application and `../resources/css/…` inside this repository, whichever is real.
*/
protected function importLine(string $file): string
{
return $this->importLineFrom((string) $this->cssEntry, $file);
}
/** `importLine()` for an entry other than `missingStylesheets()`'s. */
protected function importLineFrom(string $entry, string $file): string
{
$entryDir = dirname((string) (realpath($entry) ?: $entry));
$vendor = base_path('vendor/nonameweb/livewire-material/resources/css');
// A Composer path repository may symlink the package: the import still goes through vendor/.
if (realpath($vendor) === static::packagePath('css') && str_starts_with($file, static::packagePath('css').'/')) {
$file = $vendor.substr($file, strlen(static::packagePath('css')));
}
return static::relativeImportPath($entryDir, $file);
}
protected static function relativeImportPath(string $fromDir, string $toFile): string
{
$from = array_values(array_filter(explode('/', $fromDir), fn (string $part): bool => $part !== ''));
$to = array_values(array_filter(explode('/', $toFile), fn (string $part): bool => $part !== ''));
$i = 0;
while ($i < count($from) && $i < count($to) && $from[$i] === $to[$i]) {
$i++;
}
$path = implode('/', [...array_fill(0, count($from) - $i, '..'), ...array_slice($to, $i)]);
return str_starts_with($path, '..') ? $path : "./{$path}";
}
protected function relative(string $path): string
{
$base = rtrim(base_path(), '/').'/';
return str_starts_with($path, $base) ? substr($path, strlen($base)) : $path;
}
}