From 3256a0f5da1a9e11eeb785eed504a6fb98c75f1e Mon Sep 17 00:00:00 2001 From: YamiOdymel Date: Mon, 22 May 2023 05:49:41 +0800 Subject: [PATCH] first commit --- .travis.yml | 14 ++ LICENSE | 21 +++ README.md | 418 ++++++++++++++++++++++++++++++++++++++++++ go.mod | 11 ++ go.sum | 10 + i18n.go | 300 ++++++++++++++++++++++++++++++ i18n_test.go | 371 +++++++++++++++++++++++++++++++++++++ locale.go | 68 +++++++ test/zh-tw.json | 3 + test/zh_TW.json | 3 + test/zh_tW.yml | 3 + test/zh_tw.hello.json | 3 + util.go | 20 ++ 13 files changed, 1245 insertions(+) create mode 100644 .travis.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 i18n.go create mode 100644 i18n_test.go create mode 100644 locale.go create mode 100644 test/zh-tw.json create mode 100644 test/zh_TW.json create mode 100644 test/zh_tW.yml create mode 100644 test/zh_tw.hello.json create mode 100644 util.go diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..2aa97ad --- /dev/null +++ b/.travis.yml @@ -0,0 +1,14 @@ +language: go + +go: + - "1.18" + - "1.19" + - "1.20" + +script: + - go get golang.org/x/tools/cmd/cover + - go get github.com/mattn/goveralls + - go get ./... + - go test ./... + - go test -v -covermode=count -coverprofile=coverage.out + - $GOPATH/bin/goveralls -coverprofile=coverage.out -service=travis-ci -repotoken $COVERALLS_TOKEN diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..36035b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 TeaCat + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d86268 --- /dev/null +++ b/README.md @@ -0,0 +1,418 @@ +# i18n [![GoDoc](https://godoc.org/github.com/teacat/i18n?status.svg)](https://godoc.org/github.com/teacat/i18n) [![Coverage Status](https://coveralls.io/repos/github/teacat/i18n/badge.svg?branch=master)](https://coveralls.io/github/teacat/i18n?branch=master) [![Build Status](https://app.travis-ci.com/teacat/i18n.svg?branch=master)](https://app.travis-ci.com/github/teacat/i18n) [![Go Report Card](https://goreportcard.com/badge/github.com/teacat/i18n)](https://goreportcard.com/report/github.com/teacat/i18n) + +`teacat/i18n` is a simple, easy i18n package for Golang that helps you translate Go programs into multiple languages. + +- Token-based (`hello_world`) and Text-based (`Hello, world!`) translation. +- Variables in translation powered by [`text/template`](https://pkg.go.dev/text/template) with Pre-Compiled Techonology™ 😎👍 +- Pluralization and Custom Pluralizor. +- Load translations from a map, files or even [`fs.FS`](https://pkg.go.dev/io/fs) (`go:embed` supported). +- Supports any translation file format (e.g. JSON, YAML). + +  + +## Installation + +```bash +$ go get github.com/teacat/i18n +``` + +  + +## Example + +```go +package main + +import ( + "github.com/teacat/i18n" + "fmt" +) + +func main() { + i := i18n.New("zh-tw").LoadMap(map[string]map[string]string{ + "en-us": map[string]string{ + "hello_world": "Hello, world!" + } + }) + + l := i.NewLocale("en-us") + + // Output: Hello, world! + fmt.Println(l.String("hello_world")) + + // Output: What a wonderful world! + fmt.Println(l.String("What a wonderful world!")) + + // Output: How are you, Yami? + fmt.Println(l.String("How are you, {{ .Name }}?", map[string]any{ + "Name": "Yami", + })) + + // Output: 3 Posts + fmt.Println(l.Number("No Posts | 1 Post | {{ .Count }} Posts", 3, map[string]any{ + "Count": 3, + })) +} +``` + +  + +## Index + +- [Getting Started](#getting-started) +- [Translations](#translations) + - [Passing Data to Translation](#passing-data-to-translation) +- [Pluralization](#pluralization) +- [Text-based Translations](#text-based-translations) + - [Disambiguation by context](#disambiguation-by-context) + - [Act as fallback](#act-as-fallback) +- [Fallbacks](#fallbacks) +- [Custom Unmarshaler](#custom-unmarshaler) +- [Custom Pluralizor](#custom-pluralizor) +- [Parse Accept-Language](#parse-accept-language) +- [Load from FS](#load-from-fs) + +  + +## Getting Started + +Initialize with a default language, then load the translations from a map or the files. + +```go +package main + +import "github.com/teacat/i18n" + +func main() { + i := i18n.New("zh-tw") + + // (a) Load the translation from a map. + i.LoadMap(map[string]map[string]string{ + "zh-tw": map[string]string{ + "hello_world": "早安,世界", + }, + }) + + // (b) Load from "zh-tw.json", "en-us.json", "ja-jp.json". + i.LoadFiles("zh-tw.json", "en-us.json", "ja-jp.json") + + // (c) Load all json files under `language` folder. + i.LoadGlob("languages/*.json") +} +``` + +Filenames like `zh_TW.json`, `zh-tw.json` `zh_tw.user.json`, `zh-TW.music.json` will be combined to a single `zh-tw` translation (case-insenstive and the suffixes are ignored). + +  + +## Translations + +Translations named like `welcome_message`, `button_create`, `button_buy` are token-based translations. For text-based, check the chapters below. + +```json +{ + "message_basic": "你好,世界" +} +``` + +```go +locale := i.NewLocale("zh-tw") + +// Output: 你好,世界 +locale.String("message_basic") + +// Output: message_what_is_this +locale.String("message_what_is_this") +``` + +  + +### Passing Data to Translation + +It's possible to pass the data to translations. `text/template` is used to parse the text, the templates will be parsed and cached after the translation was loaded. + +```json +{ + "message_tmpl": "你好,{{ .Name }}" +} +``` + +```go +// Output: 你好,Yami +locale.String("message_tmpl", map[string]any{ + "Name": "Yami", +}) +``` + +  + +## Pluralization + +Simpliy dividing the translation text into `zero,one | many` (2 options) and `zero | one | many` (3 options) format to use pluralization. + +※ Spaces around the `|` separators are **REQUIRED**. + +```json +{ + "apples": "我沒有蘋果 | 我只有 1 個蘋果 | 我有 {{ .Count }} 個蘋果" +} +``` + +```go +// Output: 我沒有蘋果 +locale.Number("apples", 0) + +// Output: 我只有 1 個蘋果 +locale.Number("apples", 1) + +// Output: 我有 3 個蘋果 +locale.Number("apples", 3, map[string]any{ + "Count": 3, +}) +``` + +  + +## Text-based Translations + +Translations can also be named with sentences so it will act like fallbacks when the translation was not found. + +```json +{ + "I'm fine.": "我過得很好。", + "How about you?": "你如何呢?" +} +``` + +```go +// Output: 我過得很好。 +locale.String("I'm fine.") + +// Output: 你如何呢? +locale.String("How about you?") + +// Output: Thank you! +locale.String("Thank you!") +``` + +  + +### Disambiguation by context + +In English a "Post" can be "Post something (verb)" or "A post (noun)". With token-based translation, you can easily separating them to `post_verb` and `post_noun`. + +With text-based translation, you will need to use `StringX` (X stands for context), and giving the translation a `` suffix. + +The space before the `<` is **REQUIRED**. + +```json +{ + "Post ": "發表文章", + "Post ": "一篇文章" +} +``` + +```go +// Output: 發表文章 +locale.StringX("Post", "verb") + +// Output: 一篇文章 +locale.StringX("Post", "noun") + +// Output: Post +locale.StringX("Post", "adjective") +``` + +  + +### Act as fallback + +Remember, if a translation was not found, the token name will be output directly. The token name can also be used as template content. + +```go +// Output: Hello, World +locale.String("Hello, {{ .Name }}", map[string]any{ + "Name": "World", +}) + +// Output: 2 Posts +locale.Number("None | 1 Post | {{ .Count }} Posts", 2, map[string]any{ + "Count": 2, +}) +``` + +  + +## Fallbacks + +A fallback language will be used when a translation is missing from the current language. If it's still missing from the fallback language, it will lookup from the default language. + +If a translation cannot be found from any language, the token name will be output directly. + +```go +// `ja-jp` is the default language +i := i18n.New("ja-jp", WithFallback(map[string][]string{ + // `zh-tw` uses `zh-hk`, `zh-cn` as fallbacks. + // `en-gb` uses `en-us` as fallback. + "zh-tw": []string{"zh-hk", "zh-cn"}, + "en-gb": []string{"en-us"}, +})) +``` + +Lookup path looks like this with the example above: + +``` +zh-tw -> zh-hk -> zh-cn -> ja-jp +en-gb -> en-us -> ja-jp +``` + +Recursive fallback is also supported. If `zh-tw` has a `zh-hk` fallback, and `zh-hk` has a `zh-cn` fallback, `zh-tw` will have either `zh-hk` and `zh-cn` fallbacks. + +Fallback only works if the translation exists in default language. + +  + +## Custom Unmarshaler + +Translations are JSON format because `encoding/json` is the default unmarshaler. Change it by calling `WithUnmarshaler`. + +The following example uses [`go-yaml/yaml`](https://github.com/go-yaml/yaml) to read the files, so you can write the translation files in YAML format. + +```go +package main + +import "gopkg.in/yaml.v3" + +func main() { + i := i18n.New("zh-tw", WithUnmarshaler(yaml.Unmarshal)) + i.LoadFiles("zh-tw.yaml") +} +``` + +Your `zh-tw.yaml` should look like this: + +```yaml +hello_world: "你好,世界" +"How are you?": "你過得如何?" +"mobile_interface.button": "按鈕" +``` + +Nested translations are not supported, you will need to name them like `"mobile_interface.button"` as key and quote them in double quotes. + +  + +## Custom Pluralizor + +Languages like Slavic languages (Russian, Ukrainian, etc.) has complex pluralization rules. To change the default `zero | one | many` behaviour, use `WithPluralizor`. + +An example translation text like `a | b | c | d`, the `choices` will be `4`, if `0` was returned, then `a` will be used. + +```go +i := i18n.New("zh-tw", WithPluralizor(map[string]Pluralizor{ + // A simplified pluralizor for Slavic languages (Russian, Ukrainian, etc.). + "ru": func(number, choices int) int { + if number == 0 { + return 0 + } + + teen := number > 10 && number < 20 + endsWithOne := number % 10 == 1 + + if choices < 4 { + if !teen && endsWithOne { + return 1 + } else { + return 2 + } + } + if !teen && endsWithOne { + return 1 + } + if !teen && number % 10 >= 2 && number % 10 <= 4 { + return 2 + } + if choices < 4 { + return 2 + } + return 3 + }, +}) +``` + +The `ru.json` file: + +```json +{ + "car": "0 машин | {{ .Count }} машина | {{ .Count }} машины | {{ .Count }} машин" +} +``` + +```go +locale := i.NewLocale("ru") + +// Output: 0 машин +i.Number("car", 0, map[string]any{ + "Count": 0, +}) +// Output: 1 машина +i.Number("car", 1, map[string]any{ + "Count": 1, +}) +// Output: 2 машины +i.Number("car", 2, map[string]any{ + "Count": 2, +}) +// Output: 12 машин +i.Number("car", 12, map[string]any{ + "Count": 12, +}) +// Output: 21 машина +i.Number("car", 21, map[string]any{ + "Count": 21, +}) +``` + +  + +## Parse Accept-Language + +The built-in `ParseAcceptLanguage` function helps you to parse the `Accept-Language` from HTTP Header. + +```go +func(w http.ResponseWriter, r *http.Request) { + // Initialize i18n. + i := i18n.New("zh-tw") + i.LoadFiles("zh-tw.json", "en-us.json") + + // Get `Accept-Language` from request header. + accept := r.Header.Get("Accept-Language") + + // Use the locale. + l := i.NewLocale(...i18n.ParseAcceptLanguage(accept)) + l.String("hello_world") +} +``` + +Orders of the languages that passed to `NewLocale` won't affect the fallback priorities, it will use the first language that was found in loaded translations. + +  + +## Load from FS + +Use `LoadFS` if you are using `go:embed` to compile your translations to the program. + +```go +package main + +import "github.com/teacat/i18n" + +//go:embed languages/*.json +var langFS embed.FS + +func main() { + i := i18n.New("zh-tw") + + // Load all json files under `language` folder from the filesystem. + i.LoadFS(langFS, "languages/*.json") +} +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9ca6748 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/teacat/i18n + +go 1.19 + +require github.com/stretchr/testify v1.8.3 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..57c201b --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/i18n.go b/i18n.go new file mode 100644 index 0000000..c0520b8 --- /dev/null +++ b/i18n.go @@ -0,0 +1,300 @@ +package i18n + +import ( + "encoding/json" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "text/template" +) + +// Pluralizor decides which translation string to use by the returned index. +type Pluralizor func(number, choices int) int + +// Unmarshaler unmarshals the translation files, can be `json.Unmarshal` or `yaml.Unmarshal`. +type Unmarshaler func(data []byte, v any) error + +// I18n is the main internationalization core. +type I18n struct { + defaultLocale string + pluralizors map[string]Pluralizor + unmarshaler Unmarshaler + fallbacks map[string][]string + translations map[string]map[string]string + runtimeCompiledTranslations map[string]*compiledTranslation + compiledTranslations map[string]map[string]*compiledTranslation +} + +// WithUnmarshaler replaces the default translation file unmarshaler. +func WithUnmarshaler(u Unmarshaler) func(*I18n) { + return func(i *I18n) { + i.unmarshaler = u + } +} + +// WithFallback changes fallback settings. +func WithFallback(f map[string][]string) func(*I18n) { + return func(i *I18n) { + i.fallbacks = f + } +} + +// WithPluralizor changes pluralizors. +func WithPluralizor(p map[string]Pluralizor) func(*I18n) { + return func(i *I18n) { + i.pluralizors = p + } +} + +// New creates a new internationalization. +func New(defaultLocale string, options ...func(*I18n)) *I18n { + i := &I18n{ + defaultLocale: defaultLocale, + unmarshaler: json.Unmarshal, + pluralizors: make(map[string]Pluralizor), + fallbacks: make(map[string][]string), + translations: make(map[string]map[string]string), + runtimeCompiledTranslations: make(map[string]*compiledTranslation), + compiledTranslations: make(map[string]map[string]*compiledTranslation), + } + for _, o := range options { + o(i) + } + return i +} + +// LoadMap loads the translations from the map. +func (i *I18n) LoadMap(languages map[string]map[string]string) error { + for locale, translations := range languages { + locale = nameInsenstive(locale) + i.compiledTranslations[locale] = make(map[string]*compiledTranslation) + + for name, text := range translations { + trans := i.compileTranslation(locale, name, text) + i.compiledTranslations[locale][name] = trans + } + } + i.compileFallbacks() + return nil +} + +// LoadFiles loads the translations from the files. +func (i *I18n) LoadFiles(filenames ...string) error { + data := make(map[string]map[string]string) + + for _, v := range filenames { + b, err := os.ReadFile(v) + if err != nil { + return err + } + var trans map[string]string + if err := i.unmarshaler(b, &trans); err != nil { + return err + } + locale := nameInsenstive(v) + _, ok := data[locale] + if !ok { + data[locale] = make(map[string]string) + } + for name, text := range trans { + data[locale][name] = text + } + } + return i.LoadMap(data) +} + +// LoadGlob loads the translations from the files that matches specified patterns. +func (i *I18n) LoadGlob(pattern ...string) error { + var files []string + + for _, pattern := range pattern { + v, err := filepath.Glob(pattern) + if err != nil { + return err + } + files = append(files, v...) + } + + return i.LoadFiles(files...) +} + +// LoadFS loads the translation from a `fs.FS`, useful for `go:embed`. +func (i *I18n) LoadFS(fsys fs.FS, patterns ...string) error { + var files []string + data := make(map[string]map[string]string) + + for _, pattern := range patterns { + v, err := fs.Glob(fsys, pattern) + if err != nil { + return err + } + files = append(files, v...) + } + + for _, v := range files { + b, err := fs.ReadFile(fsys, v) + if err != nil { + return err + } + var trans map[string]string + if err := i.unmarshaler(b, &trans); err != nil { + return err + } + + locale := nameInsenstive(v) + + _, ok := data[locale] + if !ok { + data[locale] = make(map[string]string) + } + for name, text := range trans { + data[locale][name] = text + } + } + return i.LoadMap(data) +} + +// NewLocale reads a locale from the internationalization core. +func (i *I18n) NewLocale(locales ...string) *Locale { + selectedLocale := i.defaultLocale + for _, v := range locales { + if _, ok := i.compiledTranslations[v]; ok { + selectedLocale = v + break + } + } + return &Locale{ + parent: i, + locale: selectedLocale, + } +} + +var contextRegExp = regexp.MustCompile("<(.*?)>$") + +// compiledTranslation +type compiledTranslation struct { + locale string + name string + pluralizor Pluralizor + texts []*compiledText +} + +// compiledText +type compiledText struct { + text string + tmpl *template.Template +} + +// defaultPluralizor +func defaultPluralizor(number, choices int) int { + switch choices { + case 2: + switch number { + case 0, 1: + return 0 + default: + return 1 + } + default: + switch number { + case 0: + return 0 + case 1: + return 1 + default: + return 2 + } + } +} + +// pluralizor +func (i *I18n) pluralizor(lang string) Pluralizor { + v, ok := i.pluralizors[lang] + if !ok { + return defaultPluralizor + } + return v +} + +// trimContext +func trimContext(v string) string { + return contextRegExp.ReplaceAllString(v, "") +} + +// compileTranslation +func (i *I18n) compileTranslation(locale, name, text string) *compiledTranslation { + compTrans := &compiledTranslation{ + name: name, + } + compTrans.locale = locale + compTrans.pluralizor = i.pluralizor(locale) + compTrans.texts = compileText(text) + + return compTrans +} + +// compileText +func compileText(text string) (compTexts []*compiledText) { + texts := strings.Split(text, " | ") + + for _, v := range texts { + compText := &compiledText{} + + if strings.Contains(v, "{{") { + t, _ := template.New("").Parse(v) + compText.tmpl = t + } else { + compText.text = v + } + compTexts = append(compTexts, compText) + } + return +} + +// nameInsenstive converts `zh_TW.music.json`, `zh_TW` and `zh-TW` to `zh-tw`. +func nameInsenstive(v string) string { + v = filepath.Base(v) + v = strings.Split(v, ".")[0] + v = strings.ToLower(v) + v = strings.ReplaceAll(v, "_", "-") + return v +} + +// compileFallbacks +func (i *I18n) compileFallbacks() { + for _, grandTrans := range i.compiledTranslations[i.defaultLocale] { + for locale, trans := range i.compiledTranslations { + // + if locale == i.defaultLocale { + continue + } + // + if _, ok := trans[grandTrans.name]; !ok { + if bestfit := i.lookupBestFallback(locale, grandTrans.name); bestfit != nil { + i.compiledTranslations[locale][grandTrans.name] = bestfit + } + } + } + } +} + +// lookupBestFallback +func (i *I18n) lookupBestFallback(locale, name string) *compiledTranslation { + fallbacks, ok := i.fallbacks[locale] + if !ok { + if v, ok := i.compiledTranslations[i.defaultLocale][name]; ok { + return v + } + } + for _, fallback := range fallbacks { + if v, ok := i.compiledTranslations[fallback][name]; ok { + return v + } + if j := i.lookupBestFallback(fallback, name); j != nil { + return j + } + } + return nil +} diff --git a/i18n_test.go b/i18n_test.go new file mode 100644 index 0000000..0ea20d7 --- /dev/null +++ b/i18n_test.go @@ -0,0 +1,371 @@ +package i18n + +import ( + "embed" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" +) + +//go:embed test/*.json +var testTranslationFS embed.FS + +var testTranslations = map[string]map[string]string{ + "en-us": map[string]string{ + "None | 1 Apple | {{ .Count }} Apples": "None | 1 Apple | {{ .Count }} Apples", + }, + + "zh-tw": map[string]string{ + // Token-based Translations + "test_message": "這是一則測試訊息。", + "test_template": "你好,{{ .Name }}!", + "test_plural": "沒有 | 只有 1 個 | 有 {{.Count}} 個", + + // Text-based Translations. + "Hello, world!": "你好,世界!", + "How are you, {{ .Name }}?": "過得如何,{{ .Name }}?", + "Post ": "發表貼文", + "Post ": "文章", + + "None | 1 Apple | {{ .Count }} Apples": "沒有蘋果 | 1 顆蘋果 | 有 {{.Count}} 顆蘋果", + "No Post | 1 Post | {{ .Count }} Posts ": "沒有文章 | 1 篇文章 | 有 {{.Count}} 篇文章", + "No Post | 1 Post | {{ .Count }} Posts ": "沒有發表 | 1 篇發表 | 有 {{.Count}} 篇發表", + + "Post": "THIS_SHOULD_NOT_BE_USED", + "No Post | 1 Post | {{ .Count }} Posts": "THIS_SHOULD_NOT_BE_USED | THIS_SHOULD_NOT_BE_USED | THIS_SHOULD_NOT_BE_USED", + }, + + "ja-jp": map[string]string{ + // Token-based Translations + "test_message": "これはテストメッセージです。", + "test_template": "こんにちは、{{ .Name }}!", + "test_plural": "なし | 1 つだけ | {{.Count}} 個あります", + }, + + "ko-kr": map[string]string{ + // Token-based Translations + "test_message": "이것은 테스트 메시지입니다.", + "test_template": "안녕하세요, {{ .Name }} 님!", + "test_plural": "없음 | 1 개 | {{.Count}} 개가 있음", + + // Text-based Translations. + "Hello, world!": "안녕하세요, 세상!", + "How are you, {{ .Name }}?": "{{ .Name }} 님, 어떻게 지내세요?", + "Post ": "메시지 게시", + "Post ": "기사", + }, +} + +func newTestLocale() *Locale { + i := New("zh-tw") + i.LoadMap(testTranslations) + return i.NewLocale("zh-tw") +} + +func TestLoadMap(t *testing.T) { + assert := assert.New(t) + + i := New("zh-tw") + i.LoadMap(testTranslations) + l := i.NewLocale("zh-tw") + + assert.Equal("這是一則測試訊息。", l.String("test_message")) + assert.Equal("not_exists_message", l.String("not_exists_message")) +} + +func TestLoadFiles(t *testing.T) { + assert := assert.New(t) + + i := New("zh-tw") + assert.NoError(i.LoadFiles("test/zh-tw.json", "test/zh_TW.json", "test/zh_tw.hello.json")) + + l := i.NewLocale("zh-tw") + assert.Equal("訊息 A", l.String("message_a")) + assert.Equal("訊息 B", l.String("message_b")) + assert.Equal("訊息 C", l.String("message_c")) +} + +func TestLoadGlob(t *testing.T) { + assert := assert.New(t) + + i := New("zh-tw") + assert.NoError(i.LoadGlob("test/*.json")) + + l := i.NewLocale("zh-tw") + assert.Equal("訊息 A", l.String("message_a")) + assert.Equal("訊息 B", l.String("message_b")) + assert.Equal("訊息 C", l.String("message_c")) +} + +func TestLoadFS(t *testing.T) { + assert := assert.New(t) + + i := New("zh-tw") + assert.NoError(i.LoadFS(testTranslationFS, "test/*.json")) + + l := i.NewLocale("zh-tw") + assert.Equal("訊息 A", l.String("message_a")) + assert.Equal("訊息 B", l.String("message_b")) + assert.Equal("訊息 C", l.String("message_c")) +} + +func TestPluralizor(t *testing.T) { + assert := assert.New(t) + + i := New("ru", WithPluralizor(map[string]Pluralizor{ + "ru": func(number, choices int) int { + if number == 0 { + return 0 + } + + teen := number > 10 && number < 20 + endsWithOne := number%10 == 1 + + if choices < 4 { + if !teen && endsWithOne { + return 1 + } else { + return 2 + } + } + if !teen && endsWithOne { + return 1 + } + if !teen && number%10 >= 2 && number%10 <= 4 { + return 2 + } + if choices < 4 { + return 2 + } + return 3 + }, + })) + + l := i.NewLocale("ru") + assert.Equal("0 машин", l.Number("0 машин | {{ .Count }} машина | {{ .Count }} машины | {{ .Count }} машин", 0, map[string]any{ + "Count": 0, + })) + assert.Equal("1 машина", l.Number("0 машин | {{ .Count }} машина | {{ .Count }} машины | {{ .Count }} машин", 1, map[string]any{ + "Count": 1, + })) + assert.Equal("2 машины", l.Number("0 машин | {{ .Count }} машина | {{ .Count }} машины | {{ .Count }} машин", 2, map[string]any{ + "Count": 2, + })) + assert.Equal("12 машин", l.Number("0 машин | {{ .Count }} машина | {{ .Count }} машины | {{ .Count }} машин", 12, map[string]any{ + "Count": 12, + })) + assert.Equal("21 машина", l.Number("0 машин | {{ .Count }} машина | {{ .Count }} машины | {{ .Count }} машин", 21, map[string]any{ + "Count": 21, + })) +} + +func TestUnmarshaler(t *testing.T) { + assert := assert.New(t) + + i := New("zh-tw", WithUnmarshaler(yaml.Unmarshal)) + assert.NoError(i.LoadFiles("test/zh_tW.yml")) + + l := i.NewLocale("zh-tw") + assert.Equal("訊息 A", l.String("message_a")) +} + +func TestLocale(t *testing.T) { + assert := assert.New(t) + l := newTestLocale() + + assert.Equal("zh-tw", l.Locale()) +} + +func TestTokenString(t *testing.T) { + assert := assert.New(t) + l := newTestLocale() + + assert.Equal("這是一則測試訊息。", l.String("test_message")) + assert.Equal("not_exists_message", l.String("not_exists_message")) +} + +func TestTokenTmpl(t *testing.T) { + assert := assert.New(t) + l := newTestLocale() + + assert.Equal("你好,Yami!", l.String("test_template", map[string]string{ + "Name": "Yami", + })) +} + +func TestTokenPlural(t *testing.T) { + assert := assert.New(t) + l := newTestLocale() + + assert.Equal("沒有", l.Number("test_plural", 0)) + assert.Equal("只有 1 個", l.Number("test_plural", 1)) + assert.Equal("有 2 個", l.Number("test_plural", 2, map[string]int{ + "Count": 2, + })) + + // Lazy template + assert.Equal("沒有", l.Number("test_plural", 0, map[string]int{ + "Count": 2, + })) + assert.Equal("只有 1 個", l.Number("test_plural", 1, map[string]int{ + "Count": 2, + })) + assert.Equal("有 2 個", l.Number("test_plural", 2, map[string]int{ + "Count": 2, + })) +} + +func TestTextString(t *testing.T) { + assert := assert.New(t) + l := newTestLocale() + + assert.Equal("你好,世界!", l.String("Hello, world!")) +} + +func TestTextStringRaw(t *testing.T) { + assert := assert.New(t) + l := newTestLocale() + + assert.Equal("I'm fine thank you!", l.String("I'm fine thank you!")) +} + +func TestTextTmpl(t *testing.T) { + assert := assert.New(t) + l := newTestLocale() + + assert.Equal("過得如何,Yami?", l.String("How are you, {{ .Name }}?", map[string]string{ + "Name": "Yami", + })) +} + +func TestTextTmplRaw(t *testing.T) { + assert := assert.New(t) + l := newTestLocale() + + assert.Equal("I'm fine, thanks to Yami!", l.String("I'm fine, thanks to {{ .Name }}!", map[string]string{ + "Name": "Yami", + })) +} + +func TestTextPlural(t *testing.T) { + assert := assert.New(t) + l := newTestLocale() + + assert.Equal("沒有蘋果", l.Number("None | 1 Apple | {{ .Count }} Apples", 0)) + assert.Equal("1 顆蘋果", l.Number("None | 1 Apple | {{ .Count }} Apples", 1)) + assert.Equal("有 2 顆蘋果", l.Number("None | 1 Apple | {{ .Count }} Apples", 2, map[string]int{ + "Count": 2, + })) + + // Lazy template + assert.Equal("沒有蘋果", l.Number("None | 1 Apple | {{ .Count }} Apples", 0, map[string]int{ + "Count": 2, + })) + assert.Equal("1 顆蘋果", l.Number("None | 1 Apple | {{ .Count }} Apples", 1, map[string]int{ + "Count": 2, + })) + assert.Equal("有 2 顆蘋果", l.Number("None | 1 Apple | {{ .Count }} Apples", 2, map[string]int{ + "Count": 2, + })) +} + +func TestTextPluralRaw(t *testing.T) { + assert := assert.New(t) + l := newTestLocale() + + assert.Equal("Zero", l.Number("Zero | 1 Thing | {{ .Count }} Things", 0)) + assert.Equal("1 Thing", l.Number("Zero | 1 Thing | {{ .Count }} Things", 1)) + assert.Equal("2 Things", l.Number("Zero | 1 Thing | {{ .Count }} Things", 2, map[string]int{ + "Count": 2, + })) +} + +func TestTextStringContext(t *testing.T) { + assert := assert.New(t) + l := newTestLocale() + + assert.Equal("發表貼文", l.StringX("Post", "verb")) + assert.Equal("文章", l.StringX("Post", "noun")) +} + +func TestTextPluralContext(t *testing.T) { + assert := assert.New(t) + l := newTestLocale() + + assert.Equal("沒有文章", l.NumberX("No Post | 1 Post | {{ .Count }} Posts", "noun", 0)) + assert.Equal("1 篇文章", l.NumberX("No Post | 1 Post | {{ .Count }} Posts", "noun", 1)) + assert.Equal("有 2 篇文章", l.NumberX("No Post | 1 Post | {{ .Count }} Posts", "noun", 2, map[string]int{ + "Count": 2, + })) + + // Lazy template + assert.Equal("沒有文章", l.NumberX("No Post | 1 Post | {{ .Count }} Posts", "noun", 0, map[string]int{ + "Count": 2, + })) + assert.Equal("1 篇文章", l.NumberX("No Post | 1 Post | {{ .Count }} Posts", "noun", 1, map[string]int{ + "Count": 2, + })) + assert.Equal("有 2 篇文章", l.NumberX("No Post | 1 Post | {{ .Count }} Posts", "noun", 2, map[string]int{ + "Count": 2, + })) + + // + assert.Equal("沒有發表", l.NumberX("No Post | 1 Post | {{ .Count }} Posts", "verb", 0)) + assert.Equal("1 篇發表", l.NumberX("No Post | 1 Post | {{ .Count }} Posts", "verb", 1)) + assert.Equal("有 2 篇發表", l.NumberX("No Post | 1 Post | {{ .Count }} Posts", "verb", 2, map[string]int{ + "Count": 2, + })) +} + +func TestTextFallback(t *testing.T) { + assert := assert.New(t) + i := New("zh-tw", WithFallback(map[string][]string{ + "ja-jp": []string{"ko-kr"}, + })) + i.LoadMap(testTranslations) + l := i.NewLocale("ja-jp") + + // Test ja-jp + assert.Equal("これはテストメッセージです。", l.String("test_message")) + assert.Equal("こんにちは、Yami!", l.String("test_template", map[string]string{ + "Name": "Yami", + })) + assert.Equal("なし", l.Number("test_plural", 0)) + + // Test ja-jp -> ko-kr fallback + assert.Equal("안녕하세요, 세상!", l.String("Hello, world!")) + assert.Equal("Yami 님, 어떻게 지내세요?", l.String("How are you, {{ .Name }}?", map[string]string{ + "Name": "Yami", + })) + assert.Equal("메시지 게시", l.StringX("Post", "verb")) + + // Test ja-jp -> zh-tw fallback + assert.Equal("沒有蘋果", l.Number("None | 1 Apple | {{ .Count }} Apples", 0)) + assert.Equal("1 顆蘋果", l.Number("None | 1 Apple | {{ .Count }} Apples", 1)) + assert.Equal("有 2 顆蘋果", l.Number("None | 1 Apple | {{ .Count }} Apples", 2, map[string]int{ + "Count": 2, + })) + + // Test nil fallback + assert.Equal("Ni hao", l.String("Ni hao")) +} + +func TestTextFallbackResursive(t *testing.T) { + assert := assert.New(t) + i := New("en-us", WithFallback(map[string][]string{ + "ja-jp": []string{"ko-kr"}, + "ko-kr": []string{"zh-tw"}, + })) + i.LoadMap(testTranslations) + l := i.NewLocale("ja-jp") + + // Test ja-jp -> ko-kr -> zh-tw fallback + assert.Equal("1 顆蘋果", l.Number("None | 1 Apple | {{ .Count }} Apples", 1)) +} + +func TestParseAcceptLanguage(t *testing.T) { + assert := assert.New(t) + assert.Equal("[zh-tw zh en-us en ja]", fmt.Sprintf("%+v", ParseAcceptLanguage("zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7,ja;q=0.6"))) +} diff --git a/locale.go b/locale.go new file mode 100644 index 0000000..d85987a --- /dev/null +++ b/locale.go @@ -0,0 +1,68 @@ +package i18n + +import ( + "bytes" + "fmt" +) + +// Locale represents a translated locale. +type Locale struct { + parent *I18n + + locale string +} + +// Locale returns the current locale name. +func (l *Locale) Locale() string { + return l.locale +} + +// String returns a translated string. +func (l *Locale) String(name string, data ...any) string { + selectedTrans := l.lookup(name) + return l.render(selectedTrans.texts[0], data...) +} + +// StringX returns a translated string with a specified context. +func (l *Locale) StringX(name, context string, data ...any) string { + return l.String(fmt.Sprintf("%s <%s>", name, context), data...) +} + +// Number returns a translated string based on the `count`. +func (l *Locale) Number(name string, count int, data ...any) string { + selectedTrans := l.lookup(name) + selectedIndex := selectedTrans.pluralizor(count, len(selectedTrans.texts)) + return l.render(selectedTrans.texts[selectedIndex], data...) +} + +// NumberX returns a translated string based on the `count` with a specified context. +func (l *Locale) NumberX(name string, context string, count int, data ...any) string { + return l.Number(fmt.Sprintf("%s <%s>", name, context), count, data...) +} + +// lookup +func (l *Locale) lookup(name string) *compiledTranslation { + if selectedTrans, ok := l.parent.compiledTranslations[l.locale][name]; ok { + return selectedTrans + } + runtimeTrans, ok := l.parent.runtimeCompiledTranslations[name] + if !ok { + runtimeTrans = l.parent.compileTranslation(l.parent.defaultLocale, name, trimContext(name)) + } + l.parent.runtimeCompiledTranslations[name] = runtimeTrans + return runtimeTrans +} + +// render +func (l *Locale) render(text *compiledText, data ...any) string { + if text.tmpl != nil { + var tpl bytes.Buffer + if len(data) > 0 { + text.tmpl.Execute(&tpl, data[0]) + } else { + text.tmpl.Execute(&tpl, nil) + } + return tpl.String() + } + return text.text +} diff --git a/test/zh-tw.json b/test/zh-tw.json new file mode 100644 index 0000000..c8ab671 --- /dev/null +++ b/test/zh-tw.json @@ -0,0 +1,3 @@ +{ + "message_a": "訊息 A" +} diff --git a/test/zh_TW.json b/test/zh_TW.json new file mode 100644 index 0000000..3e0ab2d --- /dev/null +++ b/test/zh_TW.json @@ -0,0 +1,3 @@ +{ + "message_b": "訊息 B" +} diff --git a/test/zh_tW.yml b/test/zh_tW.yml new file mode 100644 index 0000000..c8ab671 --- /dev/null +++ b/test/zh_tW.yml @@ -0,0 +1,3 @@ +{ + "message_a": "訊息 A" +} diff --git a/test/zh_tw.hello.json b/test/zh_tw.hello.json new file mode 100644 index 0000000..2a5a536 --- /dev/null +++ b/test/zh_tw.hello.json @@ -0,0 +1,3 @@ +{ + "message_c": "訊息 C" +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..7c0b261 --- /dev/null +++ b/util.go @@ -0,0 +1,20 @@ +package i18n + +import ( + "strings" +) + +// ParseAcceptLanguage parses the `Accept-Language` header content and converts to a slice. +// So you can pass it into `NewLocale(...lang)`. +// +// Source: https://siongui.github.io/2015/02/22/go-parse-accept-language/ +func ParseAcceptLanguage(acceptLang string) []string { + var lqs []string + langQStrs := strings.Split(acceptLang, ",") + for _, langQStr := range langQStrs { + trimedLangQStr := strings.Trim(langQStr, " ") + langQ := strings.Split(trimedLangQStr, ";") + lqs = append(lqs, nameInsenstive(langQ[0])) + } + return lqs +}