Skip to content

Commit ae26653

Browse files
getevoclaude
andcommitted
tpl: add custom functions and new built-in transforms
New built-in transforms: - |html — HTML-escape <, >, &, " - |url — URL query-encode (net/url.QueryEscape) - |trim — strip leading/trailing whitespace - |json — JSON-serialize the raw typed value Custom function registry: - RegisterFunc(name, func(any) string) — registers a named function - $var|funcName — applies function to the variable's typed value - $funcName — calls function with nil when name is not in params - Built-in transforms take precedence over registered functions - Params take precedence over standalone function lookup - Missing variable + built-in transform keeps the $var|transform placeholder - Missing variable + registered function calls the function with nil Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 47a330e commit ae26653

File tree

2 files changed

+160
-26
lines changed

2 files changed

+160
-26
lines changed

lib/tpl/tpl.go

Lines changed: 98 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package tpl
22

33
import (
4+
"encoding/json"
45
"fmt"
6+
htmlpkg "html"
57
"io"
8+
neturlpkg "net/url"
69
"strconv"
710
"strings"
811
"sync"
@@ -33,12 +36,43 @@ func SetCacheSize(n int) {
3336
}
3437
}
3538

39+
// Func is the signature for custom template functions.
40+
// It receives the resolved variable value (or nil for standalone / modifier-on-empty calls)
41+
// and must return the string to insert into the output.
42+
type Func func(v any) string
43+
44+
var (
45+
funcsMu sync.RWMutex
46+
funcs = map[string]Func{}
47+
)
48+
49+
// RegisterFunc registers a named function for use in templates.
50+
// A registered function can be used in two ways:
51+
//
52+
// - modifier: $var|name — receives the variable's typed value
53+
// - standalone: $name — receives nil when "name" is not found in params
54+
//
55+
// Built-in transform names (upper, lower, title, html, url, trim, json) are
56+
// reserved and always take precedence over registered functions.
57+
func RegisterFunc(name string, fn Func) {
58+
funcsMu.Lock()
59+
funcs[name] = fn
60+
funcsMu.Unlock()
61+
}
62+
63+
func lookupFunc(name string) (Func, bool) {
64+
funcsMu.RLock()
65+
fn, ok := funcs[name]
66+
funcsMu.RUnlock()
67+
return fn, ok
68+
}
69+
3670
// segment is one piece of a compiled template: either a literal string or a
3771
// variable reference (with optional modifier).
3872
type segment struct {
3973
literal string
4074
path string
41-
modifier string // transform name ("upper","lower","title") or default value
75+
modifier string // built-in transform, registered function name, or default value
4276
isVar bool
4377
}
4478

@@ -167,30 +201,54 @@ func (t *Template) writeTo(w io.Writer, params []any) {
167201
_, _ = io.WriteString(w, seg.literal)
168202
continue
169203
}
204+
170205
val, found := resolve(seg.path, params)
171206
out := ""
172207
if found {
173208
out = stringify(val)
174209
}
210+
175211
switch {
176212
case out != "":
177-
if seg.modifier != "" && isTransform(seg.modifier) {
178-
out = applyTransform(out, seg.modifier)
213+
// We have a string value — apply modifier if it is a known transform or function.
214+
if seg.modifier != "" {
215+
if result, ok := applyModifier(out, val, seg.modifier); ok {
216+
out = result
217+
}
218+
// else: modifier is a default value string — ignore since we already have a value.
179219
}
180220
_, _ = io.WriteString(w, out)
181-
case seg.modifier != "" && !isTransform(seg.modifier):
182-
// value missing or empty and modifier is a default — use it
183-
_, _ = io.WriteString(w, seg.modifier)
184-
case !found:
185-
// variable not found, no usable default — keep original placeholder
186-
_, _ = io.WriteString(w, "$")
187-
_, _ = io.WriteString(w, seg.path)
188-
if seg.modifier != "" {
221+
222+
case seg.modifier != "":
223+
// No string value, but there is a modifier.
224+
if fn, ok := lookupFunc(seg.modifier); ok {
225+
// Registered function — call with nil (no value available).
226+
_, _ = io.WriteString(w, fn(nil))
227+
} else if isBuiltinTransform(seg.modifier) {
228+
// Built-in transform on a missing variable — keep the placeholder.
229+
_, _ = io.WriteString(w, "$")
230+
_, _ = io.WriteString(w, seg.path)
189231
_, _ = io.WriteString(w, "|")
190232
_, _ = io.WriteString(w, seg.modifier)
233+
} else {
234+
// Unknown modifier — treat as a default value.
235+
_, _ = io.WriteString(w, seg.modifier)
236+
}
237+
238+
case !found:
239+
// Variable not found and no modifier — try the path as a standalone function.
240+
if fn, ok := lookupFunc(seg.path); ok {
241+
_, _ = io.WriteString(w, fn(nil))
242+
} else {
243+
// Keep the original placeholder unchanged.
244+
_, _ = io.WriteString(w, "$")
245+
_, _ = io.WriteString(w, seg.path)
246+
if seg.modifier != "" {
247+
_, _ = io.WriteString(w, "|")
248+
_, _ = io.WriteString(w, seg.modifier)
249+
}
191250
}
192251
}
193-
// found but stringifies to "" with a transform → output nothing
194252
}
195253
}
196254

