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:
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.golem15.com/golem15/summercms/compass"
|
"git.golem15.com/golem15/summercms/compass"
|
||||||
"git.golem15.com/golem15/summercms/pact"
|
"git.golem15.com/golem15/summercms/pact"
|
||||||
"git.golem15.com/golem15/summercms/party"
|
"git.golem15.com/golem15/summercms/party"
|
||||||
|
"git.golem15.com/golem15/summercms/phrasebook"
|
||||||
"git.golem15.com/golem15/summercms/surf"
|
"git.golem15.com/golem15/summercms/surf"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -107,6 +108,30 @@ func TestBuiltBinaryGreeterHello(t *testing.T) {
|
|||||||
assertGreeting(t, got, want)
|
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) {
|
func TestTypedItemRoute(t *testing.T) {
|
||||||
cfg, err := compass.Load("config")
|
cfg, err := compass.Load("config")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
3
examples/hello/plugins/base/lang/en/lang.yaml
Normal file
3
examples/hello/plugins/base/lang/en/lang.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
greeting: Hello
|
||||||
|
labels:
|
||||||
|
pluginName: Hello plugin
|
||||||
3
examples/hello/plugins/base/lang/pl/lang.yaml
Normal file
3
examples/hello/plugins/base/lang/pl/lang.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
greeting: Cześć
|
||||||
|
labels:
|
||||||
|
pluginName: Wtyczka Hello
|
||||||
@@ -9,11 +9,17 @@ import (
|
|||||||
"git.golem15.com/golem15/summercms/party"
|
"git.golem15.com/golem15/summercms/party"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ pact.HasConfig = (*Plugin)(nil)
|
var (
|
||||||
|
_ pact.HasConfig = (*Plugin)(nil)
|
||||||
|
_ pact.HasLang = (*Plugin)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
//go:embed config
|
//go:embed config
|
||||||
var configFS embed.FS
|
var configFS embed.FS
|
||||||
|
|
||||||
|
//go:embed lang
|
||||||
|
var langFS embed.FS
|
||||||
|
|
||||||
// Plugin is the golem15.hello plugin.
|
// Plugin is the golem15.hello plugin.
|
||||||
type Plugin struct{}
|
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) Boot(*backpack.App) error { return nil }
|
||||||
|
|
||||||
func (p *Plugin) ConfigFS() fs.FS { return configFS }
|
func (p *Plugin) ConfigFS() fs.FS { return configFS }
|
||||||
|
func (p *Plugin) LangFS() fs.FS { return langFS }
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
party.Register(&Plugin{})
|
party.Register(&Plugin{})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"git.golem15.com/golem15/summercms/backpack"
|
"git.golem15.com/golem15/summercms/backpack"
|
||||||
"git.golem15.com/golem15/summercms/pact"
|
"git.golem15.com/golem15/summercms/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/phrasebook"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Plugin is the required descriptor every compiled plugin implements.
|
// 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)
|
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 {
|
for _, p := range ordered {
|
||||||
if err := p.Boot(app); err != nil {
|
if err := p.Boot(app); err != nil {
|
||||||
return nil, fmt.Errorf("party: boot %s: %w", p.ID(), err)
|
return nil, fmt.Errorf("party: boot %s: %w", p.ID(), err)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package party
|
package party
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"git.golem15.com/golem15/summercms/bonfire"
|
"git.golem15.com/golem15/summercms/bonfire"
|
||||||
"git.golem15.com/golem15/summercms/compass"
|
"git.golem15.com/golem15/summercms/compass"
|
||||||
"git.golem15.com/golem15/summercms/pact"
|
"git.golem15.com/golem15/summercms/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/phrasebook"
|
||||||
)
|
)
|
||||||
|
|
||||||
type tracePlugin struct {
|
type tracePlugin struct {
|
||||||
@@ -394,3 +396,62 @@ func TestActivateDiscoversHasConfigAndHasCommands(t *testing.T) {
|
|||||||
t.Fatal("plain plugin must not satisfy HasConfig")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
173
phrasebook/translator.go
Normal file
173
phrasebook/translator.go
Normal file
@@ -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:]
|
||||||
|
}
|
||||||
98
phrasebook/translator_test.go
Normal file
98
phrasebook/translator_test.go
Normal file
@@ -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 }
|
||||||
Reference in New Issue
Block a user