Files
summercms/phrasebook/translator.go
Jakub Zych 31634f706f feat(07-07): localize lagoon validation with Laravel-shaped Polish messages
Replay of the user-api corpus needs lagoon::validate.* catalogs and
underscore-to-space attribute names so Go 422 bodies match Winter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 23:56:58 +02:00

455 lines
10 KiB
Go

package phrasebook
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"sync"
"unicode"
"unicode/utf8"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/pact"
"git.golem15.com/golem15/summercms/towel"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/nicksnyder/go-i18n/v2/i18n/template"
"golang.org/x/text/language"
)
const (
defaultLocale = "en"
defaultFallback = "en"
)
var (
bundleCache sync.Map // locale -> *i18n.Bundle
categoryCache sync.Map // locale -> []string
identityParse = template.IdentityParser{}
pluralSamples = []any{
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
20, 21, 22, 23, 24, 25, 100, 101, 102, 111, 1000, 1000000,
"0.0", "0.1", "0.5", "1.0", "1.1", "1.5", "2.0", "2.5", "10.0", "100.0",
}
categoryMessage = &i18n.Message{
ID: "phrasebook.plural.category",
Zero: "zero",
One: "one",
Two: "two",
Few: "few",
Many: "many",
Other: "other",
}
)
// Options configures an app-scoped translator.
type Options struct {
Locale string
Fallback string
Production bool
}
// Translator resolves namespaced translation keys from a loaded catalog.
type Translator struct {
cat *Catalog
locale string
fallback string
production bool
missing sync.Map
}
// NewTranslator returns a translator that reads from cat. Empty locale or
// fallback values become the framework defaults en/en.
func NewTranslator(cat *Catalog, opts Options) *Translator {
if cat == nil {
cat = NewCatalog()
}
locale := strings.TrimSpace(opts.Locale)
if locale == "" {
locale = defaultLocale
}
fallback := strings.TrimSpace(opts.Fallback)
if fallback == "" {
fallback = defaultFallback
}
return &Translator{
cat: cat,
locale: locale,
fallback: fallback,
production: opts.Production,
}
}
// Activate loads HasLang catalogs in plugin order, then publishes one
// Translator on app before Boot. app.locale and app.fallback_locale default to en.
func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error {
if app == nil {
return fmt.Errorf("phrasebook: app is nil")
}
cat := NewCatalog()
if err := cat.Load("lagoon", systemLangFS); err != nil {
return err
}
for _, p := range plugins {
hl, ok := any(p).(pact.HasLang)
if !ok {
continue
}
if err := cat.Load(p.ID(), hl.LangFS()); err != nil {
return err
}
}
opts := Options{Locale: defaultLocale, Fallback: defaultFallback}
if app.Config != nil {
if v := strings.TrimSpace(app.Config.String("app.locale")); v != "" {
opts.Locale = v
}
if v := strings.TrimSpace(app.Config.String("app.fallback_locale")); v != "" {
opts.Fallback = v
}
opts.Production = app.Config.Environment() == "production"
}
if err := app.Publish(NewTranslator(cat, opts)); err != nil {
return fmt.Errorf("phrasebook: %w", err)
}
return nil
}
// Get returns the translation for key using the request locale when present,
// otherwise the configured app locale.
func (t *Translator) Get(ctx context.Context, key string, params map[string]string) string {
return t.GetIn(t.localeFrom(ctx), key, params)
}
// GetIn looks up key in an explicit locale.
func (t *Translator) GetIn(locale, key string, params map[string]string) string {
if t == nil {
return key
}
e, _, ok := t.find(locale, key)
if !ok {
return key
}
text := e.text
if text == "" && e.plurals != nil {
text = e.plurals["other"]
}
return interpolate(text, params)
}
// Choice selects a plural form for key using CLDR rules or Laravel pipe conditions.
func (t *Translator) Choice(ctx context.Context, key string, count any, params map[string]string) string {
return t.ChoiceIn(t.localeFrom(ctx), key, count, params)
}
// ChoiceIn is the explicit-locale form of Choice.
func (t *Translator) ChoiceIn(locale, key string, count any, params map[string]string) string {
if t == nil {
return key
}
e, found, ok := t.find(locale, key)
if !ok {
return key
}
text := e.text
switch {
case len(e.pipes) > 0:
text = selectPipe(e.pipes, found, count)
case e.plurals != nil:
text = selectMap(e.plurals, found, count)
}
return interpolate(text, withCount(params, count))
}
func (t *Translator) find(locale, key string) (entry, string, bool) {
if t == nil {
return entry{}, locale, false
}
fallback := defaultFallback
if t.fallback != "" {
fallback = t.fallback
}
for _, step := range fallbackChain(locale, fallback) {
if e, ok := t.cat.lookup(step, key); ok {
return e, step, true
}
}
t.logMissing(key)
return entry{}, locale, false
}
func (t *Translator) logMissing(key string) {
if t.production {
return
}
if _, loaded := t.missing.LoadOrStore(key, struct{}{}); loaded {
return
}
slog.Warn("phrasebook: missing translation key", "key", key)
}
func fallbackChain(requested, fallback string) []string {
out := make([]string, 0, 4)
seen := make(map[string]struct{}, 4)
add := func(s string) {
s = strings.TrimSpace(s)
if s == "" {
return
}
if _, ok := seen[s]; ok {
return
}
seen[s] = struct{}{}
out = append(out, s)
}
add(requested)
if tag, err := language.Parse(requested); err == nil {
add(tag.String())
for p := tag.Parent(); !p.IsRoot(); p = p.Parent() {
add(p.String())
}
} else if i := strings.IndexAny(requested, "-_"); i > 0 {
add(requested[:i])
}
add(fallback)
return out
}
func selectMap(plurals map[string]string, locale string, count any) string {
cat, err := cldrCategory(locale, count)
if err != nil {
return plurals["other"]
}
if s, ok := plurals[cat]; ok {
return s
}
return plurals["other"]
}
func selectPipe(parts []pipePart, locale string, count any) string {
if n, ok := asFloat(count); ok {
for _, p := range parts {
if p.exact != nil && n == *p.exact {
return p.text
}
if p.lo != nil && n >= *p.lo && (p.hi == nil || n <= *p.hi) {
return p.text
}
}
}
cat, err := cldrCategory(locale, count)
if err != nil {
cat = "other"
}
var other, last string
for _, p := range parts {
last = p.text
if p.cat == cat {
return p.text
}
if p.cat == "other" {
other = p.text
}
}
if other != "" {
return other
}
return last
}
func cldrCategory(locale string, count any) (string, error) {
if strings.TrimSpace(locale) == "" {
locale = defaultLocale
}
loc := i18n.NewLocalizer(bundleFor(locale), locale)
return loc.Localize(&i18n.LocalizeConfig{
DefaultMessage: categoryMessage,
PluralCount: coercePluralCount(count),
TemplateParser: identityParse,
})
}
func bundleFor(locale string) *i18n.Bundle {
if v, ok := bundleCache.Load(locale); ok {
return v.(*i18n.Bundle)
}
tag, err := language.Parse(locale)
if err != nil {
tag = language.English
}
b := i18n.NewBundle(tag)
actual, _ := bundleCache.LoadOrStore(locale, b)
return actual.(*i18n.Bundle)
}
func coercePluralCount(count any) any {
switch count.(type) {
case float32, float64:
return formatCount(count)
default:
return count
}
}
func cldrCategories(locale string) ([]string, map[string]struct{}, error) {
if v, ok := categoryCache.Load(locale); ok {
ordered := v.([]string)
return ordered, setFrom(ordered), nil
}
seen := make(map[string]struct{})
for _, n := range pluralSamples {
cat, err := cldrCategory(locale, n)
if err != nil {
return nil, nil, err
}
seen[cat] = struct{}{}
}
seen["other"] = struct{}{}
ordered := make([]string, 0, len(cldrOrder))
for _, cat := range cldrOrder {
if _, ok := seen[cat]; ok {
ordered = append(ordered, cat)
}
}
categoryCache.Store(locale, ordered)
return ordered, seen, nil
}
func cldrCategorySet(locale string) (map[string]struct{}, error) {
_, set, err := cldrCategories(locale)
return set, err
}
func setFrom(vals []string) map[string]struct{} {
out := make(map[string]struct{}, len(vals))
for _, v := range vals {
out[v] = struct{}{}
}
return out
}
func withCount(params map[string]string, count any) map[string]string {
out := make(map[string]string, len(params)+1)
for k, v := range params {
out[k] = v
}
if _, ok := out["count"]; !ok {
out["count"] = formatCount(count)
}
return out
}
func formatCount(count any) string {
switch v := count.(type) {
case int:
return strconv.Itoa(v)
case int8:
return strconv.FormatInt(int64(v), 10)
case int16:
return strconv.FormatInt(int64(v), 10)
case int32:
return strconv.FormatInt(int64(v), 10)
case int64:
return strconv.FormatInt(v, 10)
case uint:
return strconv.FormatUint(uint64(v), 10)
case uint64:
return strconv.FormatUint(v, 10)
case float32:
return formatFloat(float64(v))
case float64:
return formatFloat(v)
default:
return fmt.Sprint(v)
}
}
func formatFloat(v float64) string {
if v == float64(int64(v)) {
return strconv.FormatInt(int64(v), 10)
}
return strconv.FormatFloat(v, 'f', -1, 64)
}
func asFloat(count any) (float64, bool) {
switch v := count.(type) {
case int:
return float64(v), true
case int8:
return float64(v), true
case int16:
return float64(v), true
case int32:
return float64(v), true
case int64:
return float64(v), true
case uint:
return float64(v), true
case uint64:
return float64(v), true
case float32:
return float64(v), true
case float64:
return v, true
default:
return 0, false
}
}
func (t *Translator) localeFrom(ctx context.Context) string {
if loc, ok := towel.Locale(ctx); ok {
if loc = strings.TrimSpace(loc); loc != "" {
return loc
}
}
if t == nil || t.locale == "" {
return defaultLocale
}
return t.locale
}
func interpolate(text string, params map[string]string) string {
if text == "" || len(params) == 0 {
return text
}
type pair struct {
placeholder string
value string
}
pairs := make([]pair, 0, len(params)*3)
for name, value := range params {
name = strings.TrimPrefix(name, ":")
if name == "" {
continue
}
pairs = append(pairs,
pair{placeholder: ":" + ucfirst(name), value: ucfirst(value)},
pair{placeholder: ":" + strings.ToUpper(name), value: strings.ToUpper(value)},
pair{placeholder: ":" + name, value: value},
)
}
for i := 1; i < len(pairs); i++ {
j := i
for j > 0 && len(pairs[j].placeholder) > len(pairs[j-1].placeholder) {
pairs[j], pairs[j-1] = pairs[j-1], pairs[j]
j--
}
}
for _, p := range pairs {
text = strings.ReplaceAll(text, p.placeholder, p.value)
}
return text
}
func ucfirst(s string) string {
if s == "" {
return s
}
r, size := utf8.DecodeRuneInString(s)
if r == utf8.RuneError && size == 1 {
return s
}
return string(unicode.ToUpper(r)) + s[size:]
}