From 1c8a4d412a689ff07719d70a5077fa1ebb070822 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Tue, 15 Sep 2026 03:55:05 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- routes/showcase.php | 10 + .../Controllers/ShowcaseAssetController.php | 182 ++++++++++++++++++ tests/Feature/ShowcaseAssetsTest.php | 106 ++++++++++ 3 files changed, 298 insertions(+) create mode 100644 src/Http/Controllers/ShowcaseAssetController.php create mode 100644 tests/Feature/ShowcaseAssetsTest.php diff --git a/routes/showcase.php b/routes/showcase.php index 1135c8a4..f94c5611 100644 --- a/routes/showcase.php +++ b/routes/showcase.php @@ -1,6 +1,7 @@ name('showcase'); Route::get('symbols.json', [ShowcaseSymbolController::class, 'index'])->name('symbols'); 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') ->whereIn('page', ['inbox', 'starred', 'sent', 'drafts', 'travel', 'receipts']) ->name('shell'); diff --git a/src/Http/Controllers/ShowcaseAssetController.php b/src/Http/Controllers/ShowcaseAssetController.php new file mode 100644 index 00000000..8dedf0c1 --- /dev/null +++ b/src/Http/Controllers/ShowcaseAssetController.php @@ -0,0 +1,182 @@ + + */ + 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 + */ + 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 `` 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); + } +} diff --git a/tests/Feature/ShowcaseAssetsTest.php b/tests/Feature/ShowcaseAssetsTest.php new file mode 100644 index 00000000..1c1b4f6c --- /dev/null +++ b/tests/Feature/ShowcaseAssetsTest.php @@ -0,0 +1,106 @@ +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(); + } +});