diff --git a/.github/README.zh-Hans.md b/.github/README.zh-Hans.md new file mode 100644 index 00000000..c80966a6 --- /dev/null +++ b/.github/README.zh-Hans.md @@ -0,0 +1,145 @@ +# go-i18n ![Build status](https://github.com/nicksnyder/go-i18n/workflows/Build/badge.svg) [![Report card](https://goreportcard.com/badge/github.com/nicksnyder/go-i18n)](https://goreportcard.com/report/github.com/nicksnyder/go-i18n) [![codecov](https://codecov.io/gh/nicksnyder/go-i18n/branch/master/graph/badge.svg)](https://codecov.io/gh/nicksnyder/go-i18n) [![Sourcegraph](https://sourcegraph.com/github.com/nicksnyder/go-i18n/-/badge.svg)](https://sourcegraph.com/github.com/nicksnyder/go-i18n?badge) + +go-i18n 是一个帮助您将 Go 程序翻译成多种语言的 Go [包](#package-i18n) 和 [命令](#command-goi18n)。 + +- 支持 [Unicode Common Locale Data Repository (CLDR)](https://www.unicode.org/cldr/charts/28/supplemental/language_plural_rules.html) 中所有 200 多种语言的 [复数字符](http://cldr.unicode.org/index/cldr-spec/plural-rules)。 + - 代码和测试是从 [CLDR 数据](http://cldr.unicode.org/index/downloads) 中 [自动生成](https://github.com/nicksnyder/go-i18n/tree/main/v2/internal/plural/codegen) 的。 +- 使用 [text/template](http://golang.org/pkg/text/template/) 语法支持带有命名变量的字符串。 +- 支持任何格式的消息文件(例如:JSON、TOML、YAML)。 + + + + +[**English**](../README.md) · [**简体中文**](README.zh-Hans.md) + + + + +## Package i18n + +[![GoDoc](https://godoc.org/github.com/nicksnyder/go-i18n?status.svg)](https://godoc.org/github.com/nicksnyder/go-i18n/v2/i18n) + +i18n 包支持根据一组语言环境首选项查找消息。 + +```go +import "github.com/nicksnyder/go-i18n/v2/i18n" +``` + +创建一个 Bundle 以在应用程序的整个生命周期中使用。 + +```go +bundle := i18n.NewBundle(language.English) +``` + +在初始化期间将翻译加载到您的 bundle 中。 + +```go +bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) +bundle.LoadMessageFile("es.toml") +``` + +```go +// 如果使用 go:embed +//go:embed locale.*.toml +var LocaleFS embed.FS + +bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) +bundle.LoadMessageFileFS(LocaleFS, "locale.es.toml") +``` + +创建一个 Localizer 以用于一组语言首选项。 + +```go +func(w http.ResponseWriter, r *http.Request) { + lang := r.FormValue("lang") + accept := r.Header.Get("Accept-Language") + localizer := i18n.NewLocalizer(bundle, lang, accept) +} +``` + +使用 Localizer 查找消息。 + +```go +localizer.Localize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "PersonCats", + One: "{{.Name}} has {{.Count}} cat.", + Other: "{{.Name}} has {{.Count}} cats.", + }, + TemplateData: map[string]interface{}{ + "Name": "Nick", + "Count": 2, + }, + PluralCount: 2, +}) // Nick has 2 cats. +``` + +## goi18n 命令 + +[![GoDoc](https://godoc.org/github.com/nicksnyder/go-i18n?status.svg)](https://godoc.org/github.com/nicksnyder/go-i18n/v2/goi18n) + +goi18n 命令管理 i18n 包使用的消息文件。 + +``` +go install -v github.com/nicksnyder/go-i18n/v2/goi18n@latest +goi18n -help +``` + +### 提取消息 + +使用 `goi18n extract` 将 Go 源文件中的所有 i18n.Message 结构文字提取到消息文件中以进行翻译。 + +```toml +# active.en.toml +[PersonCats] +description = "The number of cats a person has" +one = "{{.Name}} has {{.Count}} cat." +other = "{{.Name}} has {{.Count}} cats." +``` + +### 翻译一种新语言 + +1. 为您要添加的语言创建一个空消息文件(例如:`translate.es.toml`)。 +2. 运行 `goi18n merge active.en.toml translate.es.toml` 以填充 `translate.es.toml` 要翻译的消息。 + + ```toml + # translate.es.toml + [HelloPerson] + hash = "sha1-5b49bfdad81fedaeefb224b0ffc2acc58b09cff5" + other = "Hello {{.Name}}" + ``` + +3. 翻译完成 `translate.es.toml` 后,将其重命名为 `active.es.toml``。 + + ```toml + # active.es.toml + [HelloPerson] + hash = "sha1-5b49bfdad81fedaeefb224b0ffc2acc58b09cff5" + other = "Hola {{.Name}}" + ``` + +4. 加载 `active.es.toml` 到您的 bundle 中。 + + ```go + bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) + bundle.LoadMessageFile("active.es.toml") + ``` + +### 翻译新消息 + +如果您在程序中添加了新消息: + +1. 运行 `goi18n extract` 以使用新消息更新 `active.en.toml`。 +2. 运行 `goi18n merge active.*.toml` 以生成更新的 `translate.*.toml` 文件。 +3. 翻译 `translate.*.toml` 文件中的所有消息。 +4. 运行 `goi18n merge active.*.toml translate.*.toml` 将翻译后的消息合并到 active 消息文件中。 + +## 有关更多信息和示例: + +- 阅读 [文档](https://godoc.org/github.com/nicksnyder/go-i18n/v2)。 +- 查看 [代码示例](https://github.com/nicksnyder/go-i18n/blob/main/v2/i18n/example_test.go) 和 [测试](https://github.com/nicksnyder/go-i18n/blob/main/v2/i18n/localizer_test.go)。 +- 查看一个示例 [程序](https://github.com/nicksnyder/go-i18n/tree/main/v2/example)。 + +## 许可证 + +go-i18n 在 MIT 许可下可用。有关更多信息,请参阅 [许可证](LICENSE) 文件。 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b3d1e925..735e02d6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,11 +9,11 @@ jobs: if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'pull_request' steps: - name: Install Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v4 with: - go-version: 1.15.2 + go-version: stable - name: Git checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Build uses: goreleaser/goreleaser-action@v2 with: @@ -25,23 +25,24 @@ jobs: run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... - name: Upload coverage uses: codecov/codecov-action@v1 - build_1_9_7: - name: Build with Go 1.9.7 + build_1_12: + name: Build with Go 1.12.17 runs-on: ubuntu-latest if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'pull_request' steps: - name: Install Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v4 with: - go-version: 1.9.7 + go-version: '1.12.17' - name: Git checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: gopath/src/github.com/nicksnyder/go-i18n - name: Build and test working-directory: gopath/src/github.com/nicksnyder/go-i18n/v2 env: GOPATH: ${{ github.workspace }}/gopath + GO111MODULE: on run: | - go get -t ./... + go get ./... go test -race ./... diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index a4da7048..3148e4d8 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -18,7 +18,7 @@ jobs: name: Set up Go uses: actions/setup-go@v2 with: - go-version: '^1.15.2' + go-version: '^1.19.3' - name: Release uses: goreleaser/goreleaser-action@v2 diff --git a/.github/workflows/lsif-go.yml b/.github/workflows/lsif-go.yml index 6ab74e00..7e56f795 100644 --- a/.github/workflows/lsif-go.yml +++ b/.github/workflows/lsif-go.yml @@ -10,5 +10,7 @@ jobs: - uses: actions/checkout@v1 - name: Generate LSIF data run: lsif-go + working-directory: v2 - name: Upload LSIF data to Sourcegraph.com run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} -ignore-upload-failure + working-directory: v2 diff --git a/README.md b/README.md index a13f268b..c703817a 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,14 @@ go-i18n is a Go [package](#package-i18n) and a [command](#command-goi18n) that h - Supports strings with named variables using [text/template](http://golang.org/pkg/text/template/) syntax. - Supports message files of any format (e.g. JSON, TOML, YAML). + + + +[**English**](README.md) · [**简体中文**](.github/README.zh-Hans.md) + + + + ## Package i18n [![GoDoc](https://godoc.org/github.com/nicksnyder/go-i18n?status.svg)](https://godoc.org/github.com/nicksnyder/go-i18n/v2/i18n) @@ -29,6 +37,15 @@ bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) bundle.LoadMessageFile("es.toml") ``` +```go +// If use go:embed +//go:embed locale.*.toml +var LocaleFS embed.FS + +bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) +bundle.LoadMessageFileFS(LocaleFS, "locale.es.toml") +``` + Create a Localizer to use for a set of language preferences. ```go @@ -62,7 +79,7 @@ localizer.Localize(&i18n.LocalizeConfig{ The goi18n command manages message files used by the i18n package. ``` -go get -u github.com/nicksnyder/go-i18n/v2/goi18n +go install -v github.com/nicksnyder/go-i18n/v2/goi18n@latest goi18n -help ``` diff --git a/v2/example/main.go b/v2/example/main.go index 838e1456..cc656340 100644 --- a/v2/example/main.go +++ b/v2/example/main.go @@ -9,7 +9,7 @@ import ( "strconv" "github.com/BurntSushi/toml" - "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/gohugoio/go-i18n/v2/i18n" "golang.org/x/text/language" ) diff --git a/v2/go.mod b/v2/go.mod index 447a6857..b11a0ed9 100644 --- a/v2/go.mod +++ b/v2/go.mod @@ -1,6 +1,6 @@ -module github.com/nicksnyder/go-i18n/v2 +module github.com/gohugoio/go-i18n/v2 -go 1.9 +go 1.16 require ( github.com/BurntSushi/toml v0.3.1 diff --git a/v2/goi18n/extract_command.go b/v2/goi18n/extract_command.go index 67afab4a..2fbc7c04 100644 --- a/v2/goi18n/extract_command.go +++ b/v2/goi18n/extract_command.go @@ -12,7 +12,7 @@ import ( "strconv" "strings" - "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/gohugoio/go-i18n/v2/i18n" ) func usageExtract() { @@ -282,7 +282,7 @@ func extractStringLiteral(expr ast.Expr) (string, bool) { func i18nPackageName(file *ast.File) string { for _, i := range file.Imports { - if i.Path.Kind == token.STRING && i.Path.Value == `"github.com/nicksnyder/go-i18n/v2/i18n"` { + if i.Path.Kind == token.STRING && i.Path.Value == `"github.com/gohugoio/go-i18n/v2/i18n"` { if i.Name == nil { return "i18n" } diff --git a/v2/goi18n/extract_command_test.go b/v2/goi18n/extract_command_test.go index 3cfe581b..60af3a8f 100644 --- a/v2/goi18n/extract_command_test.go +++ b/v2/goi18n/extract_command_test.go @@ -26,7 +26,7 @@ func TestExtract(t *testing.T) { fileName: "file.go", file: `package main - import "github.com/nicksnyder/go-i18n/v2/i18n" + import "github.com/gohugoio/go-i18n/v2/i18n" var m = &i18n.Message{ ID: "Plural ID", @@ -38,7 +38,7 @@ func TestExtract(t *testing.T) { fileName: "file.go", file: `package main - import "github.com/nicksnyder/go-i18n/v2/i18n" + import "github.com/gohugoio/go-i18n/v2/i18n" var hasnewline = &i18n.Message{ ID: "hasnewline", @@ -53,7 +53,7 @@ func TestExtract(t *testing.T) { fileName: "file.go", file: `package main - import "github.com/nicksnyder/go-i18n/v2/i18n" + import "github.com/gohugoio/go-i18n/v2/i18n" var a = &i18n.Message{ ID: "a", @@ -73,7 +73,7 @@ b = "a \" b" fileName: "file.go", file: `package main - import "github.com/nicksnyder/go-i18n/v2/i18n" + import "github.com/gohugoio/go-i18n/v2/i18n" var a = []*i18n.Message{ { @@ -95,7 +95,7 @@ b = "b" fileName: "file.go", file: `package main - import "github.com/nicksnyder/go-i18n/v2/i18n" + import "github.com/gohugoio/go-i18n/v2/i18n" var a = map[string]*i18n.Message{ "a": { @@ -117,7 +117,7 @@ b = "b" fileName: "file_test.go", file: `package main - import "github.com/nicksnyder/go-i18n/v2/i18n" + import "github.com/gohugoio/go-i18n/v2/i18n" func main() { bundle := i18n.NewBundle(language.English) @@ -131,7 +131,7 @@ b = "b" fileName: "file.go", file: `package main - import "github.com/nicksnyder/go-i18n/v2/i18n" + import "github.com/gohugoio/go-i18n/v2/i18n" func main() { bundle := i18n.NewBundle(language.English) @@ -145,7 +145,7 @@ b = "b" fileName: "file.go", file: `package main - import bar "github.com/nicksnyder/go-i18n/v2/i18n" + import bar "github.com/gohugoio/go-i18n/v2/i18n" func main() { _ := &bar.Message{ @@ -159,7 +159,7 @@ b = "b" fileName: "file.go", file: `package main - import "github.com/nicksnyder/go-i18n/v2/i18n" + import "github.com/gohugoio/go-i18n/v2/i18n" func main() { _ := &i18n.Message{ @@ -189,7 +189,7 @@ zero = "Zero translation" fileName: "file.go", file: `package main - import "github.com/nicksnyder/go-i18n/v2/i18n" + import "github.com/gohugoio/go-i18n/v2/i18n" func main() { _ := &i18n.Message{ @@ -205,7 +205,7 @@ zero = "Zero translation" fileName: "file.go", file: `package main - import "github.com/nicksnyder/go-i18n/v2/i18n" + import "github.com/gohugoio/go-i18n/v2/i18n" const constID = "ConstantID" @@ -222,7 +222,7 @@ zero = "Zero translation" fileName: "file.go", file: `package main - import "github.com/nicksnyder/go-i18n/v2/i18n" + import "github.com/gohugoio/go-i18n/v2/i18n" var m = &i18n.LocalizeConfig{ Funcs: Funcs, diff --git a/v2/goi18n/main.go b/v2/goi18n/main.go index f2f3d2c1..bc8668e4 100644 --- a/v2/goi18n/main.go +++ b/v2/goi18n/main.go @@ -1,6 +1,6 @@ // Command goi18n manages message files used by the i18n package. // -// go get -u github.com/nicksnyder/go-i18n/v2/goi18n +// go get -u github.com/gohugoio/go-i18n/v2/goi18n // goi18n -help // // Use `goi18n extract` to create a message file that contains the messages defined in your Go source files. diff --git a/v2/goi18n/marshal.go b/v2/goi18n/marshal.go index a6cc762f..396947a3 100644 --- a/v2/goi18n/marshal.go +++ b/v2/goi18n/marshal.go @@ -7,8 +7,8 @@ import ( "path/filepath" "github.com/BurntSushi/toml" - "github.com/nicksnyder/go-i18n/v2/i18n" - "github.com/nicksnyder/go-i18n/v2/internal/plural" + "github.com/gohugoio/go-i18n/v2/i18n" + "github.com/gohugoio/go-i18n/v2/internal/plural" "golang.org/x/text/language" yaml "gopkg.in/yaml.v2" ) diff --git a/v2/goi18n/merge_command.go b/v2/goi18n/merge_command.go index b736dfe9..26909d15 100644 --- a/v2/goi18n/merge_command.go +++ b/v2/goi18n/merge_command.go @@ -10,9 +10,9 @@ import ( "os" "github.com/BurntSushi/toml" - "github.com/nicksnyder/go-i18n/v2/i18n" - "github.com/nicksnyder/go-i18n/v2/internal" - "github.com/nicksnyder/go-i18n/v2/internal/plural" + "github.com/gohugoio/go-i18n/v2/i18n" + "github.com/gohugoio/go-i18n/v2/internal" + "github.com/gohugoio/go-i18n/v2/internal/plural" "golang.org/x/text/language" yaml "gopkg.in/yaml.v2" ) diff --git a/v2/i18n/bundle.go b/v2/i18n/bundle.go index 513c127d..308430fa 100644 --- a/v2/i18n/bundle.go +++ b/v2/i18n/bundle.go @@ -4,7 +4,7 @@ import ( "fmt" "io/ioutil" - "github.com/nicksnyder/go-i18n/v2/internal/plural" + "github.com/gohugoio/go-i18n/v2/internal/plural" "golang.org/x/text/language" ) @@ -26,9 +26,58 @@ type Bundle struct { matcher language.Matcher } +// The matcher in x/text/language does not handle artificial languages, +// see https://github.com/golang/go/issues/45749 +// This is a simplified matcher that delegates to the x/text/language matcher for +// the harder cases. +type matcher struct { + tags []language.Tag + defaultMatcher language.Matcher +} + +func newMatcher(tags []language.Tag) language.Matcher { + var hasArt bool + for _, tag := range tags { + base, _ := tag.Base() + hasArt = base == artTagBase + if hasArt { + break + } + } + + if !hasArt { + return language.NewMatcher(tags) + } + + return matcher{ + tags: tags, + defaultMatcher: language.NewMatcher(tags), + } +} + +func (m matcher) Match(t ...language.Tag) (language.Tag, int, language.Confidence) { + for _, candidate := range t { + base, _ := candidate.Base() + if base != artTagBase { + continue + } + + for i, tag := range m.tags { + if tag == candidate { + return candidate, i, language.Exact + } + } + } + + return m.defaultMatcher.Match(t...) +} + // artTag is the language tag used for artificial languages // https://en.wikipedia.org/wiki/Codes_for_constructed_languages -var artTag = language.MustParse("art") +var ( + artTag = language.MustParse("art") + artTagBase, _ = artTag.Base() +) // NewBundle returns a bundle with a default language and a default set of plural rules. func NewBundle(defaultLanguage language.Tag) *Bundle { @@ -59,7 +108,7 @@ func (b *Bundle) LoadMessageFile(path string) (*MessageFile, error) { return b.ParseMessageFileBytes(buf, path) } -// MustLoadMessageFile is similar to LoadTranslationFile +// MustLoadMessageFile is similar to LoadMessageFile // except it panics if an error happens. func (b *Bundle) MustLoadMessageFile(path string) { if _, err := b.LoadMessageFile(path); err != nil { @@ -126,7 +175,7 @@ func (b *Bundle) addTag(tag language.Tag) { } } b.tags = append(b.tags, tag) - b.matcher = language.NewMatcher(b.tags) + b.matcher = newMatcher(b.tags) } // LanguageTags returns the list of language tags diff --git a/v2/i18n/bundle_test.go b/v2/i18n/bundle_test.go index 12377899..e4f9280c 100644 --- a/v2/i18n/bundle_test.go +++ b/v2/i18n/bundle_test.go @@ -98,6 +98,31 @@ hello = "`+expected+`" } } +func TestPseudoLanguages(t *testing.T) { + bundle := NewBundle(language.English) + bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) + expected := "a2" + bundle.MustParseMessageFileBytes([]byte(` +hello = "a1" +`), "art-x-a1.toml") + bundle.MustParseMessageFileBytes([]byte(` +hello = "a2" +`), "art-x-a2.toml") + bundle.MustParseMessageFileBytes([]byte(` +hello = "a3" +`), "art-x-a3.toml") + + { + localized, err := NewLocalizer(bundle, "art-x-a2").Localize(&LocalizeConfig{MessageID: "hello"}) + if err != nil { + t.Fatal(err) + } + if localized != expected { + t.Fatalf("expected %q\ngot %q", expected, localized) + } + } +} + func TestJSON(t *testing.T) { bundle := NewBundle(language.English) bundle.MustParseMessageFileBytes([]byte(`{ diff --git a/v2/i18n/example_test.go b/v2/i18n/example_test.go index 2256e636..4942df57 100644 --- a/v2/i18n/example_test.go +++ b/v2/i18n/example_test.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/BurntSushi/toml" - "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/gohugoio/go-i18n/v2/i18n" "golang.org/x/text/language" ) diff --git a/v2/i18n/localizer.go b/v2/i18n/localizer.go index 17261e2e..4415c7d7 100644 --- a/v2/i18n/localizer.go +++ b/v2/i18n/localizer.go @@ -4,11 +4,20 @@ import ( "fmt" "text/template" - "github.com/nicksnyder/go-i18n/v2/internal/plural" + "github.com/gohugoio/go-i18n/v2/internal/plural" "golang.org/x/text/language" ) // Localizer provides Localize and MustLocalize methods that return localized messages. +// Localize and MustLocalize methods use a language.Tag matching algorithm based +// on the best possible value. This algorithm may cause an unexpected language.Tag returned +// value depending on the order of the tags stored in memory. For example, if the bundle +// used to create a Localizer instance ingested locales following this order +// ["en-US", "en-GB", "en-IE", "en"] and the locale "en" is asked, the underlying matching +// algorithm will return "en-US" thinking it is the best match possible. More information +// about the algorithm in this Github issue: https://github.com/golang/go/issues/49176. +// There is additionnal informations inside the Go code base: +// https://github.com/golang/text/blob/master/language/match.go#L142 type Localizer struct { // bundle contains the messages that can be returned by the Localizer. bundle *Bundle diff --git a/v2/i18n/localizer_test.go b/v2/i18n/localizer_test.go index 3dedfb67..12d646d3 100644 --- a/v2/i18n/localizer_test.go +++ b/v2/i18n/localizer_test.go @@ -5,7 +5,7 @@ import ( "reflect" "testing" - "github.com/nicksnyder/go-i18n/v2/internal/plural" + "github.com/gohugoio/go-i18n/v2/internal/plural" "golang.org/x/text/language" ) diff --git a/v2/i18n/message_template.go b/v2/i18n/message_template.go index a1a619e2..eae3cc7a 100644 --- a/v2/i18n/message_template.go +++ b/v2/i18n/message_template.go @@ -5,8 +5,8 @@ import ( "text/template" - "github.com/nicksnyder/go-i18n/v2/internal" - "github.com/nicksnyder/go-i18n/v2/internal/plural" + "github.com/gohugoio/go-i18n/v2/internal" + "github.com/gohugoio/go-i18n/v2/internal/plural" ) // MessageTemplate is an executable template for a message. diff --git a/v2/i18n/message_template_test.go b/v2/i18n/message_template_test.go index d920cd4d..6eae20ad 100644 --- a/v2/i18n/message_template_test.go +++ b/v2/i18n/message_template_test.go @@ -4,7 +4,7 @@ import ( "reflect" "testing" - "github.com/nicksnyder/go-i18n/v2/internal/plural" + "github.com/gohugoio/go-i18n/v2/internal/plural" ) func TestMessageTemplate(t *testing.T) { diff --git a/v2/internal/template_test.go b/v2/internal/template_test.go index 2f5d991b..4f569b9c 100644 --- a/v2/internal/template_test.go +++ b/v2/internal/template_test.go @@ -1,6 +1,7 @@ package internal import ( + "strings" "testing" "text/template" ) @@ -45,7 +46,7 @@ func TestExecute(t *testing.T) { template: &Template{ Src: "hello {{", }, - err: "template: :1: unexpected unclosed action in command", + err: "unclosed action", noallocs: true, }, } @@ -53,8 +54,8 @@ func TestExecute(t *testing.T) { for _, test := range tests { t.Run(test.template.Src, func(t *testing.T) { result, err := test.template.Execute(test.funcs, test.data) - if actual := str(err); actual != test.err { - t.Errorf("expected err %q; got %q", test.err, actual) + if actual := str(err); !strings.Contains(str(err), test.err) { + t.Errorf("expected err %q to contain %q", actual, test.err) } if result != test.result { t.Errorf("expected result %q; got %q", test.result, result)