From 98f9273bf00186595ad291b2a394854d81fe358a Mon Sep 17 00:00:00 2001 From: DennisBauer <37552885+DennisBauer@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:53:25 +0200 Subject: [PATCH] feat(i18n): detect browser language, drive locales from one table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub Pages serves static files, so there is no Accept-Language to branch on: an inline pre-paint script in Base.astro redirects instead. Only the unprefixed URLs redirect — a prefixed one is an explicit choice, which keeps shared links in their language and makes a loop impossible. Using the header switch records the choice in localStorage, where it outranks detection from then on; without that the redirect would trap anyone whose browser language isn't the one they want. The same change generalises the locale handling, because a third language would otherwise have rendered while being invisible to detection, the switch, og:locale, date formatting and the Play badge. src/i18n/locales.ts is now the only place a language is declared, and astro.config.ts derives its routes and sitemap from it rather than repeating the list. The switch turns into a dropdown from three languages on; LOCALIZED_SLUGS became Partial so that translated slugs stay opt-in instead of a type error. Both privacy pages now name the second localStorage key, since they claimed the theme preference was the only thing this site stores. --- README.md | 32 ++++++- astro.config.mjs => astro.config.ts | 17 ++-- src/components/LangSwitch.astro | 127 ++++++++++++++++++++++++---- src/components/ui/StoreBadges.astro | 13 ++- src/i18n/de.ts | 3 +- src/i18n/en.ts | 5 +- src/i18n/locales.ts | 40 +++++++++ src/i18n/utils.ts | 58 +++++++++---- src/layouts/Base.astro | 99 ++++++++++++++++++++-- src/layouts/Legal.astro | 4 +- src/pages/de/privacy.mdx | 9 +- src/pages/privacy.mdx | 9 +- 12 files changed, 357 insertions(+), 59 deletions(-) rename astro.config.mjs => astro.config.ts (57%) create mode 100644 src/i18n/locales.ts diff --git a/README.md b/README.md index 8b5ce9e..cc7d698 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,9 @@ src/ │ ├─ Landing.astro composes the whole landing page │ ├─ Hero / FeatureGrid / ... one file per section │ └─ ui/ Button, Card-ish primitives, Icon, PhoneFrame -├─ i18n/{en,de,utils}.ts all user-facing copy +├─ i18n/{en,de}.ts all user-facing copy +├─ i18n/locales.ts the locale table — add a language here +├─ i18n/utils.ts locale-aware paths, hreflang, copy lookup ├─ integrations/placeholder-check.ts fails the deploy on unfilled TODOs ├─ layouts/{Base,Legal}.astro ├─ pages/ @@ -99,6 +101,34 @@ switch to translated slugs like `/de/datenschutz`, fill in `LOCALIZED_SLUGS` in `src/i18n/utils.ts` and rename the page files — the header language switch and the `hreflang` tags both already route through that map. +## Adding a language + +Nothing outside this list is language-aware: ``, `hreflang`, `og:locale`, +date formatting, the sitemap, the language switch and the browser-language redirect +are all derived from the locale table. + +1. **`src/i18n/locales.ts`** — add the code to `LOCALES` and an entry to + `LOCALE_META` (`html` tag, `og:locale`, `Intl` tag, and the endonym shown in the + switch). `astro.config.ts` reads both, so the routes and the sitemap follow. +2. **`src/i18n/.ts`** — copy `en.ts` and translate. It is typed as `Strings`, + so a missing key is a build error rather than a silently English paragraph. + Register it in the `STRINGS` record in `src/i18n/utils.ts`. +3. **`src/pages//`** — `index.astro`, `privacy.mdx`, `imprint.mdx` and + `account-deletion.mdx`, mirroring `src/pages/de/`. +4. **Optional:** the localized Google Play badge in `src/assets/badges/`, wired up in + `PLAY_BADGES` (`src/components/ui/StoreBadges.astro`). Without one the English + badge is used — Google's brand rules forbid translating it yourself. +5. **Optional:** a `LOCALIZED_SLUGS` entry if that language should have translated + slugs. + +Two things stay English by design: `404.astro`, because GitHub Pages serves it for +unmatched paths at any depth and the served path is unknowable at build time, and the +redirect's starting point — only the unprefixed URLs redirect, so a shared `/de/` or +`/fr/` link keeps its language. + +From three languages on, the header switch renders as a dropdown instead of a single +link. No code change needed; it counts the locales. + ## Deployment Push to `main` → `.github/workflows/deploy.yml` builds and publishes to GitHub diff --git a/astro.config.mjs b/astro.config.ts similarity index 57% rename from astro.config.mjs rename to astro.config.ts index 318f470..335776d 100644 --- a/astro.config.mjs +++ b/astro.config.ts @@ -1,9 +1,16 @@ -// @ts-check import { defineConfig } from "astro/config"; import mdx from "@astrojs/mdx"; import sitemap from "@astrojs/sitemap"; import tailwindcss from "@tailwindcss/vite"; import { placeholderCheck } from "./src/integrations/placeholder-check"; +import { DEFAULT_LOCALE, LOCALES, LOCALE_META } from "./src/i18n/locales"; + +// Both lists below are derived, not written out: src/i18n/locales.ts is the only +// place a language is declared, and a config that disagreed with it would emit +// routes with no sitemap entry (or the reverse). +const localeTags = Object.fromEntries( + LOCALES.map((locale) => [locale, LOCALE_META[locale].html]), +); export default defineConfig({ site: "https://tabmates.de", @@ -11,16 +18,16 @@ export default defineConfig({ // resolves to /privacy/index.html without a server-side rewrite. build: { format: "directory" }, i18n: { - defaultLocale: "en", - locales: ["en", "de"], + defaultLocale: DEFAULT_LOCALE, + locales: [...LOCALES], routing: { prefixDefaultLocale: false }, }, integrations: [ mdx(), sitemap({ i18n: { - defaultLocale: "en", - locales: { en: "en", de: "de" }, + defaultLocale: DEFAULT_LOCALE, + locales: localeTags, }, }), placeholderCheck(), diff --git a/src/components/LangSwitch.astro b/src/components/LangSwitch.astro index 4b40984..cfe2b15 100644 --- a/src/components/LangSwitch.astro +++ b/src/components/LangSwitch.astro @@ -1,9 +1,10 @@ --- +import Icon from "./ui/Icon.astro"; import { + LOCALE_META, alternatePath, - otherLocale, + otherLocales, t, - HTML_LANG, type Locale, } from "../i18n/utils"; @@ -13,23 +14,113 @@ interface Props { const { locale } = Astro.props; const s = t(locale); -const target = otherLocale(locale); -const href = alternatePath(Astro.url.pathname, target); + +const targets = otherLocales(locale).map((code) => ({ + code, + meta: LOCALE_META[code], + href: alternatePath(Astro.url.pathname, code), +})); + +const chip = + "inline-flex items-center rounded-pill px-3 py-2 text-label-lg font-semibold text-on-surface-variant uppercase transition-colors hover:bg-surface-variant hover:text-on-bg"; --- { - /* A link, not a : it works without JS, and + it tells crawlers the two pages are translations of each other. `hreflang` and + `lang` are set to the *target* language so a screen reader announces the label + with that language's pronunciation. + + From two alternatives on it becomes a
disclosure, which keeps both + properties — no JS needed to open it, keyboard-operable for free — while the + items stay ordinary translated links. */ +} +{ + targets.length === 1 && ( + + + {targets[0]!.meta.name} + + ) +} + +{ + targets.length > 1 && ( +
+ + + {s.lang.label} + + + + +
+ ) } - - - {s.lang.switchTo} - + +{ + /* Using the switch is an explicit choice, and it outranks the browser-language + redirect in Base.astro from here on. Without this the redirect would be a trap: + a German browser asking for English would be bounced back on every visit. + + The second half only matters for the disclosure variant:
has no + light-dismiss of its own, so Escape and an outside click close it. */ +} + diff --git a/src/components/ui/StoreBadges.astro b/src/components/ui/StoreBadges.astro index 27118cb..8a840e8 100644 --- a/src/components/ui/StoreBadges.astro +++ b/src/components/ui/StoreBadges.astro @@ -27,7 +27,18 @@ interface Props { const { locale, align = "start", showApk = true } = Astro.props; const s = t(locale); -const playBadge = locale === "de" ? playBadgeDe : playBadgeEn; +/** + * Google publishes the badge per language and each one is vendored by hand, so a + * newly added locale has no artwork until someone downloads it. Falling back to the + * unmodified English badge is the only option the brand rules allow — never a + * recoloured, stretched or self-translated one. + */ +const PLAY_BADGES: Partial> = { + en: playBadgeEn, + de: playBadgeDe, +}; + +const playBadge = PLAY_BADGES[locale] ?? playBadgeEn; // The source is 646x250, of which the black badge occupies ~82% of the height. // 164px wide renders the visible badge at ~52px — matching the height of the diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 4cde1a5..4738f91 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -26,7 +26,8 @@ export const de: Strings = { }, lang: { - switchTo: "View in English", + label: "Sprache", + switchTo: "Sprache wechseln", }, cta: { diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 4e8faa6..3fb8a15 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -26,7 +26,10 @@ export const en = { }, lang: { - switchTo: "Auf Deutsch ansehen", + /* Language names themselves are endonyms from LOCALE_META, not copy: a picker + that says "Deutsch" is what the German speaker looking for it recognises. */ + label: "Language", + switchTo: "Change language", }, cta: { diff --git a/src/i18n/locales.ts b/src/i18n/locales.ts new file mode 100644 index 0000000..0952c3e --- /dev/null +++ b/src/i18n/locales.ts @@ -0,0 +1,40 @@ +/** + * The locale table — the single place a language is declared. + * + * Deliberately free of imports: `astro.config.ts` pulls `LOCALES` and + * `LOCALE_META` from here, and dragging the whole copy bundle (`en.ts`, `de.ts`) + * into config evaluation would be a needless cost. Everything that varies per + * language and is *not* prose lives in this file; the prose lives in `en.ts` and + * its siblings. + * + * Adding a language: add the code to `LOCALES`, add its `LOCALE_META` entry, then + * follow the checklist in README.md ("Adding a language"). + */ +export const LOCALES = ["en", "de"] as const; +export type Locale = (typeof LOCALES)[number]; +export const DEFAULT_LOCALE: Locale = "en"; + +export interface LocaleMeta { + /** ``, `hreflang`, and what browser language tags are matched against. */ + html: string; + /** `og:locale` — Open Graph wants language_TERRITORY, not a BCP-47 tag. */ + og: string; + /** BCP-47 tag for `Intl` formatters. */ + intl: string; + /** + * Endonym: how speakers of this language name it. Shown in the language switch + * and intentionally *not* translated per locale — a picker that says "Deutsch" + * is readable to the German speaker looking for it, and it keeps the copy files + * from growing an N×N matrix of language names. + */ + name: string; +} + +export const LOCALE_META: Record = { + en: { html: "en", og: "en_GB", intl: "en-GB", name: "English" }, + de: { html: "de", og: "de_DE", intl: "de-DE", name: "Deutsch" }, +}; + +export function isLocale(value: string): value is Locale { + return (LOCALES as readonly string[]).includes(value); +} diff --git a/src/i18n/utils.ts b/src/i18n/utils.ts index 223876d..b20c34d 100644 --- a/src/i18n/utils.ts +++ b/src/i18n/utils.ts @@ -1,20 +1,16 @@ import { en } from "./en"; import { de } from "./de"; import type { Strings } from "./en"; +import { DEFAULT_LOCALE, LOCALES, LOCALE_META, isLocale } from "./locales"; +import type { Locale } from "./locales"; -export const LOCALES = ["en", "de"] as const; -export type Locale = (typeof LOCALES)[number]; -export const DEFAULT_LOCALE: Locale = "en"; +// Re-exported so components can keep importing everything locale-related from +// this one module; `locales.ts` stays import-free for the Astro config's sake. +export { DEFAULT_LOCALE, LOCALES, LOCALE_META, isLocale }; +export type { Locale, LocaleMeta } from "./locales"; const STRINGS: Record = { en, de }; -/** Full `lang` attribute values, for and hreflang. */ -export const HTML_LANG: Record = { en: "en", de: "de" }; - -export function isLocale(value: string): value is Locale { - return (LOCALES as readonly string[]).includes(value); -} - /** * Derives the locale from a URL pathname. `prefixDefaultLocale` is false, so * English lives at the root ("/", "/privacy/") and German is prefixed ("/de/…"). @@ -33,14 +29,14 @@ export function t(locale: Locale): Strings { * pages reuse the English slugs (/de/privacy) so the language switcher is a * pure prefix swap. Fill this in to move to /de/datenschutz later; `localizePath` * and `alternatePath` both already route through it, so nothing else changes. + * + * `Partial` on purpose: translated slugs are opt-in, so a newly added language + * needs no entry here. A full Record would make adding one a type error. */ -const LOCALIZED_SLUGS: Record> = { - en: {}, - de: {}, -}; +const LOCALIZED_SLUGS: Partial>> = {}; function translateSlug(slug: string, to: Locale): string { - return LOCALIZED_SLUGS[to][slug] ?? slug; + return LOCALIZED_SLUGS[to]?.[slug] ?? slug; } /** Strips the locale prefix, returning the canonical (English) slug path. */ @@ -65,6 +61,34 @@ export function alternatePath(pathname: string, to: Locale): string { return localizePath(canonicalSlug(pathname), to); } -export function otherLocale(locale: Locale): Locale { - return locale === "en" ? "de" : "en"; +/** Every locale except the current one, in `LOCALES` order. Powers the switch. */ +export function otherLocales(locale: Locale): Locale[] { + return LOCALES.filter((l) => l !== locale); +} + +/** One redirect candidate for the browser-language detection in Base.astro. */ +export interface RedirectTarget { + /** Locale code, as stored in `localStorage` by the language switch. */ + code: Locale; + /** Lower-cased language tag, matched against `navigator.languages`. */ + tag: string; + /** This same page in that locale. */ + url: string; +} + +/** + * The other locales' URLs for `pathname`, ready to be handed to the detection + * script. Routed through `canonicalSlug` + `localizePath`, so translated slugs + * (`LOCALIZED_SLUGS`) are picked up automatically. + */ +export function redirectTargets( + pathname: string, + from: Locale, +): RedirectTarget[] { + const slug = canonicalSlug(pathname); + return otherLocales(from).map((code) => ({ + code, + tag: LOCALE_META[code].html.toLowerCase(), + url: localizePath(slug, code), + })); } diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 786fde0..581ef50 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -3,10 +3,12 @@ import "../styles/global.css"; import Header from "../components/Header.astro"; import Footer from "../components/Footer.astro"; import { - HTML_LANG, + DEFAULT_LOCALE, LOCALES, + LOCALE_META, canonicalSlug, localizePath, + redirectTargets, t, type Locale, } from "../i18n/utils"; @@ -43,10 +45,34 @@ const canonical = new URL(localizePath(slug, locale), SITE_URL).href; const pageTitle = title ? `${title} — TabMates` : s.meta.title; const pageDescription = description ?? s.meta.description; const ogImage = new URL("/og-image.png", SITE_URL).href; + +/** + * Everything the browser-language redirect needs, or null if it must not fire on + * this page. Only default-locale URLs redirect: the unprefixed URL is the "no + * language stated" one, while every prefixed URL is an explicit choice that stays + * put — which also makes a redirect loop impossible. `noLangSwitch` means the page + * has no counterpart in any other locale, so there is nothing to redirect to — the + * same reason the switch itself is hidden. `noindex` covers pages whose served path + * is unknown at build time, where the targets would be guesses; today both flags + * meet on 404, but either alone must already suppress the redirect. + * + * Targets are resolved at build time through localizePath, so translated slugs and + * additional languages both land here without touching the script below. + */ +const langRedirect = + locale === DEFAULT_LOCALE && !noindex && !noLangSwitch + ? { + current: { + code: locale, + tag: LOCALE_META[locale].html.toLowerCase(), + }, + targets: redirectTargets(Astro.url.pathname, locale), + } + : null; --- - + @@ -65,7 +91,7 @@ const ogImage = new URL("/og-image.png", SITE_URL).href; LOCALES.map((l) => ( )) @@ -75,7 +101,7 @@ const ogImage = new URL("/og-image.png", SITE_URL).href; ) } @@ -104,7 +130,7 @@ const ogImage = new URL("/og-image.png", SITE_URL).href; - + @@ -135,6 +161,69 @@ const ogImage = new URL("/og-image.png", SITE_URL).href; } })(); + + { + /* Browser-language detection. GitHub Pages serves static files, so there is + no Accept-Language to branch on server-side and this is the only place the + redirect can happen. Render-blocking for the same reason as the theme script + above: deferring it means a visible frame of English first. + + Rendered on every page rather than behind a template conditional — a script + inside an Astro expression has its body parsed as JSX, and this one is mostly + braces. `langRedirect` is null wherever the redirect must not fire, and the + first line then makes this a no-op. */ + } + diff --git a/src/layouts/Legal.astro b/src/layouts/Legal.astro index 29f614d..1d2dba9 100644 --- a/src/layouts/Legal.astro +++ b/src/layouts/Legal.astro @@ -1,7 +1,7 @@ --- import Base from "./Base.astro"; import Icon from "../components/ui/Icon.astro"; -import { localizePath, t, type Locale } from "../i18n/utils"; +import { LOCALE_META, localizePath, t, type Locale } from "../i18n/utils"; /** * Layout for the MDX legal pages. Astro passes `frontmatter` and `headings` @@ -30,7 +30,7 @@ const s = t(locale); const toc = headings.filter((h) => h.depth === 2); const formattedDate = lastUpdated - ? new Intl.DateTimeFormat(locale === "de" ? "de-DE" : "en-GB", { + ? new Intl.DateTimeFormat(LOCALE_META[locale].intl, { day: "numeric", month: "long", year: "numeric", diff --git a/src/pages/de/privacy.mdx b/src/pages/de/privacy.mdx index ce7c460..d1f0891 100644 --- a/src/pages/de/privacy.mdx +++ b/src/pages/de/privacy.mdx @@ -3,7 +3,7 @@ layout: ../../layouts/Legal.astro locale: de title: Datenschutzerklärung description: Welche personenbezogenen Daten die TabMates-App und diese Website verarbeiten, warum, und welche Rechte Sie haben. -lastUpdated: 2026-07-29 +lastUpdated: 2026-08-10 --- import { CONTACT_EMAIL } from "../../config"; @@ -228,9 +228,10 @@ lesen. Sie ist von der TabMates-App und von der Web-App unter `app.tabmates.de` getrennt zu betrachten — die Web-App behandelt Abschnitt 13, und ihr Datenschutzprofil unterscheidet sich von dem dieser Website. -- **Keine Cookies.** Diese Website setzt keinerlei Cookies. Das Einzige, was in - Ihrem Browser gespeichert wird, ist Ihre Einstellung für helles oder dunkles - Design — in `localStorage` auf Ihrem eigenen Gerät, ohne jede Übertragung. +- **Keine Cookies.** Diese Website setzt keinerlei Cookies. Gespeichert werden in + Ihrem Browser nur Ihre Einstellung für helles oder dunkles Design und, sobald Sie + die Sprachumschaltung benutzen, Ihre Sprachwahl — beides in `localStorage` auf + Ihrem eigenen Gerät, ohne jede Übertragung. - **Keine Analyse, keine Anfragen an Dritte.** Es gibt keine Analyse-Skripte, keine Tag-Manager, keine Werbe-Pixel und keine eingebetteten Widgets Dritter. Schriften und sämtliche weiteren Ressourcen werden von dieser Domain ausgeliefert; das Laden diff --git a/src/pages/privacy.mdx b/src/pages/privacy.mdx index 0908c38..0fcb9c3 100644 --- a/src/pages/privacy.mdx +++ b/src/pages/privacy.mdx @@ -3,7 +3,7 @@ layout: ../layouts/Legal.astro locale: en title: Privacy policy description: What personal data the TabMates app and this website handle, why, and what choices you have. -lastUpdated: 2026-07-29 +lastUpdated: 2026-08-10 --- import { CONTACT_EMAIL } from "../config"; @@ -208,9 +208,10 @@ now. It is a separate thing from the TabMates app and from the web app at `app.tabmates.de` — the web app is covered in Section 13, and its privacy profile is not the same as this site's. -- **No cookies.** This site sets no cookies of any kind. The only thing stored in - your browser is your light/dark theme preference, held in `localStorage` on your - own device and never transmitted anywhere. +- **No cookies.** This site sets no cookies of any kind. The only things stored in + your browser are your light/dark theme preference and, once you use the language + switch, your choice of language — both held in `localStorage` on your own device + and never transmitted anywhere. - **No analytics and no third-party requests.** There are no analytics scripts, tag managers, advertising pixels or embedded third-party widgets. Fonts and all other assets are served from this domain, so loading a page contacts no server