Skip to content

Commit 62e3978

Browse files
authored
Support configurable template parsers (#317)
1 parent 5257e26 commit 62e3978

File tree

9 files changed

+218
-43
lines changed

9 files changed

+218
-43
lines changed

‎v2/i18n/localizer.go‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ package i18n
22

33
import (
44
"fmt"
5-
"text/template"
5+
texttemplate "text/template"
66

7+
"github.com/nicksnyder/go-i18n/v2/i18n/template"
78
"github.com/nicksnyder/go-i18n/v2/internal/plural"
89
"golang.org/x/text/language"
910
)
@@ -66,8 +67,26 @@ type LocalizeConfig struct {
6667
// DefaultMessage is used if the message is not found in any message files.
6768
DefaultMessage *Message
6869

69-
// Funcs is used to extend the Go template engine's built in functions
70-
Funcs template.FuncMap
70+
// Funcs is used to configure a template.TextParser if TemplateParser is not set.
71+
Funcs texttemplate.FuncMap
72+
73+
// The TemplateParser to use for parsing templates.
74+
// If one is not set, a template.TextParser is used (configured with Funcs if it is set).
75+
TemplateParser template.Parser
76+
}
77+
78+
var defaultTextParser = &template.TextParser{}
79+
80+
func (lc *LocalizeConfig) getTemplateParser() template.Parser {
81+
if lc.TemplateParser != nil {
82+
return lc.TemplateParser
83+
}
84+
if lc.Funcs != nil {
85+
return &template.TextParser{
86+
Funcs: lc.Funcs,
87+
}
88+
}
89+
return defaultTextParser
7190
}
7291

7392
type invalidPluralCountErr struct {
@@ -152,15 +171,16 @@ func (l *Localizer) LocalizeWithTag(lc *LocalizeConfig) (string, language.Tag, e
152171
}
153172

154173
pluralForm := l.pluralForm(tag, operands)
155-
msg, err2 := template.Execute(pluralForm, templateData, lc.Funcs)
174+
templateParser := lc.getTemplateParser()
175+
msg, err2 := template.execute(pluralForm, templateData, templateParser)
156176
if err2 != nil {
157177
if err == nil {
158178
err = err2
159179
}
160180

161181
// Attempt to fallback to "Other" pluralization in case translations are incomplete.
162182
if pluralForm != plural.Other {
163-
msg2, err3 := template.Execute(plural.Other, templateData, lc.Funcs)
183+
msg2, err3 := template.execute(plural.Other, templateData, templateParser)
164184
if err3 == nil {
165185
msg = msg2
166186
}

‎v2/i18n/localizer_test.go‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"reflect"
66
"testing"
77

8+
"github.com/nicksnyder/go-i18n/v2/i18n/template"
89
"github.com/nicksnyder/go-i18n/v2/internal/plural"
910
"golang.org/x/text/language"
1011
)
@@ -362,6 +363,56 @@ func localizerTests() []localizerTest {
362363
},
363364
expectedLocalized: "Hello Nick",
364365
},
366+
{
367+
name: "identity parser, bundle message",
368+
defaultLanguage: language.English,
369+
messages: map[language.Tag][]*Message{
370+
language.English: {{
371+
ID: "HelloPerson",
372+
Other: "Hello {{.Person}}",
373+
}},
374+
},
375+
acceptLangs: []string{"en"},
376+
conf: &LocalizeConfig{
377+
MessageID: "HelloPerson",
378+
TemplateData: map[string]string{
379+
"Person": "Nick",
380+
},
381+
TemplateParser: template.IdentityParser{},
382+
},
383+
expectedLocalized: "Hello {{.Person}}",
384+
},
385+
{
386+
name: "identity parser, default message",
387+
defaultLanguage: language.English,
388+
acceptLangs: []string{"en"},
389+
conf: &LocalizeConfig{
390+
DefaultMessage: &Message{
391+
ID: "HelloPerson",
392+
Other: "Hello {{.Person}}",
393+
},
394+
TemplateData: map[string]string{
395+
"Person": "Nick",
396+
},
397+
TemplateParser: template.IdentityParser{},
398+
},
399+
expectedLocalized: "Hello {{.Person}}",
400+
},
401+
{
402+
name: "custom funcs, default message",
403+
defaultLanguage: language.English,
404+
acceptLangs: []string{"en"},
405+
conf: &LocalizeConfig{
406+
DefaultMessage: &Message{
407+
ID: "HelloWorld",
408+
Other: "{{HelloWorldFunc}}",
409+
},
410+
Funcs: map[string]any{
411+
"HelloWorldFunc": func() string { return "Hello World" },
412+
},
413+
},
414+
expectedLocalized: "Hello World",
415+
},
365416
{
366417
name: "template data, custom delims, bundle message",
367418
defaultLanguage: language.English,

‎v2/i18n/message.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ type Message struct {
2121
// LeftDelim is the left Go template delimiter.
2222
LeftDelim string
2323

24-
// RightDelim is the right Go template delimiter.``
24+
// RightDelim is the right Go template delimiter.
2525
RightDelim string
2626

2727
// Zero is the content of the message for the CLDR plural form "zero".

‎v2/i18n/message_template.go‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ package i18n
22

33
import (
44
"fmt"
5+
texttemplate "text/template"
56

6-
"text/template"
7-
7+
"github.com/nicksnyder/go-i18n/v2/i18n/template"
88
"github.com/nicksnyder/go-i18n/v2/internal"
99
"github.com/nicksnyder/go-i18n/v2/internal/plural"
1010
)
@@ -53,13 +53,30 @@ func (e pluralFormNotFoundError) Error() string {
5353
}
5454

5555
// Execute executes the template for the plural form and template data.
56-
func (mt *MessageTemplate) Execute(pluralForm plural.Form, data interface{}, funcs template.FuncMap) (string, error) {
56+
// Deprecated: This message is no longer used internally by go-i18n and it probably should not have been exported to
57+
// begin with. Its replacement is not exported. If you depend on this method for some reason and/or have
58+
// a use case for exporting execute, please file an issue.
59+
func (mt *MessageTemplate) Execute(pluralForm plural.Form, data interface{}, funcs texttemplate.FuncMap) (string, error) {
60+
t := mt.PluralTemplates[pluralForm]
61+
if t == nil {
62+
return "", pluralFormNotFoundError{
63+
pluralForm: pluralForm,
64+
messageID: mt.Message.ID,
65+
}
66+
}
67+
parser := &template.TextParser{
68+
Funcs: funcs,
69+
}
70+
return t.Execute(parser, data)
71+
}
72+
73+
func (mt *MessageTemplate) execute(pluralForm plural.Form, data interface{}, parser template.Parser) (string, error) {
5774
t := mt.PluralTemplates[pluralForm]
5875
if t == nil {
5976
return "", pluralFormNotFoundError{
6077
pluralForm: pluralForm,
6178
messageID: mt.Message.ID,
6279
}
6380
}
64-
return t.Execute(funcs, data)
81+
return t.Execute(parser, data)
6582
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package template
2+
3+
// IdentityParser is an Parser that does no parsing and returns tempalte string unchanged.
4+
type IdentityParser struct{}
5+
6+
func (IdentityParser) Cacheable() bool {
7+
// Caching is not necessary because Parse is cheap.
8+
return false
9+
}
10+
11+
func (IdentityParser) Parse(src, leftDelim, rightDelim string) (ParsedTemplate, error) {
12+
return &identityParsedTemplate{src: src}, nil
13+
}
14+
15+
type identityParsedTemplate struct {
16+
src string
17+
}
18+
19+
func (t *identityParsedTemplate) Execute(data any) (string, error) {
20+
return t.src, nil
21+
}

‎v2/i18n/template/parser.go‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Package template defines a generic interface for template parsers and implementations of that interface.
2+
package template
3+
4+
// Parser parses strings into executable templates.
5+
type Parser interface {
6+
// Parse parses src and returns a ParsedTemplate.
7+
Parse(src, leftDelim, rightDelim string) (ParsedTemplate, error)
8+
9+
// Cacheable returns true if Parse returns ParsedTemplates that are always safe to cache.
10+
Cacheable() bool
11+
}
12+
13+
// ParsedTemplate is an executable template.
14+
type ParsedTemplate interface {
15+
// Execute applies a parsed template to the specified data.
16+
Execute(data any) (string, error)
17+
}

‎v2/i18n/template/text_parser.go‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package template
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
"text/template"
7+
)
8+
9+
// TextParser is a Parser that uses text/template.
10+
type TextParser struct {
11+
LeftDelim string
12+
RightDelim string
13+
Funcs template.FuncMap
14+
Option string
15+
}
16+
17+
func (te *TextParser) Cacheable() bool {
18+
return te.Funcs == nil
19+
}
20+
21+
func (te *TextParser) Parse(src, leftDelim, rightDelim string) (ParsedTemplate, error) {
22+
if leftDelim == "" {
23+
leftDelim = te.LeftDelim
24+
}
25+
if leftDelim == "" {
26+
leftDelim = "{{"
27+
}
28+
if !strings.Contains(src, leftDelim) {
29+
// Fast path to avoid parsing a template that has no actions.
30+
return &identityParsedTemplate{src: src}, nil
31+
}
32+
33+
if rightDelim == "" {
34+
rightDelim = te.RightDelim
35+
}
36+
if rightDelim == "" {
37+
rightDelim = "}}"
38+
}
39+
40+
tmpl, err := template.New("").Delims(leftDelim, rightDelim).Funcs(te.Funcs).Parse(src)
41+
if err != nil {
42+
return nil, err
43+
}
44+
return &parsedTextTemplate{tmpl: tmpl}, nil
45+
}
46+
47+
type parsedTextTemplate struct {
48+
tmpl *template.Template
49+
}
50+
51+
func (t *parsedTextTemplate) Execute(data any) (string, error) {
52+
var buf bytes.Buffer
53+
if err := t.tmpl.Execute(&buf, data); err != nil {
54+
return "", err
55+
}
56+
return buf.String(), nil
57+
}

‎v2/internal/template.go‎

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,36 @@
11
package internal
22

33
import (
4-
"bytes"
5-
"strings"
64
"sync"
7-
gotemplate "text/template"
5+
6+
"github.com/nicksnyder/go-i18n/v2/i18n/template"
87
)
98

10-
// Template stores the template for a string.
9+
// Template stores the template for a string and a cached version of the parsed template if they are cacheable.
1110
type Template struct {
1211
Src string
1312
LeftDelim string
1413
RightDelim string
1514

1615
parseOnce sync.Once
17-
parsedTemplate *gotemplate.Template
16+
parsedTemplate template.ParsedTemplate
1817
parseError error
1918
}
2019

21-
func (t *Template) Execute(funcs gotemplate.FuncMap, data interface{}) (string, error) {
22-
leftDelim := t.LeftDelim
23-
if leftDelim == "" {
24-
leftDelim = "{{"
25-
}
26-
if !strings.Contains(t.Src, leftDelim) {
27-
// Fast path to avoid parsing a template that has no actions.
28-
return t.Src, nil
29-
}
30-
31-
var gt *gotemplate.Template
20+
func (t *Template) Execute(parser template.Parser, data interface{}) (string, error) {
21+
var pt template.ParsedTemplate
3222
var err error
33-
if funcs == nil {
23+
if parser.Cacheable() {
3424
t.parseOnce.Do(func() {
35-
// If funcs is nil, then we only need to parse this template once.
36-
t.parsedTemplate, t.parseError = gotemplate.New("").Delims(t.LeftDelim, t.RightDelim).Parse(t.Src)
25+
t.parsedTemplate, t.parseError = parser.Parse(t.Src, t.LeftDelim, t.RightDelim)
3726
})
38-
gt, err = t.parsedTemplate, t.parseError
27+
pt, err = t.parsedTemplate, t.parseError
3928
} else {
40-
gt, err = gotemplate.New("").Delims(t.LeftDelim, t.RightDelim).Funcs(funcs).Parse(t.Src)
29+
pt, err = parser.Parse(t.Src, t.LeftDelim, t.RightDelim)
4130
}
4231

4332
if err != nil {
4433
return "", err
4534
}
46-
var buf bytes.Buffer
47-
if err := gt.Execute(&buf, data); err != nil {
48-
return "", err
49-
}
50-
return buf.String(), nil
35+
return pt.Execute(data)
5136
}

‎v2/internal/template_test.go‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ package internal
33
import (
44
"strings"
55
"testing"
6-
"text/template"
6+
texttemplate "text/template"
7+
8+
"github.com/nicksnyder/go-i18n/v2/i18n/template"
79
)
810

911
func TestExecute(t *testing.T) {
1012
tests := []struct {
1113
template *Template
12-
funcs template.FuncMap
14+
parser template.Parser
1315
data interface{}
1416
result string
1517
err string
@@ -35,9 +37,11 @@ func TestExecute(t *testing.T) {
3537
template: &Template{
3638
Src: "hello {{world}}",
3739
},
38-
funcs: template.FuncMap{
39-
"world": func() string {
40-
return "world"
40+
parser: &template.TextParser{
41+
Funcs: texttemplate.FuncMap{
42+
"world": func() string {
43+
return "world"
44+
},
4145
},
4246
},
4347
result: "hello world",
@@ -53,15 +57,18 @@ func TestExecute(t *testing.T) {
5357

5458
for _, test := range tests {
5559
t.Run(test.template.Src, func(t *testing.T) {
56-
result, err := test.template.Execute(test.funcs, test.data)
60+
if test.parser == nil {
61+
test.parser = &template.TextParser{}
62+
}
63+
result, err := test.template.Execute(test.parser, test.data)
5764
if actual := str(err); !strings.Contains(str(err), test.err) {
5865
t.Errorf("expected err %q to contain %q", actual, test.err)
5966
}
6067
if result != test.result {
6168
t.Errorf("expected result %q; got %q", test.result, result)
6269
}
6370
allocs := testing.AllocsPerRun(10, func() {
64-
_, _ = test.template.Execute(test.funcs, test.data)
71+
_, _ = test.template.Execute(test.parser, test.data)
6572
})
6673
if test.noallocs && allocs > 0 {
6774
t.Errorf("expected no allocations; got %f", allocs)

0 commit comments

Comments
 (0)