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).
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). Alangcookie keeps the choice for a year, and every logged-out page uses it. Before any pick, the browser’sAccept-Languagedecides (its first language the app has, by primary subtag, soes-MXgets 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.langwins over the cookie and the browser. The profile’s Personal information card has the same picker; it autosaves (langthroughPOST /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:
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
<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:
{% 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 i18nchecks every language file parses, has exactly the keysen.yamlhas with the same{placeholders}, and that every key named in a template, source file or script (crate::t("…"),t('…'),tn('…')) exists inen.yaml.e2e/specs/i18n.spec.tschecks the picker lists everyi18n/*.yamlby itsmeta.name, registers through it in Spanish, checks the pages are Spanish, switches back in the profile.- The English text was moved into
en.yamlbyte for byte, so the exact and smoke specs still match on it.