feat(errors): per-status static error pages for nginx fallback
CI / update (push) Successful in 39s

Adds prerendered, JS-less, self-contained error pages for nginx
error_page use — served directly from /var/www/errors/ when the
SvelteKit upstream is unreachable or any nginx-originated 4xx/5xx
fires (including the catch-all default_server for unknown hosts).

- /errors/[status] (DE default) + /errors/en/[status] (EN), each
  with a header language toggle linking absolute to bocken.org so
  the switch works even on unknown-host fallbacks.
- httpStatus param matcher restricts entries to 401/403/404/500/
  502/503/504; entries() drives prerender output.
- generate-error-quotes.ts looks up curated bilingual references
  in the existing allioli/drb TSV bibles at prebuild time and
  writes src/lib/data/errorQuotes.json.
- build-error-page.ts (postbuild) inlines all CSS, strips module
  preloads/scripts, rewrites the home-link to canonical https URL,
  and emits .html + .gz + .br per status under build/client/errors.
- deploy.sh syncs build/client/errors → /var/www/errors with
  http:http ownership for nginx access.
This commit is contained in:
2026-05-02 20:11:34 +02:00
parent e85a2508e8
commit b10634f831
11 changed files with 652 additions and 3 deletions
+3 -2
View File
@@ -1,12 +1,13 @@
{
"name": "homepage",
"version": "1.59.2",
"version": "1.60.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts && pnpm exec vite-node scripts/download-models.ts && pnpm exec vite-node scripts/generate-loyalty-cards.ts",
"prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts && pnpm exec vite-node scripts/download-models.ts && pnpm exec vite-node scripts/generate-loyalty-cards.ts && pnpm exec vite-node scripts/generate-error-quotes.ts",
"build": "vite build",
"postbuild": "pnpm exec vite-node scripts/build-error-page.ts",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
+99
View File
@@ -0,0 +1,99 @@
/**
* Postbuild: turn each prerendered /errors/<status> route into a self-contained
* HTML file at build/client/errors/<status>.html for nginx error_page use.
*
* - Inlines every <link rel="stylesheet"> by replacing it with <style>.
* - Strips <script type="module"> and <link rel="modulepreload"> (csr=false,
* so JS is dead weight and a missing-asset risk if upstream is dead).
* - Leaves font/image URLs alone — nginx serves them from the same root.
* - Emits matching .gz + .br for nginx gzip_static / brotli_static.
*
* Run: pnpm exec vite-node scripts/build-error-page.ts
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
import { dirname, resolve, join, posix } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gzipSync, brotliCompressSync, constants as zlib } from 'node:zlib';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, '..');
const PRERENDER_DIR = join(ROOT, 'build/prerendered/errors');
const CLIENT = join(ROOT, 'build/client');
const OUT_DIR = join(CLIENT, 'errors');
// Error pages may be served from arbitrary domains via nginx's default_server
// catchall. Rewrite the home-link to an absolute canonical URL so clicking
// the logo always lands on the real site.
const CANONICAL_HOME = 'https://bocken.org/';
if (!existsSync(PRERENDER_DIR)) {
console.error(`[error-page] missing prerender dir: ${PRERENDER_DIR}`);
console.error('[error-page] is /errors/[status=httpStatus]/+page.ts setting `prerender = true` with `entries()`?');
process.exit(1);
}
mkdirSync(OUT_DIR, { recursive: true });
// Recursively collect every prerendered html under build/prerendered/errors,
// so we pick up nested language variants (errors/en/<status>.html).
function walk(dir: string, prefix = ''): { rel: string; abs: string }[] {
const out: { rel: string; abs: string }[] = [];
for (const ent of readdirSync(dir, { withFileTypes: true })) {
const abs = join(dir, ent.name);
const rel = prefix ? `${prefix}/${ent.name}` : ent.name;
if (ent.isDirectory()) out.push(...walk(abs, rel));
else if (ent.isFile() && ent.name.endsWith('.html')) out.push({ rel, abs });
}
return out;
}
const sources = walk(PRERENDER_DIR);
if (sources.length === 0) {
console.error(`[error-page] no .html files under ${PRERENDER_DIR}`);
process.exit(1);
}
// Resolve a possibly-relative href (../foo, ./foo, /foo) against the page's
// path (e.g. /errors/503.html) into a path inside CLIENT.
function resolveAsset(href: string, pagePath: string): string {
const abs = posix.resolve(posix.dirname(pagePath), href); // e.g. /_app/immutable/assets/x.css
return join(CLIENT, abs.replace(/^\//, ''));
}
function inline(html: string, pagePath: string): string {
// Inline <link rel="stylesheet"> regardless of attribute order.
html = html.replace(/<link\b[^>]*>/g, (tag) => {
if (!/\brel=["']stylesheet["']/.test(tag)) return tag;
const m = tag.match(/\bhref=["']([^"']+)["']/);
if (!m) return tag;
const cssPath = resolveAsset(m[1], pagePath);
if (!existsSync(cssPath)) {
console.warn(`[error-page] stylesheet not found, leaving link tag: ${m[1]}`);
return tag;
}
return `<style>${readFileSync(cssPath, 'utf8')}</style>`;
});
// Drop module preloads and module scripts — nothing should hydrate.
html = html.replace(/<link[^>]*\brel=["']modulepreload["'][^>]*>\s*/g, '');
html = html.replace(/<script[^>]*\btype=["']module["'][^>]*>[\s\S]*?<\/script>\s*/g, '');
// Point the brand/home link at the canonical site (the page may be served
// from any domain when used as nginx's default_server fallback).
html = html.replace(/<a\b[^>]*\bclass="[^"]*\bhome-link\b[^"]*"[^>]*>/g, (tag) =>
tag.replace(/\bhref="[^"]*"/, `href="${CANONICAL_HOME}"`)
);
return html;
}
for (const { rel, abs } of sources) {
const dst = join(OUT_DIR, rel);
mkdirSync(dirname(dst), { recursive: true });
const html = inline(readFileSync(abs, 'utf8'), `/errors/${rel}`);
const buf = Buffer.from(html, 'utf8');
writeFileSync(dst, buf);
writeFileSync(`${dst}.gz`, gzipSync(buf, { level: 9 }));
writeFileSync(`${dst}.br`, brotliCompressSync(buf, {
params: { [zlib.BROTLI_PARAM_QUALITY]: 11 }
}));
console.log(`[error-page] wrote errors/${rel} (${(buf.length / 1024).toFixed(1)} kB) + .gz + .br`);
}
+13 -1
View File
@@ -15,6 +15,8 @@ REMOTE="${REMOTE:-root@bocken.org}"
REMOTE_DIR="${REMOTE_DIR:-/usr/share/webapps/homepage}"
REMOTE_USER_GROUP="${REMOTE_USER_GROUP:-homepage:homepage}"
SERVICE="${SERVICE:-homepage.service}"
ERROR_PAGES_DIR="${ERROR_PAGES_DIR:-/var/www/errors}"
ERROR_PAGES_OWNER="${ERROR_PAGES_OWNER:-http:http}"
DRY=""
if [[ "${1:-}" == "--dry-run" ]]; then
@@ -62,13 +64,23 @@ echo ":: Syncing package.json + pnpm-lock.yaml"
rsync -az $DRY \
package.json pnpm-lock.yaml "$REMOTE:$REMOTE_DIR/"
if [[ ! -d build/client/errors ]]; then
echo "!! build/client/errors not produced — postbuild error-page step did not run"
exit 1
fi
echo ":: Syncing error pages → $REMOTE:$ERROR_PAGES_DIR/"
ssh "$REMOTE" "mkdir -p $ERROR_PAGES_DIR"
rsync -az --delete $DRY --info=progress2 \
build/client/errors/ "$REMOTE:$ERROR_PAGES_DIR/"
if [[ -n "$DRY" ]]; then
echo ":: Dry run complete — no service restart"
exit 0
fi
echo ":: Fixing ownership on server"
ssh "$REMOTE" "chown -R $REMOTE_USER_GROUP $REMOTE_DIR/dist $REMOTE_DIR/node_modules $REMOTE_DIR/static $REMOTE_DIR/package.json $REMOTE_DIR/pnpm-lock.yaml"
ssh "$REMOTE" "chown -R $REMOTE_USER_GROUP $REMOTE_DIR/dist $REMOTE_DIR/node_modules $REMOTE_DIR/static $REMOTE_DIR/package.json $REMOTE_DIR/pnpm-lock.yaml && chown -R $ERROR_PAGES_OWNER $ERROR_PAGES_DIR"
echo ":: Restarting $SERVICE"
ssh "$REMOTE" "systemctl restart $SERVICE"
+60
View File
@@ -0,0 +1,60 @@
/**
* Build-time generation of bilingual Bible quotes per HTTP error status.
*
* Looks up curated references in static/allioli.tsv (DE) + static/drb.tsv (EN)
* via the existing bible reference parser, then writes the resolved verses to
* src/lib/data/errorQuotes.json for the prerendered /errors/[status] pages.
*
* - Add or change a status by editing REFS below.
* - Refs use the abbreviations defined in the TSVs (e.g. Mt 7,7 / Mt 7:7).
* - Fails the build if any reference cannot be resolved.
*
* Run: pnpm exec vite-node scripts/generate-error-quotes.ts
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { lookupReference } from '../src/lib/server/bible';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, '..');
const ALLIOLI = join(ROOT, 'static/allioli.tsv');
const DRB = join(ROOT, 'static/drb.tsv');
const OUT = join(ROOT, 'src/lib/data/errorQuotes.json');
// Curated refs. Abbreviations must match the TSV's `abbreviation` column.
const REFS: Record<number, { de: string; en: string }> = {
401: { de: 'Mt 7,7', en: 'Mt 7:7' },
403: { de: 'Mt 7,14', en: 'Mt 7:14' },
404: { de: 'Mt 7,8', en: 'Mt 7:8' },
500: { de: '2Kor 4,7', en: '2Cor 4:7' },
502: { de: '1Mo 11,9', en: 'Gn 11:9' },
503: { de: 'Ps 37,7', en: 'Ps 37:7' },
504: { de: 'Jes 40,31', en: 'Is 40:31' }
};
type ResolvedQuote = { text: string; reference: string };
function resolveOne(ref: string, tsv: string): ResolvedQuote {
const result = lookupReference(ref, tsv);
if (!result || result.verses.length === 0) {
throw new Error(`could not resolve reference "${ref}" in ${tsv}`);
}
// Range refs join verses with a space. Display reference reuses the
// original input so the UI keeps the canonical "Mt 7,7" / "Mt 7:7" form.
const text = result.verses.map((v) => v.text).join(' ');
return { text, reference: ref };
}
const out: Record<string, { de: ResolvedQuote; en: ResolvedQuote }> = {};
for (const [status, refs] of Object.entries(REFS)) {
out[status] = {
de: resolveOne(refs.de, ALLIOLI),
en: resolveOne(refs.en, DRB)
};
console.log(`[error-quotes] ${status}: ${refs.de} / ${refs.en}`);
}
mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(OUT, JSON.stringify(out, null, 2) + '\n', 'utf8');
console.log(`[error-quotes] wrote ${OUT.replace(ROOT + '/', '')} (${Object.keys(out).length} statuses)`);
+72
View File
@@ -0,0 +1,72 @@
{
"401": {
"de": {
"text": "Bittet, und es wird euch gegeben werden; suchet, und ihr werdet finden; klopfet an, und es wird euch aufgetan werden",
"reference": "Mt 7,7"
},
"en": {
"text": "Ask, and it shall be given you: seek, and you shall find: knock, and it shall be opened to you.",
"reference": "Mt 7:7"
}
},
"403": {
"de": {
"text": "Wie eng ist die Pforte, und wie schmal der Weg, der zum Leben führt; und wenige sind, die ihn finden!",
"reference": "Mt 7,14"
},
"en": {
"text": "How narrow is the gate, and strait is the way that leadeth to life: and few there are that find it!",
"reference": "Mt 7:14"
}
},
"404": {
"de": {
"text": "Denn ein jeder, der bittet, der empfängt; und wer suchet, findet; und dem, der anklopft, wird aufgetan werden.",
"reference": "Mt 7,8"
},
"en": {
"text": "For every one that asketh, receiveth: and he that seeketh, findeth: and to him that knocketh, it shall be opened.",
"reference": "Mt 7:8"
}
},
"500": {
"de": {
"text": "Wir haben aber diesen Schatz in irdenen Gefäßen, damit die Überschwenglichkeit der Kraft nicht uns, sondern Gott beigemessen werde.",
"reference": "2Kor 4,7"
},
"en": {
"text": "But we have this treasure in earthen vessels, that the excellency may be of the power of God and not of us.",
"reference": "2Cor 4:7"
}
},
"502": {
"de": {
"text": "Darum ward ihr Name Babel genannt, weil daselbst sie Sprache der ganzen Menschheit verwirrt ward; und von da zerstreute sie der Herr über alle Lande.",
"reference": "1Mo 11,9"
},
"en": {
"text": "And therefore the name thereof was called Babel, because there the language of the whole earth was confounded: and from thence the Lord scattered them abroad upon the face of all countries.",
"reference": "Gn 11:9"
}
},
"503": {
"de": {
"text": "Sei dem Herrn untergeben und bete zu ihm. Ereifere dich nicht über den, der glücklich ist auf seinem Wege, über den Mann, der Unrecht tut.",
"reference": "Ps 37,7"
},
"en": {
"text": "I am become miserable, and am bowed down even to the end: I walked sorrowful all the day long.",
"reference": "Ps 37:7"
}
},
"504": {
"de": {
"text": "die aber auf den Herrn hoffen, erneuern ihre Kraft, heben ihre Schwingen gleich Adlern, laufen und werden nicht müde, schreiten voran und werden nicht matt.",
"reference": "Jes 40,31"
},
"en": {
"text": "But they that hope in the Lord shall renew their strength, they shall take wings as eagles, they shall run and not be weary, they shall walk and not faint.",
"reference": "Is 40:31"
}
}
}
+29
View File
@@ -5,6 +5,9 @@ export function getErrorTitle(status: number, isEnglish: boolean): string {
case 403: return 'Access Denied';
case 404: return 'Page Not Found';
case 500: return 'Server Error';
case 502: return 'Bad Gateway';
case 503: return 'Service Temporarily Unavailable';
case 504: return 'Gateway Timeout';
default: return 'Error';
}
}
@@ -13,6 +16,9 @@ export function getErrorTitle(status: number, isEnglish: boolean): string {
case 403: return 'Zugriff verweigert';
case 404: return 'Seite nicht gefunden';
case 500: return 'Serverfehler';
case 502: return 'Bad Gateway';
case 503: return 'Server vorübergehend nicht erreichbar';
case 504: return 'Gateway-Zeitüberschreitung';
default: return 'Fehler';
}
}
@@ -24,6 +30,9 @@ export function getErrorDescription(status: number, isEnglish: boolean): string
case 403: return 'You do not have permission for this area.';
case 404: return 'The requested page could not be found.';
case 500: return 'An unexpected error occurred. Please try again later.';
case 502: return 'The site is briefly offline. Please try again in a few minutes.';
case 503: return 'The site is briefly offline. Please try again in a few minutes.';
case 504: return 'The upstream is taking too long to respond. Please try again shortly.';
default: return 'An unexpected error occurred.';
}
}
@@ -32,10 +41,30 @@ export function getErrorDescription(status: number, isEnglish: boolean): string
case 403: return 'Du hast keine Berechtigung für diesen Bereich.';
case 404: return 'Die angeforderte Seite konnte nicht gefunden werden.';
case 500: return 'Es ist ein unerwarteter Fehler aufgetreten. Bitte versuche es später erneut.';
case 502: return 'Die Seite ist gerade kurz offline. Bitte in ein paar Minuten erneut probieren.';
case 503: return 'Die Seite ist gerade kurz offline. Bitte in ein paar Minuten erneut probieren.';
case 504: return 'Der Server braucht zu lange zum Antworten. Bitte gleich nochmal probieren.';
default: return 'Es ist ein unerwarteter Fehler aufgetreten.';
}
}
export interface BibleQuote {
text: string;
reference: string;
}
// Generated at prebuild time by scripts/generate-error-quotes.ts from the
// allioli (DE) + drb (EN) TSV bibles. Curated reference list lives in that
// script; this JSON is the resolved verse text + display reference.
import errorQuotesData from '$lib/data/errorQuotes.json';
const errorQuotes = errorQuotesData as Record<string, { de: BibleQuote; en: BibleQuote }>;
export function getErrorBibleQuote(status: number, isEnglish: boolean): BibleQuote | null {
const entry = errorQuotes[String(status)];
if (!entry) return null;
return isEnglish ? entry.en : entry.de;
}
export const errorLabels = {
login: { en: 'Log in', de: 'Anmelden' },
tryAgain: { en: 'Try again', de: 'Erneut versuchen' },
+6
View File
@@ -0,0 +1,6 @@
import type { ParamMatcher } from '@sveltejs/kit';
export const HTTP_ERROR_STATUSES = ['401', '403', '404', '500', '502', '503', '504'] as const;
const SET = new Set<string>(HTTP_ERROR_STATUSES);
export const match: ParamMatcher = (param) => SET.has(param);
@@ -0,0 +1,171 @@
<script lang="ts">
import Header from '$lib/components/Header.svelte';
import Lock from '@lucide/svelte/icons/lock';
import Ban from '@lucide/svelte/icons/ban';
import SearchX from '@lucide/svelte/icons/search-x';
import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
import CircleAlert from '@lucide/svelte/icons/circle-alert';
import { getErrorTitle, getErrorDescription, getErrorBibleQuote } from '$lib/js/errorStrings';
let { data } = $props();
const status = data.status;
const Icon = (() => {
switch (status) {
case 401: return Lock;
case 403: return Ban;
case 404: return SearchX;
case 500: return TriangleAlert;
default: return CircleAlert;
}
})();
const title = getErrorTitle(status, false);
const description = getErrorDescription(status, false);
const quote = getErrorBibleQuote(status, false);
const otherLangHref = `https://bocken.org/errors/en/${status}.html`;
</script>
<svelte:head>
<title>{title} — Alexander's Website</title>
<meta name="robots" content="noindex" />
<link rel="alternate" hreflang="en" href={otherLangHref} />
<link rel="alternate" hreflang="de" href={`https://bocken.org/errors/${status}.html`} />
</svelte:head>
<Header>
{#snippet links()}
<ul class="site_header"></ul>
{/snippet}
{#snippet language_selector_desktop()}
<a class="lang-toggle" href={otherLangHref} hreflang="en" aria-label="Switch to English">EN</a>
{/snippet}
<main class="error-page" lang="de">
<article class="error-article">
<header class="eyebrow">
<Icon size={14} strokeWidth={1.5} aria-hidden="true" />
<span class="eyebrow-label">Fehler</span>
</header>
<div class="code" aria-hidden="true">{status}</div>
<h1 class="title">{title}</h1>
<p class="description">{description}</p>
{#if quote}
<figure class="quote">
<blockquote class="quote-text">{quote.text}</blockquote>
<figcaption class="quote-reference">{quote.reference}</figcaption>
</figure>
{/if}
</article>
</main>
</Header>
<style>
.error-page {
min-height: calc(100vh - 6rem);
display: flex;
align-items: flex-start;
justify-content: center;
padding: clamp(3rem, 10vh, 8rem) 1.5rem 4rem;
background: var(--color-bg-primary);
}
.error-article {
width: 100%;
max-width: 640px;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--color-text-tertiary);
margin-bottom: 1rem;
}
.eyebrow-label {
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.code {
font-size: clamp(7rem, 22vw, 14rem);
font-weight: 200;
line-height: 0.9;
letter-spacing: -0.05em;
color: var(--color-text-primary);
margin: 0 0 2rem;
font-variant-numeric: lining-nums tabular-nums;
}
.title {
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 500;
letter-spacing: -0.01em;
color: var(--color-text-primary);
margin: 0 0 0.5rem;
}
.description {
font-size: 1.0625rem;
line-height: 1.55;
color: var(--color-text-secondary);
margin: 0;
max-width: 44ch;
}
.quote {
margin: 2rem 0 0;
padding-left: 1rem;
border-left: 2px solid var(--color-border);
max-width: 44ch;
}
.quote-text {
font-family: Georgia, "Times New Roman", Cambria, serif;
font-style: italic;
font-size: 1.0625rem;
line-height: 1.5;
color: var(--color-text-primary);
margin: 0 0 0.5rem;
text-wrap: balance;
hyphens: auto;
}
.quote-reference {
font-size: 0.7rem;
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-text-tertiary);
}
.lang-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 1.85rem;
padding: 0 0.6rem;
border-radius: 100px;
border: 1px solid var(--nav-btn-border, rgba(255,255,255,0.2));
color: var(--nav-text, #999);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.05em;
text-decoration: none;
transition: all 0.15s;
}
.lang-toggle:hover,
.lang-toggle:focus-visible {
color: var(--nav-text-hover, white);
background: var(--nav-hover-bg, rgba(255,255,255,0.1));
border-color: var(--nav-btn-border-hover, rgba(255,255,255,0.4));
}
</style>
@@ -0,0 +1,15 @@
import { HTTP_ERROR_STATUSES } from '../../../params/httpStatus';
import type { PageLoad, EntryGenerator } from './$types';
// Prerendered, JS-less status pages used as nginx error_page fallbacks.
// One HTML file per status; postbuild inliner self-contains them.
export const prerender = true;
export const ssr = true;
export const csr = false;
export const entries: EntryGenerator = () =>
HTTP_ERROR_STATUSES.map((status) => ({ status }));
export const load: PageLoad = ({ params }) => ({
status: parseInt(params.status, 10)
});
@@ -0,0 +1,171 @@
<script lang="ts">
import Header from '$lib/components/Header.svelte';
import Lock from '@lucide/svelte/icons/lock';
import Ban from '@lucide/svelte/icons/ban';
import SearchX from '@lucide/svelte/icons/search-x';
import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
import CircleAlert from '@lucide/svelte/icons/circle-alert';
import { getErrorTitle, getErrorDescription, getErrorBibleQuote } from '$lib/js/errorStrings';
let { data } = $props();
const status = data.status;
const Icon = (() => {
switch (status) {
case 401: return Lock;
case 403: return Ban;
case 404: return SearchX;
case 500: return TriangleAlert;
default: return CircleAlert;
}
})();
const title = getErrorTitle(status, true);
const description = getErrorDescription(status, true);
const quote = getErrorBibleQuote(status, true);
const otherLangHref = `https://bocken.org/errors/${status}.html`;
</script>
<svelte:head>
<title>{title} — Alexander's Website</title>
<meta name="robots" content="noindex" />
<link rel="alternate" hreflang="de" href={otherLangHref} />
<link rel="alternate" hreflang="en" href={`https://bocken.org/errors/en/${status}.html`} />
</svelte:head>
<Header>
{#snippet links()}
<ul class="site_header"></ul>
{/snippet}
{#snippet language_selector_desktop()}
<a class="lang-toggle" href={otherLangHref} hreflang="de" aria-label="Auf Deutsch wechseln">DE</a>
{/snippet}
<main class="error-page" lang="en">
<article class="error-article">
<header class="eyebrow">
<Icon size={14} strokeWidth={1.5} aria-hidden="true" />
<span class="eyebrow-label">Error</span>
</header>
<div class="code" aria-hidden="true">{status}</div>
<h1 class="title">{title}</h1>
<p class="description">{description}</p>
{#if quote}
<figure class="quote">
<blockquote class="quote-text">{quote.text}</blockquote>
<figcaption class="quote-reference">{quote.reference}</figcaption>
</figure>
{/if}
</article>
</main>
</Header>
<style>
.error-page {
min-height: calc(100vh - 6rem);
display: flex;
align-items: flex-start;
justify-content: center;
padding: clamp(3rem, 10vh, 8rem) 1.5rem 4rem;
background: var(--color-bg-primary);
}
.error-article {
width: 100%;
max-width: 640px;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--color-text-tertiary);
margin-bottom: 1rem;
}
.eyebrow-label {
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.code {
font-size: clamp(7rem, 22vw, 14rem);
font-weight: 200;
line-height: 0.9;
letter-spacing: -0.05em;
color: var(--color-text-primary);
margin: 0 0 2rem;
font-variant-numeric: lining-nums tabular-nums;
}
.title {
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 500;
letter-spacing: -0.01em;
color: var(--color-text-primary);
margin: 0 0 0.5rem;
}
.description {
font-size: 1.0625rem;
line-height: 1.55;
color: var(--color-text-secondary);
margin: 0;
max-width: 44ch;
}
.quote {
margin: 2rem 0 0;
padding-left: 1rem;
border-left: 2px solid var(--color-border);
max-width: 44ch;
}
.quote-text {
font-family: Georgia, "Times New Roman", Cambria, serif;
font-style: italic;
font-size: 1.0625rem;
line-height: 1.5;
color: var(--color-text-primary);
margin: 0 0 0.5rem;
text-wrap: balance;
hyphens: auto;
}
.quote-reference {
font-size: 0.7rem;
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-text-tertiary);
}
.lang-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 1.85rem;
padding: 0 0.6rem;
border-radius: 100px;
border: 1px solid var(--nav-btn-border, rgba(255,255,255,0.2));
color: var(--nav-text, #999);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.05em;
text-decoration: none;
transition: all 0.15s;
}
.lang-toggle:hover,
.lang-toggle:focus-visible {
color: var(--nav-text-hover, white);
background: var(--nav-hover-bg, rgba(255,255,255,0.1));
border-color: var(--nav-btn-border-hover, rgba(255,255,255,0.4));
}
</style>
@@ -0,0 +1,13 @@
import { HTTP_ERROR_STATUSES } from '../../../../params/httpStatus';
import type { PageLoad, EntryGenerator } from './$types';
export const prerender = true;
export const ssr = true;
export const csr = false;
export const entries: EntryGenerator = () =>
HTTP_ERROR_STATUSES.map((status) => ({ status }));
export const load: PageLoad = ({ params }) => ({
status: parseInt(params.status, 10)
});