(Feat): Initial Commit
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

This commit is contained in:
2026-07-03 19:41:31 +05:30
commit 7e940c83a7
461 changed files with 45002 additions and 0 deletions

211
internal/i18n/i18n.go Normal file
View 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
}

589
internal/i18n/i18n_test.go Normal file
View File

@@ -0,0 +1,589 @@
package i18n_test
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/config"
"allaboutapps.dev/aw/go-starter/internal/i18n"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/language"
)
func TestServerProvidedI18n(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
// uses /app/web/i18n by default
// expect all i18n files were loaded and the defaultLanguage matches the FIRST priority Tag.
assert.Equal(t, s.Config.I18n.DefaultLanguage, s.I18n.Tags()[0])
// expect all i18ns were loaded...
files, err := os.ReadDir(s.Config.I18n.BundleDirAbs)
require.NoError(t, err)
msgFilesCount := 0
for _, file := range files {
if file.IsDir() || !strings.HasSuffix(file.Name(), ".toml") {
continue
}
msgFilesCount++
}
if msgFilesCount == 0 {
// no i18n bundles were available, as the defaultLanguage is a tag itself, check for len 1
assert.Len(t, s.I18n.Tags(), 1)
} else {
assert.Len(t, s.I18n.Tags(), msgFilesCount)
}
msg := s.I18n.Translate("this.key.will.never.exist", s.Config.I18n.DefaultLanguage)
assert.Equal(t, "this.key.will.never.exist", msg)
})
}
// Note that all following tests use a special message directory within /internal/i18n/testdata.
// We do this to ensure we don't depend on your project specific i18n bundle/configuration,
// that you would typically store within /web/i18n.
func TestI18n(t *testing.T) {
srv, err := i18n.New(config.I18n{
DefaultLanguage: language.English,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
})
require.NoError(t, err)
assert.Equal(t, language.English, srv.Tags()[0])
assert.Equal(t, language.German, srv.Tags()[1])
assert.Len(t, srv.Tags(), 2)
msg := srv.Translate("Test.Welcome", language.German, i18n.Data{"Name": "Hans"})
assert.Equal(t, "Guten Tag Hans", msg)
msg = srv.Translate("Test.Welcome", language.English, i18n.Data{"Name": "Hans"})
assert.Equal(t, "Welcome Hans", msg)
msg = srv.Translate("Test.Welcome", language.Spanish, i18n.Data{"Name": "Hans"})
assert.Equal(t, "Welcome Hans", msg)
msg = srv.Translate("Test.Welcome", language.English, i18n.Data{"Name": "Franz"})
assert.Equal(t, "Welcome Franz", msg)
msg = srv.Translate("Test.Welcome", language.English)
assert.Equal(t, "Welcome <no value>", msg)
msg = srv.Translate("Test.Body", language.German)
assert.Equal(t, "Das ist ein Test", msg)
msg = srv.Translate("Test.Body", language.English)
assert.Equal(t, "This is a test", msg)
msg = srv.Translate("Test.Body", language.Spanish)
assert.Equal(t, "This is a test", msg)
msg = srv.Translate("Test.Invalid.Key.Does.Not.Exist", language.English)
assert.Equal(t, "Test.Invalid.Key.Does.Not.Exist", msg)
msg = srv.Translate("Test.Invalid.Key.Does.Not.Exist", language.German)
assert.Equal(t, "Test.Invalid.Key.Does.Not.Exist", msg)
msg = srv.Translate("Test.String.DE.only", language.English)
assert.Equal(t, "Test.String.DE.only", msg)
msg = srv.Translate("Test.String.DE.only", language.German)
assert.Equal(t, "This key only exists in DE", msg)
msg, err = srv.TranslateMaybe("Test.String.DE.only", language.English)
require.Error(t, err)
assert.Empty(t, msg) // no fallback!
msg = srv.Translate("Test.String.EN.only", language.English)
assert.Equal(t, "This key only exists in EN", msg)
msg, err = srv.TranslateMaybe("Test.String.EN.only", language.German)
require.Error(t, err)
assert.Equal(t, "This key only exists in EN", msg) // fallback (but error)
msg = srv.Translate("Test.String.EN.only", language.German)
assert.Equal(t, "Test.String.EN.only", msg)
msg = srv.Translate("", language.German) // empty key
assert.Empty(t, msg)
msg, err = srv.TranslateMaybe("", language.German) // empty key
require.Error(t, err)
assert.Empty(t, msg)
// ensure language subvariants are supported
deAt := srv.ParseLang("de-AT")
assert.NotEqual(t, language.German, deAt)
msg = srv.Translate("Test.Body", deAt)
assert.Equal(t, "Das ist ein Test", msg)
}
func TestI18nConcurrentUsage(t *testing.T) {
srv, err := i18n.New(config.I18n{
DefaultLanguage: language.English,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
})
require.NoError(t, err)
wg := sync.WaitGroup{}
wg.Add(100)
for i := 0; i < 100; i++ {
go func(index int) {
msg := srv.Translate("Test.Welcome", language.German, i18n.Data{"Name": strconv.Itoa(index)})
assert.Equal(t, fmt.Sprintf("Guten Tag %v", index), msg)
msg = srv.Translate("Test.Welcome", language.English, i18n.Data{"Name": strconv.Itoa(index)})
assert.Equal(t, fmt.Sprintf("Welcome %v", index), msg)
msg = srv.Translate("Test.Welcome", language.Spanish, i18n.Data{"Name": strconv.Itoa(index)})
assert.Equal(t, fmt.Sprintf("Welcome %v", index), msg)
msg = srv.Translate("Test.Welcome", language.English, i18n.Data{"Name": "Franz"})
assert.Equal(t, "Welcome Franz", msg)
msg = srv.Translate("Test.Welcome", language.English)
assert.Equal(t, "Welcome <no value>", msg)
msg = srv.Translate("Test.Body", language.German)
assert.Equal(t, "Das ist ein Test", msg)
msg = srv.Translate("Test.Body", language.English)
assert.Equal(t, "This is a test", msg)
msg = srv.Translate("Test.Body", language.Spanish)
assert.Equal(t, "This is a test", msg)
wg.Done()
}(i)
}
wg.Wait()
}
func TestI18nOtherDefault(t *testing.T) {
srv, err := i18n.New(config.I18n{
DefaultLanguage: language.German,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
})
require.NoError(t, err)
assert.Equal(t, language.German, srv.Tags()[0])
assert.Equal(t, language.English, srv.Tags()[1])
assert.Len(t, srv.Tags(), 2)
}
func TestI18nInexistantDefault(t *testing.T) {
srv, err := i18n.New(config.I18n{
DefaultLanguage: language.Italian,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
})
require.NoError(t, err)
assert.Equal(t, language.Italian, srv.Tags()[0])
assert.Equal(t, language.German, srv.Tags()[1])
assert.Equal(t, language.English, srv.Tags()[2])
assert.Len(t, srv.Tags(), 3)
}
func TestI18nEmpty(t *testing.T) {
srv, err := i18n.New(config.I18n{
DefaultLanguage: language.Italian,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-empty"),
})
require.NoError(t, err)
assert.Len(t, srv.Tags(), 1) // the DefaultLanguage should still be set!
assert.Equal(t, language.Italian, srv.Tags()[0])
tag := srv.ParseAcceptLanguage("de,en-US;q=0.7,en;q=0.3")
assert.Equal(t, language.Italian, tag)
msg := srv.Translate("no.test.key.exists", tag)
assert.Equal(t, "no.test.key.exists", msg)
msg, err = srv.TranslateMaybe("no.test.key.exists", tag)
require.Error(t, err)
assert.Empty(t, msg)
msg = srv.Translate("no.test.key.exists", language.Ukrainian)
assert.Equal(t, "no.test.key.exists", msg)
msg, err = srv.TranslateMaybe("no.test.key.exists", language.Ukrainian)
require.Error(t, err)
assert.Empty(t, msg)
}
func TestI18nSpecialized(t *testing.T) {
srv, err := i18n.New(config.I18n{
DefaultLanguage: language.AmericanEnglish,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-specialized"),
})
require.NoError(t, err)
assert.Len(t, srv.Tags(), 4) // specialized subvariant is default
assert.Equal(t, language.AmericanEnglish, srv.Tags()[0])
msg := srv.Translate("test.punchline", language.AmericanEnglish)
assert.Equal(t, "I can has HUMOR?", msg)
msg = srv.Translate("test.punchline", language.BritishEnglish)
assert.Equal(t, "I can has HUMOUR?", msg)
msg = srv.Translate("test.punchline", language.English)
assert.Equal(t, "I can has HUMOR?", msg) // fall back to default
msg = srv.Translate("test.punchline", language.German)
assert.Equal(t, "Habe ich Humor?", msg) // jump to parsed Austrian German
tag := srv.ParseAcceptLanguage("de-at,en-US;q=0.7,en;q=0.3") // explicit Austrian tag
msg = srv.Translate("test.punchline", tag)
assert.Equal(t, "Koan i Humor?", msg) // jump to parsed Austrian German
}
func TestReservedKeywordsResolve(t *testing.T) {
// "reserved" keys:
// "id", "description", "hash", "leftdelim", "rightdelim", "zero", "one", "two", "few", "many", "other"
// see https://github.com/nicksnyder/go-i18n/blob/2180cd9f35b3e125cfe3773a6bf3ea483347f060/v2/i18n/message.go#L181
srv, err := i18n.New(config.I18n{
DefaultLanguage: language.English,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-reserved"),
})
require.NoError(t, err)
assert.Len(t, srv.Tags(), 2)
// single reserved word (not yet mapped, requires 2 keywords at least)
msg := srv.Translate("reserved1.zero", language.English)
assert.Equal(t, "Zero", msg)
msg = srv.Translate("reserved2.one", language.English)
assert.Equal(t, "One", msg)
msg = srv.Translate("reserved3.two", language.English)
assert.Equal(t, "Two", msg)
msg = srv.Translate("reserved4.few", language.English)
assert.Equal(t, "Few", msg)
msg = srv.Translate("reserved5.many", language.English)
assert.Equal(t, "Many", msg)
msg = srv.Translate("reserved6.other", language.English)
assert.Equal(t, "Other", msg)
msg = srv.Translate("reserved7.id", language.English)
assert.Equal(t, "id", msg)
msg = srv.Translate("reserved8.description", language.English)
assert.Equal(t, "Description", msg)
// single parent is not directly resolveable
msg = srv.Translate("reserved2", language.English)
assert.Equal(t, "reserved2", msg)
// german: single reserved word
msg = srv.Translate("reserved1.zero", language.German)
assert.Equal(t, "Null", msg)
msg = srv.Translate("reserved2.one", language.German)
assert.Equal(t, "Eins", msg)
msg = srv.Translate("reserved3.two", language.German)
assert.Equal(t, "Zwei", msg)
msg = srv.Translate("reserved4.few", language.German)
assert.Equal(t, "Wenig", msg)
msg = srv.Translate("reserved5.many", language.German)
assert.Equal(t, "Mehr", msg)
msg = srv.Translate("reserved6.other", language.German)
assert.Equal(t, "Andere", msg)
msg = srv.Translate("reserved7.id", language.German)
assert.Equal(t, "ID", msg)
msg = srv.Translate("reserved8.description", language.German)
assert.Equal(t, "Beschreibung", msg)
// Combined toml map: all reserved words
// This does not work as it's parsed as map and CLDR plural rules now apply!
// The last key is interpreted as https://cldr.unicode.org/index/cldr-spec/plural-rules
msg = srv.Translate("reservedMap.zero", language.English)
assert.Equal(t, "reservedMap.zero", msg)
msg = srv.Translate("reservedMap.one", language.English)
assert.Equal(t, "reservedMap.one", msg)
msg = srv.Translate("reservedMap.two", language.English)
assert.Equal(t, "reservedMap.two", msg)
msg = srv.Translate("reservedMap.few", language.English)
assert.Equal(t, "reservedMap.few", msg)
msg = srv.Translate("reservedMap.many", language.English)
assert.Equal(t, "reservedMap.many", msg)
msg = srv.Translate("reservedMap.other", language.English)
assert.Equal(t, "reservedMap.other", msg)
msg = srv.TranslatePlural("reservedMap", 0, language.English)
assert.Equal(t, "Other", msg)
msg = srv.TranslatePlural("reservedMap", 1, language.English)
assert.Equal(t, "One", msg)
msg = srv.TranslatePlural("reservedMap", 2, language.English)
assert.Equal(t, "Other", msg)
msg = srv.TranslatePlural("reservedMap", "asdfasdf", language.English)
assert.Equal(t, "reservedMap (count=asdfasdf)", msg)
// plain toml: all reserved words
// This does not work as it's parsed as map and CLDR plural rules now apply!
// The last key is interpreted as https://cldr.unicode.org/index/cldr-spec/plural-rules
msg = srv.Translate("reserved.plain.zero", language.English)
assert.Equal(t, "reserved.plain.zero", msg)
msg = srv.Translate("reserved.plain.one", language.English)
assert.Equal(t, "reserved.plain.one", msg)
msg = srv.Translate("reserved.plain.two", language.English)
assert.Equal(t, "reserved.plain.two", msg)
msg = srv.Translate("reserved.plain.few", language.English)
assert.Equal(t, "reserved.plain.few", msg)
msg = srv.Translate("reserved.plain.many", language.English)
assert.Equal(t, "reserved.plain.many", msg)
msg = srv.Translate("reserved.plain.other", language.English)
assert.Equal(t, "reserved.plain.other", msg)
msg = srv.Translate("reserved.plain2.id", language.English)
assert.Equal(t, "reserved.plain2.id", msg)
msg = srv.Translate("reserved.plain2.description", language.English)
assert.Equal(t, "reserved.plain2.description", msg)
msg = srv.Translate("reserved.plain3.id", language.English)
assert.Equal(t, "reserved.plain3.id", msg)
msg = srv.Translate("reserved.plain3.description", language.English)
assert.Equal(t, "reserved.plain3.description", msg)
msg = srv.Translate("reserved.plain3.test", language.English)
assert.Equal(t, "reserved.plain3.test", msg)
}
func TestI18nPlural(t *testing.T) {
srv, err := i18n.New(config.I18n{
DefaultLanguage: language.English,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-plural"),
})
require.NoError(t, err)
assert.Len(t, srv.Tags(), 2)
msg := srv.TranslatePlural("cats", 0, language.AmericanEnglish)
assert.Equal(t, "I've 0 cats.", msg) // zero is not supported for CLDR English, "I don't have a cat." is not possible!
msg = srv.TranslatePlural("cats", 1, language.BritishEnglish)
assert.Equal(t, "I've one cat.", msg)
msg = srv.TranslatePlural("cats", 2, language.English)
assert.Equal(t, "I've 2 cats.", msg)
msg = srv.TranslatePlural("cats", 8, language.English)
assert.Equal(t, "I've 8 cats.", msg)
msg = srv.TranslatePlural("cats", -1, language.English) // negative and positive scales behave the same!
assert.Equal(t, "I've one cat.", msg)
msg = srv.TranslatePlural("cats", -2, language.English) // negative and positive scales behave the same!
assert.Equal(t, "I've -2 cats.", msg)
msg, err = srv.TranslatePluralMaybe("cats", -2, language.English)
require.NoError(t, err)
assert.Equal(t, "I've -2 cats.", msg)
msg = srv.TranslatePlural("cats", nil, language.English)
assert.Equal(t, "I've <nil> cats.", msg) // invalid count
msg, err = srv.TranslatePluralMaybe("cats", nil, language.English)
require.NoError(t, err)
assert.Equal(t, "I've <nil> cats.", msg) // invalid count
msg = srv.TranslatePlural("cats", "many", language.English)
assert.Equal(t, "cats (count=many)", msg) // internal failed to translate plural!
// overwrite Count
msg = srv.TranslatePlural("cats", 8, language.English, i18n.Data{"Count": "too many"})
assert.Equal(t, "I've too many cats.", msg)
msg = srv.TranslatePlural("cats", 0, language.German)
assert.Equal(t, "Ich habe 0 Katzen.", msg) // zero is not supported for CLDR German, "Ich habe keine Katze." is not possible!
msg = srv.TranslatePlural("cats", 1, language.German)
assert.Equal(t, "Ich habe eine Katze.", msg)
msg = srv.TranslatePlural("cats", 2, language.German)
assert.Equal(t, "Ich habe 2 Katzen.", msg)
msg = srv.TranslatePlural("cats", 8, language.German)
assert.Equal(t, "Ich habe 8 Katzen.", msg)
msg = srv.TranslatePlural("cats", "viele", language.German)
assert.Equal(t, "cats (count=viele)", msg) // internal failed to translate plural!
msg, err = srv.TranslatePluralMaybe("cats", "viele", language.German)
require.Error(t, err)
assert.Empty(t, msg) // empty string for errors!
// overwrite Count
msg = srv.TranslatePlural("cats", 8, language.German, i18n.Data{"Count": "zu viele"})
assert.Equal(t, "Ich habe zu viele Katzen.", msg)
// unknown language string
tag := srv.ParseLang("xx")
assert.Equal(t, language.English, tag)
msg = srv.TranslatePlural("cats", 8, tag)
assert.Equal(t, "I've 8 cats.", msg) // fall back to English
// invalid specialized language string
tag = srv.ParseLang("de-xx")
msg = srv.TranslatePlural("cats", 8, tag)
assert.Equal(t, "Ich habe 8 Katzen.", msg) // fall back to German
// invalid language string
tag = srv.ParseLang("§$%/%&/(/&%/)(")
assert.Equal(t, language.English, tag)
msg = srv.TranslatePlural("cats", 8, tag)
assert.Equal(t, "I've 8 cats.", msg) // fall back to English
// unknown
msg = srv.TranslatePlural("this.key.will.never.exist", nil, language.English)
assert.Equal(t, "this.key.will.never.exist (count=<nil>)", msg)
msg, err = srv.TranslatePluralMaybe("this.key.will.never.exist", nil, language.English)
require.Error(t, err)
assert.Empty(t, msg) // empty string for errors!
}
func TestI18nUndetermined(t *testing.T) {
_, err := i18n.New(config.I18n{
DefaultLanguage: language.English,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-undetermined"),
})
require.Error(t, err)
unwrappedErr := errors.Unwrap(err)
require.Error(t, unwrappedErr)
assert.Equal(t, unwrappedErr, errors.New("undetermined language at index 1 in i18n message bundle: [en und]"))
}
func TestI18nUndeterminedDefaultLanguage(t *testing.T) {
_, err := i18n.New(config.I18n{
DefaultLanguage: language.Und, // Undetermined is disallowed
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
})
require.Error(t, err)
unwrappedErr := errors.Unwrap(err)
require.Error(t, unwrappedErr)
assert.Equal(t, unwrappedErr, errors.New("undetermined language at index 0 in i18n message bundle: [und de en]"))
}
func TestI18nInvalidToml(t *testing.T) {
_, err := i18n.New(config.I18n{
DefaultLanguage: language.English,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-invalid"),
})
require.Error(t, err)
}
func TestI18nInexistantFolder(t *testing.T) {
_, err := i18n.New(config.I18n{
DefaultLanguage: language.AmericanEnglish,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n/this/folder/does/not/exist"),
})
require.Error(t, err)
}
func TestParseAcceptLanguage(t *testing.T) {
srv, err := i18n.New(config.I18n{
DefaultLanguage: language.English,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
})
require.NoError(t, err)
tag := srv.ParseAcceptLanguage("de,en-US;q=0.7,en;q=0.3")
assert.Equal(t, language.German, tag)
tag = srv.ParseAcceptLanguage("de-AT,en-US;q=0.7,en;q=0.3")
assert.NotEqual(t, language.German, tag) // actual: de-u-rg-atzzzz
// unknown language header
tag = srv.ParseAcceptLanguage("xx,en-US;q=0.7,en;q=0.3")
assert.Equal(t, language.English, tag)
msg := srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
assert.Equal(t, "Welcome Hans", msg)
// invalid specialized language string
tag = srv.ParseAcceptLanguage("de-xx,en-US;q=0.7,en;q=0.3")
msg = srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
assert.Equal(t, "Guten Tag Hans", msg)
// invalid language header
tag = srv.ParseAcceptLanguage("§$%/%&/(/&%/)(")
assert.Equal(t, language.English, tag)
msg = srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
assert.Equal(t, "Welcome Hans", msg)
}
func TestParseLanguage(t *testing.T) {
srv, err := i18n.New(config.I18n{
DefaultLanguage: language.English,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
})
require.NoError(t, err)
tag := srv.ParseLang("de")
assert.Equal(t, language.German, tag)
// unknown language string
tag = srv.ParseLang("xx")
assert.Equal(t, language.English, tag)
msg := srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
assert.Equal(t, "Welcome Hans", msg)
// invalid specialized language string
tag = srv.ParseLang("de-xx")
msg = srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
assert.Equal(t, "Guten Tag Hans", msg) // fall back to German
// invalid language string
tag = srv.ParseLang("§$%/%&/(/&%/)(")
assert.Equal(t, language.English, tag)
msg = srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
assert.Equal(t, "Welcome Hans", msg)
}

3
internal/i18n/testdata/README.md vendored Normal file
View File

@@ -0,0 +1,3 @@
# `internal/i18n/testdata`
These translation toml files are not meant to be touched while doing application development, rather they are here to test the baseline functionality of the i18n package.

View File

View File

@@ -0,0 +1,2 @@
# This file in invalid on purpose.
This is not toml.

View File

@@ -0,0 +1,13 @@
# {{.Count}} is injected by default while using service.TranslatePlural
# pluralized structured format
[cats]
zero="Ich habe keine Katze."
one="Ich habe eine Katze."
other="Ich habe {{.Count}} Katzen."
# pluralized single line format
"dogs.zero"="Ich habe keinen Hund."
"dogs.one"="Ich habe einen Hund."
"dogs.other"="Ich habe {{.Count}} Hunde."

View File

@@ -0,0 +1,12 @@
# {{.Count}} is injected by default while using service.TranslatePlural
# pluralized structured format
[cats]
zero="I don't have a cat."
one="I've one cat."
other="I've {{.Count}} cats."
# pluralized single line format
"dogs.zero"="I don't have a dog."
"dogs.one"="I've one dog."
"dogs.other"="I've {{.Count}} dogs."

View File

@@ -0,0 +1,34 @@
# "reserved" keys:
# "id", "description", "hash", "leftdelim", "rightdelim", "zero", "one", "two", "few", "many", "other"
# see https://github.com/nicksnyder/go-i18n/blob/2180cd9f35b3e125cfe3773a6bf3ea483347f060/v2/i18n/message.go#L181
"reserved1.zero"="Null"
"reserved2.one"="Eins"
"reserved3.two"="Zwei"
"reserved4.few"="Wenig"
"reserved5.many"="Mehr"
"reserved6.other"="Andere"
"reserved7.id"="ID"
"reserved8.description"="Beschreibung"
[reservedMap]
zero="Null"
one="Eins"
two="Zwei"
few="Wenig"
many="Mehr"
other="Andere"
"reserved.plain.zero"="Null"
"reserved.plain.one"="Eins"
"reserved.plain.two"="Zwei"
"reserved.plain.few"="Wenig"
"reserved.plain.many"="Mehr"
"reserved.plain.other"="Andere"
"reserved.plain2.id"="ID"
"reserved.plain2.description"="Beschreibung"
"reserved.plain3.id"="ID"
"reserved.plain3.description"="Beschreibung"
"reserved.plain3.test"="Test" # can i see this non-reserved one by FQDN?

