diff --git a/README.md b/README.md index c6e09f95..56757baa 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,46 @@ localizer.Localize(&i18n.LocalizeConfig{ }) // Nick has 2 cats. ``` + +Use the singleton to ease usage. + +```go +bundle := i18n.NewBundle(language.English) +bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) +bundle.MustParseMessageFileBytes([]byte(` +HelloCookie = "Hello Cookie!" + +[Cookies] +one = "I have {{.PluralCount}} cookie!" +other = "I have {{.PluralCount}} cookies!" + +[CookiesX] +other = "I have {{.X}} cookies!" + +[CookiesX2] +one = "I have {{.PluralCount}} {{.X}} cookie!" +other = "I have {{.PluralCount}} {{.X}} cookies!" + +[CookiesABC] +other = "I have {{.A}} cookies in my {{.B}} back at {{.C}}!" + +[CookiesABC2] +other = "I have {{.PluralCount}} {{.A}} cookies in my {{.B}} back at {{.C}}!" +`), "en.toml") + +localizer := i18n.NewLocalizer(bundle, "en-US") +i18n.SetLocalizerInstance(localizer) +i18n.SetABCParams([]string{"A", "B", "C"}) + +i18n.Localize("HelloCookie") // "Hello Cookie!" +i18n.LocalizePlural("Cookies", 4) // "I have 4 cookies!" +i18n.LocalizeTemplateSingle("CookiesX", "X", "chocolate") // "I have chocolate cookies!" +i18n.LocalizeTemplateSingleWithPlural("CookiesX2", 4, "X", "chocolate") // "I have 4 chocolate cookies!" +i18n.LocalizeTemplateX("CookiesABC", "chocolate", "basket", "home") // "I have chocolate cookies in my basket back at home!" +i18n.LocalizeTemplateXPlural("CookiesABC2", 400, "chocolate", "basket", "home") // "I have 400 chocolate cookies in my basket back at home!" +``` + + ## Command goi18n [![Go Reference](https://pkg.go.dev/badge/github.com/nicksnyder/go-i18n/v2/goi18n.svg)](https://pkg.go.dev/github.com/nicksnyder/go-i18n/v2/goi18n) diff --git a/i18n/singleton.go b/i18n/singleton.go new file mode 100644 index 00000000..47498095 --- /dev/null +++ b/i18n/singleton.go @@ -0,0 +1,196 @@ +package i18n + +import ( + "sync" +) + +var ( + localizerSingletonOnce sync.Once + localizerInstance *Localizer + localizerMutex = &sync.Mutex{} + + notLocalized string + + abcParams []string +) + +func GetLocalizerInstance() *Localizer { + localizerSingletonOnce.Do(func() { + if localizerInstance == nil { + localizerInstance = new(Localizer) + } + }) + return localizerInstance +} + +// SetLocalizerInstance must be run before using `GetLocalizerInstance()` in a multi-threading manner. +func SetLocalizerInstance(l *Localizer) { + if localizerMutex != nil { + localizerMutex.Lock() + defer localizerMutex.Unlock() + } + + localizerInstance = l +} + +// SetUseNotLocalizedInfo is optional. +func SetUseNotLocalizedInfo(use bool) { + if use { + notLocalized = Localize("NotLocalized") + } else { + notLocalized = "" + } +} + +func ResetSingletonContext() { + SetUseNotLocalizedInfo(false) + SetABCParams(nil) +} + +// Localize loads localization for static text. +// +// Use only if you called SetLocalizerInstance(). +func Localize(id string) string { + localizerMutex.Lock() + defer localizerMutex.Unlock() + + localization, err := GetLocalizerInstance().Localize(&LocalizeConfig{MessageID: id}) + if err != nil { + return notLocalized + } + return localization +} + +// LocalizePlural loads localization for text with plural count. +// +// Use only if you called SetLocalizerInstance(). +func LocalizePlural(id string, count int) string { + localizerMutex.Lock() + defer localizerMutex.Unlock() + + localization, err := GetLocalizerInstance().Localize(&LocalizeConfig{ + MessageID: id, + PluralCount: count, + }) + if err != nil { + return notLocalized + } + return localization +} + +// LocalizeTemplate loads localization using template. +// +// Use only if you called SetLocalizerInstance(). +func LocalizeTemplate(id string, templateData map[string]any) string { + localizerMutex.Lock() + defer localizerMutex.Unlock() + + localization, err := GetLocalizerInstance().Localize(&LocalizeConfig{ + MessageID: id, + TemplateData: templateData, + }) + if err != nil { + return notLocalized + } + return localization +} + +// LocalizeTemplateSingle loads localization using template which has only one key-value pair. +// +// Use only if you called SetLocalizerInstance(). +func LocalizeTemplateSingle(id, singleKey string, singleValue any) string { + localizerMutex.Lock() + defer localizerMutex.Unlock() + + localization, err := GetLocalizerInstance().Localize(&LocalizeConfig{ + MessageID: id, + TemplateData: map[string]any{singleKey: singleValue}, + }) + if err != nil { + return notLocalized + } + return localization +} + +// LocalizeTemplateSingleWithPlural loads localization using template +// which has only one key-value pair for text with plural count. +// +// Use only if you called SetLocalizerInstance(). +func LocalizeTemplateSingleWithPlural(id string, count int, key string, value any) string { + localizerMutex.Lock() + defer localizerMutex.Unlock() + + localization, err := GetLocalizerInstance().Localize(&LocalizeConfig{ + MessageID: id, + TemplateData: map[string]any{ + key: value, + "PluralCount": count, + }, + PluralCount: count, + }) + if err != nil { + return notLocalized + } + return localization +} + +// SetABCParams defines the default keys for TemplateData when using an x-func. +// Defining a non-empty slice unlocks the following funcs: +// - LocalizeTemplateX() +// - LocalizeTemplateXPlural() +// +// Use only if you called SetLocalizerInstance(). +func SetABCParams(abc []string) { + abcParams = abc +} + +func buildABCTemplateData(values []any) map[string]any { + if len(values) > len(abcParams) { + return nil + } + + data := map[string]any{} + for i, val := range values { + data[abcParams[i]] = val + } + return data +} + +// LocalizeTemplateX assigns the given values to the TemplateData keys +// as per the key definitions you set by calling SetABCParams(). +// +// Use only if you called SetLocalizerInstance(). +func LocalizeTemplateX(id string, values ...any) string { + localizerMutex.Lock() + defer localizerMutex.Unlock() + + localization, err := GetLocalizerInstance().Localize(&LocalizeConfig{ + MessageID: id, + TemplateData: buildABCTemplateData(values), + }) + if err != nil { + return notLocalized + } + return localization +} + +// LocalizeTemplateXPlural assigns the given values to the TemplateData keys +// as per the key definitions you set by calling SetABCParams() with plural count. +// +// Use only if you called SetLocalizerInstance(). +func LocalizeTemplateXPlural(id string, count int, values ...any) string { + localizerMutex.Lock() + defer localizerMutex.Unlock() + + data := buildABCTemplateData(values) + data["PluralCount"] = count + + localization, err := GetLocalizerInstance().Localize(&LocalizeConfig{ + MessageID: id, + TemplateData: data, + }) + if err != nil { + return notLocalized + } + return localization +} diff --git a/i18n/singleton_test.go b/i18n/singleton_test.go new file mode 100644 index 00000000..0403bd2e --- /dev/null +++ b/i18n/singleton_test.go @@ -0,0 +1,416 @@ +package i18n_test + +import ( + "testing" + + "github.com/BurntSushi/toml" + "github.com/nicksnyder/go-i18n/v2/i18n" + "golang.org/x/text/language" +) + +func TestNotLocalizedInfo(t *testing.T) { + defer i18n.ResetSingletonContext() + + bundle := i18n.NewBundle(language.English) + bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) + bundle.MustParseMessageFileBytes([]byte(` +NotLocalized = "Text is not localized!" +HelloCookie = "Hello Cookie!" +`), "en.toml") + + localizer := i18n.NewLocalizer(bundle, "en-US") + i18n.SetLocalizerInstance(localizer) + + type test struct { + name string + id string + expected string + } + + // No "not localized" message defined. + tests := []test{ + { + name: "ID - no match found", + id: "Hello", + expected: "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := i18n.Localize(test.id) + if actual != test.expected { + t.Errorf("expected %q, got %q", test.expected, actual) + } + }) + } + + // "not localized" message defined! + i18n.SetUseNotLocalizedInfo(true) + + tests = []test{ + { + name: "ID - no match found", + id: "Hello", + expected: "Text is not localized!", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := i18n.Localize(test.id) + if actual != test.expected { + t.Errorf("expected %q, got %q", test.expected, actual) + } + }) + } + +} + +func TestLocalize(t *testing.T) { + defer i18n.ResetSingletonContext() + + bundle := i18n.NewBundle(language.English) + bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) + bundle.MustParseMessageFileBytes([]byte(` +HelloCookie = "Hello Cookie!" +`), "en.toml") + + localizer := i18n.NewLocalizer(bundle, "en-US") + i18n.SetLocalizerInstance(localizer) + + tests := []struct { + name string + id string + expected string + }{ + { + name: "ID - match found", + id: "HelloCookie", + expected: "Hello Cookie!", + }, + { + name: "ID - no match found", + id: "Hello", + expected: "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := i18n.Localize(test.id) + if actual != test.expected { + t.Errorf("expected %q, got %q", test.expected, actual) + } + }) + } +} + +func TestLocalizePlural(t *testing.T) { + defer i18n.ResetSingletonContext() + + bundle := i18n.NewBundle(language.English) + bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) + bundle.MustParseMessageFileBytes([]byte(` +HelloCookie = "Hello Cookie!" + +[Cookies] +one = "I have {{.PluralCount}} cookie!" +other = "I have {{.PluralCount}} cookies!" +`), "en.toml") + + localizer := i18n.NewLocalizer(bundle, "en-US") + i18n.SetLocalizerInstance(localizer) + + tests := []struct { + name string + id string + count int + expected string + }{ + { + name: "hello cookie found", + id: "HelloCookie", + expected: "Hello Cookie!", + }, + { + name: "hello cookie NOT found", + id: "HelloCookie", + count: 1, + expected: "", + }, + { + name: "1 cookie", + id: "Cookies", + count: 1, + expected: "I have 1 cookie!", + }, + { + name: "4 cookies", + id: "Cookies", + count: 4, + expected: "I have 4 cookies!", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := i18n.LocalizePlural(test.id, test.count) + if actual != test.expected { + t.Errorf("expected %q, got %q", test.expected, actual) + } + }) + } +} + +func TestLocalizeTemplate(t *testing.T) { + defer i18n.ResetSingletonContext() + + bundle := i18n.NewBundle(language.English) + bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) + bundle.MustParseMessageFileBytes([]byte(` +HelloCookie = "Hello Cookie!" + +[CookiesX] +other = "I have {{.X}} cookies!" +`), "en.toml") + + localizer := i18n.NewLocalizer(bundle, "en-US") + i18n.SetLocalizerInstance(localizer) + + tests := []struct { + name string + id string + key string + keyValue any + expected string + }{ + { + name: "hello cookie found", + id: "HelloCookie", + expected: "Hello Cookie!", + }, + { + name: "found chocolate cookies", + id: "CookiesX", + key: "X", + keyValue: "chocolate", + expected: "I have chocolate cookies!", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := i18n.LocalizeTemplate(test.id, map[string]any{ + test.key: test.keyValue, + }) + if actual != test.expected { + t.Errorf("expected %q, got %q", test.expected, actual) + } + }) + } +} + +func TestLocalizeTemplateSingle(t *testing.T) { + defer i18n.ResetSingletonContext() + + bundle := i18n.NewBundle(language.English) + bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) + bundle.MustParseMessageFileBytes([]byte(` +HelloCookie = "Hello Cookie!" + +[CookiesX] +other = "I have {{.X}} cookies!" +`), "en.toml") + + localizer := i18n.NewLocalizer(bundle, "en-US") + i18n.SetLocalizerInstance(localizer) + + tests := []struct { + name string + id string + key string + keyValue any + expected string + }{ + { + name: "hello cookie found", + id: "HelloCookie", + expected: "Hello Cookie!", + }, + { + name: "found chocolate cookies", + id: "CookiesX", + key: "X", + keyValue: "chocolate", + expected: "I have chocolate cookies!", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := i18n.LocalizeTemplateSingle(test.id, test.key, test.keyValue) + if actual != test.expected { + t.Errorf("expected %q, got %q", test.expected, actual) + } + }) + } +} + +func TestLocalizeTemplateSingleWithPlural(t *testing.T) { + defer i18n.ResetSingletonContext() + + bundle := i18n.NewBundle(language.English) + bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) + bundle.MustParseMessageFileBytes([]byte(` +HelloCookie = "Hello Cookie!" + +[CookiesX] +zero = "I have no {{.X}} cookie!" +one = "I have {{.PluralCount}} {{.X}} cookie!" +other = "I have {{.PluralCount}} {{.X}} cookies!" +`), "en.toml") + + localizer := i18n.NewLocalizer(bundle, "en-US") + i18n.SetLocalizerInstance(localizer) + + tests := []struct { + name string + id string + count int + key string + keyValue any + expected string + }{ + { + name: "hello cookie found", + id: "HelloCookie", + expected: "Hello Cookie!", + }, + // { // FIXME: Fails because of addPluralRules() in rule_gen.go for language "en". Zero is not defined. + // name: "found zero chocolate cookies", + // id: "CookiesX", + // count: 0, + // key: "X", + // keyValue: "chocolate", + // expected: "I have no chocolate cookie!", + // }, + { + name: "found 1 chocolate cookie", + id: "CookiesX", + count: 1, + key: "X", + keyValue: "chocolate", + expected: "I have 1 chocolate cookie!", + }, + { + name: "found 4 chocolate cookies", + id: "CookiesX", + count: 4, + key: "X", + keyValue: "chocolate", + expected: "I have 4 chocolate cookies!", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := i18n.LocalizeTemplateSingleWithPlural(test.id, test.count, test.key, test.keyValue) + if actual != test.expected { + t.Errorf("expected %q, got %q", test.expected, actual) + } + }) + } +} + +func TestLocalizeTemplateX(t *testing.T) { + defer i18n.ResetSingletonContext() + + bundle := i18n.NewBundle(language.English) + bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) + bundle.MustParseMessageFileBytes([]byte(` +HelloCookie = "Hello Cookie!" + +[CookiesABC] +other = "I have {{.A}} cookies in my {{.B}} back at {{.C}}!" +`), "en.toml") + + localizer := i18n.NewLocalizer(bundle, "en-US") + i18n.SetLocalizerInstance(localizer) + i18n.SetABCParams([]string{"A", "B", "C"}) + + tests := []struct { + name string + id string + keyValues []any + expected string + }{ + { + name: "hello cookie found", + id: "HelloCookie", + expected: "Hello Cookie!", + }, + { + name: "found chocolate cookies", + id: "CookiesABC", + keyValues: []any{"chocolate", "basket", "home"}, + expected: "I have chocolate cookies in my basket back at home!", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := i18n.LocalizeTemplateX(test.id, test.keyValues...) + if actual != test.expected { + t.Errorf("expected %q, got %q", test.expected, actual) + } + }) + } +} + +func TestLocalizeTemplateXPlural(t *testing.T) { + defer i18n.ResetSingletonContext() + + bundle := i18n.NewBundle(language.English) + bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) + bundle.MustParseMessageFileBytes([]byte(` +HelloCookie = "Hello Cookie!" + +[CookiesABC] +other = "I have {{.PluralCount}} {{.A}} cookies in my {{.B}} back at {{.C}}!" +`), "en.toml") + + localizer := i18n.NewLocalizer(bundle, "en-US") + i18n.SetLocalizerInstance(localizer) + i18n.SetABCParams([]string{"A", "B", "C"}) + + tests := []struct { + name string + id string + count int + keyValues []any + expected string + }{ + { + name: "hello cookie found", + id: "HelloCookie", + expected: "Hello Cookie!", + }, + { + name: "found chocolate cookies", + id: "CookiesABC", + count: 400, + keyValues: []any{"chocolate", "basket", "home"}, + expected: "I have 400 chocolate cookies in my basket back at home!", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := i18n.LocalizeTemplateXPlural(test.id, test.count, test.keyValues...) + if actual != test.expected { + t.Errorf("expected %q, got %q", test.expected, actual) + } + }) + } +}