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
+10
View File
@@ -1,6 +1,7 @@
<?php <?php
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseAssetController;
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseController; use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseController;
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcasePageController; use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcasePageController;
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseSymbolController; use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseSymbolController;
@@ -11,6 +12,15 @@ Route::get('/', [ShowcaseController::class, 'index'])->name('showcase');
Route::get('symbols.json', [ShowcaseSymbolController::class, 'index'])->name('symbols'); Route::get('symbols.json', [ShowcaseSymbolController::class, 'index'])->name('symbols');
Route::get('symbols/{style}/{name}.svg', [ShowcaseSymbolController::class, 'show'])->name('symbol'); Route::get('symbols/{style}/{name}.svg', [ShowcaseSymbolController::class, 'show'])->name('symbol');
// The showcase's own bundle (all.css, showcase.css, the application's scheme), long-cached under
// the hash of its own content, and the two package folders its relative url()s may point into.
Route::get('showcase.{hash}.css', [ShowcaseAssetController::class, 'stylesheet'])
->where('hash', '[0-9a-f]+')
->name('stylesheet');
Route::get('assets/{path}', [ShowcaseAssetController::class, 'file'])
->where('path', '.*')
->name('asset');
Route::view('shell/{page?}', 'livewire-material::showcase.shell') Route::view('shell/{page?}', 'livewire-material::showcase.shell')
->whereIn('page', ['inbox', 'starred', 'sent', 'drafts', 'travel', 'receipts']) ->whereIn('page', ['inbox', 'starred', 'sent', 'drafts', 'travel', 'receipts'])
->name('shell'); ->name('shell');
@@ -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);
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseAssetController;
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
/**
* The showcase's own stylesheet route (plan step 38): `all.css` plus `showcase.css`, bundled by
* `Stylesheets::bundle()` and served without the application's Vite build, and the two package
* folders its relative `url()`s point into.
*/
afterEach(function () {
Stylesheets::resetCache();
});
it('serves the bundle as long-cached CSS at the hash of its own content', function () {
$response = $this->get(ShowcaseAssetController::url())
->assertOk()
->assertHeader('Content-Type', 'text/css; charset=utf-8')
->assertHeader('Cache-Control', 'immutable, max-age=31536000, public');
expect($response->getContent())
->toContain('[data-md-showcase]')
->toContain('[data-md-pane]')
->toContain('@layer material.reset, material.tokens');
});
it('redirects a hash that does not match the current bundle to the current one', function () {
$stale = route('livewire-material.stylesheet', ['hash' => '0000000000000000']);
$this->get($stale)->assertRedirect(ShowcaseAssetController::url());
});
it('serves the font and svg files the bundle references, mime-typed by extension', function () {
$css = $this->get(ShowcaseAssetController::url())->getContent();
$fontUrl = route('livewire-material.asset', ['path' => 'fonts/google-sans-flex/GoogleSansFlex-Latin.woff2'], false);
$svgUrl = route('livewire-material.asset', ['path' => 'svg/symbols/outlined/check.svg'], false);
expect($css)->toContain($fontUrl)->toContain($svgUrl);
$this->get($fontUrl)
->assertOk()
->assertHeader('Content-Type', 'font/woff2')
->assertHeader('Cache-Control', 'immutable, max-age=31536000, public');
$this->get($svgUrl)
->assertOk()
->assertHeader('Content-Type', 'image/svg+xml');
});
it('404s a path that climbs out of the two served folders', function () {
$this->get('/material/assets/fonts/../../composer.json')->assertNotFound();
});
it('404s a folder the showcase does not serve', function () {
$this->get('/material/assets/css/all.css')->assertNotFound();
});
it('404s an extension the showcase does not serve, even inside a served folder', function () {
$this->get('/material/assets/fonts/google-sans-flex/OFL.txt')->assertNotFound();
});
it('bundles the application generated scheme, unlayered, when one sits beside its configured JSON', function () {
$dir = sys_get_temp_dir().'/livewire-material-showcase-scheme-'.Str::random(8);
mkdir($dir);
file_put_contents($dir.'/material-scheme.css', ":root { --md-showcase-test-marker: #123456; }\n");
config(['livewire-material.scheme' => $dir.'/material-scheme.json']);
Stylesheets::resetCache();
try {
$this->get(ShowcaseAssetController::url())
->assertOk()
->assertSee('--md-showcase-test-marker: #123456', false);
} finally {
unlink($dir.'/material-scheme.css');
rmdir($dir);
}
});
it('falls back to Scheme::load() in the default shape when no generated stylesheet is found', function () {
config(['livewire-material.scheme' => sys_get_temp_dir().'/livewire-material-showcase-missing-'.Str::random(8).'.json']);
Stylesheets::resetCache();
$css = $this->get(ShowcaseAssetController::url())->assertOk()->getContent();
// The fallback's own one-line selector list, distinct from tokens/scheme.css's multi-line one
// (already in all.css), so its presence here can only come from the appended fallback block.
expect($css)->toContain("[data-contrast='medium'], [data-contrast='medium'][data-theme='light'], [data-contrast='medium'] [data-theme='light']");
});
it('mounts none of the showcase asset routes when the showcase is off', function () {
putenv('MATERIAL_SHOWCASE=false');
$_ENV['MATERIAL_SHOWCASE'] = $_SERVER['MATERIAL_SHOWCASE'] = 'false';
$this->refreshApplication();
try {
$this->get('/material/showcase.0000000000000000.css')->assertNotFound();
$this->get('/material/assets/fonts/google-sans-flex/GoogleSansFlex-Latin.woff2')->assertNotFound();
} finally {
putenv('MATERIAL_SHOWCASE=true');
$_ENV['MATERIAL_SHOWCASE'] = $_SERVER['MATERIAL_SHOWCASE'] = 'true';
$this->refreshApplication();
}
});