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>
707 lines
19 KiB
Go
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
|
|
}
|