An over-engineering audit of the whole tree, applied in five reviewed batches. Behaviour stays the same except where UPGRADE.md says otherwise. PHP: the showcase and error-page stylesheets are prebuilt into resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import (first occurrence kept, the order an application's build gives), instead of Stylesheets::bundle() inlining imports on every request; only the import walk DesignGuard needs stays. SchemeStylesheet::withProfiles() replaces three copies of the scheme-plus-profiles loop, material:scheme leaves spec and contrast checks to the node script that already made them, and the error page's scheme cache, the hashed view namespace, the translations path with no lang/ folder and DesignGuard's 1.x-name hints are gone. JS: the androidx shape port progress.js and both bin scripts each carried lives once in resources/js/shapes.js (the generated SVGs are unchanged); util.js holds ringIndex(), ms(), reopenGuard() and remember(), which were written out several times; listeners are released through AbortController; tooltip.js's hoverPopover() serves the rich tooltip too. CSS: every rule for an element inside the navigation rail queries `--md-navigation-rail-value` instead of repeating the seven collapsed conditions under five media branches; badge, alert, progress, slider and button read one non-inheriting colour-role table (components/color.css); the dialog chrome, the submenu's popover chrome, the chip's state layer and touch target, and the visually-hidden inputs use the shared rules they copied; foundation/tokens.css is folded into foundation.css. Views: Support\Field and Support\Link replace the error-key, bound-value and link-attribute blocks copied into the fields and link components; the timepicker period group, the menu filter and the showcase head are partials; the datepicker's steppers and entry fields are loops; component docblocks no longer restate SKILL.md. Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces four per-group files, DesignGuardTest and the layout-component tests use datasets, browser tests share one ready() helper, CSS parsing lives in ComponentStylesheet alone. docs/audits and the finding IDs citing it are removed, as are pestphp/pest-plugin-laravel, the unused composer scripts and check:font; the lint job runs in the feature job, which now installs node packages so the prebuilt-stylesheet staleness test runs in CI. Feature suite 1177 passed, Chrome browser suite 299 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1839 lines
92 KiB
PHP
1839 lines
92 KiB
PHP
<?php
|
||
|
||
namespace NoNameWeb\LivewireMaterial\Testing;
|
||
|
||
use Illuminate\Support\Str;
|
||
use Illuminate\View\Compilers\ComponentTagCompiler;
|
||
use NoNameWeb\LivewireMaterial\Support\Layout;
|
||
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
|
||
use NoNameWeb\LivewireMaterial\Support\SvgFile;
|
||
use RuntimeException;
|
||
use SplFileInfo;
|
||
use Symfony\Component\Finder\Finder;
|
||
|
||
/**
|
||
* Finds what compiles to nothing in 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, position, border, effect, interactivity, text or display
|
||
* utility, a colour utility on a role, white or black (`bg-white`, `text-on-surface/60`)
|
||
* or on a colour the application's own Tailwind theme named (`bg-brand`,
|
||
* `text-sport-run/60`), a variant, or an arbitrary `[…]` value or property — each with its
|
||
* 2.0.0 replacement: a layout component and prop (`gap-4` → `gap="space200"`), 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.
|
||
* (ii) `missingStylesheets($cssEntry)`: a package component tag used in a view — unprefixed,
|
||
* under the configured prefix, or `<x-livewire-material::…>` — whose stylesheet the
|
||
* entry's relative `@import` graph does not reach (followed through every package file's
|
||
* own imports; a package name or URL the entry also imports is skipped, never fatal),
|
||
* `->links()` needing `pagination.css`, and a hook an application's view writes on markup
|
||
* of its own whose rules a component's stylesheet holds — `data-md-list-row` on anything
|
||
* but `<x-card>` needing `list-item.css` (`HOOK_STYLESHEETS`); each names the missing
|
||
* `@import` line once, at its first use. A tag the application shadows with its own
|
||
* component of the same name is reported instead — the application's component wins in
|
||
* Blade, so the package's stylesheet is moot. `unusedStylesheets($cssEntry)` reports the
|
||
* other way: a package stylesheet the entry imports directly that no scanned view needs,
|
||
* not even through another needed stylesheet's imports (an entry importing `all.css` is
|
||
* left alone).
|
||
* (iii) every `.css` file among the scanned paths, outside the package and excluding the
|
||
* generated `material-scheme.css`: a literal colour, radius, shadow, font size, weight,
|
||
* line height, letter spacing, easing or duration, and a media query at a width other
|
||
* than 600/840/1200/1600px — each with its token or breakpoint. A `var()`, or a `calc()`,
|
||
* `min()`, `max()` or `clamp()` built on one, is never flagged, whatever else it holds.
|
||
* (iv) Tailwind palette colours, 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()`. 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.
|
||
*
|
||
* 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, and the theme's own classes (`table`, `button`, `panel`, `break-all`) share a
|
||
* Tailwind utility's name. So family (i) and the breakpoint, scale, palette and colour-value
|
||
* checks skip 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 exempt 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. False positives are
|
||
* kept low two ways: family (i)'s bare-word checks match only a class already isolated from a
|
||
* class list in a `.php` file (`class="…"`, `wire:loading.class`, `x-transition:enter`, `:class`,
|
||
* `@class`, `->class()`, `Arr::toCssClasses()`, `'class' => '…'`), never a word scanned across a
|
||
* whole line — which keeps "this creates a grid of cards" from matching `grid` — and skip a string
|
||
* a condition compares (`view === 'grid'`); a line-by-line match (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: a bare-word utility
|
||
* (`flex`, `hidden`) and a colour of the application's own Tailwind theme (`bg-brand`) are
|
||
* invisible to this guard anywhere but inside a class list.
|
||
*/
|
||
class DesignGuard
|
||
{
|
||
protected const string UTILITY = '(?:bg|text|border(?:-(?:[trblxyse]|bs|be))?|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|mauve|olive|mist|taupe)-(?:50|[1-9]00|950)';
|
||
|
||
/** 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"\']*\]?/';
|
||
|
||
/**
|
||
* Tailwind's breakpoint prefixes and the M3 window size class each names. Tailwind's
|
||
* 640/768/1024/1280/1536 are 40–88 px from M3's 600/840/1200/1600, so a prefix maps to the
|
||
* class that carries the same intent, never to the same pixel: both of its phone breakpoints
|
||
* are M3's medium. Since 2.0.0 the prefix itself compiles to nothing — there is no Tailwind
|
||
* left to read it — so the hint points at the layout components' props and a plain media
|
||
* query instead of another class.
|
||
*/
|
||
protected const array WINDOW_CLASSES = [
|
||
'sm' => 'medium',
|
||
'md' => 'medium',
|
||
'lg' => 'expanded',
|
||
'xl' => 'large',
|
||
'2xl' => 'extra-large',
|
||
];
|
||
|
||
/** 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,
|
||
];
|
||
|
||
/**
|
||
* Every other Tailwind utility family an application's views wrote, as [pattern, hint] pairs
|
||
* tried in order on a class-list token (so a bare word like `flex` is only ever matched there).
|
||
* `%s` in a hint is the token. The spacing, container and text families with a finer hint are
|
||
* handled before this table (see `tailwindFamilyHint()`); the breakpoint, scale and palette
|
||
* families are the line-by-line checks'.
|
||
*/
|
||
protected const array FAMILIES = [
|
||
// Tailwind's cleared easing and duration scale: matched only here, in a class list a
|
||
// Blade or PHP file writes literally (never line-by-line across every file, the way
|
||
// outsideTheScale() still reads its other scale steps) — a plain PHP or JS string such as
|
||
// `matchMedia(…) ? 'linear' : 'ease-out'` names a real CSS keyword, not a Tailwind class,
|
||
// and reading every line for the bare word flagged both that and the word appearing inside
|
||
// a stylesheet test's own regex literal (`ease-in`, `ease-out`) as if it were one.
|
||
['/^ease-(?:in-out|linear|in|out)$/', 'value outside the M3 scale `%s` — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition`'],
|
||
['/^duration-\d+$/', 'value outside the M3 scale `%s` — pair `var(--md-sys-motion-…-duration)` with its easing in your own `transition`'],
|
||
['/^[a-z][a-z-]*-\((?<property>--[\w-]+)\)$/', 'Tailwind custom-property utility `%s` compiles to nothing — write `var({property})` in your own CSS'],
|
||
|
||
['/^(?:static|fixed|absolute|relative|sticky)$/', "Tailwind position utility `%s` compiles to nothing — write `position` in your own CSS (a FAB goes in `<x-scaffold>`'s `fab` slot)"],
|
||
['/^-?(?:inset(?:-[xyse])?|top|right|bottom|left|start|end)-(?:\d+(?:\.\d+)?|px|full|auto|\d+\/\d+)$/', 'Tailwind inset utility `%s` compiles to nothing — write the offset in your own CSS, from `var(--md-sys-measurement-space*)` where it is a spacing step'],
|
||
['/^-?z-(?:\d+|auto)$/', 'Tailwind z-index utility `%s` compiles to nothing — write `z-index` in your own CSS'],
|
||
['/^(?:overflow|overscroll)(?:-[xy])?-(?:auto|hidden|clip|visible|scroll|contain|none)$/', 'Tailwind overflow utility `%s` compiles to nothing — write `overflow` in your own CSS'],
|
||
['/^(?:flex-(?:1|auto|initial|none|row-reverse|col-reverse|nowrap|wrap-reverse)|grow(?:-\d+)?|shrink(?:-\d+)?|basis-[\w.\/]+|order-(?:\d+|first|last|none)|contents|grid-(?:rows|cols)-(?:none|subgrid|\d+)|(?:col|row)-(?:span-(?:\d+|full)|start-\d+|end-\d+|auto)|grid-flow-[\w-]+|auto-(?:cols|rows)-[\w]+|justify-(?:around|evenly|stretch|normal|items-[\w-]+|self-[\w-]+)|(?:content|place-content|place-items|place-self|self)-(?:start|end|center|between|around|evenly|stretch|baseline|normal|auto|none))$/', "Tailwind flex/grid item utility `%s` compiles to nothing — the layout components arrange their children; an item's own `flex`, `order` or `grid-column` is a rule in your own CSS"],
|
||
['/^(?:min-|max-)?(?:w|h|size)-(?:\d+(?:\.\d+)?|\d+\/\d+|px|full|screen|auto|min|max|fit|dvh|svh|lvh|dvw|svw|lvw|lh|prose|none|3xs|2xs|xs|sm|md|lg|xl|[2-7]xl|screen-(?:sm|md|lg|xl|2xl))$/', 'Tailwind sizing utility `%s` compiles to nothing — M3 keeps no size scale: `<x-pane width>` sets a content column\'s measure, `<x-icon size>` an icon\'s; anything else is a length in your own CSS'],
|
||
['/^container$/', 'Tailwind\'s `container` compiles to nothing — use `<x-pane width>`, which sets M3\'s margins and a measure'],
|
||
['/^(?:table(?:-[a-z-]+)?|flow-root|list-item|inline-table)$/', 'Tailwind display utility `%s` compiles to nothing — write the `display` rule in your own CSS'],
|
||
['/^(?:border(?:-(?:[trblxyse]|bs|be))?(?:-\d+)?|border-(?:solid|dashed|dotted|double|hidden|none)|divide-[xy](?:-\d+|-reverse)?|divide-(?:solid|dashed|dotted|double|none))$/', 'Tailwind border utility `%s` compiles to nothing — a line is `<x-divider>` or `<x-surface outlined>`; any other border is your own CSS, in `var(--md-sys-color-outline-variant)`'],
|
||
['/^rounded(?:-(?:ss|se|ee|es|tl|tr|br|bl|t|r|b|l|s|e))?$/', 'Tailwind radius utility `%s` compiles to nothing — use `var(--md-sys-shape-corner-xs)` in your own CSS, or `<x-surface corner="xs">`'],
|
||
['/^(?:shadow|shadow-none|shadow-inner|inset-shadow(?:-[\w]+)?|drop-shadow(?:-[\w]+)?)$/', 'Tailwind shadow utility `%s` compiles to nothing — use `var(--md-sys-elevation-*)` in your own CSS'],
|
||
['/^(?:outline(?:-none|-hidden|-\d+|-offset-\d+|-dashed|-dotted|-double|-solid)?|ring(?:-\d+|-inset)?|ring-offset-\d+)$/', 'Tailwind outline utility `%s` compiles to nothing — M3\'s focus indicator is `md-focus-ring` (interaction.css); any other outline is your own CSS'],
|
||
['/^(?:transition(?:-(?:all|colors|opacity|shadow|transform|none|discrete))?|animate-[\w-]+)$/', 'Tailwind motion utility `%s` compiles to nothing — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition` or `animation`'],
|
||
['/^opacity-\d+$/', 'Tailwind opacity utility `%s` compiles to nothing — write `opacity` in your own CSS (M3\'s disabled content is 38 %%)'],
|
||
['/^(?:transform(?:-none|-gpu|-cpu)?|-?(?:scale|rotate|skew-[xy]|translate-[xy]|scale-[xy])-[\w.\/]+|origin-[\w-]+|will-change-[\w-]+|blur(?:-\w+)?|backdrop-[\w-]+|mix-blend-[\w-]+|isolate|isolation-auto)$/', 'Tailwind effect utility `%s` compiles to nothing — write the rule in your own CSS'],
|
||
['/^(?:cursor-[\w-]+|accent-auto|pointer-events-(?:none|auto)|select-(?:none|text|all|auto)|touch-[\w-]+|resize(?:-[xy]|-none)?|appearance-(?:none|auto)|scroll-(?:smooth|auto)|-?scroll-[mp][trblxyse]?-[\w.]+|snap-[\w-]+)$/', 'Tailwind interactivity utility `%s` compiles to nothing — write the rule in your own CSS'],
|
||
['/^(?:aspect-(?:auto|square|video|\d+\/\d+)|object-(?:contain|cover|fill|none|scale-down|top|bottom|center|left|right|left-top|left-bottom|right-top|right-bottom))$/', 'Tailwind media utility `%s` compiles to nothing — write `aspect-ratio`/`object-fit` in your own CSS'],
|
||
['/^font-(?:sans|serif)$/', 'Tailwind\'s `%s` compiles to nothing — the foundation already sets the brand typeface; any other `font-family` is your own CSS'],
|
||
['/^font-mono$/', 'Tailwind\'s `%s` compiles to nothing — put the value in `<code>`, `<kbd>` or `<samp>`, or use `md-mono` (text.css)'],
|
||
['/^antialiased$/', 'Tailwind\'s `%s` compiles to nothing — the foundation already smooths text in grayscale (base.css); drop it'],
|
||
['/^(?:underline|no-underline)$/', 'Tailwind\'s `%s` compiles to nothing — `md-link` draws a link (interaction.css); any other decoration is your own CSS'],
|
||
['/^(?:uppercase|lowercase|capitalize|normal-case|italic|not-italic|overline|line-through|underline-offset-\w+|decoration-(?:\d+|solid|double|dotted|dashed|wavy|auto|from-font|clone|slice)|subpixel-antialiased|whitespace-(?:normal|pre|pre-line|pre-wrap|break-spaces)|break-(?:words|all|keep|normal)|wrap-(?:break-word|anywhere|normal)|text-(?:wrap|balance|pretty|ellipsis|clip|justify)|text-shadow-[\w-]+(?:\/\d+)?|line-clamp-(?:\d+|none)|not-sr-only|list-(?:disc|decimal|none|inside|outside)|align-(?:baseline|top|middle|bottom|text-top|text-bottom|sub|super)|indent-[\w.]+|hyphens-(?:none|manual|auto)|(?:normal|lining|oldstyle|proportional)-nums|ordinal|slashed-zero|(?:diagonal|stacked)-fractions)$/', 'Tailwind text utility `%s` compiles to nothing — write the rule in your own CSS'],
|
||
['/^bg-(?:(?:gradient-to|linear-to)-[a-z]+|(?:linear|conic)-\d+|radial|conic|cover|contain|auto|center|top|bottom|left|right|(?:top|bottom)-(?:left|right)|(?:left|right)-(?:top|bottom)|no-repeat|repeat(?:-[xy]|-round|-space)?|fixed|local|scroll|none|clip-[a-z]+|origin-[a-z]+|blend-[a-z-]+)$/', 'Tailwind background utility `%s` compiles to nothing — write the rule in your own CSS'],
|
||
['/^(?:from|via|to)-(?:\d{1,3}%|none)$/', 'Tailwind gradient utility `%s` compiles to nothing — write the gradient in your own CSS'],
|
||
['/^(?:border-(?:collapse|separate)|border-spacing(?:-[xy])?-(?:\d+(?:\.\d+)?|px))$/', 'Tailwind table utility `%s` compiles to nothing — write the rule in your own CSS'],
|
||
['/^(?:(?:fill|stroke)-none|stroke-\d+)$/', 'Tailwind SVG utility `%s` compiles to nothing — write `fill` or `stroke` in your own CSS'],
|
||
];
|
||
|
||
/** Bare display utilities, matched only as a whole class-list token. */
|
||
protected const array DISPLAY_UTILITY = ['block', 'inline-block', 'inline', 'invisible', 'visible'];
|
||
|
||
/** Text utilities with a direct `md-*` replacement (text.css). */
|
||
protected const array TEXT_LAYOUT_UTILITY = [
|
||
'text-left' => 'md-text-start',
|
||
'text-start' => 'md-text-start',
|
||
'text-center' => 'md-text-center',
|
||
'text-right' => 'md-text-end',
|
||
'text-end' => 'md-text-end',
|
||
'truncate' => 'md-truncate',
|
||
'line-clamp-2' => 'md-line-clamp-2',
|
||
'line-clamp-3' => 'md-line-clamp-3',
|
||
'whitespace-nowrap' => 'md-nowrap',
|
||
'text-nowrap' => 'md-nowrap',
|
||
'sr-only' => 'md-visually-hidden',
|
||
'tabular-nums' => 'md-tabular',
|
||
];
|
||
|
||
/** A colour utility's prefix, up to its dash; `COLOUR_UTILITY` and `THEME_COLOUR_UTILITY` share it. */
|
||
protected const string COLOUR_PREFIX = '/^(?<utility>bg|text|border(?:-(?:[trblxyse]|bs|be))?|divide|ring(?:-offset)?|outline|fill|stroke|decoration|accent|caret|placeholder|shadow|from|via|to)-';
|
||
|
||
/** A colour utility's opacity modifier (`/60`, `/[0.32]`, `/(--alpha)`), to the end of the token. */
|
||
protected const string COLOUR_OPACITY = '(?:\/(?<opacity>\d{1,3}|\[[^\]]*\]|\(--[\w-]+\)))?$/';
|
||
|
||
/**
|
||
* A colour utility written on an M3 role, or white/black/current, with an optional opacity
|
||
* modifier (`text-on-surface/60`, `bg-scrim/[0.32]`). The roles are every `--md-sys-color-*`
|
||
* the default scheme and elevation tokens declare.
|
||
*/
|
||
protected const string COLOUR_UTILITY = self::COLOUR_PREFIX
|
||
.'(?<role>(?:on-)?(?:primary|secondary|tertiary|error|success|warning|info)(?:-container|-dim|-fixed(?:-dim|-variant)?)?'
|
||
.'|inverse-(?:primary|surface|on-surface|error|success|warning|info)'
|
||
.'|(?:on-)?background|(?:on-)?surface(?:-variant|-dim|-bright|-container(?:-lowest|-low|-high|-highest)?)?'
|
||
.'|outline(?:-variant)?|scrim|shadow|white|black|current|transparent|inherit|initial)'
|
||
.self::COLOUR_OPACITY;
|
||
|
||
/**
|
||
* A colour utility on any other name (`bg-off-plan`, `text-sport-run`, `border-l-zone-4`,
|
||
* `bg-route-reference/8`): a colour the application's own Tailwind theme named before 2.0.0,
|
||
* which nothing declares now. Tried only after `COLOUR_UTILITY`, the palette and every family
|
||
* in `FAMILIES`, so a Tailwind utility sharing the prefix (`text-balance`, `border-collapse`,
|
||
* `bg-cover`, `shadow-none`) is reported as that family; the name starts with a letter, so a
|
||
* width or a stop (`border-2`, `from-10%`) never reads as one.
|
||
*/
|
||
protected const string THEME_COLOUR_UTILITY = self::COLOUR_PREFIX.'(?<name>[a-z][a-z\d]*(?:-[a-z\d]+)*)'.self::COLOUR_OPACITY;
|
||
|
||
/**
|
||
* The hooks an application writes on markup of its own, not through a component tag, whose
|
||
* rules live in a component's stylesheet, and that stylesheet (check ii). `data-md-list-row`
|
||
* on an `<li>`, a `<div>`, a `<tr>` or a layout component such as `<x-row>` draws its hover,
|
||
* focus and press state layer and its `data-md-selected` fill from list-item.css, which no tag
|
||
* asks for when the page renders no `<x-list-item>` and no `<x-table>`. `except` names the
|
||
* package tags that draw the hook themselves: `<x-card data-md-list-row>` is card.css's row.
|
||
*
|
||
* Nothing else that list-rows.js, the skill or the README has an application write needs a
|
||
* stylesheet of its own: `data-md-list-open` and a row's `data-md-selected` are drawn only
|
||
* through the row, a selected row in `<x-table>` by table.css, `data-md-dragged` by
|
||
* foundation.css's `md-state-layer` (and card.css on `<x-card>`), and `data-md-field-control`
|
||
* by field.css inside `<x-field>`.
|
||
*/
|
||
protected const array HOOK_STYLESHEETS = [
|
||
'data-md-list-row' => ['stylesheet' => 'components/list-item.css', 'except' => ['card']],
|
||
];
|
||
|
||
/** An ink role, and the `md-ink-*` class (text.css) that sets it on plain text. */
|
||
protected const array INK_ROLE = [
|
||
'on-surface' => 'md-ink',
|
||
'on-surface-variant' => 'md-ink-variant',
|
||
'outline' => 'md-ink-quiet',
|
||
'primary' => 'md-ink-primary',
|
||
'error' => 'md-ink-error',
|
||
'success' => 'md-ink-success',
|
||
'warning' => 'md-ink-warning',
|
||
'info' => 'md-ink-info',
|
||
'inverse-on-surface' => 'md-ink-inverse',
|
||
];
|
||
|
||
/** The roles `<x-surface level>` takes. */
|
||
protected const array SURFACE_LEVELS = [
|
||
'surface', 'surface-dim', 'surface-bright', 'surface-container-lowest', 'surface-container-low',
|
||
'surface-container', 'surface-container-high', 'surface-container-highest',
|
||
];
|
||
|
||
/** The named colours a browser understands, other than `transparent` and `currentColor`. */
|
||
protected const string CSS_NAMED_COLOURS = 'aliceblue|antiquewhite|aqua|aquamarine|azure|beige|bisque|black|blanchedalmond|blue|blueviolet|brown|burlywood|cadetblue|chartreuse|chocolate|coral|cornflowerblue|cornsilk|crimson|cyan|darkblue|darkcyan|darkgoldenrod|darkgray|darkgreen|darkgrey|darkkhaki|darkmagenta|darkolivegreen|darkorange|darkorchid|darkred|darksalmon|darkseagreen|darkslateblue|darkslategray|darkslategrey|darkturquoise|darkviolet|deeppink|deepskyblue|dimgray|dimgrey|dodgerblue|firebrick|floralwhite|forestgreen|fuchsia|gainsboro|ghostwhite|gold|goldenrod|gray|green|greenyellow|grey|honeydew|hotpink|indianred|indigo|ivory|khaki|lavender|lavenderblush|lawngreen|lemonchiffon|lightblue|lightcoral|lightcyan|lightgoldenrodyellow|lightgray|lightgreen|lightgrey|lightpink|lightsalmon|lightseagreen|lightskyblue|lightslategray|lightslategrey|lightsteelblue|lightyellow|lime|limegreen|linen|magenta|maroon|mediumaquamarine|mediumblue|mediumorchid|mediumpurple|mediumseagreen|mediumslateblue|mediumspringgreen|mediumturquoise|mediumvioletred|midnightblue|mintcream|mistyrose|moccasin|navajowhite|navy|oldlace|olive|olivedrab|orange|orangered|orchid|palegoldenrod|palegreen|paleturquoise|palevioletred|papayawhip|peachpuff|peru|pink|plum|powderblue|purple|rebeccapurple|red|rosybrown|royalblue|saddlebrown|salmon|sandybrown|seagreen|seashell|sienna|silver|skyblue|slateblue|slategray|slategrey|snow|springgreen|steelblue|tan|teal|thistle|tomato|turquoise|violet|wheat|white|whitesmoke|yellow|yellowgreen';
|
||
|
||
/** A property whose literal value check (iii) reports, and the token that replaces it. */
|
||
protected const array LITERAL_PROPERTIES = [
|
||
'border-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
|
||
'border-top-left-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
|
||
'border-top-right-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
|
||
'border-bottom-right-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
|
||
'border-bottom-left-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
|
||
'border-start-start-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
|
||
'border-start-end-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
|
||
'border-end-start-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
|
||
'border-end-end-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
|
||
'box-shadow' => ['kind' => 'shadow', 'hint' => 'use `var(--md-sys-elevation-*)`'],
|
||
'font' => ['kind' => 'font', 'hint' => 'use `font: var(--md-sys-typescale-*)` with its `-tracking`, or an `md-type-*` class'],
|
||
'font-size' => ['kind' => 'font size', 'hint' => 'set the whole style with `font: var(--md-sys-typescale-*)` and its `-tracking`, or an `md-type-*` class'],
|
||
'font-weight' => ['kind' => 'font weight', 'hint' => 'use an `md-type-emphasized-*` class, or `var(--md-ref-typeface-weight-regular|medium|bold)`'],
|
||
'line-height' => ['kind' => 'line height', 'hint' => 'set the whole style with `font: var(--md-sys-typescale-*)`, or an `md-type-*` class'],
|
||
'letter-spacing' => ['kind' => 'letter spacing', 'hint' => 'use `var(--md-sys-typescale-*-tracking)`'],
|
||
'transition-timing-function' => ['kind' => 'easing', 'hint' => 'use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`'],
|
||
'animation-timing-function' => ['kind' => 'easing', 'hint' => 'use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`'],
|
||
'transition-duration' => ['kind' => 'duration', 'hint' => 'use `var(--md-sys-motion-…-duration)`, paired with its easing'],
|
||
'animation-duration' => ['kind' => 'duration', 'hint' => 'use `var(--md-sys-motion-…-duration)`, paired with its easing'],
|
||
];
|
||
|
||
/** @var list<array{pattern: string, reason: string}> */
|
||
protected array $forbidden = [];
|
||
|
||
/** @var list<string> */
|
||
protected array $forbiddenColours = [];
|
||
|
||
protected ?string $cssEntry = null;
|
||
|
||
protected ?string $unusedEntry = null;
|
||
|
||
/** @var array<string, true>|null */
|
||
protected ?array $applicationClassesCache = null;
|
||
|
||
/**
|
||
* @param list<string> $paths
|
||
*/
|
||
final public function __construct(protected array $paths) {}
|
||
|
||
/**
|
||
* @param string|list<string> $paths Files or directories.
|
||
*/
|
||
public static function scan(string|array $paths): static
|
||
{
|
||
return new static((array) $paths);
|
||
}
|
||
|
||
/**
|
||
* Roles the application's own rules leave out, e.g. ['tertiary', 'primary-container'].
|
||
* Their on-roles and containers are forbidden with them, wherever 2.0.0 lets an application
|
||
* write one: the `--md-sys-color-*` custom property (a `var()` in its CSS, an inline `style`, a
|
||
* script reading it), the `md-ink-*` class text.css has for it, and a component's `color` or
|
||
* `tone` prop (`color="tertiary"`, `:tone="'tertiary'"`). A Tailwind `bg-tertiary` left behind
|
||
* is family (i)'s to report, as every colour utility is.
|
||
*
|
||
* @param list<string> $roles
|
||
*/
|
||
public function forbidColours(array $roles): static
|
||
{
|
||
$this->forbiddenColours = [...$this->forbiddenColours, ...$roles];
|
||
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Any further pattern, matched line by line.
|
||
*/
|
||
public function forbid(string $pattern, string $reason): static
|
||
{
|
||
$this->forbidden[] = ['pattern' => $pattern, 'reason' => $reason];
|
||
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Check (ii): every package component tag used in a scanned view — unprefixed, under the
|
||
* configured prefix, or `<x-livewire-material::…>` — every `->links()` call, and every hook in
|
||
* `HOOK_STYLESHEETS` a view outside the package writes on its own markup (a row's
|
||
* `data-md-list-row`), against `$cssEntry`'s `@import` graph (followed through each package
|
||
* file's own imports). A missing one names the `@import` line to add; a tag the application
|
||
* shadows with its own component of the same name is reported instead, since the package's
|
||
* stylesheet is then moot. Each missing stylesheet and each shadowed tag is reported once, at
|
||
* its first use. Only the imports are read here: the literal values in the application's own
|
||
* CSS are check (iii)'s, which reads the `.css` files `scan()` is given, entry or not — so an
|
||
* application part-way through its migration can check its imports before its stylesheets
|
||
* are on tokens. The classes the entry's own imports declare do join check (i)'s exemptions.
|
||
*/
|
||
public function missingStylesheets(string $cssEntry): static
|
||
{
|
||
$this->cssEntry = $cssEntry;
|
||
$this->applicationClassesCache = null;
|
||
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Check (ii) the other way round: each package stylesheet `$cssEntry` imports directly that no
|
||
* scanned Blade view needs — no package tag it renders, no `->links()`, no hook it writes by
|
||
* hand (`HOOK_STYLESHEETS`), and not reached through the imports of a stylesheet that is
|
||
* needed — named at its `@import` line, to remove. A component whose last use left the views
|
||
* otherwise keeps its CSS in every page. `foundation.css` is always needed; an entry that
|
||
* imports `all.css` has chosen everything and is not read.
|
||
*/
|
||
public function unusedStylesheets(string $cssEntry): static
|
||
{
|
||
$this->unusedEntry = $cssEntry;
|
||
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* @return list<string>
|
||
*/
|
||
public function violations(): array
|
||
{
|
||
$violations = [];
|
||
$resolved = $this->cssEntry !== null
|
||
? array_fill_keys(Stylesheets::resolvedFiles([$this->cssEntry]), true)
|
||
: [];
|
||
|
||
$reported = [];
|
||
$needed = [];
|
||
|
||
if ($this->cssEntry !== null && ! is_file($this->cssEntry)) {
|
||
$violations[] = "{$this->cssEntry}:1 the CSS entry `missingStylesheets()` names does not exist";
|
||
} elseif ($this->cssEntry !== null) {
|
||
$foundation = static::packagePath('css').'/foundation.css';
|
||
|
||
if (! isset($resolved[$foundation])) {
|
||
$violations[] = sprintf(
|
||
'%s:1 the CSS entry never imports `foundation.css`, required by every package stylesheet — add `@import \'%s\';`',
|
||
$this->relative($this->cssEntry),
|
||
$this->importLine($foundation),
|
||
);
|
||
}
|
||
}
|
||
|
||
$mailPaths = $this->mailComponentPaths();
|
||
|
||
foreach ($this->files() as $file) {
|
||
if (str_ends_with($file->getFilename(), '.css')) {
|
||
continue; // A directly-scanned CSS file is check (iii)'s alone; these checks read Blade, PHP and JS.
|
||
}
|
||
|
||
$contents = (string) file_get_contents($file->getPathname());
|
||
$where = $this->relative($file->getPathname());
|
||
$isBlade = str_ends_with($file->getFilename(), '.blade.php');
|
||
$readsClasses = ! $this->isUnder($file->getPathname(), $mailPaths);
|
||
// A hook written in the package's own views is a tag's own, so hooks are read only outside them.
|
||
$readsHooks = ! $this->isUnder($file->getPathname(), array_filter([static::packagePath('views')]));
|
||
|
||
if ($isBlade) {
|
||
$contents = $this->withoutBladeComments($contents);
|
||
}
|
||
|
||
if ($readsClasses && str_ends_with($file->getFilename(), '.php')) {
|
||
foreach ($this->literalClasses($contents) as [$line, $class]) {
|
||
if (($hint = $this->tailwindFamilyHint($class)) !== null) {
|
||
$violations[] = "{$where}:{$line} {$hint}";
|
||
}
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
if ($readsClasses && preg_match_all($this->colourPattern(), $text, $matches)) {
|
||
foreach ($matches[0] as $class) {
|
||
$violations[] = "{$where}:{$line} Tailwind palette colour `{$class}` compiles to nothing — M3 paints with roles: an `md-ink-*` class, or `var(--md-sys-color-*)` in your own CSS";
|
||
}
|
||
}
|
||
|
||
foreach ([...($readsClasses ? $this->offTheTokens($text) : []), ...$this->forbiddenRoles($text)] as $what) {
|
||
$violations[] = "{$where}:{$line} {$what}";
|
||
}
|
||
|
||
foreach ($this->forbidden as $rule) {
|
||
if (preg_match($rule['pattern'], $text) === 1) {
|
||
$violations[] = "{$where}:{$line} {$rule['reason']}";
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($this->unusedEntry !== null) {
|
||
$violations = [...$violations, ...$this->unusedStylesheetViolations($needed)];
|
||
}
|
||
|
||
return $this->sortViolations([...$violations, ...$this->applicationCssViolations()]);
|
||
}
|
||
|
||
/**
|
||
* The package stylesheets a Blade view's own tags, `->links()` calls and, unless `$readsHooks`
|
||
* is false (a package view), the hooks it writes by hand need, before their imports are
|
||
* followed.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
protected function neededStylesheets(string $contents, bool $readsHooks = true): array
|
||
{
|
||
$needed = [];
|
||
|
||
foreach ($this->packageTagUsages($contents) as [, $name, $spelling]) {
|
||
if ($spelling === 'plain' && $this->shadowedByApplication($name)) {
|
||
continue;
|
||
}
|
||
|
||
if (($stylesheet = static::packageStylesheetFor($name)) !== null) {
|
||
$needed[] = $stylesheet;
|
||
}
|
||
}
|
||
|
||
if ($readsHooks) {
|
||
$needed = [...$needed, ...array_column($this->hookUsages($contents), 2)];
|
||
}
|
||
|
||
if ($this->paginationUsages($contents) !== []) {
|
||
$needed[] = static::packagePath('css').'/components/pagination.css';
|
||
}
|
||
|
||
return $needed;
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $needed
|
||
* @return list<string>
|
||
*/
|
||
protected function unusedStylesheetViolations(array $needed): array
|
||
{
|
||
$entry = (string) $this->unusedEntry;
|
||
$real = realpath($entry);
|
||
|
||
if ($real === false || ! is_file($real)) {
|
||
return ["{$entry}:1 the CSS entry `unusedStylesheets()` names does not exist"];
|
||
}
|
||
|
||
$root = static::packagePath('css');
|
||
// Comments out, line count kept; the import strings themselves stay readable.
|
||
$css = (string) preg_replace_callback('~/\*.*?\*/~s', fn (array $comment): string => str_repeat("\n", substr_count($comment[0], "\n")), (string) file_get_contents($real));
|
||
$imports = [];
|
||
|
||
preg_match_all('/@import\s+(?:url\(\s*)?([\'"])([^\'"]+)\1/i', $css, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
|
||
|
||
foreach ($matches as $match) {
|
||
$file = realpath(dirname($real).'/'.$match[2][0]);
|
||
|
||
if ($file === false || ! static::isPackageFile($file)) {
|
||
continue;
|
||
}
|
||
|
||
if ($file === $root.'/all.css') {
|
||
return [];
|
||
}
|
||
|
||
$imports[] = [substr_count(substr($css, 0, $match[0][1]), "\n") + 1, $file];
|
||
}
|
||
|
||
$reached = array_fill_keys(Stylesheets::resolvedFiles([...array_unique($needed), $root.'/foundation.css']), true);
|
||
$violations = [];
|
||
|
||
foreach ($imports as [$line, $file]) {
|
||
if (! isset($reached[$file])) {
|
||
$violations[] = sprintf(
|
||
'%s:%d `%s` is imported, but no scanned view renders a component that needs it — remove `@import \'%s\';`',
|
||
$this->relative($real),
|
||
$line,
|
||
static::packageRelativeName($file),
|
||
$this->importLineFrom($real, $file),
|
||
);
|
||
}
|
||
}
|
||
|
||
return $violations;
|
||
}
|
||
|
||
/**
|
||
* `$violations` ("path:line message", `relative()`'s shape) sorted by path, then line
|
||
* (numerically, so line 9 sits before line 10), then message — whichever check produced each
|
||
* one, so two checks that land on the same file read in one deterministic order instead of
|
||
* each check's own pass order (colours before shadows before media queries, line order inside
|
||
* `missingStylesheets()`'s own pass, and so on).
|
||
*
|
||
* @param list<string> $violations
|
||
* @return list<string>
|
||
*/
|
||
protected function sortViolations(array $violations): array
|
||
{
|
||
$parsed = array_map(function (string $violation): array {
|
||
preg_match('/^(.*):(\d+) (.*)$/s', $violation, $match);
|
||
|
||
return [$match[1] ?? $violation, isset($match[2]) ? (int) $match[2] : 0, $match[3] ?? '', $violation];
|
||
}, $violations);
|
||
|
||
usort($parsed, fn (array $a, array $b): int => $a[0] <=> $b[0] ?: $a[1] <=> $b[1] ?: $a[2] <=> $b[2]);
|
||
|
||
return array_column($parsed, 3);
|
||
}
|
||
|
||
/**
|
||
* @return iterable<SplFileInfo>
|
||
*/
|
||
protected function files(): iterable
|
||
{
|
||
foreach ($this->paths as $path) {
|
||
if (is_file($path)) {
|
||
yield new SplFileInfo($path);
|
||
|
||
continue;
|
||
}
|
||
|
||
if (is_dir($path)) {
|
||
yield from Finder::create()->files()->in($path)->name(['*.php', '*.js', '*.ts'])->sortByName();
|
||
}
|
||
}
|
||
}
|
||
|
||
protected function colourPattern(): string
|
||
{
|
||
return '/(?<![\w-])'.self::UTILITY.'-'.self::PALETTE.'(?![\w-])/';
|
||
}
|
||
|
||
/**
|
||
* Every place one line names a role `forbidColours()` left out: its `--md-sys-color-*` custom
|
||
* property, or the `md-ink-*` class text.css draws it with.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
protected function forbiddenRoles(string $text): array
|
||
{
|
||
$found = [];
|
||
|
||
foreach ($this->forbiddenColours as $role) {
|
||
$names = ['--md-sys-color-(?:on-)?'.preg_quote($role, '/').'(?:-container)?'];
|
||
|
||
foreach ([$role, "on-{$role}", "{$role}-container", "on-{$role}-container"] as $variant) {
|
||
if (isset(self::INK_ROLE[$variant])) {
|
||
$names[] = preg_quote(self::INK_ROLE[$variant], '/');
|
||
}
|
||
}
|
||
|
||
preg_match_all('/(?<![\w-])(?:'.implode('|', $names).')(?![\w-])/', $text, $matches);
|
||
|
||
foreach ($matches[0] as $written) {
|
||
$found[] = "`{$written}`: role `{$role}` is not part of this application's palette";
|
||
}
|
||
}
|
||
|
||
return $found;
|
||
}
|
||
|
||
/**
|
||
* Every component `color` or `tone` prop in `$contents` naming a role `forbidColours()` left
|
||
* out, literal (`color="tertiary"`) or any string a bound one can take
|
||
* (`:tone="$failed ? 'error' : 'info'"`).
|
||
*
|
||
* @return list<array{0: int, 1: string}>
|
||
*/
|
||
protected function forbiddenRoleProps(string $contents): array
|
||
{
|
||
if ($this->forbiddenColours === []) {
|
||
return [];
|
||
}
|
||
|
||
preg_match_all('/<x-[\w.:-]+((?:[^>"]|"[^"]*")*)>/s', $contents, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
|
||
|
||
$found = [];
|
||
|
||
foreach ($tags as $tag) {
|
||
preg_match_all('/\s(?<bound>:?)(?:color|tone)="(?<value>[^"]*)"/', $tag[1][0], $props, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
|
||
|
||
foreach ($props as $prop) {
|
||
$values = $prop['bound'][0] === ':'
|
||
? (preg_match_all("/'([^']*)'/", $prop['value'][0], $strings) ? $strings[1] : [])
|
||
: [$prop['value'][0]];
|
||
|
||
foreach ($values as $value) {
|
||
foreach ($this->forbiddenColours as $role) {
|
||
if (preg_match('/^(?:on-)?'.preg_quote($role, '/').'(?:-container)?$/', $value) === 1) {
|
||
$found[] = [
|
||
substr_count(substr($contents, 0, $tag[1][1] + $prop[0][1]), "\n") + 1,
|
||
'`'.trim($prop[0][0])."`: role `{$role}` is not part of this application's palette",
|
||
];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return $found;
|
||
}
|
||
|
||
/**
|
||
* 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 = Layout::BREAKPOINTS[$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 and tracking utilities Tailwind shipped and
|
||
* M3's own scales replace. The easing and duration steps of the same Tailwind scale are not
|
||
* read here — see the class header's note by the `ease-*`/`duration-*` entries of `FAMILIES`.
|
||
*
|
||
* @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)'
|
||
.')(?![\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',
|
||
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 hex, function or mix.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
protected function colourValues(string $text): array
|
||
{
|
||
preg_match_all(self::ARBITRARY_COLOUR, $text, $matches);
|
||
|
||
return array_map(fn (string $class): string => "arbitrary colour `{$class}`, use an M3 role", $matches[0]);
|
||
}
|
||
|
||
/**
|
||
* 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), the application's own CSS declares it
|
||
* (`applicationClasses()`), or a line-by-line check reports it already (a breakpoint prefix, a
|
||
* cleared scale step, an arbitrary or palette colour). 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 = trim($token, '!');
|
||
|
||
if (preg_match(self::ARBITRARY_COLOUR, $token) === 1 || preg_match($this->colourPattern(), $token) === 1 || $this->outsideTheScale($token) !== []) {
|
||
return null; // colourValues(), the palette check and outsideTheScale() already report this, line by line.
|
||
}
|
||
|
||
if (preg_match('/^(?<lead>\[[^\]]*\]|[^:\[]+(?:\[[^\]]*\])?):/', $token, $variant) === 1) {
|
||
$bare = str_starts_with($variant['lead'], 'max-') ? substr($variant['lead'], 4) : $variant['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 — ".$this->variantHint($variant['lead']);
|
||
}
|
||
|
||
if (preg_match('/^\[(?<property>-{0,2}[a-z][\w-]*):[^\]]+\]$/', $token, $arbitrary) === 1) {
|
||
return "Tailwind arbitrary property `{$token}` compiles to nothing — write `{$arbitrary['property']}` in your own CSS";
|
||
}
|
||
|
||
if (preg_match('/^-?[a-z][\w-]*-\[[^\]]*\]$/', $token) === 1) {
|
||
return "Tailwind arbitrary value `{$token}` compiles to nothing — write the literal value in your own CSS, or use an M3 token";
|
||
}
|
||
|
||
if (($hint = $this->containerHint($token) ?? $this->spacingHint($token)) !== null) {
|
||
return $hint;
|
||
}
|
||
|
||
if ($token === 'hidden') {
|
||
return "Tailwind's `hidden` compiles to nothing — use a layout component's `hide-below`/`hide-from` prop, the `hidden` attribute (the reset keeps it hidden), or `x-show`";
|
||
}
|
||
|
||
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 (($hint = $this->colourUtilityHint($token)) !== null) {
|
||
return $hint;
|
||
}
|
||
|
||
foreach (self::FAMILIES as [$pattern, $hint]) {
|
||
if (preg_match($pattern, $token, $match) === 1) {
|
||
return (string) preg_replace_callback(
|
||
'/\{(\w+)\}/',
|
||
fn (array $name): string => (string) ($match[$name[1]] ?? ''),
|
||
str_replace('%%', '%', str_replace('%s', $token, $hint)),
|
||
);
|
||
}
|
||
}
|
||
|
||
return $this->themeColourHint($token);
|
||
}
|
||
|
||
/**
|
||
* The 2.0.0 replacement for a colour utility on a name neither M3 nor Tailwind has (see
|
||
* `THEME_COLOUR_UTILITY`), or null: the application's own custom property in its own CSS, mixed
|
||
* toward transparent where the utility carried an opacity.
|
||
*/
|
||
protected function themeColourHint(string $token): ?string
|
||
{
|
||
if (preg_match(self::THEME_COLOUR_UTILITY, $token, $m, PREG_UNMATCHED_AS_NULL) !== 1) {
|
||
return null;
|
||
}
|
||
|
||
$dead = "Tailwind colour utility `{$token}` compiles to nothing — `{$m['name']}` is neither an M3 role nor a Tailwind colour, so it named a colour of the application's own theme";
|
||
|
||
return $m['opacity'] !== null
|
||
? "{$dead}: use `color-mix(in srgb, var(--…) <n>%, transparent)` in your own CSS"
|
||
: "{$dead}: use `var(--…)` in your own CSS";
|
||
}
|
||
|
||
/**
|
||
* The 2.0.0 replacement for a Tailwind variant, by its first segment: a state, the theme, a
|
||
* structural pseudo-class, a direction, an M3 breakpoint, or an arbitrary selector.
|
||
*/
|
||
protected function variantHint(string $lead): string
|
||
{
|
||
$bare = str_starts_with($lead, 'max-') ? substr($lead, 4) : $lead;
|
||
|
||
return match (true) {
|
||
$bare !== 'compact' && isset(Layout::BREAKPOINTS[$bare]) => '`@media (width '.($bare === $lead ? '>=' : '<').' '.Layout::BREAKPOINTS[$bare]."px)` in your own CSS, or a layout component's `hide-below`/`hide-from`/`stack-below` prop",
|
||
$lead === 'dark' => "the roles already switch with the theme; a dark-only rule is `[data-theme='dark'] …` in your own CSS",
|
||
$lead === 'rail-collapsed' => "`:root[data-rail='collapsed'] …` in your own CSS",
|
||
in_array($lead, ['motion-reduce', 'motion-safe'], true) => '`@media (prefers-reduced-motion: reduce)` in your own CSS',
|
||
in_array($lead, ['rtl', 'ltr'], true) => "`:is([dir='rtl'], [dir='rtl'] *)` in your own CSS",
|
||
$lead === 'print' => '`@media print` in your own CSS',
|
||
preg_match('/^(?:hover|focus|focus-visible|focus-within|active|pressed)$/', $lead) === 1 => "M3's hover, focus and press states are `md-state-layer` and `md-focus-ring` (interaction.css); any other state rule is `:hover`/`:focus-visible` in your own CSS",
|
||
preg_match('/^(?:group|peer|has|not|in)(?:-|\/|$)/', $lead) === 1 => '`:has()` or a descendant selector in your own CSS',
|
||
preg_match('/^(?:aria|data)-/', $lead) === 1 => 'an `[aria-…]` or `[data-…]` attribute selector in your own CSS',
|
||
preg_match('/^(?:before|after|placeholder|file|marker|selection|backdrop|first-line|first-letter)$/', $lead) === 1 => 'a pseudo-element rule in your own CSS',
|
||
str_starts_with($lead, '[') => 'write the arbitrary selector as a rule in your own CSS',
|
||
default => 'write the state or condition as a selector or `@media` rule in your own CSS',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The layout component and prop for a Tailwind flex or grid container utility, or null.
|
||
*/
|
||
protected function containerHint(string $token): ?string
|
||
{
|
||
$dead = "Tailwind layout utility `{$token}` compiles to nothing";
|
||
|
||
return match (true) {
|
||
in_array($token, ['flex', 'inline-flex', 'flex-row'], true) => "{$dead} — use `<x-row>` (`gap`, `align`, `justify`, `wrap`, `stack-below`), or `<x-stack>` for a column",
|
||
$token === 'flex-col' => "{$dead} — use `<x-stack>` (`gap`, `align`)",
|
||
$token === 'flex-wrap' => "{$dead} — use `<x-row wrap>`",
|
||
in_array($token, ['grid', 'inline-grid'], true) => "{$dead} — use `<x-grid>` (`:columns` per breakpoint, `gap`, `min-item`), or `<x-feed>` for a grid of cards",
|
||
preg_match('/^grid-cols-(\d+)$/', $token, $m) === 1 => "{$dead} — use `<x-grid :columns=\"{$m[1]}\">`, or a per-breakpoint map (`:columns=\"['compact' => 1, 'medium' => {$m[1]}]\"`)",
|
||
preg_match('/^items-(start|end|center|stretch)$/', $token, $m) === 1 => "{$dead} — use `align=\"{$m[1]}\"` on `<x-row>` or `<x-stack>`",
|
||
$token === 'items-baseline' => "{$dead} — use `align=\"baseline\"` on `<x-row>`",
|
||
preg_match('/^justify-(start|end|center|between)$/', $token, $m) === 1 => "{$dead} — use `justify=\"{$m[1]}\"` on `<x-row>`",
|
||
default => null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The M3 spacing step for a Tailwind gap, padding, margin or space-between utility, or null.
|
||
* Tailwind's spacing unit is 4px (`gap-4` is 16px, `space200`); a value between two of M3's
|
||
* steps names both neighbours.
|
||
*/
|
||
protected function spacingHint(string $token): ?string
|
||
{
|
||
if (preg_match('/^(?<negative>-)?(?<kind>gap(?:-[xy])?|[pm][trblxyse]?|space-[xy])-(?<value>\d+(?:\.\d+)?|px|auto|reverse)$/', $token, $m) !== 1) {
|
||
return null;
|
||
}
|
||
|
||
$dead = "Tailwind spacing utility `{$token}` compiles to nothing";
|
||
|
||
if ($m['value'] === 'auto') {
|
||
return in_array($m['kind'], ['m', 'mx'], true)
|
||
? "{$dead} — `<x-pane width>` centres its content; otherwise `margin-inline: auto` in your own CSS"
|
||
: "{$dead} — an `auto` margin is a rule in your own CSS";
|
||
}
|
||
|
||
if ($m['value'] === 'reverse') {
|
||
return "{$dead} — a reversed row is a `flex-direction` rule in your own CSS";
|
||
}
|
||
|
||
$px = $m['value'] === 'px' ? 1.0 : (float) $m['value'] * 4;
|
||
$steps = array_combine(Layout::SPACING, array_map(
|
||
fn (string $step): float => (float) substr($step, 5) / 100 * 8,
|
||
Layout::SPACING,
|
||
));
|
||
$exact = array_search($px, $steps, true);
|
||
$below = array_key_last(array_filter($steps, fn (float $step): bool => $step < $px));
|
||
$above = array_key_first(array_filter($steps, fn (float $step): bool => $step > $px));
|
||
$pxText = rtrim(rtrim(number_format($px, 2, '.', ''), '0'), '.').'px';
|
||
|
||
$choice = fn (string $attribute): string => $exact !== false
|
||
? "`{$attribute}=\"{$exact}\"` ({$pxText})"
|
||
: implode(' or ', array_map(
|
||
fn (string $step): string => "`{$attribute}=\"{$step}\"` (".rtrim(rtrim(number_format($steps[$step], 2, '.', ''), '0'), '.').'px)',
|
||
array_values(array_filter([$below, $above])),
|
||
))." — {$pxText} is not an M3 spacing step";
|
||
$token = $exact !== false ? "`var(--md-sys-measurement-{$exact})`" : '`var(--md-sys-measurement-space*)`';
|
||
|
||
return match (true) {
|
||
$px === 0.0 => "{$dead} — leave it out: the reset zeroes margins and padding",
|
||
$m['negative'] === '-' => "{$dead} — a negative margin is `calc(-1 * ".trim($token, '`').')` in your own CSS',
|
||
str_starts_with($m['kind'], 'gap') => "{$dead} — use ".$choice('gap').' on `<x-row>`, `<x-stack>`, `<x-grid>` or `<x-feed>`',
|
||
$m['kind'] === 'space-y' => "{$dead} — use `<x-stack>` with ".$choice('gap'),
|
||
$m['kind'] === 'space-x' => "{$dead} — use `<x-row>` with ".$choice('gap'),
|
||
str_starts_with($m['kind'], 'p') => "{$dead} — use ".$choice('padding')." on `<x-surface>`, or {$token} in your own CSS",
|
||
default => "{$dead} — space between siblings is a layout component's ".$choice('gap')."; any other margin is {$token} in your own CSS",
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A colour utility's 2.0.0 replacement (see `COLOUR_UTILITY`), or null for anything else: an
|
||
* ink on plain text is its `md-ink-*` class, a line `<x-divider>` or `<x-surface outlined>`, a
|
||
* tonal background `<x-surface level>`, white and black a role, opacity a role or the state
|
||
* layer, and every other role its `var(--md-sys-color-*)` in the application's own CSS.
|
||
*/
|
||
protected function colourUtilityHint(string $token): ?string
|
||
{
|
||
if (preg_match(self::COLOUR_UTILITY, $token, $m, PREG_UNMATCHED_AS_NULL) !== 1) {
|
||
return null;
|
||
}
|
||
|
||
$utility = $m['utility'];
|
||
$role = $m['role'];
|
||
$dead = "Tailwind colour utility `{$token}` compiles to nothing";
|
||
|
||
return match (true) {
|
||
in_array($role, ['white', 'black'], true) => "{$dead} — M3 paints with roles, never white or black: `<x-surface level=\"surface-container-lowest\">` or `var(--md-sys-color-surface-container-lowest)` for a white surface, an `on-` role (`md-ink`, `var(--md-sys-color-on-primary)`) for ink",
|
||
in_array($role, ['current', 'transparent', 'inherit', 'initial'], true) => "{$dead} — write `".($role === 'current' ? 'currentColor' : $role).'` in your own CSS',
|
||
$m['opacity'] !== null && $utility === 'text' => "{$dead} — M3's quieter text is a role, not a faded one: `md-ink-variant` or `md-ink-quiet` (text.css)",
|
||
$m['opacity'] !== null => "{$dead} — M3's hover, focus and press overlays are `md-state-layer`; any other tint is `color-mix(in srgb, var(--md-sys-color-{$role}) <n>%, transparent)` in your own CSS",
|
||
$utility === 'text' && isset(self::INK_ROLE[$role]) => "{$dead} — use `".self::INK_ROLE[$role].'` (text.css)',
|
||
preg_match('/^(?:border(?:-(?:[trblxyse]|bs|be))?|divide)$/', $utility) === 1 && in_array($role, ['outline', 'outline-variant'], true) => "{$dead} — a line is `<x-divider>` or `<x-surface outlined>`, not a border utility",
|
||
$utility === 'bg' && in_array($role === 'background' ? 'surface' : $role, self::SURFACE_LEVELS, true) => "{$dead} — use `<x-surface level=\"".($role === 'background' ? 'surface' : $role).'">`',
|
||
default => "{$dead} — use `var(--md-sys-color-{$role})` in your own CSS",
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Check (iii): literal design values in the application's own CSS. `var(--md-sys-…)` and
|
||
* `calc()` are blanked out first (see `withoutTokenFunctions()`), so a value built from tokens
|
||
* is invisible to every pattern below, whatever it contains; `0`, `none` and `inherit` are
|
||
* always fine.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
protected function applicationCssViolations(): array
|
||
{
|
||
$violations = [];
|
||
|
||
foreach ($this->applicationStylesheets() as $file) {
|
||
$masked = $this->maskedCss((string) file_get_contents($file));
|
||
$css = $this->withoutTokenFunctions($masked);
|
||
$where = $this->relative($file);
|
||
|
||
foreach (explode("\n", $masked) as $index => $text) {
|
||
foreach ($this->forbiddenRoles($text) as $what) {
|
||
$violations[] = "{$where}:".($index + 1)." {$what}";
|
||
}
|
||
}
|
||
|
||
foreach ([
|
||
...$this->literalColours($css),
|
||
...$this->literalDeclarations($css, $masked),
|
||
...$this->offScaleMediaQueries($css),
|
||
] as [$line, $what]) {
|
||
$violations[] = "{$where}:{$line} {$what}";
|
||
}
|
||
}
|
||
|
||
return $violations;
|
||
}
|
||
|
||
/**
|
||
* Every literal colour in a declaration's value: a hex code, a colour function, or a named
|
||
* colour other than `transparent`/`currentColor`, anywhere in the value (`border: 1px solid
|
||
* white` as much as `color: white`). Only values are read, so a selector like `.red-banner` or
|
||
* an id like `#add` never matches.
|
||
*
|
||
* @return list<array{0: int, 1: string}>
|
||
*/
|
||
protected function literalColours(string $css): array
|
||
{
|
||
$found = [];
|
||
|
||
foreach ($this->declarations($css) as [, $value, $offset]) {
|
||
preg_match_all('/(?<![\w#-])(?:#[0-9a-fA-F]{3,8}(?![\w-])|(?:rgba?|hsla?|hwb|oklch|oklab|lch|lab|color)\([^)]*\)|(?:'.self::CSS_NAMED_COLOURS.')(?![\w-]))/i', $value, $matches, PREG_OFFSET_CAPTURE);
|
||
|
||
foreach ($matches[0] as [$colour, $at]) {
|
||
$found[] = [
|
||
substr_count(substr($css, 0, $offset + $at), "\n") + 1,
|
||
"literal colour `{$colour}` — use `var(--md-sys-color-*)`",
|
||
];
|
||
}
|
||
}
|
||
|
||
return $found;
|
||
}
|
||
|
||
/**
|
||
* Every declaration in `$css` — a property, custom properties included, and its value up to
|
||
* the `;` or `}` that ends it — with the value's byte offset. A selector (`a:hover {`) or an
|
||
* at-rule condition (`@media (prefers-color-scheme: dark) {`) ends in `{` and is never one.
|
||
*
|
||
* @return list<array{0: string, 1: string, 2: int}>
|
||
*/
|
||
protected function declarations(string $css): array
|
||
{
|
||
preg_match_all('/(?<![\w-])(?<property>--[\w-]+|-?[a-zA-Z][\w-]*)\s*:\s*(?<value>[^;{}]*)(?=[;}])/', $css, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
|
||
|
||
return array_map(fn (array $match): array => [strtolower($match['property'][0]), $match['value'][0], $match['value'][1]], $matches);
|
||
}
|
||
|
||
/**
|
||
* Every literal value on a property check (iii) knows, plus a `transition`/`animation`
|
||
* shorthand's embedded easing or duration (its property name alone cannot say which part of
|
||
* the value is which, so both are searched for whenever either is written literally). A
|
||
* custom property is never one of them, whatever its name says. `$css` has its token functions
|
||
* blanked; a message quotes the declaration as written in `$source`, the same CSS before that
|
||
* (blanking keeps every offset).
|
||
*
|
||
* @return list<array{0: int, 1: string}>
|
||
*/
|
||
protected function literalDeclarations(string $css, ?string $source = null): array
|
||
{
|
||
$found = [];
|
||
|
||
foreach ($this->declarations($css) as [$property, $blanked, $offset]) {
|
||
$value = trim($blanked);
|
||
$written = trim(substr($source ?? $css, $offset, strlen($blanked)));
|
||
$line = substr_count(substr($css, 0, $offset), "\n") + 1;
|
||
|
||
$isRing = $property === 'box-shadow' && $this->isSafeBoxShadowRing($value);
|
||
|
||
if (isset(self::LITERAL_PROPERTIES[$property]) && ! $this->isSafeLiteralValue($value) && ! $isRing) {
|
||
$config = self::LITERAL_PROPERTIES[$property];
|
||
$found[] = [$line, "literal {$config['kind']} `{$property}: {$written}` — {$config['hint']}"];
|
||
}
|
||
|
||
if (! in_array($property, ['transition', 'animation'], true)) {
|
||
continue;
|
||
}
|
||
|
||
// `linear` is M3's own easing for continuous motion (an indeterminate progress
|
||
// indicator's rotation, foundations-supplement.md § Motion) — legitimate literal CSS,
|
||
// unlike `ease`/`ease-in`/`ease-out`/`ease-in-out`, a spring's own shape and never
|
||
// written by hand, or `cubic-bezier()`/`steps()`, always a hand-rolled curve.
|
||
if (preg_match('/cubic-bezier\(|steps\(|(?<![\w-])(?:ease(?:-in-out|-in|-out)?|step-start|step-end)(?![\w-])/i', $value) === 1) {
|
||
$found[] = [$line, "literal easing in `{$property}: {$written}` — use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`"];
|
||
}
|
||
|
||
preg_match_all('/(?<![\w.-])(\d*\.?\d+)m?s(?![\w-])/i', $value, $durations);
|
||
|
||
if (array_filter($durations[1], fn (string $number): bool => (float) $number > 0) !== []) {
|
||
$found[] = [$line, "literal duration in `{$property}: {$written}` — use `var(--md-sys-motion-…-duration)`, paired with its easing"];
|
||
}
|
||
}
|
||
|
||
return $found;
|
||
}
|
||
|
||
/**
|
||
* A value `withoutTokenFunctions()` left with nothing but the empty shell of a token function
|
||
* — whitespace where the call's own argument list used to be, the call's own name and
|
||
* parentheses still standing — is exactly as fine as one masked away entirely. A list of values
|
||
* (`transition-duration: var(…), var(…)`) is fine when every item in it is.
|
||
*/
|
||
protected function isSafeLiteralValue(string $value): bool
|
||
{
|
||
$value = (string) preg_replace('/\b(?:var|calc|min|max|clamp)\(\s*\)/i', '', $value);
|
||
|
||
foreach (explode(',', $value) as $item) {
|
||
$item = trim($item);
|
||
|
||
if ($item !== '' && ! in_array(strtolower($item), ['0', '0px', '0s', '0ms', 'none', 'inherit', 'initial', 'unset', 'revert', 'normal', 'auto'], true)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* A `box-shadow` shaped like an inset or outline-style ring in a colour role — `[inset] 0 0 0
|
||
* <n>px` in a `var(--md-sys-color-*)`, or one mixed toward transparent for a disabled ring
|
||
* (`color-mix(in srgb, var(--md-sys-color-*) <n>, transparent)`) — the shape the package's own
|
||
* stylesheets (and their stylesheet tests) use for a day's or a year's "current" outline, a
|
||
* focused field's edge, a selected chip's border: legitimate M3 CSS, not a hand-made shadow.
|
||
* `$value` has already had every `var()`/`calc()` call's own arguments blanked (see
|
||
* `withoutTokenFunctions()`), so the colour itself is read here only as an empty shell.
|
||
*/
|
||
protected function isSafeBoxShadowRing(string $value): bool
|
||
{
|
||
return preg_match(
|
||
'/^(?:inset\s+)?0\s+0\s+0\s+\d+(?:\.\d+)?px\s+(?:var\([ \t]*\)|color-mix\(in srgb,\s*var\([ \t]*\)[^,]*,\s*transparent\s*\))$/',
|
||
trim($value),
|
||
) === 1;
|
||
}
|
||
|
||
/**
|
||
* Every width in an `@media` condition that is not one of M3's four breakpoints written in
|
||
* px: `(width >= 840px)`, `(min-width: 840px)` and `(600px <= width < 840px)` pass, `(width >
|
||
* 839px)`, `(max-width: 839.98px)` and anything in `rem`/`em` do not. A height, a
|
||
* `prefers-*` feature and an `@container` query are not breakpoints and are not read.
|
||
*
|
||
* @return list<array{0: int, 1: string}>
|
||
*/
|
||
protected function offScaleMediaQueries(string $css): array
|
||
{
|
||
preg_match_all('/@media\s*([^{]*)\{/i', $css, $matches, PREG_OFFSET_CAPTURE);
|
||
|
||
$found = [];
|
||
|
||
foreach ($matches[1] as [$prelude, $preludeOffset]) {
|
||
preg_match_all('/\(([^()]*)\)/', $prelude, $features, PREG_OFFSET_CAPTURE);
|
||
|
||
foreach ($features[1] as [$feature, $featureOffset]) {
|
||
if (preg_match('/(?<![\w-])(?:min-|max-)?(?:device-)?width(?![\w-])/i', $feature) !== 1) {
|
||
continue;
|
||
}
|
||
|
||
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/i', $feature, $widths, PREG_OFFSET_CAPTURE);
|
||
|
||
foreach ($widths[0] as $i => [$whole, $widthOffset]) {
|
||
$number = (float) $widths[1][$i][0];
|
||
|
||
if (strtolower($widths[2][$i][0]) === 'px' && in_array($number, [600.0, 840.0, 1200.0, 1600.0], true)) {
|
||
continue;
|
||
}
|
||
|
||
$line = substr_count(substr($css, 0, $preludeOffset + $featureOffset + $widthOffset), "\n") + 1;
|
||
$found[] = [$line, "media query width `{$whole}` is not one of M3's breakpoints — use 600, 840, 1200 or 1600px (medium, expanded, large, extra-large) with `>=` or `<`"];
|
||
}
|
||
}
|
||
}
|
||
|
||
return $found;
|
||
}
|
||
|
||
/**
|
||
* The names as one alternation, longest first so that a prefix of another name — `xl` of
|
||
* `2xl` — cannot win where the pattern is not anchored.
|
||
*
|
||
* @param list<string> $names
|
||
*/
|
||
protected function alternation(array $names): string
|
||
{
|
||
usort($names, fn (string $a, string $b): int => strlen($b) <=> strlen($a));
|
||
|
||
return implode('|', array_map(fn (string $name): string => preg_quote($name, '/'), $names));
|
||
}
|
||
|
||
/**
|
||
* Blade comments blanked out, their line breaks kept so line numbers still match.
|
||
*/
|
||
protected function withoutBladeComments(string $contents): string
|
||
{
|
||
return (string) preg_replace_callback(
|
||
'/\{\{--.*?--\}\}/s',
|
||
fn (array $match): string => str_repeat("\n", substr_count($match[0], "\n")),
|
||
$contents,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* `$css` with every `/* … *\/` comment, the inside of every quoted string and of every
|
||
* unquoted `url(…)` blanked out, line breaks kept — so neither a class check nor a value check
|
||
* reads `content: ".flex"`, a font name or a data URI's `fill='white'`.
|
||
*/
|
||
protected function maskedCss(string $css): string
|
||
{
|
||
return (string) preg_replace_callback(
|
||
'/\/\*.*?\*\/|"(?:[^"\\\n]|\\.)*"|\'(?:[^\'\\\n]|\\.)*\'|(?<=url\()[^)\'"]*(?=\))/is',
|
||
fn (array $match): string => str_starts_with($match[0], '/*')
|
||
? str_repeat("\n", substr_count($match[0], "\n"))
|
||
: (string) preg_replace('/[^\n]/', ' ', $match[0]),
|
||
$css,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* `$css` with the argument list of every `var(…)` call, and of every `calc()`, `min()`,
|
||
* `max()` or `clamp()` built on one, blanked out (nested parentheses tracked) — what lets
|
||
* check (iii) treat any value built from a token as fine, whatever literal numbers or colours
|
||
* it wraps, a `var()` fallback included.
|
||
*/
|
||
protected function withoutTokenFunctions(string $css): string
|
||
{
|
||
return $this->maskFunctionCalls($this->maskFunctionCalls($css, 'var'), '(?:calc|min|max|clamp)', true);
|
||
}
|
||
|
||
/**
|
||
* `$css` with the argument list of every `$name(…)` call blanked out — or, with `$withVar`,
|
||
* of only those whose arguments use a `var()`, so `calc(var(--x) + 4px)` is a token's value
|
||
* and `calc(12px + 2px)` or `clamp(1rem, 2vw, 2rem)` stays a literal one.
|
||
*/
|
||
protected function maskFunctionCalls(string $css, string $name, bool $withVar = false): string
|
||
{
|
||
$pattern = '/(?<![\w-])'.$name.'\(/i';
|
||
$offset = 0;
|
||
|
||
while (preg_match($pattern, $css, $match, PREG_OFFSET_CAPTURE, $offset) === 1) {
|
||
$open = $match[0][1] + strlen($match[0][0]) - 1;
|
||
$depth = 1;
|
||
$i = $open + 1;
|
||
$length = strlen($css);
|
||
|
||
while ($i < $length && $depth > 0) {
|
||
if ($css[$i] === '(') {
|
||
$depth++;
|
||
} elseif ($css[$i] === ')') {
|
||
$depth--;
|
||
}
|
||
|
||
$i++;
|
||
}
|
||
|
||
$inner = substr($css, $open + 1, $i - $open - 2);
|
||
|
||
if ($withVar && preg_match('/(?<![\w-])var\(/i', $inner) !== 1) {
|
||
$offset = $open + 1;
|
||
|
||
continue;
|
||
}
|
||
|
||
$blank = (string) preg_replace('/[^\n]/', ' ', $inner);
|
||
$css = substr($css, 0, $open + 1).$blank.substr($css, $i - 1);
|
||
$offset = $i;
|
||
}
|
||
|
||
return $css;
|
||
}
|
||
|
||
/**
|
||
* Every class written out literally: in `class="…"`, Livewire's `wire:loading.class="…"` and
|
||
* Alpine's `x-transition:enter="…"` lists, and in the strings of `:class`/`x-bind:class` (its
|
||
* object keys too, quoted or not), `@class([...])`, `->class(...)`, `Arr::toCssClasses([...])`
|
||
* and a `'class' => '…'` pair. A string compared in a condition (`view === 'grid'`,
|
||
* `$status === 'hidden'`) or passed to a call (`isActive('grid')`) is not a class; echoes
|
||
* inside a list are skipped. A token is returned exactly as written, variant prefix (`sm:`,
|
||
* `hover:`) and all, since `tailwindFamilyHint()` reports the prefix itself, with the line it
|
||
* sits on.
|
||
*
|
||
* @return list<array{0: int, 1: string}>
|
||
*/
|
||
protected function literalClasses(string $contents): array
|
||
{
|
||
$lists = [];
|
||
|
||
preg_match_all('/(?:(?<![\w:.-])class|wire:[\w.-]*\.class(?:\.remove)?|x-transition:(?:enter|leave)(?:-start|-end)?)="([^"]*)"/', $contents, $attributes, PREG_OFFSET_CAPTURE);
|
||
|
||
foreach ($attributes[1] as [$list, $offset]) {
|
||
$lists[] = [$list, $offset];
|
||
}
|
||
|
||
preg_match_all('/:class="(?<alpine>[^"]*)"|(?:@class|->class|Arr::toCssClasses)\((?<php>\[.*?\]|\'[^\']*\'|"[^"]*")\)|([\'"])class\3\s*=>\s*(?<pair>\'[^\']*\'|"[^"]*")/s', $contents, $bindings, PREG_OFFSET_CAPTURE | PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL);
|
||
|
||
foreach ($bindings as $binding) {
|
||
$alpine = $binding['alpine'][0] !== null;
|
||
[$body, $offset] = $binding['alpine'][0] !== null ? $binding['alpine'] : ($binding['php'][0] !== null ? $binding['php'] : $binding['pair']);
|
||
|
||
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) {
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Literal symbol names that do not exist: `<x-icon name="…">`, and an `icon="…"` or
|
||
* `icon-right="…"` on any component.
|
||
*
|
||
* @return list<array{0: int, 1: string}>
|
||
*/
|
||
protected function iconNames(string $contents): array
|
||
{
|
||
static $symbols = null;
|
||
$symbols ??= array_flip(SvgFile::symbolNames());
|
||
|
||
$iconTag = 'x-'.config('livewire-material.prefix', '').'icon';
|
||
|
||
preg_match_all('/<(x-[\w.:-]+)((?:[^>"]|"[^"]*")*)>/s', $contents, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
|
||
|
||
$unknown = [];
|
||
|
||
foreach ($tags as $tag) {
|
||
$attributes = $tag[1][0] === $iconTag ? 'name|icon|icon-right' : 'icon|icon-right';
|
||
|
||
preg_match_all('/\s(?:'.$attributes.')="([^"]*)"/', $tag[2][0], $values, PREG_OFFSET_CAPTURE);
|
||
|
||
foreach ($values[1] as [$name, $offset]) {
|
||
if ($name === '' || preg_match('/[{$@]/', $name) === 1 || isset($symbols[$name])) {
|
||
continue;
|
||
}
|
||
|
||
$unknown[] = [substr_count(substr($contents, 0, $tag[2][1] + $offset), "\n") + 1, $name];
|
||
}
|
||
}
|
||
|
||
return $unknown;
|
||
}
|
||
|
||
/**
|
||
* What a component tag takes in place of a directive: an attribute expression, which Blade
|
||
* compiles inside the tag.
|
||
*/
|
||
protected function directiveInTag(string $directive): string
|
||
{
|
||
return match ($directive) {
|
||
'@js' => 'use `{{ \\Illuminate\\Support\\Js::from(…) }}`',
|
||
'@json' => 'use `{{ json_encode(…) }}`',
|
||
'@class' => 'use `:class="\\Illuminate\\Support\\Arr::toCssClasses([…])"`',
|
||
'@style' => 'use `:style="\\Illuminate\\Support\\Arr::toCssStyles([…])"`',
|
||
'@entangle' => "use `\$wire.entangle('…')` in the Alpine expression",
|
||
'@disabled', '@checked', '@selected', '@readonly', '@required' => 'use `:'.substr($directive, 1).'="…"`',
|
||
default => 'use a `:prop` binding or `{{ }}`, or move it to a plain element inside the slot',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Blade compiles a component tag before its directives, so `<x-icon @class([...])>` or
|
||
* `x-show="ok(@js($v))"` on a component reaches the browser as literal text. Use `:class`
|
||
* and `{{ }}` there instead.
|
||
*
|
||
* @return list<array{0: int, 1: string}>
|
||
*/
|
||
protected function directivesInComponentTags(string $contents): array
|
||
{
|
||
preg_match_all('/<x-[\w.:-]+((?:[^>"]|"[^"]*")*)>/s', $contents, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
|
||
|
||
$found = [];
|
||
|
||
foreach ($tags as $tag) {
|
||
preg_match_all('/(?<![\w@])@(class|style|js|json|if|unless|isset|foreach|disabled|checked|selected|readonly|required|entangle)\b/', $tag[1][0], $directives, PREG_OFFSET_CAPTURE);
|
||
|
||
foreach ($directives[0] as [$directive, $offset]) {
|
||
$found[] = [substr_count(substr($contents, 0, $tag[1][1] + $offset), "\n") + 1, $directive];
|
||
}
|
||
}
|
||
|
||
return $found;
|
||
}
|
||
|
||
/**
|
||
* Every package tag used in `$contents`, in any of the three spellings — unprefixed, under
|
||
* the configured prefix, or `<x-livewire-material::…>` — each as [line, name, spelling].
|
||
* `spelling` is `plain`, `prefix` or `namespace`: only `plain` can be shadowed by an
|
||
* application component of the same name (Blade tries the application's own
|
||
* `resources/views/components/<name>.blade.php` — and a class-based one — before this
|
||
* package's registered anonymous path, whatever the configured prefix; a namespaced or
|
||
* prefixed tag always reaches the package). `<x-slot:…>` is not a component tag.
|
||
*
|
||
* @return list<array{0: int, 1: string, 2: string}>
|
||
*/
|
||
protected function packageTagUsages(string $contents): array
|
||
{
|
||
$prefix = (string) config('livewire-material.prefix', '');
|
||
|
||
preg_match_all('/<(x-[\w.:-]+)(?=[\s\/>])/', $contents, $tags, PREG_OFFSET_CAPTURE);
|
||
|
||
$found = [];
|
||
|
||
foreach ($tags[1] as [$tag, $offset]) {
|
||
if (str_starts_with($tag, 'x-slot')) {
|
||
continue;
|
||
}
|
||
|
||
$name = substr($tag, 2);
|
||
$spelling = 'plain';
|
||
|
||
if (str_contains($name, '::')) {
|
||
[$namespace, $name] = explode('::', $name, 2);
|
||
|
||
if ($namespace === 'livewire-material') {
|
||
$spelling = 'namespace';
|
||
} elseif ($prefix !== '' && $namespace === $prefix) {
|
||
$spelling = 'prefix';
|
||
} else {
|
||
continue;
|
||
}
|
||
} elseif (! static::isPackageTag($name)) {
|
||
continue;
|
||
}
|
||
|
||
$found[] = [substr_count(substr($contents, 0, $offset), "\n") + 1, $name, $spelling];
|
||
}
|
||
|
||
return $found;
|
||
}
|
||
|
||
/**
|
||
* Whether the application defines its own component of this name, found the way Blade itself
|
||
* looks before it reaches this package's anonymous path: an alias registered with
|
||
* `Blade::component()`, a class under the application's `View\\Components` namespace (its own
|
||
* root namespace, as Blade guesses it), or an anonymous
|
||
* `resources/views/components/<name>.blade.php` (the base view finder's `components.<name>`).
|
||
*/
|
||
protected function shadowedByApplication(string $name): bool
|
||
{
|
||
if (view()->exists('components.'.$name)) {
|
||
return true;
|
||
}
|
||
|
||
$blade = app('blade.compiler');
|
||
$compiler = new ComponentTagCompiler($blade->getClassComponentAliases(), $blade->getClassComponentNamespaces(), $blade);
|
||
|
||
try {
|
||
$class = $compiler->guessClassName($name);
|
||
} catch (RuntimeException) {
|
||
return false; // No application namespace to guess a class in.
|
||
}
|
||
|
||
return isset($blade->getClassComponentAliases()[$name])
|
||
|| $compiler->findClassByComponent($name) !== null
|
||
|| class_exists($class)
|
||
|| class_exists($class.'\\'.Str::afterLast($class, '\\'));
|
||
}
|
||
|
||
/**
|
||
* Every `->links()` call in `$contents` (Laravel's or Livewire's paginator, rendered outside
|
||
* a package component), each as the line it is on.
|
||
*
|
||
* @return list<int>
|
||
*/
|
||
protected function paginationUsages(string $contents): array
|
||
{
|
||
preg_match_all('/->links\s*\(/', $contents, $matches, PREG_OFFSET_CAPTURE);
|
||
|
||
return array_map(fn (array $match): int => substr_count(substr($contents, 0, $match[1]), "\n") + 1, $matches[0]);
|
||
}
|
||
|
||
/**
|
||
* Every hook in `HOOK_STYLESHEETS` a Blade view writes, each as [line, hook, stylesheet]: an
|
||
* attribute of a plain element or of a component tag other than those `except` names (blanked
|
||
* out first), or a name a PHP array or a script sets. A selector (`[data-md-list-row]`), a name
|
||
* in backticks and a longer name that starts with the hook write no hook.
|
||
*
|
||
* @return list<array{0: int, 1: string, 2: string}>
|
||
*/
|
||
protected function hookUsages(string $contents): array
|
||
{
|
||
$found = [];
|
||
|
||
foreach (self::HOOK_STYLESHEETS as $hook => ['stylesheet' => $stylesheet, 'except' => $except]) {
|
||
if (! str_contains($contents, $hook)) {
|
||
continue;
|
||
}
|
||
|
||
$markup = $this->withoutPackageTags($contents, $except);
|
||
|
||
preg_match_all('/(?<![\w`\[-])'.preg_quote($hook, '/').'(?![\w-])/', $markup, $matches, PREG_OFFSET_CAPTURE);
|
||
|
||
foreach ($matches[0] as [, $offset]) {
|
||
$found[] = [substr_count(substr($markup, 0, $offset), "\n") + 1, $hook, static::packagePath('css').'/'.$stylesheet];
|
||
}
|
||
}
|
||
|
||
return $found;
|
||
}
|
||
|
||
/**
|
||
* `$contents` with the opening tag of each package component `$names` names — plain, under the
|
||
* configured prefix or `<x-livewire-material::…>` — blanked out, line breaks kept. A quoted
|
||
* attribute value or a `{{ … }}` inside the tag may hold a `>`.
|
||
*
|
||
* @param list<string> $names
|
||
*/
|
||
protected function withoutPackageTags(string $contents, array $names): string
|
||
{
|
||
if ($names === []) {
|
||
return $contents;
|
||
}
|
||
|
||
$prefix = (string) config('livewire-material.prefix', '');
|
||
$namespaces = $prefix !== '' ? ['livewire-material', $prefix] : ['livewire-material'];
|
||
|
||
return (string) preg_replace_callback(
|
||
'/<x-(?:(?:'.$this->alternation($namespaces).')::)?(?:'.$this->alternation($names).')(?=[\s\/>])(?:\{\{.*?\}\}|"[^"]*"|\'[^\']*\'|[^>"\'])*+>?/s',
|
||
fn (array $tag): string => (string) preg_replace('/[^\n]/', ' ', $tag[0]),
|
||
$contents,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Check (ii)'s findings for one file: a package tag whose stylesheet `$resolved` (every file
|
||
* `Stylesheets::resolvedFiles()` reached from the CSS entry, keyed by real path) does not
|
||
* contain, a tag the application shadows, a hook written by hand whose stylesheet it does not
|
||
* contain (unless `$readsHooks` is false, for a package view), and a `->links()` needing
|
||
* `pagination.css` — each only the first time `$reported` (shared across every file of one
|
||
* `violations()` run) sees it, at the earliest line in the file that needs it.
|
||
*
|
||
* @param array<string, true> $resolved
|
||
* @param array<string, true> $reported
|
||
* @return list<array{0: int, 1: string}>
|
||
*/
|
||
protected function missingStylesheetViolations(string $contents, array $resolved, array &$reported, bool $readsHooks = true): array
|
||
{
|
||
$found = [];
|
||
$needs = [];
|
||
|
||
foreach ($this->packageTagUsages($contents) as [$line, $name, $spelling]) {
|
||
if ($spelling === 'plain' && $this->shadowedByApplication($name)) {
|
||
if (! isset($reported["shadow:{$name}"])) {
|
||
$reported["shadow:{$name}"] = true;
|
||
$found[] = [$line, "`<x-{$name}>` is shadowed by the application's own component of the same name — the package's `<x-{$name}>` never renders here"];
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
if (($stylesheet = static::packageStylesheetFor($name)) !== null) {
|
||
$needs[] = [$line, "`<x-{$name}>`", $stylesheet];
|
||
}
|
||
}
|
||
|
||
if ($readsHooks) {
|
||
foreach ($this->hookUsages($contents) as [$line, $hook, $stylesheet]) {
|
||
$needs[] = [$line, "`{$hook}`", $stylesheet];
|
||
}
|
||
}
|
||
|
||
foreach ($this->paginationUsages($contents) as $line) {
|
||
$needs[] = [$line, '`->links()`', static::packagePath('css').'/components/pagination.css'];
|
||
}
|
||
|
||
usort($needs, fn (array $a, array $b): int => $a[0] <=> $b[0]);
|
||
|
||
foreach ($needs as [$line, $what, $stylesheet]) {
|
||
if (! isset($resolved[$stylesheet]) && ! isset($reported[$stylesheet])) {
|
||
$reported[$stylesheet] = true;
|
||
$found[] = [$line, sprintf(
|
||
"%s needs `%s`, missing from %s — add `@import '%s';`",
|
||
$what,
|
||
static::packageRelativeName($stylesheet),
|
||
$this->relative($this->cssEntry),
|
||
$this->importLine($stylesheet),
|
||
)];
|
||
}
|
||
}
|
||
|
||
return $found;
|
||
}
|
||
|
||
/**
|
||
* Every `.css` file this guard reads for check (iii): the ones `scan()`'s paths hold, outside
|
||
* the package, the generated `material-scheme.css` and a mail theme under a mail component path
|
||
* excluded. The CSS entry's imports are not followed here — a vendor stylesheet it pulls in is
|
||
* not the application's to put on tokens.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
protected function applicationStylesheets(): array
|
||
{
|
||
$files = [];
|
||
|
||
foreach ($this->paths as $path) {
|
||
if (is_file($path)) {
|
||
if (str_ends_with($path, '.css')) {
|
||
$files[] = (string) realpath($path);
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
if (is_dir($path)) {
|
||
foreach (Finder::create()->files()->in($path)->name('*.css')->sortByName() as $file) {
|
||
$files[] = $file->getRealPath();
|
||
}
|
||
}
|
||
}
|
||
|
||
$mailPaths = $this->mailComponentPaths();
|
||
|
||
return array_values(array_unique(array_filter(
|
||
$files,
|
||
fn (string $file): bool => $file !== '' && ! static::isPackageFile($file) && ! $this->isGeneratedScheme($file) && ! $this->isUnder($file, $mailPaths),
|
||
)));
|
||
}
|
||
|
||
/**
|
||
* The Markdown mail component paths Laravel renders mail from (`mail.markdown.paths`), as real
|
||
* paths; one that does not exist is left out. See the class header.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
protected function mailComponentPaths(): array
|
||
{
|
||
return array_values(array_filter(array_map(
|
||
fn (mixed $path): string => is_string($path) ? (string) realpath($path) : '',
|
||
(array) config('mail.markdown.paths', []),
|
||
)));
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $directories Real paths.
|
||
*/
|
||
protected function isUnder(string $file, array $directories): bool
|
||
{
|
||
$file = realpath($file) ?: $file;
|
||
|
||
foreach ($directories as $directory) {
|
||
if (str_starts_with($file, rtrim($directory, '/').'/')) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Every class an application's own stylesheets select on — the scanned ones and whatever the
|
||
* CSS entry imports outside the package — 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.
|
||
* An escaped selector (`.sm\:flex`) counts under its unescaped name.
|
||
*
|
||
* @return array<string, true>
|
||
*/
|
||
protected function applicationClasses(): array
|
||
{
|
||
if ($this->applicationClassesCache !== null) {
|
||
return $this->applicationClassesCache;
|
||
}
|
||
|
||
$classes = [];
|
||
|
||
$files = $this->applicationStylesheets();
|
||
|
||
if ($this->cssEntry !== null) {
|
||
$files = array_unique([...$files, ...array_filter(
|
||
Stylesheets::resolvedFiles([$this->cssEntry]),
|
||
fn (string $file): bool => ! static::isPackageFile($file),
|
||
)]);
|
||
}
|
||
|
||
foreach ($files as $file) {
|
||
$css = $this->maskedCss((string) file_get_contents($file));
|
||
|
||
preg_match_all('/(?<![\w.#\\\\-])\.(-?(?:[a-zA-Z_]|\\\\.)(?:[\w-]|\\\\.)*)/', $css, $matches);
|
||
|
||
foreach ($matches[1] as $class) {
|
||
$classes[stripslashes($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::packagePath('css');
|
||
|
||
return $root !== '' && ($file === $root || str_starts_with($file, $root.'/'));
|
||
}
|
||
|
||
/**
|
||
* A folder of this package's own `resources/` (`css`, `views`, `views/components`), from this
|
||
* file's own location — works whether the class loads from `vendor/nonameweb/livewire-material`
|
||
* (a consuming application) or from this repository itself. Empty when it does not exist.
|
||
*/
|
||
protected static function packagePath(string $folder): string
|
||
{
|
||
return (string) realpath(dirname(__DIR__, 2)."/resources/{$folder}");
|
||
}
|
||
|
||
protected static function isPackageTag(string $name): bool
|
||
{
|
||
$root = static::packagePath('views/components');
|
||
|
||
return $root !== '' && is_file("{$root}/{$name}.blade.php");
|
||
}
|
||
|
||
/**
|
||
* The package stylesheet a component view of this name draws from — `components/<name>.css`
|
||
* or `layout/<name>.css`, whichever exists — mapped from the files that exist rather than a
|
||
* hand-kept list. Null when the view has no stylesheet of its own (`theme-script`) or the
|
||
* name is not a package component at all.
|
||
*/
|
||
protected static function packageStylesheetFor(string $name): ?string
|
||
{
|
||
if (! static::isPackageTag($name)) {
|
||
return null;
|
||
}
|
||
|
||
foreach (['components', 'layout'] as $group) {
|
||
$file = static::packagePath('css')."/{$group}/{$name}.css";
|
||
|
||
if (is_file($file)) {
|
||
return $file;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
protected static function packageRelativeName(string $file): string
|
||
{
|
||
$root = static::packagePath('css').'/';
|
||
|
||
return str_starts_with($file, $root) ? substr($file, strlen($root)) : $file;
|
||
}
|
||
|
||
/**
|
||
* The `@import` target `$file` should be written as, from the CSS entry's own directory —
|
||
* computed from the real paths, so it reads `../vendor/nonameweb/livewire-material/…` from a
|
||
* consuming application and `../resources/css/…` inside this repository, whichever is real.
|
||
*/
|
||
protected function importLine(string $file): string
|
||
{
|
||
return $this->importLineFrom((string) $this->cssEntry, $file);
|
||
}
|
||
|
||
/** `importLine()` for an entry other than `missingStylesheets()`'s. */
|
||
protected function importLineFrom(string $entry, string $file): string
|
||
{
|
||
$entryDir = dirname((string) (realpath($entry) ?: $entry));
|
||
$vendor = base_path('vendor/nonameweb/livewire-material/resources/css');
|
||
|
||
// A Composer path repository may symlink the package: the import still goes through vendor/.
|
||
if (realpath($vendor) === static::packagePath('css') && str_starts_with($file, static::packagePath('css').'/')) {
|
||
$file = $vendor.substr($file, strlen(static::packagePath('css')));
|
||
}
|
||
|
||
return static::relativeImportPath($entryDir, $file);
|
||
}
|
||
|
||
protected static function relativeImportPath(string $fromDir, string $toFile): string
|
||
{
|
||
$from = array_values(array_filter(explode('/', $fromDir), fn (string $part): bool => $part !== ''));
|
||
$to = array_values(array_filter(explode('/', $toFile), fn (string $part): bool => $part !== ''));
|
||
|
||
$i = 0;
|
||
|
||
while ($i < count($from) && $i < count($to) && $from[$i] === $to[$i]) {
|
||
$i++;
|
||
}
|
||
|
||
$path = implode('/', [...array_fill(0, count($from) - $i, '..'), ...array_slice($to, $i)]);
|
||
|
||
return str_starts_with($path, '..') ? $path : "./{$path}";
|
||
}
|
||
|
||
protected function relative(string $path): string
|
||
{
|
||
$base = rtrim(base_path(), '/').'/';
|
||
|
||
return str_starts_with($path, $base) ? substr($path, strlen($base)) : $path;
|
||
}
|
||
}
|