🌐 Internationalization (i18n)¶
Erudi's interface ships in four languages — English, Français,
Español, 中文 — selected on the Settings page ("Application language")
and persisted with the other user settings (GET/PUT /erudi/user_settings/,
field language). English is the source of truth and the fallback.
Model answers, Hugging Face catalog descriptions and backend log lines are not translated (logs stay ASCII English by convention).
Where things live¶
| Path | Role |
|---|---|
frontend/src/locales/<lang>/<namespace>.json |
The catalogs. One folder per language (en, fr, es, zh), one file per namespace. |
frontend/src/i18n/resources.js |
Static imports of every catalog, the NAMESPACES list. Everything is bundled by webpack — no remote loading (the app runs under a strict CSP and must speak the right language before the backend is up). |
frontend/src/i18n/index.js |
The i18next instance (synchronous init), setAppLanguage(code) — the single entry point to change the language at runtime. |
frontend/src/i18n/languages.js |
SUPPORTED_LANGUAGES, native LANGUAGE_NAMES, OS-locale mapping, the erudi.language localStorage mirror key. Dependency-free: shared with the Electron main process. |
frontend/src/i18n/format.js |
formatNumber, formatPercent, formatFileSize, formatGigabytes, formatDate — locale-aware formatting that follows the active language. Use these instead of toFixed/Intl.* in components. |
frontend/src/i18n/sync.js |
Reconciles the boot language with the persisted setting once the backend answers (see "Language resolution"). |
frontend/src/i18n/mainTranslator.js |
Tiny lookup used by main.js for the native menu and dialogs (main namespace), with the same {{var}} interpolation and English fallback. |
backend/src/entities/UserSettings.py |
SUPPORTED_LANGUAGES mirror; the language column (en default, validated). |
Namespaces: common (shared actions, units, navigation, boot screen),
settings, models (catalog and cards), downloads (download / delete /
embedding-gate modals), chat, arena, knowledgeBase, landing, errors,
main (Electron main process only).
Adding or changing a string¶
- Never write user-facing copy in a component. Add a key to
frontend/src/locales/en/<namespace>.jsonand the same key, translated, tofr,esandzh. - Keys are semantic and stable (
settings.language.title,card.download), never the English sentence, nested by feature. - Render it with the explicit namespace prefix:
import { useTranslation } from "react-i18next";
const { t } = useTranslation();
<h2>{t("settings:language.title")}</h2>
<button aria-label={t("common:actions.cancel")} />
Dynamic values use interpolation: t("common:boot.elapsed", { seconds })
with "elapsed": "({{seconds}}s)". Plurals use the i18next JSON v4
suffixes key_one / key_other with a count option — provide both
suffixes in every language (Chinese has no plural form: same text twice).
Inline markup inside a sentence goes through <Trans>:
- Copy that lives in plain JS (a util returning a label, a status map, a
normalized error message) must also be a key. Either return a key or a
code that the component translates, or call
i18n.t("ns:key")inside the function at call time (never at module load — the language can change). String constants exported fromsrc/utils/**and rendered by a component are user-facing copy: the ESLint rule cannot see them, so the reviewer must. If a constant is rendered, replace it with a key. - Numbers, sizes, percentages and dates shown to the user go through
src/i18n/format.js, which reads the active language. - Never rely on the fallback on purpose: a key missing in a language is a test failure, not a feature.
Electron main process¶
Menu labels and native dialogs read from the main namespace through
createMainTranslator() in frontend/src/main.js. The renderer pushes the
current language over IPC (language:set, exposed as
window.languageAPI.set by preload.js) at boot and on every change; main
rebuilds the application menu when it changes.
Adding a language¶
- Create
frontend/src/locales/<code>/with every namespace file, fully translated (the guard test refuses holes). - Add the code to
SUPPORTED_LANGUAGESand its native name toLANGUAGE_NAMESinfrontend/src/i18n/languages.js, and its Intl locale tag toLOCALE_BY_LANGUAGEinfrontend/src/i18n/format.js. - Import the new catalogs in
frontend/src/i18n/resources.jsand infrontend/src/i18n/mainTranslator.js(mainnamespace). - Backend: add the code to
SUPPORTED_LANGUAGESinbackend/src/entities/UserSettings.pyand to theLanguageCodeliteral inbackend/src/domains/user_settings/schemas.py; extend the parametrized tests inbackend/tests/test_user_settings.py. - Run
npx vitest run src/locales src/i18nand the backend suite.
The Settings selector, the OS-locale mapping and the menu rebuild pick the new language up from the lists above; nothing else to wire.
Language resolution¶
- Boot (before React renders):
resolveInitialLanguage()reads theerudi.languagelocalStorage mirror, else mapsnavigator.language(fr-FR→fr,zh-Hans-CN→zh), elseen. The loading screen is already in that language. - Backend ready:
syncLanguageWithBackend()fetches the user settings. On the first launch on this machine (no mirror yet) the boot language — derived from the OS — is pushed to the backend and mirrored. On later launches the persisted backend value is the source of truth and is applied locally (the mirror is only a cache for the boot screen). - Settings page: the selector calls
setAppLanguage()(immediate UI switch, mirror, documentlang, IPC to main) and thenPUTs the code.
What is enforced¶
| Guard | Where | Fails when |
|---|---|---|
| Catalog completeness | frontend/src/locales/locales.test.js (vitest) |
A key present in en is missing in another language; a non-en file has a key en lacks; a value is empty or not a string; a {{placeholder}} of en is missing in a translation; a namespace file is missing for a language. |
| No hardcoded copy in JSX | ESLint i18next/no-literal-string (.eslintrc.json, mode jsx-only, src/**/*.jsx, tests excluded) |
A JSX text node or a copy-bearing attribute (title, placeholder, aria-label, alt, label, …) is a string literal. Technical attributes (className, id, href, data-*, …) and technical tokens (GGUF, MLX, punctuation, all-caps identifiers) are excluded. |
| Backend validation | backend/tests/test_user_settings.py, backend/tests/test_migrations.py |
PUT with an unknown code is not a 422; the default is not en; the migration does not backfill language='en' on existing rows. |
Silence the ESLint rule (// eslint-disable-next-line i18next/no-literal-string)
only for a genuinely non-translatable token rendered in JSX, with a short
justification on the same line.
Run locally: