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:
@@ -4,7 +4,9 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
@@ -20,9 +22,30 @@ type Catalog struct {
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
text string
|
||||
text string
|
||||
plurals map[string]string
|
||||
pipes []pipePart
|
||||
}
|
||||
|
||||
type pipePart struct {
|
||||
exact *float64
|
||||
lo *float64
|
||||
hi *float64
|
||||
text string
|
||||
cat string
|
||||
}
|
||||
|
||||
var cldrNames = map[string]struct{}{
|
||||
"zero": {},
|
||||
"one": {},
|
||||
"two": {},
|
||||
"few": {},
|
||||
"many": {},
|
||||
"other": {},
|
||||
}
|
||||
|
||||
var cldrOrder = []string{"zero", "one", "two", "few", "many", "other"}
|
||||
|
||||
// NewCatalog returns an empty translation catalog.
|
||||
func NewCatalog() *Catalog {
|
||||
return &Catalog{
|
||||
@@ -105,11 +128,14 @@ func (c *Catalog) loadFile(pluginID, file, locale, group string, raw []byte) err
|
||||
func flatten(c *Catalog, pluginID, file, locale, prefix string, v any) error {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return c.put(pluginID, file, locale, prefix, val)
|
||||
return c.putString(pluginID, file, locale, prefix, val)
|
||||
case map[string]any:
|
||||
if len(val) == 0 {
|
||||
return nil
|
||||
}
|
||||
if isPluralMap(val) {
|
||||
return c.putPluralMap(pluginID, file, locale, prefix, val)
|
||||
}
|
||||
keys := make([]string, 0, len(val))
|
||||
for k := range val {
|
||||
keys = append(keys, k)
|
||||
@@ -135,7 +161,58 @@ func flatten(c *Catalog, pluginID, file, locale, prefix string, v any) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Catalog) put(pluginID, file, locale, key, text string) error {
|
||||
func isPluralMap(m map[string]any) bool {
|
||||
if len(m) == 0 {
|
||||
return false
|
||||
}
|
||||
for k := range m {
|
||||
if _, ok := cldrNames[k]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *Catalog) putString(pluginID, file, locale, key, text string) error {
|
||||
e := entry{text: text}
|
||||
if strings.Contains(text, "|") {
|
||||
parts, err := parsePipe(text, locale)
|
||||
if err != nil {
|
||||
return catalogErrorf(pluginID, file, key, "%s", err.Error())
|
||||
}
|
||||
e.pipes = parts
|
||||
}
|
||||
return c.put(pluginID, file, locale, key, e)
|
||||
}
|
||||
|
||||
func (c *Catalog) putPluralMap(pluginID, file, locale, key string, raw map[string]any) error {
|
||||
if _, ok := raw["other"]; !ok {
|
||||
return catalogErrorf(pluginID, file, key, "plural map requires other")
|
||||
}
|
||||
allowed, err := cldrCategorySet(locale)
|
||||
if err != nil {
|
||||
return catalogErrorf(pluginID, file, key, "%s", err.Error())
|
||||
}
|
||||
plurals := make(map[string]string, len(raw))
|
||||
keys := make([]string, 0, len(raw))
|
||||
for k := range raw {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if _, ok := allowed[k]; !ok {
|
||||
return catalogErrorf(pluginID, file, key, "invalid plural category %q for locale %s", k, locale)
|
||||
}
|
||||
s, ok := raw[k].(string)
|
||||
if !ok {
|
||||
return catalogErrorf(pluginID, file, joinKey(key, k), "non-string leaf (%T)", raw[k])
|
||||
}
|
||||
plurals[k] = s
|
||||
}
|
||||
return c.put(pluginID, file, locale, key, entry{plurals: plurals})
|
||||
}
|
||||
|
||||
func (c *Catalog) put(pluginID, file, locale, key string, e entry) error {
|
||||
if key == "" || strings.HasSuffix(key, "::") || strings.HasSuffix(key, ".") {
|
||||
return catalogErrorf(pluginID, file, key, "empty key")
|
||||
}
|
||||
@@ -147,7 +224,7 @@ func (c *Catalog) put(pluginID, file, locale, key, text string) error {
|
||||
if _, exists := byKey[key]; exists {
|
||||
return catalogErrorf(pluginID, file, key, "duplicate key")
|
||||
}
|
||||
byKey[key] = entry{text: text}
|
||||
byKey[key] = e
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -247,6 +324,87 @@ func anyMapToStringMap(v map[any]any) (map[string]any, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var (
|
||||
pipeExact = regexp.MustCompile(`^\{(-?\d+)\}\s*(.*)$`)
|
||||
pipeRange = regexp.MustCompile(`^\[(-?\d+),(-?\d+|\*)\]\s*(.*)$`)
|
||||
)
|
||||
|
||||
func parsePipe(text, locale string) ([]pipePart, error) {
|
||||
raw := strings.Split(text, "|")
|
||||
if len(raw) < 2 {
|
||||
return nil, fmt.Errorf("malformed pipe")
|
||||
}
|
||||
cats, _, err := cldrCategories(locale)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts := make([]pipePart, 0, len(raw))
|
||||
var forms []pipePart
|
||||
for _, seg := range raw {
|
||||
seg = strings.TrimSpace(seg)
|
||||
if seg == "" {
|
||||
return nil, fmt.Errorf("malformed pipe")
|
||||
}
|
||||
part, err := parsePipeSegment(seg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts = append(parts, part)
|
||||
if part.exact == nil && part.lo == nil {
|
||||
forms = append(forms, part)
|
||||
}
|
||||
}
|
||||
if len(forms) > 0 && len(forms) != len(cats) {
|
||||
return nil, fmt.Errorf("incomplete pipe")
|
||||
}
|
||||
fi := 0
|
||||
for i := range parts {
|
||||
if parts[i].exact == nil && parts[i].lo == nil {
|
||||
parts[i].cat = cats[fi]
|
||||
fi++
|
||||
}
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func parsePipeSegment(seg string) (pipePart, error) {
|
||||
if strings.HasPrefix(seg, "{") {
|
||||
m := pipeExact.FindStringSubmatch(seg)
|
||||
if m == nil {
|
||||
return pipePart{}, fmt.Errorf("malformed pipe")
|
||||
}
|
||||
n, err := strconv.ParseFloat(m[1], 64)
|
||||
if err != nil {
|
||||
return pipePart{}, fmt.Errorf("malformed pipe")
|
||||
}
|
||||
return pipePart{exact: &n, text: m[2]}, nil
|
||||
}
|
||||
if strings.HasPrefix(seg, "[") {
|
||||
m := pipeRange.FindStringSubmatch(seg)
|
||||
if m == nil {
|
||||
return pipePart{}, fmt.Errorf("malformed pipe")
|
||||
}
|
||||
lo, err := strconv.ParseFloat(m[1], 64)
|
||||
if err != nil {
|
||||
return pipePart{}, fmt.Errorf("malformed pipe")
|
||||
}
|
||||
part := pipePart{lo: &lo, text: m[3]}
|
||||
if m[2] == "*" {
|
||||
return part, nil
|
||||
}
|
||||
hi, err := strconv.ParseFloat(m[2], 64)
|
||||
if err != nil {
|
||||
return pipePart{}, fmt.Errorf("malformed pipe")
|
||||
}
|
||||
if lo > hi {
|
||||
return pipePart{}, fmt.Errorf("malformed pipe")
|
||||
}
|
||||
part.hi = &hi
|
||||
return part, nil
|
||||
}
|
||||
return pipePart{text: seg}, nil
|
||||
}
|
||||
|
||||
func catalogErrorf(pluginID, file, key, format string, args ...any) error {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
switch {
|
||||
|
||||
Reference in New Issue
Block a user