- 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>
174 lines
4.3 KiB
Go
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:]
|
|
}
|