Plan step 41(i): every Tailwind utility or variant in a Tailwind-free application's views now gets a 2.0.0 hint instead of the old Tailwind-utility replacement (which no longer compiles either) -- a layout component and prop, an md-* class, or a var(--md-sys-*) token. New families cover flex/grid/gap, spacing, sizing, display, text-layout and the M3 role utilities (text-on-surface-variant, border-outline-variant, bg-surface-*, bg-primary); the breakpoint and scale checks fold in with their hints rewritten the same way. A class the application's own stylesheets declare is exempt. literalClasses() now returns each class token unstripped of its variant prefix (`sm:`, `hover:`) so the new checks can see it; the daisyUI check strips it itself via the new withoutVariantPrefix() helper, as it always did internally before. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
1248 lines
55 KiB
PHP
1248 lines
55 KiB
PHP
<?php
|
||
|
||
namespace NoNameWeb\LivewireMaterial\Testing;
|
||
|
||
use Illuminate\Support\Str;
|
||
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
|
||
use NoNameWeb\LivewireMaterial\Support\SvgFile;
|
||
use SplFileInfo;
|
||
use Symfony\Component\Finder\Finder;
|
||
|
||
/**
|
||
* Finds what compiles to nothing in a Tailwind-free application (plan step 41), and what a
|
||
* Tailwind migration still leaves behind:
|
||
*
|
||
* (i) any Tailwind utility or variant in a view, PHP or JS file — a breakpoint prefix, a
|
||
* cleared scale (radius, shadow, type size/weight/leading/tracking, easing, duration), a
|
||
* layout, spacing, sizing or display utility, a text-layout utility, an M3 role utility
|
||
* (`text-on-surface-variant`, `bg-primary`), a pseudo-class variant, or an arbitrary
|
||
* `[…]` value — each with its 2.0.0 replacement: a layout component and prop, an `md-*`
|
||
* class, or a token for the application's own CSS. A class the application's own
|
||
* stylesheets declare is exempt, and so is every `md-*` class and the package's shared
|
||
* interaction hooks (`md-state-layer`, `md-focus-ring`, `md-touch-target`, `md-link`).
|
||
* (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 `@import` graph does not reach (followed through every package file's own
|
||
* imports, `Support\Stylesheets` resolves), and `->links()` needing `pagination.css`;
|
||
* each names the missing `@import` line to add. 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.
|
||
* (iii) the application's own CSS (the entry and what it imports outside the package, plus any
|
||
* `.css` file the scanned paths hold directly), 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 value inside
|
||
* `var(--md-sys-…)` or `calc()` is never flagged, whatever it contains.
|
||
* (iv) maryUI tags, daisyUI classes, colours the theme does not declare, 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()`,
|
||
* `forbidAbsolutes()`, `forbidOpacityInk()` and `forbid()`. These, and the breakpoint,
|
||
* scale and colour-value checks, read a line at a time, so a class assembled at runtime
|
||
* (`'text-'.$tone`) or hidden in a comment stays invisible — the same reason to write
|
||
* class names out whole. `forbidAbsolutes()` and `forbidOpacityInk()` matter mainly to an
|
||
* application still migrating off Tailwind, where the package's own opacity-based
|
||
* disabled state is still the only legitimate use of either.
|
||
*
|
||
* expect(DesignGuard::scan([resource_path('views'), resource_path('js'), 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. False positives are
|
||
* kept low two ways: family (i)'s new checks — everything but the breakpoint, scale and
|
||
* colour-value patterns kept from before 2.0.0 — match only a class already isolated from a
|
||
* `class`/`@class`/`:class`/`x-bind:class`/`->class()`/`Arr::toCssClasses()` list in a `.blade.php`
|
||
* file, never a bare word scanned across a whole line, which is what keeps an English sentence
|
||
* ("this creates a grid of cards") from matching `grid`; and a line-by-line match (used for the
|
||
* breakpoint, scale and colour-value families, which also have to see a class assembled as a
|
||
* plain PHP or JS string, as an enum's own literal colour string does) requires the utility's actual
|
||
* shape — a digit, a known scale step or a colour function — never a bare word. The trade-off:
|
||
* `Arr::toCssClasses()` and `->class()` outside a `.blade.php` file, and a bare-word utility
|
||
* (`flex`, `hidden`) anywhere but inside a class list, are invisible to this guard.
|
||
*/
|
||
class DesignGuard
|
||
{
|
||
protected const string UTILITY = '(?:bg|text|border(?:-[trblxyse])?|ring|ring-offset|fill|stroke|from|via|to|outline|divide|decoration|caret|accent|shadow|placeholder)';
|
||
|
||
protected const string PALETTE = '(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone)-(?:50|[1-9]00|950)';
|
||
|
||
protected const string DAISY_COLOURS = '(?:base-(?:100|200|300|content)|(?:primary|secondary|accent|neutral|info|success|warning|error)-content|accent|neutral)';
|
||
|
||
/**
|
||
* daisyUI's component classes. Tailwind utilities that share a name — `collapse`, `table`,
|
||
* `select-none`, `tab-4` — and the package's own `link` are not on it.
|
||
*/
|
||
protected const string DAISY_CLASSES = '/^(?:btn|badge|card|alert|modal|drawer|dropdown|menu|navbar|footer|hero|stats?|tabs?|tooltip|toast|toggle|checkbox|radio|range|rating|input|select|textarea|file-input|fieldset|label|join|kbd|loading|progress|radial-progress|skeleton|steps?|timeline|swap|indicator|avatar|divider|dock|fab|status|validator|breadcrumbs|carousel|chat|countdown|diff|stack|theme-controller)(?:-[a-z0-9-]+)?$'
|
||
.'|^(?:table-(?:zebra|xs|sm|md|lg|xl|pin-rows|pin-cols)|collapse-(?:arrow|plus|title|content|open|close)|list-row|link-(?:primary|secondary|accent|neutral|info|success|warning|error))$/';
|
||
|
||
protected const string TAILWIND_LOOKALIKES = '/^(?:select-(?:none|text|all|auto)|tab-\d+)$/';
|
||
|
||
/** A colour written as a value: an arbitrary hex, function or mix instead of a role. */
|
||
protected const string ARBITRARY_COLOUR = '/(?<![\w-])'.self::UTILITY.'-\[(?:#|rgb|hsl|oklch|color-mix)[^\]\s"\']*\]?/';
|
||
|
||
/** The two absolutes. M3's white is a surface role, not a colour. */
|
||
protected const string ABSOLUTE_COLOUR = '/(?<![\w-])'.self::UTILITY.'-(?:white|black)(?![\w-])/';
|
||
|
||
/** Opacity used as emphasis on ink, which M3 expresses as a role. */
|
||
protected const string OPACITY_INK = '/(?<![\w-])(?:bg|text|border(?:-[trblxyse])?)-[a-z][a-z\d-]*\/\d{1,3}(?![\w-])/';
|
||
|
||
/**
|
||
* Tailwind's breakpoint prefixes and the M3 window size class each names. Tailwind's
|
||
* 640/768/1024/1280/1536 are 40–88 px from M3's 600/840/1200/1600, so a prefix maps to the
|
||
* class that carries the same intent, never to the same pixel: both of its phone breakpoints
|
||
* are M3's medium. Since 2.0.0 the prefix itself compiles to nothing — there is no Tailwind
|
||
* left to read it — so the hint points at the layout components' props and a plain media
|
||
* query instead of another class.
|
||
*/
|
||
protected const array WINDOW_CLASSES = [
|
||
'sm' => 'medium',
|
||
'md' => 'medium',
|
||
'lg' => 'expanded',
|
||
'xl' => 'large',
|
||
'2xl' => 'extra-large',
|
||
];
|
||
|
||
/** The M3 breakpoint each window size class is, in px (foundations.md § Layout). */
|
||
protected const array WINDOW_PX = [
|
||
'medium' => 600,
|
||
'expanded' => 840,
|
||
'large' => 1200,
|
||
'extra-large' => 1600,
|
||
];
|
||
|
||
/** Tailwind's radius scale and the M3 corner it replaces each (styles §Shape). */
|
||
protected const array CORNERS = [
|
||
'none' => 'none',
|
||
'xs' => 'xs',
|
||
'sm' => 'sm',
|
||
'md' => 'md',
|
||
'lg' => 'lg',
|
||
'xl' => 'xl',
|
||
'2xl' => 'xxl',
|
||
'3xl' => 'xxl',
|
||
'4xl' => 'xxl',
|
||
'full' => 'full',
|
||
];
|
||
|
||
/** Tailwind's shadow scale and the M3 elevation level it replaces each (styles §Elevation). */
|
||
protected const array ELEVATIONS = [
|
||
'2xs' => 1,
|
||
'xs' => 1,
|
||
'sm' => 1,
|
||
'md' => 2,
|
||
'lg' => 3,
|
||
'xl' => 4,
|
||
'2xl' => 5,
|
||
];
|
||
|
||
/**
|
||
* Tailwind's flex, grid and gap utilities: `<x-row>`, `<x-grid>` and `<x-stack>` and their
|
||
* `gap`/`align`/`justify`/`columns` props replace every one of them.
|
||
*/
|
||
protected const string LAYOUT_UTILITY = '/^(?:flex|inline-flex|grid|inline-grid|contents|flex-(?:row|row-reverse|col|col-reverse|wrap|wrap-reverse|nowrap|1|auto|initial|none)|grid-cols-\d+|grid-rows-\d+|col-span-\d+|row-span-\d+|(?:items|justify|content|place-items|place-content|place-self|self)-[a-z]+|gap(?:-[xy])?-[\w.\/]+)$/';
|
||
|
||
/** Tailwind's padding, margin and gap-adjacent space-between utilities. */
|
||
protected const string SPACING_UTILITY = '/^(?:[pm][trblxyse]?|space-[xy])-[\w.\/-]+$/';
|
||
|
||
/** Tailwind's width and height utilities. M3 keeps no size scale of its own. */
|
||
protected const string SIZING_UTILITY = '/^(?:min-|max-)?(?:w|h|size)-[\w.\/%-]+$/';
|
||
|
||
/**
|
||
* Bare display utilities: no digit or bracket sets them apart from an English word, so these
|
||
* are matched only as a whole class-list token (never scanned line by line), which is what
|
||
* keeps "hidden" or "block" in running text from matching.
|
||
*/
|
||
protected const array DISPLAY_UTILITY = ['block', 'inline-block', 'inline', 'invisible', 'visible'];
|
||
|
||
/** Text-layout utilities with a direct `md-*` replacement (text.css). */
|
||
protected const array TEXT_LAYOUT_UTILITY = [
|
||
'text-left' => 'md-text-start',
|
||
'text-center' => 'md-text-center',
|
||
'text-right' => 'md-text-end',
|
||
'truncate' => 'md-truncate',
|
||
'line-clamp-2' => 'md-line-clamp-2',
|
||
'line-clamp-3' => 'md-line-clamp-3',
|
||
'whitespace-nowrap' => 'md-nowrap',
|
||
'sr-only' => 'md-visually-hidden',
|
||
'tabular-nums' => 'md-tabular',
|
||
];
|
||
|
||
/** An ink role Tailwind's `text-*` utility named, and the `md-ink-*` class that replaces it. */
|
||
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-size' => ['kind' => 'font size', 'hint' => 'use `var(--md-sys-typescale-*)`, which sets size, line height and weight together'],
|
||
'font-weight' => ['kind' => 'font weight', 'hint' => 'use `var(--md-sys-typescale-*)` or `var(--md-sys-typescale-emphasized-*)`'],
|
||
'line-height' => ['kind' => 'line height', 'hint' => 'use `var(--md-sys-typescale-*)`, which sets it with the size'],
|
||
'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 bool $forbiddenAbsolutes = false;
|
||
|
||
protected bool $forbiddenOpacityInk = false;
|
||
|
||
protected ?string $cssEntry = 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.
|
||
*
|
||
* @param list<string> $roles
|
||
*/
|
||
public function forbidColours(array $roles): static
|
||
{
|
||
$this->forbiddenColours = [...$this->forbiddenColours, ...$roles];
|
||
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Also ban the two absolutes. M3 paints with roles only: the white of a light page is
|
||
* `surface-container-lowest`, the black of a dark one `surface-dim`, and the ink on a filled
|
||
* button is its `on-` role. Off by default, because a logo, a scrim or a print stylesheet
|
||
* sometimes does mean the absolute — and because, until an application finishes leaving
|
||
* Tailwind, `bg-white`/`text-black` may still be sitting in an untouched view.
|
||
*/
|
||
public function forbidAbsolutes(): static
|
||
{
|
||
$this->forbiddenAbsolutes = true;
|
||
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Also ban opacity as emphasis on ink: M3 says secondary text is a role — on-surface-variant
|
||
* for supporting text, outline for the quietest — never a faded on-surface. It reserves two
|
||
* opacities for the disabled state, 38 % on content and 12 % on a container, and the
|
||
* package's own disabled styles are written with those two, which is why this is off by
|
||
* default; an application that draws its disabled states from the components does not need
|
||
* either opacity of its own.
|
||
*/
|
||
public function forbidOpacityInk(): static
|
||
{
|
||
$this->forbiddenOpacityInk = true;
|
||
|
||
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::…>` — and every `->links()` call, 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. Also
|
||
* turns on check (iii), the application's own CSS: `$cssEntry` and what it imports outside
|
||
* the package become part of what that check reads, alongside any `.css` file `scan()`'s own
|
||
* paths hold directly.
|
||
*/
|
||
public function missingStylesheets(string $cssEntry): static
|
||
{
|
||
$this->cssEntry = $cssEntry;
|
||
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* @return list<string>
|
||
*/
|
||
public function violations(): array
|
||
{
|
||
$violations = [];
|
||
$resolved = $this->cssEntry !== null
|
||
? array_fill_keys(Stylesheets::resolvedFiles([$this->cssEntry]), true)
|
||
: [];
|
||
|
||
if ($this->cssEntry !== null) {
|
||
$foundation = static::packageCssRoot().'/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),
|
||
);
|
||
}
|
||
}
|
||
|
||
foreach ($this->files() as $file) {
|
||
$contents = (string) file_get_contents($file->getPathname());
|
||
$where = $this->relative($file->getPathname());
|
||
$isBlade = str_ends_with($file->getFilename(), '.blade.php');
|
||
|
||
if ($isBlade) {
|
||
$contents = $this->withoutBladeComments($contents);
|
||
|
||
foreach ($this->literalClasses($contents) as [$line, $class]) {
|
||
$bare = $this->withoutVariantPrefix($class);
|
||
|
||
if (preg_match(self::DAISY_CLASSES, $bare) === 1 && preg_match(self::TAILWIND_LOOKALIKES, $bare) !== 1) {
|
||
$violations[] = "{$where}:{$line} daisyUI class `{$bare}`";
|
||
}
|
||
|
||
if (($hint = $this->tailwindFamilyHint($class)) !== null) {
|
||
$violations[] = "{$where}:{$line} {$hint}";
|
||
}
|
||
}
|
||
|
||
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";
|
||
}
|
||
|
||
if ($this->cssEntry !== null) {
|
||
foreach ($this->missingStylesheetViolations($contents, $resolved) as [$line, $what]) {
|
||
$violations[] = "{$where}:{$line} {$what}";
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach (explode("\n", $contents) as $index => $text) {
|
||
$line = $index + 1;
|
||
|
||
if ($isBlade && preg_match_all('/<x-mary-[\w.:-]+/', $text, $matches)) {
|
||
foreach ($matches[0] as $tag) {
|
||
$violations[] = "{$where}:{$line} maryUI component `{$tag}`";
|
||
}
|
||
}
|
||
|
||
if (preg_match_all($this->colourPattern(), $text, $matches)) {
|
||
foreach ($matches[0] as $class) {
|
||
$violations[] = "{$where}:{$line} colour the theme does not declare `{$class}`";
|
||
}
|
||
}
|
||
|
||
foreach ($this->offTheTokens($text) as $what) {
|
||
$violations[] = "{$where}:{$line} {$what}";
|
||
}
|
||
|
||
foreach ($this->forbidden as $rule) {
|
||
if (preg_match($rule['pattern'], $text) === 1) {
|
||
$violations[] = "{$where}:{$line} {$rule['reason']}";
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return [...$violations, ...$this->applicationCssViolations()];
|
||
}
|
||
|
||
/**
|
||
* @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();
|
||
}
|
||
}
|
||
}
|
||
|
||
protected function colourPattern(): string
|
||
{
|
||
$names = [self::PALETTE, self::DAISY_COLOURS];
|
||
|
||
foreach ($this->forbiddenColours as $role) {
|
||
$role = preg_quote($role, '/');
|
||
$names[] = "(?:on-)?{$role}(?:-container)?";
|
||
}
|
||
|
||
return '/(?<![\w-])'.self::UTILITY.'-(?:'.implode('|', $names).')(?![\w-])/';
|
||
}
|
||
|
||
/**
|
||
* Everything on one line that names a value the theme no longer carries, each with the M3
|
||
* token or component that replaces it. Line by line like the colour check, so a class inside
|
||
* a PHP or JS string is seen too, not only one inside a `class` attribute — the reach these
|
||
* three families have always needed (a plain PHP string returning a stray colour name), and the reason they
|
||
* stay a line-by-line match rather than moving to the class-token check every newer family
|
||
* uses (see the class header).
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
protected function offTheTokens(string $text): array
|
||
{
|
||
return [
|
||
...$this->breakpointPrefixes($text),
|
||
...$this->outsideTheScale($text),
|
||
...$this->colourValues($text),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* `sm:`, `max-2xl:` and the rest, stacked variants included. A prefix must be followed at
|
||
* once by the next variant or the utility itself, so a `md:` that is really an object key
|
||
* (`md: { … }` in a script) is left alone.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
protected function breakpointPrefixes(string $text): array
|
||
{
|
||
static $pattern = null;
|
||
$pattern ??= '/(?<![\w-])(?<max>max-)?(?<name>'.$this->alternation(array_keys(self::WINDOW_CLASSES)).'):(?=[a-z\d!*\[(_-])/';
|
||
|
||
preg_match_all($pattern, $text, $matches, PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL);
|
||
|
||
return array_map(function (array $match): string {
|
||
$max = $match['max'] ?? '';
|
||
$name = self::WINDOW_CLASSES[$match['name']];
|
||
$px = self::WINDOW_PX[$name];
|
||
$comparison = $max !== '' ? "width < {$px}px" : "width >= {$px}px";
|
||
|
||
return "Tailwind breakpoint `{$max}{$match['name']}:` compiles to nothing — M3's {$name} ({$px}px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media ({$comparison})` in your own CSS";
|
||
}, $matches);
|
||
}
|
||
|
||
/**
|
||
* The radius, shadow, type-size, weight, leading, tracking, easing and duration utilities
|
||
* Tailwind shipped and M3's own scales replace.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
protected function outsideTheScale(string $text): array
|
||
{
|
||
static $pattern = null;
|
||
$pattern ??= '/(?<![\w-])(?:'
|
||
.'rounded(?<side>-(?:ss|se|ee|es|tl|tr|br|bl|t|r|b|l|s|e))?-(?<corner>'.$this->alternation(array_keys(self::CORNERS)).')'
|
||
.'|shadow-(?<elevation>'.$this->alternation(array_keys(self::ELEVATIONS)).')'
|
||
.'|text-(?<size>xs|sm|base|lg|xl|[2-9]xl)'
|
||
.'|font-(?<weight>thin|extralight|light|normal|medium|semibold|bold|extrabold|black)'
|
||
.'|leading-(?<leading>none|tight|snug|normal|relaxed|loose|\d+(?:\.\d+)?)'
|
||
.'|tracking-(?<tracking>tighter|tight|normal|wider|widest|wide)'
|
||
.'|ease-(?<easing>in-out|linear|in|out)'
|
||
.'|duration-(?<duration>\d+)'
|
||
.')(?![\w-])/';
|
||
|
||
preg_match_all($pattern, $text, $matches, PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL);
|
||
|
||
return array_map(
|
||
fn (array $match): string => "value outside the M3 scale `{$match[0]}` — ".$this->scaleReplacement($match),
|
||
$matches,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The 2.0.0 replacement for one match of the scale pattern: a token for the application's own
|
||
* CSS, or (for text) one of the `md-type-*` classes, which set size, line height, weight and
|
||
* tracking together — never a Tailwind utility, since none compiles any more.
|
||
*
|
||
* @param array<array-key, string|null> $match
|
||
*/
|
||
protected function scaleReplacement(array $match): string
|
||
{
|
||
return match (true) {
|
||
isset($match['corner']) => 'use `var(--md-sys-shape-corner-'.self::CORNERS[$match['corner']].')` in your own CSS, or `<x-surface corner="'.self::CORNERS[$match['corner']].'">`',
|
||
isset($match['elevation']) => 'use `var(--md-sys-elevation-'.self::ELEVATIONS[$match['elevation']].')` in your own CSS',
|
||
isset($match['weight']) => 'use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
|
||
isset($match['easing']) => 'pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition`',
|
||
isset($match['duration']) => 'pair `var(--md-sys-motion-…-duration)` with its easing in your own `transition`',
|
||
default => 'use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Colours written as a value rather than a role: an arbitrary one always, the two absolutes
|
||
* and opacity on ink when the application asks for them.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
protected function colourValues(string $text): array
|
||
{
|
||
$found = [];
|
||
|
||
preg_match_all(self::ARBITRARY_COLOUR, $text, $matches);
|
||
|
||
foreach ($matches[0] as $class) {
|
||
$found[] = "arbitrary colour `{$class}`, use an M3 role";
|
||
}
|
||
|
||
if ($this->forbiddenAbsolutes) {
|
||
preg_match_all(self::ABSOLUTE_COLOUR, $text, $matches);
|
||
|
||
foreach ($matches[0] as $class) {
|
||
$found[] = "absolute colour `{$class}`, use an M3 role";
|
||
}
|
||
}
|
||
|
||
if ($this->forbiddenOpacityInk) {
|
||
preg_match_all(self::OPACITY_INK, $text, $matches);
|
||
|
||
foreach ($matches[0] as $class) {
|
||
$found[] = "opacity on ink `{$class}`, use a role (`text-on-surface-variant`, `text-outline`)";
|
||
}
|
||
}
|
||
|
||
return $found;
|
||
}
|
||
|
||
/**
|
||
* `$token` with a variant prefix — a breakpoint, a stacked pseudo-class chain — and a leading
|
||
* `!` dropped, so the daisyUI check can still recognise a variant-prefixed daisy class for
|
||
* what it is underneath. `tailwindFamilyHint()` reports the prefix itself (see the variant
|
||
* family below), so it works from the token `literalClasses()` returns, unstripped.
|
||
*/
|
||
protected function withoutVariantPrefix(string $token): string
|
||
{
|
||
return ltrim((string) preg_replace('/^.*:/', '', $token), '!');
|
||
}
|
||
|
||
/**
|
||
* A Tailwind-shaped class token's 2.0.0 replacement, or null when the token is not this
|
||
* guard's to report: it is not Tailwind-shaped at all (an application's own class, an ARIA or
|
||
* data token, a plain word), it is on the small set nothing here ever flags (`md-*` — the
|
||
* text classes and the shared interaction hooks), or the application's own CSS declares it
|
||
* (`applicationClasses()`). Only ever called with a token `literalClasses()` already isolated
|
||
* from a class list, which is what makes it safe to match a bare word like `flex` or `hidden`
|
||
* (see the class header's false-positive note).
|
||
*/
|
||
protected function tailwindFamilyHint(string $token): ?string
|
||
{
|
||
if ($token === '' || str_starts_with($token, 'md-') || isset($this->applicationClasses()[$token])) {
|
||
return null;
|
||
}
|
||
|
||
$token = rtrim($token, '!');
|
||
|
||
if (preg_match(self::ARBITRARY_COLOUR, $token) === 1) {
|
||
return null; // colourValues() already reports this, line by line.
|
||
}
|
||
|
||
if (str_contains($token, ':')) {
|
||
$lead = explode(':', $token, 2)[0];
|
||
$bare = str_starts_with($lead, 'max-') ? substr($lead, 4) : $lead;
|
||
|
||
if (isset(self::WINDOW_CLASSES[$bare])) {
|
||
return null; // breakpointPrefixes() already names the M3 breakpoint for this one.
|
||
}
|
||
|
||
return "Tailwind variant class `{$token}` compiles to nothing — a state is `:has()`/`aria-*`/`data-md-*` in your own CSS, a breakpoint a layout component's `hide-below`/`hide-from`/`stack-below` prop";
|
||
}
|
||
|
||
if (preg_match('/\[[^\]]*\]/', $token) === 1) {
|
||
return "Tailwind arbitrary value `{$token}` compiles to nothing — write the literal value in your own CSS, or use an M3 token";
|
||
}
|
||
|
||
if (preg_match(self::LAYOUT_UTILITY, $token) === 1) {
|
||
return "Tailwind layout utility `{$token}` compiles to nothing — use `<x-row>`, `<x-grid>` or `<x-stack>` and their `gap`/`align`/`justify`/`columns` props";
|
||
}
|
||
|
||
if (preg_match(self::SPACING_UTILITY, $token) === 1) {
|
||
return "Tailwind spacing utility `{$token}` compiles to nothing — use a layout component's `gap`/`padding` prop (`<x-stack gap>`, `<x-surface padding>`), or `var(--md-sys-measurement-space*)` in your own CSS";
|
||
}
|
||
|
||
if (preg_match(self::SIZING_UTILITY, $token) === 1) {
|
||
return "Tailwind sizing utility `{$token}` compiles to nothing — M3 keeps no size scale; write the literal length in your own CSS, or use `<x-pane width>`/`<x-grid min-item>` where it fits";
|
||
}
|
||
|
||
if ($token === 'hidden') {
|
||
return "Tailwind's `hidden` compiles to nothing — use a layout component's `hide-below`/`hide-from` prop, or the reset's `[hidden]` attribute";
|
||
}
|
||
|
||
if (in_array($token, self::DISPLAY_UTILITY, true)) {
|
||
return "Tailwind display utility `{$token}` compiles to nothing — write the `display` rule in your own CSS";
|
||
}
|
||
|
||
if (isset(self::TEXT_LAYOUT_UTILITY[$token])) {
|
||
return "Tailwind's `{$token}` compiles to nothing — use `".self::TEXT_LAYOUT_UTILITY[$token].'` (text.css)';
|
||
}
|
||
|
||
if (preg_match('/^text-(?<role>on-surface(?:-variant)?|outline|primary|error|success|warning|info|inverse-on-surface)$/', $token, $m) === 1) {
|
||
return "Tailwind ink role `{$token}` compiles to nothing — use `".self::INK_ROLE[$m['role']].'` (text.css)';
|
||
}
|
||
|
||
if (preg_match('/^(?:border(?:-[trblxyse])?|divide|ring)-outline(?:-variant)?$/', $token) === 1) {
|
||
return "Tailwind line role `{$token}` compiles to nothing — use `<x-divider>` or `<x-surface outlined>` for a line, not a border utility";
|
||
}
|
||
|
||
if (preg_match('/^bg-(?<role>surface(?:-dim|-bright|-container(?:-lowest|-low|-high|-highest)?)?)$/', $token, $m) === 1) {
|
||
return "Tailwind surface role `{$token}` compiles to nothing — use `<x-surface level=\"{$m['role']}\">`";
|
||
}
|
||
|
||
if (preg_match('/^(?:bg|border(?:-[trblxyse])?|ring|fill|stroke|divide|decoration|accent|caret|outline)-(?:on-)?(?:primary|secondary|tertiary|error|success|warning|info)(?:-container)?$/', $token) === 1) {
|
||
return "Tailwind role utility `{$token}` compiles to nothing — use `var(--md-sys-color-*)` in your own CSS";
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 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) {
|
||
$css = $this->withoutTokenFunctions($this->withoutCssComments((string) file_get_contents($file)));
|
||
$where = $this->relative($file);
|
||
|
||
foreach ([
|
||
...$this->literalColours($css),
|
||
...$this->literalDeclarations($css),
|
||
...$this->offScaleMediaQueries($css),
|
||
] as [$line, $what]) {
|
||
$violations[] = "{$where}:{$line} {$what}";
|
||
}
|
||
}
|
||
|
||
return $violations;
|
||
}
|
||
|
||
/**
|
||
* Every literal colour in `$css`: a hex code, a colour function, or a named colour other than
|
||
* `transparent`/`currentColor` written where a value is expected (after `:`, `,` or `(`) —
|
||
* which is what keeps a selector like `.red-banner` from matching.
|
||
*
|
||
* @return list<array{0: int, 1: string}>
|
||
*/
|
||
protected function literalColours(string $css): array
|
||
{
|
||
$pattern = '/(?<![\w#-])(?:#[0-9a-fA-F]{3,8}\b|(?:rgb|rgba|hsl|hsla|oklch|oklab|lch|lab|color)\(|(?<=[:,(]\s{0,20})(?:'.self::CSS_NAMED_COLOURS.')\b(?!-))/i';
|
||
|
||
preg_match_all($pattern, $css, $matches, PREG_OFFSET_CAPTURE);
|
||
|
||
return array_map(
|
||
fn (array $match): array => [
|
||
substr_count(substr($css, 0, $match[1]), "\n") + 1,
|
||
"literal colour `{$match[0]}` — use `var(--md-sys-color-*)`",
|
||
],
|
||
$matches[0],
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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).
|
||
*
|
||
* @return list<array{0: int, 1: string}>
|
||
*/
|
||
protected function literalDeclarations(string $css): array
|
||
{
|
||
$properties = implode('|', array_map(fn (string $p): string => preg_quote($p, '/'), array_keys(self::LITERAL_PROPERTIES)));
|
||
|
||
preg_match_all('/(?<property>'.$properties.')\s*:\s*(?<value>[^;{}]+);/i', $css, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
|
||
|
||
$found = [];
|
||
|
||
foreach ($matches as $match) {
|
||
$property = strtolower($match['property'][0]);
|
||
$value = trim($match['value'][0]);
|
||
|
||
if ($this->isSafeLiteralValue($value)) {
|
||
continue;
|
||
}
|
||
|
||
$line = substr_count(substr($css, 0, $match[0][1]), "\n") + 1;
|
||
$config = self::LITERAL_PROPERTIES[$property];
|
||
$found[] = [$line, "literal {$config['kind']} `{$property}: {$value}` — {$config['hint']}"];
|
||
}
|
||
|
||
foreach (['transition', 'animation'] as $shorthand) {
|
||
preg_match_all('/(?<![\w-])'.$shorthand.'\s*:\s*(?<value>[^;{}]+);/i', $css, $shorthandMatches, PREG_OFFSET_CAPTURE);
|
||
|
||
foreach ($shorthandMatches['value'] as [$value, $offset]) {
|
||
$line = substr_count(substr($css, 0, $offset), "\n") + 1;
|
||
|
||
if (preg_match('/cubic-bezier\(|(?<![\w-])(?:ease(?:-in-out|-in|-out)?|linear)(?![\w-])/i', $value) === 1) {
|
||
$found[] = [$line, "literal easing in `{$shorthand}: {$value}` — use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`"];
|
||
}
|
||
|
||
if (preg_match('/(?<![\w.-])\d+(?:\.\d+)?m?s(?![\w-])/i', $value) === 1) {
|
||
$found[] = [$line, "literal duration in `{$shorthand}: {$value}` — use `var(--md-sys-motion-…-duration)`, paired with its easing"];
|
||
}
|
||
}
|
||
}
|
||
|
||
return $found;
|
||
}
|
||
|
||
protected function isSafeLiteralValue(string $value): bool
|
||
{
|
||
return trim($value) === '' || in_array(strtolower(trim($value)), ['0', '0px', '0s', 'none', 'inherit', 'initial', 'unset', 'normal', 'auto'], true);
|
||
}
|
||
|
||
/**
|
||
* Every `@media` width in `$css` that is not one of M3's four breakpoints written in px.
|
||
*
|
||
* @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('/(\d+(?:\.\d+)?)(px|rem|em)\b/i', $prelude, $widths, PREG_OFFSET_CAPTURE);
|
||
|
||
foreach ($widths[0] as $i => [$whole, $widthOffset]) {
|
||
$number = (float) $widths[1][$i][0];
|
||
$unit = strtolower($widths[2][$i][0]);
|
||
|
||
if ($unit === 'px' && in_array((int) $number, [600, 840, 1200, 1600], true) && $number == (int) $number) {
|
||
continue;
|
||
}
|
||
|
||
$line = substr_count(substr($css, 0, $preludeOffset + $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)"];
|
||
}
|
||
}
|
||
|
||
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 blanked out, line breaks kept.
|
||
*/
|
||
protected function withoutCssComments(string $css): string
|
||
{
|
||
return (string) preg_replace_callback(
|
||
'/\/\*.*?\*\//s',
|
||
fn (array $match): string => str_repeat("\n", substr_count($match[0], "\n")),
|
||
$css,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* `$css` with the argument list of every `var(…)` and `calc(…)` call blanked out (nested
|
||
* parentheses tracked, so a `calc(var(--x) + 4px)` inside a `var()` fallback stays hidden
|
||
* too) — what lets check (iii) treat any value built from a token as fine, whatever literal
|
||
* numbers or colours it wraps.
|
||
*/
|
||
protected function withoutTokenFunctions(string $css): string
|
||
{
|
||
return $this->maskFunctionCalls($this->maskFunctionCalls($css, 'var'), 'calc');
|
||
}
|
||
|
||
protected function maskFunctionCalls(string $css, string $name): 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);
|
||
$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 `class="…"`, and in the string keys and values of
|
||
* `:class`/`x-bind:class`, `@class([...])`, `->class([...])` and `Arr::toCssClasses([...])`.
|
||
* Echoes inside a list are skipped. A token is returned exactly as written, variant prefix
|
||
* (`sm:`, `hover:`) and all — `withoutVariantPrefix()` strips one for the daisyUI check, which
|
||
* needs to see through it; `tailwindFamilyHint()` needs to see it.
|
||
*
|
||
* @return list<array{0: int, 1: string}>
|
||
*/
|
||
protected function literalClasses(string $contents): array
|
||
{
|
||
$lists = [];
|
||
|
||
preg_match_all('/(?<![\w:.-])class="([^"]*)"/', $contents, $attributes, PREG_OFFSET_CAPTURE);
|
||
|
||
foreach ($attributes[1] as [$list, $offset]) {
|
||
$lists[] = [$list, $offset];
|
||
}
|
||
|
||
preg_match_all('/(?::class="([^"]*)"|@class\(\[(.*?)\]\)|->class\(\[(.*?)\]\)|Arr::toCssClasses\(\[(.*?)\]\))/s', $contents, $bindings, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
|
||
|
||
foreach ($bindings as $binding) {
|
||
[$body, $offset] = array_values(array_filter(array_slice($binding, 1), fn (array $group): bool => $group[1] !== -1))[0] ?? ['', 0];
|
||
|
||
preg_match_all("/'([^']*)'/", $body, $strings, PREG_OFFSET_CAPTURE);
|
||
|
||
foreach ($strings[1] as [$list, $inner]) {
|
||
$lists[] = [$list, $offset + $inner];
|
||
}
|
||
}
|
||
|
||
$found = [];
|
||
|
||
foreach ($lists as [$list, $offset]) {
|
||
$line = substr_count(substr($contents, 0, $offset), "\n") + 1;
|
||
$list = (string) preg_replace('/\{\{.*?\}\}|\{!!.*?!!\}/', ' ', $list);
|
||
|
||
foreach (preg_split('/\s+/', $list, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $token) {
|
||
if (preg_match('/[$@(){}]/', $token) !== 1) {
|
||
$found[] = [$line, $token];
|
||
}
|
||
}
|
||
}
|
||
|
||
return $found;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
/**
|
||
* 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, and
|
||
* `<x-mary-…>` is the maryUI check's concern.
|
||
*
|
||
* @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') || str_starts_with($tag, 'x-mary-')) {
|
||
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 — an anonymous
|
||
* `resources/views/components/<name>.blade.php`, found the way Blade itself falls back to it
|
||
* (the base view finder's `components.<name>`, which `resource_path('views/components')`
|
||
* feeds by default), or an `App\View\Components` class. Either wins over this package's
|
||
* `<x-{$name}>` in Blade's own resolution order.
|
||
*/
|
||
protected function shadowedByApplication(string $name): bool
|
||
{
|
||
return view()->exists('components.'.$name) || class_exists('App\\View\\Components\\'.Str::studly($name));
|
||
}
|
||
|
||
/**
|
||
* 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]);
|
||
}
|
||
|
||
/**
|
||
* 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, and a `->links()` needing `pagination.css`.
|
||
*
|
||
* @param array<string, true> $resolved
|
||
* @return list<array{0: int, 1: string}>
|
||
*/
|
||
protected function missingStylesheetViolations(string $contents, array $resolved): array
|
||
{
|
||
$found = [];
|
||
|
||
foreach ($this->packageTagUsages($contents) as [$line, $name, $spelling]) {
|
||
if ($spelling === 'plain' && $this->shadowedByApplication($name)) {
|
||
$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;
|
||
}
|
||
|
||
$stylesheet = static::packageStylesheetFor($name);
|
||
|
||
if ($stylesheet !== null && ! isset($resolved[$stylesheet])) {
|
||
$found[] = [$line, sprintf(
|
||
"`<x-%s>` needs `%s`, missing from %s — add `@import '%s';`",
|
||
$name,
|
||
static::packageRelativeName($stylesheet),
|
||
$this->relative($this->cssEntry),
|
||
$this->importLine($stylesheet),
|
||
)];
|
||
}
|
||
}
|
||
|
||
$pagination = static::packageCssRoot().'/components/pagination.css';
|
||
|
||
foreach ($this->paginationUsages($contents) as $line) {
|
||
if (! isset($resolved[$pagination])) {
|
||
$found[] = [$line, sprintf(
|
||
"`->links()` needs `components/pagination.css`, missing from %s — add `@import '%s';`",
|
||
$this->relative($this->cssEntry),
|
||
$this->importLine($pagination),
|
||
)];
|
||
}
|
||
}
|
||
|
||
return $found;
|
||
}
|
||
|
||
/**
|
||
* Every `.css` file this guard reads for check (iii): any the scanned paths hold directly,
|
||
* plus — once `missingStylesheets()` names a CSS entry — every file its `@import` graph
|
||
* reaches outside the package. The generated `material-scheme.css` is excluded either way.
|
||
*
|
||
* @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();
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($this->cssEntry !== null) {
|
||
array_push($files, ...Stylesheets::resolvedFiles([$this->cssEntry]));
|
||
}
|
||
|
||
return array_values(array_unique(array_filter(
|
||
$files,
|
||
fn (string $file): bool => $file !== '' && ! static::isPackageFile($file) && ! $this->isGeneratedScheme($file),
|
||
)));
|
||
}
|
||
|
||
/**
|
||
* Every class an application's own stylesheets select on — check (i)'s exemption list, so a
|
||
* class it defines is never reported as a dead Tailwind utility just because it happens to
|
||
* share a shape with one.
|
||
*
|
||
* @return array<string, true>
|
||
*/
|
||
protected function applicationClasses(): array
|
||
{
|
||
if ($this->applicationClassesCache !== null) {
|
||
return $this->applicationClassesCache;
|
||
}
|
||
|
||
$classes = [];
|
||
|
||
foreach ($this->applicationStylesheets() as $file) {
|
||
$css = $this->withoutCssComments((string) file_get_contents($file));
|
||
|
||
preg_match_all('/(?<![\w.#-])\.(-?[a-zA-Z_][\w-]*)/', $css, $matches);
|
||
|
||
foreach ($matches[1] as $class) {
|
||
$classes[$class] = true;
|
||
}
|
||
}
|
||
|
||
return $this->applicationClassesCache = $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) and its exempt classes never come from the package's own rules.
|
||
*/
|
||
protected static function isPackageFile(string $file): bool
|
||
{
|
||
$root = static::packageCssRoot();
|
||
|
||
return $root !== '' && ($file === $root || str_starts_with($file, $root.'/'));
|
||
}
|
||
|
||
/**
|
||
* This package's own `resources/css`, from this file's own location — works whether the class
|
||
* loads from `vendor/nonameweb/livewire-material` (a consuming application) or from this
|
||
* repository itself, the same trick `Support\Stylesheets::name()` uses.
|
||
*/
|
||
protected static function packageCssRoot(): string
|
||
{
|
||
return (string) realpath(dirname(__DIR__, 2).'/resources/css');
|
||
}
|
||
|
||
/**
|
||
* This package's own `resources/views/components` — every component and layout component
|
||
* lives here, so a tag's own view existing there is what "is this a package tag" checks.
|
||
*/
|
||
protected static function packageComponentViewRoot(): string
|
||
{
|
||
return (string) realpath(dirname(__DIR__, 2).'/resources/views/components');
|
||
}
|
||
|
||
protected static function isPackageTag(string $name): bool
|
||
{
|
||
$root = static::packageComponentViewRoot();
|
||
|
||
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::packageCssRoot()."/{$group}/{$name}.css";
|
||
|
||
if (is_file($file)) {
|
||
return $file;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
protected static function packageRelativeName(string $file): string
|
||
{
|
||
$root = static::packageCssRoot().'/';
|
||
|
||
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
|
||
{
|
||
$entryDir = dirname((string) (realpath($this->cssEntry) ?: $this->cssEntry));
|
||
|
||
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;
|
||
}
|
||
}
|