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 four 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, or a locale resolver — see below).
  3. Bridges the translator into r.Context() for framework/ui. A
    second middleware stashes the Translator via
    i18nui.WithTranslator, so every framework/ui component that
    reads r.Context() resolves its labels through the catalog
    automatically — no manual WithTranslator call is needed.
  4. 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.

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.

<!-- gofastr:compile
import "github.com/DonaldMurillo/gofastr/core/i18n"
import "context"
var ctx = context.Background()
-->

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).

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-LocaleAccept-Language → fallback.

A resolver value only wins when it matches a catalog locale (with the
same fr-CAfr 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:

4 lines
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"
-->

9 lines
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 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

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.

6 lines
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 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.
  • 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 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. 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().