Initial commit
This commit is contained in:
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
*~
|
||||||
|
.fuse_hidden*
|
||||||
|
.directory
|
||||||
|
.Trash-*
|
||||||
|
.nfs*
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
.idea
|
||||||
42
context.go
Normal file
42
context.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
"golang.org/x/text/message"
|
||||||
|
)
|
||||||
|
|
||||||
|
type i18nCtxType uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
langCtxKey i18nCtxType = iota
|
||||||
|
printerCtxKey
|
||||||
|
)
|
||||||
|
|
||||||
|
func ContextWithLang(ctx context.Context, lang language.Tag) context.Context {
|
||||||
|
ctx = context.WithValue(ctx, langCtxKey, lang)
|
||||||
|
ctx = context.WithValue(ctx, printerCtxKey, GetPrinter(lang))
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func PrinterFromContext(ctx context.Context) *message.Printer {
|
||||||
|
if p, ok := ctx.Value(printerCtxKey).(*message.Printer); ok {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetPrinter(LanguageFromContext(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Sprintf(ctx context.Context, val string, args ...interface{}) string {
|
||||||
|
return PrinterFromContext(ctx).Sprintf(val, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LanguageFromContext(ctx context.Context) language.Tag {
|
||||||
|
if lang, ok := ctx.Value(langCtxKey).(language.Tag); ok {
|
||||||
|
return lang
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultLanguage
|
||||||
|
}
|
||||||
63
error.go
Normal file
63
error.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TranslatableError interface {
|
||||||
|
error
|
||||||
|
Translatable
|
||||||
|
}
|
||||||
|
|
||||||
|
func TryTranslateError(ctx context.Context, err error) (string, bool) {
|
||||||
|
var translatable TranslatableError
|
||||||
|
|
||||||
|
if errors.As(err, &translatable) {
|
||||||
|
return translatable.Translate(ctx), true
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewError(key string) *Error {
|
||||||
|
return &Error{key: key}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Error struct {
|
||||||
|
key string
|
||||||
|
params []interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string {
|
||||||
|
return e.key
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) WithParams(params ...interface{}) *Error {
|
||||||
|
newErr := *e
|
||||||
|
|
||||||
|
newErr.params = params
|
||||||
|
|
||||||
|
return &newErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Translate(ctx context.Context) string {
|
||||||
|
printer := PrinterFromContext(ctx)
|
||||||
|
|
||||||
|
translatedParams := make([]interface{}, len(e.params))
|
||||||
|
|
||||||
|
for idx, param := range e.params {
|
||||||
|
switch typed := param.(type) {
|
||||||
|
case string:
|
||||||
|
translatedParams[idx] = printer.Sprintf(typed)
|
||||||
|
case fmt.Stringer:
|
||||||
|
translatedParams[idx] = printer.Sprintf(typed.String())
|
||||||
|
default:
|
||||||
|
translatedParams[idx] = param
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return printer.Sprintf(e.key, translatedParams...)
|
||||||
|
}
|
||||||
31
error_test.go
Normal file
31
error_test.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package i18n_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
|
||||||
|
. "github.com/derfenix/goi18n"
|
||||||
|
"github.com/derfenix/goi18n/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTryTranslateError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
require.NoError(t, Init(internal.TestFS))
|
||||||
|
|
||||||
|
err := NewError("test").WithParams("book")
|
||||||
|
wrapped := errors.Wrap(err, "foo bar")
|
||||||
|
|
||||||
|
errorString, translated := TryTranslateError(context.Background(), wrapped)
|
||||||
|
require.True(t, translated)
|
||||||
|
assert.Equal(t, "Тест book", errorString)
|
||||||
|
|
||||||
|
errorString, translated = TryTranslateError(ContextWithLang(context.Background(), language.English), wrapped)
|
||||||
|
require.True(t, translated)
|
||||||
|
assert.Equal(t, "Test of the book", errorString)
|
||||||
|
}
|
||||||
42
example/example.go
Normal file
42
example/example.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package example
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
"golang.org/x/text/language/display"
|
||||||
|
|
||||||
|
i18n "github.com/derfenix/goi18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed locales/**/*.json
|
||||||
|
var locales embed.FS
|
||||||
|
|
||||||
|
func Basic() {
|
||||||
|
if err := i18n.Init(locales); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
printer := i18n.GetPrinter(language.Russian)
|
||||||
|
fmt.Println(printer.Sprintf("test", "теста"))
|
||||||
|
|
||||||
|
printer = i18n.GetPrinter(language.English)
|
||||||
|
fmt.Println(printer.Sprintf("test", "beer"))
|
||||||
|
|
||||||
|
ctx := i18n.ContextWithLang(context.Background(), language.Russian)
|
||||||
|
handler(ctx)
|
||||||
|
|
||||||
|
ctx = i18n.ContextWithLang(context.Background(), language.English)
|
||||||
|
handler(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handler(ctx context.Context) {
|
||||||
|
translated := i18n.Sprintf(ctx, "test plural", 2)
|
||||||
|
fmt.Println(translated)
|
||||||
|
|
||||||
|
lang := i18n.LanguageFromContext(ctx)
|
||||||
|
script, _ := lang.Script()
|
||||||
|
fmt.Println("Использован", display.Languages(language.Russian).Name(lang), "язык,", display.Scripts(language.Russian).Name(script))
|
||||||
|
}
|
||||||
17
example/example_test.go
Normal file
17
example/example_test.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package example_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/derfenix/goi18n/example"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleBasic() {
|
||||||
|
example.Basic()
|
||||||
|
|
||||||
|
// Output:
|
||||||
|
// Тест теста
|
||||||
|
// Test of the beer
|
||||||
|
// всего пара пауков
|
||||||
|
// Использован русский язык, кириллица
|
||||||
|
// just pair of spiders
|
||||||
|
// Использован английский язык, латиница
|
||||||
|
}
|
||||||
38
example/locales/en/active.json
Normal file
38
example/locales/en/active.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"key": "test",
|
||||||
|
"description": "Для тестов, не трогать",
|
||||||
|
"translation": "Test of the %s"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "test plural",
|
||||||
|
"description": "Для тестов, не трогать",
|
||||||
|
"plural": {
|
||||||
|
"other": "exactly %d spiders",
|
||||||
|
"one": "spider",
|
||||||
|
"=0": "no spiders",
|
||||||
|
"=2": "just pair of spiders"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "transition_not_allowed",
|
||||||
|
"description": "Ошибка при попытке произвести запрещённое изменение состояния",
|
||||||
|
"translation": "Transition from '%s' to '%s' not allowed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Should be shorter than %d symbols",
|
||||||
|
"translation": "Should be shorter than %d symbols"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Should be longer than %d symbols",
|
||||||
|
"translation": "Should be longer than %d symbols"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Deleted",
|
||||||
|
"translation": "Deleted"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Published",
|
||||||
|
"translation": "Published"
|
||||||
|
}
|
||||||
|
]
|
||||||
38
example/locales/ru/active.json
Normal file
38
example/locales/ru/active.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"key": "test",
|
||||||
|
"description": "Для тестов, не трогать",
|
||||||
|
"translation": "Тест %s"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "test plural",
|
||||||
|
"description": "Для тестов, не трогать",
|
||||||
|
"plural": {
|
||||||
|
"other": "всего %d пауков",
|
||||||
|
"one": "паучок",
|
||||||
|
"=0": "нет пауков",
|
||||||
|
"=2": "всего пара пауков"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "transition_not_allowed",
|
||||||
|
"description": "Ошибка при попытке произвести запрещённое изменение состояния",
|
||||||
|
"translation": "Переход от '%s' к '%s' запрещён"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Should be shorter than %d symbols",
|
||||||
|
"translation": "Должно быть короче %d символов"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Should be longer than %d symbols",
|
||||||
|
"translation": "Должно быть длиннее %d символов"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Deleted",
|
||||||
|
"translation": "Удалён"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Published",
|
||||||
|
"translation": "Опубликован"
|
||||||
|
}
|
||||||
|
]
|
||||||
15
go.mod
Normal file
15
go.mod
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
module github.com/derfenix/goi18n
|
||||||
|
|
||||||
|
go 1.18
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/pkg/errors v0.9.1
|
||||||
|
github.com/stretchr/testify v1.8.0
|
||||||
|
golang.org/x/text v0.4.0
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
19
go.sum
Normal file
19
go.sum
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
|
||||||
|
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
93
internal/external.go
Normal file
93
internal/external.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
"golang.org/x/text/message/catalog"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidResponseCode = errors.New("invalid response status code")
|
||||||
|
|
||||||
|
func NewExternalLoader(baseURL string, header http.Header) *ExternalLoader {
|
||||||
|
client := http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 2 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
|
DisableKeepAlives: true,
|
||||||
|
MaxIdleConns: 1,
|
||||||
|
MaxIdleConnsPerHost: 1,
|
||||||
|
MaxConnsPerHost: 2,
|
||||||
|
IdleConnTimeout: 30 * time.Second,
|
||||||
|
ResponseHeaderTimeout: 5 * time.Second,
|
||||||
|
MaxResponseHeaderBytes: 1024 * 3,
|
||||||
|
WriteBufferSize: 100,
|
||||||
|
ReadBufferSize: 1024 * 8,
|
||||||
|
},
|
||||||
|
Timeout: time.Second * 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewExternalLoaderWithClient(baseURL, header, &client)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExternalLoaderWithClient(baseURL string, header http.Header, client *http.Client) *ExternalLoader {
|
||||||
|
return &ExternalLoader{baseURL: baseURL, client: client, header: header}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExternalLoader struct {
|
||||||
|
baseURL string
|
||||||
|
header http.Header
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ExternalLoader) Load(builder *catalog.Builder) error {
|
||||||
|
languages := builder.Languages()
|
||||||
|
|
||||||
|
for _, lang := range languages {
|
||||||
|
if err := e.load(lang, builder); err != nil {
|
||||||
|
return errors.WithMessagef(err, "load translation for %s", lang.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ExternalLoader) load(lang language.Tag, builder *catalog.Builder) error {
|
||||||
|
langURL, err := url.JoinPath(e.baseURL, lang.String())
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "join url path")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, langURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "new request")
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.header != nil {
|
||||||
|
req.Header = e.header
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := e.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "do request")
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return errors.Wrapf(ErrInvalidResponseCode, "got status %d", response.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := load(response.Body, lang, builder); err != nil {
|
||||||
|
return errors.WithMessage(err, "load translation")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
239
internal/loader.go
Normal file
239
internal/loader.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"golang.org/x/text/feature/plural"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
"golang.org/x/text/message/catalog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Loader interface {
|
||||||
|
Load(cat *catalog.Builder) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
extendBuilder func(builder *catalog.Builder) error
|
||||||
|
extLoader Loader
|
||||||
|
)
|
||||||
|
|
||||||
|
func SetExtendBuilder(b func(builder *catalog.Builder) error) {
|
||||||
|
extendBuilder = b
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetExternalLoader(loader Loader) {
|
||||||
|
extLoader = loader
|
||||||
|
}
|
||||||
|
|
||||||
|
func RefreshTranslations(builder *catalog.Builder) error {
|
||||||
|
if extLoader == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := extLoader.Load(builder); err != nil {
|
||||||
|
return errors.WithMessage(err, "load translations from external")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Translation struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Translation string `json:"translation"`
|
||||||
|
Plural *plurals `json:"plural"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type pluralsBase struct {
|
||||||
|
Zero string `json:"zero"`
|
||||||
|
One string `json:"one"`
|
||||||
|
Two string `json:"two"`
|
||||||
|
Few string `json:"few"`
|
||||||
|
Many string `json:"many"`
|
||||||
|
Other string `json:"other"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type plurals struct {
|
||||||
|
pluralsBase
|
||||||
|
Custom map[string]string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *plurals) UnmarshalJSON(data []byte) error {
|
||||||
|
var (
|
||||||
|
base pluralsBase
|
||||||
|
val map[string]string
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &base); err != nil {
|
||||||
|
return errors.Wrap(err, "unmarshal base")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &val); err != nil {
|
||||||
|
return errors.Wrap(err, "unmarshal custom")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME Looks hacky, there should be a better way
|
||||||
|
delete(val, "one")
|
||||||
|
delete(val, "zero")
|
||||||
|
delete(val, "two")
|
||||||
|
delete(val, "few")
|
||||||
|
delete(val, "may")
|
||||||
|
delete(val, "other")
|
||||||
|
|
||||||
|
p.pluralsBase = base
|
||||||
|
p.Custom = val
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *plurals) cases() (cases []interface{}) {
|
||||||
|
capacity := 12
|
||||||
|
if len(p.Custom) > 0 {
|
||||||
|
capacity += len(p.Custom) * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
cases = make([]interface{}, 0, capacity)
|
||||||
|
|
||||||
|
if p.Custom != nil {
|
||||||
|
for cond, val := range p.Custom {
|
||||||
|
cases = append(cases, cond, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Zero != "" {
|
||||||
|
cases = append(cases, plural.Zero, p.Zero)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.One != "" {
|
||||||
|
cases = append(cases, plural.One, p.One)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Two != "" {
|
||||||
|
cases = append(cases, plural.Two, p.Two)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Few != "" {
|
||||||
|
cases = append(cases, plural.Few, p.Few)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Many != "" {
|
||||||
|
cases = append(cases, plural.Many, p.Many)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Other != "" {
|
||||||
|
cases = append(cases, plural.Other, p.Other)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cases
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitBuilder(fs fs.ReadDirFS) (*catalog.Builder, error) {
|
||||||
|
cat := catalog.NewBuilder()
|
||||||
|
|
||||||
|
if err := loadTranslations(fs, cat); err != nil {
|
||||||
|
return nil, errors.Wrap(err, "load translations")
|
||||||
|
}
|
||||||
|
|
||||||
|
if extendBuilder != nil {
|
||||||
|
if err := extendBuilder(cat); err != nil {
|
||||||
|
return nil, errors.Wrap(err, "extend builder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTranslations(files fs.ReadDirFS, cat *catalog.Builder) error {
|
||||||
|
dir, err := files.ReadDir("locales")
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "read locales dir")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range dir {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
lang, err := language.Parse(entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "parse language %s", entry.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
subDirPath := path.Join("locales", entry.Name())
|
||||||
|
|
||||||
|
filePath := path.Join(subDirPath, "active.json")
|
||||||
|
|
||||||
|
reader, err := files.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "open file %s", filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := load(reader, lang, cat); err != nil {
|
||||||
|
if cErr := reader.Close(); cErr != nil {
|
||||||
|
_ = cErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrapf(err, "load translations from %s", filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := reader.Close(); err != nil {
|
||||||
|
return errors.Wrapf(err, "close file %s", filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if extLoader != nil {
|
||||||
|
if err := extLoader.Load(cat); err != nil {
|
||||||
|
return errors.WithMessage(err, "load translations from external")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(r io.Reader, lang language.Tag, cat *catalog.Builder) error {
|
||||||
|
var translations []Translation
|
||||||
|
if err := json.NewDecoder(r).Decode(&translations); err != nil {
|
||||||
|
return errors.Wrap(err, "decode translation")
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx := range translations {
|
||||||
|
trans := &translations[idx]
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case trans.Plural != nil:
|
||||||
|
count, format := getPlaceholders(trans.Plural.Other)
|
||||||
|
|
||||||
|
msg := plural.Selectf(count, format, trans.Plural.cases()...)
|
||||||
|
|
||||||
|
if err := cat.Set(lang, trans.Key, msg); err != nil {
|
||||||
|
return errors.Wrapf(err, "set message for %s", trans.Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
case trans.Translation != "":
|
||||||
|
if err := cat.Set(lang, trans.Key, catalog.String(trans.Translation)); err != nil {
|
||||||
|
return errors.Wrapf(err, "set string for %s", trans.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPlaceholders(s string) (count int, format string) {
|
||||||
|
for idx := 0; idx < len(s); idx++ {
|
||||||
|
if s[idx] == '%' {
|
||||||
|
count++
|
||||||
|
|
||||||
|
if format == "" {
|
||||||
|
format = s[idx : idx+1]
|
||||||
|
}
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, format
|
||||||
|
}
|
||||||
51
internal/loader_test.go
Normal file
51
internal/loader_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package internal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
. "github.com/derfenix/goi18n/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
var vvv = `[
|
||||||
|
{
|
||||||
|
"key": "test",
|
||||||
|
"description": "Для тестов, не трогать",
|
||||||
|
"translation": "Тест %s"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "test plural",
|
||||||
|
"description": "Для тестов, не трогать",
|
||||||
|
"plural": {
|
||||||
|
"other": "всего %d пауков",
|
||||||
|
"one": "паучок",
|
||||||
|
"=0": "нет пауков",
|
||||||
|
"=2": "всего пара пауков"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "transition_not_allowed",
|
||||||
|
"description": "Ошибка при попытке произвести запрещённое изменение состояния",
|
||||||
|
"translation": "Переход запрещён"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Should be shorter than %d symbols",
|
||||||
|
"translation": "Должно быть короче %d символов"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Should be longer than %d symbols",
|
||||||
|
"translation": "Должно быть длиннее %d символов"
|
||||||
|
}
|
||||||
|
]`
|
||||||
|
|
||||||
|
func Benchmark_plurals_UnmarshalJSON(b *testing.B) {
|
||||||
|
var trans []Translation
|
||||||
|
|
||||||
|
b.ReportAllocs()
|
||||||
|
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
require.NoError(b, json.Unmarshal([]byte(vvv), &trans))
|
||||||
|
}
|
||||||
|
}
|
||||||
57
internal/testsfs.go
Normal file
57
internal/testsfs.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var TestFS = fstest.MapFS{
|
||||||
|
"locales": &fstest.MapFile{Mode: 0777 | fs.ModeDir},
|
||||||
|
"locales/ru": &fstest.MapFile{Mode: 0777 | fs.ModeDir},
|
||||||
|
"locales/en": &fstest.MapFile{Mode: 0777 | fs.ModeDir},
|
||||||
|
"locales/ru/active.json": &fstest.MapFile{
|
||||||
|
Data: []byte(`[
|
||||||
|
{
|
||||||
|
"key": "test",
|
||||||
|
"description": "Для тестов, не трогать",
|
||||||
|
"translation": "Тест %s"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "test plural",
|
||||||
|
"description": "Для тестов, не трогать",
|
||||||
|
"plural": {
|
||||||
|
"other": "всего %d пауков",
|
||||||
|
"one": "паучок",
|
||||||
|
"=0": "нет пауков",
|
||||||
|
"=2": "всего пара пауков"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]`),
|
||||||
|
Mode: 0555,
|
||||||
|
ModTime: time.Now(),
|
||||||
|
Sys: 1,
|
||||||
|
},
|
||||||
|
"locales/en/active.json": &fstest.MapFile{
|
||||||
|
Data: []byte(`[
|
||||||
|
{
|
||||||
|
"key": "test",
|
||||||
|
"description": "Для тестов, не трогать",
|
||||||
|
"translation": "Test of the %s"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "test plural",
|
||||||
|
"description": "Для тестов, не трогать",
|
||||||
|
"plural": {
|
||||||
|
"other": "exactly %d spiders",
|
||||||
|
"one": "spider",
|
||||||
|
"=0": "no spiders",
|
||||||
|
"=2": "just pair of spiders"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]`),
|
||||||
|
Mode: 0555,
|
||||||
|
ModTime: time.Now(),
|
||||||
|
Sys: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
111
translator.go
Normal file
111
translator.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
"golang.org/x/text/message"
|
||||||
|
"golang.org/x/text/message/catalog"
|
||||||
|
|
||||||
|
"github.com/derfenix/goi18n/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
var defaultLanguage = language.Russian
|
||||||
|
|
||||||
|
var (
|
||||||
|
builder *catalog.Builder
|
||||||
|
|
||||||
|
supportedLanguages []language.Tag
|
||||||
|
supportedLanguagesMap = map[string]struct{}{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewExternalLoader(baseURL string, header http.Header) *internal.ExternalLoader {
|
||||||
|
return internal.NewExternalLoader(baseURL, header)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExternalLoaderWithClient(baseURL string, header http.Header, client *http.Client) *internal.ExternalLoader {
|
||||||
|
return internal.NewExternalLoaderWithClient(baseURL, header, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Translatable interface {
|
||||||
|
Translate(ctx context.Context) string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TryTranslate(ctx context.Context, obj interface{}) (string, bool) {
|
||||||
|
if translatable, ok := obj.(Translatable); ok {
|
||||||
|
return translatable.Translate(ctx), true
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func Init(fs fs.ReadDirFS) error {
|
||||||
|
if builder != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
initCatalog, err := internal.InitBuilder(fs)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "init catalog")
|
||||||
|
}
|
||||||
|
|
||||||
|
builder = initCatalog
|
||||||
|
|
||||||
|
// Fill the local cache
|
||||||
|
GetLanguages()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPrinter(lang language.Tag) *message.Printer {
|
||||||
|
_ = GetLanguages()
|
||||||
|
|
||||||
|
base, _ := lang.Base()
|
||||||
|
if _, ok := supportedLanguagesMap[lang.String()]; !ok {
|
||||||
|
if _, ok = supportedLanguagesMap[base.String()]; !ok {
|
||||||
|
lang = defaultLanguage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p := message.NewPrinter(lang, message.Catalog(builder))
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLanguages() []language.Tag {
|
||||||
|
if supportedLanguages == nil {
|
||||||
|
supportedLanguages = builder.Languages()
|
||||||
|
|
||||||
|
for idx := range supportedLanguages {
|
||||||
|
supportedLanguagesMap[supportedLanguages[idx].String()] = struct{}{}
|
||||||
|
|
||||||
|
base, _ := supportedLanguages[idx].Base()
|
||||||
|
supportedLanguagesMap[base.String()] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return supportedLanguages
|
||||||
|
}
|
||||||
|
|
||||||
|
func RefreshTranslations() error {
|
||||||
|
if builder == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := internal.RefreshTranslations(builder); err != nil {
|
||||||
|
return errors.WithMessage(err, "refresh translations")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetExternalBuilder(b func(builder *catalog.Builder) error) {
|
||||||
|
internal.SetExtendBuilder(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetExternalLoader(loader internal.Loader) {
|
||||||
|
internal.SetExternalLoader(loader)
|
||||||
|
}
|
||||||
89
translator_extra_test.go
Normal file
89
translator_extra_test.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
//go:build i18n_extra
|
||||||
|
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
"golang.org/x/text/message/catalog"
|
||||||
|
|
||||||
|
"github.com/derfenix/goi18n/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOverrideTranslation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
SetExternalBuilder(func(builder *catalog.Builder) error {
|
||||||
|
if err := builder.Set(language.Russian, "test", catalog.String("Тост %s")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, Init(internal.TestFS))
|
||||||
|
|
||||||
|
translated := GetPrinter(language.Russian).Sprintf("test", "был")
|
||||||
|
assert.Equal(t, "Тост был", translated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExternalLoader(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.RequestURI {
|
||||||
|
case "/en":
|
||||||
|
_, _ = w.Write([]byte(`[{"key": "Published","translation": "Foo"}]`))
|
||||||
|
case "/ru":
|
||||||
|
_, _ = w.Write([]byte(`[{"key": "Published","translation": "Буу"}]`))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
SetExternalLoader(NewExternalLoader(srv.URL, http.Header{}))
|
||||||
|
require.NoError(t, Init(internal.TestFS))
|
||||||
|
|
||||||
|
{
|
||||||
|
translated := GetPrinter(language.Russian).Sprintf("Published")
|
||||||
|
assert.Equal(t, "Буу", translated)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
translated := GetPrinter(language.English).Sprintf("Published")
|
||||||
|
assert.Equal(t, "Foo", translated)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("refresh", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.RequestURI {
|
||||||
|
case "/en":
|
||||||
|
_, _ = w.Write([]byte(`[{"key": "Published","translation": "Bar"}]`))
|
||||||
|
case "/ru":
|
||||||
|
_, _ = w.Write([]byte(`[{"key": "Published","translation": "Бяя"}]`))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
SetExternalLoader(NewExternalLoader(srv.URL, http.Header{}))
|
||||||
|
require.NoError(t, RefreshTranslations())
|
||||||
|
|
||||||
|
{
|
||||||
|
translated := GetPrinter(language.Russian).Sprintf("Published")
|
||||||
|
assert.Equal(t, "Бяя", translated)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
translated := GetPrinter(language.English).Sprintf("Published")
|
||||||
|
assert.Equal(t, "Bar", translated)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
106
translator_test.go
Normal file
106
translator_test.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
//go:build !i18n_extra
|
||||||
|
|
||||||
|
package i18n_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
|
||||||
|
. "github.com/derfenix/goi18n"
|
||||||
|
"github.com/derfenix/goi18n/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
var supportedLanguages = []language.Tag{language.Russian, language.English}
|
||||||
|
|
||||||
|
func TestPrinter(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
require.NoError(t, Init(internal.TestFS))
|
||||||
|
|
||||||
|
t.Run("translation engaged", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("russian", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
printer := GetPrinter(language.Russian)
|
||||||
|
require.Equal(t, "Тест пива", printer.Sprintf("test", "пива"))
|
||||||
|
assert.Equal(t, "111,223", printer.Sprint(111.223))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("russian (extended)", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
printer := GetPrinter(language.MustParse("ru_RU"))
|
||||||
|
require.Equal(t, "Тест пива", printer.Sprintf("test", "пива"))
|
||||||
|
assert.Equal(t, "111,223", printer.Sprint(111.223))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("russian (extended kz)", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
printer := GetPrinter(language.MustParse("ru_KZ"))
|
||||||
|
require.Equal(t, "Тест пива", printer.Sprintf("test", "пива"))
|
||||||
|
assert.Equal(t, "111,223", printer.Sprint(111.223))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("english (exactly)", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
printer := GetPrinter(language.English)
|
||||||
|
require.Equal(t, "Test of the beer", printer.Sprintf("test", "beer"))
|
||||||
|
assert.Equal(t, "111.223", printer.Sprint(111.223))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("english (dialect)", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
printer := GetPrinter(language.AmericanEnglish)
|
||||||
|
require.Equal(t, "Test of the gun", printer.Sprintf("test", "gun"))
|
||||||
|
assert.Equal(t, "111.223", printer.Sprint(111.223))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unsupported language", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
printer := GetPrinter(language.Albanian)
|
||||||
|
require.Equal(t, "Тест пива", printer.Sprintf("test", "пива"))
|
||||||
|
assert.Equal(t, "111,223", printer.Sprint(111.223))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("plural", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("russian", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
printer := GetPrinter(language.Russian)
|
||||||
|
assert.Equal(t, "всего 100 пауков", printer.Sprintf("test plural", 100))
|
||||||
|
assert.Equal(t, "нет пауков", printer.Sprintf("test plural", 0))
|
||||||
|
assert.Equal(t, "паучок", printer.Sprintf("test plural", 1))
|
||||||
|
assert.Equal(t, "всего пара пауков", printer.Sprintf("test plural", 2))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("english", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
printer := GetPrinter(language.English)
|
||||||
|
assert.Equal(t, "exactly 100 spiders", printer.Sprintf("test plural", 100))
|
||||||
|
assert.Equal(t, "no spiders", printer.Sprintf("test plural", 0))
|
||||||
|
assert.Equal(t, "spider", printer.Sprintf("test plural", 1))
|
||||||
|
assert.Equal(t, "just pair of spiders", printer.Sprintf("test plural", 2))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("languages ok", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
languages := GetLanguages()
|
||||||
|
require.Len(t, languages, len(supportedLanguages))
|
||||||
|
require.ElementsMatch(t, supportedLanguages, languages)
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user