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
+205
View File
@@ -0,0 +1,205 @@
<?php
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Markdown;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
class ThemedProbeMail extends Mailable
{
public function content(): Content
{
return new Content(markdown: 'mail-theme-probe');
}
}
beforeEach(function () {
$this->temporary = sys_get_temp_dir().'/livewire-material-mail-'.Str::random(8);
File::ensureDirectoryExists($this->temporary);
File::put($this->temporary.'/mail-theme-probe.blade.php', <<<'BLADE'
<x-mail::message>
# Your export is ready
The report has **finished**.
<x-mail::button :url="'https://example.com/export'">
Download
</x-mail::button>
<x-mail::panel>
Files expire after seven days.
</x-mail::panel>
<x-mail::table>
| File | Size |
|:-----|-----:|
| orders.csv | 1.2 MB |
</x-mail::table>
</x-mail::message>
BLADE);
File::put($this->temporary.'/mail-footer-probe.blade.php', <<<'BLADE'
<x-mail::message>
Hello.
<x-slot:footer>
Sent by the probe · [Unsubscribe](https://example.com/unsubscribe)
</x-slot:footer>
</x-mail::message>
BLADE);
View::addLocation($this->temporary);
config(['mail.markdown.theme' => 'livewire-material::mail.theme']);
});
afterEach(function () {
File::deleteDirectory($this->temporary);
});
/**
* The inline style of the first element carrying the class.
*/
function inlineStyle(string $html, string $class): string
{
preg_match('/<[^>]+class="[^"]*\b'.preg_quote($class, '/').'\b[^"]*"[^>]*style="([^"]*)"/', $html, $match);
return $match[1] ?? '';
}
/**
* Put the package's mail components on `mail.markdown.paths`, as the provider does when
* `livewire-material.mail.components` is on at boot, and make the renderer again.
*/
function withPackageMailComponents(): void
{
config(['livewire-material.mail.components' => true]);
$provider = app()->getProvider(LivewireMaterialServiceProvider::class);
(fn () => $this->registerMailComponents())->call($provider);
app()->forgetInstance(Markdown::class);
}
it('inlines the application\'s scheme onto the mail', function () {
File::put($this->temporary.'/material-scheme.json', json_encode([
'light' => ['primary' => '#123456', 'on-primary' => '#fefefe', 'surface-container' => '#eeeeee', 'surface-container-lowest' => '#fdfdfd'],
'dark' => ['primary' => '#abcdef'],
]));
config(['livewire-material.scheme' => $this->temporary.'/material-scheme.json']);
$html = (new ThemedProbeMail)->render();
expect(inlineStyle($html, 'button-primary'))
->toContain('background-color: #123456')
->toContain('border-top: 16px solid #123456')
->toContain('color: #fefefe')
->toContain('border-radius: 9999px')
->and(inlineStyle($html, 'inner-body'))->toContain('background-color: #fdfdfd')
->and(inlineStyle($html, 'wrapper'))->toContain('background-color: #eeeeee')
->and(inlineStyle($html, 'panel-content'))->toContain('background-color: #eeeeee')
->and($html)
->toMatch('/<h1 style="[^"]*font-size: 24px[^"]*line-height: 32px/')
->toMatch('/<p style="[^"]*font-size: 16px[^"]*letter-spacing: 0.5px/')
->toMatch('/<th [^>]*style="[^"]*font-weight: 500/')
->toMatch('/<th align="right" style="[^"]*text-align: right/')
->not->toContain('#abcdef')
->not->toContain('var(--')
->not->toContain('color-mix');
});
it('takes the theme from a mailable too', function () {
config(['mail.markdown.theme' => 'default']);
$mail = new ThemedProbeMail;
$mail->theme = 'livewire-material::mail.theme';
expect(inlineStyle($mail->render(), 'button-primary'))->toContain('border-radius: 9999px');
});
it('falls back to the package\'s default scheme', function (?string $contents) {
$path = $this->temporary.'/material-scheme.json';
if ($contents !== null) {
File::put($path, $contents);
}
config(['livewire-material.scheme' => $path]);
$default = json_decode(File::get(__DIR__.'/../../resources/css/tokens/scheme.json'), true);
expect(inlineStyle((new ThemedProbeMail)->render(), 'button-primary'))
->toContain("background-color: {$default['light']['primary']}");
})->with([
'no scheme file' => [null],
'not JSON' => ['{ nope'],
'not a colour' => [json_encode(['light' => ['primary' => 'red; background: url(x)']])],
]);
it('styles every button colour Laravel and M3 name', function () {
$css = view('livewire-material::mail.theme')->render();
foreach (['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info', 'blue', 'green', 'red'] as $color) {
expect($css)->toContain(".button-{$color} {");
}
expect($css)->not->toContain('@media');
});
it('leaves the application\'s mail components alone unless asked', function () {
expect(config('mail.markdown.paths'))->not->toContain(LivewireMaterialServiceProvider::mailComponentPath());
withPackageMailComponents();
withPackageMailComponents();
expect(config('mail.markdown.paths'))->toBe([
resource_path('views/vendor/mail'),
LivewireMaterialServiceProvider::mailComponentPath(),
]);
});
it('renders the package header: the app name, never Laravel\'s logo', function () {
config(['app.name' => 'Laravel']);
$framework = (new ThemedProbeMail)->render();
withPackageMailComponents();
$html = (new ThemedProbeMail)->render();
expect($framework)->toContain('notification-logo')
->and($html)->not->toContain('notification-logo')
->toMatch('/<td class="header"[^>]*>\s*<a [^>]*>\s*Laravel\s*<\/a>/');
});
it('renders a configured logo in the header, sized by attributes', function () {
config(['app.name' => 'Probe', 'livewire-material.mail.logo' => ['src' => 'https://example.com/logo.png', 'width' => 160, 'height' => 40]]);
withPackageMailComponents();
expect((new ThemedProbeMail)->render())
->toMatch('/<img src="https:\/\/example.com\/logo.png" class="logo" alt="Probe" width="160" height="40"/');
});
it('lets a mail replace the footer, in HTML and in text', function () {
config(['app.name' => 'Probe']);
withPackageMailComponents();
$markdown = app(Markdown::class);
expect((string) $markdown->render('mail-footer-probe'))
->toContain('Sent by the probe')
->toContain('href="https://example.com/unsubscribe"')
->not->toContain('All rights reserved.')
->and((string) $markdown->renderText('mail-footer-probe'))
->toContain('Sent by the probe')
->not->toContain('All rights reserved.')
->and((string) $markdown->render('mail-theme-probe'))
->toContain('© '.date('Y').' Probe. All rights reserved.');
});