Gonçalo Rodrigues 4b7c01e632 feat(finance): i18n — TOML-based translations for all personal finance templates
Adds a full translation layer (English + European Portuguese) using
BurntSushi/toml with go:embed. Locale detection reads the lang cookie,
falls back to Accept-Language, then defaults to "en". A language switcher
in the nav writes the cookie and redirects back. All 20 personal finance
templates now use {{.T.Get "key"}} for every UI string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 22:32:49 +01:00

121 lines
2.7 KiB
Go

package main
import (
"embed"
"log/slog"
"net/http"
"strings"
"github.com/BurntSushi/toml"
)
//go:embed locales/*.toml
var localeFS embed.FS
const defaultLang = "en"
var supportedLangs = map[string]bool{"en": true, "pt": true}
// catalogue holds the flattened key→string map for one language.
type catalogue map[string]string
var catalogues = map[string]catalogue{}
func init() {
for lang := range supportedLangs {
cat, err := loadCatalogue(lang)
if err != nil {
slog.Error("i18n: failed to load locale", "lang", lang, "err", err)
continue
}
catalogues[lang] = cat
}
}
func loadCatalogue(lang string) (catalogue, error) {
data, err := localeFS.ReadFile("locales/" + lang + ".toml")
if err != nil {
return nil, err
}
var raw map[string]any
if err := toml.Unmarshal(data, &raw); err != nil {
return nil, err
}
cat := make(catalogue)
flattenTOML("", raw, cat)
return cat, nil
}
// flattenTOML recursively flattens nested TOML tables into dot-notation keys.
func flattenTOML(prefix string, node map[string]any, out catalogue) {
for k, v := range node {
key := k
if prefix != "" {
key = prefix + "." + k
}
switch val := v.(type) {
case string:
out[key] = val
case map[string]any:
flattenTOML(key, val, out)
}
}
}
// Translator wraps a locale lookup and exposes a Get method callable from
// Go templates as {{.T.Get "key"}}.
type Translator struct {
cat catalogue
en catalogue
}
func (tr *Translator) Get(key string) string {
if s, ok := tr.cat[key]; ok {
return s
}
if s, ok := tr.en[key]; ok {
return s
}
return key
}
// newT returns a Translator for the given language.
func newT(lang string) *Translator {
cat, ok := catalogues[lang]
if !ok {
cat = catalogues[defaultLang]
}
return &Translator{cat: cat, en: catalogues[defaultLang]}
}
// detectLang reads the lang from a cookie, falling back to Accept-Language,
// then to the default. Only returns a supported language code.
func detectLang(r *http.Request) string {
if c, err := r.Cookie("lang"); err == nil {
if supportedLangs[c.Value] {
return c.Value
}
}
for _, part := range strings.Split(r.Header.Get("Accept-Language"), ",") {
tag := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
code := strings.ToLower(strings.SplitN(tag, "-", 2)[0])
if supportedLangs[code] {
return code
}
}
return defaultLang
}
// setLangCookie writes the lang preference cookie.
func setLangCookie(w http.ResponseWriter, lang string, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: "lang",
Value: lang,
Path: "/",
HttpOnly: false, // JS may read it for future enhancement
Secure: secure,
MaxAge: 365 * 24 * 3600,
SameSite: http.SameSiteStrictMode,
})
}