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:
co-authored by
Claude Sonnet 5
parent
bce55b26a2
commit
1c8a4d412a
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user