feat: German as the main language, English under /en, with a language switch
The site now exists in German (at the root, with /impressum and /datenschutz) and English (/en). A request without a language follows the visitor's choice from the language switch, then the browser language, then German; the old English legal addresses move to /en for good. All visible texts are data in src/content/de.ts and en.ts, so components hold none of their own. The header carries a DE/EN switch whose bit curtain covers the page while it is swapped and reopens at the same reading position. Title, description, canonical, hreflang, Open Graph, the share image, the sitemap and the structured data are per language. The privacy notice now describes the cookie that remembers the chosen language. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
import { LOCALES, preferredLocale, type Locale } from "./locale";
|
||||
|
||||
/**
|
||||
* The public addresses of the site. German is the main language and lives at the
|
||||
* root, with German names for the legal pages; English lives under /en.
|
||||
* Internally every page is rendered under its language (/de/..., /en/...);
|
||||
* `routeRequest` maps one onto the other.
|
||||
*/
|
||||
export type PageId = "home" | "imprint" | "privacy";
|
||||
|
||||
type LanguageAlternates = Readonly<Record<Locale | "x-default", string>>;
|
||||
|
||||
export type RouteDecision =
|
||||
| {
|
||||
readonly kind: "redirect";
|
||||
readonly to: string;
|
||||
readonly permanent: boolean;
|
||||
/** The answer depends on the visitor's language, so caches must not share it. */
|
||||
readonly negotiated: boolean;
|
||||
}
|
||||
| {
|
||||
readonly kind: "rewrite";
|
||||
readonly to: string;
|
||||
readonly negotiated: boolean;
|
||||
};
|
||||
|
||||
export type SitemapEntry = {
|
||||
readonly url: string;
|
||||
readonly alternates: { readonly languages: LanguageAlternates };
|
||||
};
|
||||
|
||||
type PagePaths = Readonly<Record<PageId, string>>;
|
||||
|
||||
const PAGES: readonly PageId[] = ["home", "imprint", "privacy"];
|
||||
|
||||
/** The path of a page below its language, as the app directory names it. */
|
||||
const INTERNAL: PagePaths = {
|
||||
home: "",
|
||||
imprint: "/imprint",
|
||||
privacy: "/privacy",
|
||||
};
|
||||
|
||||
/** The path of a page below its language, as visitors see it. */
|
||||
const PUBLIC: Readonly<Record<Locale, PagePaths>> = {
|
||||
de: { home: "", imprint: "/impressum", privacy: "/datenschutz" },
|
||||
en: INTERNAL,
|
||||
};
|
||||
|
||||
const PREFIX: Readonly<Record<Locale, string>> = { de: "", en: "/en" };
|
||||
|
||||
const withoutTrailingSlash = (pathname: string): string =>
|
||||
pathname.replace(/\/+$/, "");
|
||||
|
||||
/** What follows a prefix such as "/en", or null when the path is not below it. */
|
||||
const restAfter = (pathname: string, prefix: string): string | null => {
|
||||
const path = withoutTrailingSlash(pathname);
|
||||
if (path === prefix) return "";
|
||||
return path.startsWith(`${prefix}/`) ? path.slice(prefix.length) : null;
|
||||
};
|
||||
|
||||
const pageAt = (rest: string, paths: PagePaths): PageId | null =>
|
||||
PAGES.find((page) => paths[page] === rest) ?? null;
|
||||
|
||||
const redirect = (to: string, permanent: boolean): RouteDecision => ({
|
||||
kind: "redirect",
|
||||
to,
|
||||
permanent,
|
||||
negotiated: !permanent,
|
||||
});
|
||||
|
||||
export function pathFor(page: PageId, locale: Locale): string {
|
||||
return `${PREFIX[locale]}${PUBLIC[locale][page]}` || "/";
|
||||
}
|
||||
|
||||
/** The addresses of one page in every language, for hreflang links. */
|
||||
export function languageAlternates(page: PageId): LanguageAlternates {
|
||||
return {
|
||||
de: pathFor(page, "de"),
|
||||
en: pathFor(page, "en"),
|
||||
"x-default": pathFor(page, "de"),
|
||||
};
|
||||
}
|
||||
|
||||
export function localeOfPath(pathname: string): Locale {
|
||||
return restAfter(pathname, PREFIX.en) === null ? "de" : "en";
|
||||
}
|
||||
|
||||
/** Where the language switch leads from a given address. */
|
||||
export function switchPath(pathname: string, target: Locale): string {
|
||||
const locale = localeOfPath(pathname);
|
||||
const rest = withoutTrailingSlash(pathname).slice(PREFIX[locale].length);
|
||||
return pathFor(pageAt(rest, PUBLIC[locale]) ?? "home", target);
|
||||
}
|
||||
|
||||
/**
|
||||
* What to do with a request. Addresses without a language are German and follow
|
||||
* the visitor's preference; English addresses are shown to everyone; every page
|
||||
* has exactly one public address, so all other forms are moved to it.
|
||||
*/
|
||||
export function routeRequest(request: {
|
||||
readonly pathname: string;
|
||||
readonly cookie?: string | null;
|
||||
readonly acceptLanguage?: string | null;
|
||||
}): RouteDecision {
|
||||
const internalGerman = restAfter(request.pathname, "/de");
|
||||
if (internalGerman !== null) {
|
||||
const page =
|
||||
pageAt(internalGerman, PUBLIC.de) ?? pageAt(internalGerman, INTERNAL);
|
||||
return redirect(page === null ? internalGerman : pathFor(page, "de"), true);
|
||||
}
|
||||
|
||||
const english = restAfter(request.pathname, PREFIX.en);
|
||||
if (english !== null) {
|
||||
const misnamed = pageAt(english, PUBLIC.de);
|
||||
return misnamed === null || misnamed === "home"
|
||||
? { kind: "rewrite", to: `/en${english}`, negotiated: false }
|
||||
: redirect(pathFor(misnamed, "en"), true);
|
||||
}
|
||||
|
||||
const rest = withoutTrailingSlash(request.pathname);
|
||||
const page = pageAt(rest, PUBLIC.de);
|
||||
if (page === null) {
|
||||
const formerEnglish = pageAt(rest, INTERNAL);
|
||||
return formerEnglish === null
|
||||
? { kind: "rewrite", to: `/de${rest}`, negotiated: false }
|
||||
: redirect(pathFor(formerEnglish, "en"), true);
|
||||
}
|
||||
const locale = preferredLocale(request);
|
||||
return locale === "de"
|
||||
? { kind: "rewrite", to: `/de${INTERNAL[page]}`, negotiated: true }
|
||||
: redirect(pathFor(page, locale), false);
|
||||
}
|
||||
|
||||
/** Sitemap entries for the given pages: one per language, each naming all of them. */
|
||||
export function sitemapEntries(
|
||||
siteUrl: string,
|
||||
pages: readonly PageId[],
|
||||
): readonly SitemapEntry[] {
|
||||
return pages.flatMap((page) => {
|
||||
const alternates = languageAlternates(page);
|
||||
const languages: LanguageAlternates = {
|
||||
de: `${siteUrl}${alternates.de}`,
|
||||
en: `${siteUrl}${alternates.en}`,
|
||||
"x-default": `${siteUrl}${alternates["x-default"]}`,
|
||||
};
|
||||
return LOCALES.map((locale) => ({
|
||||
url: languages[locale],
|
||||
alternates: { languages },
|
||||
}));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user