From f824e55c8143594c3066171f00c84da1a64671a0 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 18 Sep 2026 13:47:45 +0200 Subject: [PATCH] feat(04-02): load namespaced translations from embedded plugins - Walk HasLang FS for lang//.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 --- examples/hello/hello_test.go | 25 ++ examples/hello/plugins/base/lang/en/lang.yaml | 3 + examples/hello/plugins/base/lang/pl/lang.yaml | 3 + examples/hello/plugins/base/plugin.go | 9 +- party/registry.go | 4 + party/registry_test.go | 61 ++++ phrasebook/loader.go | 260 ++++++++++++++++++ phrasebook/translator.go | 173 ++++++++++++ phrasebook/translator_test.go | 98 +++++++ 9 files changed, 635 insertions(+), 1 deletion(-) create mode 100644 examples/hello/plugins/base/lang/en/lang.yaml create mode 100644 examples/hello/plugins/base/lang/pl/lang.yaml create mode 100644 phrasebook/loader.go create mode 100644 phrasebook/translator.go create mode 100644 phrasebook/translator_test.go diff --git a/examples/hello/hello_test.go b/examples/hello/hello_test.go index 675eace..0c156bb 100644 --- a/examples/hello/hello_test.go +++ b/examples/hello/hello_test.go @@ -15,6 +15,7 @@ import ( "git.golem15.com/golem15/summercms/compass" "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/party" + "git.golem15.com/golem15/summercms/phrasebook" "git.golem15.com/golem15/summercms/surf" ) @@ -107,6 +108,30 @@ func TestBuiltBinaryGreeterHello(t *testing.T) { assertGreeting(t, got, want) } +func TestHelloPluginResolvesLang(t *testing.T) { + cfg, err := compass.Load("config") + if err != nil { + t.Fatal(err) + } + app := backpack.New(cfg) + if _, err := party.Activate(app, []string{"golem15.hello"}); err != nil { + t.Fatal(err) + } + tr, ok := app.Lookup[*phrasebook.Translator]() + if !ok { + t.Fatal("translator not published") + } + if got := tr.GetIn("en", "golem15.hello::lang.labels.pluginName", nil); got != "Hello plugin" { + t.Fatalf("en = %q", got) + } + if got := tr.GetIn("pl", "golem15.hello::lang.labels.pluginName", nil); got != "Wtyczka Hello" { + t.Fatalf("pl = %q", got) + } + if got := tr.GetIn("en", "golem15.hello::lang.greeting", nil); got != "Hello" { + t.Fatalf("greeting = %q", got) + } +} + func TestTypedItemRoute(t *testing.T) { cfg, err := compass.Load("config") if err != nil { diff --git a/examples/hello/plugins/base/lang/en/lang.yaml b/examples/hello/plugins/base/lang/en/lang.yaml new file mode 100644 index 0000000..31faeb4 --- /dev/null +++ b/examples/hello/plugins/base/lang/en/lang.yaml @@ -0,0 +1,3 @@ +greeting: Hello +labels: + pluginName: Hello plugin diff --git a/examples/hello/plugins/base/lang/pl/lang.yaml b/examples/hello/plugins/base/lang/pl/lang.yaml new file mode 100644 index 0000000..978bcf5 --- /dev/null +++ b/examples/hello/plugins/base/lang/pl/lang.yaml @@ -0,0 +1,3 @@ +greeting: Cześć +labels: + pluginName: Wtyczka Hello diff --git a/examples/hello/plugins/base/plugin.go b/examples/hello/plugins/base/plugin.go index 167d225..137db2f 100644 --- a/examples/hello/plugins/base/plugin.go +++ b/examples/hello/plugins/base/plugin.go @@ -9,11 +9,17 @@ import ( "git.golem15.com/golem15/summercms/party" ) -var _ pact.HasConfig = (*Plugin)(nil) +var ( + _ pact.HasConfig = (*Plugin)(nil) + _ pact.HasLang = (*Plugin)(nil) +) //go:embed config var configFS embed.FS +//go:embed lang +var langFS embed.FS + // Plugin is the golem15.hello plugin. type Plugin struct{} @@ -24,6 +30,7 @@ func (p *Plugin) Register(*backpack.App) error { return nil } func (p *Plugin) Boot(*backpack.App) error { return nil } func (p *Plugin) ConfigFS() fs.FS { return configFS } +func (p *Plugin) LangFS() fs.FS { return langFS } func init() { party.Register(&Plugin{}) diff --git a/party/registry.go b/party/registry.go index 8a00787..54e9be6 100644 --- a/party/registry.go +++ b/party/registry.go @@ -7,6 +7,7 @@ import ( "git.golem15.com/golem15/summercms/backpack" "git.golem15.com/golem15/summercms/pact" + "git.golem15.com/golem15/summercms/phrasebook" ) // Plugin is the required descriptor every compiled plugin implements. @@ -116,6 +117,9 @@ func activate(registered []Plugin, app *backpack.App, ids []string) ([]Plugin, e return nil, fmt.Errorf("party: register %s: %w", p.ID(), err) } } + if err := phrasebook.Activate(app, ordered); err != nil { + return nil, fmt.Errorf("party: lang: %w", err) + } for _, p := range ordered { if err := p.Boot(app); err != nil { return nil, fmt.Errorf("party: boot %s: %w", p.ID(), err) diff --git a/party/registry_test.go b/party/registry_test.go index cb25ea8..43fda14 100644 --- a/party/registry_test.go +++ b/party/registry_test.go @@ -1,6 +1,7 @@ package party import ( + "fmt" "io/fs" "os" "path/filepath" @@ -12,6 +13,7 @@ import ( "git.golem15.com/golem15/summercms/bonfire" "git.golem15.com/golem15/summercms/compass" "git.golem15.com/golem15/summercms/pact" + "git.golem15.com/golem15/summercms/phrasebook" ) type tracePlugin struct { @@ -394,3 +396,62 @@ func TestActivateDiscoversHasConfigAndHasCommands(t *testing.T) { t.Fatal("plain plugin must not satisfy HasConfig") } } + +type langPlugin struct { + id string + fsys fs.FS + got *string +} + +func (p *langPlugin) ID() string { return p.id } +func (p *langPlugin) Requires() []string { return nil } +func (p *langPlugin) Register(*backpack.App) error { return nil } +func (p *langPlugin) LangFS() fs.FS { return p.fsys } + +func (p *langPlugin) Boot(app *backpack.App) error { + if p.got == nil { + return nil + } + tr, ok := app.Lookup[*phrasebook.Translator]() + if !ok { + return fmt.Errorf("translator missing at boot") + } + *p.got = tr.GetIn("en", p.id+"::lang.greeting", nil) + return nil +} + +func TestActivatePublishesTranslatorBeforeBoot(t *testing.T) { + var got string + p := &langPlugin{ + id: "golem15.hello", + got: &got, + fsys: fstest.MapFS{ + "lang/en/lang.yaml": {Data: []byte("greeting: Hello\n")}, + }, + } + app := backpack.New(&compass.Config{}) + if _, err := activate([]Plugin{p}, app, []string{"golem15.hello"}); err != nil { + t.Fatalf("activate: %v", err) + } + if got != "Hello" { + t.Fatalf("boot lookup = %q, want Hello (translator must be published before Boot)", got) + } +} + +func TestActivateMalformedLangFailsWithPluginContext(t *testing.T) { + p := &langPlugin{ + id: "golem15.hello", + fsys: fstest.MapFS{ + "lang/en.yaml": {Data: []byte("greeting: Hello\n")}, + }, + } + app := backpack.New(&compass.Config{}) + _, err := activate([]Plugin{p}, app, []string{"golem15.hello"}) + if err == nil { + t.Fatal("expected malformed catalog error") + } + msg := err.Error() + if !strings.Contains(msg, "golem15.hello") || !strings.Contains(msg, "malformed path") { + t.Fatalf("error %q should name the plugin and path problem", msg) + } +} diff --git a/phrasebook/loader.go b/phrasebook/loader.go new file mode 100644 index 0000000..efef037 --- /dev/null +++ b/phrasebook/loader.go @@ -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//.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) + } +} diff --git a/phrasebook/translator.go b/phrasebook/translator.go new file mode 100644 index 0000000..7bc615a --- /dev/null +++ b/phrasebook/translator.go @@ -0,0 +1,173 @@ +package phrasebook + +import ( + "context" + "fmt" + "strings" + "unicode" + "unicode/utf8" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/pact" + "git.golem15.com/golem15/summercms/towel" +) + +const ( + defaultLocale = "en" + defaultFallback = "en" +) + +// Options configures an app-scoped translator. +type Options struct { + Locale string + Fallback string + Production bool +} + +// Translator resolves namespaced translation keys from a loaded catalog. +type Translator struct { + cat *Catalog + locale string + fallback string + production bool +} + +// NewTranslator returns a translator that reads from cat. Empty locale or +// fallback values become the framework defaults en/en. +func NewTranslator(cat *Catalog, opts Options) *Translator { + if cat == nil { + cat = NewCatalog() + } + locale := strings.TrimSpace(opts.Locale) + if locale == "" { + locale = defaultLocale + } + fallback := strings.TrimSpace(opts.Fallback) + if fallback == "" { + fallback = defaultFallback + } + return &Translator{ + cat: cat, + locale: locale, + fallback: fallback, + production: opts.Production, + } +} + +// Activate loads HasLang catalogs in plugin order, then publishes one +// Translator on app before Boot. app.locale and app.fallback_locale default to en. +func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error { + if app == nil { + return fmt.Errorf("phrasebook: app is nil") + } + cat := NewCatalog() + for _, p := range plugins { + hl, ok := any(p).(pact.HasLang) + if !ok { + continue + } + if err := cat.Load(p.ID(), hl.LangFS()); err != nil { + return err + } + } + opts := Options{Locale: defaultLocale, Fallback: defaultFallback} + if app.Config != nil { + if v := strings.TrimSpace(app.Config.String("app.locale")); v != "" { + opts.Locale = v + } + if v := strings.TrimSpace(app.Config.String("app.fallback_locale")); v != "" { + opts.Fallback = v + } + opts.Production = app.Config.Environment() == "production" + } + if err := app.Publish(NewTranslator(cat, opts)); err != nil { + return fmt.Errorf("phrasebook: %w", err) + } + return nil +} + +// Get returns the translation for key using the request locale when present, +// otherwise the configured app locale. +func (t *Translator) Get(ctx context.Context, key string, params map[string]string) string { + return t.GetIn(t.localeFrom(ctx), key, params) +} + +// GetIn looks up key in an explicit locale. +func (t *Translator) GetIn(locale, key string, params map[string]string) string { + if t == nil { + return key + } + e, ok := t.cat.lookup(locale, key) + if !ok { + return key + } + return interpolate(e.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. +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) +} + +func (t *Translator) localeFrom(ctx context.Context) string { + if loc, ok := towel.Locale(ctx); ok { + if loc = strings.TrimSpace(loc); loc != "" { + return loc + } + } + if t == nil || t.locale == "" { + return defaultLocale + } + return t.locale +} + +func interpolate(text string, params map[string]string) string { + if text == "" || len(params) == 0 { + return text + } + type pair struct { + placeholder string + value string + } + pairs := make([]pair, 0, len(params)*3) + for name, value := range params { + name = strings.TrimPrefix(name, ":") + if name == "" { + continue + } + pairs = append(pairs, + pair{placeholder: ":" + ucfirst(name), value: ucfirst(value)}, + pair{placeholder: ":" + strings.ToUpper(name), value: strings.ToUpper(value)}, + pair{placeholder: ":" + name, value: value}, + ) + } + for i := 1; i < len(pairs); i++ { + j := i + for j > 0 && len(pairs[j].placeholder) > len(pairs[j-1].placeholder) { + pairs[j], pairs[j-1] = pairs[j-1], pairs[j] + j-- + } + } + for _, p := range pairs { + text = strings.ReplaceAll(text, p.placeholder, p.value) + } + return text +} + +func ucfirst(s string) string { + if s == "" { + return s + } + r, size := utf8.DecodeRuneInString(s) + if r == utf8.RuneError && size == 1 { + return s + } + return string(unicode.ToUpper(r)) + s[size:] +} diff --git a/phrasebook/translator_test.go b/phrasebook/translator_test.go new file mode 100644 index 0000000..d866f14 --- /dev/null +++ b/phrasebook/translator_test.go @@ -0,0 +1,98 @@ +package phrasebook + +import ( + "io/fs" + "strings" + "testing" + "testing/fstest" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/compass" +) + +func TestTranslationSmoke(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{ + "lang/en/lang.yaml": {Data: []byte("greeting: Hello\nlabels:\n pluginName: Hello plugin\n")}, + "lang/pl/lang.yaml": {Data: []byte("greeting: Cześć\nlabels:\n pluginName: Wtyczka Hello\n")}, + } + cat := NewCatalog() + if err := cat.Load("golem15.hello", fsys); err != nil { + t.Fatalf("Load: %v", err) + } + tr := NewTranslator(cat, Options{}) + if got := tr.GetIn("en", "golem15.hello::lang.labels.pluginName", nil); got != "Hello plugin" { + t.Fatalf("en nested = %q", got) + } + if got := tr.GetIn("pl", "golem15.hello::lang.labels.pluginName", nil); got != "Wtyczka Hello" { + t.Fatalf("pl nested = %q", got) + } + if got := tr.GetIn("en", "golem15.hello::lang.greeting", nil); got != "Hello" { + t.Fatalf("en greeting = %q", got) + } + if got := tr.GetIn("pl", "golem15.hello::lang.greeting", nil); got != "Cześć" { + t.Fatalf("pl greeting = %q", got) + } + + t.Run("malformed path", func(t *testing.T) { + err := NewCatalog().Load("golem15.hello", fstest.MapFS{ + "lang/en.yaml": {Data: []byte("greeting: Hello\n")}, + }) + if err == nil || !strings.Contains(err.Error(), "golem15.hello") || !strings.Contains(err.Error(), "malformed path") { + t.Fatalf("malformed path error = %v", err) + } + }) + t.Run("non-string leaf", func(t *testing.T) { + err := NewCatalog().Load("golem15.hello", fstest.MapFS{ + "lang/en/lang.yaml": {Data: []byte("count: 1\n")}, + }) + if err == nil || !strings.Contains(err.Error(), "golem15.hello") || !strings.Contains(err.Error(), "lang/en/lang.yaml") || !strings.Contains(err.Error(), "non-string leaf") { + t.Fatalf("non-string leaf error = %v", err) + } + }) + t.Run("duplicate key", func(t *testing.T) { + err := NewCatalog().Load("golem15.hello", fstest.MapFS{ + "lang/en/lang.yaml": {Data: []byte("labels:\n name: a\nlabels.name: b\n")}, + }) + if err == nil || !strings.Contains(err.Error(), "golem15.hello") || !strings.Contains(err.Error(), "duplicate key") { + t.Fatalf("duplicate key error = %v", err) + } + }) + t.Run("duplicate namespace owner", func(t *testing.T) { + cat := NewCatalog() + fsys := fstest.MapFS{"lang/en/lang.yaml": {Data: []byte("greeting: Hello\n")}} + if err := cat.Load("golem15.hello", fsys); err != nil { + t.Fatalf("first Load: %v", err) + } + err := cat.Load("golem15.hello", fsys) + if err == nil || !strings.Contains(err.Error(), "golem15.hello") || !strings.Contains(err.Error(), "duplicate namespace owner") { + t.Fatalf("duplicate namespace error = %v", err) + } + }) + t.Run("activate publishes translator", func(t *testing.T) { + app := backpack.New(&compass.Config{}) + p := &langPlugin{id: "golem15.hello", fsys: fsys} + if err := Activate(app, []*langPlugin{p}); err != nil { + t.Fatalf("Activate: %v", err) + } + tr, ok := app.Lookup[*Translator]() + if !ok { + t.Fatal("translator not published") + } + if got := tr.GetIn("pl", "golem15.hello::lang.greeting", nil); got != "Cześć" { + t.Fatalf("activated pl = %q", got) + } + }) +} + +type langPlugin struct { + id string + fsys fstest.MapFS +} + +func (p *langPlugin) ID() string { return p.id } +func (p *langPlugin) Requires() []string { return nil } +func (p *langPlugin) Register(*backpack.App) error { return nil } +func (p *langPlugin) Boot(*backpack.App) error { return nil } +func (p *langPlugin) LangFS() fs.FS { return p.fsys }