Skip to content
Zever
Home

Internationalization

Translate user-facing strings with an embedded catalog locally and a remote catalog in production.

2 min readEdit on GitHub

i18n resolves user-facing strings per locale. Development embeds the catalog in the binary, and production can pull from a remote service through the same interface. Use it for every visible string (confirmations, emails, error explanations), and never concatenate translated fragments: translators need full sentences with placeholders.

i18n:
adapter: embed # default: compiled-in catalog
options:
embed:
dir: locales
fallback: en

Switch to remote for a shared catalog service: it takes endpoint, api_key, timeout, max_in_flight, allow_insecure, and allow_private (see the Config reference). Outbound requests dial only globally routable addresses and never follow redirects; a local catalog sidecar needs allow_private: true, which logs a line at startup. DefaultTimeout is 30s for remote fetches. The embed filesystem cannot come from YAML, so set Embed.FS in code as shown below; without it the embed adapter fails with embed fs is required.

Catalogs are JSON per locale. A message is plain text or a Go text/template string with {{.name}} placeholders:

locales/en.json
{"welcome": "Welcome back!", "hello": "Hello, {{.name}}!"}
locales/fr.json
{"hello": "Bonjour, {{.name}}!"}
main.go
import (
"embed"
i18nembed "github.com/zenta-dev/zever/adapters/i18n/embed"
"github.com/zenta-dev/zever/config"
"github.com/zenta-dev/zever/container"
"github.com/zenta-dev/zever/core/i18n"
)
//go:embed locales/*.json
var localesFS embed.FS
i18nembed.Register()
cfg := config.Default()
cfg.I18n.Options.Embed = i18n.EmbedOptions{FS: localesFS, Dir: "locales", Fallback: "en"}
c := container.New(cfg)
t, _ := c.I18n()
msg, _ := t.Translate(ctx, "fr", "hello", map[string]string{"name": "Ada"})
fmt.Println(msg)
missing, _ := t.Translate(ctx, "fr", "welcome", nil)
fmt.Println(missing)
Bonjour, Ada!
Welcome back!

A key missing in fr falls back to en; a key missing everywhere returns i18n.KeyNotFoundError, never an empty string. Templates use missingkey=error, so a missing argument fails the lookup instead of rendering silently. I18n() resolves lazily and caches, and c.Close(ctx) shuts it down.

Resolve in this order: user preference, then Accept-Language, then the configured default. Keep it in one helper or middleware so every handler reads the same value.

func localeOf(r *http.Request, user *User) string {
if user != nil && user.Locale != "" {
return user.Locale
}
if h := r.Header.Get("Accept-Language"); h != "" {
return parsePrimary(h) // "fr-CA,fr;q=0.9" -> "fr-CA"
}
return "en"
}

The lookup chain for fr-CA is fr-CA, then fr, then the fallback; i18n.BaseLocale and i18n.LocaleChain expose both steps. Pass structured args (map[string]string), not pre-formatted strings, because word order differs across languages.

The interface has Translate(ctx, locale, key, args) and Locales(ctx); i18n.Open(adapter, opts) and i18n.Register(adapter, factory) open or extend an adapter without the container. EmbedOptions{FS, Dir, Fallback} configures the compiled-in catalog and RemoteOptions{Endpoint, APIKey, Timeout, MaxInFlight, AllowInsecure} the shared service; Options.Validate() rejects bad settings at startup. Treat catalogs as data: version them with the schema, diff them in review, and deploy them beside the binary (embed) or behind the remote endpoint.

Assert fallbacks, not specific copy, since translators change text without code review. Test with embed: no network, no timing assertions. Call Translate with a missing key and check you get the fallback locale’s text.

  • Always set fallback to your source locale so a missing translation degrades to readable text.
  • Pass r.Context() so slow remote catalogs cannot hang requests, and cache hot keys in front of remote.
  • Option maps can carry endpoint credentials. Display config only through config.RedactedServices.
Blank strings in one locale

No fallback is configured, or your helper returns empty on a miss. Set fallback and test the chain.

The wrong language is served

Two resolution points disagree. Centralize localeOf and log the resolved locale at debug level.

The remote catalog hangs requests

The fetch ignores the request context, or no timeout is set. Pass r.Context() to Translate and keep the timeout bounded.