- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
580 lines
14 KiB
Go
580 lines
14 KiB
Go
package phrasebook
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/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
|
|
}
|
|
}
|