167 lines
5.9 KiB
Go
167 lines
5.9 KiB
Go
// Package i18n is enkly's shared translation catalog: the single
|
|
// source of truth, for every service, of which UI languages exist and
|
|
// what their strings are. It is deliberately tiny and dependency-free
|
|
// (encoding/json + embed, nothing else) so both the gateway (which
|
|
// renders the HTML chrome a visitor reads) and the agent service
|
|
// (which needs the same locale codes and display names to tell the
|
|
// model which language the visitor has chosen — see
|
|
// agentrun.WithPreferredLanguage) can import it without pulling either
|
|
// service into the other's build.
|
|
//
|
|
// This is UI-chrome localization — buttons, labels, system-generated
|
|
// notices ("you've reached today's message limit") — not a substitute
|
|
// for the agent's own language behavior: the model already detects
|
|
// and mirrors whatever language a user types in conversation (see
|
|
// agentrun.SystemPrompt's "## Language" section). The two compose: a
|
|
// visitor who has never touched the language switcher still gets
|
|
// replies in their own language because the model notices; a visitor
|
|
// who *has* set a UI language gets the chrome in that language too,
|
|
// and the agent treats it as their standing preference (see
|
|
// agentrun.WithPreferredLanguage's doc comment for exactly how the
|
|
// two signals are reconciled when they disagree).
|
|
package i18n
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"sync"
|
|
)
|
|
|
|
// Locale is a BCP-47-ish language tag — in practice just the short
|
|
// codes listed in Supported below ("en", "fr", …). It's a defined
|
|
// type rather than a bare string so a caller can't accidentally pass
|
|
// a translation key where a locale was expected, or vice versa.
|
|
type Locale string
|
|
|
|
// Default is used whenever no locale can be determined at all (no
|
|
// cookie, no Accept-Language header, no account preference) and as
|
|
// the fallback catalog for any key missing from a more specific
|
|
// locale's translation file.
|
|
const Default Locale = "en"
|
|
|
|
// Info is one supported locale's code and its own display name (in
|
|
// that language, not in the viewer's — "Français", not "French" —
|
|
// which is how every language picker from a browser's own settings to
|
|
// Claude Code's `/language` command does it).
|
|
type Info struct {
|
|
Code Locale
|
|
Name string
|
|
}
|
|
|
|
// Supported lists every locale enkly ships a catalog for, in the
|
|
// order a language switcher should present them. Adding a language is
|
|
// a two-line change: a new entry here, naming the file added under
|
|
// locales/, and the entry in catalogs below the embed directive.
|
|
//
|
|
// enkly's first deployments are French West African (see the FCFA
|
|
// pricing in the billing service), so French ships complete; the rest
|
|
// cover enough of the chrome (nav, composer, auth, errors) to be
|
|
// usable, falling back to English for anything not yet translated —
|
|
// see T's doc comment. Extending any of them to full coverage is a
|
|
// matter of filling in locales/<code>.json, not touching any Go code.
|
|
var Supported = []Info{
|
|
{Code: "en", Name: "English"},
|
|
{Code: "fr", Name: "Français"},
|
|
{Code: "es", Name: "Español"},
|
|
{Code: "de", Name: "Deutsch"},
|
|
{Code: "pt", Name: "Português"},
|
|
}
|
|
|
|
// IsSupported reports whether code names a locale enkly ships a
|
|
// catalog for.
|
|
func IsSupported(code string) bool {
|
|
for _, info := range Supported {
|
|
if string(info.Code) == code {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ParseLocale returns code as a Locale if it's one enkly supports,
|
|
// else Default. Use this anywhere an untrusted string (a cookie
|
|
// value, a query parameter, one tag out of an Accept-Language header)
|
|
// needs to become a Locale — it never returns an unsupported value.
|
|
func ParseLocale(code string) Locale {
|
|
if IsSupported(code) {
|
|
return Locale(code)
|
|
}
|
|
return Default
|
|
}
|
|
|
|
// NameOf returns a locale's own display name ("Français" for "fr"),
|
|
// or its bare code if it isn't one of Supported (shouldn't happen for
|
|
// a Locale that came from ParseLocale, but T and callers that build a
|
|
// prompt string from a Locale value want something printable either
|
|
// way).
|
|
func NameOf(l Locale) string {
|
|
for _, info := range Supported {
|
|
if info.Code == l {
|
|
return info.Name
|
|
}
|
|
}
|
|
return string(l)
|
|
}
|
|
|
|
var (
|
|
mu sync.RWMutex
|
|
missLog = map[string]bool{} // "locale/key" already warned about, so a hot template doesn't spam the log
|
|
logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
|
|
catalogs map[Locale]map[string]string
|
|
)
|
|
|
|
func init() {
|
|
catalogs = make(map[Locale]map[string]string, len(catalogFS))
|
|
for locale, raw := range catalogFS {
|
|
var strings map[string]string
|
|
if err := json.Unmarshal(raw, &strings); err != nil {
|
|
// A malformed embedded catalog is a build-time mistake, not
|
|
// a runtime condition any caller can recover from — fail
|
|
// loudly at process start rather than silently serving
|
|
// blank strings in production.
|
|
panic(fmt.Sprintf("i18n: locale %q: invalid catalog JSON: %v", locale, err))
|
|
}
|
|
catalogs[locale] = strings
|
|
}
|
|
}
|
|
|
|
// T looks up key in locale's catalog and, if args is non-empty, runs
|
|
// the result through fmt.Sprintf (so a catalog entry can contain %s /
|
|
// %d verbs the same way Go source would). A key missing from locale
|
|
// falls back to the Default catalog, and a key missing from Default
|
|
// too falls back to the key itself — so a template that references a
|
|
// not-yet-translated string degrades to showing its own key (an
|
|
// unmistakable "translate me" placeholder during development) instead
|
|
// of a blank space or a panic in production. Each distinct miss is
|
|
// logged once per process, not once per request.
|
|
func T(locale Locale, key string, args ...any) string {
|
|
mu.RLock()
|
|
s, ok := catalogs[locale][key]
|
|
if !ok {
|
|
s, ok = catalogs[Default][key]
|
|
}
|
|
mu.RUnlock()
|
|
|
|
if !ok {
|
|
warnMissing(locale, key)
|
|
s = key
|
|
}
|
|
if len(args) == 0 {
|
|
return s
|
|
}
|
|
return fmt.Sprintf(s, args...)
|
|
}
|
|
|
|
func warnMissing(locale Locale, key string) {
|
|
id := string(locale) + "/" + key
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if missLog[id] {
|
|
return
|
|
}
|
|
missLog[id] = true
|
|
logger.Warn("i18n: missing translation key", "locale", locale, "key", key)
|
|
}
|