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:
+3
-2
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.59.2",
|
"version": "1.60.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"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",
|
"build": "vite build",
|
||||||
|
"postbuild": "pnpm exec vite-node scripts/build-error-page.ts",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
|||||||
@@ -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
@@ -15,6 +15,8 @@ REMOTE="${REMOTE:-root@bocken.org}"
|
|||||||
REMOTE_DIR="${REMOTE_DIR:-/usr/share/webapps/homepage}"
|
REMOTE_DIR="${REMOTE_DIR:-/usr/share/webapps/homepage}"
|
||||||
REMOTE_USER_GROUP="${REMOTE_USER_GROUP:-homepage:homepage}"
|
REMOTE_USER_GROUP="${REMOTE_USER_GROUP:-homepage:homepage}"
|
||||||
SERVICE="${SERVICE:-homepage.service}"
|
SERVICE="${SERVICE:-homepage.service}"
|
||||||
|
ERROR_PAGES_DIR="${ERROR_PAGES_DIR:-/var/www/errors}"
|
||||||
|
ERROR_PAGES_OWNER="${ERROR_PAGES_OWNER:-http:http}"
|
||||||
|
|
||||||
DRY=""
|
DRY=""
|
||||||
if [[ "${1:-}" == "--dry-run" ]]; then
|
if [[ "${1:-}" == "--dry-run" ]]; then
|
||||||
@@ -62,13 +64,23 @@ echo ":: Syncing package.json + pnpm-lock.yaml"
|
|||||||
rsync -az $DRY \
|
rsync -az $DRY \
|
||||||
package.json pnpm-lock.yaml "$REMOTE:$REMOTE_DIR/"
|
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
|
if [[ -n "$DRY" ]]; then
|
||||||
echo ":: Dry run complete — no service restart"
|
echo ":: Dry run complete — no service restart"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ":: Fixing ownership on server"
|
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"
|
echo ":: Restarting $SERVICE"
|
||||||
ssh "$REMOTE" "systemctl restart $SERVICE"
|
ssh "$REMOTE" "systemctl restart $SERVICE"
|
||||||
|
|||||||
@@ -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)`);
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ export function getErrorTitle(status: number, isEnglish: boolean): string {
|
|||||||
case 403: return 'Access Denied';
|
case 403: return 'Access Denied';
|
||||||
case 404: return 'Page Not Found';
|
case 404: return 'Page Not Found';
|
||||||
case 500: return 'Server Error';
|
case 500: return 'Server Error';
|
||||||
|
case 502: return 'Bad Gateway';
|
||||||
|
case 503: return 'Service Temporarily Unavailable';
|
||||||
|
case 504: return 'Gateway Timeout';
|
||||||
default: return 'Error';
|
default: return 'Error';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,6 +16,9 @@ export function getErrorTitle(status: number, isEnglish: boolean): string {
|
|||||||
case 403: return 'Zugriff verweigert';
|
case 403: return 'Zugriff verweigert';
|
||||||
case 404: return 'Seite nicht gefunden';
|
case 404: return 'Seite nicht gefunden';
|
||||||
case 500: return 'Serverfehler';
|
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';
|
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 403: return 'You do not have permission for this area.';
|
||||||
case 404: return 'The requested page could not be found.';
|
case 404: return 'The requested page could not be found.';
|
||||||
case 500: return 'An unexpected error occurred. Please try again later.';
|
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.';
|
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 403: return 'Du hast keine Berechtigung für diesen Bereich.';
|
||||||
case 404: return 'Die angeforderte Seite konnte nicht gefunden werden.';
|
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 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.';
|
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 = {
|
export const errorLabels = {
|
||||||
login: { en: 'Log in', de: 'Anmelden' },
|
login: { en: 'Log in', de: 'Anmelden' },
|
||||||
tryAgain: { en: 'Try again', de: 'Erneut versuchen' },
|
tryAgain: { en: 'Try again', de: 'Erneut versuchen' },
|
||||||
|
|||||||
@@ -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)
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user