Skip to content

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:

  1. Creates an i18n bundle (English source-language tag, builtin validation translations)
  2. Auto-discovers all apps implementing HasTranslations and loads their TOML files — every locale discovered (from active.<locale>.toml filenames) is automatically added to the matcher's supported set
  3. Registers locale detection middleware (reads Accept-Language header)
  4. Provides lang, t, tData, tPlural template 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

translations/
├── active.en.toml
└── active.de.toml

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 by buildNavLinks when the navLinks template function is invoked.
  • forms.BoundField.Label — read from the verbose: / verbose_name: struct tag, translated by extractFields when the form's Fields() is called.
  • forms.Choice.Label — translated alongside BoundField.Label in the same pass, covering both WithChoices and WithChoicesFunc.

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

import "github.com/oliverandrich/burrow/i18n"

msg := i18n.T(ctx, "welcome")

TData — Translation with Template Data

msg := i18n.TData(ctx, "welcome", map[string]any{
    "Name": "Alice",
})
// "Welcome, Alice!"

TPlural — Pluralised Translation

msg := i18n.TPlural(ctx, "notes_count", 5)
// "5 notes"

msg = i18n.TPlural(ctx, "notes_count", 1)
// "1 note"

Locale — Current Locale

locale := i18n.Locale(ctx)
// "en", "de", etc.

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:

# translations/active.en.toml
validation-required = "{{.Field}} cannot be blank"

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:

bundle, err := i18n.NewTestBundle(translationFS)
ctx := bundle.WithLocale(context.Background(), "de")
assert.Equal(t, "Hallo", i18n.T(ctx, "hello"))