Serve the showcase's own stylesheet without the application's build

Plan step 38 (first batch): a route bundles all.css, showcase.css and
the application's generated scheme (Stylesheets::bundle(), found next
to its configured JSON by swapping the extension, or a Scheme::load()
fallback in tokens/scheme.css's own shape) into one long-cached,
content-hashed CSS response; a stale hash redirects to the current
one. A second route serves the fonts and SVGs its relative url()s
point at, from the package's fonts/ and svg/ folders only, MIME-typed
by extension and 404ing on ".." or an unlisted extension. Both stay
unregistered with the showcase off.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-15 03:55:05 +02:00
co-authored by Claude Sonnet 5
parent bce55b26a2
commit 1c8a4d412a
3 changed files with 298 additions and 0 deletions
@@ -0,0 +1,182 @@
<?php
namespace NoNameWeb\LivewireMaterial\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
use NoNameWeb\LivewireMaterial\Support\Scheme;
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
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).
*
* 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
* 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.
*/
class ShowcaseAssetController
{
/**
* The package folders a relative `url()` in the bundle 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.
*
* @var list<string>
*/
protected const array FOLDERS = ['fonts', 'svg'];
/**
* The MIME types the showcase's stylesheet can reference: the brand's variable woff2 and the
* package's SVGs (Material Symbols, M3 Expressive shapes). Anything else 404s, never guessed.
*
* @var array<string, string>
*/
protected const array MIME_TYPES = ['woff2' => 'font/woff2', 'svg' => 'image/svg+xml'];
/**
* `all.css` plus the showcase's own chrome (and the application's scheme, found or not), at
* the hash the current bundle content hashes to. Any other hash — a stale link kept past a
* source change — redirects to the current one rather than 404ing a bookmarked or cached page.
*/
public function stylesheet(string $hash): Response|RedirectResponse
{
$css = self::build();
$actual = self::hash($css);
if (! hash_equals($actual, $hash)) {
return redirect()->route('livewire-material.stylesheet', ['hash' => $actual]);
}
return response($css, 200, [
'Content-Type' => 'text/css',
'Cache-Control' => 'public, max-age=31536000, immutable',
]);
}
/**
* A font or an SVG the stylesheet's `url()`s point at, MIME-typed by extension: `..` and
* anything the two folders above do not contain resolves to nothing and 404s, the same as an
* unlisted extension (the font's own `OFL.txt`, sitting right beside it, included).
*/
public function file(string $path): BinaryFileResponse
{
$segments = explode('/', $path, 2);
$folder = $segments[0] ?? null;
$rest = $segments[1] ?? null;
abort_unless(in_array($folder, self::FOLDERS, true) && $rest !== null, 404);
$root = realpath(dirname(__DIR__, 3)."/resources/{$folder}");
$real = $root === false ? false : realpath("{$root}/{$rest}");
abort_unless($real !== false && str_starts_with($real, $root.DIRECTORY_SEPARATOR), 404);
$mime = self::MIME_TYPES[strtolower(pathinfo($real, PATHINFO_EXTENSION))] ?? null;
abort_if($mime === null, 404);
return response()->file($real, [
'Content-Type' => $mime,
'Cache-Control' => 'public, max-age=31536000, immutable',
]);
}
/**
* The current bundle's URL, for the showcase's own `<head>` to link.
*/
public static function url(): string
{
return route('livewire-material.stylesheet', ['hash' => self::hash(self::build())]);
}
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
{
$json = (string) config('livewire-material.scheme');
$css = 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";
}
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);
}
protected static function hash(string $css): string
{
return substr(hash('sha256', $css), 0, 16);
}
}