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 four 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, or a locale resolver — see below). - Bridges the translator into
r.Context()forframework/ui. A
second middleware stashes the Translator via
i18nui.WithTranslator, so everyframework/uicomponent that
readsr.Context()resolves its labels through the catalog
automatically — no manualWithTranslatorcall is needed. - 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) (plus the translator bridge) 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.
<!-- gofastr:compile
import "github.com/DonaldMurillo/gofastr/core/i18n"
import "context"
var ctx = context.Background()
-->
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).
Per-user locale (cookie) switching
Middleware and Negotiate accept optional resolvers that run
before the headers, so a stored per-user locale can win over the
browser's Accept-Language. Resolution order is: locale resolver(s) →
X-Locale → Accept-Language → fallback.
A resolver value only wins when it matches a catalog locale (with the
same fr-CA → fr fallback the headers use); an unknown, malformed,
or over-long value falls through to the next source. Resolver values
are attacker-controlled (cookies), so they are length- and
character-bounded before matching.
Use i18n.CookieLocale(name) for the common cookie case, and wire it
through framework.WithLocaleResolver on the App:
app := framework.NewApp( framework.WithI18n(tr), framework.WithLocaleResolver(i18n.CookieLocale("locale")),)
A "change language" handler just sets the cookie and redirects:
<!-- gofastr:compile
import "github.com/DonaldMurillo/gofastr/framework"
var app = framework.NewApp()
import "net/http"
-->
app.Router().PostFunc("/locale", func(w http.ResponseWriter, r *http.Request) { lang := r.FormValue("lang") // validate against your catalog http.SetCookie(w, &http.Cookie{ Name: "locale", Value: lang, Path: "/", }) http.Redirect(w, r, r.Referer(), http.StatusSeeOther)})
WithLocaleResolver panics if used without WithI18n — locale
resolution is meaningless without a translator to resolve against.
What's NOT in this package
- ICU-grade number/date/currency formatting. Use stdlib (
time,
strconv) or wrap a third-party formatter. The part that matters
most here — locale-aware lookups + pluralisation — is already
covered; add formatting on top yourself. - 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
Every framework/ui component that emits user-visible copy resolves its
labels through i18nui.T(ctx, key) (or i18nui.TVars for composed
labels like "Sort by {column}" or "Step {step} of {total}"). The
ctx comes from the config's Ctx field; once WithI18n is set, the
bridge middleware stashes the translator on r.Context() for you, so a
component resolves in the caller's locale with no manual wiring.
The Ctx field pattern
Components that take a config struct expose a Ctx context.Context
field. Pass the request context and the component resolves its labels
through the catalog; pass nothing (or nil) and context.Background()
is used, returning the built-in English fallbacks. An explicit label
field on the config (SubmitLabel, Placeholder, ApplyLabel, …)
always wins over the i18n default — set it to override per call
site.
h := ui.DataTable(ui.DataTableConfig{ Columns: cols, Rows: rows, Ctx: r.Context(), // ← locale + translator from the bridge // ... other fields})
No existing call site needs to change: Ctx defaults to nil and the
English fallbacks match the pre-i18n output exactly.
The ui.* key namespace
Component labels live under the ui.* namespace (e.g.
ui.pagination.next, ui.table.sortBy, ui.form.save). The built-in
English fallbacks are in framework/i18nui.Defaults. Your app
catalog can override any ui.* key — add the key to your locale's
JSON and it wins for that locale; keys you don't override keep the
English default. i18nui.T/TVars fall back to the default on a
catalog miss, so a catalog without any ui.* entries renders exactly
as before.
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, and the
framework/ui component set now resolves its labels through the
catalog too (see above). A "French" deployment shows French strings
wherever the app calls T and on every framework/ui component that
receives r.Context() via Ctx. The remaining places that still show
English are deeper in the stack and need follow-up integration work:
- 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. 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 rest of the framework". That pass adds
LabelKey / MessageKey style hooks to the relevant configs and
shifts validators to return codes; the framework/ui label coverage
landed here is the template for it.
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().