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:
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:]
|
||||
}
|
||||
Reference in New Issue
Block a user