<!-- wiki/subsystems/i18n.md - the bilingual layer: locale routing, typed dictionaries, localized content and the hreflang contract. -->
---
title: Internationalisation
summary: One page source per route, one output per language; the dictionary is typed so a missing translation fails the build instead of shipping.
sources:
  - src/i18n/config.ts
  - src/i18n/index.ts
  - src/i18n/content.ts
  - src/i18n/ui/types.ts
  - src/i18n/ui/en/index.ts
  - src/i18n/ui/fr/index.ts
  - src/components/ui/locale-switcher/LocaleSwitcher.astro
  - src/pages/[...locale]/index.astro
  - astro.config.mjs
updated: 2026-08-15
---

# Internationalisation

The theme ships in English and French. English is served at the root
(`/blog/`), French under a prefix (`/fr/blog/`). Adding a third language does
not require touching a single page.

## The three decisions that shape everything else

**One page source, many outputs.** Every route lives under
`src/pages/[...locale]/`. The rest parameter is `undefined` for the default
language and the locale code for the others, so `about.astro` emits both
`/about/` and `/fr/about/`. There is no `src/pages/fr/` tree to keep in
sync, and no page to duplicate when a language is added. `localePaths()`
(src/i18n/index.ts) is the `getStaticPaths` of every static route:

```astro
import { localePaths } from "@i18n";
export const getStaticPaths = localePaths;
```

**The dictionary is a typed object, not string keys.** Components read
`t.reading.title`, never `t("reading.title")`. The first is autocompleted,
checked at build time and renamed by the editor's refactoring; the second is
a string nothing watches. `useTranslations(locale)` returns the whole
dictionary.

**A missing translation fails the build.** `src/i18n/ui/en/` is written with
`as const`, and `Widen` (src/i18n/ui/types.ts) turns those literal types into
their base types while keeping the shape. Every other language declares
`Pick<Dictionary, ...>`, so forgetting a key is a compile error and inventing
one that does not exist in English is too. A partial translation that
compiles is a partial translation that reaches production.

## The pattern in a component

```astro
---
import { getLocale, localizePath, useTranslations } from "@i18n";

const locale = getLocale(Astro);
const t = useTranslations(locale);
---
<h2>{t.index.title}</h2>
<a href={localizePath("/blog/", locale)}>{t.nav.blog}</a>
```

`getLocale` reads `Astro.currentLocale`, which Astro derives from the URL
once the `i18n` block is configured (astro.config.mjs). It revalidates the
value with `isLocale` rather than trusting a URL segment to index a
dictionary.

Never write an internal path in a component. `localizePath` handles the
prefix, passes absolute URLs, `mailto:` and anchors straight through, and is
the only function the navigation calls. Route targets that a user may want
to redirect (`blog`, `topics`, `authors`, `about`, `contact`, `legal`) go
through `getSiteRoutes(locale)` in src/config/navData.json.ts.

## Content collections

Content is filed by language: `src/data/posts/en/<slug>.md` and
`src/data/posts/fr/<slug>.md`, same base filename in both. The glob loader
therefore produces prefixed ids (`en/writing-html-that-ages-well`), which buys
two things at once: filtering a language is a prefix test, and the two versions
of one post share a slug once the prefix is removed, so the language switcher
can point at the actual translation instead of the home page.

src/i18n/content.ts owns every id manipulation:

- `entrySlug(id)` strips the prefix. **Any id used as a URL segment must go
  through it**, or URLs become `/blog/en/my-post/`.
- `entryLocale(id)` returns the language.
- `getLocalizedCollection(collection, locale, filter?)` is the replacement
  for `getCollection`.
- `localesForEntry(collection, slug)` lists the languages a given entry
  actually exists in, so a hreflang is never declared toward a 404.

## The hreflang contract

`getAlternates(url, site, locale)` is the single source for both the
`<link rel="alternate" hreflang>` tags in BaseHead and the language switcher
in the navbar. They cannot drift apart because they read the same function.
`x-default` points at the default language. The sitemap emits the same
alternates through the `i18n` option of `@astrojs/sitemap`: Google
cross-checks the two, and a disagreement makes it ignore both.

Per-language RSS: `/rss.xml` and `/fr/rss.xml`. A French subscriber should
not receive half the posts in English, so the feeds are separate and each
page declares its own in the head.

`llms.txt` is the exception: there is one per domain, so it lists every
language's URLs in one file, written in English, the working language of
agents.

The 404 is the other exception. Most static hosts serve a single 404
whatever the requested URL, so it renders in the default language and offers
both roots.

## Numbers and dates

`formatDate` and `formatNumber` go through `Intl` with the locale's `intl`
code, so English writes 1,284 and its date format while French writes 1 284
and its own. Reading time, publication dates and any count on a page all pass
through these helpers rather than being written by hand.

## Adding a language

Three edits, no page touched:

1. Add the code to `locales` and its card to `localeMeta` in
   src/i18n/config.ts. `dir` is already part of the card, so a RTL language
   does not require reopening the layouts.
2. Create `src/i18n/ui/<code>/` by copying the English slices. TypeScript
   refuses to compile until every key is present.
3. Add the content folders (`src/data/posts/<code>/` and so on).

The dictionary is split by domain (`chrome`, `index`, `pages`, `reading`)
rather than kept in one file, so one person can translate the reading slice
while another translates the index slice without touching the same file.

## The switcher

src/components/ui/locale-switcher/LocaleSwitcher.astro changes shape with
the number of languages: a segmented control for two (one gesture, no menu
to open), a dropdown from three (a five-cell segment overflows the bar on an
iPhone). No flags: a flag names a country, not a language. Links carry
`data-astro-reload` because the target document has a different `lang`,
`dir` and hreflang set, and the client router only swaps the body.
