# Languages (i18n)

The app speaks to a learner in their language: every piece of its own text
(navigation, pages, forms, buttons, messages, what the scripts draw) lives in
`i18n/<code>.yaml`, one file per language, each entry under a stable key.
Adding a language is adding one file (`i18n/README.md` has the format and the
conventions; it is the page to read before touching text). The languages
shipped are the files in `i18n/` (`ls i18n/*.yaml`); the pickers list them by
their `meta.name`, so a new language needs no other change. Translations are
made with the help of a model and checked by hand, so a wrong turn of phrase is
a one-line fix in the file.

A course is written in one language by its author, the lesson text, labels
(`terms.label`, `meanings.label`), praise and Guide steps included, and the
app shows it as written whatever the learner's language. To have it in
another language, a learner copies it with **Copy and translate to…**. An AI
job translates the copy's learner-side text and keeps its ids and answers
(`src/translate.rs`, #629, #630; see
[Copying and translating a course](features#copying-and-translating-a-course)).
The original stays as it was, and the copy does not follow later changes to it.

A built-in theme's own text (its title, description, praise and "not quite"
lines, and the names of its colours and backgrounds) is app text. It sits
under `themes.<id>.` in every `i18n/<code>.yaml`, and the app shows it in the
learner's language, or what `theme.json` says when a key is missing (#463). en.yaml
says exactly what `theme.json` says; a test checks it. A theme a learner made
says what its file says. Picture names are names and stay as written. A
course's own lines for a theme (`course.json` `themes.<id>.strings`) are in
the course's language and win while the theme is worn.

The Markdown documentation under `docs/` and the legal pages (`/terms`,
`/privacy`) are English, and so is everything that only exists in dev mode:
the `/dev` pages (`templates/dev_*.html`) and the dev panels inside other
pages (`{% if dev %}` blocks, such as "Log in as dev" on the login page). They
are tools for whoever works on the app, never shown on the hosted site or in
Skrongle mini, so their text can be written in the template, with no keys.

## Picking a language

- **Logged out**, the login and register pages have a picker (`POST /lang`).
  A `lang` cookie keeps the choice for a year, and every logged-out page
  uses it. Before any pick, the browser's `Accept-Language`
  decides (its first language the app has, by primary subtag, so `es-MX`
  gets Spanish); English otherwise.
- **Registering** stores the cookie's choice on the new account
  (`users.lang`). An account that never picked follows the browser.
- **Logged in**, the account's `users.lang` wins over the cookie and the
  browser. The profile's Personal information card has the same picker; it
  autosaves (`lang` through `POST /api/profile`) like any other field (the
  yellow circle, then the green check on its label) and the page then
  reloads in the new language, still showing the pick as saved.
- The `<html lang>` attribute is the language in use, on every page.

## How code reads it

The language is decided once per request (`src/i18n.rs`). The
`i18n::middleware` layer reads the cookie or the header and runs the rest of
the request inside a task-local, and the `CurrentUser` / `MaybeUser`
extractors then switch it to the account's choice. Templates and handlers
call the same functions, with no field to thread through:

```rust
crate::t("nav.login")                    // -> String
crate::t1("nav.hello", "name", name)     // {name} filled
crate::t2("x.y", "a", 1, "b", 2)
crate::tn("study.lessons", n)            // study.lessons.one / .other, {n} filled
```

```html
<h1>{{ crate::t("auth.login.title") }}</h1>
{{ crate::t1("auth.no_account_html", "url", crate::url("/register"))|safe }}
```

A key ending in `_html` may hold markup and is rendered with `|safe`; only
trusted values (app URLs, numbers) go into its placeholders.

Scripts read the same table. `base.html` embeds the `common.js.*` keys on
every page as `window.I18N` with `t(key, args)` and `tn(key, n)`; a page
whose script has text of its own adds its area's keys first:

```html
{% block scripts %}
<script>Object.assign(I18N, {{ crate::i18n::js_json("sprint.js")|safe }});</script>
<script src="{{ crate::routes::asset_url("sprint.js") }}" defer></script>
{% endblock %}
```

Outside a request (startup, tests, a thread without the task-local) `t`
returns the English text, so unit tests that compare messages keep working.

## Tests

- `cargo test i18n` checks every language file parses, has exactly the keys
  `en.yaml` has with the same `{placeholders}`, and that every key named in
  a template, source file or script (`crate::t("…")`, `t('…')`, `tn('…')`)
  exists in `en.yaml`.
- `e2e/specs/i18n.spec.ts` checks the picker lists every `i18n/*.yaml` by
  its `meta.name`, registers through it in Spanish, checks the pages are
  Spanish, switches back in the profile.
- The English text was moved into `en.yaml` byte for byte, so the exact and
  smoke specs still match on it.
