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. make new-module scaffolds this method and a starter en.json automatically.
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-react";
const { t } = useT();
t("orders.greeting", { name: user.name });Backend (endpoints, emails):
from simple_module_core.i18n import get_translator
t = get_translator(request.state.locale)
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-react";
// ✅ 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
SM_I18N_DEFAULT_LOCALE=en
SM_I18N_SUPPORTED_LOCALES=en,es,de
SM_I18N_COOKIE_NAME=localeA pydantic validator enforces that the default locale is in the supported list.
Diagnostics (SM013–SM016)
make doctor (and 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.