feat(errors): merge DE/EN into one page with client-side toggle
CI / update (push) Successful in 48s
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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.64.1",
|
||||
"version": "1.64.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -26,6 +26,62 @@ const OUT_DIR = join(CLIENT, 'errors');
|
||||
// the logo always lands on the real site.
|
||||
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)) {
|
||||
console.error(`[error-page] missing prerender dir: ${PRERENDER_DIR}`);
|
||||
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) =>
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,17 +20,17 @@
|
||||
}
|
||||
})();
|
||||
|
||||
const title = getErrorTitle(status, false);
|
||||
const description = getErrorDescription(status, false);
|
||||
const quote = getErrorBibleQuote(status, false);
|
||||
const otherLangHref = `https://bocken.org/errors/en/${status}.html`;
|
||||
const titleDe = getErrorTitle(status, false);
|
||||
const titleEn = getErrorTitle(status, true);
|
||||
const descDe = getErrorDescription(status, false);
|
||||
const descEn = getErrorDescription(status, true);
|
||||
const quoteDe = getErrorBibleQuote(status, false);
|
||||
const quoteEn = getErrorBibleQuote(status, true);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title} — Alexander's Website</title>
|
||||
<title>{titleDe} — 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>
|
||||
@@ -39,32 +39,52 @@
|
||||
{/snippet}
|
||||
|
||||
{#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}
|
||||
|
||||
<main class="error-page" lang="de">
|
||||
<main class="error-page">
|
||||
<article class="error-article">
|
||||
<header class="eyebrow">
|
||||
<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>
|
||||
|
||||
<div class="code" aria-hidden="true">{status}</div>
|
||||
|
||||
<h1 class="title">{title}</h1>
|
||||
<p class="description">{description}</p>
|
||||
|
||||
{#if quote}
|
||||
<section data-lang="de" lang="de">
|
||||
<h1 class="title">{titleDe}</h1>
|
||||
<p class="description">{descDe}</p>
|
||||
{#if quoteDe}
|
||||
<figure class="quote">
|
||||
<blockquote class="quote-text">„{quote.text}“</blockquote>
|
||||
<figcaption class="quote-reference">{quote.reference}</figcaption>
|
||||
<blockquote class="quote-text">„{quoteDe.text}“</blockquote>
|
||||
<figcaption class="quote-reference">{quoteDe.reference}</figcaption>
|
||||
</figure>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section data-lang="en" lang="en">
|
||||
<h1 class="title">{titleEn}</h1>
|
||||
<p class="description">{descEn}</p>
|
||||
{#if quoteEn}
|
||||
<figure class="quote">
|
||||
<blockquote class="quote-text">“{quoteEn.text}”</blockquote>
|
||||
<figcaption class="quote-reference">{quoteEn.reference}</figcaption>
|
||||
</figure>
|
||||
{/if}
|
||||
</section>
|
||||
</article>
|
||||
</main>
|
||||
</Header>
|
||||
|
||||
<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 {
|
||||
min-height: calc(100vh - 6rem);
|
||||
display: flex;
|
||||
@@ -155,11 +175,13 @@
|
||||
padding: 0 0.6rem;
|
||||
border-radius: 100px;
|
||||
border: 1px solid var(--nav-btn-border, rgba(255,255,255,0.2));
|
||||
background: transparent;
|
||||
color: var(--nav-text, #999);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.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)
|
||||
});
|
||||
Reference in New Issue
Block a user