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" | "freelance" | "imprint" | "privacy"; type LanguageAlternates = Readonly>; 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>; const PAGES: readonly PageId[] = ["home", "freelance", "imprint", "privacy"]; /** The path of a page below its language, as the app directory names it. */ const INTERNAL: PagePaths = { home: "", freelance: "/freelance", imprint: "/imprint", privacy: "/privacy", }; /** The path of a page below its language, as visitors see it. */ const PUBLIC: Readonly> = { de: { home: "", freelance: "/freelance", imprint: "/impressum", privacy: "/datenschutz", }, en: INTERNAL, }; const PREFIX: Readonly> = { 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) { // A German page name under /en is moved, unless the page has the same name in both languages. const misnamed = pageAt(english, PUBLIC.de); return misnamed === null || PUBLIC.de[misnamed] === INTERNAL[misnamed] ? { 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 }, })); }); }