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