Files
llmgw/internal/web/i18n.go
T
Ilker ManapandClaude Opus 5.5 11231d8c2b The portal in English and Turkish: a catalogue per language, a switcher at the foot of every page, landing.language, Turkish numbers and dates
Every template and handler message in internal/web goes through a JSON
catalogue under internal/web/locales/<code>/, with English as the fallback.
The language is the person's choice (a cookie set by /_lang/{code}), else
Accept-Language, else landing.language. Tests refuse a key some language
lacks, a translation with other arguments than English, and template text
left in English.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:12:53 +02:00

707 lines
19 KiB
Go

package web
import (
"bytes"
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"io/fs"
"math"
"net/http"
"net/url"
"path"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"golang.org/x/text/language"
)
// The portal speaks more than one language. Every string a person reads is
// looked up by a key in a catalogue, one directory per language under
// locales/, so that adding a language is adding a directory: the code names
// no language but English, which is what everything falls back to.
//
// A catalogue is flat JSON, key to text. The text is a fmt format when the
// caller passes arguments ("%s was approved", "%[2]s içinde %[1]d"), so a
// language whose word order differs moves the arguments rather than asking
// the code to. Keys under "_meta." are not text but how the language writes
// numbers and dates.
//
// Several files per language rather than one, so the catalogue reads in the
// same parts as the pages; a key defined twice in one language is an error at
// start, not a silent winner.
//go:embed locales
var localeFiles embed.FS
// Lang is one language the portal speaks.
type Lang struct {
// Code is the language's BCP 47 base tag: "en", "tr".
Code string
// Name is what the language calls itself, which is what the switcher
// offers: a person looking for Türkçe looks for that word, not "Turkish".
Name string
msgs map[string]string
// decimal and group are the separators numbers are written with. An empty
// group writes no thousands separator, which is how English figures have
// always appeared on these pages.
decimal, group string
// date and dateTime are Go layouts.
date, dateTime string
tmpl *template.Template
}
// langCookie holds the language a person chose with the switcher. It outranks
// what the browser asks for, because the person said so on this site and the
// browser said so for every site.
const langCookie = "llmgw_lang"
var (
// languages is every language with a catalogue, English first and then by
// code, which is the order the switcher shows.
languages []*Lang
// english is the fallback for a key another language lacks.
english *Lang
// defaultLang is the server's choice for a person who neither chose nor
// sent a language this server speaks: landing.language.
defaultLang atomic.Pointer[Lang]
)
func init() {
langs, err := loadLanguages(localeFiles)
if err != nil {
panic("web: " + err.Error())
}
languages = langs
english = languages[0]
defaultLang.Store(english)
}
// loadLanguages reads every catalogue under locales/.
func loadLanguages(fsys fs.FS) ([]*Lang, error) {
dirs, err := fs.ReadDir(fsys, "locales")
if err != nil {
return nil, err
}
var out []*Lang
for _, d := range dirs {
if !d.IsDir() {
continue
}
l, err := loadLang(fsys, d.Name())
if err != nil {
return nil, err
}
out = append(out, l)
}
slices.SortFunc(out, func(a, b *Lang) int {
switch {
case a.Code == b.Code:
return 0
case a.Code == "en":
return -1
case b.Code == "en":
return 1
}
return strings.Compare(a.Code, b.Code)
})
if len(out) == 0 || out[0].Code != "en" {
return nil, errors.New("the English catalogue is missing from the binary")
}
return out, nil
}
func loadLang(fsys fs.FS, code string) (*Lang, error) {
l := &Lang{Code: code, msgs: map[string]string{}}
names, err := fs.Glob(fsys, path.Join("locales", code, "*.json"))
if err != nil {
return nil, err
}
for _, name := range names {
b, err := fs.ReadFile(fsys, name)
if err != nil {
return nil, err
}
m, err := decodeCatalogue(b)
if err != nil {
return nil, fmt.Errorf("%s: %w", name, err)
}
for k, v := range m {
if _, dup := l.msgs[k]; dup {
return nil, fmt.Errorf("%s: %q is defined twice in %s", name, k, code)
}
l.msgs[k] = v
}
}
l.Name = l.msgs["_meta.name"]
if l.Name == "" {
return nil, fmt.Errorf("the %s catalogue does not say its own name (_meta.name)", code)
}
l.decimal = cmpOr(l.msgs["_meta.decimal"], ".")
l.group = l.msgs["_meta.group"]
l.date = cmpOr(l.msgs["_meta.date"], "2006-01-02")
l.dateTime = cmpOr(l.msgs["_meta.datetime"], "2006-01-02 15:04")
return l, nil
}
// decodeCatalogue reads one file of a catalogue, refusing a key that appears
// twice in it: encoding/json would quietly keep the second.
func decodeCatalogue(b []byte) (map[string]string, error) {
dec := json.NewDecoder(bytes.NewReader(b))
if tok, err := dec.Token(); err != nil || tok != json.Delim('{') {
return nil, errors.New("a catalogue is one JSON object")
}
m := map[string]string{}
for dec.More() {
tok, err := dec.Token()
if err != nil {
return nil, err
}
k, _ := tok.(string)
var v string
if err := dec.Decode(&v); err != nil {
return nil, fmt.Errorf("%q: %w", k, err)
}
if _, dup := m[k]; dup {
return nil, fmt.Errorf("%q is defined twice", k)
}
m[k] = v
}
return m, nil
}
func cmpOr(v, def string) string {
if v == "" {
return def
}
return v
}
// Languages are the codes the portal speaks, for a message naming them.
func Languages() []string {
codes := make([]string, len(languages))
for i, l := range languages {
codes[i] = l.Code
}
return codes
}
// SetDefaultLanguage is landing.language: what a person sees who neither
// chose a language nor sent one this server speaks. Empty is English.
//
// Package state rather than a field of the pages, because the dashboard on
// the admin listener and the portal on the client port are separate handlers
// built in separate places and must agree.
func SetDefaultLanguage(code string) error {
if code == "" {
defaultLang.Store(english)
return nil
}
l := langByCode(code)
if l == nil {
return fmt.Errorf("the portal does not speak %q; it speaks %s", code, strings.Join(Languages(), ", "))
}
defaultLang.Store(l)
return nil
}
func langByCode(code string) *Lang {
code = strings.ToLower(strings.TrimSpace(code))
if i := strings.IndexAny(code, "-_"); i >= 0 {
code = code[:i]
}
for _, l := range languages {
if l.Code == code {
return l
}
}
return nil
}
// langOf is the language a request is answered in: the person's own choice,
// else the first language their browser asks for that this server speaks,
// else the server's default.
func langOf(r *http.Request) *Lang {
if r == nil {
return defaultLang.Load()
}
if c, err := r.Cookie(langCookie); err == nil {
if l := langByCode(c.Value); l != nil {
return l
}
}
if h := r.Header.Get("Accept-Language"); h != "" {
// Sorted by the weights the browser gave; a malformed header is
// worth what can be read of it.
tags, _, _ := language.ParseAcceptLanguage(h)
for _, t := range tags {
base, _ := t.Base()
if l := langByCode(base.String()); l != nil {
return l
}
}
}
return defaultLang.Load()
}
// T is the text for key, formatted with args when there are any. A key this
// language lacks is the English text, and a key English lacks is the key
// itself — visible, which is the point, and caught by the catalogue test
// before it ships.
func (l *Lang) T(key string, args ...any) string {
s, ok := l.msgs[key]
if !ok && l != english {
s, ok = english.msgs[key]
}
if !ok {
s = key
}
// A text that takes no arguments is not a format, even when it is
// handed some: "1 hour ago" says its count in words.
if len(args) == 0 || !strings.Contains(s, "%") {
return s
}
return fmt.Sprintf(s, args...)
}
// N is T for a sentence about a count: key.one when n is one and the
// language has a singular there, else key.other, else key. The count is the
// first argument. Turkish says "3 dakika" as it says "1 dakika", and needs
// only key.other; English needs both.
func (l *Lang) N(key string, n int64, args ...any) string {
args = append([]any{n}, args...)
// The singular only from this language's own catalogue: English's
// "1 hour ago" is no fallback for a language that says "1 saat önce"
// with the same form as any other count.
if _, ok := l.msgs[key+".one"]; ok && n == 1 {
return l.T(key+".one", args...)
}
if l.has(key + ".other") {
return l.T(key+".other", args...)
}
return l.T(key, args...)
}
// has is whether the key has text in this language or, failing that, in
// English.
func (l *Lang) has(key string) bool {
if _, ok := l.msgs[key]; ok {
return true
}
_, ok := english.msgs[key]
return ok
}
// TH is T as markup, for the few sentences with a link or emphasis in the
// middle, which a translation has to be able to move. The catalogue is
// compiled into the binary and trusted; the arguments are not, and are
// escaped unless they are markup already.
func (l *Lang) TH(key string, args ...any) template.HTML {
safe := make([]any, len(args))
for i, a := range args {
switch v := a.(type) {
case template.HTML:
safe[i] = string(v)
case string:
safe[i] = template.HTMLEscapeString(v)
case fmt.Stringer:
safe[i] = template.HTMLEscapeString(v.String())
default:
safe[i] = template.HTMLEscapeString(fmt.Sprint(v))
}
}
return template.HTML(l.T(key, safe...)) //nolint:gosec // the catalogue is ours, the arguments are escaped above
}
// errorKeys name the errors from outside this package that a page shows,
// so a person reads them in their language. An error not listed is shown as
// it is, in English, which is better than not at all.
var (
errorKeysMu sync.Mutex
errorKeys []errorKey
)
type errorKey struct {
err error
key string
}
// registerErrorKey says which catalogue key an error is shown as. Called
// from the init of the file whose page shows it.
func registerErrorKey(err error, key string) {
errorKeysMu.Lock()
defer errorKeysMu.Unlock()
errorKeys = append(errorKeys, errorKey{err, key})
}
// Err is an error as a person reads it in this language.
func (l *Lang) Err(err error) string {
if err == nil {
return ""
}
errorKeysMu.Lock()
keys := errorKeys
errorKeysMu.Unlock()
for _, ek := range keys {
if errors.Is(err, ek.err) {
return l.T(ek.key)
}
}
return err.Error()
}
// dynamicKeys are keys a template or handler builds rather than writes out
// — "key.state." and a state — which the catalogue test cannot find by
// reading the source. Whoever builds one lists the whole set here.
var (
dynamicKeysMu sync.Mutex
dynamicKeys []string
)
// registerKeys lists keys that are looked up by a computed name.
func registerKeys(keys ...string) {
dynamicKeysMu.Lock()
defer dynamicKeysMu.Unlock()
dynamicKeys = append(dynamicKeys, keys...)
}
// Numbers.
// Num writes v with places decimals, in this language's separators.
func (l *Lang) Num(v float64, places int) string {
if math.IsNaN(v) || math.IsInf(v, 0) {
return "—"
}
return l.localNumber(strconv.FormatFloat(v, 'f', places, 64))
}
// Int writes a whole number in this language's separators.
func (l *Lang) Int(v int64) string {
return l.localNumber(strconv.FormatInt(v, 10))
}
// localNumber takes a number as Go writes it — an optional minus, digits,
// optionally a point and more digits — and writes it this language's way:
// 1234.5 is 1.234,5 in Turkish.
func (l *Lang) localNumber(s string) string {
if l.decimal == "." && l.group == "" {
return s
}
sign := ""
if strings.HasPrefix(s, "-") {
sign, s = "-", s[1:]
}
whole, frac, hasFrac := strings.Cut(s, ".")
if l.group != "" && len(whole) > 3 {
var b strings.Builder
for i, c := range whole {
if i > 0 && (len(whole)-i)%3 == 0 {
b.WriteString(l.group)
}
b.WriteRune(c)
}
whole = b.String()
}
if hasFrac {
return sign + whole + l.decimal + frac
}
return sign + whole
}
// Compact writes a large count the way someone says it aloud: 1.2k, 1,2 bin.
func (l *Lang) Compact(v float64) string {
switch {
case v >= 1e9:
return l.T("num.billions", l.Num(v/1e9, 1))
case v >= 1e6:
return l.T("num.millions", l.Num(v/1e6, 1))
case v >= 1e3:
return l.T("num.thousands", l.Num(v/1e3, 1))
case v == float64(int64(v)):
return l.Int(int64(v))
case v > 0 && v < 1:
// Two significant digits rather than one decimal place: a spend of
// 0.03 printed as "0.0" says nothing was spent.
return l.localNumber(strconv.FormatFloat(v, 'g', 2, 64))
default:
return l.Num(v, 1)
}
}
// Units writes micro-units as the whole units a budget is written in.
func (l *Lang) Units(micros int64) string {
return l.Num(float64(micros)/1e6, 2)
}
// Bytes writes a size in binary units, the way the shell does.
func (l *Lang) Bytes(n int64) string {
const unit = 1024
if n < unit {
return l.Int(n) + " B"
}
div, exp := int64(unit), 0
for v := n / unit; v >= unit; v /= unit {
div *= unit
exp++
}
return l.Num(float64(n)/float64(div), 1) + " " + string("KMGTPE"[exp]) + "iB"
}
// Latency writes a mean latency the way a person reads one: 5 ms, 8.8 s,
// 1m 51s — not 110695 ms.
func (l *Lang) Latency(ms float64) string {
switch {
case ms < 1000:
return l.T("unit.ms", l.Num(ms, 0))
case ms < 60_000:
return l.T("unit.s", l.Num(ms/1000, 1))
default:
d := time.Duration(ms) * time.Millisecond
return l.T("unit.min_s", int(d.Minutes()), int(d.Seconds())%60)
}
}
// Ago says how long ago something was, to the unit that matters and no finer.
func (l *Lang) Ago(t time.Time) string {
if t.IsZero() {
return l.T("time.never")
}
d := time.Since(t)
n := func(v float64) int64 { return int64(v + 0.5) }
switch {
case d < 45*time.Second:
return l.T("time.just_now")
case d < time.Hour:
return l.N("time.minutes_ago", n(d.Minutes()))
case d < 36*time.Hour:
return l.N("time.hours_ago", n(d.Hours()))
case d < 14*24*time.Hour:
return l.N("time.days_ago", n(d.Hours()/24))
case d < 60*24*time.Hour:
return l.N("time.weeks_ago", n(d.Hours()/24/7))
default:
return l.N("time.months_ago", n(d.Hours()/24/30))
}
}
// Age is a short duration in seconds as a column shows it: 45s, 12m, 3h05m,
// 9d.
func (l *Lang) Age(sec int64) string {
d := time.Duration(sec) * time.Second
switch {
case d < time.Minute:
return l.T("unit.age_s", sec)
case d < time.Hour:
return l.T("unit.age_m", int(d.Minutes()))
case d < 48*time.Hour:
return l.T("unit.age_hm", int(d.Hours()), int(d.Minutes())%60)
}
return l.T("unit.age_d", int(d.Hours()/24))
}
// Date is a day, the way this language writes one: 2026-10-04, 04.10.2026.
func (l *Lang) Date(t time.Time) string {
if t.IsZero() {
return "—"
}
return t.Format(l.date)
}
// DateTime is a day and a time of day.
func (l *Lang) DateTime(t time.Time) string {
if t.IsZero() {
return "—"
}
return t.Format(l.dateTime)
}
var (
weekdayNames = []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
monthNames = []string{"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"}
)
// Format is time.Format with the names of days and months in this
// language, for layouts that use them ("Mon 15:04", "2 Jan").
func (l *Lang) Format(t time.Time, layout string) string {
out := t.Format(layout)
if l == english {
return out
}
// Go writes the English name only where the layout asked for one, and
// the rest of a layout is digits and punctuation, so swapping the names
// in the result is exact. Full names first: "Monday" contains "Mon".
wd, mo := weekdayNames[t.Weekday()], monthNames[t.Month()-1]
key := strings.ToLower(wd[:3])
mkey := strings.ToLower(mo[:3])
out = strings.ReplaceAll(out, wd, l.T("time.weekday."+key))
out = strings.ReplaceAll(out, wd[:3], l.T("time.wd."+key))
out = strings.ReplaceAll(out, mo, l.T("time.month."+mkey))
out = strings.ReplaceAll(out, mo[:3], l.T("time.mon."+mkey))
return out
}
func init() {
for _, d := range weekdayNames {
k := strings.ToLower(d[:3])
registerKeys("time.weekday."+k, "time.wd."+k)
}
for _, m := range monthNames {
k := strings.ToLower(m[:3])
registerKeys("time.month."+k, "time.mon."+k)
}
}
// toFloat accepts any number a template has in hand.
func toFloat(v any) float64 {
switch n := v.(type) {
case float64:
return n
case float32:
return float64(n)
case int:
return float64(n)
case int64:
return float64(n)
case int32:
return float64(n)
case uint64:
return float64(n)
case uint32:
return float64(n)
case uint:
return float64(n)
}
return math.NaN()
}
// toInt is toFloat for whole numbers, exact for the ones that are.
func toInt(v any) (int64, bool) {
switch n := v.(type) {
case int:
return int64(n), true
case int64:
return n, true
case int32:
return int64(n), true
case uint64:
return int64(min(n, math.MaxInt64)), true
case uint32:
return int64(n), true
case uint:
return int64(min(uint64(n), math.MaxInt64)), true
}
return 0, false
}
// funcs are the template functions that speak this language. They replace
// the English ones of the same name in the language's own set of templates.
func (l *Lang) funcs() template.FuncMap {
return template.FuncMap{
"t": l.T,
"th": l.TH,
"tn": func(key string, n any, args ...any) string {
c, ok := toInt(n)
if !ok {
c = int64(toFloat(n))
}
return l.N(key, c, args...)
},
// lang is the code for <html lang>.
"lang": func() string { return l.Code },
// languages is what the switcher offers.
"languages": func() []*Lang { return languages },
// num is a figure with a fixed number of decimals; count a whole
// one. Both write the language's separators, and neither belongs in
// a style attribute or an SVG coordinate, which are always written
// the C way.
"num": func(v any, places int) string { return l.Num(toFloat(v), places) },
"count": func(v any) string {
if n, ok := toInt(v); ok {
return l.Int(n)
}
return l.Num(toFloat(v), 0)
},
"pct1": func(v float64) string { return l.Num(v, 1) },
"units": l.Units,
"compact": func(v int64) string { return l.Compact(float64(v)) },
"bytes": l.Bytes,
"latency": l.Latency,
"ago": l.Ago,
"age": l.Age,
"date": l.Date,
"datetime": l.DateTime,
"pctile": l.pctile,
"buildLabel": func(b string) string {
switch b {
case "auto", "none":
return l.T("engine.build." + b)
case "cu13":
return "CUDA 13"
case "cu129":
return "CUDA 12.9"
case "cu12":
return "CUDA 12"
}
return b
},
}
}
func init() {
registerKeys("engine.build.auto", "engine.build.none")
}
// templates parses the pages once per language, each set with that
// language's functions, so a template says {{t "key"}} and gets the
// language of the request it is answering without being handed it.
func parseTemplates() {
for _, l := range languages {
l.tmpl = template.Must(template.New("").Funcs(funcs).Funcs(l.funcs()).ParseFS(files, "templates/*.html"))
}
}
// Language switching.
// SwitchLanguage is GET /_lang/{code}: it remembers the choice in a cookie
// and goes back to the page the link was on.
//
// A link rather than a form, so it works on every page without a CSRF token
// or a script. Changing someone's language is the whole of what a forged
// request could do with it.
func SwitchLanguage(w http.ResponseWriter, r *http.Request) {
l := langByCode(r.PathValue("code"))
if l == nil {
http.NotFound(w, r)
return
}
http.SetCookie(w, &http.Cookie{
Name: langCookie, Value: l.Code, Path: "/",
Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
MaxAge: 365 * 24 * 3600,
})
http.Redirect(w, r, backTo(r), http.StatusSeeOther)
}
// backTo is the page a switch came from — only a path on this same site, so
// the link cannot be used to send someone elsewhere.
func backTo(r *http.Request) string {
ref, err := url.Parse(r.Referer())
if err != nil || ref.Host == "" || ref.Host != r.Host || !strings.HasPrefix(ref.Path, "/") ||
strings.HasPrefix(ref.Path, "//") || strings.HasPrefix(ref.Path, "/_lang/") {
return "/"
}
if ref.RawQuery != "" {
return ref.Path + "?" + ref.RawQuery
}
return ref.Path
}