// 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/.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) }