feat(04-02): select CLDR and Laravel plural forms with PHP parameters
- Use go-i18n only to pick a category label, then substitute :name/:Name/:NAME
- Treat YAML maps as plurals when all keys are locale CLDR categories
- Parse pipe strings with {n} and [a,b]/[a,*] conditions at catalog load
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,13 +3,18 @@ package phrasebook
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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 (
|
||||
@@ -17,6 +22,26 @@ const (
|
||||
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
|
||||
@@ -101,19 +126,216 @@ func (t *Translator) GetIn(locale, key string, params map[string]string) string
|
||||
if !ok {
|
||||
return key
|
||||
}
|
||||
return interpolate(e.text, params)
|
||||
text := e.text
|
||||
if text == "" && e.plurals != nil {
|
||||
text = e.plurals["other"]
|
||||
}
|
||||
return interpolate(text, params)
|
||||
}
|
||||
|
||||
// Choice selects a translation for key. Plural selection is added in a later
|
||||
// task; a non-plural value is returned as Get would.
|
||||
// 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 {
|
||||
_ = count
|
||||
return t.GetIn(locale, key, params)
|
||||
if t == nil {
|
||||
return key
|
||||
}
|
||||
e, ok := t.cat.lookup(locale, key)
|
||||
if !ok {
|
||||
return key
|
||||
}
|
||||
text := e.text
|
||||
switch {
|
||||
case len(e.pipes) > 0:
|
||||
text = selectPipe(e.pipes, locale, count)
|
||||
case e.plurals != nil:
|
||||
text = selectMap(e.plurals, locale, count)
|
||||
}
|
||||
return interpolate(text, withCount(params, count))
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user