Files
livewire-material/src/Testing/DesignGuard.php
T
Andreas Reinhold / reiniandClaude Sonnet 5 08f2ae9d76 Rewrite the design guard's family table for a Tailwind-free application
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
2026-09-15 08:08:08 +02:00

1248 lines
55 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 4088 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;
}
}