feat(04-02): load namespaced translations from embedded plugins
- Walk HasLang FS for lang/<locale>/<group>.yaml and flatten nested keys - Publish one phrasebook.Translator before Boot with en/en defaults - Hello plugin resolves en/pl vendor.plugin::group.key after Activate Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
260
phrasebook/loader.go
Normal file
260
phrasebook/loader.go
Normal file
@@ -0,0 +1,260 @@
|
||||
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/<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.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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user