Internationalization (i18n)¶
Burrow has built-in internationalization with Accept-Language detection and go-i18n translations. i18n is managed by the Server automatically — no app registration needed.
Package: github.com/oliverandrich/burrow/i18n
How It Works¶
The Server:
- Creates an i18n bundle (English source-language tag, builtin validation translations)
- Auto-discovers all apps implementing
HasTranslationsand loads their TOML files — every locale discovered (fromactive.<locale>.tomlfilenames) is automatically added to the matcher's supported set - Registers locale detection middleware (reads
Accept-Languageheader) - Provides
lang,t,tData,tPluraltemplate functions
No i18n.New() call, no flag, no explicit setup needed. Drop an active.fr.toml into any contrib's translations/ directory and French is supported. With the Label-as-key convention, the "source language" lives in your {{ t "..." }} literals — a translation miss falls back to the literal, so a German-first project just writes German keys and ships only active.de.toml.
Adding Translations¶
Apps contribute translations by implementing the burrow.HasTranslations interface:
//go:embed translations
var translationFS embed.FS
func (a *App) TranslationFS() fs.FS { return translationFS }
The returned fs.FS must contain a translations/ directory with TOML files.
Translation Files¶
Example translations/active.en.toml:
[welcome]
other = "Welcome, {{.Name}}!"
[notes_count]
one = "{{.Count}} note"
other = "{{.Count}} notes"
Example translations/active.de.toml:
[welcome]
other = "Willkommen, {{.Name}}!"
[notes_count]
one = "{{.Count}} Notiz"
other = "{{.Count}} Notizen"
Labels as Keys¶
For short UI labels (nav items, form fields, button captions), the convention is to use the English string itself as the message ID — the same approach as Django's gettext. This avoids maintaining a separate dictionary of opaque keys:
# translations/active.en.toml
Notes = "Notes"
Save = "Save"
"Sign out" = "Sign out"
# translations/active.de.toml
Notes = "Notizen"
Save = "Speichern"
"Sign out" = "Abmelden"
TOML keys with spaces must be quoted ("Sign out"); single-word labels can stay bare (Notes). Both forms work identically as message IDs.
Three burrow APIs do this translation automatically — templates just render {{ .Label }} and get the translated string:
NavItem.Label— translated bybuildNavLinkswhen thenavLinkstemplate function is invoked.forms.BoundField.Label— read from theverbose:/verbose_name:struct tag, translated byextractFieldswhen the form'sFields()is called.forms.Choice.Label— translated alongsideBoundField.Labelin the same pass, covering bothWithChoicesandWithChoicesFunc.
If no translation matches the Label, the raw Label is rendered, so an English-only app needs no translation files at all.
Caveat: rewording a Label (e.g., "Sign out" → "Log out") silently invalidates existing translations under the old key. When labels change, re-extract translations like you would with any gettext-style workflow. For labels that are deliberately unstable, structured keys (auth-action-signout) remain a valid choice — just don't mix the two patterns for the same string.
Labels vs. Messages¶
Use Label-as-key for labels, structured keys for messages:
| Type | Use Label-as-key | Use structured key |
|---|---|---|
| Single word or short phrase (1–4 words) | ✅ | |
| No sentence-ending punctuation | ✅ | |
| Button label, table header, form field label, badge, breadcrumb, tab | ✅ | |
| Full sentence (subject + verb) | ✅ | |
Has ., ?, or ! |
✅ | |
| User-facing message, prompt, warning, error | ✅ | |
Plural variant ([key] with one/other) |
✅ | |
Has variable interpolation ({{.Count}} items) |
✅ | |
Key constructed at runtime (e.g. printf "admin-alert-%s" .Level) |
✅ |
This produces TOML files in two clearly-grouped blocks — a glossary of UI labels at the top, structured messages below:
# Field labels (Label-as-key)
Activate = "Activate"
Active = "Active"
Cancel = "Cancel"
"Create Invite" = "Create Invite"
Deactivate = "Deactivate"
Email = "Email"
Save = "Save"
Username = "Username"
# Messages (structured)
admin-users-none = "No users found."
admin-users-delete-confirm = "Delete this user? This cannot be undone."
# Plural variants (must be structured for go-i18n)
[modeladmin-delete-confirm]
one = "Are you sure you want to delete this item?"
other = "Are you sure you want to delete {{.Count}} items?"
When in doubt, default to a structured key. Migrating from structured to Label-as-key later is straightforward; the reverse is more invasive because you then have to invent a key.
Using in Templates¶
These template functions are available in all templates:
| Function | Description |
|---|---|
{{ lang }} |
Current locale string (e.g., "en", "de") |
{{ t "key" }} |
Simple translation lookup |
{{ tData "key" .DataMap }} |
Translation with template data |
{{ tPlural "key" .Count }} |
Pluralised translation |
These are registered as request-scoped template functions (via HasRequestFuncMap) and are available in all templates — no import or {% load %} needed.
{{ define "notes/list" -}}
<html lang="{{ lang }}">
<body>
<h1>{{ t "notes-title" }}</h1>
<p>{{ tData "welcome" .WelcomeData }}</p>
<span>{{ tPlural "notes_count" .Count }}</span>
</body>
{{- end }}
Go API¶
T — Simple Translation¶
TData — Translation with Template Data¶
TPlural — Pluralised Translation¶
msg := i18n.TPlural(ctx, "notes_count", 5)
// "5 notes"
msg = i18n.TPlural(ctx, "notes_count", 1)
// "1 note"
Locale — Current Locale¶
All functions fall back to the message ID if no translation is found.
Programmatic Locale (Jobs)¶
For background jobs that need a specific locale (e.g., sending emails), use AppConfig.WithLocale:
func (a *App) Configure(cfg *burrow.AppConfig, _ *cli.Command) error {
a.withLocale = cfg.WithLocale
return nil
}
// In a job handler:
ctx = a.withLocale(ctx, userLocale)
Language Matching & Fallback¶
The middleware reads the browser's Accept-Language header and matches it against the configured supported languages using Go's golang.org/x/text/language matcher. If no match is found, the default language is used.
| Accept-Language | Resolved Locale | Reason |
|---|---|---|
de-AT,de;q=0.9 |
de |
Regional variant matches base language |
fr-FR,fr;q=0.9 |
en |
French not supported, falls back to default |
de;q=0.8,en;q=0.9 |
en |
English has higher quality value |
| (empty) | en |
No header, uses default |
Translating Validation Errors¶
When using validation, error messages default to English. Use ValidationError.Translate to translate them:
import "github.com/oliverandrich/burrow/i18n"
if err := burrow.Bind(r, &req); err != nil {
var ve *burrow.ValidationError
if errors.As(err, &ve) {
ve.Translate(r.Context(), i18n.TData)
return burrow.Render(w, r, http.StatusUnprocessableEntity, "myapp/form", map[string]any{
"Errors": ve,
})
}
return err
}
Built-in Translation Keys¶
Burrow ships with English and German translations for all built-in validation tags:
| Key | English Template |
|---|---|
validation-required |
{{.Field}} is required |
validation-email |
{{.Field}} must be a valid email address |
validation-min |
{{.Field}} must be at least {{.Param}} |
validation-max |
{{.Field}} must be at most {{.Param}} |
validation-len |
{{.Field}} must be exactly {{.Param}} characters |
validation-gte |
{{.Field}} must be greater than or equal to {{.Param}} |
validation-lte |
{{.Field}} must be less than or equal to {{.Param}} |
validation-url |
{{.Field}} must be a valid URL |
Template variables: {{.Field}} is the field name, {{.Param}} is the tag parameter.
Overriding Translations¶
Your app's translation files are loaded after the built-in ones (last-loaded wins). To customise a validation message, define the same key in your TOML file:
Configuration¶
No CLI flags. The set of supported locales is the union of active.<locale>.toml filenames across every registered HasTranslations app; the bundle's source-language tag is fixed to English (a plural-rule + last-resort matcher fallback only — the Label-as-key convention makes the source-language string the actual fallback for any {{ t "..." }} call).
Testing¶
Use i18n.NewTestBundle for tests: