Skip to main content
Version: 1.24.0

Internationalization

Nucleus i18n has two halves that share one file layout:

  • Tooling — the CLI pair. nucleus makemessages extracts translatable strings from your source into .po catalogs; nucleus compilemessages compiles them into JSON bundles.
  • Runtimepkg/i18n (experimental). At startup, app.New loads any compiled bundles found under locales_path and, when at least one locale exists, mounts an Accept-Language negotiation middleware. Handlers then translate with c.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:

  1. the negotiated locale (es-MX),
  2. its base language (es),
  3. default_locale,
  4. 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 compilemessages to 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.