(Feat): Initial Commit
This commit is contained in:
211
internal/i18n/i18n.go
Normal file
211
internal/i18n/i18n.go
Normal file
@@ -0,0 +1,211 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user