Files
summercms/phrasebook/translator.go
Jakub Zych f824e55c81 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>
2026-09-18 13:47:45 +02:00

174 lines
4.3 KiB
Go

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:]
}