Contents

GoUI ships a tiny, dependency-free translator in github.com/zatrano/goui/i18n. It is intentionally simple: flat JSON key/value files per locale, one fallback locale, and a visible placeholder for missing keys so broken wiring never renders as a silently blank string.

import "github.com/zatrano/goui/i18n"

1. Translator setup

type Translator struct { /* ... */ }

func NewTranslator() *Translator
func (t *Translator) LoadLocale(locale string, filePath string) error
func (t *Translator) Translate(locale, key string, args ...any) string

Create one Translator per application (not per session, not per component) and share it everywhere:

tr := i18n.NewTranslator()
if err := tr.LoadLocale("tr", filepath.Join(root, "i18n", "locales", "tr.json")); err != nil {
    log.Fatal(err)
}
if err := tr.LoadLocale("en", filepath.Join(root, "i18n", "locales", "en.json")); err != nil {
    log.Fatal(err)
}

Then pass it to ws.NewServer — every session created by that server receives the same *Translator instance, and ws.Session calls SetTranslator on each mounted component automatically:

server := ws.NewServer(hub, registry, tr)
gouifiber.Register(app, gouifiber.Options{Server: server})

If you construct sub-fields yourself (e.g. Tier 1 inputs inside a parent form struct), call SetTranslator on each of them explicitly, since the session only reflects into the top-level component's own core.BaseComponent:

c := &ContactForm{ /* ... */ }
c.SetTranslator(tr)
c.Name.SetTranslator(tr)
c.Email.SetTranslator(tr)

An empty i18n.NewTranslator() with no loaded locales is perfectly valid — Translate just always falls through to the [[key]] placeholder, which is convenient for examples/demos that don't need real copy (see examples/counter, examples/searchable-select, etc., which pass a bare i18n.NewTranslator() into ws.NewServer(...)).

2. JSON file format

Locale files are flat JSON objects: string keys mapped to string values. LoadLocale explicitly does not support nested JSON objects — a key like "form.submit" is a single flat map key that merely looks namespaced because of the dot in it, not a nested object path like {"form": {"submit": "..."}}.

{
  "welcome_message": "Welcome, {{.Name}}",
  "form.required_field": "This field is required",
  "form.submit": "Submit",
  "form.cancel": "Cancel",
  "nav.home": "Home",
  "validation.required": "This field is required",
  "validation.email": "Please enter a valid email address"
}

This is what the codebase means by "nested-flat keys": dotted, hierarchical-looking key names (form.required_field, validation.email, forms.password_strength.weak) stored as a flat map[string]string, not an actually-nested JSON tree. Loading a genuinely nested JSON document ({"form": {"submit": "Submit"}}) will fail to json.Unmarshal into the map[string]string that LoadLocale expects — keep every locale file one level deep.

The framework's own bundled locales live at i18n/locales/tr.json and i18n/locales/en.json and cover the keys used by Tier 1/Tier 2 controls out of the box (validation.*, forms.password_strength.*, forms.date_range.invalid, forms.time_range.invalid, forms.otp.incomplete, etc.). Load them (or your own copies) so those built-in error messages render translated instead of as [[validation.required]].

Placeholders

Values may contain Go text/template placeholders. Translate re-parses the matched string as a template and executes it against the first variadic argument you pass:

tr.Translate("tr", "welcome_message", map[string]any{"Name": "Serhan"})
// → "Hoş geldin, Serhan"

If interpolation fails for any reason (bad template syntax in the JSON value, mismatched field name, etc.), Translate returns the raw (un-interpolated) string rather than erroring.

3. Lookup and fallback: tr then [[key]]

Translate(locale, key, args...) resolves in this order:

  1. Look up key in the messages loaded for locale.
  2. If not found (either the locale was never loaded, or the key is missing from it), and locale != i18n.BaseLocale, look up key in the messages loaded for i18n.BaseLocale ("tr" — the constant, currently hard-coded to Turkish).
  3. If still not found, return the literal string "[[" + key + "]]".
const BaseLocale = "tr"

This means:

  • An unsupported/unknown locale (say "de", never loaded) transparently falls back to whatever "tr" has for that key.
  • A supported locale that is simply missing one key (e.g. "en" loaded but someone forgot to add "form.cancel") also falls back to the "tr" value for that one key, not to a blank string.
  • A key that exists in neither the requested locale nor "tr" renders as "[[the.missing.key]]" — visible in the UI, easy to grep for in a demo or during QA, and impossible to confuse with real copy.
tr.Translate("de", "form.submit")       // "Gönder" — falls back to tr
tr.Translate("tr", "does.not.exist")    // "[[does.not.exist]]"

BaseComponent.T layers one more fallback on top: if a component has no translator injected at all (SetTranslator was never called), T returns "[[" + key + "]]" immediately without even attempting a lookup — so missing wiring and missing keys look identical and equally obvious.

4. Using translations from a component

func (c *MyComponent) Render() (string, error) {
    label := c.T("form.submit")                       // no placeholders
    greeting := c.T("welcome_message", map[string]any{"Name": "Ada"}) // with placeholders
    return "<button>" + html.EscapeString(label) + "</button>" +
        "<p>" + html.EscapeString(greeting) + "</p>", nil
}

Or, when rendering through core.RenderTemplate (which auto-escapes via html/template), pass T in the data and use {{call .T "key" .}} — see 02-components.md for the exact placeholder rules.

Locale on core.BaseComponent is what selects which locale T uses; it is set from the ?locale= query string parameter on the WebSocket URL (new GoUIClient('/goui/ws', 'counter', { locale: 'tr' })) and propagated by ws.Session when it mounts or activates a component. If Locale is empty, T uses i18n.BaseLocale ("tr").

5. Adding a new language

  1. Create i18n/locales/<code>.json (or wherever you keep your own locale files) with the same flat key set as tr.json/en.json — at minimum, the validation.* and forms.* keys the built-in Tier 1/Tier 2 controls rely on, plus your own application keys.

  2. Load it at startup alongside the others:

    tr := i18n.NewTranslator()
    _ = tr.LoadLocale("tr", filepath.Join(root, "i18n", "locales", "tr.json"))
    _ = tr.LoadLocale("en", filepath.Join(root, "i18n", "locales", "en.json"))
    _ = tr.LoadLocale("fr", filepath.Join(root, "i18n", "locales", "fr.json"))
    
  3. Have the client connect with locale: "fr":

    new GoUIClient('/goui/ws', 'contact', { mount: '#app', locale: 'fr' }).connect();
    
  4. Any key you haven't translated yet in fr.json silently falls back to the tr (base locale) value rather than breaking the page — fill it in whenever convenient, there is no build step or code change required.

There is currently no runtime API to change i18n.BaseLocale — it is a compile-time constant ("tr"). If your application's canonical language is not Turkish, ensure your primary/default locale file still has full coverage, since it is the fallback target for every other locale.