diff --git a/package.json b/package.json index b9baf552..31e322c0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build-error-page.ts b/scripts/build-error-page.ts new file mode 100644 index 00000000..e9db0aba --- /dev/null +++ b/scripts/build-error-page.ts @@ -0,0 +1,99 @@ +/** + * Postbuild: turn each prerendered /errors/ route into a self-contained + * HTML file at build/client/errors/.html for nginx error_page use. + * + * - Inlines every by replacing it with `; + }); + // Drop module preloads and module scripts — nothing should hydrate. + html = html.replace(/]*\brel=["']modulepreload["'][^>]*>\s*/g, ''); + html = html.replace(/]*\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(/]*\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`); +} diff --git a/scripts/deploy.sh b/scripts/deploy.sh index bbf0c9b4..c263ed7c 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -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" diff --git a/scripts/generate-error-quotes.ts b/scripts/generate-error-quotes.ts new file mode 100644 index 00000000..abe7c85a --- /dev/null +++ b/scripts/generate-error-quotes.ts @@ -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 = { + 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 = {}; +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)`); diff --git a/src/lib/data/errorQuotes.json b/src/lib/data/errorQuotes.json new file mode 100644 index 00000000..e9a19b9c --- /dev/null +++ b/src/lib/data/errorQuotes.json @@ -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" + } + } +} diff --git a/src/lib/js/errorStrings.ts b/src/lib/js/errorStrings.ts index 328252d6..5df1913e 100644 --- a/src/lib/js/errorStrings.ts +++ b/src/lib/js/errorStrings.ts @@ -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; + +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' }, diff --git a/src/params/httpStatus.ts b/src/params/httpStatus.ts new file mode 100644 index 00000000..c357d7dd --- /dev/null +++ b/src/params/httpStatus.ts @@ -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(HTTP_ERROR_STATUSES); + +export const match: ParamMatcher = (param) => SET.has(param); diff --git a/src/routes/errors/[status=httpStatus]/+page.svelte b/src/routes/errors/[status=httpStatus]/+page.svelte new file mode 100644 index 00000000..bc30f3c2 --- /dev/null +++ b/src/routes/errors/[status=httpStatus]/+page.svelte @@ -0,0 +1,171 @@ + + + + {title} — Alexander's Website + + + + + +
+ {#snippet links()} + + {/snippet} + + {#snippet language_selector_desktop()} + EN + {/snippet} + +
+
+
+
+ + + +

{title}

+

{description}

+ + {#if quote} +
+
„{quote.text}“
+
{quote.reference}
+
+ {/if} +
+
+
+ + diff --git a/src/routes/errors/[status=httpStatus]/+page.ts b/src/routes/errors/[status=httpStatus]/+page.ts new file mode 100644 index 00000000..4a9cb766 --- /dev/null +++ b/src/routes/errors/[status=httpStatus]/+page.ts @@ -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) +}); diff --git a/src/routes/errors/en/[status=httpStatus]/+page.svelte b/src/routes/errors/en/[status=httpStatus]/+page.svelte new file mode 100644 index 00000000..c743454d --- /dev/null +++ b/src/routes/errors/en/[status=httpStatus]/+page.svelte @@ -0,0 +1,171 @@ + + + + {title} — Alexander's Website + + + + + +
+ {#snippet links()} + + {/snippet} + + {#snippet language_selector_desktop()} + DE + {/snippet} + +
+
+
+
+ + + +

{title}

+

{description}

+ + {#if quote} +
+
“{quote.text}”
+
{quote.reference}
+
+ {/if} +
+
+
+ + diff --git a/src/routes/errors/en/[status=httpStatus]/+page.ts b/src/routes/errors/en/[status=httpStatus]/+page.ts new file mode 100644 index 00000000..bf33d92a --- /dev/null +++ b/src/routes/errors/en/[status=httpStatus]/+page.ts @@ -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) +});