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>
1456 lines
63 KiB
PHP
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;
|
|
}
|
|
}
|