View File

@@ -0,0 +1,34 @@
# "reserved" keys:
# "id", "description", "hash", "leftdelim", "rightdelim", "zero", "one", "two", "few", "many", "other"
# see https://github.com/nicksnyder/go-i18n/blob/2180cd9f35b3e125cfe3773a6bf3ea483347f060/v2/i18n/message.go#L181
"reserved1.zero"="Zero"
"reserved2.one"="One"
"reserved3.two"="Two"
"reserved4.few"="Few"
"reserved5.many"="Many"
"reserved6.other"="Other"
"reserved7.id"="id"
"reserved8.description"="Description"
[reservedMap]
zero="Zero"
one="One"
two="Two"
few="Few"
many="Many"
other="Other"
"reserved.plain.zero"="Zero"
"reserved.plain.one"="One"
"reserved.plain.two"="Two"
"reserved.plain.few"="Few"
"reserved.plain.many"="Many"
"reserved.plain.other"="Other"
"reserved.plain2.id"="id"
"reserved.plain2.description"="Description"
"reserved.plain3.id"="id"
"reserved.plain3.description"="Description"
"reserved.plain3.test"="Test" # can i see this non-reserved one by FQDN?

View File

@@ -0,0 +1,2 @@
[test]
punchline="Koan i Humor?"

View File

@@ -0,0 +1,2 @@
[test]
punchline="Habe ich Humor?"

View File

@@ -0,0 +1,2 @@
[test]
punchline="I can has HUMOUR?"

View File

@@ -0,0 +1,2 @@
[test]
punchline="I can has HUMOR?"

View File

@@ -0,0 +1,2 @@
[test]
language="xx"

4
internal/i18n/testdata/i18n/de.toml vendored Normal file
View File

@@ -0,0 +1,4 @@
[Test]
Body = "Das ist ein Test"
Welcome = "Guten Tag {{.Name}}"
"String.DE.only" = "This key only exists in DE"

4
internal/i18n/testdata/i18n/en.toml vendored Normal file
View File

@@ -0,0 +1,4 @@
[Test]
Body = "This is a test"
Welcome = "Welcome {{.Name}}"
"String.EN.only" = "This key only exists in EN"