-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy patheasytpl.go
290 lines (245 loc) · 8.08 KB
/
easytpl.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
// Package easytpl is a very simple template system.
//
// easytpl transforms to Go's template system as follows:
//
// {%var%} -> {{Var}}
// {%var.val%} -> {{Var.Val}}
// {%var.val,fallback=some string%} -> {{if .Var.Val}}{{.Var.Val}}{{else}}some string{{end}}
// {{var.val}} -> {{ "{{var.val}}" }}
//
// That's all :-) It doesn't support if, range, or anything else.
package easytpl
import (
"bytes"
"context"
"fmt"
htmlTemplate "html/template"
"io"
"regexp"
"strings"
textTemplate "text/template"
"github.com/teamwork/utils/v2/sliceutil"
)
// Templateable allows template substitution.
type Templateable interface {
// TemplateKeys returns a zero or more keys to be used as template
// parameters; e.g.
//
// return easytpl.Params{
// "ID": obj.ID,
// }
//
// The parentKeys are what you passed in with Text()/HTML()/HTMLSafe().
TemplateKeys(ctx context.Context, parentKeys Keys) (params Keys)
// TemplateCallbacks allows getting a template key dynamically only when
// it's used. The advantage of this is performance (some variables are
// expensive but not used a lot).
//
// If setIt is false, we won't set anything for this key.
TemplateCallbacks(ctx context.Context, key string, parentKeys Keys) (value string, setIt bool)
}
// Keys are parameters passed to the template.
type Keys map[string]interface{}
var (
escapeGo = regexp.MustCompile(`\{\{.*?\}\}`)
tagsToGo = regexp.MustCompile(`(?i)\\?(\{\%|\%7B\%25)+(.*?)(\%\}|\%25\%7D)+`)
findFallback = regexp.MustCompile(`(?i)\s*(fallback)\s*=`)
parseTemplateError = regexp.MustCompile(`executing "email" at \<\.(.*?)\>.*?\"(.*?)\"`)
)
// Text attempts to parse the template with text/template. The templatables in
// keys will be used as template parameters; for example with:
//
// map[string]Templateable{
// "Inbox": inbox,
// }
//
// You get {%inbox.ID%} (or any other keys that the inbox type has).
//
// The parentKeys are always passed to every Templateable; it's useful to pass
// some global state around (e.g. context, session, etc.)
//
// If processing fails the original template and an error are returned.
func Text(ctx context.Context, body string, keys map[string]Templateable, parentKeys Keys) (string, error) {
body, usedVars := prepareTemplateTags(body)
tmpl, err := textTemplate.New("email").Parse(body)
if err != nil {
return body, err
}
return replaceVariables(ctx, tmpl, body, keys, parentKeys, usedVars)
}
// HTML works exactly like Text() but uses the html/template package so it will
// escape the variables values.
func HTML(ctx context.Context, body string, keys map[string]Templateable, parentKeys Keys) (string, error) {
body, usedVars := prepareTemplateTags(body)
tmpl, err := htmlTemplate.New("email").Parse(body)
if err != nil {
return body, err
}
return replaceVariables(ctx, tmpl, body, keys, parentKeys, usedVars)
}
// TestSafe will test the given data strictly against the HTMLSafe standard to
// find variable errors in particular.
func TestSafe(ctx context.Context, body string, keys map[string]Templateable, parentKeys Keys) (string, error) {
body, usedVars := prepareTemplateTags(body)
var (
tmpl tpl
err error
)
tmpl, err = htmlTemplate.New("email").Option("missingkey=error").Parse(body)
if err != nil {
return "", err
}
out, err := replaceVariables(ctx, tmpl, body, keys, parentKeys, usedVars)
if err != nil {
tmpl, err = textTemplate.New("email").Option("missingkey=error").Parse(body)
if err != nil {
return "", err
}
out, err = replaceVariables(ctx, tmpl, body, keys, parentKeys, usedVars)
}
if err != nil {
if _, ok := err.(textTemplate.ExecError); !ok {
return out, err
}
variables := parseTemplateError.FindAllStringSubmatch(err.Error(), -1)
if len(variables) < 1 {
return out, err
}
if len(variables[0]) < 2 {
return out, err
}
err = fmt.Errorf("unknown variable %s at %s", variables[0][2], variables[0][1])
}
if err != nil {
return "", err
}
return out, nil
}
// HTMLSafe works exactly like HTML() but will fall back to Text() if HTML()
// fails.
// This is to allow people to use broken HTML. We can't fix the world,
// unfortunately.
//
// HTML() errors will be logged. Errors from Text() will be returned.
func HTMLSafe(ctx context.Context, body string, keys map[string]Templateable, parentKeys Keys) (string, error) {
out, err := HTML(ctx, body, keys, parentKeys)
if err != nil {
out, err = Text(ctx, body, keys, parentKeys)
}
return out, err
}
type tpl interface {
Execute(wr io.Writer, data interface{}) error
}
func replaceVariables(
ctx context.Context,
tmpl tpl,
body string,
keys map[string]Templateable,
parentKeys Keys,
usedVars map[string][]string,
) (string, error) {
// Build params
params := make(map[string]Keys)
for key, value := range keys {
params[key] = value.TemplateKeys(ctx, parentKeys)
if params[key] == nil {
params[key] = make(Keys)
}
if used, has := usedVars[key]; has {
for _, v := range used {
if result, setIt := value.TemplateCallbacks(ctx, v, parentKeys); setIt {
params[key][v] = result
}
}
}
}
// Parse template
output := bytes.NewBufferString("")
err := tmpl.Execute(output, params)
if err != nil {
return body, err
}
return output.String(), nil
}
// prepareTemplateTags parses the body input and converts:
//
// - {{anything.here}} to {{ "{{anything.here}}" }}
// - {%anything.here%} to {{.Anything.Here}}
//
// This allows us to parse this by the Go template engine.
func prepareTemplateTags(body string) (string, map[string][]string) {
// Escape {{ text }} to {{ "{{ text }}" }}
body = escapeGo.ReplaceAllStringFunc(body, func(match string) string {
if strings.Contains(match, `"`) {
return match
}
return fmt.Sprintf(`{{ "%s" }}`, match)
})
usedVars := map[string][]string{}
// Convert {%user.firstName%} to {{.User.FirstName}}
body = tagsToGo.ReplaceAllStringFunc(body, func(match string) string {
// Allow escaping { with \{
if match[0] == '\\' {
return match[1:]
}
submatch := tagsToGo.FindStringSubmatch(match)
if len(submatch) < 3 {
return match
}
tagParts := strings.Split(strings.TrimSpace(submatch[2]), ".")
if len(tagParts) < 2 {
return match
}
if len(tagParts[0]) > 0 {
tagParts[0] = strings.ToUpper(tagParts[0][0:1]) + tagParts[0][1:]
}
if len(tagParts[1]) > 0 {
tagParts[1] = strings.ToUpper(tagParts[1][0:1]) + tagParts[1][1:]
}
if strings.HasPrefix(tagParts[0], "@") {
// Convert {% @user.HasPermission "feature-x" %} to {{call .user.HasPermission "feature-x"}}
return replaceTemplateFuncCall(strings.TrimSpace(tagParts[0]), strings.TrimSpace(tagParts[1]))
}
if _, ok := usedVars[tagParts[0]]; !ok {
usedVars[tagParts[0]] = []string{}
}
if !sliceutil.Contains(usedVars[tagParts[0]], tagParts[1]) {
usedVars[tagParts[0]] = append(usedVars[tagParts[0]], tagParts[1])
}
return replaceTemplateFallback(fmt.Sprintf("{{.%s.%s}}", tagParts[0], tagParts[1]))
})
return body, usedVars
}
// replaceTemplateFallback takes input like this:
//
// {{.Inbox.Name,fallback=this inbox}}
//
// and turns it in to proper template tags:
//
// {{if .Inbox.Name}}{{.Inbox.Name}}{{else}}this inbox{{end}}
func replaceTemplateFallback(tag string) string {
if !strings.Contains(strings.ToLower(tag), "fallback") {
return tag
}
tagBody := tag[2 : len(tag)-2]
// Split out the variable name and fallback text
parts := strings.SplitN(tagBody, ",", 2)
if len(parts) != 2 {
// This can never happen, I think... Leaving it here just in case.
return tag
}
variable := strings.TrimSpace(parts[0])
fallback := parts[1]
// Now we have a string that looks like fallback=, so let's just get rid of
// that, trim the string and move on
fallback = strings.TrimSpace(findFallback.ReplaceAllString(fallback, ""))
return fmt.Sprintf("{{if %s}}{{%s}}{{else}}%s{{end}}", variable, variable, fallback)
}
func replaceTemplateFuncCall(variable, method string) string {
variable = "." + variable[1:]
parts := strings.SplitN(method, " ", 2)
method = parts[0]
args := parts[1]
return fmt.Sprintf("{{call %s.%s %s}}", variable, method, args)
}