@@ -229,24 +287,40 @@ func resolve(path string, params []any) (any, bool) {
229287

230288
var titleCaser = cases.Title(language.English, cases.NoLower)
231289

232-
// applyTransform applies the named string transform to s.
233-
func applyTransform(s, transform string) string {
234-
switch strings.ToLower(transform) {
290+
// applyModifier applies a modifier to a value.
291+
// Returns (result, true) when the modifier is a built-in transform or a registered function.
292+
// Returns ("", false) when the modifier should be treated as a default value string.
293+
func applyModifier(str string, val any, mod string) (string, bool) {
294+
switch strings.ToLower(mod) {
235295
case "upper":
236-
return strings.ToUpper(s)
296+
return strings.ToUpper(str), true
237297
case "lower":
238-
return strings.ToLower(s)
298+
return strings.ToLower(str), true
239299
case "title":
240-
return titleCaser.String(s)
241-
default:
242-
return s
300+
return titleCaser.String(str), true
301+
case "html":
302+
return htmlpkg.EscapeString(str), true
303+
case "url":
304+
return neturlpkg.QueryEscape(str), true
305+
case "trim":
306+
return strings.TrimSpace(str), true
307+
case "json":
308+
b, err := json.Marshal(val)
309+
if err != nil {
310+
return str, true
311+
}
312+
return string(b), true
313+
}
314+
if fn, ok := lookupFunc(mod); ok {
315+
return fn(val), true
243316
}
317+
return "", false
244318
}
245319

246-
// isTransform reports whether m is a known transform name.
247-
func isTransform(m string) bool {
248-
switch strings.ToLower(m) {
249-
case "upper", "lower", "title":
320+
// isBuiltinTransform reports whether mod is a reserved built-in transform name.
321+
func isBuiltinTransform(mod string) bool {
322+
switch strings.ToLower(mod) {
323+
case "upper", "lower", "title", "html", "url", "trim", "json":
250324
return true
251325
}
252326
return false

lib/tpl/tpl_test.go

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package tpl
22

33
import (
4+
"fmt"
45
"strings"
56
"testing"
67
)
@@ -38,14 +39,17 @@ func TestDollarEscape(t *testing.T) {
3839
}
3940
}
4041

41-
func TestTransforms(t *testing.T) {
42+
func TestBuiltinTransforms(t *testing.T) {
4243
cases := []struct {
4344
src, want string
4445
params map[string]any
4546
}{
4647
{"$name|upper", "HELLO WORLD", Pairs("name", "hello world")},
4748
{"$name|lower", "hello", Pairs("name", "HELLO")},
4849
{"$name|title", "Hello World", Pairs("name", "hello world")},
50+
{"$name|trim", "hi", Pairs("name", " hi ")},
51+
{"$html|html", "&lt;b&gt;bold&lt;/b&gt;", Pairs("html", "<b>bold</b>")},
52+
{"$q|url", "hello+world", Pairs("q", "hello world")},
4953
}
5054
for _, c := range cases {
5155
if got := Render(c.src, c.params); got != c.want {
@@ -54,6 +58,21 @@ func TestTransforms(t *testing.T) {
5458
}
5559
}
5660

61+
func TestJsonTransform(t *testing.T) {
62+
type Point struct {
63+
X, Y int
64+
}
65+
got := Render("$p|json", Pairs("p", Point{1, 2}))
66+
if got != `{"X":1,"Y":2}` {
67+
t.Errorf("json transform: got %q", got)
68+
}
69+
// scalar
70+
got = Render("$n|json", Pairs("n", 42))
71+
if got != "42" {
72+
t.Errorf("json scalar: got %q", got)
73+
}
74+
}
75+
5776
func TestDefault(t *testing.T) {
5877
// missing variable uses default
5978
if got := Render("$missing|guest", map[string]any{}); got != "guest" {
@@ -125,6 +144,48 @@ func TestMissingVarKept(t *testing.T) {
125144
}
126145
}
127146

147+
func TestRegisterFuncAsModifier(t *testing.T) {
148+
RegisterFunc("currency", func(v any) string {
149+
if f, ok := v.(float64); ok {
150+
return fmt.Sprintf("$%.2f", f)
151+
}
152+
return fmt.Sprint(v)
153+
})
154+
got := Render("Total: $amount|currency", Pairs("amount", 99.99))
155+
if got != "Total: $99.99" {
156+
t.Errorf("currency modifier: got %q", got)
157+
}
158+
}
159+
160+
func TestRegisterFuncStandalone(t *testing.T) {
161+
RegisterFunc("greeting", func(_ any) string {
162+
return "Hello, World"
163+
})
164+
// $greeting is not in params — should call the registered function
165+
got := Render("$greeting!", map[string]any{})
166+
if got != "Hello, World!" {
167+
t.Errorf("standalone func: got %q", got)
168+
}
169+
}
170+
171+
func TestRegisterFuncParamShadowsFunc(t *testing.T) {
172+
RegisterFunc("shadow", func(_ any) string { return "from-func" })
173+
// param value takes precedence over registered function
174+
got := Render("$shadow", Pairs("shadow", "from-param"))
175+
if got != "from-param" {
176+
t.Errorf("param should shadow func: got %q", got)
177+
}
178+
}
179+
180+
func TestRegisterFuncModifierOnMissingVar(t *testing.T) {
181+
RegisterFunc("shout", func(_ any) string { return "HEY" })
182+
// $x is missing, |shout is a registered function → call with nil
183+
got := Render("$x|shout", map[string]any{})
184+
if got != "HEY" {
185+
t.Errorf("modifier func on missing var: got %q", got)
186+
}
187+
}
188+
128189
func TestCacheSize(t *testing.T) {
129190
SetCacheSize(2)
130191
Parse("tpl-a-$x")
@@ -157,7 +218,6 @@ func TestCacheHit(t *testing.T) {
157218
}
158219

159220
func TestMultipleParams(t *testing.T) {
160-
// later params fill in gaps from earlier params
161221
got := Render("$a $b", Pairs("a", "1"), Pairs("b", "2"))
162222
if got != "1 2" {
163223
t.Errorf("multi params: got %q", got)

0 commit comments

Comments
 (0)