Internationalization
Nucleus i18n has two halves that share one file layout:
- Tooling — the CLI pair.
nucleus makemessagesextracts translatable strings from your source into.pocatalogs;nucleus compilemessagescompiles them into JSON bundles. - Runtime —
pkg/i18n(experimental). At startup,app.Newloads any compiled bundles found underlocales_pathand, when at least one locale exists, mounts anAccept-Languagenegotiation middleware. Handlers then translate withc.T(...).
If you have no catalogs, nothing changes: the middleware is not mounted and
c.T("key") returns the key.
Workflow
# 1. Extract strings (wrap them in T("..."), or {{t "..."}} in templates)
nucleus makemessages --locale es
# 2. Translate locales/es/LC_MESSAGES/messages.po by hand or with your TMS
# 3. Compile to the JSON bundles the runtime loads
nucleus compilemessages
This produces the gettext-style layout both halves agree on:
locales/
es/
LC_MESSAGES/
messages.po # source catalog (edited by translators)
messages.json # compiled bundle (loaded at startup)
The two config keys involved:
default_locale: en # fallback when negotiation matches nothing
locales_path: locales/ # where catalogs live
Translating in handlers
a.Router.Get("/greet", func(c *router.Context) error {
return c.JSON(http.StatusOK, map[string]string{
"msg": c.T("greeting"),
})
})
c.T resolves the key for the locale negotiated from the request's
Accept-Language header. The fallback chain is deterministic:
- the negotiated locale (
es-MX), - its base language (
es), default_locale,- the key itself.
Extra arguments are formatted fmt-style: c.T("%d items", n).
The middleware also sets the Content-Language response header to the
resolved locale.
Locale negotiation
Accept-Language is parsed with RFC 9110 q-values. For each acceptable tag
in quality order the runtime tries an exact catalog match, then the tag's
base language (es-MX falls back to a plain es catalog), then any catalog
locale sharing that base (es can resolve to an es-ES-only catalog).
* and a header that matches nothing both resolve to default_locale.
Comparisons fold case and the _/- separator, so a es_MX catalog
directory matches an es-MX header.
Outside a request
Task handlers, mail rendering, or any code path without a router.Context
can use the context helpers from pkg/i18n directly:
import "github.com/jcsvwinston/nucleus/pkg/i18n"
// a.I18n is non-nil whenever catalogs were loaded at startup.
ctx = i18n.WithTranslator(ctx, a.I18n)
ctx = i18n.WithLocale(ctx, "es")
msg := i18n.T(ctx, "greeting")
Inside a request the middleware has already injected both values, so
i18n.T(r.Context(), "greeting") works as-is.
Failure behaviour
- A missing
locales_path, or one with no compiled bundles, is not an error — the app simply boots without i18n. - A compiled bundle that exists but does not parse fails startup. A
corrupt catalog should surface at boot, not as untranslated strings in
production. Re-run
nucleus compilemessagesto regenerate it. - Keys missing from every catalog resolve to the key itself, never to an error.
Current limits
pkg/i18n is experimental. It resolves flat message keys with fmt-style
arguments; plural forms and per-domain lookup are not implemented yet. The
.po pipeline stores plain msgid/msgstr pairs.