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