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). 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:

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 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.