Internationalization
Translations live in each module as JSON files under <package>/locales/<lang>.json. They're collected into a single I18nRegistry at boot and surfaced to React via the Inertia shared props.
Declaring locale files
modules/orders/orders/locales/
├── en.json
├── es.json
└── de.jsonDeclare them from ModuleBase.locale_dirs():
import importlib.resources
from pathlib import Path
class OrdersModule(ModuleBase):
def locale_dirs(self) -> dict[str, Path]:
return {
"orders": Path(str(importlib.resources.files(__package__) / "locales")),
}The key ("orders") is the namespace — it prefixes every key in the files. smpy create-module scaffolds this method and a starter en.json automatically.
Audience
Every module's catalog ships inside the Inertia shared props on full page loads. A module whose UI sits entirely behind login can declare that:
meta = ModuleMeta(name="Orders", ..., i18n_audience="admin")"admin" catalogs are withheld from anonymous visitors — a public content page stops paying for settings-form labels it can never render — and shipped as soon as the user authenticates (the login transition re-sends the bundle even on an Inertia partial). The default is "public": ship to everyone. Server-side Translator lookups always see every namespace regardless. The framework's own admin modules (settings, permissions, dashboard, file_storage, audit_log, feature_flags, background_tasks, branding) declare "admin".
Key naming
Keys are <namespace>.<area>.<string> with hierarchical JSON objects that flatten at boot:
{
"browse": {
"title": "Orders",
"empty": "No orders yet"
},
"fields": {
"customer": "Customer"
}
}Under namespace orders, these become:
orders.browse.titleorders.browse.emptyorders.fields.customer
Convention: snake_case at leaves, camelCase or snake_case consistently at levels.
Interpolation
Placeholders use {name} syntax, identical between frontend and backend:
{ "greeting": "Hello, {name}" }Frontend:
import { useT } from "@simple-module-py/i18n";
const { t } = useT();
t("orders.greeting", { name: user.name });Backend (endpoints, emails) — inject the request-scoped Translator via TranslatorDep, or build one directly:
from simple_module_hosting.i18n_deps import TranslatorDep
@router.get("/greet")
async def greet(t: TranslatorDep, ...):
return {"msg": t.t("orders.greeting", name=user.name)}
# Or construct one explicitly:
from simple_module_core.i18n import Translator
t = Translator(request.app.state.sm.i18n_registry,
locale=request.state.locale, default_locale="en")
t.t("orders.greeting", name=user.name)Missing placeholders are left verbatim ("Hello, {name}") rather than raising — intentional, so a translation bug doesn't 500 a page.
Pluralization
Use CLDR suffixes: _zero, _one, _two, _few, _many, _other. Only _other is required. Pass count as a param:
{
"items_one": "{count} item",
"items_other": "{count} items"
}t("orders.items", { count: orders.length });t.t("orders.items", count=len(orders))Behavior matches across the stack: Babel's CLDR rules on the backend, Intl.PluralRules in i18next on the frontend.
Locale resolution
LocaleMiddleware picks the active locale per request in this order:
- Cookie named by
SM_I18N_COOKIE_NAME(defaultlocale), validated againstSM_I18N_SUPPORTED_LOCALES. Accept-Languageheader, with q-value parsing and longest-prefix match (es-MX→es).SM_I18N_DEFAULT_LOCALE.
Resolved locale lands on request.state.locale.
<LocaleSwitcher />
Ships in @simple-module-py/ui. POSTs to /i18n/set-locale, which sets a 1-year cookie and redirects back.
Zod schemas with translated messages
Zod schemas that reference translations must be constructed inside a hook — never at module scope:
import { z } from "zod";
import { useT } from "@simple-module-py/i18n";
// ✅ Correct — resolves against the active locale per render
export function useProductSchema() {
const { t } = useT();
return z.object({
name: z.string().min(1, t("products.validation.name_required")),
});
}
// ❌ Wrong — freezes against whichever locale was active at module load
const schema = z.object({
name: z.string().min(1, t("products.validation.name_required")),
});Module-scope t(...) calls capture the first-render locale and never update.
Host and shared-package strings
- Host strings (landing page, error page):
host/locales/<lang>.json, namespacehost.*. - Shared UI strings (packages/ui):
packages/ui/locales/<lang>.json, namespaceui.*.
Both are auto-discovered alongside module contributions — no manual wiring.
Configuration
The three i18n fields are declared on HostSettings, but they are read from the environment at boot:
SM_I18N_DEFAULT_LOCALE=en
SM_I18N_SUPPORTED_LOCALES='["en","es"]'
SM_I18N_COOKIE_NAME=locale| Field / env var | Default | Purpose |
|---|---|---|
i18n_default_locale · SM_I18N_DEFAULT_LOCALE | en | Language served when nothing else resolves, and the fallback every other locale layers over. |
i18n_supported_locales · SM_I18N_SUPPORTED_LOCALES | ["en"] | Which locales are served at all. The <LocaleSwitcher /> hides itself when there is only one. |
i18n_cookie_name · SM_I18N_COOKIE_NAME | locale | Cookie the switcher writes and LocaleMiddleware reads first. |
A pydantic validator enforces that the default locale is in the supported list.
Why env, when HostSettings is the DB-backed class
HostSettings is consumed through two different objects, and i18n uses the env-backed one:
Settings(HostSettings+BootstrapSettings) inheritsenv_prefix="SM_", so every field on it resolves fromSM_*at boot. This instance lands onapp.state.sm.settings, and it is whatLocaleMiddleware, the i18n manifest, the shared-props builder andi18n_depsall read.app.state.host.settingsis a separateHostSettingshydrated from the DB.HostSettingson its own declares noenv_prefix, so this instance is pydantic defaults plus stored overrides.maintenance_modeis read from here, which is what makes that flag live-editable.
The practical consequence: changing the i18n values in the admin UI does not change locale resolution. LocaleMiddleware captured its locale set at construction from the env-derived object, and a settings save swaps app.state.host.settings, not app.state.sm.settings. Moving the served locale set means setting the env var and restarting. The same is true of multi_tenant / tenant_header — SM_MULTI_TENANT is what decides whether TenantMiddleware is installed at all, and no DB write can install a middleware after boot.
SM_I18N_SUPPORTED_LOCALES is additionally read by the standalone diagnostics runner (python -m simple_module_core, i.e. make doctor), which has no DB: it skips the i18n checks entirely when the variable is unset.
Shipping a locale is not the same as enabling it. es.json files exist across this repo but es is not in the default supported list, so they are never loaded.
Falling back across locales
Each non-default locale's snapshot is layered over the default locale's before it reaches the client, so an untranslated key renders in the default language rather than as a raw dotted key.
This matters more than it sounds. The client initialises i18next with fallbackLng set to the active locale, so there is no cross-locale fallback in the browser — a key missing from the payload renders as dashboard.home.system_meta on screen. The server-side Translator has always fallen back to the default locale, so without this the two paths disagreed: menu labels (translated server-side) rendered English while the page body showed keys.
The practical effect is that partial translation is a safe, incremental state. A half-finished locale reads as a mix of two languages, not as a screen of dotted keys.
Translating menu labels and audit links
Some strings are chosen in Python and rendered on every page. Those are translated server-side rather than shipped to the client as keys.
MenuItem takes label_key and group_key alongside label/group; MenuRegistry.get_for_user(translate=…) resolves them, so the Inertia payload carries finished text and every render site (sidebar, topbar, ⌘K palette) keeps reading item.label untouched. AuditLink.label_key does the same for audit-log entity labels.
MenuItem(
label="Users", # fallback, still required
label_key="users.nav.users", # module's own namespace
url="/admin/users/",
group="Access",
group_key="ui.nav_groups.access", # shared vocabulary
)Two consequences worth knowing:
- An admin-audience module's labels don't need to be in the anonymous catalog snapshot to render, because they are resolved before the payload is built — this sidesteps the audience split.
- A key that resolves to nothing falls back to
label. A missing translation degrades to English, never to a raw dotted key on screen.
Both fields are optional, so modules written before them keep working. Group headers are shared across modules and live in the ui namespace (ui.nav_groups.access|appearance|content|system) rather than each module inventing its own — otherwise one module's "System" could translate differently from another's and split a single header in two.
The untranslated-string gate (make ci-check-untranslated)
SM013–SM016 only compare catalogs against each other, so with i18n_supported_locales = ["en"] they never fire — a module could ship a complete en.json that no page ever read and CI stayed green. tsc is equally happy with hardcoded English. This check is what notices.
It runs in make lint and as its own CI job, parsing every .tsx under modules/*/*/, packages/ui/src/ and host/client_app/ (skipping vendored shadcn primitives under packages/ui/src/components/ui/, plus .test.tsx and .stories.tsx). It fails on user-visible text rendered as a literal:
- JSX text —
<p>Save</p> - An allowlisted text attribute —
title,placeholder,aria-label,label,description,alt,emptyText,confirmLabel, … The list is deliberately closed:className,variant,roleandtypecarry machine tokens, and flagging those would train people to reach for the exemption instead of the catalog. - A
toast.*/confirmargument - Copy hidden in a ternary —
cond ? 'Enabled' : 'Disabled'
It parses rather than greps, which costs one devDependency (@babel/parser). No regex over JSX can distinguish <p>Save</p> from the Promise<void> in a type annotation, and one that tries flags both.
Exempting a legitimate literal
Three escape hatches, in order of preference:
| How | When |
|---|---|
Wrap in <code> / <pre> | Code samples, identifiers, terminal output — recognised automatically, no comment needed. |
// i18n-exempt: <reason> | One line. A truncated token echo, a JSON example placeholder. |
// i18n-exempt-file: <reason> | A whole file. Reserved for dev-only fixtures like DemoPlaceholders.tsx. |
Always give a reason — the comment is the only record of why the string is allowed to stay English.
Known blind spot
A string that reaches the screen through a variable or config object (const THEME = { mobileTitleLabel: 'Admin' }) is invisible to this check. Catching it needs taint analysis; guessing instead would produce the false positives that get a check switched off. Documented rather than papered over.
The detection logic lives in scripts/lib/untranslated-strings.mjs behind its own unit tests, so a later "fix" to one of its heuristics cannot quietly stop it detecting anything.
Type generation
packages/i18n/src/keys.generated.ts is generated over every installed module, not just the ones the running host activated, and make ci-js-typecheck runs tsc -p for each modules/*/tsconfig.json and packages/*/tsconfig.json in the workspace. (A module shipping .tsx with no tsconfig.json fails the target outright, rather than being silently skipped — the same gap SM017 reports.)
That distinction is load-bearing: with a filtered union, translating an inactive auth provider's pages (say keycloak, when this host runs users) would break its build on the next regeneration while the app itself ran fine. The runtime registry stays filtered — an inactive module's strings are typed, never served.
t() needs literal keys to typecheck; a key assembled at runtime won't resolve.
Diagnostics (SM013–SM016)
App boot runs I18nDiagnostics against every declared locale dir:
| Code | Level | Trigger |
|---|---|---|
SM013 | WARNING | Locale file missing for a supported locale (e.g. declared es but no es.json). |
SM014 | WARNING | Non-default locale missing keys present in the default (untranslated). |
SM015 | WARNING | Non-default locale has keys not in the default (stale / orphan translation). |
SM016 | ERROR | Locale JSON invalid or contains non-string leaves. |
In dev these print as warnings; in production SM016 fails boot.
Testing
For tests that assert on translated strings, set the locale explicitly:
async def test_landing_page_i18n(client):
r = await client.get("/", headers={"Accept-Language": "es"})
assert "Bienvenido" in r.textOr cookie:
r = await client.get("/", cookies={"locale": "es"})The LocaleMiddleware resolves and the shared props carry the right bundle to the Inertia page.