Reference

i18n (translations)

Request-locale resolution, a Mongo-backed message catalog with a locale fallback chain, admin CRUD, and FlowDSL nodes for translating inside flows.

i18n — translation and localization

github.com/redelay/go-modules/i18n gives a Redelay app three things: it resolves the active locale for every request, it serves a public key→value message catalog, and it exposes a Service that other modules use to translate strings with a locale fallback chain.

shell
go get github.com/redelay/go-modules/i18n
go
import (
    _ "github.com/redelay/go-modules/i18n"          // module + locale middleware + GET /i18n/messages
    _ "github.com/redelay/go-modules/i18n/flowdsl"  // FlowDSL nodes: i18n/translate, i18n/resolve-locale
)

The admin submodule at i18n/admin mounts translation CRUD under the admin prefix — import it from cmd/admin-api only.

Default locale is Polish (pl) with an English (en) fallback, inherited from the AsyncShop / go.shop.pl deployment that first used the module. Override per deployment.

Concepts

TermMeaning
MessageOne translated string, uniquely keyed by (namespace, key, locale).
NamespaceGroups related keys — ui, checkout, emails. The empty namespace is the default bucket.
Locale chainThe ordered fallback list used for every lookup.
CatalogThe merged key→value map for one (namespace, locale), fallback chain applied.

The locale chain

Every lookup walks a de-duplicated chain, most specific first:

text
requested → its base → fallback → default
   pl-PL   →   pl     →    en    →   pl

The base is derived by splitting on - or _, so pl-PL falls back to pl before reaching the configured fallback. This means a partially-translated regional variant degrades to its language, then to English, rather than to raw keys.

TranslateLocale returns the key itself when nothing in the chain matches. A missing translation renders as checkout.submit, never as an empty string — the failure is visible in the UI instead of silently blanking it.

Request-locale resolution

The module registers middleware on every request. Resolution order:

  1. ?lang= query parameter
  2. lang cookie
  3. Accept-Language header — first tag only, quality markers stripped (pl-PL,pl;q=0.9 → pl-PL)
  4. I18N_DEFAULT_LOCALE

The result lands in the request context. Other modules read it without importing any HTTP plumbing:

go
import "github.com/redelay/go-modules/i18n"

locale := i18n.LocaleFromContext(ctx)  // "" if the middleware did not run

This is how catalog, content, and onepage modules project localized entity fields for the active request.

i18n itself accepts any locale string. A module that needs to constrain the set — the AsyncShop channels module restricts to a channel's SupportedLocales — wraps this middleware rather than modifying it.

Interpolation

Values may carry Go-template placeholders, filled from the args map:

text
greeting.hello = "Cześć {{.Name}}!"
go
svc.Translate(ctx, "ui", "greeting.hello", map[string]any{"Name": "Ada"})
// → "Cześć Ada!"

