# Localization

On-demand guide (`uniweb guide i18n`); AGENTS.md is assumed read. Full model: `development/internationalization.md`. Author's view: `authoring/translating.md`.

**Content reaches components already translated.** No `t()`, no runtime lookup — each language is a complete static build with its own HTML, `hreflang` tags and search index. The default language has no URL prefix (`/about`); others are prefixed (`/es/about`).

**Translations live in `locales/`, a sibling of `pages/` — never inside `pages/`.** The markdown under `pages/` is one language: the default.

```yaml
# site.yml
defaultLanguage: en
languages: [en, es, fr]        # codes only; or '*' to auto-discover from locales/
publishLanguages: [en, es]     # optional — fr stays a dev-previewable draft, excluded from production
```

## Workflow

```bash
uniweb i18n extract            # → locales/manifest.json, keyed by 8-char content hash; reads the site's source files
uniweb i18n init es fr         # → locales/es.json, locales/fr.json, pre-filled with source
#  …translate the values…
uniweb build                   # merges translations, emits dist/es/, dist/fr/
```

Records are the exception: `extract` reads them from what a build left in `public/data/`, so build first when the site has records. After content changes: `uniweb i18n sync` updates the manifest, `init` refreshes language files, `status` and `audit` report coverage and stale entries.

**A value is inline Markdown.** Links and bold go into the translation (`"Voir [Rôles](page:docs/roles)."`) or it renders without them; `audit` lists these under *Loses markup*; `status` counts them as translated. A **button** (a line that is only a link) is the exception: translate the label alone and it keeps its link and options. Buttons on consecutive lines are one string — one label per line (`"Créer un groupe\nEn savoir plus"`). The same words are one string wherever they appear; where their places differ the manifest marks `markupVaries`, and the plain words are what to write.

**Two mechanisms.** *Hash-based strings* (default): `locales/{locale}.json` maps hash → translation; structure stays identical across languages. One source string needing different translations by place takes the override form — keys are route + section id (`/pricing:cta`), a page's own metadata `/pricing:_meta`, a record its manifest context (`std/article/launch`):

```json
{ "e5f6g7h8": { "default": "Learn More", "overrides": { "/pricing:cta": "See Pricing" } } }
```

An entry can be flagged **`outdated`** (`{ "default": "…", "outdated": { "/about:hero": "About PandaWatch" } }`): the source was edited a little and the old translation shows until reviewed; `status` lists it. New words in the entry end the flag; if the old words still hold, make it a plain string. An edit sharing under half its words keeps nothing, nor does a page title, alt text or a data block string — those show the source until translated. `locales/_kept.json` holds what the app keeps for a section no file has a place for; a pull writes it, a push sends it back — commit it, leave it alone.

*Free-form bodies* — when a language needs different structure, images, or copy rewritten rather than translated. Body only, no frontmatter; params still come from the source section:

```
locales/freeform/es/pages/about/hero.md        # by page route
locales/freeform/es/records/article/x.md
```

`uniweb i18n init-freeform es pages/about hero` creates one pre-filled with a source hash; `status --freeform` says when the original moved on (`update-hash` to acknowledge). `move`, `rename`, `prune --freeform` keep them aligned.

**Data blocks** translate by the shape their key declares in `meta.js data:` — text fields, never an enum, URL or `translatable: false` field. A key declared `{}` falls to a heuristic that may offer structural strings (`style: display-black`) — declare the shape of every block a translator will see.

**Images** translate by alt text. Words alone give the alt text in that language; a translation that is an image also names the file: `"2d093636": "![La superposition](/docs-media/fr/overview.png)"` — a `/` path in `public/` (a `./` path has no page folder to start from). Captions and a video's `poster` work the same way. Give every image alt text.

**Records** translate in the same `extract` run into `locales/records/manifest.json`, keyed by the record. A record whose query has a data schema translates exactly the fields the schema says are text; mark a code-like string `translatable: false`. Excerpts re-derive per language.

**A data schema's own labels** are not in `locales/`: write them in `source_locale`, other languages in the schema's `i18n` keyed by path (`i18n: { fr: { model.label: Séance, section.brief.field.title.label: Titre } }`). See `development/data-schemas.md`.

**Localized URLs:** `slug: { fr: a-propos }` in `page.yml` — the folder name stays canonical; nested folders compose; flows through navigation, switcher and sitemap. Each language gets its own `/fr/llms.txt` and `/fr/search-index.json`.

## Component side

Nothing to translate — `content.title` arrives in the active language. The one thing a foundation builds is a switcher:

```jsx
import { useWebsite, Link, getLocaleLabel } from '@uniweb/kit'
function LanguageSwitcher() {
  const { website } = useWebsite()
  if (!website.hasMultipleLocales()) return null
  return website.getLocales().map(locale => (
    <Link reload key={locale.code} href={website.getLocaleUrl(locale.code)}>{getLocaleLabel(locale)}</Link>
  ))
}
```

Use `<Link reload>` — not a plain `<a>`, not `window.location.href`: `getLocaleUrl()` returns a root-relative path without the deployment base path (`base: /docs/`), and a locale switch needs a full page load because the other language is a different build. A `<select>` or button calls `website.switchLocale(code)`, which makes the same load (base path included) and, inside a host that switches languages itself (an editor's preview), hands the switch to the host.
