Internationalization

core/i18n is GoFastr's small i18n primitive: locale negotiation from
Accept-Language, JSON-backed message catalogs with {{placeholder}}
interpolation, and CLDR-style plural categories with English defaults
plus a hook for per-locale custom rules.

The goal is to make "translate this string for this caller's locale"
trivial without pulling in the full ICU stack. Number / date /
currency formatting are explicitly out of scope here — use stdlib
time / strconv, or wire your own formatter on top.

Wiring

9 lines
import "github.com/DonaldMurillo/gofastr/core/i18n"// Load translations from disk (an embed.FS works the same way).cat, err := i18n.LoadJSONCatalog(os.DirFS("locales"), ".")if err != nil { /* ... */ }tr := i18n.NewTranslator(cat, "en") // "en" = fallback localeapp := framework.NewApp(framework.WithI18n(tr))

framework.WithI18n does three things:

  1. Records the Translator on the App.
  2. Wires i18n.Middleware(tr) into the default chain so every request
    gets a Locale in r.Context() from Accept-Language (or the
    X-Locale override header).
  3. Installs the Translator as i18n.Default() so the package-level
    i18n.T(ctx, key, params...) helper works from anywhere.

Pair with WithoutDefaultMiddleware and the framework panics — mount
i18n.Middleware(tr) explicitly in your custom chain instead.

Catalog format

Files are named <locale>.json (e.g. en.json, fr.json,
fr-CA.json). Keys nest freely; nested objects are flattened with
. separators unless every key is a CLDR plural category, in which
case the bucket becomes a plural message.

10 lines
{  "welcome": "Hello, {{name}}!",  "cart": {    "empty": "Your cart is empty",    "items": {      "one": "1 item in cart",      "other": "{{count}} items in cart"    }  }}

After loading:

KeyForm
welcomeText
cart.emptyText
cart.itemsPlural (one / other)

Plural categories recognised: zero, one, two, few, many,
other. Catalogs may also be built in code via
i18n.NewMapCatalog() for tests or embedded strings.

Using a translation

4 lines
ctx := r.Context()                     // locale already attached by middlewarename := "Alice"msg := app.T(ctx, "welcome", map[string]any{"name": name})// "Hello, Alice!" in en, "Bonjour, Alice !" in fr, ...

Or via the package-level helper:

1 lines
msg := i18n.T(ctx, "welcome", map[string]any{"name": name})

Both consult the same Translator.

Placeholders

{{name}} is replaced with the matching params value (stringified by
fmt). Unknown placeholders are left intact — easier to spot
during development than silently empty.

Plurals

Pass a numeric count (or n) in params; the Translator picks the
category for the request locale and interpolates.

2 lines
i18n.T(ctx, "cart.items", map[string]any{"count": 3})// "3 items in cart" (en, "other")

English's rule is built in. Register more:

11 lines
tr.RegisterPluralRule("ru", func(n int) string {    mod10, mod100 := n%10, n%100    switch {    case mod10 == 1 && mod100 != 11:        return "one"    case mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14):        return "few"    default:        return "many"    }})

Locale negotiation

By default the middleware uses Accept-Language and picks the highest
q-value entry that matches a locale in the catalog (with progressive
language-base fallback: fr-CAfr). When nothing matches, the
Translator's fallback locale wins.

A request header X-Locale: ja short-circuits negotiation — useful
for tests, A/B switches, and apps that prefer locale routing via path
or query.

You can also call i18n.Negotiate(tr, r) directly when wiring custom
middleware (e.g. when you need to write the locale to a cookie).

What's NOT in this package

  • ICU-grade number/date/currency formatting. Use stdlib (time,
    strconv) or wrap a third-party formatter. The primitive that
    matters most here — locale-aware lookups + pluralisation — is
    already covered; bolt formatting on top.
  • Pre-bundled CLDR plural rules for every language. Only English
    is built in; register more per locale as the app picks them up.
  • Locale routing via path prefix (/en/..., /fr/...). Roll your
    own router prefix handling and set X-Locale on the inner request,
    or call i18n.WithContext(ctx, locale) before next.ServeHTTP.
  • Pluralisation for floats. Pass an integer count — CLDR
    plural rules are integer-valued.

Translating framework/ui component labels

Several framework/ui components expose a Ctx context.Context field
in their config structs. When set, the component uses that context to
resolve its built-in labels through i18nui.T(ctx, key) — picking up
whatever locale and translator WithI18n attached to the request context.

The affected components and their localizable labels:

Config typeLocalizable labels
RepeaterConfig.CtxAdd button, Remove button
PasswordInputConfig.CtxShow-password toggle aria-label
StepWizardConfig.CtxBack, Continue, Submit buttons
LightboxConfig.CtxPrev/Next nav aria-labels, Download aria-label

Pass the request's r.Context() in your handler:

5 lines
h := ui.Repeater(ui.RepeaterConfig{    Name:    "links",    Ctx:     r.Context(),   // ← locale resolved from Accept-Language    // ... other fields})

When Ctx is nil (or omitted), context.Background() is used and
English fallbacks are returned — preserving the existing behaviour for
callers that have not yet adopted this field. No existing call site
needs to change.

What this primitive does NOT translate (yet)

WithI18n gives your handler code the capability — app.T(ctx, key) works the moment middleware attaches a locale. Most of the
framework's own surfaces, however, still emit hardcoded English. A
"French" deployment today shows French strings only where the app calls
T itself (or passes Ctx as shown above); everything else stays
English.

Concretely, the following are still hardcoded and need follow-up
integration work before they participate in locale negotiation:

  • Entity field labelsentity.EntityConfig has no LabelKey
    hook; CRUD / generated forms surface field names raw.
  • Validator error messagesframework/entity validators
    return English strings ("required", "too long", ...) instead
    of error codes the rendering layer could translate.
  • Most framework/ui default copy — Pagination "Next" / "Previous",
    ValidationSummary headings, EmptyState placeholders, Banner /
    Toast / Modal defaults. Only Repeater, PasswordInput, StepWizard,
    and Lightbox support Ctx today; the full audit is Tier-2.5.
  • framework/crud error response bodies — 400 / 422 / 5xx
    JSON bodies carry English message fields.
  • battery/admin page chrome — "Overview", "Queue", "Audit
    log", filter chip labels are hardcoded.
  • OpenAPI spec descriptions and llm.md auto-generated docs
    these mirror entity/field names verbatim and have no translation
    hook today.

These are tracked as a follow-up integration pass — call it Tier 2.5
or "wire i18n through the framework's own surfaces". The full pass
adds LabelKey / MessageKey style hooks to the relevant configs,
shifts validators to return codes, and replaces literal strings in
all remaining framework/ui defaults with i18n.T calls behind
English fallbacks. Tier-2.5 remains deferred.
Until that lands, the framework is bilingual only on the surfaces
your app builds, plus the four components that accept Ctx above.

Common mistakes

  • Don't forget the middleware. Without it r.Context() has no
    Locale, every request falls back to the fallback locale, and
    per-user translations don't happen. WithI18n wires it
    automatically; if you opt out of defaults, you must mount it
    yourself.
  • Don't include user input in keys. Keys are looked up verbatim —
    passing user input opens up a "no such key returns the bare key"
    reflection vector. Always look up a fixed key and pass values as
    params.
  • Don't switch the default Translator at runtime. SetDefault
    is for startup wiring; swapping it mid-flight is a race against
    any goroutine that's already cached Default().