Internationalisation

Gebweb's i18n module covers four concerns: deciding which locale a request belongs to, looking up translated messages, pluralising counts, and formatting numbers and dates. Validation error messages can flow through the same catalog automatically.

Wiring

import gebweb;

let app = gebweb.app([HomeController()]);
gebweb.useI18n(app, {
    "locales": ["en", "de", "fr"],
    "default": "en",
    "messages": "messages"
});

opts.locales is the supported list. opts.default is the fallback when negotiation yields nothing. opts.messages is the directory of catalog files (one YAML per locale tag, see below); omit it to wire negotiation without translation.

Locale negotiation

The middleware tries the configured sources in order. The default order is ["url", "cookie", "accept-language"].

Source How it picks
"url" URL prefix /<tag>/.... When opts.urlStrip is true (the default), the matching prefix is removed from request["path"] so routes match against /....
"cookie" Reads opts.cookieName (default gebweb_locale) from the request Cookie header. The value must be in opts.locales.
"accept-language" Parses the Accept-Language header with q-value ranking against opts.locales.

The resolved Locale is published on request["_locale"]. Reorder or shorten sources to match the app's identity model (cookie before URL when a sticky preference should override path).

Locale injection

A handler that declares gebweb.Locale as a parameter receives the resolved locale directly:

class HomeController {
    @Get("/")
    func home(gebweb.Locale locale): dict<string, any> {
        return {"tag": locale.tag, "language": locale.language};
    }
}
Field Type Description
tag string Canonical BCP-47 tag ("en-GB", "de").
language string Primary language subtag ("en").
region string Region subtag ("GB") or "".
zone string IANA time zone name when supplied via opts.defaultZone.

Translation catalogs

Catalogs live under the directory passed as opts.messages. One YAML file per locale tag (messages/en.yaml, messages/de-AT.yaml). Nested keys flatten to dotted strings:

# messages/en.yaml
auth:
  login:
    title: "Sign in"
    button: "Log in"
home:
  greeting: "Hello, {name}!"

Look up by the flat key. Placeholders use {name} syntax and are substituted from args:

class HomeController {
    @Get("/")
    func home(gebweb.Locale locale, gebweb.Translator tr): dict<string, any> {
        return {"greeting": tr.translate(locale.tag, "home.greeting", {"name": "Ada"})};
    }
}

gebweb.Translator is auto-injected once a catalog is loaded. The top-level surface is gebweb.t(app, locale, key, args) for code holding the app reference; views see t(key, args) automatically bound to the request locale.

Missing-key behaviour falls back from the requested locale to the default locale to the raw key itself, so untranslated strings remain visible without breaking the page.

Pluralisation

A catalog entry whose value is a sub-dict with CLDR variant keys becomes plural-aware:

# messages/en.yaml
cart:
  items:
    one: "1 item"
    other: "{count} items"

# messages/ru.yaml
cart:
  items:
    one: "{count} товар"
    few: "{count} товара"
    many: "{count} товаров"

Pass a count arg to translate:

tr.translate("ru", "cart.items", {"count": 3});
tr.translate("ru", "cart.items", {"count": 5});

The CLDR rule for the resolved language picks the variant (one/other/few/many/zero/two). Hand-coded rules ship for en, de, fr, es, it, pt, ru, uk, pl, ar, zh, ja, ko, nl, tr; other languages fall back to the English rule.

The helper gebweb.i18n.pluralForm(language, count) returns the variant name directly when you want to drive other catalogs.

Validation messages

Translation flows into the @Assert.* pipeline automatically. When a Translator is bound to the request, validation failures look up validation.<code> in the catalog with placeholders filled from the failure's params:

# messages/de.yaml
validation:
  minLength: "Feld {field} muss mindestens {min} Zeichen lang sein"
  email: "Feld {field} muss eine gültige E-Mail-Adresse sein"

A POST /signup to a German request will return the localised messages in the 422 Problem Details body. Codes without a catalog entry preserve the raw English message.

The set of codes is: email, url, uuid, regex, minLength, maxLength, range, in, notBlank (plus any custom assertions registered via gebweb.registerAssertion). Each rule supplies the relevant params; minLength supplies min, range supplies min and max, regex supplies pattern.

Number and date formatting

Import the Gebweb module by its qualified name. Bare import i18n; loads Geblang's standard-library message catalog module instead.

import gebweb.i18n as i18n;

i18n.formatNumber(1234567, "de");
i18n.formatNumber("1234.56", "fr");
let parts = {"year": 2026, "month": 6, "day": 3};
i18n.formatDate(parts, "en-US");
i18n.formatDate(parts, "en", "long");
i18n.formatDate(parts, "de", "long");

i18n.numberSeparators(locale) returns the {"thousands": ..., "decimal": ...} pair for callers that need to format values in a custom way.

Reference

Helper Purpose
gebweb.useI18n(app, opts) Mount the negotiator and optionally load catalogs.
gebweb.Locale Typed parameter; injected from request["_locale"].
gebweb.Translator Typed parameter; injected from request["_translator"].
gebweb.parseLocale(tag) Parse a BCP-47 tag; null on malformed input.
gebweb.t(app, locale, key, args) Top-level translate.
gebweb.i18n.loadCatalogs(dir) Read every <dir>/<tag>.yaml.
gebweb.i18n.pluralForm(language, count) CLDR variant name.
gebweb.i18n.formatNumber(value, locale) Apply locale separators.
gebweb.i18n.formatDate(parts, locale, style) "short" or "long" style.