Skip to content

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

text
modules/orders/orders/locales/
├── en.json
├── es.json
└── de.json

Declare them from ModuleBase.locale_dirs():

python
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:

json
{
  "browse": {
    "title": "Orders",
    "empty": "No orders yet"
  },
  "fields": {
    "customer": "Customer"
  }
}

Under namespace orders, these become:

  • orders.browse.title
  • orders.browse.empty
  • orders.fields.customer

Convention: snake_case at leaves, camelCase or snake_case consistently at levels.

Interpolation

Placeholders use {name} syntax, identical between frontend and backend:

json
{ "greeting": "Hello, {name}" }

Frontend:

tsx
import { useT } from "@simple-module-py/i18n-react";
const { t } = useT();
t("orders.greeting", { name: user.name });

Backend (endpoints, emails):

python
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:

json
{
  "items_one": "{count} item",
  "items_other": "{count} items"
}
tsx
t("orders.items", { count: orders.length });
python
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:

  1. Cookie named by SM_I18N_COOKIE_NAME (default locale), validated against SM_I18N_SUPPORTED_LOCALES.
  2. Accept-Language header, with q-value parsing and longest-prefix match (es-MXes).
  3. 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:

tsx
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, namespace host.*.
  • Shared UI strings (packages/ui): packages/ui/locales/<lang>.json, namespace ui.*.

Both are auto-discovered alongside module contributions — no manual wiring.

Configuration

bash
SM_I18N_DEFAULT_LOCALE=en
SM_I18N_SUPPORTED_LOCALES=en,es,de
SM_I18N_COOKIE_NAME=locale

A pydantic validator enforces that the default locale is in the supported list.

Diagnostics (SM013SM016)

make doctor (and app boot) runs I18nDiagnostics against every declared locale dir:

CodeLevelTrigger
SM013WARNINGLocale file missing for a supported locale (e.g. declared es but no es.json).
SM014WARNINGNon-default locale missing keys present in the default (untranslated).
SM015WARNINGNon-default locale has keys not in the default (stale / orphan translation).
SM016ERRORLocale 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:

python
async def test_landing_page_i18n(client):
    r = await client.get("/", headers={"Accept-Language": "es"})
    assert "Bienvenido" in r.text

Or cookie:

python
r = await client.get("/", cookies={"locale": "es"})

The LocaleMiddleware resolves and the shared props carry the right bundle to the Inertia page.

Released under the MIT License.