package phrasebook import ( "fmt" "io/fs" "path" "sort" "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 } // 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//.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.put(pluginID, file, locale, prefix, val) case map[string]any: if len(val) == 0 { return nil } 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 (c *Catalog) put(pluginID, file, locale, key, text string) 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] = entry{text: text} 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 } 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) } }