Cut duplicated and speculative code across the package
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
471d927e64
commit
247c596c3a
@@ -32,41 +32,13 @@ class SchemeCommand extends Command
|
||||
*/
|
||||
protected $description = 'Generate the application\'s Material 3 colour scheme from a seed colour, or every configured colour profile';
|
||||
|
||||
/**
|
||||
* The colour specs Google's colour utilities know. 2025 is M3 Expressive's colour; the library
|
||||
* falls back to 2021 by itself for the variants 2025 does not define.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected const SPECS = ['2021', '2025'];
|
||||
|
||||
/**
|
||||
* M3's other two contrast levels, generated for every scheme beside the standard one and
|
||||
* keyed on <html data-contrast> (styles/color/roles, "What's new May 2025"): medium is
|
||||
* 3:1 on the roles that carry text, high 7:1. Their levels are Google's, not the
|
||||
* installation's — `--contrast` moves the standard block alone.
|
||||
*
|
||||
* @var array<string, float>
|
||||
*/
|
||||
protected const LEVELS = ['medium' => 0.5, 'high' => 1.0];
|
||||
|
||||
public function handle(Filesystem $files): int
|
||||
{
|
||||
$stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css');
|
||||
$data = preg_replace('/\.css$/', '', $stylesheet).'.json';
|
||||
$spec = (string) $this->option('spec');
|
||||
|
||||
if (! in_array($spec, self::SPECS, true)) {
|
||||
$this->components->error("Unknown spec \"{$spec}\". Use one of: ".implode(', ', self::SPECS).'.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (filled($this->argument('seed'))) {
|
||||
if ($this->refusesContrast((float) $this->option('contrast'))) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$input = [
|
||||
'seed' => (string) $this->argument('seed'),
|
||||
'variant' => (string) $this->option('variant'),
|
||||
@@ -104,10 +76,6 @@ class SchemeCommand extends Command
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->refusesContrast((float) ($profile['contrast'] ?? 0), "Profile \"{$name}\": ")) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// A profile's own spec and state colours win; without them, the command's options apply.
|
||||
$scheme = $this->generate([
|
||||
'seed' => (string) ($profile['seed'] ?? ''),
|
||||
@@ -137,28 +105,6 @@ class SchemeCommand extends Command
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given standard contrast level is one this command generates, having said why
|
||||
* it is not. Medium and high are M3's own levels and always generated; `--contrast` moves
|
||||
* the standard block, which has to stay below them.
|
||||
*/
|
||||
protected function refusesContrast(float $contrast, string $context = ''): bool
|
||||
{
|
||||
if ($contrast >= self::LEVELS['medium'] || $contrast < -1) {
|
||||
$this->components->error($context.sprintf(
|
||||
'The contrast level %s is the standard block\'s, from -1 to below %s. Medium (%s) and high (%s) are always generated beside it, under [data-contrast]; the head script picks one.',
|
||||
$contrast,
|
||||
self::LEVELS['medium'],
|
||||
self::LEVELS['medium'],
|
||||
self::LEVELS['high'],
|
||||
));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* One scheme from Google's colour utilities, or null once the reason has been shown.
|
||||
*
|
||||
@@ -249,11 +195,8 @@ class SchemeCommand extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* The default profile as the plain blocks, then every profile under its own data-scheme. A
|
||||
* profile's two-attribute selectors outrank the plain ones, and its one-attribute selector
|
||||
* comes after `:root`, which it only ties with: the order is part of the format. The
|
||||
* descendant selectors keep a nested `data-theme` panel (a light card on a dark page) in the
|
||||
* page's profile.
|
||||
* The default profile as the plain blocks, then every profile under its own data-scheme
|
||||
* (`SchemeStylesheet::withProfiles()`), headed by the command that regenerates them.
|
||||
*
|
||||
* @param array<string, array{label: string, seed: string, variant: string, spec: string, harmonize: bool, contrast: array<string, mixed>, light: array<string, string>, dark: array<string, string>}> $profiles
|
||||
*/
|
||||
@@ -289,12 +232,6 @@ class SchemeCommand extends Command
|
||||
|
||||
CSS;
|
||||
|
||||
$css .= "\n".SchemeStylesheet::levels($profiles[$default]);
|
||||
|
||||
foreach ($profiles as $name => $profile) {
|
||||
$css .= "\n".SchemeStylesheet::levels($profile, "[data-scheme='{$name}']");
|
||||
}
|
||||
|
||||
return $css;
|
||||
return $css."\n".SchemeStylesheet::withProfiles($profiles[$default], $profiles);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,34 +5,33 @@ namespace NoNameWeb\LivewireMaterial\Http\Controllers;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
|
||||
use NoNameWeb\LivewireMaterial\Support\SchemeStylesheet;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
/**
|
||||
* The showcase's own CSS, served outside the application's Vite build so its chrome — the layout
|
||||
* components, the text classes, `showcase.css` — renders whatever an application's build contains
|
||||
* (plan step 38): `all.css` (every package stylesheet) and `showcase.css` bundled by
|
||||
* `Stylesheets::bundle()`, long-cached under a content hash, and the two package folders a
|
||||
* relative `url()` in that bundle points into (the brand's woff2, `menu.css`'s check mark SVG).
|
||||
* (plan step 38): `all.css` (every package stylesheet) and `showcase.css`, prebuilt into
|
||||
* resources/dist/showcase.css (`npm run build:stylesheets`), long-cached under a content hash, and
|
||||
* the two package folders a relative `url()` in it points into (the brand's woff2, `menu.css`'s
|
||||
* check mark SVG). The stylesheet is served from `assets/css/`, beside `file()`'s `assets/fonts/`
|
||||
* and `assets/svg/`, so its `../fonts/…` and `../svg/…` resolve there as written.
|
||||
*
|
||||
* The application's own scheme: `livewire-material.scheme` names its generated JSON
|
||||
* (`material:scheme` writes it beside a same-named `.css`, README and `SchemeCommand`'s own
|
||||
* `--output` default), so the CSS is found by swapping the extension. Found, it is bundled in
|
||||
* `--output` default), so the CSS is found by swapping the extension. Found, it is appended
|
||||
* unlayered, last, exactly as the recommended install's `@import './material-scheme.css';` does,
|
||||
* so it wins the cascade over the package default (`tokens/scheme.css`, already part of `all.css`)
|
||||
* whatever its selectors — profiles and every contrast level included, since it is the same file
|
||||
* `material:scheme` generates for the application's own build. Without one — no seed has been
|
||||
* generated yet, or `--output` broke the convention — the bundle falls back to the package default
|
||||
* already in `all.css`, plus a small unlayered block built from `Scheme::load()` in the same
|
||||
* selector shape (`:root`, `[data-theme]`, `[data-contrast]`) so a scheme configured only as JSON
|
||||
* (a resolver, `livewire-material.profiles` without a generated stylesheet) still reaches the
|
||||
* showcase; it carries no colour profile of its own, since `Scheme::load()` returns only the one
|
||||
* currently active.
|
||||
* generated yet, or `--output` broke the convention — the same blocks are built from the scheme's
|
||||
* JSON instead (`Scheme::forStylesheet()`, `SchemeStylesheet::withProfiles()`), so a scheme
|
||||
* configured only as JSON still reaches the showcase.
|
||||
*/
|
||||
class ShowcaseAssetController
|
||||
{
|
||||
/**
|
||||
* The package folders a relative `url()` in the bundle may point into, and `file()`'s first
|
||||
* The package folders a relative `url()` in the stylesheet may point into, and `file()`'s first
|
||||
* path segment: `assets/fonts/…`, `assets/svg/…`. Anything else, and a `..` that climbs out of
|
||||
* one, is a 404.
|
||||
*
|
||||
@@ -129,73 +128,15 @@ class ShowcaseAssetController
|
||||
|
||||
protected static function build(): string
|
||||
{
|
||||
$files = [self::path('all.css'), self::path('showcase.css')];
|
||||
$scheme = self::schemeStylesheet();
|
||||
|
||||
if ($scheme !== null) {
|
||||
$files[] = $scheme;
|
||||
}
|
||||
|
||||
$css = Stylesheets::bundle($files, self::base());
|
||||
|
||||
return $scheme === null ? $css."\n".self::schemeFallback() : $css;
|
||||
}
|
||||
|
||||
/**
|
||||
* The application's generated `material-scheme.css`, next to its configured
|
||||
* `material-scheme.json` (`livewire-material.scheme`) under the same base name — or null when
|
||||
* nothing is there yet.
|
||||
*/
|
||||
protected static function schemeStylesheet(): ?string
|
||||
{
|
||||
$css = (string) file_get_contents(dirname(__DIR__, 3).'/resources/dist/showcase.css');
|
||||
$json = (string) config('livewire-material.scheme');
|
||||
$css = preg_replace('/\.json$/', '.css', $json);
|
||||
$generated = preg_replace('/\.json$/', '.css', $json);
|
||||
|
||||
return $css !== null && $css !== $json && is_file($css) ? $css : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `Scheme::load()`'s roles, unlayered, in `tokens/scheme.css`'s own selector shape, for when no
|
||||
* generated stylesheet was found: the standard, medium and high contrast blocks, light and dark.
|
||||
*/
|
||||
protected static function schemeFallback(): string
|
||||
{
|
||||
$blocks = [
|
||||
":root, [data-theme='light']" => Scheme::load(null, null, 'standard')['light'],
|
||||
"[data-theme='dark']" => Scheme::load(null, null, 'standard')['dark'],
|
||||
"[data-contrast='medium'], [data-contrast='medium'][data-theme='light'], [data-contrast='medium'] [data-theme='light']" => Scheme::load(null, null, 'medium')['light'],
|
||||
"[data-contrast='medium'][data-theme='dark'], [data-contrast='medium'] [data-theme='dark']" => Scheme::load(null, null, 'medium')['dark'],
|
||||
"[data-contrast='high'], [data-contrast='high'][data-theme='light'], [data-contrast='high'] [data-theme='light']" => Scheme::load(null, null, 'high')['light'],
|
||||
"[data-contrast='high'][data-theme='dark'], [data-contrast='high'] [data-theme='dark']" => Scheme::load(null, null, 'high')['dark'],
|
||||
];
|
||||
|
||||
$css = '';
|
||||
|
||||
foreach ($blocks as $selector => $roles) {
|
||||
$declarations = implode('', array_map(
|
||||
fn (string $role, string $hex): string => "--md-sys-color-{$role}: {$hex}; ",
|
||||
array_keys($roles),
|
||||
$roles,
|
||||
));
|
||||
|
||||
$css .= "{$selector} {\n {$declarations}\n}\n";
|
||||
if ($generated !== null && $generated !== $json && is_file($generated)) {
|
||||
return $css.file_get_contents($generated);
|
||||
}
|
||||
|
||||
return $css;
|
||||
}
|
||||
|
||||
protected static function path(string $file): string
|
||||
{
|
||||
return dirname(__DIR__, 3)."/resources/css/{$file}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Where `resources/css/` is served from, so `Stylesheets::bundle()` rewrites a relative
|
||||
* `../fonts/…` or `../svg/…` into `file()`'s own route.
|
||||
*/
|
||||
protected static function base(): string
|
||||
{
|
||||
return route('livewire-material.asset', ['path' => 'css'], false);
|
||||
return $css.SchemeStylesheet::withProfiles(...Scheme::forStylesheet());
|
||||
}
|
||||
|
||||
protected static function hash(string $css): string
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace NoNameWeb\LivewireMaterial;
|
||||
use Illuminate\Contracts\View\Factory;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class LivewireMaterialServiceProvider extends ServiceProvider
|
||||
@@ -22,7 +21,6 @@ class LivewireMaterialServiceProvider extends ServiceProvider
|
||||
public function boot(): void
|
||||
{
|
||||
$this->loadViewsFrom(__DIR__.'/../resources/views', 'livewire-material');
|
||||
$this->loadTranslationsFrom(__DIR__.'/../lang', 'livewire-material');
|
||||
|
||||
$this->registerComponents();
|
||||
$this->registerPagination();
|
||||
@@ -49,8 +47,7 @@ class LivewireMaterialServiceProvider extends ServiceProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* The anonymous components. Blade names their view namespace after a hash of this string, and
|
||||
* compiled views keep that name, so it stays written the way it always was.
|
||||
* The anonymous components.
|
||||
*/
|
||||
public static function componentPath(): string
|
||||
{
|
||||
@@ -121,8 +118,7 @@ class LivewireMaterialServiceProvider extends ServiceProvider
|
||||
* component folder's absolute path when there is none. That path differs between a laptop and
|
||||
* a container sharing `storage/framework/views`, or between release directories, while the
|
||||
* compiled file's name does not — so a view compiled on one showed `a1b2…::button` as text on
|
||||
* the other. A prefix does not stop an unprefixed tag resolving. The path's namespace stays
|
||||
* registered, so views compiled before this release still render until they are recompiled.
|
||||
* the other. A prefix does not stop an unprefixed tag resolving.
|
||||
*
|
||||
* The package's own views never go through this registration: they write
|
||||
* `<x-livewire-material::button>`, which resolves through the view namespace whatever the
|
||||
@@ -134,8 +130,6 @@ class LivewireMaterialServiceProvider extends ServiceProvider
|
||||
static::componentPath(),
|
||||
filled(config('livewire-material.prefix')) ? config('livewire-material.prefix') : 'livewire-material',
|
||||
);
|
||||
|
||||
View::addNamespace(hash('xxh128', static::componentPath()), static::componentPath());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+18
-125
@@ -23,31 +23,6 @@ use Throwable;
|
||||
*/
|
||||
class ErrorPage
|
||||
{
|
||||
/**
|
||||
* The error layout's own stylesheet, which imports button.css and shape.css (and through
|
||||
* button.css, icon.css, loading.css and tooltip.css) for what the layout renders.
|
||||
*/
|
||||
protected const string LAYOUT = 'components/error-page.css';
|
||||
|
||||
/**
|
||||
* The package stylesheets the fallback bundles: the foundation (every application needs it)
|
||||
* and the error layout's own stylesheet — the same rules a Vite build would serve the page,
|
||||
* `Stylesheets::bundle()` inlining them in PHP instead. Not `all.css`: that would bundle every
|
||||
* component's rules for a page that draws three of them.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected const array FILES = ['foundation.css', self::LAYOUT];
|
||||
|
||||
/**
|
||||
* The scheme half of the fallback, cached per worker like `Stylesheets::bundle()`'s own cache:
|
||||
* by the scheme file's path and mtime, so a request that never regenerates it never rebuilds
|
||||
* this either, and a fresh `material:scheme` run is picked up the moment its mtime changes.
|
||||
*
|
||||
* @var array<string, array{css: string, mtime: int|false}>
|
||||
*/
|
||||
protected static array $schemeCache = [];
|
||||
|
||||
/**
|
||||
* The application's Vite tags, or null when they cannot be made. They bring the foundation,
|
||||
* the tokens, the scheme and the font; the layout's own rules come from `layoutStyles()`
|
||||
@@ -88,10 +63,10 @@ class ErrorPage
|
||||
}
|
||||
|
||||
/**
|
||||
* The error layout's own rules for a page with its build: `Stylesheets::bundle()` of
|
||||
* `components/error-page.css` and the stylesheets it imports, inlined beside the application's
|
||||
* Vite tags, which bring everything else. `bundle()` caches it for the worker by its files'
|
||||
* mtimes, as it does the fallback's half.
|
||||
* The error layout's own rules for a page with its build: `components/error-page.css` and the
|
||||
* stylesheets it imports, prebuilt into resources/dist/error-page.css (`npm run
|
||||
* build:stylesheets`) and inlined beside the application's Vite tags, which bring everything
|
||||
* else.
|
||||
*
|
||||
* Nothing is dropped from it, because nothing in it needs a build: the package's only
|
||||
* `@font-face` is `tokens/font.css`'s, which only the foundation reaches and the application's
|
||||
@@ -103,115 +78,33 @@ class ErrorPage
|
||||
*/
|
||||
public static function layoutStyles(): HtmlString
|
||||
{
|
||||
return new HtmlString(Stylesheets::bundle([self::path(self::LAYOUT)]));
|
||||
return new HtmlString(self::dist('error-page.css'));
|
||||
}
|
||||
|
||||
/**
|
||||
* A stylesheet for a page without its build: the foundation and the error layout's own rules —
|
||||
* `Stylesheets::bundle()` of `self::FILES`, the same inlining a Vite build does, so the fallback
|
||||
* can never drift from `resources/css/components/error-page.css` the way a hand-written copy
|
||||
* did — with every `@font-face` dropped (`withoutFontFace()`: there is no build here to serve
|
||||
* the font file, and a relative `url()` a request 404s on is worse than none) and the
|
||||
* application's colours appended in `material-scheme.css`'s own shape (`schemeStylesheet()`):
|
||||
* the standard level, the medium and high contrast levels under `[data-contrast]`, and a block
|
||||
* per colour profile under `[data-scheme]`. `<x-theme-script>` has already written
|
||||
* A stylesheet for a page without its build: the foundation and the error layout's own rules,
|
||||
* prebuilt into resources/dist/error-page-fallback.css — the same inlining a Vite build does,
|
||||
* so the fallback can never drift from `resources/css/components/error-page.css` the way a
|
||||
* hand-written copy did, and without the `@font-face` (there is no build here to serve the font
|
||||
* file; the brand typeface's stack in tokens/type.css falls back to the system fonts) — with
|
||||
* the application's colours appended in `material-scheme.css`'s own shape
|
||||
* (`SchemeStylesheet::withProfiles()`): the standard level, the medium and high contrast levels
|
||||
* under `[data-contrast]`, and a block per colour profile under `[data-scheme]`, built from
|
||||
* `Scheme::forStylesheet()` on every call. `<x-theme-script>` has already written
|
||||
* `data-theme`, `data-contrast` and `data-scheme` onto `<html>` by the time this tag is parsed
|
||||
* (it renders first), so the browser resolves the right block on its own — nothing here decides
|
||||
* an active profile in PHP.
|
||||
*/
|
||||
public static function fallbackStyles(): HtmlString
|
||||
{
|
||||
$files = array_map(self::path(...), self::FILES);
|
||||
|
||||
return new HtmlString(self::withoutFontFace(Stylesheets::bundle($files)).self::schemeStylesheet());
|
||||
return new HtmlString(self::dist('error-page-fallback.css').SchemeStylesheet::withProfiles(...Scheme::forStylesheet()));
|
||||
}
|
||||
|
||||
/**
|
||||
* A package stylesheet's absolute path, from its path under `resources/css/`.
|
||||
* A prebuilt stylesheet under resources/dist/.
|
||||
*/
|
||||
protected static function path(string $file): string
|
||||
protected static function dist(string $file): string
|
||||
{
|
||||
return dirname(__DIR__, 2)."/resources/css/{$file}";
|
||||
}
|
||||
|
||||
/**
|
||||
* `$css` with every `@font-face` block dropped, wherever it sits: not a search for the block's
|
||||
* text (fragile the moment a comment or a value is reworded) but a structural read — find
|
||||
* `@font-face` outside comments and strings, then remove the balanced `{ … }` that follows it,
|
||||
* brace for brace, however the block itself is written. Both reads go through
|
||||
* `Stylesheets::mask()`, because the bundle's comments name the rule too (foundation/base.css's
|
||||
* and error-page.css's headers): read as the rule, such a mention cut from inside its comment to
|
||||
* the end of the next balanced block, leaving the comment open over the rules after it —
|
||||
* base.css's `html` rule and icon.css's rules left the fallback that way. `tokens/font.css`'s
|
||||
* block is the only one the bundle carries, and it is also the bundle's only relative `url()`
|
||||
* (button.css, icon.css, shape.css and the rest reference nothing on disk), so this removal is
|
||||
* also what leaves the fallback with no `url()` to a font it cannot serve. The typeface itself
|
||||
* degrades on its own: `--md-ref-typeface-brand` (tokens/type.css) lists `ui-sans-serif`,
|
||||
* `system-ui` and `sans-serif` right after the brand name, so with no `@font-face` to resolve
|
||||
* it the browser skips straight to that system stack — nothing here has to name one.
|
||||
*/
|
||||
protected static function withoutFontFace(string $css): string
|
||||
{
|
||||
$masked = Stylesheets::mask($css);
|
||||
$result = '';
|
||||
$offset = 0;
|
||||
$length = strlen($css);
|
||||
|
||||
while (($start = stripos($masked, '@font-face', $offset)) !== false) {
|
||||
$result .= substr($css, $offset, $start - $offset);
|
||||
$open = strpos($masked, '{', $start);
|
||||
|
||||
if ($open === false) {
|
||||
$offset = $start + strlen('@font-face');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$depth = 1;
|
||||
$i = $open + 1;
|
||||
|
||||
while ($i < $length && $depth > 0) {
|
||||
$depth += match ($masked[$i]) {
|
||||
'{' => 1,
|
||||
'}' => -1,
|
||||
default => 0,
|
||||
};
|
||||
$i++;
|
||||
}
|
||||
|
||||
$offset = $i;
|
||||
}
|
||||
|
||||
return $result.substr($css, $offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* The application's colours, in `material-scheme.css`'s own selector shape
|
||||
* (`SchemeStylesheet::levels()`), from `Scheme::forStylesheet()` rather than a generated file
|
||||
* that may not exist yet. Cached by the scheme file's path and mtime (`self::$schemeCache`):
|
||||
* `Scheme` itself reads on every call and keeps nothing, so a resolver or a regenerated file
|
||||
* applies at once everywhere else it is asked — this cache is safe only because the CSS built
|
||||
* here never depends on which profile is *active*, only on the file's own content.
|
||||
*/
|
||||
protected static function schemeStylesheet(): string
|
||||
{
|
||||
$path = (string) config('livewire-material.scheme');
|
||||
clearstatcache(true, $path);
|
||||
$mtime = is_file($path) ? filemtime($path) : false;
|
||||
|
||||
if (isset(self::$schemeCache[$path]) && self::$schemeCache[$path]['mtime'] === $mtime) {
|
||||
return self::$schemeCache[$path]['css'];
|
||||
}
|
||||
|
||||
$scheme = Scheme::forStylesheet($path);
|
||||
$css = SchemeStylesheet::levels($scheme['scheme']);
|
||||
|
||||
foreach ($scheme['profiles'] as $name => $profile) {
|
||||
$css .= "\n".SchemeStylesheet::levels($profile, "[data-scheme='{$name}']");
|
||||
}
|
||||
|
||||
self::$schemeCache[$path] = ['css' => $css, 'mtime' => $mtime];
|
||||
|
||||
return $css;
|
||||
return (string) file_get_contents(dirname(__DIR__, 2)."/resources/dist/{$file}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Support;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* What every bound field shares: turning a plain form's `name` into the errors key its
|
||||
* validation messages live under, reading those messages out of the bag, and reading a bound
|
||||
* property's value straight from the Livewire component rendering it.
|
||||
*/
|
||||
class Field
|
||||
{
|
||||
/**
|
||||
* A plain form's field is named, not bound: its errors are under its name (`files[]` →
|
||||
* `files`, `a[b]` → `a.b`). A bound field's key is the `wire:model` name itself.
|
||||
*/
|
||||
public static function key(mixed $model, mixed $name): ?string
|
||||
{
|
||||
return $model ?? (filled($name) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $name) : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The messages under $key, or [] with no key or no error bag. `$wildcard` also collects
|
||||
* `$key.*` — a file input's items, a bound array's own entries — deduplicated, for a field
|
||||
* whose errors can land on the value itself and on what it holds.
|
||||
*/
|
||||
public static function messages(mixed $errors, ?string $key, bool $wildcard = false): array
|
||||
{
|
||||
if ($key === null || $errors === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (! $wildcard) {
|
||||
return Arr::flatten($errors->get($key));
|
||||
}
|
||||
|
||||
return array_values(array_unique(Arr::flatten([$errors->get($key), $errors->get("{$key}.*")])));
|
||||
}
|
||||
|
||||
/**
|
||||
* The bound property's live value, straight from the rendering Livewire component instead of
|
||||
* the prop it was given — so a render reflects a value the server already changed. Falls back
|
||||
* to $fallback with no bound model, no live component (`x-model` alone, or a component
|
||||
* rendered outside Livewire), or, with $requireFilled, a live value that is blank.
|
||||
*/
|
||||
public static function bound(?string $model, mixed $fallback = null, bool $requireFilled = false): mixed
|
||||
{
|
||||
if ($model === null || ($component = Livewire::current()) === null) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$value = data_get($component, $model);
|
||||
|
||||
return ! $requireFilled || filled($value) ? $value : $fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Support;
|
||||
|
||||
use Illuminate\View\ComponentAttributeBag;
|
||||
|
||||
/**
|
||||
* What a control that can also be a link shares: a button, a FAB, a menu item, a chip, a
|
||||
* navigation item. Every reader is `null` for a control that draws no `href`, so the result only
|
||||
* ever adds an attribute the caller's `merge()` would otherwise write itself.
|
||||
*/
|
||||
class Link
|
||||
{
|
||||
/**
|
||||
* `href`, `target`, `rel` and `wire:navigate` for a control that may render as `<a>`.
|
||||
* `$noWireNavigate` is false for the callers with no such prop of their own (a FAB has none).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function attributes(bool $isLink, ?string $link, bool $external, ComponentAttributeBag $attributes, bool $noWireNavigate = false): array
|
||||
{
|
||||
return [
|
||||
'href' => $isLink ? $link : null,
|
||||
'target' => $isLink && $external ? '_blank' : null,
|
||||
'rel' => $isLink && $external ? 'noopener' : null,
|
||||
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
+3
-14
@@ -129,20 +129,9 @@ class Scheme
|
||||
public static function forStylesheet(?string $path = null): array
|
||||
{
|
||||
$data = static::data($path);
|
||||
$profiles = [];
|
||||
|
||||
if (is_array($data['profiles'] ?? null)) {
|
||||
foreach ($data['profiles'] as $name => $profile) {
|
||||
if (! is_string($name) || preg_match('/^[a-z0-9-]+$/', $name) !== 1 || ! is_array($profile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$profiles[$name] = [
|
||||
'label' => is_string($profile['label'] ?? null) ? $profile['label'] : Str::headline($name),
|
||||
...static::fullScheme($profile),
|
||||
];
|
||||
}
|
||||
}
|
||||
$profiles = collect(static::profilesFrom($data))
|
||||
->map(fn (array $profile, string $name): array => ['label' => $profile['label'], ...static::fullScheme($data['profiles'][$name])])
|
||||
->all();
|
||||
|
||||
if ($profiles === []) {
|
||||
return ['scheme' => static::fullScheme($data), 'profiles' => []];
|
||||
|
||||
@@ -9,9 +9,9 @@ namespace NoNameWeb\LivewireMaterial\Support;
|
||||
* a caller gives one — every colour profile again under its own prefix (`[data-scheme='name']`).
|
||||
*
|
||||
* `SchemeCommand::handle()` writes this to the application's `material-scheme.css`; plan step 40
|
||||
* moved the shape itself here so `ErrorPage::fallbackStyles()` can draw the same scheme, from
|
||||
* `Scheme::forStylesheet()` rather than a generated file, without a second copy of the selectors to
|
||||
* keep in step with the command's.
|
||||
* moved the shape itself here so `ErrorPage::fallbackStyles()` and the showcase's stylesheet can
|
||||
* draw the same scheme, from `Scheme::forStylesheet()` rather than a generated file, without a
|
||||
* second copy of the selectors to keep in step with the command's.
|
||||
*/
|
||||
final class SchemeStylesheet
|
||||
{
|
||||
@@ -49,6 +49,27 @@ final class SchemeStylesheet
|
||||
return $css;
|
||||
}
|
||||
|
||||
/**
|
||||
* A scheme and its colour profiles, whole: the default's `levels()` as the plain blocks, then
|
||||
* every profile's again under its own `[data-scheme]`. A profile's two-attribute selectors
|
||||
* outrank the plain ones, and its one-attribute selector comes after `:root`, which it only
|
||||
* ties with: the order is part of the format. The descendant selectors keep a nested
|
||||
* `data-theme` panel (a light card on a dark page) in the page's profile.
|
||||
*
|
||||
* @param array{contrast: array<string, array{light: array<string, string>, dark: array<string, string>}>, light: array<string, string>, dark: array<string, string>} $scheme
|
||||
* @param array<string, array{contrast: array<string, array{light: array<string, string>, dark: array<string, string>}>, light: array<string, string>, dark: array<string, string>}> $profiles
|
||||
*/
|
||||
public static function withProfiles(array $scheme, array $profiles): string
|
||||
{
|
||||
$css = self::levels($scheme);
|
||||
|
||||
foreach ($profiles as $name => $profile) {
|
||||
$css .= "\n".self::levels($profile, "[data-scheme='{$name}']");
|
||||
}
|
||||
|
||||
return $css;
|
||||
}
|
||||
|
||||
/**
|
||||
* The light and the dark selectors of one block, under the given prefix. Without a prefix the
|
||||
* plain blocks stand on `:root` and on the attribute alone; with one, the prefix is on `<html>`
|
||||
|
||||
+11
-334
@@ -2,105 +2,21 @@
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Support;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Bundles a list of package stylesheets into one string, without a JavaScript build: the showcase
|
||||
* and the error page's fallback (`ErrorPage::fallbackStyles()`, plan step 40) serve CSS on their
|
||||
* own, outside the application's Vite build, so nothing deduplicates their `@import`s for them —
|
||||
* a browser's native `@import` fetches and applies every occurrence, it does not skip a file it
|
||||
* has already loaded. This class does in PHP what Vite's bundled postcss-import does in the
|
||||
* application's build: it inlines every `@import`, once per file, first occurrence kept.
|
||||
*
|
||||
* `bundle()` walks each given file's `@import`s depth-first, in source order, and replaces each
|
||||
* one with the imported file's own content (which is itself walked the same way) the first time
|
||||
* that file is reached; a later `@import` of the same file, anywhere in the graph, is simply
|
||||
* dropped, since the file's rules are already in the output at their first position. A file is
|
||||
* one file however it is reached (`./a.css` or `../components/a.css`): identity is its real path.
|
||||
* Because every package stylesheet imports what it depends on before its own rules, a file's
|
||||
* rules always follow those of every file it imports, so an override that ties on specificity
|
||||
* still lands after the rule it overrides.
|
||||
*
|
||||
* Only the form every package stylesheet writes is accepted — `@import './x.css';` or
|
||||
* `@import url('./x.css');`, before any rule. Anything the output could not keep meaning the same
|
||||
* throws, naming the file: a `layer()`, `supports()` or media condition (inlining would drop it),
|
||||
* an absolute URL or path (a browser would ignore an `@import` in the middle of the bundle), an
|
||||
* `@import` after a rule or inside a block (CSS ignores the first; the second would nest a whole
|
||||
* file's layers inside another), or a target that does not exist — which is also what a package
|
||||
* specifier such as `tailwindcss` is to a resolver that knows no `node_modules`. A leading
|
||||
* `@charset` (and a byte-order mark) is dropped from every file, since it means nothing past the
|
||||
* first byte of a stylesheet; the bundle is UTF-8. The `@layer` statement every package
|
||||
* stylesheet opens with is ordinary content: a repeated `@layer` statement naming the same
|
||||
* sub-layers in the same order is valid anywhere at the top level and changes nothing.
|
||||
*
|
||||
* A `url()` outside comments and strings — a font, an SVG mask — is rewritten when it is relative
|
||||
* (an absolute URL or path, a `data:` URI or a `#` fragment is left alone): resolved against the
|
||||
* file that wrote it, then re-expressed relative to the directory of `$files[0]` —
|
||||
* `resources/css/` for the package's own entry points, so without `$base` a font or an SVG stays
|
||||
* reachable exactly as it would from a file sitting there. `$base` is the directory or URL that
|
||||
* directory is served as, and every relative `url()` is joined onto it.
|
||||
*
|
||||
* Bundles are cached for the life of the process, per file list and `$base`, and a cached bundle
|
||||
* is served only while every file it inlined — nested imports included — keeps its modification
|
||||
* time.
|
||||
* The package's stylesheet graph as `DesignGuard::missingStylesheets()` and `unusedStylesheets()`
|
||||
* (plan step 41) read it: which files a CSS entry reaches through its relative `@import`s. The
|
||||
* stylesheets the package serves on its own are not bundled here but prebuilt, by
|
||||
* `npm run build:stylesheets` (bin/stylesheets.mjs), into resources/dist/.
|
||||
*/
|
||||
final class Stylesheets
|
||||
{
|
||||
/**
|
||||
* @var array<string, array{css: string, files: array<string, int|false>}>
|
||||
*/
|
||||
private static array $cache = [];
|
||||
|
||||
/**
|
||||
* Every file in `$files`, with its own and every transitively imported file's content inlined
|
||||
* once, first occurrence kept; relative `url()`s resolved against `$base` (or, without one,
|
||||
* against the directory of `$files[0]`).
|
||||
*
|
||||
* @param list<string> $files
|
||||
*/
|
||||
public static function bundle(array $files, ?string $base = null): string
|
||||
{
|
||||
if ($files === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$key = serialize([$files, $base]);
|
||||
|
||||
if (isset(self::$cache[$key]) && self::fresh(self::$cache[$key]['files'])) {
|
||||
return self::$cache[$key]['css'];
|
||||
}
|
||||
|
||||
$root = dirname(self::resolve($files[0]));
|
||||
$seen = [];
|
||||
$css = '';
|
||||
|
||||
foreach ($files as $file) {
|
||||
$css .= self::inline(self::resolve($file), $seen, $root, $base);
|
||||
}
|
||||
|
||||
self::$cache[$key] = ['css' => $css, 'files' => $seen];
|
||||
|
||||
return $css;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops every cached bundle: a test that writes its own fixtures between calls needs a fresh
|
||||
* read, since the cache otherwise lives for the rest of the worker process.
|
||||
*/
|
||||
public static function resetCache(): void
|
||||
{
|
||||
self::$cache = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file `$files` reaches, transitively, through their relative `@import`s — real paths,
|
||||
* `$files` themselves included — without concatenating anything.
|
||||
* `DesignGuard::missingStylesheets()` (plan step 41) asks this of an application's CSS entry,
|
||||
* which may import what `bundle()` refuses and a Vite build still resolves: a package name
|
||||
* (`tailwindcss`), a URL, an import with a `layer()` or media condition. So unlike `bundle()`
|
||||
* this never throws: an import naming a URL, an absolute path or a file that does not exist
|
||||
* relative to its stylesheet is skipped, and a conditional import still counts as reaching
|
||||
* its file.
|
||||
* `$files` themselves included — without concatenating anything. An application's CSS entry
|
||||
* may import what only a Vite build resolves — a package name (`tailwindcss`), a URL, an import
|
||||
* with a `layer()` or media condition — so this never throws: an import naming a URL, an
|
||||
* absolute path or a file that does not exist relative to its stylesheet is skipped, and a
|
||||
* conditional import still counts as reaching its file.
|
||||
*
|
||||
* @param list<string> $files
|
||||
* @return list<string>
|
||||
@@ -152,88 +68,12 @@ final class Stylesheets
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int|false> $files every file a cached bundle inlined, with its mtime then
|
||||
*/
|
||||
private static function fresh(array $files): bool
|
||||
{
|
||||
foreach ($files as $file => $mtime) {
|
||||
clearstatcache(true, $file);
|
||||
|
||||
if ((is_file($file) ? filemtime($file) : false) !== $mtime) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `$path`'s real path; `$importer` and `$target`, when given, name the stylesheet and the
|
||||
* `@import` target as written, for the exception.
|
||||
*/
|
||||
private static function resolve(string $path, ?string $importer = null, ?string $target = null): string
|
||||
{
|
||||
$real = realpath($path);
|
||||
|
||||
if ($real === false || ! is_file($real)) {
|
||||
throw new RuntimeException($importer === null
|
||||
? "Stylesheets::bundle() cannot read \"{$path}\": it does not exist."
|
||||
: "{$importer} imports \"{$target}\", which does not exist.");
|
||||
}
|
||||
|
||||
return $real;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int|false> $seen every file already inlined, by real path, with its mtime
|
||||
*/
|
||||
private static function inline(string $file, array &$seen, string $root, ?string $base): string
|
||||
{
|
||||
if (array_key_exists($file, $seen)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Marked before its imports are walked, so a cycle back to this file ends there.
|
||||
$seen[$file] = filemtime($file);
|
||||
|
||||
$css = (string) preg_replace('/\A(?:\xEF\xBB\xBF)?@charset\s+"[^"]*"\s*;/i', '', (string) file_get_contents($file));
|
||||
$dir = dirname($file);
|
||||
$result = '';
|
||||
$cursor = 0;
|
||||
|
||||
// A child's own content is already rewritten against its own directory by its own call
|
||||
// below, so only this file's own text — never the spliced-in content of another file —
|
||||
// is passed to rewriteUrls() here; running it again over the concatenated result would
|
||||
// resolve an already-rewritten url() a second time, against the wrong directory.
|
||||
foreach (self::imports($file, $css) as $import) {
|
||||
$result .= self::rewriteUrls(substr($css, $cursor, $import['start'] - $cursor), $dir, $root, $base);
|
||||
$result .= self::inline(self::resolve($dir.'/'.$import['target'], self::name($file), $import['target']), $seen, $root, $base);
|
||||
$cursor = $import['end'];
|
||||
}
|
||||
|
||||
return $result.self::rewriteUrls(substr($css, $cursor), $dir, $root, $base);
|
||||
}
|
||||
|
||||
/**
|
||||
* The path a stylesheet is named by in an exception: relative to the package, when it is
|
||||
* inside it, so a message reads `components/button.css imports …` rather than a long
|
||||
* absolute path.
|
||||
*/
|
||||
private static function name(string $file): string
|
||||
{
|
||||
$package = realpath(dirname(__DIR__, 2).'/resources/css').'/';
|
||||
|
||||
return str_starts_with($file, $package) ? substr($file, strlen($package)) : $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* `$css` with every comment and quoted string blinded to spaces (newlines kept), same length —
|
||||
* so a search on it for `@import` or `url(` never matches one written inside a comment or a
|
||||
* string, while every offset still lines up with `$css` itself. Public for
|
||||
* `ErrorPage::withoutFontFace()`, which searches a bundle for `@font-face` the same way.
|
||||
* string, while every offset still lines up with `$css` itself.
|
||||
*/
|
||||
public static function mask(string $css): string
|
||||
private static function mask(string $css): string
|
||||
{
|
||||
$masked = $css;
|
||||
$length = strlen($css);
|
||||
@@ -280,167 +120,4 @@ final class Stylesheets
|
||||
|
||||
return $masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `@import` statement in `$css` outside comments and strings, each with the byte range of
|
||||
* the whole statement (the semicolon included) and the target it names — or an exception, for
|
||||
* one the bundle could not inline without changing what it means (see the class comment).
|
||||
*
|
||||
* @return list<array{start: int, end: int, target: string}>
|
||||
*/
|
||||
private static function imports(string $file, string $css): array
|
||||
{
|
||||
$masked = self::mask($css);
|
||||
$imports = [];
|
||||
|
||||
if (preg_match_all('/@import\b/i', $masked, $matches, PREG_OFFSET_CAPTURE) === 0) {
|
||||
return $imports;
|
||||
}
|
||||
|
||||
// Where the text allowed before the next @import starts: only whitespace, blinded comments,
|
||||
// `@charset` and `@layer` statements and earlier @imports may precede one.
|
||||
$allowedFrom = 0;
|
||||
|
||||
foreach ($matches[0] as [, $start]) {
|
||||
$end = strpos($masked, ';', $start);
|
||||
$end = $end === false ? strlen($css) : $end + 1;
|
||||
$statement = trim(substr($css, $start, $end - $start));
|
||||
|
||||
$before = (string) preg_replace('/@(?:layer|charset)\s[^;{}]*;/i', '', substr($masked, $allowedFrom, $start - $allowedFrom));
|
||||
|
||||
if (trim($before) !== '') {
|
||||
throw new RuntimeException(self::name($file)." writes `{$statement}` after a rule or inside a block; Stylesheets::bundle() inlines an @import only before every rule, where CSS reads it.");
|
||||
}
|
||||
|
||||
if (preg_match('/^@import\s*(?:url\(\s*([\'"]?)([^\'"()\s]+)\1\s*\)|([\'"])(.+?)\3)\s*;?$/is', $statement, $match, PREG_UNMATCHED_AS_NULL) !== 1) {
|
||||
throw new RuntimeException(self::name($file)." writes `{$statement}`; Stylesheets::bundle() inlines a plain @import only, without layer(), supports() or a media condition, which the bundle would drop.");
|
||||
}
|
||||
|
||||
$target = $match[2] ?? $match[4] ?? '';
|
||||
|
||||
if (preg_match('~^(?:[a-z][a-z0-9+.-]*:|/)~i', $target) === 1) {
|
||||
throw new RuntimeException(self::name($file)." imports \"{$target}\", an absolute URL or path; Stylesheets::bundle() inlines files relative to the importing stylesheet only.");
|
||||
}
|
||||
|
||||
$imports[] = ['start' => $start, 'end' => $end, 'target' => $target];
|
||||
$allowedFrom = $end;
|
||||
}
|
||||
|
||||
return $imports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every relative `url()` in `$css` re-expressed against `$root` (or `$base`, once joined onto
|
||||
* it below), resolved first against `$dir`, the directory of the file that wrote it. An
|
||||
* absolute URL or path, a `data:` URI or a `#` fragment is left untouched.
|
||||
*/
|
||||
private static function rewriteUrls(string $css, string $dir, string $root, ?string $base): string
|
||||
{
|
||||
$masked = self::mask($css);
|
||||
|
||||
if (preg_match_all('/url\(/i', $masked, $matches, PREG_OFFSET_CAPTURE) === 0) {
|
||||
return $css;
|
||||
}
|
||||
|
||||
$result = '';
|
||||
$cursor = 0;
|
||||
|
||||
foreach ($matches[0] as [, $start]) {
|
||||
if ($start < $cursor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$close = strpos($masked, ')', $start);
|
||||
|
||||
if ($close === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$call = substr($css, $start, $close - $start + 1);
|
||||
|
||||
if (preg_match('/^url\(\s*([\'"]?)(.*?)\1\s*\)$/is', $call, $match) !== 1 || ! self::isRelativeUrl($match[2])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result .= substr($css, $cursor, $start - $cursor);
|
||||
$absolute = self::normalise($dir.'/'.$match[2]);
|
||||
$target = self::relative($root, $absolute);
|
||||
$target = $base === null ? $target : self::join($base, $target);
|
||||
$result .= "url({$match[1]}{$target}{$match[1]})";
|
||||
$cursor = $close + 1;
|
||||
}
|
||||
|
||||
return $result.substr($css, $cursor);
|
||||
}
|
||||
|
||||
private static function isRelativeUrl(string $value): bool
|
||||
{
|
||||
if ($value === '' || str_starts_with($value, '#') || str_starts_with($value, '/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return preg_match('/^[a-z][a-z0-9+.-]*:/i', $value) !== 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* `$path`, its `.` and `..` segments resolved away (a `..` at the root of an absolute path
|
||||
* stays at the root). Pure string handling — the target need not exist on disk, since a bundle
|
||||
* a test builds may not ship the fonts and SVGs it points at.
|
||||
*/
|
||||
private static function normalise(string $path): string
|
||||
{
|
||||
$absolute = str_starts_with($path, '/');
|
||||
$segments = [];
|
||||
|
||||
foreach (explode('/', $path) as $segment) {
|
||||
if ($segment === '' || $segment === '.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($segment === '..' && $segments !== [] && end($segments) !== '..') {
|
||||
array_pop($segments);
|
||||
} elseif ($segment !== '..' || ! $absolute) {
|
||||
$segments[] = $segment;
|
||||
}
|
||||
}
|
||||
|
||||
return ($absolute ? '/' : '').implode('/', $segments);
|
||||
}
|
||||
|
||||
/**
|
||||
* `$to`, expressed relative to the directory `$from`.
|
||||
*/
|
||||
private static function relative(string $from, string $to): string
|
||||
{
|
||||
$fromParts = array_values(array_filter(explode('/', $from), fn (string $part): bool => $part !== ''));
|
||||
$toParts = array_values(array_filter(explode('/', $to), fn (string $part): bool => $part !== ''));
|
||||
|
||||
$i = 0;
|
||||
|
||||
while ($i < count($fromParts) && $i < count($toParts) && $fromParts[$i] === $toParts[$i]) {
|
||||
$i++;
|
||||
}
|
||||
|
||||
$path = implode('/', [
|
||||
...array_fill(0, count($fromParts) - $i, '..'),
|
||||
...array_slice($toParts, $i),
|
||||
]);
|
||||
|
||||
return $path === '' ? '.' : $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* `$relative` appended to `$base`, a directory or a URL: `..` in `$relative` climbs out of
|
||||
* `$base`'s own path, never past a URL's scheme and host.
|
||||
*/
|
||||
private static function join(string $base, string $relative): string
|
||||
{
|
||||
$base = rtrim($base, '/');
|
||||
|
||||
if (preg_match('~^([a-z][a-z0-9+.-]*://[^/]*)(/.*)?$~i', $base, $match) === 1) {
|
||||
return $match[1].self::normalise(($match[2] ?? '').'/'.$relative);
|
||||
}
|
||||
|
||||
return self::normalise($base.'/'.$relative);
|
||||
}
|
||||
}
|
||||
|
||||
+38
-81
@@ -18,10 +18,9 @@ use Symfony\Component\Finder\Finder;
|
||||
* (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, a 1.x ink, white or black (`text-meta`, `bg-white`,
|
||||
* `text-on-surface/60`) or on a colour the application's own Tailwind theme named
|
||||
* (`bg-brand`, `text-sport-run/60`), a 1.x package utility (`type-body-md`, `focus-ring`,
|
||||
* `rounded-corner-lg`), a variant, or an arbitrary `[…]` value or property — each with its
|
||||
* 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.
|
||||
@@ -101,14 +100,6 @@ class DesignGuard
|
||||
'2xl' => 'extra-large',
|
||||
];
|
||||
|
||||
/** The M3 breakpoint each window size class is, in px (foundations.md § Layout). */
|
||||
protected const array WINDOW_PX = [
|
||||
'medium' => 600,
|
||||
'expanded' => 840,
|
||||
'large' => 1200,
|
||||
'extra-large' => 1600,
|
||||
];
|
||||
|
||||
/** Tailwind's radius scale and the M3 corner it replaces each (styles §Shape). */
|
||||
protected const array CORNERS = [
|
||||
'none' => 'none',
|
||||
@@ -142,12 +133,6 @@ class DesignGuard
|
||||
* families are the line-by-line checks'.
|
||||
*/
|
||||
protected const array FAMILIES = [
|
||||
// 1.x's own utilities, which compiled through the package's Tailwind theme until 2.0.0.
|
||||
['/^(?:focus-ring|state-layer|touch-target|link)$/', '1.x utility `%s` compiles to nothing — use `md-%s` (interaction.css)'],
|
||||
['/^rounded(?:-(?:ss|se|ee|es|tl|tr|br|bl|t|r|b|l|s|e))?-corner-(?<corner>[a-z-]+)$/', '1.x utility `%s` compiles to nothing — use `var(--md-sys-shape-corner-{corner})` in your own CSS, or `<x-surface corner="{corner}">`'],
|
||||
['/^shadow-elevation-(?<level>[0-5])$/', '1.x utility `%s` compiles to nothing — use `var(--md-sys-elevation-{level})` in your own CSS'],
|
||||
['/^ease-(?<easing>(?:spatial|effects)-(?:fast|default|slow))$/', '1.x utility `%s` compiles to nothing — pair `var(--md-sys-motion-{easing})` with `var(--md-sys-motion-{easing}-duration)` in your own `transition`'],
|
||||
['/^ease-(?<easing>standard|emphasized)(?<phase>-accelerate|-decelerate)?$/', '1.x utility `%s` compiles to nothing — use `var(--md-sys-motion-easing-{easing}{phase})` in your own `transition`'],
|
||||
// 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
|
||||
@@ -205,17 +190,23 @@ class DesignGuard
|
||||
'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, a 1.x ink name, 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.
|
||||
* 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 = '/^(?<utility>bg|text|border(?:-(?:[trblxyse]|bs|be))?|divide|ring(?:-offset)?|outline|fill|stroke|decoration|accent|caret|placeholder|shadow|from|via|to)-'
|
||||
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|body|meta|quiet|structure|chrome|divider|white|black|current|transparent|inherit|initial)'
|
||||
.'(?:\/(?<opacity>\d{1,3}|\[[^\]]*\]|\(--[\w-]+\)))?$/';
|
||||
.'|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`,
|
||||
@@ -225,8 +216,7 @@ class DesignGuard
|
||||
* `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 = '/^(?<utility>bg|text|border(?:-(?:[trblxyse]|bs|be))?|divide|ring(?:-offset)?|outline|fill|stroke|decoration|accent|caret|placeholder|shadow|from|via|to)-'
|
||||
.'(?<name>[a-z][a-z\d]*(?:-[a-z\d]+)*)(?:\/(?<opacity>\d{1,3}|\[[^\]]*\]|\(--[\w-]+\)))?$/';
|
||||
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
|
||||
@@ -246,16 +236,6 @@ class DesignGuard
|
||||
'data-md-list-row' => ['stylesheet' => 'components/list-item.css', 'except' => ['card']],
|
||||
];
|
||||
|
||||
/** 1.x's semantic ink names, and the M3 role each resolved to (UPGRADE.md § 4). */
|
||||
protected const array INK_ALIASES = [
|
||||
'body' => 'on-surface-variant',
|
||||
'meta' => 'on-surface-variant',
|
||||
'quiet' => 'outline',
|
||||
'structure' => 'outline-variant',
|
||||
'chrome' => 'outline-variant',
|
||||
'divider' => 'outline-variant',
|
||||
];
|
||||
|
||||
/** 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',
|
||||
@@ -406,7 +386,7 @@ class DesignGuard
|
||||
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::packageCssRoot().'/foundation.css';
|
||||
$foundation = static::packagePath('css').'/foundation.css';
|
||||
|
||||
if (! isset($resolved[$foundation])) {
|
||||
$violations[] = sprintf(
|
||||
@@ -428,7 +408,8 @@ class DesignGuard
|
||||
$where = $this->relative($file->getPathname());
|
||||
$isBlade = str_ends_with($file->getFilename(), '.blade.php');
|
||||
$readsClasses = ! $this->isUnder($file->getPathname(), $mailPaths);
|
||||
$readsHooks = ! $this->isUnder($file->getPathname(), array_filter([static::packageViewRoot()]));
|
||||
// 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);
|
||||
@@ -520,7 +501,7 @@ class DesignGuard
|
||||
}
|
||||
|
||||
if ($this->paginationUsages($contents) !== []) {
|
||||
$needed[] = static::packageCssRoot().'/components/pagination.css';
|
||||
$needed[] = static::packagePath('css').'/components/pagination.css';
|
||||
}
|
||||
|
||||
return $needed;
|
||||
@@ -539,7 +520,7 @@ class DesignGuard
|
||||
return ["{$entry}:1 the CSS entry `unusedStylesheets()` names does not exist"];
|
||||
}
|
||||
|
||||
$root = static::packageCssRoot();
|
||||
$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 = [];
|
||||
@@ -730,7 +711,7 @@ class DesignGuard
|
||||
return array_map(function (array $match): string {
|
||||
$max = $match['max'] ?? '';
|
||||
$name = self::WINDOW_CLASSES[$match['name']];
|
||||
$px = self::WINDOW_PX[$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";
|
||||
@@ -825,10 +806,6 @@ class DesignGuard
|
||||
return "Tailwind variant class `{$token}` compiles to nothing — ".$this->variantHint($variant['lead']);
|
||||
}
|
||||
|
||||
if (preg_match('/^(?:type-(?:emphasized-)?(?:display|headline|title|body|label)-(?:lg|md|sm))$/', $token) === 1) {
|
||||
return "1.x utility `{$token}` compiles to nothing — use `md-{$token}` (text.css)";
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
@@ -890,16 +867,14 @@ class DesignGuard
|
||||
|
||||
/**
|
||||
* 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 variant 1.x compiled, or an
|
||||
* arbitrary selector.
|
||||
* structural pseudo-class, a direction, an M3 breakpoint, or an arbitrary selector.
|
||||
*/
|
||||
protected function variantHint(string $lead): string
|
||||
{
|
||||
$m3 = ['medium' => 600, 'expanded' => 840, 'large' => 1200, 'extra-large' => 1600];
|
||||
$bare = str_starts_with($lead, 'max-') ? substr($lead, 4) : $lead;
|
||||
|
||||
return match (true) {
|
||||
isset($m3[$bare]) => '`@media (width '.($bare === $lead ? '>=' : '<')." {$m3[$bare]}px)` in your own CSS, or a layout component's `hide-below`/`hide-from`/`stack-below` prop",
|
||||
$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',
|
||||
@@ -999,7 +974,7 @@ class DesignGuard
|
||||
}
|
||||
|
||||
$utility = $m['utility'];
|
||||
$role = self::INK_ALIASES[$m['role']] ?? $m['role'];
|
||||
$role = $m['role'];
|
||||
$dead = "Tailwind colour utility `{$token}` compiles to nothing";
|
||||
|
||||
return match (true) {
|
||||
@@ -1553,7 +1528,7 @@ class DesignGuard
|
||||
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::packageCssRoot().'/'.$stylesheet];
|
||||
$found[] = [substr_count(substr($markup, 0, $offset), "\n") + 1, $hook, static::packagePath('css').'/'.$stylesheet];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1622,7 +1597,7 @@ class DesignGuard
|
||||
}
|
||||
|
||||
foreach ($this->paginationUsages($contents) as $line) {
|
||||
$needs[] = [$line, '`->links()`', static::packageCssRoot().'/components/pagination.css'];
|
||||
$needs[] = [$line, '`->links()`', static::packagePath('css').'/components/pagination.css'];
|
||||
}
|
||||
|
||||
usort($needs, fn (array $a, array $b): int => $a[0] <=> $b[0]);
|
||||
@@ -1762,42 +1737,24 @@ class DesignGuard
|
||||
*/
|
||||
protected static function isPackageFile(string $file): bool
|
||||
{
|
||||
$root = static::packageCssRoot();
|
||||
$root = static::packagePath('css');
|
||||
|
||||
return $root !== '' && ($file === $root || str_starts_with($file, $root.'/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* This package's own `resources/css`, from this file's own location — works whether the class
|
||||
* loads from `vendor/nonameweb/livewire-material` (a consuming application) or from this
|
||||
* repository itself, the same trick `Support\Stylesheets::name()` uses.
|
||||
* 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 packageCssRoot(): string
|
||||
protected static function packagePath(string $folder): string
|
||||
{
|
||||
return (string) realpath(dirname(__DIR__, 2).'/resources/css');
|
||||
}
|
||||
|
||||
/**
|
||||
* This package's own `resources/views/components` — every component and layout component
|
||||
* lives here, so a tag's own view existing there is what "is this a package tag" checks.
|
||||
*/
|
||||
protected static function packageComponentViewRoot(): string
|
||||
{
|
||||
return (string) realpath(dirname(__DIR__, 2).'/resources/views/components');
|
||||
}
|
||||
|
||||
/**
|
||||
* This package's own `resources/views`: its components, showcase, pagination and error pages.
|
||||
* A hook written there is a tag's own, so check (ii) reads hooks only outside it.
|
||||
*/
|
||||
protected static function packageViewRoot(): string
|
||||
{
|
||||
return (string) realpath(dirname(__DIR__, 2).'/resources/views');
|
||||
return (string) realpath(dirname(__DIR__, 2)."/resources/{$folder}");
|
||||
}
|
||||
|
||||
protected static function isPackageTag(string $name): bool
|
||||
{
|
||||
$root = static::packageComponentViewRoot();
|
||||
$root = static::packagePath('views/components');
|
||||
|
||||
return $root !== '' && is_file("{$root}/{$name}.blade.php");
|
||||
}
|
||||
@@ -1815,7 +1772,7 @@ class DesignGuard
|
||||
}
|
||||
|
||||
foreach (['components', 'layout'] as $group) {
|
||||
$file = static::packageCssRoot()."/{$group}/{$name}.css";
|
||||
$file = static::packagePath('css')."/{$group}/{$name}.css";
|
||||
|
||||
if (is_file($file)) {
|
||||
return $file;
|
||||
@@ -1827,7 +1784,7 @@ class DesignGuard
|
||||
|
||||
protected static function packageRelativeName(string $file): string
|
||||
{
|
||||
$root = static::packageCssRoot().'/';
|
||||
$root = static::packagePath('css').'/';
|
||||
|
||||
return str_starts_with($file, $root) ? substr($file, strlen($root)) : $file;
|
||||
}
|
||||
@@ -1849,8 +1806,8 @@ class DesignGuard
|
||||
$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::packageCssRoot() && str_starts_with($file, static::packageCssRoot().'/')) {
|
||||
$file = $vendor.substr($file, strlen(static::packageCssRoot()));
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user