Files
summercms/phrasebook/translator.go
Jakub Zych c87148a34f feat(10-02): backend strings, controller messages and declarative toolbar
- phrasebook ships the backend::lang admin strings (pl, en) with CLDR
  plural maps, loads them as namespace backend, applies
  pact.HasLangOverrides trees (lang/<locale>/<namespace>/<group>.yaml)
  after every namespace, and fails activation when a backend key cannot
  convert to plural forms
- Translator.Forms, Bundle, Resolved and Has serve keys as CLDR form maps
- Public GET /lang returns every backend::lang key for the request
  locale over the fallback locale, Cache-Control no-cache
- config_list, config_form and config_relation accept a strict messages
  block; omitted keys take framework defaults, schemas serve every message
  as CLDR forms, and activation fails on a missing phrase key
- toolbar.buttons is an ordered [create, delete] list; the Winter string
  form, duplicates, unknown actions and delete without showCheckboxes fail
  at boot, and create is dropped when the controller has no form
- Form schema serves the raw Winter redirects; scaffold emits the list
  syntax; form and relation schema routes are typed in the admin OpenAPI
2026-09-27 16:16:32 +02:00

580 lines
14 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
}
if err := cat.Load("backend", backendLang()); 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
}
}
// Overrides run after every namespace is loaded (D-20): a project can
// replace framework or plugin strings and add locales without a rebuild
// of the admin SPA.
for _, p := range plugins {
ho, ok := any(p).(pact.HasLangOverrides)
if !ok {
continue
}
if err := cat.Override(p.ID(), ho.LangOverridesFS()); err != nil {
return err
}
}
if err := cat.checkForms("backend::"); 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
}
// Locale returns the configured app locale. Request locales stay on the context.
func (t *Translator) Locale() string {
if t == nil || strings.TrimSpace(t.locale) == "" {
return defaultLocale
}
return t.locale
}
// 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:]
}
// Forms returns key's text as CLDR plural forms for the SPA (D-13, D-20): a
// plain text is {"other": text}, a YAML plural map is copied, and a pipe of
// category forms maps by category. Exact and range pipes ({0}, [2,*]) have
// no Intl.PluralRules equivalent and report false, as does a missing key.
// Placeholders are left for the client to interpolate.
func (t *Translator) Forms(locale, key string) (map[string]string, bool) {
if t == nil {
return nil, false
}
e, _, ok := t.find(locale, key)
if !ok {
return nil, false
}
return entryForms(e)
}
// Bundle returns every key starting with prefix as CLDR forms, keys of the
// requested locale over those of its fallback chain. Keys that cannot convert
// are left out.
func (t *Translator) Bundle(locale, prefix string) map[string]map[string]string {
out := map[string]map[string]string{}
if t == nil || t.cat == nil {
return out
}
chain := fallbackChain(locale, t.fallbackLocale())
for i := len(chain) - 1; i >= 0; i-- {
for key, e := range t.cat.messages[chain[i]] {
if !strings.HasPrefix(key, prefix) {
continue
}
if forms, ok := entryForms(e); ok {
out[key] = forms
}
}
}
return out
}
// Resolved returns the first locale of locale's fallback chain that the
// catalog has any key for under prefix: the locale a Bundle is mostly in.
func (t *Translator) Resolved(locale, prefix string) string {
if t == nil || t.cat == nil {
return locale
}
for _, step := range fallbackChain(locale, t.fallbackLocale()) {
for key := range t.cat.messages[step] {
if strings.HasPrefix(key, prefix) {
return step
}
}
}
return t.fallbackLocale()
}
// Has reports whether any locale of the catalog defines key.
func (t *Translator) Has(key string) bool {
if t == nil || t.cat == nil {
return false
}
for _, byKey := range t.cat.messages {
if _, ok := byKey[key]; ok {
return true
}
}
return false
}
func (t *Translator) fallbackLocale() string {
if t == nil || strings.TrimSpace(t.fallback) == "" {
return defaultFallback
}
return t.fallback
}
func entryForms(e entry) (map[string]string, bool) {
switch {
case len(e.pipes) > 0:
out := make(map[string]string, len(e.pipes))
for _, part := range e.pipes {
if part.exact != nil || part.lo != nil || part.cat == "" {
return nil, false
}
out[part.cat] = part.text
}
if _, ok := out["other"]; !ok {
return nil, false
}
return out, true
case e.plurals != nil:
out := make(map[string]string, len(e.plurals))
for cat, text := range e.plurals {
out[cat] = text
}
return out, true
default:
return map[string]string{"other": e.text}, true
}
}