Skip to content

Commit

Permalink
Support pluggable template engines
Browse files Browse the repository at this point in the history
  • Loading branch information
nicksnyder committed Jan 28, 2024
1 parent 5257e26 commit b597d2a
Show file tree
Hide file tree
Showing 8 changed files with 243 additions and 73 deletions.
7 changes: 3 additions & 4 deletions v2/goi18n/merge_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (

"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"
"golang.org/x/text/language"
yaml "gopkg.in/yaml.v2"
Expand Down Expand Up @@ -171,7 +170,7 @@ func merge(messageFiles map[string][]byte, sourceLanguageTag language.Tag, outdi
Description: srcTemplate.Description,
Hash: srcTemplate.Hash,
},
PluralTemplates: make(map[plural.Form]*internal.Template),
PluralTemplates: make(map[plural.Form]*i18n.Template),
}
all[dstLangTag][srcTemplate.ID] = dstMessageTemplate
}
Expand Down Expand Up @@ -269,7 +268,7 @@ func activeDst(src, dst *i18n.MessageTemplate, pluralRule *plural.Rule) (active
Description: src.Description,
Hash: src.Hash,
},
PluralTemplates: make(map[plural.Form]*internal.Template),
PluralTemplates: make(map[plural.Form]*i18n.Template),
}
}
srcPlural := src.PluralTemplates[pluralForm]
Expand All @@ -286,7 +285,7 @@ func activeDst(src, dst *i18n.MessageTemplate, pluralRule *plural.Rule) (active
Description: src.Description,
Hash: src.Hash,
},
PluralTemplates: make(map[plural.Form]*internal.Template),
PluralTemplates: make(map[plural.Form]*i18n.Template),
}
}
active.PluralTemplates[pluralForm] = dt
Expand Down
25 changes: 22 additions & 3 deletions v2/i18n/localizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,26 @@ type LocalizeConfig struct {
// DefaultMessage is used if the message is not found in any message files.
DefaultMessage *Message

// Funcs is used to extend the Go template engine's built in functions
// Funcs is used to extend the Go template engine's built in functions if TemplateEngine is not set.
Funcs template.FuncMap

// The TemplateEngine to use for parsing templates.
// If one is not set, a TextTemplateEngine is used.
TemplateEngine TemplateEngine
}

var defaultTextTemplateEngine = &TextTemplateEngine{}

func (lc *LocalizeConfig) getTemplateEngine() TemplateEngine {
if lc.TemplateEngine != nil {
return lc.TemplateEngine
}

Check warning on line 82 in v2/i18n/localizer.go

View check run for this annotation

Codecov / codecov/patch

v2/i18n/localizer.go#L81-L82

Added lines #L81 - L82 were not covered by tests
if lc.Funcs != nil {
return &TextTemplateEngine{
Funcs: lc.Funcs,
}
}

Check warning on line 87 in v2/i18n/localizer.go

View check run for this annotation

Codecov / codecov/patch

v2/i18n/localizer.go#L84-L87

Added lines #L84 - L87 were not covered by tests
return defaultTextTemplateEngine
}

