212 lines
8.8 KiB
Go
212 lines
8.8 KiB
Go
package i18n
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"allaboutapps.dev/aw/go-starter/internal/config"
|
|
"github.com/BurntSushi/toml"
|
|
"github.com/nicksnyder/go-i18n/v2/i18n"
|
|
"github.com/rs/zerolog/log"
|
|
"golang.org/x/text/language"
|
|
)
|
|
|
|
// i18n, we expect the following:
|
|
// Your translation toml files should live within /web/i18n and are named according to their supported locale e.g. en.toml, de.toml or en-uk.toml, en-us.toml.
|
|
//
|
|
// All translation files should hold the same keys (there are no unique keys on a single bundle locale, apart from pluralization).
|
|
//
|
|
// The Service object is created and owned by the api.Server (s.I18n), you typically don't want to create your own object.
|
|
//
|
|
// Pluralization obeys to CLDR rules (https://cldr.unicode.org/index/cldr-spec/plural-rules).
|
|
// Some keywords are reserved for CLDR behaviour, templating and documentation: id, description, hash, leftdelim, rightdelim, zero, one, two, few, many, other
|
|
// See
|
|
|
|
// Service is your convenience object to call Translate/TranslatePlural and match languages according to your loaded translation bundle and its supported languages/locales.
|
|
type Service struct {
|
|
bundle *i18n.Bundle
|
|
matcher language.Matcher
|
|
}
|
|
|
|
// Data should be used to pass your template data
|
|
type Data map[string]string
|
|
|
|
// New returns a new Service struct holding bundle and matcher with the settings of the given config
|
|
//
|
|
// Note that Service is typically created and owned by the api.Server (use it via s.I18n)
|
|
func New(config config.I18n) (*Service, error) {
|
|
bundle := i18n.NewBundle(config.DefaultLanguage)
|
|
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
|
|
|
// Load all translation files in each language...
|
|
files, err := os.ReadDir(config.BundleDirAbs)
|
|
if err != nil {
|
|
log.Err(err).Str("dir", config.BundleDirAbs).Msg("Failed to read i18n bundle directory")
|
|
return nil, fmt.Errorf("failed to read i18n bundle directory: %w", err)
|
|
}
|
|
|
|
for _, file := range files {
|
|
if file.IsDir() || !strings.HasSuffix(file.Name(), ".toml") {
|
|
continue
|
|
}
|
|
|
|
// bundle.LoadMessageFile automatically guesses the language.Tag based on the filenames it encounters
|
|
_, err := bundle.LoadMessageFile(filepath.Join(config.BundleDirAbs, file.Name()))
|
|
if err != nil {
|
|
log.Err(err).Str("file", file.Name()).Msg("Failed to load i18n message file")
|
|
return nil, fmt.Errorf("failed to load i18n message file: %w", err)
|
|
}
|
|
}
|
|
|
|
tags := bundle.LanguageTags()
|
|
|
|
for tagIndex, tag := range tags {
|
|
// Undetermined languages are disallowed in our bundle.
|
|
if tag == language.Und {
|
|
err := fmt.Errorf("undetermined language at index %v in i18n message bundle: %v", tagIndex, tags)
|
|
log.Err(err).Int("index", tagIndex).Str("tags", fmt.Sprintf("%v", tags)).Msg("Invalid i18n message bundle or default language.")
|
|
return nil, fmt.Errorf("invalid i18n message bundle or default language: %w", err)
|
|
}
|
|
}
|
|
|
|
return &Service{
|
|
bundle: bundle,
|
|
matcher: language.NewMatcher(tags),
|
|
}, nil
|
|
}
|
|
|
|
// Translate your key into a localized string.
|
|
//
|
|
// Translate makes a lookup for the key in the current bundle with the specified language.
|
|
// If a language translation is not available the default language will be used.
|
|
// Additional data for templated strings can be passed as key value pairs with by passing an optional data map.
|
|
//
|
|
// Translate will not fail if a template value is missing "<no value>" will be inserted instead.
|
|
// Translate will also not fail if the key is not present. "{{key}}" will be returned instead.
|
|
func (m *Service) Translate(key string, lang language.Tag, data ...Data) string {
|
|
msg, err := m.TranslateMaybe(key, lang, data...)
|
|
|
|
if err != nil {
|
|
log.Debug().Err(err).Str("key", key).Str("lang", lang.String()).Msg("Failed to translate")
|
|
return key
|
|
}
|
|
|
|
return msg
|
|
}
|
|
|
|
// TranslateMaybe has the same sematics as Translate with the following exceptions:
|
|
// It exposes encountered errors (does not automatically log this error) and encountered errors may result in an empty "" string!
|
|
//
|
|
// This method may be useful for conditional translation rendering (if key is available, use that, else...).
|
|
func (m *Service) TranslateMaybe(key string, lang language.Tag, data ...Data) (string, error) {
|
|
localizeConfig := &i18n.LocalizeConfig{
|
|
MessageID: key,
|
|
}
|
|
|
|
if len(data) > 0 {
|
|
localizeConfig.TemplateData = data[0]
|
|
}
|
|
|
|
return m.translateConfigurable(lang, localizeConfig)
|
|
}
|
|
|
|
// TranslatePlural translates a pluralized cldrKey into a localized string.
|
|
//
|
|
// TranslatePlural makes a lookup for the cldrKey (a base key holding CLDR keys like "one" and "other") in the current bundle with the specified language.
|
|
// This function should be used to conditionally show the pluralized form, controlled by the count param and according to the CLDR rules.
|
|
//
|
|
// Note that English and German only support .one and .other CLDR plural rules.
|
|
// See https://cldr.unicode.org/index/cldr-spec/plural-rules and https://www.unicode.org/cldr/cldr-aux/charts/28/supplemental/language_plural_rules.html
|
|
//
|
|
// If a language translation is not available the default language will be used.
|
|
// Additional data for templated strings can be passed as key value pairs with by passing an optional data map.
|
|
// The count param is automatically injected into this data map as stringified {{.Count}} and may be overwritten.
|
|
//
|
|
// TranslatePlural will not fail if a template value is missing "<no value>" will be inserted instead.
|
|
// TranslatePlural will also not fail if the cldrKey is not present. "{{cldrKey}} (count={{count}})" will be returned instead.
|
|
func (m *Service) TranslatePlural(cldrKey string, count interface{}, lang language.Tag, data ...Data) string {
|
|
msg, err := m.TranslatePluralMaybe(cldrKey, count, lang, data...)
|
|
if err != nil {
|
|
log.Debug().Err(err).Str("count", fmt.Sprintf("%v", count)).Str("cldrKey", cldrKey).Str("lang", lang.String()).Msg("Failed to translate plural")
|
|
return fmt.Sprintf("%s (count=%v)", cldrKey, count)
|
|
}
|
|
|
|
return msg
|
|
}
|
|
|
|
// TranslatePluralMaybe uses the same sematics as TranslatePlural with the following exceptions:
|
|
// It exposes encountered errors (does not automatically log this error) and encountered errors may result in an empty "" string!
|
|
//
|
|
// This method may be useful for conditional plural translation rendering (if key is available, use that, else...).
|
|
func (m *Service) TranslatePluralMaybe(cldrKey string, count interface{}, lang language.Tag, data ...Data) (string, error) {
|
|
localizeConfig := &i18n.LocalizeConfig{
|
|
MessageID: cldrKey,
|
|
PluralCount: count,
|
|
}
|
|
|
|
// We inject Count by default into our template data (for rare usecases you may overwrite it)
|
|
templateData := make(Data)
|
|
templateData["Count"] = fmt.Sprintf("%v", count)
|
|
|
|
// If optional data was provided, merge them into the templateData map
|
|
if len(data) > 0 {
|
|
for k, v := range data[0] {
|
|
templateData[k] = v
|
|
}
|
|
}
|
|
|
|
localizeConfig.TemplateData = templateData
|
|
|
|
return m.translateConfigurable(lang, localizeConfig)
|
|
}
|
|
|
|
// ParseAcceptLanguage takes the value of the Accept-Language header and returns
|
|
// the best matched language using the matcher.
|
|
func (m *Service) ParseAcceptLanguage(lang string) language.Tag {
|
|
// we deliberately ignore the error returned here, as it will be nil and the matcher will simply pick the default language
|
|
// this allows us to skip any malformed Accept-Language headers without returning 500 errors to the client
|
|
// additionally, we don't really care about the q-factor weighting or confidence, the first match will be picked (with a fallback to config.DefaultLanguage)
|
|
tags, _, err := language.ParseAcceptLanguage(lang)
|
|
if err != nil {
|
|
log.Err(err).Str("lang", lang).Msg("Failed to parse accept language")
|
|
}
|
|
matchedTag, _, _ := m.matcher.Match(tags...)
|
|
|
|
return matchedTag
|
|
}
|
|
|
|
// ParseLang parses the string as language tag and returns
|
|
// the best matched language using the matcher.
|
|
func (m *Service) ParseLang(lang string) language.Tag {
|
|
t, err := language.Parse(lang)
|
|
if err != nil {
|
|
log.Err(err).Str("lang", lang).Msg("Failed to parse language")
|
|
}
|
|
|
|
matchedTag, _, _ := m.matcher.Match(t)
|
|
|
|
return matchedTag
|
|
}
|
|
|
|
// Tags returns the parsed and priority ordered []language.Tag (your config.DefaultLanguage will be on position 0)
|
|
func (m *Service) Tags() []language.Tag {
|
|
return m.bundle.LanguageTags()
|
|
}
|
|
|
|
// translateConfigurable is used internally for fully configurable translations according to our configured language precedence semantics (new Localizer per call).
|
|
func (m *Service) translateConfigurable(lang language.Tag, localizeConfig *i18n.LocalizeConfig) (string, error) {
|
|
// We benchmarked precaching all known []i18n.NewLocalizer during initialization,
|
|
// but it doesn't make a significant difference even with 10000 concurrent * 8 .Translate calls.
|
|
// Thus we take the easy route and initialize a new localizer with each .Translate or .TranslatePlural call.
|
|
localizer := i18n.NewLocalizer(m.bundle, lang.String())
|
|
|
|
msg, err := localizer.Localize(localizeConfig)
|
|
if err != nil {
|
|
return msg, fmt.Errorf("failed to localize: %w", err)
|
|
}
|
|
|
|
return msg, nil
|
|
}
|