Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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: `<html lang>`, `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/<code>.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/<code>/`** — `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
Expand Down
17 changes: 12 additions & 5 deletions astro.config.mjs → astro.config.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,33 @@
// @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",
// GitHub Pages serves the artifact verbatim; `directory` format means /privacy/
// 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(),
Expand Down
127 changes: 109 additions & 18 deletions src/components/LangSwitch.astro
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
---
import Icon from "./ui/Icon.astro";
import {
LOCALE_META,
alternatePath,
otherLocale,
otherLocales,
t,
HTML_LANG,
type Locale,
} from "../i18n/utils";

Expand All @@ -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 <select>: it is one alternative, 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
"Auf Deutsch ansehen" with German pronunciation. */
/* With one alternative this is a link, not a <select>: 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 <details> 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 && (
<a
href={targets[0]!.href}
hreflang={targets[0]!.meta.html}
lang={targets[0]!.meta.html}
data-lang-switch={targets[0]!.code}
class={chip}
title={`${s.lang.switchTo}: ${targets[0]!.meta.name}`}
>
<span aria-hidden="true">{targets[0]!.code}</span>
<span class="sr-only">{targets[0]!.meta.name}</span>
</a>
)
}

{
targets.length > 1 && (
<details class="relative" data-lang-menu>
<summary
class={`${chip} cursor-pointer list-none gap-1 [&::-webkit-details-marker]:hidden`}
title={s.lang.switchTo}
>
<span aria-hidden="true">{locale}</span>
<span class="sr-only">{s.lang.label}</span>
<Icon name="chevron-down" class="size-4" />
</summary>

<ul class="absolute end-0 z-50 mt-1 min-w-40 rounded-md border border-outline-variant bg-surface py-1 shadow-lg">
{targets.map((target) => (
<li>
<a
href={target.href}
hreflang={target.meta.html}
lang={target.meta.html}
data-lang-switch={target.code}
class="block px-4 py-2 text-body-md text-on-surface-variant transition-colors hover:bg-surface-variant hover:text-on-bg"
>
{target.meta.name}
</a>
</li>
))}
</ul>
</details>
)
}
<a
href={href}
hreflang={HTML_LANG[target]}
lang={HTML_LANG[target]}
class="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"
title={s.lang.switchTo}
>
<span aria-hidden="true">{target}</span>
<span class="sr-only">{s.lang.switchTo}</span>
</a>

{
/* 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: <details> has no
light-dismiss of its own, so Escape and an outside click close it. */
}
<script is:inline>
(function () {
document.querySelectorAll("[data-lang-switch]").forEach(function (link) {
link.addEventListener("click", function () {
try {
localStorage.setItem("tabmates-lang", link.dataset.langSwitch);
} catch (e) {
/* Storage unavailable — the link still navigates. */
}
});
});

const menus = document.querySelectorAll("[data-lang-menu]");
if (!menus.length) return;

function closeAll(except) {
menus.forEach(function (menu) {
if (menu !== except) menu.open = false;
});
}

document.addEventListener("click", function (event) {
const target = event.target;
closeAll(
target && target.closest ? target.closest("[data-lang-menu]") : null,
);
});

document.addEventListener("keydown", function (event) {
if (event.key !== "Escape") return;
closeAll(null);
});
})();
</script>
13 changes: 12 additions & 1 deletion src/components/ui/StoreBadges.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<Locale, ImageMetadata>> = {
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
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ export const de: Strings = {
},

lang: {
switchTo: "View in English",
label: "Sprache",
switchTo: "Sprache wechseln",
},

cta: {
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
40 changes: 40 additions & 0 deletions src/i18n/locales.ts
Original file line number Diff line number Diff line change
@@ -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 {
/** `<html lang>`, `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<Locale, LocaleMeta> = {
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);
}
58 changes: 41 additions & 17 deletions src/i18n/utils.ts
Original file line number Diff line number Diff line change
@@ -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<Locale, Strings> = { en, de };

/** Full `lang` attribute values, for <html lang> and hreflang. */
export const HTML_LANG: Record<Locale, string> = { 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/…").
Expand All @@ -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<Locale, Record<string, string>> = {
en: {},
de: {},
};
const LOCALIZED_SLUGS: Partial<Record<Locale, Record<string, string>>> = {};

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. */
Expand All @@ -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),
}));
}
Loading
Loading