Skip to content

🌐 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

  1. Never write user-facing copy in a component. Add a key to frontend/src/locales/en/<namespace>.json and the same key, translated, to fr, es and zh.
  2. Keys are semantic and stable (settings.language.title, card.download), never the English sentence, nested by feature.
  3. 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>:

<Trans i18nKey="landing:welcome.body" components={{ b: <b /> }} />
  1. 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 from src/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.
  2. Numbers, sizes, percentages and dates shown to the user go through src/i18n/format.js, which reads the active language.
  3. 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

  1. Create frontend/src/locales/<code>/ with every namespace file, fully translated (the guard test refuses holes).
  2. Add the code to SUPPORTED_LANGUAGES and its native name to LANGUAGE_NAMES in frontend/src/i18n/languages.js, and its Intl locale tag to LOCALE_BY_LANGUAGE in frontend/src/i18n/format.js.
  3. Import the new catalogs in frontend/src/i18n/resources.js and in frontend/src/i18n/mainTranslator.js (main namespace).
  4. Backend: add the code to SUPPORTED_LANGUAGES in backend/src/entities/UserSettings.py and to the LanguageCode literal in backend/src/domains/user_settings/schemas.py; extend the parametrized tests in backend/tests/test_user_settings.py.
  5. Run npx vitest run src/locales src/i18n and 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

  1. Boot (before React renders): resolveInitialLanguage() reads the erudi.language localStorage mirror, else maps navigator.language (fr-FR → fr, zh-Hans-CN → zh), else en. The loading screen is already in that language.
  2. 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).
  3. Settings page: the selector calls setAppLanguage() (immediate UI switch, mirror, document lang, IPC to main) and then PUTs 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:

cd frontend
npm run lint:check
npx vitest run src/locales src/i18n