Files
summercms/phrasebook/loader.go
Jakub Zych 6d8786a2af 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>
2026-09-18 13:51:15 +02:00

419 lines
9.9 KiB
Go

package phrasebook
import (
"fmt"
"io/fs"
"path"
"regexp"
"sort"
"strconv"
"strings"
"unicode"
"github.com/goccy/go-yaml"
)
// Catalog is an immutable-after-load set of namespaced translation entries.
type Catalog struct {
// locale -> full key (vendor.plugin::group.dot.path) -> entry
messages map[string]map[string]entry
// namespace (plugin ID) -> owning plugin ID
namespaces map[string]string
}
type entry struct {
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{
messages: make(map[string]map[string]entry),
namespaces: make(map[string]string),
}
}
// Load walks fsys for lang/<locale>/<group>.yaml files, flattens nested YAML
// maps into vendor.plugin::group.dot.path keys, and merges them into c.
func (c *Catalog) Load(pluginID string, fsys fs.FS) error {
if c == nil {
return fmt.Errorf("phrasebook: catalog is nil")
}
if pluginID == "" {
return fmt.Errorf("phrasebook: plugin id is empty")
}
if fsys == nil {
return nil
}
if owner, ok := c.namespaces[pluginID]; ok {
return catalogErrorf(pluginID, "", "", "duplicate namespace owner %q", owner)
}
if c.messages == nil {
c.messages = make(map[string]map[string]entry)
}
if c.namespaces == nil {
c.namespaces = make(map[string]string)
}
var files []string
err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
files = append(files, path.Clean(p))
return nil
})
if err != nil {
return catalogErrorf(pluginID, "", "", "walk lang fs: %s", err.Error())
}
sort.Strings(files)
for _, file := range files {
locale, group, err := parseLangPath(file)
if err != nil {
return catalogErrorf(pluginID, file, "", "%s", err.Error())
}
raw, err := fs.ReadFile(fsys, file)
if err != nil {
return catalogErrorf(pluginID, file, "", "read: %s", err.Error())
}
if err := c.loadFile(pluginID, file, locale, group, raw); err != nil {
return err
}
}
c.namespaces[pluginID] = pluginID
return nil
}
func (c *Catalog) loadFile(pluginID, file, locale, group string, raw []byte) error {
var decoded any
if err := yaml.Unmarshal(raw, &decoded); err != nil {
return catalogErrorf(pluginID, file, "", "parse: %s", err.Error())
}
if decoded == nil {
return nil
}
root, err := asStringMap(decoded)
if err != nil {
return catalogErrorf(pluginID, file, "", "%s", err.Error())
}
prefix := pluginID + "::" + group
return flatten(c, pluginID, file, locale, prefix, root)
}
func flatten(c *Catalog, pluginID, file, locale, prefix string, v any) error {
switch val := v.(type) {
case string:
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)
}
sort.Strings(keys)
for _, k := range keys {
next := joinKey(prefix, k)
if err := flatten(c, pluginID, file, locale, next, val[k]); err != nil {
return err
}
}
return nil
case map[any]any:
converted, err := anyMapToStringMap(val)
if err != nil {
return catalogErrorf(pluginID, file, prefix, "%s", err.Error())
}
return flatten(c, pluginID, file, locale, prefix, converted)
case nil:
return catalogErrorf(pluginID, file, prefix, "non-string leaf (null)")
default:
return catalogErrorf(pluginID, file, prefix, "non-string leaf (%T)", v)
}
}
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")
}
byKey := c.messages[locale]
if byKey == nil {
byKey = make(map[string]entry)
c.messages[locale] = byKey
}
if _, exists := byKey[key]; exists {
return catalogErrorf(pluginID, file, key, "duplicate key")
}
byKey[key] = e
return nil
}
func (c *Catalog) lookup(locale, key string) (entry, bool) {
if c == nil {
return entry{}, false
}
byKey := c.messages[locale]
if byKey == nil {
return entry{}, false
}
e, ok := byKey[key]
return e, ok
}
func parseLangPath(p string) (locale, group string, err error) {
p = path.Clean("/" + strings.ReplaceAll(p, "\\", "/"))
p = strings.TrimPrefix(p, "/")
if p == ".." || strings.HasPrefix(p, "../") || strings.Contains(p, "/../") {
return "", "", fmt.Errorf("malformed path")
}
parts := strings.Split(p, "/")
if len(parts) != 3 || parts[0] != "lang" {
return "", "", fmt.Errorf("malformed path")
}
locale = parts[1]
base := parts[2]
if !strings.HasSuffix(base, ".yaml") {
return "", "", fmt.Errorf("malformed path")
}
group = strings.TrimSuffix(base, ".yaml")
if !validLocale(locale) || !validGroup(group) {
return "", "", fmt.Errorf("malformed path")
}
return locale, group, nil
}
func validLocale(s string) bool {
if s == "" || s == "." || s == ".." {
return false
}
for i, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
continue
}
if (r == '-' || r == '_') && i > 0 && i < len(s)-1 {
continue
}
return false
}
return true
}
func validGroup(s string) bool {
if s == "" || s == "." || s == ".." {
return false
}
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' {
continue
}
return false
}
return true
}
func joinKey(prefix, key string) string {
if prefix == "" {
return key
}
if key == "" {
return prefix
}
return prefix + "." + key
}
func asStringMap(v any) (map[string]any, error) {
switch val := v.(type) {
case map[string]any:
return val, nil
case map[any]any:
return anyMapToStringMap(val)
default:
return nil, fmt.Errorf("root must be a mapping")
}
}
func anyMapToStringMap(v map[any]any) (map[string]any, error) {
out := make(map[string]any, len(v))
for k, val := range v {
sk, ok := k.(string)
if !ok {
return nil, fmt.Errorf("non-string key (%T)", k)
}
out[sk] = val
}
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 {
case key != "":
return fmt.Errorf("phrasebook: plugin %s file %s key %s: %s", pluginID, file, key, msg)
case file != "":
return fmt.Errorf("phrasebook: plugin %s file %s: %s", pluginID, file, msg)
default:
return fmt.Errorf("phrasebook: plugin %s: %s", pluginID, msg)
}
}