Files
Zane Walker 7e940c83a7
Some checks failed
Build & Test / build-test (push) Has been cancelled
Build & Test / swagger-codegen-cli (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
(Feat): Initial Commit
2026-07-03 19:41:31 +05:30

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
}