Templates use missingkey=zero. On any parse or execution error the raw string is returned unchanged — a malformed template degrades to visible source text rather than erroring the request. Strings without {{ skip the template engine entirely.

Configuration

KeyDefaultPurpose
I18N_DEFAULT_LOCALEplLocale when none resolves from the request
I18N_FALLBACK_LOCALEenFills gaps before falling back to the default
I18N_COLLECTIONtranslationsMongoDB collection for messages
I18N_AUTOREGISTERtrueEnables the public POST /i18n/report-keys write-once endpoint. Set false to lock the catalog in production.
I18N_SOURCE_LOCALE(fallback locale)Language the frontend's inline default labels are written in — the locale new keys are stored under and the from language for bulk auto-translate.

No database → module disabled. The factory logs i18n: no database — module disabled and returns a Module with a nil Service. Handlers then answer 503 i18n not configured rather than panicking, so an app can boot without Mongo.

HTTP surface

Public

MethodPathPurpose
GET/i18n/messagesMessage catalog for a locale + namespace
POST/i18n/report-keysAuto-register UI-label keys a frontend uses (text write-once; optional context note upserts)

Query params: locale (defaults to the resolved request locale, then the configured default) and ns (defaults to empty).

shell
curl '/api/v1/i18n/messages?locale=pl&ns=ui'
json
{
  "locale": "pl",
  "namespace": "ui",
  "messages": { "checkout.submit": "Zamów", "cart.empty": "Koszyk jest pusty" }
}

The response is the merged catalog — the chain is walked least-specific first so the requested locale overwrites fallback entries. A storefront fetches this once per locale and renders UI copy from it.

Admin (i18n/admin)

Mounted under /admin/i18n, gated by auth.RequireActiveUser + auth.RequirePermission("admin:access").

MethodPathPurpose
GET/admin/i18n/messagesList messages, optional ns / locale filters
PUT/admin/i18n/messagesCreate or update one message
DELETE/admin/i18n/messagesDelete one message (key + locale required)
GET/admin/i18n/localesList locales (a project's supported languages)
POST/PUT/DELETE/admin/i18n/locales[/{id}]Manage the locale registry
GET/admin/i18n/translationsList translation keys (each key with every locale)
POST/admin/i18n/translations/translate-missingBulk auto-translate keys into every active locale via the ai module
GET/POST/admin/i18n/translations/pending, …/pending/approve, …/pending/reject, …/reviewReview gate queue — see the review gate

The admin surface backs the Languages module in the @redelay/js-admin base admin (Locales manager + Translations catalog with a per-key AI-translate editor).

Service API

Obtain it from the module via Translations(), or discover the module in Configure():

go
func (m *Module) Configure(registry *modules.Registry) error {
    if mod := registry.Get("i18n"); mod != nil {
        if i, ok := mod.(*i18n.Module); ok { m.i18n = i }
    }
    return nil
}
MethodPurpose
Translate(ctx, ns, key, args)Translate using the context locale
TranslateLocale(ctx, ns, locale, key, args)Translate in an explicit locale
Messages(ctx, ns, locale)Merged catalog for a namespace + locale
Upsert(ctx, ns, key, locale, value)Write + invalidate the affected cache bucket
Delete(ctx, ns, key, locale)Delete + invalidate
InvalidateAll()Drop the whole cache

Caching

The Service caches per (namespace, locale) bucket behind an RWMutex, loading on miss. Writes through Upsert/Delete invalidate only the affected bucket.

The cache is per-process. A write on one instance does not invalidate peers. In a multi-instance deployment, broadcast a change event and have each instance call InvalidateAll() — that is what the method exists for.

Storage

One Mongo collection, one unique index:

text
i18n_ns_key_locale  →  (namespace, key, locale)  unique

Created idempotently in Startup. Upsert is a Mongo upsert against that triple, setting updated_at on write and created_at only on insert.

FlowDSL nodes (i18n/flowdsl)

NodeKindOutputsPurpose
i18n/translatetransformTranslated, ErrorResolve a key to a localized value (chain + interpolation)
i18n/resolve-localetransformResolvedReturn the active locale from context or input
i18n/fill-translationsaction—Fill missing / stale translations — see below

Both nodes are configured by edge data, not by node settings — neither declares a settings_schema. Supply their inputs with an edge transform:

NodeInput fieldsOutput payload
i18n/translatekey (required), ns, locale, args (map){value, locale, key}
i18n/resolve-localelocale (fallback only){locale}

locale is optional on both — when absent, each falls back to i18n.LocaleFromContext(ctx). i18n/translate emits on the Error port when key is missing; a key that simply has no translation is not an error and returns the key itself on Translated.

Localizing a notification subject before sending:

yaml
nodes:
  - id: subject
    nodeType: i18n/translate
  - id: send
    nodeType: redelay/notifications-send

edges:
  - from: on_order_shipped
    to:   subject
    transform:
      ns:     "emails"
      key:    "order.shipped.subject"
      locale: "{{.payload.customer_locale}}"
      args:
        Order: "{{.payload.order_number}}"
  - from: subject
    to:   send
    when: "output.name == 'Translated'"
    transform:
      user_id: "{{.payload.customer_id}}"
      title:   "{{.value}}"
Because neither node declares a settings_schema, Studio renders them with an empty config panel — all wiring happens on the edges. Worth knowing before you go looking for node settings that aren't there.

Frontend UI-label i18n (customer apps)

The catalog also drives a customer app's own UI chrome (buttons, headings, placeholders, toasts) — not just backend-localized content. The pattern is provider-agnostic and reusable across every Nuxt customer app on the framework; the starter-kit skeleton ships it under app/plugins/i18n.ts + app/composables/useT.ts + scripts/i18n-extract.mjs.

1. A $t(key, default) helper. A Nuxt plugin loads the catalog for the active lang cookie from GET /i18n/messages?locale=<lang> server-side (so labels render translated on first paint, then hydrate), and provides a global $t:

vue
<h1>{{ $t('home.hero.title', 'Track Your Fitness Journey') }}</h1>

$t returns the catalog value, else the inline English default, else the key. The default is the source-of-truth text — no separate en.json file. Keep interpolated variables in the template, outside $t:

vue
{{ $t('dashboard.greeting', 'Welcome back') }}, {{ user.name }}

Switching language flips the lang cookie and reloads, so the plugin re-runs with the new catalog. The Nitro /api/v1 proxy forwards the cookie as ?lang + Accept-Language, so backend-localized content follows the same switch.

2. Auto-detection of new keys. In dev, any key the app renders that is not in the catalog is batched and POSTed to POST /i18n/report-keys (write-once — never overwrites a curated key), so new labels appear in the admin Translations catalog as the app is used. The write path is gated by I18N_AUTOREGISTER and is off in production; there, a build-time extractor seeds keys deterministically:

shell
npm run i18n:seed        # scan $t('key','default') sites → POST /i18n/report-keys

3. Auto-translation. Once keys exist (in English), an admin bulk-fills every active locale from one action:

shell
curl -X POST /api/v1/admin/i18n/translations/translate-missing \
  -H 'Authorization: Bearer <admin>' -d '{}'
# → {"keysProcessed":40,"localesFilled":960,...}  (re-run until keysProcessed is 0)

It translates each key's source text into the locales it's missing via the ai module (Ollama/OpenAI/…), and is idempotent — already-filled locales are skipped. The admin Translations catalog also offers per-key AI translation inline.

The end-to-end loop: write $t('key','English') → key auto-registers → admin translate-missing → every locale filled → users see translated UI. Adding a new language is: create the locale in the admin, run translate-missing, done.

Grammar & context — don't translate bare words

Translating an isolated word loses grammatical context. "Days" alone is nominative (Дни), but after a number Russian needs the genitive (5 дней). Two complementary fixes:

Whole-phrase keys with placeholders (preferred). Put the variable inside the string so the model translates the whole phrase and gets agreement right. $t interpolates {token} params and the translate prompt preserves them:

vue
<!-- ✗ splits the number from the word — "Days" is translated in isolation -->
{{ streak }} {{ $t('dashboard.days', 'Days') }}
<!-- ✓ one phrase — model sees the count, returns "{count} дней" -->
{{ $t('dashboard.streakDays', '{count} Days', { count: streak }) }}

ICU plural for full CLDR pluralization. For languages with several plural forms (Russian 1 день / 2 дня / 5 дней, Polish, Arabic's six forms), write an ICU MessageFormat plural in the source; $t selects the branch with the browser-native Intl.PluralRules (full CLDR categories per locale — no dependency), replacing # with the count:

vue
{{ $t('dashboard.streakDays', '{count, plural, one {# Day} other {# Days}}', { count: streak }) }}

The AI translate prompt recognises the ICU plural and emits the target language's own categories, e.g. Russian {count, plural, one {# день} few {# дня} many {# дней} other {# дней}} — so Intl.PluralRules('ru').select(21) → 'one' renders 21 день correctly. =N exact cases are supported (=0 {No days}). A locale whose value is plain text (no plural block) degrades to plain {token}/# substitution.

Per-key context hint. When a whole phrase isn't possible, each translation key carries an optional context field (Translation.Context) injected into the AI prompt. Set it in the admin editor — "appears as '{count} Days', a count" — and hit the row's Re-translate button (re-runs translate-missing for that key with overwrite:true). The ai/translate contract takes the same context field, and the prompt instructs the model to honour it for agreement, gender, and pluralisation.

How to declare translation context

Two places carry a context hint — one for admin data fields, one for UI-label keys. Both feed the same AI prompt.

Admin localized data fields. Declare context per field in the module's ResourceConfig form to disambiguate the AI Translate button on @redelay/js-admin's LocalizedField:

ts
// In a module's ResourceConfig form field (any module using LocalizedField):
{ key: 'name', type: 'custom', component: 'LocalizedFieldAuto',
  props: {
    label: 'muscle group name',   // used as fallback context if `context` is unset
    context: 'A human-body muscle group name (e.g. Back, Chest) — the body part, not a direction/verb.'
  } }

The context prop threads LocalizedField → AiFieldActions → useAiContent.translate → POST /admin/ai/translate (as the request body's context field), falling back to the field label when context is unset. Without it the muscle group "Back" translates to Zurück / Atrás / Назад (the direction); with it, Rücken / Espalda / Спина (the body part).

Per-key catalog context (UI-label keys). For $t keys, set the Translation context (AI hint) field on a key in the admin Translations editor (or POST /admin/i18n/translations with a context field), then hit the row's Re-translate button. The hint persists on Translation.Context and is injected into the prompt by both translate-missing and per-row re-translate.

Frontend-owned context (recommended for $t keys). Rather than curating the hint per key in the admin, a customer app can own it in source. Add i18n-contexts.json next to the extractor — a map of a key (or a trailing-* prefix glob) to a free-form note; the most specific match wins:

jsonc
// nuxt/i18n-contexts.json
{
  "tracking.set*":         "Gym term: a SET is one group of reps. Not a kit/collection, not the verb 'to set'. Sample — EN: \"Set {n}\"; RU: \"Сет {n}\".",
  "dashboard.metric_volume": "Training VOLUME = total weight lifted across all sets (tonnage). Not sound/geometric volume. Sample — EN: \"Volume\"; RU: \"Общий вес\"."
}

Include sample EN + target-language translations in the note for ambiguous terms — the model few-shots on them and renders the right domain sense in every locale (fixes Set→kit, Resume→CV, Volume→loudness, Log→timber/journal). The extractor resolves each key's note and sends it as context on POST /i18n/report-keys, which — unlike the write-once text — upserts the note on new and existing keys (ReportKeysResponse.contextUpdated counts the syncs). Re-run the extractor after editing the file, then translate-missing (with overwrite:true to replace already-translated locales). The frontend registry is then the source of truth; admin edits to a covered key's context are overwritten on the next extractor run.

Sharing keys between frontends

A second surface — a native app, a watch app, a kiosk — usually renders strings the web app already has. It should reuse those keys rather than introduce parallel ones: the string is then translated once for both, and a wording change moves both at the same time.

Report a shared key with your own module. Modules accumulate:

jsonc
// the web app, at build time
{"key": "tracking.addSet", "text": "Add set", "module": "frontend"}
// the watch app, later — same key, second module
{"key": "tracking.addSet", "text": "Add set", "module": "watch"}
// stored: modules: ["frontend", "watch"]

This is what makes GET /i18n/messages?locale=X&ns=watch return the keys a surface reuses as well as the ones it introduced. Modules used to be written only when a key was created, so shared keys kept the first reporter's module alone and a namespaced fetch silently returned a fraction of what the second surface renders — a catalogue that looks healthy and ships an app in English. Adding a module is idempotent, and it never touches translations.

Do not send context on a key you are reusing. Unlike text, the note is upserted on every report, so two surfaces writing their own notes would overwrite each other on alternate deploys. Context belongs to whichever surface introduced the key.

ns= returns raw translations, no fallback chain. ?locale=ru resolves a missing key to the source text; ?locale=ru&ns=watch omits it. For a UI that renders live, the first is what you want. For anything building a snapshot — a bundled catalogue, a coverage report — use the namespaced form, or every locale will appear fully translated because the source text was substituted in.

Format integrity. /admin/ai/translate validates every returned translation against the source's format signature — {placeholder} tokens, ICU plural structure, and HTML tags must survive. Corrupted locales are re-requested and, if still broken, omitted (the caller keeps the source) rather than persisting markup that renders wrong. ICU-plural sources are translated one locale per call so a single malformed value can't fail the whole batch.

Automatic translation: provenance, flows and the review gate

A catalogue that fills itself needs three things a one-off bulk translate does not: a record of who wrote each translation (so automation never overwrites a person), a trigger that is not a person pressing a button, and a check before machine text reaches users. Each exists because its absence shipped a defect.

Provenance

Every row carries provenance beside translations — locale → {source, by, at, source_hash, context_hash, review}. translations stays a plain map, so no reader changes.

sourceWritten byAutomation may replace it?
machinea translate runyes
(absent)anything before provenance existedonly with overwrite
humanan admin edit, an approved correctiononly with overwrite + includeProtected
code / seeda frontend default, a migrationonly with overwrite + includeProtected

Every write sets or clears the entry for the locale it touches in the same update, so a stale human can never protect text no human wrote. An admin whole-row save marks only the locales whose text changed — a form submits all 25 languages, and marking them all would protect 24 machine strings forever.

source_hash / context_hash fingerprint the source text and translator note a machine translation was made from. When either changes, the translation is stale and a missing_and_stale run re-translates it — and only it. Unknown-origin text has no fingerprints and is never considered stale: fixing old text is an explicit overwrite, scoped with locales to the languages that are actually wrong (fixing Polish «cal» must not re-roll 23 correct locales).

i18n.keys_changed — the work signal

Published when a translation input changes: keys added, source-language text changed, translator note changed (report-keys, RegisterMessages, admin create/update). Never on a translation write — a flow that fills translations in response cannot wake itself.

It is deliberately not i18n.updated, which is the per-instance cache broadcast: consuming that for work would translate the same keys once per replica.

i18n/fill-translations node

Runs the same loop as the admin endpoint and the CLI (needs go-modules/ai).

SettingDefaultMeaning
keys—Keys to fill; otherwise the event's payload.keys
all_keysfalseMust be set for an empty key list to mean the whole catalogue
modemissingmissing or missing_and_stale
localesall activeRestrict to these languages
limit200Keys per run
reviewtrueRun the review gate before writing (below)

There is no overwrite setting, by design. The most an unattended flow does is re-translate stale machine text.

Read node config by kind, not Go type. A published flow version comes back from Mongo, so arrays are primitive.A and small numbers int32. A case []any matched nothing: the first real run read locales: [pl, de] as empty and translated into all 25 languages while every literal-based unit test passed.

i18n/flowtemplates — auto-published flows

Blank-import github.com/redelay/go-modules/i18n/flowtemplates. On startup it publishes, via EnsureTemplatePublished (idempotent by template id, re-published when the template changes, never clobbering an operator-edited flow):

FlowTriggerFill
i18n/translate-on-changei18n.keys_changedthe event's keys, missing_and_stale, review on
i18n/translate-sweepi18n.translate.sweepall_keys, missing_and_stale, 200 keys, review on

Two flows rather than one because a run walks one path. Each names its own groupID: a group derived from the flow id would differ between two replicas publishing on the same first boot, and every event would be translated twice.

It also seeds a scheduler entry, i18n-translate-sweep (stable id, UTC), that publishes the sweep event. It is never retimed or re-enabled once present — an operator who paused it during a provider outage has decided something.

EnvDefaultPurpose
I18N_TRANSLATE_SWEEP_CRON30 3 * * *Sweep schedule (empty = do not seed)
I18N_AUTO_TRANSLATE_DISABLEDfalseSkip publishing the flows and seeding the schedule
flowexec subscribes event-source flows at startup. A flow published at runtime from Studio receives no events until the process restarts. EnsureTemplatePublished re-syncs subscribers itself, so these templates are unaffected.

The review gate

Every defect found in one week of real catalogue data had the same shape: fluent, well-formed, in the right language — and wrong. Polish «cal» on a calorie counter is the word for inch; «Zalogowano» for "you logged 3 sets" is logged in; Turkish «km/s» is per second; Greek «ημερολ» abbreviates calendar. No format or wrong-language check can see them.

ai.Service.ReviewTranslations asks a model, in one call per key, whether each candidate's meaning matches the source given the translator note — wrong sense, contradicts note, wrong unit, broken placeholder, untranslated. It is told explicitly not to judge style: a reviewer that proposes improvements flags everything, and a gate that holds back everything gets turned off. A rejection whose suggestion equals the candidate is discarded as self-contradicting.

What happens to each translation:

VerdictKey has a translator noteResult
passed, or no verdict—live
flaggedyesheld in pending.<locale> — never served; users see the fallback
flaggednolive, flag recorded in provenance.<locale>.review

A reviewer outage never stops translation. The note/no-note split is measured, not assumed — on 17 known-bad strings (15 real shipped defects) and 87 correct catalogue strings:

Reviewed withBad caughtCorrect wrongly flagged
the translator note16 / 172 / 87
no note14 / 177 / 87

Without a note the reviewer confidently imposes its own terms (it claimed German «Satz» cannot mean a workout set), so holding on its word alone would fill the queue with correct text. With a note, the figure is optimistic: many notes were written after the defect was found and spell out the answer. The miss was exactly that case — Polish «cal» with a plain "calories" note. A note that names the trap is what makes the gate catch it. Cost: ~640 prompt + ~80 completion tokens per key.

Held and rejected entries block re-translation while their source and note fingerprints match, so a nightly sweep does not re-bill the same flagged answer. Editing the note or the source releases them. Any live write for the locale clears its pending entry.

Admin review queue

MethodPathPurpose
GET/admin/i18n/translations/pendingRows with held or rejected translations
POST/admin/i18n/translations/pending/approve{key, locale, text?} — unchanged text goes live as machine (review approved); edited text goes live as human
POST/admin/i18n/translations/pending/rejectKeep it out; blocks re-translation until inputs change
POST/admin/i18n/translations/reviewRead-only review of up to 40 keys; items[].candidates and items[].context override the stored values
POST/admin/i18n/translations/translate-missingAlso takes overwrite, includeProtected, locales, review (default off)

The override on /review exists to measure the reviewer honestly — test a known-bad string without writing it, or with the note as it was before the defect was found.

@redelay/js-admin ≥ v0.4.16 renders the queue at Languages → Review (/translations/review): the source, the note, what the reviewer objected to and its suggestion, an editable translation, and Approve / Publish correction / Reject. Correcting beats re-rolling — a re-translation can come back worse.

The CLI mirrors it: i18n-translate --include-protected --review.

  • Localized content — [[ module.key ]] settings tokens, which are expanded in catalogue messages too (after parameter interpolation, so both syntaxes coexist in one string), plus the shared translation lifecycle
  • go-modules addons — the full addon module list, including content (localized pages and fragments, which resolve locale the same way)

A locale's URL prefix is not its language

Locale.code is how a language appears in a URL. Locale.language is the BCP-47 tag it actually is. They are usually the same and sometimes must not be: a project can reasonably route Ukrainian at /ua, because uk in a path reads as the United Kingdom to a human — while the language is uk, and hreflang="ua" is not a language at all.

Getting this wrong is quiet. Four of one project's prefixes (cz, dk, gr, ua) were codes Google discards outright, so those languages carried hreflang markup that did nothing. The other two were worse: ee and se are valid ISO 639-1 codes — for Ewe and Northern Sami — so they parsed cleanly and advertised the wrong language, and no validator flags it.

LanguageTag() is never empty: an unset Language falls back to an alias table (cz→cs, ee→et, se→sv, …) and then to the code itself. The table is a default, not a rule — it lists only codes that are wrong by default, and any locale overrides it from admin.

Consumers should read language off the locale record and never carry their own mapping. Two copies of this mapping in one frontend, pointing opposite ways, is how the wrong ones shipped.

Making the admin the only source of truth

A frontend that hardcodes its locale list needs a release every time a language is added. The one thing that usually forces it is the ROUTER, because route patterns are built at compile time.

Resist listing the codes there. Match the shape of a prefix and validate membership at runtime against the active-locale list:

ts
// Build time: a shape, not a list.
const LANG = ':lang([a-z]{2})'

// Runtime: the database decides what is actually served.
if (!locales.value.some(l => l.code === lang)) {
  throw createError({ statusCode: 404, statusMessage: 'Unknown language' })
}

A constrained list does buy one real thing — :lang(bg|cz|…) makes /dashboard structurally incapable of being read as a language. Keep that guarantee with an assertion instead: fail the build if any route's first segment matches the locale shape, so adding a two-letter page like /ai is a loud error rather than a page silently routed as a language.

With that in place, adding a locale in admin routes immediately, appears in the switcher, the hreflang set and the sitemap; removing one 404s it and drops it from the sitemap. Both verified without a deploy.

The sitemap should fetch the active list too. Built from a compiled-in array it advertises whatever the last release knew: a language added in admin is missing, and one removed is still being submitted.