feat(errors): merge DE/EN into one page with client-side toggle
CI / update (push) Successful in 48s

Collapses /errors/<n>.html and /errors/en/<n>.html into a single
prerendered page that shows both languages and reveals the right one
via <html data-lang>. Build script injects an inline bootstrap that
sets data-lang from localStorage before paint and wires the lang +
theme buttons (no Svelte hydration).
This commit is contained in:
2026-05-03 21:42:41 +02:00
parent 86c72c2dc3
commit 1bceabe967
5 changed files with 104 additions and 203 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.64.1", "version": "1.64.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+63
View File
@@ -26,6 +26,62 @@ const OUT_DIR = join(CLIENT, 'errors');
// the logo always lands on the real site. // the logo always lands on the real site.
const CANONICAL_HOME = 'https://bocken.org/'; const CANONICAL_HOME = 'https://bocken.org/';
// Marker for idempotent script injection (so re-runs don't stack copies).
const LANG_SCRIPT_MARKER = 'data-error-toggles';
// Wires up language + theme toggles without Svelte hydration. Runs early
// so <html data-lang="…"> is set before paint (avoids flash of both langs).
// The icon inside the theme button is Svelte-reactive and stays at the
// SSR-rendered shape; the actual theme cycle + persistence still works.
const LANG_SCRIPT = `
<script ${LANG_SCRIPT_MARKER}>
(function(){try{
var html=document.documentElement;
var pref=localStorage.getItem('preferredLanguage');
var lang=(pref==='en'||pref==='de')?pref:'de';
html.setAttribute('data-lang',lang);
var wire=function(){
var langBtn=document.getElementById('lang-toggle');
if(langBtn){
var refresh=function(){
var cur=html.getAttribute('data-lang')||'de';
var next=cur==='de'?'en':'de';
langBtn.textContent=next.toUpperCase();
langBtn.setAttribute('aria-label',next==='en'?'Switch to English':'Auf Deutsch wechseln');
};
refresh();
langBtn.addEventListener('click',function(){
var cur=html.getAttribute('data-lang')||'de';
var next=cur==='de'?'en':'de';
html.setAttribute('data-lang',next);
try{localStorage.setItem('preferredLanguage',next);}catch(_){}
refresh();
});
}
var themeBtn=document.querySelector('button[aria-label^="Toggle theme"]');
if(themeBtn){
var CYCLE=['system','light','dark'];
var getTheme=function(){
var s=localStorage.getItem('theme');
return (s==='light'||s==='dark')?s:'system';
};
var applyTheme=function(t){
if(t==='system'){delete html.dataset.theme;try{localStorage.removeItem('theme');}catch(_){}}
else{html.dataset.theme=t;try{localStorage.setItem('theme',t);}catch(_){}}
themeBtn.setAttribute('aria-label','Toggle theme ('+t+')');
themeBtn.setAttribute('title','Theme: '+t);
};
themeBtn.addEventListener('click',function(){
var cur=getTheme();
var next=CYCLE[(CYCLE.indexOf(cur)+1)%CYCLE.length];
applyTheme(next);
});
}
};
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wire);
else wire();
}catch(_){}})();
</script>`;
if (!existsSync(PRERENDER_DIR)) { if (!existsSync(PRERENDER_DIR)) {
console.error(`[error-page] missing prerender dir: ${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()`?'); console.error('[error-page] is /errors/[status=httpStatus]/+page.ts setting `prerender = true` with `entries()`?');
@@ -82,6 +138,13 @@ function inline(html: string, pagePath: string): string {
html = html.replace(/<a\b[^>]*\bclass="[^"]*\bhome-link\b[^"]*"[^>]*>/g, (tag) => html = html.replace(/<a\b[^>]*\bclass="[^"]*\bhome-link\b[^"]*"[^>]*>/g, (tag) =>
tag.replace(/\bhref="[^"]*"/, `href="${CANONICAL_HOME}"`) tag.replace(/\bhref="[^"]*"/, `href="${CANONICAL_HOME}"`)
); );
// Inject the language-toggle bootstrap script just before </head> so
// <html data-lang="…"> is set before the body paints (avoids flash of
// both languages). Idempotent — if the marker is already present, skip.
if (!html.includes(LANG_SCRIPT_MARKER)) {
html = html.replace('</head>', `${LANG_SCRIPT}</head>`);
}
return html; return html;
} }
@@ -20,17 +20,17 @@
} }
})(); })();
const title = getErrorTitle(status, false); const titleDe = getErrorTitle(status, false);
const description = getErrorDescription(status, false); const titleEn = getErrorTitle(status, true);
const quote = getErrorBibleQuote(status, false); const descDe = getErrorDescription(status, false);
const otherLangHref = `https://bocken.org/errors/en/${status}.html`; const descEn = getErrorDescription(status, true);
const quoteDe = getErrorBibleQuote(status, false);
const quoteEn = getErrorBibleQuote(status, true);
</script> </script>
<svelte:head> <svelte:head>
<title>{title} — Alexander's Website</title> <title>{titleDe} — Alexander's Website</title>
<meta name="robots" content="noindex" /> <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> </svelte:head>
<Header> <Header>
@@ -39,32 +39,52 @@
{/snippet} {/snippet}
{#snippet language_selector_desktop()} {#snippet language_selector_desktop()}
<a class="lang-toggle" href={otherLangHref} hreflang="en" aria-label="Switch to English">EN</a> <button id="lang-toggle" type="button" class="lang-toggle" aria-label="Switch language">EN</button>
{/snippet} {/snippet}
<main class="error-page" lang="de"> <main class="error-page">
<article class="error-article"> <article class="error-article">
<header class="eyebrow"> <header class="eyebrow">
<Icon size={14} strokeWidth={1.5} aria-hidden="true" /> <Icon size={14} strokeWidth={1.5} aria-hidden="true" />
<span class="eyebrow-label">Fehler</span> <span class="eyebrow-label" data-lang="de">Fehler</span>
<span class="eyebrow-label" data-lang="en">Error</span>
</header> </header>
<div class="code" aria-hidden="true">{status}</div> <div class="code" aria-hidden="true">{status}</div>
<h1 class="title">{title}</h1> <section data-lang="de" lang="de">
<p class="description">{description}</p> <h1 class="title">{titleDe}</h1>
<p class="description">{descDe}</p>
{#if quoteDe}
<figure class="quote">
<blockquote class="quote-text">{quoteDe.text}</blockquote>
<figcaption class="quote-reference">{quoteDe.reference}</figcaption>
</figure>
{/if}
</section>
{#if quote} <section data-lang="en" lang="en">
<figure class="quote"> <h1 class="title">{titleEn}</h1>
<blockquote class="quote-text">{quote.text}</blockquote> <p class="description">{descEn}</p>
<figcaption class="quote-reference">{quote.reference}</figcaption> {#if quoteEn}
</figure> <figure class="quote">
{/if} <blockquote class="quote-text">{quoteEn.text}</blockquote>
<figcaption class="quote-reference">{quoteEn.reference}</figcaption>
</figure>
{/if}
</section>
</article> </article>
</main> </main>
</Header> </Header>
<style> <style>
/* Visibility driven by <html data-lang="…">, set by an inline script
injected by scripts/build-error-page.ts. Default-language fallback
(no JS / no localStorage) shows German. */
:global(html[data-lang="de"]) [data-lang="en"] { display: none; }
:global(html[data-lang="en"]) [data-lang="de"] { display: none; }
:global(html:not([data-lang])) [data-lang="en"] { display: none; }
.error-page { .error-page {
min-height: calc(100vh - 6rem); min-height: calc(100vh - 6rem);
display: flex; display: flex;
@@ -155,11 +175,13 @@
padding: 0 0.6rem; padding: 0 0.6rem;
border-radius: 100px; border-radius: 100px;
border: 1px solid var(--nav-btn-border, rgba(255,255,255,0.2)); border: 1px solid var(--nav-btn-border, rgba(255,255,255,0.2));
background: transparent;
color: var(--nav-text, #999); color: var(--nav-text, #999);
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.05em; letter-spacing: 0.05em;
text-decoration: none; text-decoration: none;
cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
} }
.lang-toggle:hover, .lang-toggle:hover,
@@ -1,171 +0,0 @@
<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>
@@ -1,13 +0,0 @@
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)
});