Add M3 error pages and the Markdown mail theme
tests / feature (8.5) (push) Successful in 1m8s
tests / browser (safari, webkit) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / lint (push) Successful in 1m5s
tests / feature (8.4) (push) Successful in 1m11s
tests / browser (chrome, chromium) (push) Failing after 6m9s
tests / feature (8.5) (push) Successful in 1m8s
tests / browser (safari, webkit) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / lint (push) Successful in 1m5s
tests / feature (8.4) (push) Successful in 1m11s
tests / browser (chrome, chromium) (push) Failing after 6m9s
Error pages for 401 to 503 in an error-view root appended to view.paths, with an inline fallback when the Vite build is missing, and a mail theme rendered from the application's scheme JSON, with opt-in header and message components. Completes Phase 9. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
co-authored by
Claude Opus 5
parent
ab692b66bb
commit
8f174520eb
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Mail\Markdown;
|
||||
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
|
||||
|
||||
/**
|
||||
* Previews of the pages the showcase cannot draw inline: an error page, raised for real so the
|
||||
* exception handler picks the view exactly as it would in the application (an app's own
|
||||
* `resources/views/errors` included), and a sample Markdown mail in the package theme.
|
||||
*/
|
||||
class ShowcasePageController
|
||||
{
|
||||
public function error(string $code): never
|
||||
{
|
||||
abort((int) $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sample mail, with the package's header and message after the application's own mail
|
||||
* components — what an application gets with `livewire-material.mail.components` on.
|
||||
*/
|
||||
public function mail(): Response
|
||||
{
|
||||
$markdown = new Markdown(view(), [
|
||||
'theme' => 'livewire-material::mail.theme',
|
||||
'paths' => array_values(array_unique([
|
||||
...(array) config('mail.markdown.paths', []),
|
||||
LivewireMaterialServiceProvider::mailComponentPath(),
|
||||
])),
|
||||
]);
|
||||
|
||||
return response($markdown->render('livewire-material::showcase.mail')->toHtml());
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ class LivewireMaterialServiceProvider extends ServiceProvider
|
||||
$this->mergeConfigFrom(__DIR__.'/../config/livewire-material.php', 'livewire-material');
|
||||
|
||||
$this->disableBladeIconsComponent();
|
||||
$this->registerErrorViews();
|
||||
$this->registerMailComponents();
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
@@ -33,6 +35,71 @@ class LivewireMaterialServiceProvider extends ServiceProvider
|
||||
$this->publishes([
|
||||
__DIR__.'/../config/livewire-material.php' => config_path('livewire-material.php'),
|
||||
], 'livewire-material-config');
|
||||
|
||||
$this->publishes([
|
||||
static::errorViewPath().'/errors' => resource_path('views/errors'),
|
||||
], 'livewire-material-errors');
|
||||
|
||||
$this->publishes([
|
||||
static::mailComponentPath().'/html' => resource_path('views/vendor/mail/html'),
|
||||
static::mailComponentPath().'/text' => resource_path('views/vendor/mail/text'),
|
||||
], 'livewire-material-mail');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The error-view root: a folder holding only `errors/`.
|
||||
*/
|
||||
public static function errorViewPath(): string
|
||||
{
|
||||
return dirname(__DIR__).'/resources/views/error-pages';
|
||||
}
|
||||
|
||||
/**
|
||||
* The mail component root, laid out like the framework's: `html/` and `text/`.
|
||||
*/
|
||||
public static function mailComponentPath(): string
|
||||
{
|
||||
return dirname(__DIR__).'/resources/views/mail';
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the error-view root to `view.paths`, after the application's own.
|
||||
*
|
||||
* The exception handler replaces the `errors` namespace each time it renders an HTTP
|
||||
* exception, with every entry of `view.paths` plus `/errors` and the framework's views last
|
||||
* (RegisterErrorViewPaths), so a namespace added with addNamespace() is gone by then; only a
|
||||
* view path survives. That config is read at render time, but also once when the view
|
||||
* finder is built: appended here, in register(), before any boot() resolves the view
|
||||
* factory, the finder and `view:cache` see the same paths the handler does. Appending keeps
|
||||
* `view.paths[0]` — Laravel's viewPath() — the application's, and puts the application's
|
||||
* own `resources/views/errors` first. Idempotent, since a cached config already holds it.
|
||||
*/
|
||||
protected function registerErrorViews(): void
|
||||
{
|
||||
$paths = (array) config('view.paths', []);
|
||||
|
||||
if (! in_array(static::errorViewPath(), $paths, true)) {
|
||||
config(['view.paths' => [...$paths, static::errorViewPath()]]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the package's mail header and message after the application's own mail components,
|
||||
* when `livewire-material.mail.components` asks for it. Off by default: it changes every
|
||||
* Markdown mail the application sends, theme or not, and the framework reads
|
||||
* `mail.markdown.paths` once, when the Markdown renderer is first made — after register().
|
||||
*/
|
||||
protected function registerMailComponents(): void
|
||||
{
|
||||
if (! config('livewire-material.mail.components')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$paths = (array) config('mail.markdown.paths', []);
|
||||
|
||||
if (! in_array(static::mailComponentPath(), $paths, true)) {
|
||||
config(['mail.markdown.paths' => [...$paths, static::mailComponentPath()]]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Support;
|
||||
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Illuminate\Foundation\Vite;
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* What the error layout needs from PHP, written so that nothing in it can fail the page: an
|
||||
* error page is shown precisely when something else already went wrong.
|
||||
*/
|
||||
class ErrorPage
|
||||
{
|
||||
/**
|
||||
* The roles the fallback stylesheet draws with.
|
||||
*/
|
||||
protected const array ROLES = [
|
||||
'surface', 'on-surface', 'on-surface-variant', 'primary', 'on-primary',
|
||||
'primary-container', 'on-primary-container', 'secondary',
|
||||
];
|
||||
|
||||
/**
|
||||
* The application's Vite tags, or null when they cannot be made.
|
||||
*
|
||||
* A 500 during a deploy is the moment the manifest is missing, half-written or names files
|
||||
* that are not there yet — a missing manifest throws a ViteException, a truncated one a
|
||||
* PHP warning turned exception — so every failure means "no build", never a second error.
|
||||
*/
|
||||
public static function assets(): ?Htmlable
|
||||
{
|
||||
try {
|
||||
$tags = app(Vite::class)(config('livewire-material.showcase.vite', []));
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $tags instanceof Htmlable ? $tags : new HtmlString((string) $tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the Back button goes: the page the visitor came from, when there was one other than
|
||||
* this page and the home page Home already offers. Null when the session behind it cannot
|
||||
* be read.
|
||||
*/
|
||||
public static function backUrl(): ?string
|
||||
{
|
||||
try {
|
||||
$previous = url()->previous();
|
||||
$current = url()->current();
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$isHome = rtrim($previous, '/') === rtrim(url('/'), '/');
|
||||
|
||||
return $previous === $current || $isHome ? null : $previous;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stylesheet for a page without its build: the application's scheme in both themes, a
|
||||
* system font, and the layout's `data-error-*` hooks drawn to match the Tailwind version.
|
||||
*/
|
||||
public static function fallbackStyles(): HtmlString
|
||||
{
|
||||
$scheme = Scheme::load();
|
||||
|
||||
$roles = fn (array $theme): string => implode('', array_map(
|
||||
fn (string $role): string => "--md-sys-color-{$role}: {$theme[$role]}; ",
|
||||
self::ROLES,
|
||||
));
|
||||
|
||||
return new HtmlString(<<<CSS
|
||||
:root, [data-theme='light'] { color-scheme: light; {$roles($scheme['light'])}}
|
||||
[data-theme='dark'] { color-scheme: dark; {$roles($scheme['dark'])}}
|
||||
*, ::before, ::after { box-sizing: border-box; }
|
||||
body { margin: 0; background-color: var(--md-sys-color-surface); color: var(--md-sys-color-on-surface); font: 400 1rem/1.5rem ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-font-smoothing: antialiased; }
|
||||
[data-error-page] { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100dvh; max-width: 36rem; margin: 0 auto; padding: 3rem 1.5rem; text-align: center; }
|
||||
[data-error-art] { position: relative; display: grid; place-items: center; width: 12rem; height: 12rem; }
|
||||
[data-error-shape] { position: absolute; inset: 0; color: var(--md-sys-color-primary-container); }
|
||||
[data-error-shape] svg { display: block; width: 100%; height: 100%; }
|
||||
[data-error-code] { position: relative; margin: 0; font-size: 3.5625rem; font-weight: 500; line-height: 4rem; color: var(--md-sys-color-on-primary-container); font-variant-numeric: tabular-nums; }
|
||||
[data-error-headline] { margin: 2.5rem 0 0; font-size: 1.75rem; font-weight: 400; line-height: 2.25rem; text-wrap: balance; }
|
||||
[data-error-message] { margin: 0.75rem 0 0; color: var(--md-sys-color-on-surface-variant); text-wrap: balance; }
|
||||
[data-error-actions] { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 0.75rem; margin-top: 2.5rem; }
|
||||
[data-error-actions] :is(a, button) { display: inline-flex; align-items: center; height: 3.5rem; padding: 0 1.5rem; border: 0; border-radius: 9999px; background: none; color: var(--md-sys-color-primary); font-family: inherit; font-size: 1rem; font-weight: 500; line-height: 1.5rem; text-decoration: none; cursor: pointer; }
|
||||
[data-error-actions] > :first-child { background-color: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary); }
|
||||
[data-error-actions] :is(a, button):focus-visible { outline: 3px solid var(--md-sys-color-secondary); outline-offset: 2px; }
|
||||
CSS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Support;
|
||||
|
||||
/**
|
||||
* The colour scheme as data, for the places CSS custom properties cannot reach: a mail client
|
||||
* resolves none, and an error page whose build is missing has no stylesheet to declare them.
|
||||
*
|
||||
* The data is the JSON `php artisan material:scheme` writes beside the stylesheet
|
||||
* (`livewire-material.scheme`, resources/css/material-scheme.json by default). Without that
|
||||
* file — or with one that is not a scheme — the package's own default applies, the same one
|
||||
* tokens/scheme.css carries. A role the application's file lacks (a scheme generated before
|
||||
* the role existed) is taken from the default, and a value that is not a #rrggbb hex is
|
||||
* refused, so nothing but a colour ever reaches a stylesheet.
|
||||
*
|
||||
* Read on every call and never kept: a regenerated scheme applies at once, and a long-running
|
||||
* worker holds nothing.
|
||||
*/
|
||||
class Scheme
|
||||
{
|
||||
/**
|
||||
* @return array{light: array<string, string>, dark: array<string, string>}
|
||||
*/
|
||||
public static function load(?string $path = null): array
|
||||
{
|
||||
$default = static::read(dirname(__DIR__, 2).'/resources/css/tokens/scheme.json');
|
||||
$scheme = static::read($path ?? (string) config('livewire-material.scheme'));
|
||||
|
||||
return [
|
||||
'light' => [...$default['light'], ...$scheme['light']],
|
||||
'dark' => [...$default['dark'], ...$scheme['dark']],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The light roles: what a mail wears.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function light(?string $path = null): array
|
||||
{
|
||||
return static::load($path)['light'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{light: array<string, string>, dark: array<string, string>}
|
||||
*/
|
||||
protected static function read(string $path): array
|
||||
{
|
||||
$data = is_file($path) ? json_decode((string) file_get_contents($path), true) : null;
|
||||
|
||||
return [
|
||||
'light' => static::roles($data['light'] ?? null),
|
||||
'dark' => static::roles($data['dark'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected static function roles(mixed $roles): array
|
||||
{
|
||||
if (! is_array($roles)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_filter(
|
||||
$roles,
|
||||
fn (mixed $hex, mixed $role): bool => is_string($role)
|
||||
&& preg_match('/^[a-z-]+$/', $role) === 1
|
||||
&& is_string($hex)
|
||||
&& preg_match('/^#[0-9a-fA-F]{6}$/', $hex) === 1,
|
||||
ARRAY_FILTER_USE_BOTH,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user