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

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:
Andreas Reinhold / reini
2026-09-13 08:55:58 +02:00
co-authored by Claude Opus 5
parent ab692b66bb
commit 8f174520eb
29 changed files with 1595 additions and 2 deletions
@@ -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());
}
}
+67
View File
@@ -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()]]);
}
}
+92
View File
@@ -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);
}
}
+76
View File
@@ -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,
);
}
}