Internationalization
core/i18n is GoFastr's small i18n primitive: locale negotiation from
Accept-Language, JSON-backed message catalogs with {{placeholder}}
interpolation, CLDR plural categories for every language, and CLDR
number, date, currency and list formatting.
The goal is to make "translate this string for this caller's locale"
trivial without pulling in the full ICU stack. The plural rules and the
number, date and list patterns come from a CLDR 47 table generated into
the package (cldr_tables.gen.go), because golang.org/x/text's data is
older: its plural rules are CLDR 32's, where a French million is
other and not many.
Wiring
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 locale app := framework.NewApp(framework.WithI18n(tr))
framework.WithI18n does three things:
- Records the Translator on the App.
- Wires
i18n.Middleware(tr)into the default chain. Every request gets aLocaleinr.Context()fromAccept-Language(or theX-Localeoverride header, or a locale resolver, described below), and the Translator beside it (i18n.WithTranslator), so every phrase and everyframework/uicomponent that readsr.Context()resolves its text through the catalog. No manualWithTranslatorcall is needed. - Installs the Translator as
i18n.Default()so the package-leveli18n.T(ctx, key, params...)helper works from anywhere, and code with no request context (desktop menus, notifications) still translates.
Pair with WithoutDefaultMiddleware and the framework panics. Mount
i18n.Middleware(tr) 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.
{ "welcome": "Hello, {{name}}!", "cart": { "empty": "Your cart is empty", "items": { "one": "1 item in cart", "other": "{{count}} items in cart" } } }
After loading:
| Key | Form |
|---|---|
welcome | Text |
cart.empty | Text |
cart.items | Plural (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
ctx := r.Context() // locale already attached by middleware name := "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:
msg := i18n.T(ctx, "welcome", map[string]any{"name": name})Both consult the same Translator.
Placeholders
{{name}} is replaced with the matching params value. A number (any
integer or float kind) is written the reader's way, 1,234 in English
and 1 234 in French, so pass the int, not strconv.Itoa(n); anything
else is stringified by fmt. Unknown placeholders are left intact,
which is 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.
i18n.T(ctx, "cart.items", map[string]any{"count": 3}) // "3 items in cart" (en, "other")
Every language's rule is CLDR 47's: Russian's 21 is one and 25 is
many, Arabic uses all six forms, French's 1.5 is one and its
1,000,000 is many. A regional locale takes its parent's rule
(pt-AO reads as pt-PT), and a language CLDR has no rule for reads
as English. A count is an integer, a finite float, or a decimal
string, and the string's written zeros count: English's "1.0" is
other. A form the message leaves out falls back to other.
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
fallback that drops one subtag at a time from the right: fr-CA → fr).
A tag's script, stated or read from its region the way CLDR does, is
tried before the bare language: zh-CN → zh-Hans → zh, and
zh-TW, zh-HK and zh-MO → zh-Hant. A script that is not the
language's usual one ends the chain, because the bare language's
catalog is written in the other script: zh-Hant-HK → zh-Hant and
sr-Latn never reach zh (Simplified) or sr (Cyrillic); they get
the fallback locale instead. A * entry matches any
catalog locale the header did not refuse with q=0 (the fallback first),
and an entry whose q is not a number from 0 to 1 is ignored. When
nothing matches, the Translator's fallback locale wins.
A request header X-Locale: ja short-circuits negotiation, useful
for tests and A/B switches. An app that names the language in the URL
uses WithLocalePrefixes instead (see Locale in the URL),
and the path then beats every source below.
A locale read from the headers makes the response depend on them, so
the middleware adds Vary: Accept-Language and Vary: X-Locale and a
CDN or the cache battery stores one copy per language. Each locale
resolver adds the headers it reads as well (Cookie for
i18n.CookieLocale). A locale the URL names adds no Vary: the URL
already differs.
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).
Per-user locale (cookie) switching
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-Locale → Accept-Language → fallback.
A resolver value only wins when it matches a catalog locale (with the
same fr-CA → fr 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. The same bound applies to
X-Locale and to every Accept-Language entry: a range longer than
35 characters, or with characters outside lowercase letters, digits
and -, is skipped.
A resolver is an i18n.LocaleResolver: Resolve reads the request
and Vary names the request headers its answer depends on, which the
middleware adds to Vary. A custom resolver must name them:
framework.WithLocaleResolver(i18n.LocaleResolver{ Resolve: func(r *http.Request) (string, bool) { return r.Header.Get("X-User-Lang"), true }, Vary: []string{"X-User-Lang"}, })
Use i18n.CookieLocale(name) for the common cookie case, and wire it
through framework.WithLocaleResolver on the App:
app := framework.NewApp( framework.WithI18n(tr), framework.WithLocaleResolver(i18n.CookieLocale("locale")), )
A "change language" handler sets the cookie and redirects back. The
cookie carries HttpOnly, Secure and SameSite Strict (a locale
preference never needs script access or cross-site rides), and the
Referer is resolved to a same-origin relative path first: the header is
attacker-controlled, so a bare r.Referer() redirect would send the
user (and the fresh cookie) to whatever page linked here.
func setLocale(w http.ResponseWriter, r *http.Request) { lang := r.FormValue("lang") // validate against your catalog http.SetCookie(w, &http.Cookie{ Name: "locale", Value: lang, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, }) http.Redirect(w, r, backToSender(r), http.StatusSeeOther) } // backToSender turns the Referer into a same-origin relative path, "/" // when it is anything else (missing, absolute on another host, or a // protocol-relative URL). The Referer header is attacker-controlled; // redirecting to it raw is an open redirect. func backToSender(r *http.Request) string { u, err := url.Parse(r.Referer()) if err != nil || u.Host != "" && !strings.EqualFold(u.Host, r.Host) { return "/" } if p := u.Path; strings.HasPrefix(p, "/") && !strings.HasPrefix(p, "//") { return p } return "/" }
WithLocaleResolver panics if used without WithI18n. Locale
resolution is meaningless without a translator to resolve against.
Locale in the URL
WithLocalePrefixes puts the language in the path. The translator's
fallback locale keeps the plain paths, and every other catalog locale
serves the same routes under /<tag>/:
app := framework.NewApp( framework.WithI18n(i18n.NewTranslator(catalog, "en")), // en, es, fr framework.WithLocalePrefixes(), ) log.Fatal(app.Start(":8080"))
/pricing is English, /es/pricing Spanish, /fr/pricing French.
- The router strips the prefix before matching. Route patterns,
screens and handlers see
/pricing;i18n.FromContext(ctx)sayses. Nothing registers a route per language. - The path decides. A path with no prefix is the default locale,
whatever the cookie or
Accept-Languagesay, so a page and itshreflangalways agree./en/pricinganswers 308 to/pricing. Tags are lowercase:/ES/pricingand/esmeraldaare ordinary paths. - Responses keep the locale. In a
/es/response, root-relativehref, formaction,formaction, the URL-valueddata-cui-*attributes, and theLocation,X-Gofastr-Location,X-Gofastr-Push-StateandX-Gofastr-Documentheaders (a full URL on the app's own host included) gain/eson the way out (core-ui/urlprefix), so a login redirect lands on/es/login?next=/account. Assets,/__gofastr/endpoints, external URLs, an anchor withhreflangordownload, and a URL that already starts with a locale stay as written. JSON bodies and SSE pushes are not rewritten: code that puts a URL there prependsi18n.PathPrefix(ctx). - The browser runtime follows. It reads the locale set from the
page (
window.__gofastr_locales). A link between two/es/pages is an ordinary client-side navigation, and the runtime's own fetches (actions, islands, polls, widgets) carry the prefix. A link into another locale loads a new document, so<html lang>, the shell and the runtime's strings all change with it. - Search engines see every language. Each page carries a
<link rel="alternate" hreflang>per locale plusx-default, and its canonical URL in its own locale. The sitemap lists every page once per locale withxhtml:linkalternates, and robots.txt repeats eachAllowandDisallowunder every prefix (Disallow: /appalso writesDisallow: /es/app). - A static export writes a tree per locale.
gofastr exportrenders/es/pricing/index.htmlbeside/pricing/index.html, with the links inside already prefixed.
ui.LanguageMenu is the language switcher. Its trigger shows a globe
and the current language; it has one row per catalog locale, each
linking the page the reader is on in that language and named in that
language (i18n.LanguageName: "English", "Español", "Português
(Brasil)"). Put it in chrome every page shares, such as the footer:
func languageSwitcher(ctx context.Context) render.HTML { return ui.LanguageMenu(ui.LanguageMenuConfig{Ctx: ctx, Position: ui.MenuTopStart}) }
Each row is a rel="alternate" hreflang link. After every client-side
navigation the runtime points such links at the new page in their
locale, so a switcher in a header or footer that stays on screen never
links the page the reader left. The menu renders nothing without
WithLocalePrefixes, and Names overrides a label.
For a one-off link to another language, build the URL with
i18n.LocalePath and mark the anchor with hreflang so the rewriter
leaves it alone:
func spanishLink(ctx context.Context) render.HTML { return ui.Link(ui.LinkConfig{ Href: i18n.LocalePath(ctx, "es", "/pricing"), Text: i18n.LanguageName("es"), // "Español" in every locale ExtraAttrs: html.Attrs{"hreflang": "es", "lang": "es"}, }) }
There is no redirect from / by Accept-Language. Crawlers send no
such header, and a reader who picked English from a Spanish browser
would be sent back to Spanish on every visit. Offer the switch as a
link.
NewApp panics without WithI18n, together with WithLocaleResolver
(a stored locale would contradict the URL), and when a catalog locale
is not a lowercase tag that can be a path segment. Start panics when
a registered route's first segment names a locale (/es/promo), since
the router would strip it before that route could match.
A generated app turns it on with app.locale_prefixes: true in its
spec. The pieces underneath are public for a host that routes on its
own: i18n.PathLocales (the set: Split, Path, All),
i18n.WithPathLocale / i18n.PathLocale / i18n.PathPrefix on the
context, Router.PreRoute for the strip, urlprefix.Middleware for
the response rewrite, and UIHost.SetPathLocales, which the framework
calls when it mounts a UI host.
Formatting numbers, dates and lists
Every formatter takes the request's context and writes in its locale. A locale CLDR does not cover, or a context with none, writes English.
i18n.Number(ctx, 1234.5) // "1,234.5" fr: "1 234,5" i18n.Number(ctx, 2.0, i18n.MinFraction(2)) // "2.00" i18n.Percent(ctx, 0.25) // "25%" fr: "25 %" i18n.Currency(ctx, 1234.5, "EUR") // "€1,234.50" fr: "1 234,50 €" i18n.Date(ctx, t, i18n.DateMedium) // "Mar 5, 2026" i18n.DateTime(ctx, t, i18n.DateMedium, i18n.TimeShort) // "Mar 5, 2026, 2:05 PM" i18n.List(ctx, []string{"a", "b", "c"}) // "a, b, and c" fr: "a, b et c" i18n.ListOr(ctx, []string{"a", "b"}) // "a or b"
- Digits and grouping are the locale's. Egyptian Arabic writes
١٢٣, Indian English groups12,34,567. - Floats round to three fraction digits,
Percentto whole percents andCurrencyto the currency's own digits (none for JPY), unlessMinFraction/MaxFractionsay otherwise. NaN and the infinities print as the locale's symbols. i18n.Decimal(ctx, s, opts...)takes a plain decimal string ("-1234.5", what a NUMERIC column orbig.Rat.FloatStringhands back) and never routes it through a float64, so a value past 15–17 significant digits prints exactly. It rounds half away from zero and returnsok == falsefor anything that is not a sign, digits and a fraction: exponents, fractions like"1/3", and strings over 512 bytes.- Dates use
t's own zone. Convert witht.In(loc)first to show another zone's date.DateYearMonth,DateMonthDayandDateWeekdayMonthDayare CLDR's skeletons for partial dates;DateYearMonthLongis CLDR's year with the wide month ("March 2026","marzo de 2026"), the way a calendar's caption says it; a locale whose CLDR format keeps the month numeric (lt,sk) keeps it numeric. - Calendars.
i18n.WeekdayName(ctx, d)andi18n.WeekdayAbbr(ctx, d)are a weekday's names ("Thursday","Thu";"jeudi","jeu."in French).i18n.FirstWeekday(ctx)is the day the reader's week starts on: a tag's-u-fw-extension when it has one (en-GB-u-fw-sun), else CLDR's week data for the region: Sunday foren(read as en-US) andja, Monday foren-GB,frandes, Saturday forar(read as ar-EG). A tag that does not parse reads as English, as it does for every formatter here.ui.DatePickeruses all three. i18n.FillContext(ctx, s, params)isi18n.Fillwith numbers written the reader's way: what a phrase does, for a template that arrives as a plain string.i18n.Dir(tag)is"rtl"or"ltr"from the language's script, its own subtag or CLDR's likely one (ar,he,fa,ur,dv,az-Arab). The app writes it on<html dir>(see "The document language").
The rawtext analyzer's numtext arm keeps Go's own digits out of the
page: a strconv.Itoa, FormatInt/FormatFloat or fmt.Sprint(f) of a number that
reaches a phrase param, render.Text, a label attribute or a UI field
is reported, directly or through a local variable that holds it. Pass
the number itself, or format it with i18n.Number.
What's NOT in this package
- ICU message format, compact numbers (
1.2K), units and relative time. Write those as phrases with a count, the wayframework/ui.Agodoes. - CLDR time zone names.
i18n.Timewrites Go's zone abbreviation in English and the locale's GMT format elsewhere (UTC+1in French).
Translating framework/ui component labels
Every word a framework/ui component says is an i18n.Phrase in
framework/i18nui (i18nui.PaginationNext, i18nui.TableSortedBy,
i18nui.KeyCtrl, ...). A component says a phrase with
phrase.Text(ctx), so the reader's locale picks the words. Once
WithI18n is set, the bridge middleware puts the translator on
r.Context(), and a component handed that context needs no other
wiring.
Placeholders are {{name}} everywhere: in phrases, in catalogs, in
headless.Strings fields and in the runtime's strings. i18n.Fill(s,
params) fills a template that arrives as a plain string. It writes each
value as given and never rescans it, and it leaves an unknown placeholder
as written. i18nui.HTML(ctx, phrase, vars) is the same for a line that
carries markup, such as a bold name or a link: the translated text is
escaped, and each {{name}} becomes vars[name], which the caller
built from escaped parts.
The Ctx field pattern
Components that take a config struct expose a Ctx context.Context
field. Pass the request context and the component says its words in the
reader's language. Pass nothing and it uses context.Background(),
which says English. An explicit label field on the config
(SubmitLabel, Placeholder, CTALabel, ...) always wins over the
phrase.
h := ui.DataTable(ui.DataTableConfig{ Columns: cols, Rows: rows, Ctx: r.Context(), // locale + translator from the bridge })
Mounted widgets
A widget you mount once at startup has no reader when you build it. Its
body renders on every request that shows or opens it, so the framework's
own words in it follow that request's locale: the confirm dialog's
Confirm and Cancel, the lightbox's Prev, Next and Download, the
notification bell's empty text, the shortcut sheet's title and close
button, and the command palette's placeholder and key hints.
LightboxConfig and ConfirmActionConfig have no Ctx field for that
reason.
Words you pass in are said as given. When the rows or commands should be
translated too, mount the Func variant, which builds the config for
each request:
func paletteConfig(ctx context.Context) ui.CommandPaletteConfig { return ui.CommandPaletteConfig{ FallbackHref: "/search", Commands: []ui.PaletteCommand{{Label: homePhrase.Text(ctx), Href: "/"}}, Ctx: ctx, } } // At startup: widget.MountBuilder(app.Router(), ui.CommandPaletteFunc(paletteConfig)) // In the page chrome, the trigger for this request: trigger := ui.CommandPaletteTrigger(r.Context(), paletteConfig(r.Context()))
ui.ShortcutSheetFunc is the same for the shortcut sheet: mount its body
once, then call ui.ShortcutSheetTrigger(ctx, sheetConfig(ctx)) in each
request's chrome. The explicit context resolves the default trigger label
and the mounted body resolves its title independently for the same reader.
An explicit TriggerLabel wins for both. The widget's name, shortcut and
fallback link come from build(context.Background()); keep that identity
consistent in the request-built config. A nil trigger context uses English
fallbacks, not cfg.Ctx.
The bell follows the same split without a Func variant: mount the body
from ui.NotificationBell(cfg) once and render
ui.NotificationBellTrigger(ctx, cfg) for every request. That explicit
context resolves the initial spoken unread count and the sentence template
used by SignalUnread updates, including zero. The required caller Label
remains the prefix and is never interpreted as part of the count template.
The popover's default empty text resolves from its body's request context,
not NotificationBellConfig.Ctx; an explicit EmptyText stays as supplied.
The ui.* key namespace
Component phrases live under ui.* (ui.pagination.next,
ui.table.sortedBy, ui.key.ctrl). Your catalog can override any of
them: add the key to a locale's JSON and it wins for that locale. A key
you leave out keeps its English, so a catalog with no ui.* entries
renders the English page.
Keycap words are phrases too. ui.ShortcutHint prints Ctrl, Esc,
Tab and Space from ui.key.*, because a German keyboard says
Strg where an English one says Ctrl.
Per-entity keys (entity.*, nav.groups.*)
Entity screens resolve their labels through the i18nui entity helpers.
These keys are built per entity. They name the app's own entities,
fields, views, form sections and nav groups, so no phrase defines them.
The fallback chain is: the catalog entry, then the value the entity's
Display config declares (see
Entity declarations), then the key title-cased
(past_due becomes Past due). A nested JSON catalog holds them all,
since every key path ends in a string:
| Key | Helper | Fallback when nothing is set |
|---|---|---|
entity.<entity>.singular / .plural | EntitySingular / EntityPlural | entity name singularized + title-cased / entity name title-cased |
entity.<entity>.description | EntityDescription | empty (prose has no derived text) |
entity.<entity>.fields.<field>.label | FieldLabel, RelationLabel | the Display label, else the humanized field name; RelationLabel (a Relation field) drops a trailing _id, so customer_id reads "Customer" |
entity.<entity>.fields.<field>.help | FieldHelp | the Display help, else empty |
entity.<entity>.fields.<field>.values.<value> | FieldValueLabel | the value title-cased |
entity.<entity>.views.<key> | ViewLabel | the view's label, else the key title-cased |
entity.<entity>.transitions.<key> | TransitionLabel | the move's label, else the key title-cased |
entity.<entity>.sections.<key> | SectionLabel | the key title-cased |
nav.groups.<key> | NavGroupLabel | the key title-cased |
Every helper also consults a translator left on the context by the
WithI18n bridge when its tr argument is nil.
Pseudo-locale testing
i18nui.Pseudo(s) pseudo-localizes a string: every letter accented,
every vowel doubled, the whole bracketed and padded until it is at
least a third longer (about what German or Spanish adds to English).
{{placeholders}} stay as written. i18nui.AddPseudo(catalog, "en-XA")
puts the pseudo form of every defined phrase into a *i18n.MapCatalog
under a tag, so a page browsed with Accept-Language: en-XA shows any
text that skipped the catalog as plain English and any layout that
cannot take longer words as overflow. Entity names, field labels, enum
values, view tabs and the other entity.* labels have no phrase: with
no catalog entry they fall back to their English, and under that tag the
English is pseudo-localized too, since it is the text a translator would
replace.
cat, _ := i18n.LoadJSONCatalog(locales, "locales") if os.Getenv("GOFASTR_PSEUDO_LOCALE") == "1" { i18nui.AddPseudo(cat, "en-XA") } app := framework.NewApp(framework.WithI18n(i18n.NewTranslator(cat, "en")))
A generated app's i18n.go is this code: GOFASTR_PSEUDO_LOCALE=1
turns it on, and every word the app spec wrote is a phrase it covers
(see app-spec → "Translations"). Meridian uses the same
variable, and its
TestE2E_AdminPseudoLocaleOverflow browses every admin screen in it,
and in Spanish, at desktop and phone widths. It fails on a page that
scrolls sideways, content past the screen, a control that leaves or is
covered inside its box, or text clipped with no ellipsis.
Headless components: ui.StringsFor
Headless components (framework/headless) take their words through a
typed headless.Strings prop rather than per-config label fields, so
the whole table resolves in one call:
headless.SystemBanner(headless.SystemBannerProps{ ID: "sys-maint", Tone: "danger", Title: "Ending in 4 minutes", Strings: ui.StringsFor(r.Context()), // every field from the catalog }, nil)
Each field maps to one i18nui phrase. With no translator on the ctx,
and for any key the catalog misses, the field keeps the English
headless ships, so a partial catalog is safe. Fields with a value to
put in (Dismiss: {{title}}, {{n}} files selected: {{names}}.) travel
unfilled, and headless or the runtime fills them. A translation may move
a placeholder but must keep the set. One that names a different set is
refused and the field stays English, because the alternative is a
literal {{n}} or a lost count inside an accessible name, where nobody
sighted would see it.
ui.StringsFor also hands the request to the table
(Strings.ForRequest), so the numbers headless writes, a step number, a
count or a slider value, follow the reader's locale through
Strings.Number. A counter or meter the browser updates carries
data-cui-signal-mode="number", which loads the headless-numbers
runtime module: it rewrites each new value with Intl.NumberFormat for
the page's <html lang>, so the live number matches the server's.
Checking a catalog: i18n.CheckCatalog
A refused or missing translation is invisible on its own: the page says
one English sentence among the translated ones. i18n.CheckCatalog
compares a locale's catalog with every phrase the program defines and
reports four problems:
- unknown key: an entry no
Defineregistered, left behind by a rename or hidden by a typo. - placeholders differ: a translation that names a
{{placeholder}}its English does not fill, or drops one it does. - missing: a phrase under a
Required prefix that the locale does not translate. - plural without other: a plural message with no
otherform and no plain text, which has nothing to say for a count its forms miss. At runtime such a count falls back to the next locale candidate, then the fallback locale, then the phrase's English, never an empty string.
Only phrases linked into the binary are known, so call it from a test in the package that wires the whole app, once per locale you ship:
func TestSpanishCatalog(t *testing.T) { cat, err := i18n.LoadJSONCatalog(locales, "locales") if err != nil { t.Fatal(err) } for _, is := range i18n.CheckCatalog(cat, "es", i18n.Skip("entity.", "nav."), // the app's data, not phrases i18n.Require("ui.")) { // every component word translated t.Errorf("%s: %s (English %q, Spanish %q)", is.Key, is.Problem, is.English, is.Text) } }
Words the browser says: the runtime strings block
The runtime says a few things itself: a failed request's toast, a
navigation that could not load, the embed frame's error, a local
entity's validation messages. Those are phrases too, registered with
runtime.RegisterStrings(phrases...) from the package that owns them.
On every page the host writes the ones the reader's locale translates
into a <script type="application/json" id="gofastr-strings"> block,
and the runtime's say(key, english, params) reads it, falling back to
the English argument on a miss. An English reader's page carries no
block at all.
A test walks the repo's JavaScript and holds every say('key',
'English' call to a registered phrase with the same English, and every
registered phrase to a call. Change the words in one place and the test
names the other. core-ui/check.LintRuntimeProse refuses literal prose
written straight into a toast, a textContent, an aria-label or a
title in runtime JavaScript, a unit or noun glued to a value with +
(n + ' chars') included: the count goes in a {{n}} phrase, written
with NS.formatNumber.
A client-side navigation keeps the document, and with it the first
page's block. So the destination carries its own: a partial response's
page-head template has a data-cui-strings attribute, a full fetch has
its #gofastr-strings block, and the runtime swaps say()'s table on
each navigation. A destination with neither is English, which clears a
Spanish page's table when the reader moves to an English one.
Beyond the page body
Screen titles and descriptions
ScreenTitle and ScreenDescription take the request's context, so a
page's tab title and its description meta speak the reader's language:
var helpTitle = i18n.Define("help.title", "Help center") func (s *HelpScreen) ScreenTitle(ctx context.Context) string { return helpTitle.Text(ctx) }
App.Register also reads each once with a bare context, for the route
table and a route's default title. With no locale on it, that copy is
the phrase's English. The rawtext analyzer refuses a literal returned
from either method in framework code. A component that still declares
the old ScreenTitle() string or ScreenDescription() string makes
registration panic, because that method satisfies neither interface.
Register, RegisterScreen, WithNotFoundBody, and uihost's
WithNotFoundScreen and WithErrorScreen all check. A component no
registration sees is checked when it renders: a policy's RenderAlt
result, the body passed to the not-found and error page renders, and
uihost's RenderScreen. Code that holds a component anywhere else
calls app.RefuseRetiredScreenMethods itself.
Requests the router never sees
App.Start negotiates the locale ahead of the server wrappers and the
setup wizard, which run before the router and its i18n middleware. A
battery page that renders outside the app shell (the sign-in gate, the
setup wizard, the magic-link confirmation page) writes
<html lang> with i18n.DocLang(ctx): the negotiated locale when the
catalog has it, the translator's fallback when it does not, and en
with no translator.
Caching output per locale
Output that is the same for every reader of one language (a rendered
fragment, a script body) can be cached per locale. Key it on
i18n.CatalogLocale(ctx), never on i18n.FromContext(ctx).Tag: the
X-Locale header reaches the context sanitized but not matched against
the catalog, so the raw tag is whatever the caller sent. CatalogLocale
answers the catalog locale a phrase under ctx reads in: the tag when
the catalog has it, its language when the catalog has only that (es
for es-mx), the translator's fallback otherwise, and "" with no
translator. Its answers are bounded by the catalog's locale set. The UI
host keys its per-locale route graph and manifest.js this way.
An email is read later, but it is written for the person who asked.
BodyTemplate on the password-reset, email-verification and magic-link
configs takes a context carrying the requester's locale, so a phrase
rendered in it reads in their language:
var resetBody = i18n.Define("mail.reset_body", "Reset your password: {{url}}") auth.PasswordResetConfig{ BodyTemplate: func(ctx context.Context, url string) string { return resetBody.Text(ctx, map[string]any{"url": url}) }, }
A notification is written for its recipient, not for the request that
sent it. Set notify.Recipient.Locale to the recipient's stored
preference and Send renders the message under it. A tag the catalog
lacks falls back to the catalog's fallback, not to the sender's
language; an empty tag keeps the caller's locale. See gofastr docs notifications
→ "Translated notifications".
A user's stored language
Storing a language per user is the app's job: auth.User has no locale
field. Keep it on your own user table, and when the user signs in or
changes it, set the cookie i18n.CookieLocale reads (see "Per-user
locale (cookie) switching" above). The resolver also runs before the
router, for the setup wizard and server wrappers, where no session has
been read yet. A cookie reaches it there; a session lookup would not.
Text with no request
A desktop shell's menus and About box, and a page a CLI writes for the
browser, have no Accept-Language to read. i18n.Match(tr, tags...)
picks the first of a preference list the catalog has, the way
negotiation reads the header, and i18n.EnvLocale(tr) reads the
process environment (LANGUAGE, then LC_ALL, LC_MESSAGES, LANG).
A script modifier becomes the script subtag, so sr_RS.UTF-8@latin
reads as sr-Latn-RS; other modifiers (@euro) are ignored.
desktop.ShellContext combines them: the OS's preferred languages
first, then the environment. The settings window's title and
OnSettings follow that locale.
Framework messages and error codes
Every message the framework can send a person is an i18n.Phrase: a
catalog key plus the English used when no catalog has the key. Error
bodies, validation messages, plain-text refusals and battery screens
all read them, so one catalog translates the lot.
var vatInvalid = i18n.Define("validation.vat_invalid", "is not a valid VAT number") vatInvalid.Key() // "validation.vat_invalid" vatInvalid.Code() // "vat_invalid": the key's last segment vatInvalid.Text(r.Context()) // the request's locale, English on a miss vatInvalid.Format(nil) // always English (logs, Error() methods)
Define runs at package level, with a constant key and constant
English. It panics on a malformed key, and on a key another package
already defined with different English. i18n.Phrases() lists every
defined phrase, which is what a catalog-completeness test reads.
Placeholders use the catalog's {{name}} form.
The error contract
A JSON error body carries the text in the request's locale and the
phrase's code beside it. Every route answers in this one envelope: the
crud routes, a declared endpoint and its auth gate, and every typed
handler through handler.WriteError. A client branches on reason,
never on the error text:
{"error": "ya está en uso", "code": 409, "reason": "unique_taken", "success": false}The field is reason because code already holds the HTTP status.
success is always false; it stays for clients that branch on it.
Field-level failures add field_reasons, one code per message in
fields, in the same order:
{ "error": "validation failed", "code": 400, "reason": "validation_failed", "fields": {"email": ["is required"]}, "field_reasons": {"email": ["required"]}, "success": false }
A malformed JSON body is the same: reason is invalid_json (or
invalid_body on a crud route that reads the body itself), and the
message names the JSON path and JSON type (invalid JSON: ids[0]:
expected a string, got a number), built from phrases under json.decode.
and json.type.. It never carries encoding/json's text, which names
Go types. See core-packages → handler for the full list.
Every envelope the framework writes follows this: core/handler
errors, framework/crud (single, batch and state-move results),
battery/auth JSON bodies, entity bulk actions and saved views,
desktop preferences, semantic search. The OpenAPI document lists
reason and field_reasons on its error schemas, and the generated Go
client, JS SDK and CLI decode them. Auth's form-redirect ?error= codes
are the same codes (invalid_credentials, rate_limit, ...).
The pieces, all in core/i18n unless named:
| Piece | What it does |
|---|---|
i18n.Error{Reason, Params, Err}, i18n.NewError(p, params) | An error that names its phrase. Error() is the English; Err is a wrapped cause, never shown |
i18n.Wrap(p, err, params) | An *i18n.Error for p with err as its {{detail}} and its cause. A Phrased cause translates inside the frame: nested filter "author.age_like": operator "like" cannot filter "age" (type int) |
i18n.Phrased | The interface such errors implement. Writers find one anywhere in an error's chain |
i18n.ErrorText(ctx, err, frame) | Text and code for any error. The text is the outermost Phrased error, causes included; the code is the innermost one's (op_type_mismatch above), the most specific. With no Phrased error, err's text fills frame's {{detail}} |
i18n.HTTPError(w, r, status, p, params) | http.Error with the text translated |
i18n.HTTPErrorFor(w, r, status, err) | The same for an error: a Phrased error translates, any other writes the status phrase, so a driver's or library's text never reaches the body |
i18n.StatusPhrase(status) | The generic phrase per status (error.internal, "internal server error") |
handler.Fail(code, p, params) | A *handler.Error from a phrase |
handler.WrapError(code, p, err) | The same, keeping err as the cause |
handler.ValidationError(map[string][]schema.Violation) | A 400 with fields and field_reasons |
handler.WriteError(w, r, err) | Writes any error as the envelope above (error, code, reason, fields, field_reasons, success) in r's locale |
schema.Violation{Reason, Params} | One field failure. Built-in validators return them; schema.Reason* are their phrases (validation.required, validation.max_length, validation.unique_taken, ...) |
handler.Errorf is still there for an app's own wording, sent as
written with no code. Framework code never uses it.
An app's validators define phrases the same way, and their messages translate with no extra wiring:
var vatInvalid = i18n.Define("validation.vat_invalid", "is not a valid VAT number") func checkVAT(v any) error { if !validVAT(v) { return schema.Violation{Reason: vatInvalid} } return nil }
The framework/entity validators (Required, Unique) write the
validation.required and validation.unique_taken phrases in the
context's locale.
Translating them
The catalog key is the phrase's key, so a locale file translates a framework message by naming it:
{ "validation": { "required": "es obligatorio", "max_length": "debe tener como máximo {{max}} caracteres", "unique_taken": "ya está en uso" }, "error": { "not_found": "no encontrado" } }
Namespaces: error.* for request-level refusals, validation.* for
field failures, and one namespace per battery or package for its own
messages (auth.*, desktop.*, entityui.*, semantic.*, ...).
Query parsers have theirs too: filter.* (?field_<op>=, ?sort=,
?where=), dsl.* (the query DSL, ?filter= expressions) and
pagination.* (cursors). A crud list answers a bad query with the
parser's code, unknown_filter rather than a generic one. An
untranslated key falls back to its English.
The rawtext analyzer
make analyze runs rawtext over the module's core/, framework/,
battery/ and core-ui/ packages, and over examples/meridian, the
flagship app. It refuses the shapes that send
English past the catalog:
- a bare
http.Error(usei18n.HTTPError); i18n.Defineinside a function body, or with a computed key or English;handler.Errorf, or ahandler.Errorliteral that setsMessage;- a response helper taking an
http.ResponseWriterand a string parameter namedmsg,message,errMsgor the like; - a map literal whose
"error"or"message"entry is English prose or an error's.Error()text; - a constant JSON body written straight to the response
(
fmt.Fprintf(w, ...),io.WriteString,w.Write([]byte(...))) whose"error","message","title"or"detail"is prose; - words written into the page: a constant passed to
render.Text, set as anaria-label,title,placeholderoralt, or set on a UI component'sLabel/Title/Text-style field, plus the same constant passed to a package helper whose parameter reaches one of those (chartEmpty(ctx, ..., "No data yet", ...)), a local variable or parameter that holds such a constant (aria := "Page steps"written later), and an index write into an attribute map (attrs["aria-label"] = "Close"); - a number in Go's digits (
strconv.Itoa,fmt.Sprint) at any of those sinks or in a phrase's params, including one held in a local first (n := strconv.Itoa(size)passed as{"n": n}); - a
ScreenTitleorScreenDescriptionmethod returning a constant; - words a desktop shell hands to the OS: a constant passed to
objc.NSStringor as the title or text of a Win32 menu, window or message box. The shell renders its words in the OS language (desktop.ShellContext), so it passes a phrase's text instead. The desktop types themselves (MenuItem.Title,Tray.Tooltip,Preference.Label) are phrases, so the compiler refuses a literal there.
A site that sends text as written on purpose (an agent protocol's
documented English detail, for one) carries
//gofastr:allow(rawtext) <why>. Apps and the other examples are
outside its scope.
Still English
- OpenAPI descriptions,
llm.md,llms.txtand the Markdown 404. They are written for developers and agents, and stay English. - Agent protocols. MCP and A2A JSON-RPC error messages, A2A task
text, Web Bot Auth problem details and the desktop bridge's
{"code", "message"}answers are protocol text for a program, beside their own codes. The raw-body sites carry the allow marker.
The document language
<html lang> is a separate thing from the message catalog, and it is the one
piece of i18n a site cannot skip. It is what a screen reader reads to pick
pronunciation rules (WCAG 3.1.1, Language of Page), and what a full-text
indexer such as Pagefind reads to decide which language index a page belongs to
and which stemmer to apply.
WithLang sets one language for the whole app. That is right until the app
serves two, at which point every translated page claims the site language: the
Spanish pages are read aloud with English rules and filed in the English index.
Per route
With WithLocalePrefixes there is nothing to declare: the path names the
locale, the locale is the negotiated one, and <html lang> follows it. A site
that registers each language's routes itself (/es/reference as its own
route) gives the rule as a function:
application := app.NewApp("docs"). WithLang("en"). WithLangFunc(func(path string) string { if strings.HasPrefix(path, "/es/") { return "es" } return "" // fall back to Lang })
LangFunc is called with the page path on every full-page render. Returning
"" falls back to the request's negotiated locale (i18n.FromContext(ctx),
set by WithI18n's middleware), then to Lang. App.LangFor(ctx, path)
exposes the same answer to callers that build their own document shells.
With WithI18n and no LangFunc, a French reader's page is therefore
<html lang="fr">: the document says the language the catalog spoke.
Per page
A page that knows its own language declares it, which covers a route whose language is data rather than path:
func (s *ArticleScreen) ScreenLang() string { return s.article.Lang }ScreenLanger is read after Load, exactly like ScreenTitle, so a
dynamic route can take the tag from the content it fetched. It beats
App.LangFunc for that page. Returning "" falls through to LangFunc, then
the negotiated locale, then Lang, then "en".
Direction
The page's direction follows its language. Every full page carries
<html dir> from i18n.Dir(lang), so an Arabic, Hebrew or Persian page
is dir="rtl" with nothing to set. After a client navigation the core
runtime copies only the language; the doc-dir runtime module, which
the first data-cui-dir="rtl" marker loads, copies the direction. A
site that never serves a right-to-left language never fetches it.
The kit mirrors with it. Its CSS uses logical properties
(inset-inline-start, padding-inline-end, border-inline-end,
text-align: start), so the sidebar, list indents, chips, the select
chevron, switches and toggles, the segmented indicator and the timeline
rail all flip. Positions named by a side mean the reading direction's
start or end: a bottom-right notification, back-to-top button or
widget, and an Edge drawer, sit on the left right to left, and
TooltipLeft opens on the start side. A code block stays left to right
in every locale. Contracts rule GOFASTR1824 refuses a physical side in
kit CSS, so a new component cannot drift back.
The shell on client-side navigation
WithLangFunc tags every full-page render, but a client-side navigation
never re-renders <html>: it swaps layout layers inside <body>. After
an English page, a Spanish page reached by partial navigation kept
lang="en", the English skip link, and the English header (#408).
Under WithLocalePrefixes this cannot happen: a link into another
locale loads a new document. The rest of this section is for a site that
registers each language's routes itself. The fix has two halves, and such
a site wants both:
- Key the outer layer per language. Two layouts with the same name
compare as the same layer, so the first one loaded stays on screen.
Give each language's shell the same name (one CSS contract) and a
per-language
WithKey; a language switch then re-renders the shell like any other cross-chain navigation.
- Let the document markers travel. The outermost layer of every
render carries the page's resolved language, its direction and the
skip label (
data-cui-lang/data-cui-dir/data-cui-skip-label), and the runtime copies them onto<html lang>,<html dir>and the skip link after every swap. You get this by declaring the resolvers; there is nothing to render yourself.
shell := func(key string) *app.Layout { return app.NewLayout("docs", app.LayoutSpec{}, func(ctx context.Context, l *app.LayoutTree) render.HTML { return html.Div(html.DivConfig{}, html.Header(html.HeaderConfig{Banner: true}, docsHeader(ctx)), l.Primary(), ) }).WithKey(key) } application := app.NewApp("docs"). WithLang("en"). WithLangFunc(func(path string) string { if strings.HasPrefix(path, "/es/") { return "es" } return "" // fall back to Lang }). WithSkipLabelFunc(func(path string) string { if strings.HasPrefix(path, "/es/") { return "Saltar al contenido principal" } return "" // fall back to the English default }) application.Register("/en/reference", &ReferenceScreen{}, shell("docs-en")) application.Register("/es/reference", &ReferenceScreen{}, shell("docs-es"))
WithSkipLabel / WithSkipLabelFunc localize the skip link, the first
string a keyboard user tabs to (#411); SkipLabelFor(ctx, path) resolves
like LangFor (func, then app-wide label, then the app.skip_to_main
phrase in the request's language, "Skip to main content" in English).
A page whose whole chain is shared with the previous one (same layout
instances top to bottom) swaps bare content and carries no markers, so
the document language cannot change on that hop. That is the contract
working as designed: if the language differs, key the outer layer per
language and the carrier always arrives.
Host-built shells
The shells uihost builds itself — the 404, the 405, the embed frame — are not
app pages and resolve separately. A 404 under /es/ that says lang="en" is
the same failure the pages had:
host := uihost.New(application, uihost.WithLangFunc(esPrefix))UIHost.LangFor(ctx, path) tries uihost.WithLangFunc, then the app's own
LangFunc, then the request's negotiated locale, then EffectiveLang. Because it falls through to the app, a site that declared
its languages once on the App gets its shells tagged without repeating itself.
A shell that belongs to no route, such as the PWA offline page, passes an empty
path and no request, and takes EffectiveLang.
Every fallback ends at EffectiveLang, so an app with no WithI18n, no
LangFunc and no ScreenLang gets the same lang as before, plus
dir="ltr".
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.WithI18nwires 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.
SetDefaultis for startup wiring; swapping it mid-flight is a race against any goroutine that's already cachedDefault().