type invalidPluralCountErr struct {
Expand Down Expand Up @@ -152,15 +170,16 @@ func (l *Localizer) LocalizeWithTag(lc *LocalizeConfig) (string, language.Tag, e
}

pluralForm := l.pluralForm(tag, operands)
msg, err2 := template.Execute(pluralForm, templateData, lc.Funcs)
templateEngine := lc.getTemplateEngine()
msg, err2 := template.executeEngine(pluralForm, templateData, templateEngine)
if err2 != nil {
if err == nil {
err = err2
}

// Attempt to fallback to "Other" pluralization in case translations are incomplete.
if pluralForm != plural.Other {
msg2, err3 := template.Execute(plural.Other, templateData, lc.Funcs)
msg2, err3 := template.executeEngine(plural.Other, templateData, templateEngine)
if err3 == nil {
msg = msg2
}
Expand Down
2 changes: 1 addition & 1 deletion v2/i18n/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type Message struct {
// LeftDelim is the left Go template delimiter.
LeftDelim string

// RightDelim is the right Go template delimiter.``
// RightDelim is the right Go template delimiter.
RightDelim string

// Zero is the content of the message for the CLDR plural form "zero".
Expand Down
28 changes: 21 additions & 7 deletions v2/i18n/message_template.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,20 @@ package i18n

import (
"fmt"

"text/template"

"github.com/nicksnyder/go-i18n/v2/internal"
"github.com/nicksnyder/go-i18n/v2/internal/plural"
)

// MessageTemplate is an executable template for a message.
type MessageTemplate struct {
*Message
PluralTemplates map[plural.Form]*internal.Template
PluralTemplates map[plural.Form]*Template
}

// NewMessageTemplate returns a new message template.
func NewMessageTemplate(m *Message) *MessageTemplate {
pluralTemplates := map[plural.Form]*internal.Template{}
pluralTemplates := map[plural.Form]*Template{}
setPluralTemplate(pluralTemplates, plural.Zero, m.Zero, m.LeftDelim, m.RightDelim)
setPluralTemplate(pluralTemplates, plural.One, m.One, m.LeftDelim, m.RightDelim)
setPluralTemplate(pluralTemplates, plural.Two, m.Two, m.LeftDelim, m.RightDelim)
Expand All @@ -33,9 +31,9 @@ func NewMessageTemplate(m *Message) *MessageTemplate {
}
}

func setPluralTemplate(pluralTemplates map[plural.Form]*internal.Template, pluralForm plural.Form, src, leftDelim, rightDelim string) {
func setPluralTemplate(pluralTemplates map[plural.Form]*Template, pluralForm plural.Form, src, leftDelim, rightDelim string) {
if src != "" {
pluralTemplates[pluralForm] = &internal.Template{
pluralTemplates[pluralForm] = &Template{
Src: src,
LeftDelim: leftDelim,
RightDelim: rightDelim,
Expand All @@ -61,5 +59,21 @@ func (mt *MessageTemplate) Execute(pluralForm plural.Form, data interface{}, fun
messageID: mt.Message.ID,
}
}
return t.Execute(funcs, data)
engine := &TextTemplateEngine{
LeftDelim: t.LeftDelim,
RightDelim: t.RightDelim,
Funcs: funcs,
}
return t.execute(engine, data)

Check warning on line 67 in v2/i18n/message_template.go

View check run for this annotation

Codecov / codecov/patch

v2/i18n/message_template.go#L62-L67

Added lines #L62 - L67 were not covered by tests
}

func (mt *MessageTemplate) executeEngine(pluralForm plural.Form, data interface{}, engine TemplateEngine) (string, error) {
t := mt.PluralTemplates[pluralForm]
if t == nil {
return "", pluralFormNotFoundError{
pluralForm: pluralForm,
messageID: mt.Message.ID,
}
}
return t.execute(engine, data)
}
34 changes: 34 additions & 0 deletions v2/i18n/template.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package i18n

import (
"sync"
)

// Template stores the template for a string.
type Template struct {
Src string
LeftDelim string
RightDelim string

parseOnce sync.Once
parsedTemplate ParsedTemplate
parseError error
}

func (t *Template) execute(engine TemplateEngine, data interface{}) (string, error) {
var pt ParsedTemplate
var err error
if engine.Cacheable() {
t.parseOnce.Do(func() {
t.parsedTemplate, t.parseError = engine.ParseTemplate(t.Src, t.LeftDelim, t.RightDelim)
})
pt, err = t.parsedTemplate, t.parseError
} else {
pt, err = engine.ParseTemplate(t.Src, t.LeftDelim, t.RightDelim)
}

if err != nil {
return "", err
}
return pt.Execute(data)
}
150 changes: 150 additions & 0 deletions v2/i18n/template_engine.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package i18n

import (
"bytes"
"strings"
texttemplate "text/template"
)

type ParsedTemplate interface {
Execute(data any) (string, error)
}

type TemplateEngine interface {
// Cacheable returns true if the ParsedTemplate returned by ParseTemplate is safe to cache.
Cacheable() bool

ParseTemplate(src, leftDelim, rightDelim string) (ParsedTemplate, error)
}

type NoTemplateEngine struct{}

func (NoTemplateEngine) Cacheable() bool {
// Caching is not necessary because ParseTemplate is cheap.
return false

Check warning on line 24 in v2/i18n/template_engine.go

View check run for this annotation

Codecov / codecov/patch

v2/i18n/template_engine.go#L22-L24

Added lines #L22 - L24 were not covered by tests
}

func (NoTemplateEngine) ParseTemplate(src, leftDelim, rightDelim string) (ParsedTemplate, error) {
return &identityParsedTemplate{src: src}, nil

Check warning on line 28 in v2/i18n/template_engine.go

View check run for this annotation

Codecov / codecov/patch

v2/i18n/template_engine.go#L27-L28

Added lines #L27 - L28 were not covered by tests
}

type TextTemplateEngine struct {
LeftDelim string
RightDelim string
Funcs texttemplate.FuncMap
Option string
}

func (te *TextTemplateEngine) Cacheable() bool {
return te.Funcs == nil
}

func (te *TextTemplateEngine) ParseTemplate(src, leftDelim, rightDelim string) (ParsedTemplate, error) {
if leftDelim == "" {
leftDelim = te.LeftDelim
}
if leftDelim == "" {
leftDelim = "{{"
}
if !strings.Contains(src, leftDelim) {
// Fast path to avoid parsing a template that has no actions.
return &identityParsedTemplate{src: src}, nil
}

if rightDelim == "" {
rightDelim = te.RightDelim
}
if rightDelim == "" {
rightDelim = "}}"
}

tmpl, err := texttemplate.New("").Delims(leftDelim, rightDelim).Funcs(te.Funcs).Parse(src)
if err != nil {
return nil, err
}
return &parsedTextTemplate{tmpl: tmpl}, nil
}

type identityParsedTemplate struct {
src string
}

func (t *identityParsedTemplate) Execute(data any) (string, error) {
return t.src, nil
}

type parsedTextTemplate struct {
tmpl *texttemplate.Template
}

func (t *parsedTextTemplate) Execute(data any) (string, error) {
var buf bytes.Buffer
if err := t.tmpl.Execute(&buf, data); err != nil {
return "", err
}

Check warning on line 84 in v2/i18n/template_engine.go

View check run for this annotation

Codecov / codecov/patch

v2/i18n/template_engine.go#L83-L84

Added lines #L83 - L84 were not covered by tests
return buf.String(), nil
}

// type TemplateEngine interface {
// Execute(src string, data any) (string, error)
// }

// type NoTemplateEngine struct{}

// func (*NoTemplateEngine) Execute(src string, data any) (string, error) {
// return src, nil
// }

// type TextTemplateEngine2 struct {
// LeftDelim string
// RightDelim string
// Funcs texttemplate.FuncMap
// Option string

// cache map[string]*executeResult
// cacheMutex sync.RWMutex
// }

// type executeResult struct {
// tmpl *texttemplate.Template
// err error
// }

// func (t *TextTemplateEngine2) Execute(src string, data any) (string, error) {
// tmpl, err := t.getTemplate(src)
// if err != nil {
// return "", err
// }
// var buf bytes.Buffer
// if err := tmpl.Execute(&buf, data); err != nil {
// return "", err
// }
// return buf.String(), nil
// }

// func (t *TextTemplateEngine2) getTemplate(template string) (*texttemplate.Template, error) {
// // It is not safe to use the cache if t.Funcs or t.Option is set.
// if t.Funcs != nil || t.Option != "" {
// return texttemplate.New("").Delims(t.LeftDelim, t.RightDelim).Funcs(t.Funcs).Option(t.Option).Parse(template)
// }

// // If there is a cached result, return it.
// t.cacheMutex.RLock()
// result := t.cache[template]
// t.cacheMutex.RUnlock()
// if result != nil {
// return result.tmpl, result.err
// }

// // Parse the template and save it to the cache
// tmpl, err := texttemplate.New("").Delims(t.LeftDelim, t.RightDelim).Parse(template)
// r := &executeResult{
// tmpl: tmpl,
// err: err,
// }
// t.cacheMutex.Lock()
// t.cache[template] = r
// t.cacheMutex.Unlock()

// return tmpl, err
// }
19 changes: 12 additions & 7 deletions v2/internal/template_test.go → v2/i18n/template_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package internal
package i18n

import (
"strings"
Expand All @@ -9,7 +9,7 @@ import (
func TestExecute(t *testing.T) {
tests := []struct {
template *Template
funcs template.FuncMap
engine TemplateEngine
data interface{}
result string
err string
Expand All @@ -35,9 +35,11 @@ func TestExecute(t *testing.T) {
template: &Template{
Src: "hello {{world}}",
},
funcs: template.FuncMap{
"world": func() string {
return "world"
engine: &TextTemplateEngine{
Funcs: template.FuncMap{
"world": func() string {
return "world"
},
},
},
result: "hello world",
Expand All @@ -53,15 +55,18 @@ 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 test.engine == nil {
test.engine = &TextTemplateEngine{}
}
result, err := test.template.execute(test.engine, test.data)
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)
}
allocs := testing.AllocsPerRun(10, func() {
_, _ = test.template.Execute(test.funcs, test.data)
_, _ = test.template.execute(test.engine, test.data)
})
if test.noallocs && allocs > 0 {
t.Errorf("expected no allocations; got %f", allocs)
Expand Down
Loading

0 comments on commit b597d2a

Please sign in to comment.