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

9 lines
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:

  1. Records the Translator on the App.
  2. Wires i18n.Middleware(tr) into the default chain. Every request gets a Locale in r.Context() from Accept-Language (or the X-Locale override header, or a locale resolver, described below), and the Translator beside it (i18n.WithTranslator), so every phrase and every framework/ui component that reads r.Context() resolves its text through the catalog. No manual WithTranslator call is needed.
  3. Installs the Translator as i18n.Default() so the package-level i18n.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.

10 lines
{
  "welcome": "Hello, {{name}}!",
  "cart": {
    "empty": "Your cart is empty",
    "items": {
      "one": "1 item in cart",
      "other": "{{count}} items in cart"
    }
  }
}

After loading:

KeyForm
welcomeText
cart.emptyText
cart.itemsPlural (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

4 lines
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:

1 lines
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.

2 lines
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).

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:

4 lines
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:

4 lines
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.

27 lines
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>/:

5 lines
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) says es. Nothing registers a route per language.
  • The path decides. A path with no prefix is the default locale, whatever the cookie or Accept-Language say, so a page and its hreflang always agree. /en/pricing answers 308 to /pricing. Tags are lowercase: /ES/pricing and /esmeralda are ordinary paths.
  • Responses keep the locale. In a /es/ response, root-relative href, form action, formaction, the URL-valued data-cui-* attributes, and the Location, X-Gofastr-Location, X-Gofastr-Push-State and X-Gofastr-Document headers (a full URL on the app's own host included) gain /es on the way out (core-ui/urlprefix), so a login redirect lands on /es/login?next=/account. Assets, /__gofastr/ endpoints, external URLs, an anchor with hreflang or download, 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 prepends i18n.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 plus x-default, and its canonical URL in its own locale. The sitemap lists every page once per locale with xhtml:link alternates, and robots.txt repeats each Allow and Disallow under every prefix (Disallow: /app also writes Disallow: /es/app).
  • A static export writes a tree per locale. gofastr export renders /es/pricing/index.html beside /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:

3 lines
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:

7 lines
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.

8 lines
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 groups 12,34,567.
  • Floats round to three fraction digits, Percent to whole percents and Currency to the currency's own digits (none for JPY), unless MinFraction / MaxFraction say 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 or big.Rat.FloatString hands 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 returns ok == false for 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 with t.In(loc) first to show another zone's date. DateYearMonth, DateMonthDay and DateWeekdayMonthDay are CLDR's skeletons for partial dates; DateYearMonthLong is 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) and i18n.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 for en (read as en-US) and ja, Monday for en-GB, fr and es, Saturday for ar (read as ar-EG). A tag that does not parse reads as English, as it does for every formatter here. ui.DatePicker uses all three.
  • i18n.FillContext(ctx, s, params) is i18n.Fill with 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 way framework/ui.Ago does.
  • CLDR time zone names. i18n.Time writes Go's zone abbreviation in English and the locale's GMT format elsewhere (UTC+1 in 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.

5 lines
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:

12 lines
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:

KeyHelperFallback when nothing is set
entity.<entity>.singular / .pluralEntitySingular / EntityPluralentity name singularized + title-cased / entity name title-cased
entity.<entity>.descriptionEntityDescriptionempty (prose has no derived text)
entity.<entity>.fields.<field>.labelFieldLabel, RelationLabelthe Display label, else the humanized field name; RelationLabel (a Relation field) drops a trailing _id, so customer_id reads "Customer"
entity.<entity>.fields.<field>.helpFieldHelpthe Display help, else empty
entity.<entity>.fields.<field>.values.<value>FieldValueLabelthe value title-cased
entity.<entity>.views.<key>ViewLabelthe view's label, else the key title-cased
entity.<entity>.transitions.<key>TransitionLabelthe move's label, else the key title-cased
entity.<entity>.sections.<key>SectionLabelthe key title-cased
nav.groups.<key>NavGroupLabelthe 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.

5 lines
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:

4 lines
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 Define registered, 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 other form 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:

11 lines
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:

3 lines
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.

Mail

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:

7 lines
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.

6 lines
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:

1 lines
{"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:

6 lines
{
  "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:

PieceWhat 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.PhrasedThe 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:

8 lines
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:

10 lines
{
  "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 (use i18n.HTTPError);
  • i18n.Define inside a function body, or with a computed key or English;
  • handler.Errorf, or a handler.Error literal that sets Message;
  • a response helper taking an http.ResponseWriter and a string parameter named msg, message, errMsg or 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 an aria-label, title, placeholder or alt, or set on a UI component's Label/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 ScreenTitle or ScreenDescription method returning a constant;
  • words a desktop shell hands to the OS: a constant passed to objc.NSString or 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.txt and 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:

8 lines
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:

1 lines
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:

  1. 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.
  1. 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.
25 lines
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:

1 lines
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. WithI18n wires 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. SetDefault is for startup wiring; swapping it mid-flight is a race against any goroutine that's already cached Default().