|
1 | 1 | package tpl |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "encoding/json" |
4 | 5 | "fmt" |
| 6 | + htmlpkg "html" |
5 | 7 | "io" |
| 8 | + neturlpkg "net/url" |
6 | 9 | "strconv" |
7 | 10 | "strings" |
8 | 11 | "sync" |
@@ -33,12 +36,43 @@ func SetCacheSize(n int) { |
33 | 36 | } |
34 | 37 | } |
35 | 38 |
|
| 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 | + |
36 | 70 | // segment is one piece of a compiled template: either a literal string or a |
37 | 71 | // variable reference (with optional modifier). |
38 | 72 | type segment struct { |
39 | 73 | literal string |
40 | 74 | 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 |
42 | 76 | isVar bool |
43 | 77 | } |
44 | 78 |
|
@@ -167,30 +201,54 @@ func (t *Template) writeTo(w io.Writer, params []any) { |
167 | 201 | _, _ = io.WriteString(w, seg.literal) |
168 | 202 | continue |
169 | 203 | } |
| 204 | + |
170 | 205 | val, found := resolve(seg.path, params) |
171 | 206 | out := "" |
172 | 207 | if found { |
173 | 208 | out = stringify(val) |
174 | 209 | } |
| 210 | + |
175 | 211 | switch { |
176 | 212 | 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. |
179 | 219 | } |
180 | 220 | _, _ = 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) |
189 | 231 | _, _ = io.WriteString(w, "|") |
190 | 232 | _, _ = 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 | + } |
191 | 250 | } |
192 | 251 | } |
193 | | - // found but stringifies to "" with a transform → output nothing |
194 | 252 | } |
195 | 253 | } |
196 | 254 |
|
@@ -229,24 +287,40 @@ func resolve(path string, params []any) (any, bool) { |
229 | 287 |
|
230 | 288 | var titleCaser = cases.Title(language.English, cases.NoLower) |
231 | 289 |
|
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) { |
235 | 295 | case "upper": |
236 | | - return strings.ToUpper(s) |
| 296 | + return strings.ToUpper(str), true |
237 | 297 | case "lower": |
238 | | - return strings.ToLower(s) |
| 298 | + return strings.ToLower(str), true |
239 | 299 | 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 |
243 | 316 | } |
| 317 | + return "", false |
244 | 318 | } |
245 | 319 |
|
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": |
250 | 324 | return true |
251 | 325 | } |
252 | 326 | return false |
|
0 commit comments