# Multiple languages (i18n)

Ovellum can publish the same site in several languages. It's **opt-in**: a
single-language site needs no locale folders and behaves exactly as before.
Turn it on by declaring your languages, and content moves into one subtree per
language.

## Enable it

Add [`site.locales`](/docs/reference/config/) (and optionally
`site.defaultLocale`) to your config:

```ts
export default {
  site: {
    defaultLocale: 'en-US',
    locales: [
      { code: 'en-US', label: 'English' },
      { code: 'ja', label: '日本語' },
      { code: 'zh-Hans', label: '简体中文' },
    ],
  },
} satisfies OvellumUserConfig;
```

- **`code`** is a [BCP 47](https://www.w3.org/International/articles/language-tags/)
  language tag — `en-US`, `ja`, `zh-Hans` / `zh-Hant`, `de`, `pt-BR`. This is
  also the content folder name and the `<html lang>`.
- **`label`** is what the picker shows — use the language's **autonym** (its own
  name: `日本語`, `简体中文`, `English`), since readers scan for their language in
  its native script.
- **`defaultLocale`** is served at the site root; it defaults to the first
  entry of `locales`.

> Use the right codes. UK English is **`en-GB`** (not `en-uk`), Japanese is
> **`ja`** (not `jp`), and Chinese uses a **script** subtag — `zh-Hans`
> (Simplified) or `zh-Hant` (Traditional) — not a country code.

## Organize content per locale

With locales on, content lives in one subtree per language, named by its code:

```
content/
  public/              ← shared across all locales (copied to the root)
  en-US/               ← the default locale
    _landing.md
    docs/
      getting-started.md
      guides/install.md
  ja/                  ← Japanese
    docs/
      getting-started.md
```

Pages line up across languages **by identical relative path**:
`en-US/docs/guides/install.md` is the translation of `ja/docs/guides/install.md`.
That mapping is what the language picker follows. Each locale has its own
sidebar, `_meta.json` ordering, and frontmatter.

The reserved [`publicDir`](/docs/reference/config/) (`public/`) stays **shared** —
it is not a locale and is copied to the output root once.

### Migrating an existing site

Adding i18n to a single-language site is a one-time move: put your existing
content into `content/<defaultLocale>/` and add the `locales` config. Files that
were at the content root (a `CNAME`, say) move into `public/` so they still
reach the output root. Nothing else changes.

## URLs

The default locale is served at the **root**; every other locale is served under
its **code**:

| Page                          | en-US (default) | ja              |
| ----------------------------- | --------------- | --------------- |
| `docs/guides/install.md`      | `/docs/guides/install/` | `/ja/docs/guides/install/` |
| home / landing                | `/`             | `/ja/`          |

So existing URLs don't change when the default locale is the language you
already had.

## The language picker

A globe dropdown appears in the topbar (after your nav links, before the icon
cluster), listing every locale by its label. Switching takes the reader to the
**same page** in that language. If a page isn't translated yet, the picker falls
back to that locale's **home** — so you can ship a language with only a few
pages translated and grow it over time.

Each page also gets `<html lang>` and, when `site.baseUrl` is set,
`hreflang` alternate links (plus `x-default` for the default locale) so search
engines serve the right language.

## What's localized — and what isn't yet

**Localized:** all page content and frontmatter, the sidebar/nav, page URLs,
`<html lang>`, hreflang, the sitemap, **and the template's own UI chrome** — "On
this page", "Edited" and its dates, "min read", the appearance-panel labels,
prev/next, the 404 page, and the rest. The chrome ships translated for a set of
built-in languages (English and Japanese today); any other locale falls back to
English per string, and you can fill the gaps yourself (below). Right-to-left
languages also get `<html dir="rtl">` automatically.

Config-driven text — the landing hero/CTA/feature copy and the `topbarNav` /
`footerNav` link labels you write in `ovellum.config.*` — is localizable too:
any such field accepts either a plain string or a per-locale map (below).

Each locale also gets its own RSS feed — `/feed.xml` for the default language,
`/<code>/feed.xml` for the rest — scoped to that locale's pages (the sitemap
stays a single combined file). Requires `site.baseUrl`.

### Localizing config text

Anywhere the config takes a user-facing label or copy string, you can give a
per-locale map instead of a plain string — keyed by locale `code`, falling back
to the default locale:

```ts
topbarNav: [{ label: { 'en-US': 'Docs', ja: 'ドキュメント' }, href: '/docs/' }],
landing: {
  hero: {
    title: { 'en-US': "Docs that don't drift.", ja: 'ドリフトしないドキュメント。' },
    ctas: [{ label: { 'en-US': 'Get started', ja: 'はじめる' }, href: '/docs/' }],
  },
},
```

A plain string still works and shows in every locale — so you only mapify the
strings you actually translate. This applies to `topbarNav`/`footerNav` labels
and the landing hero title/subtitle, CTA labels, feature titles/descriptions,
install titles, and trust-strip text.

### Overriding or adding chrome strings

If a locale isn't built in, or you want different wording, set `strings` on the
locale — it's merged over the built-in table (English fills anything you omit):

```ts
site: {
  locales: [
    { code: 'en-US', label: 'English' },
    {
      code: 'fr',
      label: 'Français',
      strings: { tocTitle: 'Sur cette page', editedLabel: 'Modifié', backToTop: 'Haut de page' },
    },
  ],
}
```

The keys are the UI-string names (`tocTitle`, `editedLabel`, `minRead`,
`previous`, `next`, `backToTop`, the appearance-panel labels, and so on).

## Translations are yours to write or generate

Ovellum renders whatever Markdown is in each locale folder. Author translations
by hand, or pre-translate them however you like and drop the files in — the tool
doesn't translate for you, and it doesn't get in the way.

## Keeping translations in sync

The hard part of a hand-maintained translation isn't writing it — it's noticing
when the **source** page changes and the translation silently falls behind.
`ovellum check` watches for that drift.

Each translated page can carry a `sourceHash` in its frontmatter — a fingerprint
of the default-locale page it mirrors (matched by identical path across the
locale folders, e.g. `ja/docs/install.md` ↔ `en-US/docs/install.md`). `check`
recomputes the source's fingerprint and compares:

- **source unchanged** → nothing to report;
- **source changed since the translation was stamped** → flagged `[i18n]` as
  stale (exit code `1`, so CI catches it);
- **no `sourceHash` yet** → flagged, so new translations get stamped;
- **no matching source page** → flagged as an orphan translation.

You don't write the hash by hand. After you've brought a translation back in
line with its source, stamp it:

```bash
ovellum check --update-translations
```

That writes the current `sourceHash` into every translated page (touching only
that one frontmatter line) and exits. The fingerprint covers the page **body**,
not its frontmatter, and normalizes line endings — so reformatting or a
frontmatter tweak won't trip a false "stale". A typical loop: edit an English
page → `ovellum check` flags the Japanese mirror → translate the change →
`ovellum check --update-translations` to re-stamp.
