Internationalization

WebComPy's internationalization core ships as webcompy.i18n: a DI-managed I18nManager holding the reactive locale, message catalogs, and fallback locale, plus the use_i18n() composable that hands components the locale signal, the translation function t, and a controller for switching locales.

Setting up a manager

Everything starts with an I18nManager holding your catalogs. The manager is provided by the application (there is no implicit default, mirroring how DI values are per-app state), so add it once during app setup:

from webcompy.i18n import I18N_KEY, I18nManager
from webcompy.app import WebComPyApp, WebComPyAppConfig

catalogs = {
    "en": {
        "nav": {"home": "Home", "about": "About"},
        "greeting": "Hello, {name}!",
        "items": {"one": "{count} item", "other": "{count} items"},
    },
    "ja": {
        "nav": {"home": "ホーム", "about": "概要"},
        "greeting": "こんにちは、{name} さん!",
        "items": {"other": "{count} 個のアイテム"},
    },
}

def make_manager():
    return I18nManager(
        catalogs,
        default_locale="en",
        supported_locales={"en", "ja"},
    )

app = WebComPyApp(root_component=Root, config=WebComPyAppConfig())
app.provide(I18N_KEY, make_manager)

Providing a factory (not a pre-built instance) lets the manager resolve the initial locale inside the render context's DI scope — so the browser locale cookie is honored at first render, and SSR seeding works per request.

The use_i18n composable

Inside a component, use_i18n() injects the manager and returns (locale, t, controller):

from webcompy.i18n import use_i18n

@define_component()
def Navbar(context):
    locale, t, controller = use_i18n()
    return html.NAV({}, t("nav.home"))
  • locale — the reactive Signal[str] for the current locale.
  • t(key, *, count=None, **params) — translates a key against the current locale.
  • controller.set(locale) — switches the locale (and persists it).
  • Calling use_i18n() without a provided manager raises LookupError naming the requirement.

If you need a translation label as a reactive value for an attribute or element (not a template interpolation), derive it with use_computed:

label = use_computed(lambda: t("nav.home"))
return html.A({"aria-label": label}, t("nav.home"))

Reactive translation

t() reads locale.value at call time. Because the template interpolation binder tracks any signal read during expression evaluation — including reads made inside called functions — a template {{ t("nav.home") }} registers a dependency on the locale signal through ordinary reactive tracking:

return html.DIV({}, render_template("<p>{{ t('nav.home') }}</p>", {"t": t}))

When controller.set("ja") fires, every rendered translation updates automatically; no subscription registry is needed.

Catalog format

Catalogs are {locale: {nested dictionaries}}, and keys resolve by dot path through the nesting:

catalogs = {
    "en": {
        "nav": {"home": "Home"},            # key: "nav.home"
        "greeting": "Hello, {name}!",       # key: "greeting"
    },
}

Leaf values are strings (interpolated) or plural dictionaries keyed by CLDR categories (see next section).

Interpolation

String messages interpolate {param} placeholders from the keyword arguments passed to t:

t("greeting", name="Alice")   # → "Hello, Alice!"
t("greeting")                  # → "Hello, {name}!"  (missing params stay literal)

Unknown placeholders render literally, which makes missing parameters visible during development instead of silently dropping them.

Pluralization

Plural messages can be a dictionary keyed by CLDR plural categories, or a pipe-separated string shorthand for languages with one/other:

"items": {"one": "{count} item", "other": "{count} items"}

# shorthand equivalent:
"items": "{count} item|{count} items"

t(key, count=n) selects the category for count using the active locale's plural rules and interpolates count alongside any other params:

t("items", count=1)   # → "1 item"
t("items", count=3)   # → "3 items"

Plural rules

A built-in table covers ~30 common locales (en, de, fr, es, pt, it, nl, sv, da, no, fi, hu, el, tr, hi, ja, zh, ko, th, vi, id, ru, uk, pl, cs, sk, ro, he, ar, …), including the category-rich rules for Russian (one/few/many) and Arabic (zero/one/two/few/many). Locales absent from the table fall back to one/other with a warning.

Projects needing full CLDR coverage add the optional babel dependency and register the adapter once during startup:

from webcompy.i18n._adapters._babel import register_babel_plural_rules

register_babel_plural_rules(["ru", "ar", "pl"])

Babel is never imported by the framework; the adapter replaces the rule source only when you register it, so the Pyodide bundle stays lean by default.

Fallback chain

Resolution tries the exact locale, its language part, then the configured fallback locale, and finally returns the key itself:

# locale "de-AT" → "de" → fallback ("en") → the key string

So a region-specific catalog (de-AT) may provide just a few regional overrides and inherit everything else from de or the fallback. Returning the key (rather than a blank) makes missing translations obvious in the UI.

Locale resolution and persistence

Browser — the initial locale is resolved in order: the locale cookie (webcompy-locale), then default_locale. Switching locales with controller.set() writes the cookie (path=/, SameSite=Lax), so the choice survives reloads.

SSRServerRenderContext already exposes the request's Cookie header through the cookie port (ServerCookiePort), so a factory-provided manager reads the same cookie on the server that the browser will see during hydration: first render and hydration agree by construction. Unsupported cookie values normalize to default_locale identically on both sides. For custom seeding (e.g. a URL prefix that carries the language), pass initial_locale:

from webcompy.i18n import I18nManager, resolve_locale

locale = resolve_locale(
    request.headers,               # Mapping or sequence of (name, value) pairs
    supported_locales=["en", "ja"],
    default_locale="en",
)
manager = I18nManager(catalogs, default_locale="en", supported_locales={"en", "ja"}, initial_locale=locale)

resolve_locale consults only the webcompy-locale cookie (exact tag or language part must be supported), then falls back to default_locale.

Why not Accept-Language / navigator.language? The server cannot see navigator.language and the browser never sees which Accept-Language header the server used, so a first-visit fallback resolved independently on each side produces two different locales and breaks hydration (rendered text differs from the hydrated text). Language negotiation can only be hydration-safe if the server transfers its resolved value to the client; that mechanism is planned as a follow-up, at which point negotiation returns.