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
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:
- Records the Translator on the App.
- Wires
i18n.Middleware(tr)into the default chain so every request
gets aLocaleinr.Context()fromAccept-Language(or the
X-Localeoverride header). - 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.
{ "welcome": "Hello, {{name}}!", "cart": { "empty": "Your cart is empty", "items": { "one": "1 item in cart", "other": "{{count}} items in cart" } }}
After loading:
| Key | Form |
|---|---|
welcome | Text |
cart.empty | Text |
cart.items | Plural (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
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:
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.
i18n.T(ctx, "cart.items", map[string]any{"count": 3})// "3 items in cart" (en, "other")
English's rule is built in. Register more:
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-CA → fr). 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 setX-Localeon the inner request,
or calli18n.WithContext(ctx, locale)beforenext.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 type | Localizable labels |
|---|---|
RepeaterConfig.Ctx | Add button, Remove button |
PasswordInputConfig.Ctx | Show-password toggle aria-label |
StepWizardConfig.Ctx | Back, Continue, Submit buttons |
LightboxConfig.Ctx | Prev/Next nav aria-labels, Download aria-label |
Pass the request's r.Context() in your handler:
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 labels —
entity.EntityConfighas noLabelKey
hook; CRUD / generated forms surface field names raw. - Validator error messages —
framework/entityvalidators
return English strings ("required","too long", ...) instead
of error codes the rendering layer could translate. - Most
framework/uidefault copy — Pagination "Next" / "Previous",
ValidationSummary headings, EmptyState placeholders, Banner /
Toast / Modal defaults. Only Repeater, PasswordInput, StepWizard,
and Lightbox supportCtxtoday; the full audit is Tier-2.5. framework/cruderror response bodies — 400 / 422 / 5xx
JSON bodies carry Englishmessagefields.battery/adminpage chrome — "Overview", "Queue", "Audit
log", filter chip labels are hardcoded.- OpenAPI spec descriptions and
llm.mdauto-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.WithI18nwires 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 cachedDefault().