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:
- Look up
keyin the messages loaded forlocale. - If not found (either the locale was never loaded, or the key is missing
from it), and
locale != i18n.BaseLocale, look upkeyin the messages loaded fori18n.BaseLocale("tr"— the constant, currently hard-coded to Turkish). - 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
-
Create
i18n/locales/<code>.json(or wherever you keep your own locale files) with the same flat key set astr.json/en.json— at minimum, thevalidation.*andforms.*keys the built-in Tier 1/Tier 2 controls rely on, plus your own application keys. -
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")) -
Have the client connect with
locale: "fr":new GoUIClient('/goui/ws', 'contact', { mount: '#app', locale: 'fr' }).connect(); -
Any key you haven't translated yet in
fr.jsonsilently falls back to thetr(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.