nano-var-template 1.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +178 -46
- package/index.js +44 -7
- package/package.json +36 -30
- package/test/index.test.js +438 -0
package/README.md
CHANGED
|
@@ -1,83 +1,215 @@
|
|
|
1
1
|
# nano-var-template
|
|
2
|
-
The smallest safe variable template engine possible (without eval / new function / injecting ES6 backticks or other things you can't expose in userland).
|
|
3
2
|
|
|
4
|
-
|
|
3
|
+
The smallest safe variable template engine with N-pass composition.
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
No `eval`. No `new Function`. No ES6 backtick injection. Just `String.replace()` with a configurable regex — safe for userland input.
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
## Why this exists
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
274 bytes minified/gzipped
|
|
9
|
+
Most template engines are single-pass: they take a template and a data object and produce output. nano-var-template is a **composable pipeline**. You create multiple instances with different delimiters, and pipe the output of one into the next. Each pass resolves its own markers and leaves everything else untouched.
|
|
12
10
|
|
|
13
|
-
This
|
|
11
|
+
This gives you layered abstraction:
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
```
|
|
14
|
+
Pass 1 ${} Raw data → ${user.name} → "Jane"
|
|
15
|
+
Pass 2 #{} Functions → #{avatar:jane.png} → "<img src='jane.png' />"
|
|
16
|
+
Pass 3 @{} User references → @{42} → "Jane Doe, Admin"
|
|
17
|
+
Pass N ~{} Whatever you need next
|
|
18
|
+
```
|
|
16
19
|
|
|
20
|
+
Each pass's output can contain markers for subsequent passes, but never for prior ones. That's a directed pipeline — easy to reason about, easy to debug (inspect the string between passes), and it costs almost nothing to add another pass.
|
|
17
21
|
|
|
18
|
-
|
|
22
|
+
**This is the core idea.** The package is small because it's finished, not because it's trivial.
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
```
|
|
22
|
-
npm install nano-var-template
|
|
23
|
-
```
|
|
24
|
+
## Install
|
|
24
25
|
|
|
25
|
-
Yarn
|
|
26
26
|
```
|
|
27
|
-
|
|
27
|
+
npm install nano-var-template
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
##
|
|
30
|
+
## Quick start
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
```
|
|
32
|
+
```js
|
|
34
33
|
const tpl = require('nano-var-template')()
|
|
35
|
-
console.log(tpl("Hello {{user}}!", {user: "Jane Doe"}))
|
|
36
|
-
```
|
|
37
34
|
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
tpl("Hello ${name}!", { name: "Jane" })
|
|
36
|
+
// → "Hello Jane!"
|
|
40
37
|
```
|
|
41
38
|
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
## Variable substitution
|
|
40
|
+
|
|
41
|
+
Supports full nested paths:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
44
|
const tpl = require('nano-var-template')()
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
console.log(tpl(template, data))
|
|
59
|
-
```
|
|
46
|
+
const template = "Welcome to ${app}. You are ${person.name.first} ${person.name.last}!"
|
|
47
|
+
|
|
48
|
+
const data = {
|
|
49
|
+
app: "Super App",
|
|
50
|
+
person: {
|
|
51
|
+
name: { first: "Jane", last: "Doe" }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
60
54
|
|
|
55
|
+
tpl(template, data)
|
|
56
|
+
// → "Welcome to Super App. You are Jane Doe!"
|
|
61
57
|
```
|
|
62
|
-
|
|
58
|
+
|
|
59
|
+
## Custom delimiters
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
// Vue/Angular style
|
|
63
|
+
const tpl = require('nano-var-template')({ start: '{{', end: '}}' })
|
|
64
|
+
tpl("Hello {{name}}!", { name: "Jane" })
|
|
65
|
+
// → "Hello Jane!"
|
|
66
|
+
|
|
67
|
+
// Anything you want
|
|
68
|
+
const tpl2 = require('nano-var-template')({ start: '@#[', end: ']#' })
|
|
69
|
+
tpl2("Hello @#[name]#!", { name: "Jane" })
|
|
70
|
+
// → "Hello Jane!"
|
|
63
71
|
```
|
|
64
72
|
|
|
65
|
-
|
|
73
|
+
## Functions (plugins)
|
|
74
|
+
|
|
75
|
+
Enable function mode to call named functions from templates. Everything after `:` is passed as the argument string:
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
const tpl = require('nano-var-template')({ functions: true })
|
|
79
|
+
|
|
80
|
+
const plugins = {
|
|
81
|
+
upper: s => s.toUpperCase(),
|
|
82
|
+
greet: name => `Welcome, ${name}!`,
|
|
83
|
+
badge: type => `<span class="badge badge-${type}">${type}</span>`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
tpl("#{upper:hello}", plugins)
|
|
87
|
+
// → "HELLO"
|
|
88
|
+
|
|
89
|
+
tpl("#{greet:Jane}", plugins)
|
|
90
|
+
// → "Welcome, Jane!"
|
|
91
|
+
|
|
92
|
+
tpl("#{badge:admin}", plugins)
|
|
93
|
+
// → '<span class="badge badge-admin">admin</span>'
|
|
66
94
|
```
|
|
67
|
-
|
|
68
|
-
|
|
95
|
+
|
|
96
|
+
Functions can be as complex as you need — any JavaScript function works. Split multiple arguments yourself:
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
const plugins = {
|
|
100
|
+
link: args => {
|
|
101
|
+
const [url, text] = args.split(',')
|
|
102
|
+
return `<a href="${url.trim()}">${text.trim()}</a>`
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
tpl("#{link:https://example.com, Click here}", plugins)
|
|
106
|
+
// → '<a href="https://example.com">Click here</a>'
|
|
69
107
|
```
|
|
70
108
|
|
|
109
|
+
## N-pass composition
|
|
110
|
+
|
|
111
|
+
This is the architectural pattern that makes nano-var-template more than a string replacer. Create multiple instances with different delimiters and pipe them together:
|
|
112
|
+
|
|
113
|
+
### Two-pass: variables then functions
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
const Tpl = require('nano-var-template')
|
|
117
|
+
const varTpl = Tpl()
|
|
118
|
+
const fnTpl = Tpl({ functions: true })
|
|
119
|
+
|
|
120
|
+
const template = "Hello #{greet:${name}}!"
|
|
121
|
+
const data = { name: "Jane" }
|
|
122
|
+
const plugins = { greet: name => `Welcome, ${name}` }
|
|
123
|
+
|
|
124
|
+
// Pass 1: resolve ${} variables
|
|
125
|
+
const pass1 = varTpl(template, data)
|
|
126
|
+
// → "Hello #{greet:Jane}!"
|
|
127
|
+
|
|
128
|
+
// Pass 2: resolve #{} functions (now with resolved data)
|
|
129
|
+
const pass2 = fnTpl(pass1, plugins)
|
|
130
|
+
// → "Hello Welcome, Jane!"
|
|
71
131
|
```
|
|
72
|
-
|
|
132
|
+
|
|
133
|
+
### Three-pass: variables, functions, and user references
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
const Tpl = require('nano-var-template')
|
|
137
|
+
const varTpl = Tpl()
|
|
138
|
+
const fnTpl = Tpl({ functions: true })
|
|
139
|
+
const userTpl = Tpl({ start: '@{', end: '}' })
|
|
140
|
+
|
|
141
|
+
const template = "Hi @{${user.id}}! Avatar: #{avatar:${user.avatar}}"
|
|
142
|
+
|
|
143
|
+
const data = { user: { id: '42', avatar: 'cat.png' } }
|
|
144
|
+
const users = { 42: 'Jane Doe' }
|
|
145
|
+
const plugins = { avatar: src => `<img src="${src}" />` }
|
|
146
|
+
|
|
147
|
+
const result = userTpl(fnTpl(varTpl(template, data), plugins), users)
|
|
148
|
+
// → 'Hi Jane Doe! Avatar: <img src="cat.png" />'
|
|
73
149
|
```
|
|
74
150
|
|
|
75
|
-
###
|
|
151
|
+
### N-pass: as many layers as you need
|
|
152
|
+
|
|
153
|
+
Each pass is the same ~10-line function with a different delimiter. Adding a 4th, 5th, or Nth pass costs essentially nothing. The only rule: **choose delimiters for each pass so that output from one pass doesn't accidentally contain markers for a later pass.** For example, if a function produces output containing `}`, use `]` or `)` as the closing delimiter for subsequent passes.
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
const Tpl = require('nano-var-template')
|
|
157
|
+
const dataTpl = Tpl() // ${}
|
|
158
|
+
const tagTpl = Tpl({ functions: true }) // #{}
|
|
159
|
+
const wrapTpl = Tpl({ start: '@{', end: '}', functions: true }) // @{}
|
|
160
|
+
const frameTpl = Tpl({ start: '~(', end: ')' }) // ~()
|
|
161
|
+
|
|
162
|
+
const template = "~(before)@{wrap:#{tag:${word}}}~(after)"
|
|
163
|
+
|
|
164
|
+
let result = template
|
|
165
|
+
result = dataTpl(result, { word: "hello" }) // → "~(before)@{wrap:#{tag:hello}}~(after)"
|
|
166
|
+
result = tagTpl(result, { tag: w => w.toUpperCase() }) // → "~(before)@{wrap:HELLO}~(after)"
|
|
167
|
+
result = wrapTpl(result, { wrap: s => `[${s}]` }) // → "~(before)[HELLO]~(after)"
|
|
168
|
+
result = frameTpl(result, { before: ">>>", after: "<<<" }) // → ">>>[HELLO]<<<"
|
|
76
169
|
```
|
|
170
|
+
|
|
171
|
+
## Error handling
|
|
172
|
+
|
|
173
|
+
By default, missing variables throw descriptive errors:
|
|
174
|
+
|
|
175
|
+
```js
|
|
77
176
|
const tpl = require('nano-var-template')()
|
|
78
|
-
|
|
177
|
+
|
|
178
|
+
tpl("Hello ${user.name}!", { user: {} })
|
|
179
|
+
// throws: "nano-var-template: 'name' missing in ${user.name}"
|
|
79
180
|
```
|
|
80
181
|
|
|
182
|
+
Set `warn: false` to silently leave unresolved tokens in place:
|
|
183
|
+
|
|
184
|
+
```js
|
|
185
|
+
const tpl = require('nano-var-template')({ warn: false })
|
|
186
|
+
|
|
187
|
+
tpl("Hello ${name}!", {})
|
|
188
|
+
// → "Hello ${name}!"
|
|
81
189
|
```
|
|
82
|
-
|
|
190
|
+
|
|
191
|
+
## Options
|
|
192
|
+
|
|
193
|
+
```js
|
|
194
|
+
const tpl = require('nano-var-template')({
|
|
195
|
+
start: '${', // Opening delimiter (any string)
|
|
196
|
+
end: '}', // Closing delimiter (any string)
|
|
197
|
+
functions: false, // true = function mode (data object contains functions, not values)
|
|
198
|
+
path: '[a-z0-9_$][\\.a-z0-9_]*', // Regex for allowed variable paths
|
|
199
|
+
warn: true // true = throw on missing variables, false = leave token unchanged
|
|
200
|
+
})
|
|
83
201
|
```
|
|
202
|
+
|
|
203
|
+
## Design notes
|
|
204
|
+
|
|
205
|
+
**Why is this package so small?** Because it's a single, well-defined operation: regex match → path lookup → replace. There's nothing to add. The power comes from composing multiple instances, not from framework complexity.
|
|
206
|
+
|
|
207
|
+
**Why N-pass instead of one big template engine?** Single-pass engines need to eagerly compute every possible variable upfront. N-pass composition is lazy — each pass only evaluates what the template actually uses. New functions don't bloat existing templates. Template authors compose building blocks without understanding the internals.
|
|
208
|
+
|
|
209
|
+
**Is this the same idea as Unix pipes?** Yes. Each pass is a filter that transforms the string and passes it along. Same principle as compiler passes, middleware chains, and stream pipelines. The difference is that each filter ignores delimiters it doesn't own.
|
|
210
|
+
|
|
211
|
+
**Delimiter design:** When piping passes together, choose delimiters so that output from one pass can't accidentally contain markers for a later pass. For example, if your functions produce HTML containing `}`, don't use `}` as the closing delimiter for subsequent passes — use `]`, `)`, or a multi-character sequence like `]]` instead.
|
|
212
|
+
|
|
213
|
+
## License
|
|
214
|
+
|
|
215
|
+
MIT
|
package/index.js
CHANGED
|
@@ -1,17 +1,54 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
2
|
+
|
|
3
|
+
const Tpl = options => {
|
|
4
|
+
// You can pass options to overide defaults...
|
|
5
|
+
options = Object.assign(
|
|
6
|
+
{
|
|
7
|
+
start: options && options.functions
|
|
8
|
+
? "#{"
|
|
9
|
+
: "${",
|
|
10
|
+
end: "}",
|
|
11
|
+
path:
|
|
12
|
+
options && options.functions
|
|
13
|
+
? "[a-z0-9_$][: ,\\.a-z0-9_]*"
|
|
14
|
+
: "[a-z0-9_$][\\.a-z0-9_]*",
|
|
15
|
+
warn: true,
|
|
16
|
+
functions: false
|
|
17
|
+
},
|
|
18
|
+
options
|
|
19
|
+
)
|
|
20
|
+
const match = new RegExp(
|
|
21
|
+
escapeRegex(options.start) + "\\s*(" + options.path + ")\\s*" + escapeRegex(options.end),
|
|
22
|
+
"gi"
|
|
23
|
+
)
|
|
4
24
|
return (template, data) => {
|
|
5
|
-
// Merge the passed data into the template
|
|
25
|
+
// Merge the passed data into the template string we're sending back...
|
|
6
26
|
return template.replace(match, (tag, token) => {
|
|
7
|
-
|
|
27
|
+
var delim = options.functions ? ":" : "."
|
|
28
|
+
var path = token.split(delim),
|
|
29
|
+
lookup = data
|
|
30
|
+
if (options.functions) {
|
|
31
|
+
if (typeof data[path[0]] !== "function") {
|
|
32
|
+
if (options.warn) throw `nano-var-template: Missing function ${path}`
|
|
33
|
+
return tag
|
|
34
|
+
}
|
|
35
|
+
return data[path[0]](path[1])
|
|
36
|
+
}
|
|
8
37
|
for (let i = 0; i < path.length; i++) {
|
|
38
|
+
if (lookup == null) {
|
|
39
|
+
if (options.warn) throw `nano-var-template: '${path[i]}' missing in ${tag}`
|
|
40
|
+
return tag
|
|
41
|
+
}
|
|
9
42
|
lookup = lookup[path[i]]
|
|
10
43
|
// Property not found
|
|
11
|
-
if (lookup === undefined
|
|
44
|
+
if (lookup === undefined) {
|
|
45
|
+
if (options.warn) throw `nano-var-template: '${path[i]}' missing in ${tag}`
|
|
46
|
+
return tag
|
|
47
|
+
}
|
|
12
48
|
// Return the required value
|
|
13
49
|
if (i === path.length - 1) return lookup
|
|
14
50
|
}
|
|
15
51
|
})
|
|
16
52
|
}
|
|
17
|
-
}
|
|
53
|
+
}
|
|
54
|
+
module.exports = Tpl
|
package/package.json
CHANGED
|
@@ -1,30 +1,36 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "nano-var-template",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "The smallest *safe* variable template engine for Javascript",
|
|
5
|
-
"main": "index.js",
|
|
6
|
-
"scripts": {
|
|
7
|
-
"test": "
|
|
8
|
-
},
|
|
9
|
-
"repository": {
|
|
10
|
-
"type": "git",
|
|
11
|
-
"url": "git+https://github.com/onexdata/nano-var-template.git"
|
|
12
|
-
},
|
|
13
|
-
"keywords": [
|
|
14
|
-
"template",
|
|
15
|
-
"engine",
|
|
16
|
-
"variable",
|
|
17
|
-
"ES6",
|
|
18
|
-
"backticks",
|
|
19
|
-
"injection",
|
|
20
|
-
"inject",
|
|
21
|
-
"vars",
|
|
22
|
-
"variables"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "nano-var-template",
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"description": "The smallest and most robust *safe* variable template engine for Javascript",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test test/*.test.js"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/onexdata/nano-var-template.git"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"template",
|
|
15
|
+
"engine",
|
|
16
|
+
"variable",
|
|
17
|
+
"ES6",
|
|
18
|
+
"backticks",
|
|
19
|
+
"injection",
|
|
20
|
+
"inject",
|
|
21
|
+
"vars",
|
|
22
|
+
"variables",
|
|
23
|
+
"functions",
|
|
24
|
+
"userland",
|
|
25
|
+
"safe",
|
|
26
|
+
"scrubbed",
|
|
27
|
+
"XSS",
|
|
28
|
+
"strategy"
|
|
29
|
+
],
|
|
30
|
+
"author": "NIck Steele <njsteele@gmail.com>",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/onexdata/nano-var-template/issues"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/onexdata/nano-var-template#readme"
|
|
36
|
+
}
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
const { describe, it } = require("node:test")
|
|
2
|
+
const assert = require("node:assert/strict")
|
|
3
|
+
const Tpl = require("../index")
|
|
4
|
+
|
|
5
|
+
// ─── Basic variable substitution ────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
describe("basic variable substitution", () => {
|
|
8
|
+
const tpl = Tpl()
|
|
9
|
+
|
|
10
|
+
it("replaces a single variable", () => {
|
|
11
|
+
assert.equal(tpl("Hello ${name}!", { name: "Jane" }), "Hello Jane!")
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("replaces multiple variables", () => {
|
|
15
|
+
assert.equal(
|
|
16
|
+
tpl("${greeting} ${name}!", { greeting: "Hi", name: "Jane" }),
|
|
17
|
+
"Hi Jane!"
|
|
18
|
+
)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("replaces the same variable used multiple times", () => {
|
|
22
|
+
assert.equal(
|
|
23
|
+
tpl("${x} and ${x}", { x: "ok" }),
|
|
24
|
+
"ok and ok"
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("leaves non-matching text untouched", () => {
|
|
29
|
+
assert.equal(tpl("no vars here", {}), "no vars here")
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("handles empty template string", () => {
|
|
33
|
+
assert.equal(tpl("", { x: 1 }), "")
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("handles template with no matching vars in data", () => {
|
|
37
|
+
// warn defaults to true, so missing var throws
|
|
38
|
+
assert.throws(() => tpl("${missing}", {}), /missing/)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// ─── Nested path access ────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
describe("nested path access", () => {
|
|
45
|
+
const tpl = Tpl()
|
|
46
|
+
|
|
47
|
+
it("resolves a two-level path", () => {
|
|
48
|
+
assert.equal(
|
|
49
|
+
tpl("${user.name}", { user: { name: "Jane" } }),
|
|
50
|
+
"Jane"
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("resolves a deep path", () => {
|
|
55
|
+
const data = { a: { b: { c: { d: "deep" } } } }
|
|
56
|
+
assert.equal(tpl("${a.b.c.d}", data), "deep")
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("throws on missing intermediate property (warn: true)", () => {
|
|
60
|
+
assert.throws(
|
|
61
|
+
() => tpl("${user.name}", { user: {} }),
|
|
62
|
+
/name.*missing/
|
|
63
|
+
)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("throws when traversing through a non-object (warn: true)", () => {
|
|
67
|
+
assert.throws(
|
|
68
|
+
() => tpl("${user.name.first}", { user: { name: "Jane" } }),
|
|
69
|
+
/first.*missing/
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// ─── Falsy but valid values ─────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
describe("falsy but valid values", () => {
|
|
77
|
+
const tpl = Tpl()
|
|
78
|
+
|
|
79
|
+
it("resolves 0", () => {
|
|
80
|
+
assert.equal(tpl("${count}", { count: 0 }), "0")
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("resolves false", () => {
|
|
84
|
+
assert.equal(tpl("${flag}", { flag: false }), "false")
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("resolves empty string", () => {
|
|
88
|
+
assert.equal(tpl("${empty}", { empty: "" }), "")
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("resolves null (returns 'null' as string)", () => {
|
|
92
|
+
// null is a valid value — String.replace coerces to "null"
|
|
93
|
+
assert.equal(tpl("${val}", { val: null }), "null")
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// ─── warn: false (silent mode) ─────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
describe("warn: false (silent mode)", () => {
|
|
100
|
+
const tpl = Tpl({ warn: false })
|
|
101
|
+
|
|
102
|
+
it("returns original tag for missing top-level variable", () => {
|
|
103
|
+
assert.equal(tpl("Hello ${name}!", {}), "Hello ${name}!")
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("returns original tag for missing nested property", () => {
|
|
107
|
+
assert.equal(
|
|
108
|
+
tpl("${user.name}", { user: {} }),
|
|
109
|
+
"${user.name}"
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("returns original tag when traversing through null", () => {
|
|
114
|
+
assert.equal(
|
|
115
|
+
tpl("${a.b.c}", { a: { b: null } }),
|
|
116
|
+
"${a.b.c}"
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("returns original tag when traversing through a primitive", () => {
|
|
121
|
+
assert.equal(
|
|
122
|
+
tpl("${user.name.first}", { user: { name: "Jane" } }),
|
|
123
|
+
"${user.name.first}"
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("still resolves variables that do exist", () => {
|
|
128
|
+
assert.equal(
|
|
129
|
+
tpl("${found} ${missing}", { found: "yes" }),
|
|
130
|
+
"yes ${missing}"
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// ─── Custom delimiters ─────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
describe("custom delimiters", () => {
|
|
138
|
+
it("supports {{ }} (Vue/Angular style)", () => {
|
|
139
|
+
const tpl = Tpl({ start: "{{", end: "}}" })
|
|
140
|
+
assert.equal(tpl("Hello {{name}}!", { name: "Jane" }), "Hello Jane!")
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it("supports @#[ ]# (arbitrary custom)", () => {
|
|
144
|
+
const tpl = Tpl({ start: "@#[", end: "]#" })
|
|
145
|
+
assert.equal(tpl("Hello @#[name]#!", { name: "Jane" }), "Hello Jane!")
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("supports delimiters with regex special characters", () => {
|
|
149
|
+
const tpl = Tpl({ start: "$(", end: ")" })
|
|
150
|
+
assert.equal(tpl("Hello $(name)!", { name: "Jane" }), "Hello Jane!")
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it("supports [ ] delimiters", () => {
|
|
154
|
+
const tpl = Tpl({ start: "[", end: "]" })
|
|
155
|
+
assert.equal(tpl("Hello [name]!", { name: "Jane" }), "Hello Jane!")
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it("supports single-char delimiters", () => {
|
|
159
|
+
const tpl = Tpl({ start: "%", end: "%" })
|
|
160
|
+
assert.equal(tpl("Hello %name%!", { name: "Jane" }), "Hello Jane!")
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// ─── Whitespace handling ────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
describe("whitespace in tokens", () => {
|
|
167
|
+
const tpl = Tpl()
|
|
168
|
+
|
|
169
|
+
it("handles leading whitespace inside delimiters", () => {
|
|
170
|
+
assert.equal(tpl("Hello ${ name}!", { name: "Jane" }), "Hello Jane!")
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("handles trailing whitespace inside delimiters", () => {
|
|
174
|
+
assert.equal(tpl("Hello ${name }!", { name: "Jane" }), "Hello Jane!")
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("handles whitespace on both sides", () => {
|
|
178
|
+
assert.equal(tpl("Hello ${ name }!", { name: "Jane" }), "Hello Jane!")
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// ─── Case sensitivity ───────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
describe("case sensitivity", () => {
|
|
185
|
+
const tpl = Tpl()
|
|
186
|
+
|
|
187
|
+
it("matches variable names case-insensitively in the template", () => {
|
|
188
|
+
// regex has 'i' flag so ${NAME} matches, but token preserves original case
|
|
189
|
+
// for data lookup — so data key must match the template's case
|
|
190
|
+
assert.equal(tpl("${Name}", { Name: "Jane" }), "Jane")
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it("data key case must match template token case", () => {
|
|
194
|
+
// ${NAME} looks up data["NAME"], not data["name"]
|
|
195
|
+
assert.throws(() => tpl("${NAME}", { name: "Jane" }))
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// ─── Function / plugin mode ────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
describe("function mode", () => {
|
|
202
|
+
const tpl = Tpl({ functions: true })
|
|
203
|
+
|
|
204
|
+
it("calls a function with no arguments", () => {
|
|
205
|
+
const plugins = { greet: () => "Hello!" }
|
|
206
|
+
assert.equal(tpl("#{greet}", plugins), "Hello!")
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("calls a function with a string argument", () => {
|
|
210
|
+
const plugins = { upper: s => s.toUpperCase() }
|
|
211
|
+
assert.equal(tpl("#{upper:hello}", plugins), "HELLO")
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it("passes everything after : as the argument string", () => {
|
|
215
|
+
const plugins = { echo: s => s }
|
|
216
|
+
assert.equal(
|
|
217
|
+
tpl("#{echo:a, b, c}", plugins),
|
|
218
|
+
"a, b, c"
|
|
219
|
+
)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it("function receives undefined when no argument given", () => {
|
|
223
|
+
const plugins = {
|
|
224
|
+
check: arg => (arg === undefined ? "none" : arg)
|
|
225
|
+
}
|
|
226
|
+
assert.equal(tpl("#{check}", plugins), "none")
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it("throws on missing function (warn: true)", () => {
|
|
230
|
+
assert.throws(() => tpl("#{nope}", {}), /Missing function/)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it("returns original tag on missing function (warn: false)", () => {
|
|
234
|
+
const quietTpl = Tpl({ functions: true, warn: false })
|
|
235
|
+
assert.equal(quietTpl("#{nope}", {}), "#{nope}")
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it("returns original tag when value is not a function (warn: false)", () => {
|
|
239
|
+
const quietTpl = Tpl({ functions: true, warn: false })
|
|
240
|
+
assert.equal(quietTpl("#{notfn}", { notfn: "string" }), "#{notfn}")
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it("supports custom function delimiters", () => {
|
|
244
|
+
const tpl2 = Tpl({ functions: true, start: "@{", end: "}" })
|
|
245
|
+
const plugins = { greet: () => "Hi" }
|
|
246
|
+
assert.equal(tpl2("@{greet}", plugins), "Hi")
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// ─── Multi-pass composition (the core architectural pattern) ────────────────
|
|
251
|
+
|
|
252
|
+
describe("multi-pass composition", () => {
|
|
253
|
+
it("two-pass: variables then functions", () => {
|
|
254
|
+
const varTpl = Tpl()
|
|
255
|
+
const fnTpl = Tpl({ functions: true })
|
|
256
|
+
|
|
257
|
+
const template = "Hello #{greet:${name}}!"
|
|
258
|
+
const data = { name: "Jane" }
|
|
259
|
+
const plugins = { greet: name => `Welcome, ${name}` }
|
|
260
|
+
|
|
261
|
+
// Pass 1: resolve ${} variables
|
|
262
|
+
const pass1 = varTpl(template, data)
|
|
263
|
+
assert.equal(pass1, "Hello #{greet:Jane}!")
|
|
264
|
+
|
|
265
|
+
// Pass 2: resolve #{} functions
|
|
266
|
+
const pass2 = fnTpl(pass1, plugins)
|
|
267
|
+
assert.equal(pass2, "Hello Welcome, Jane!")
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it("three-pass: variables, then functions, then user references", () => {
|
|
271
|
+
const varTpl = Tpl()
|
|
272
|
+
const fnTpl = Tpl({ functions: true })
|
|
273
|
+
const userTpl = Tpl({ start: "@{", end: "}" })
|
|
274
|
+
|
|
275
|
+
const template = "Hi @{${user.id}}! Avatar: #{avatar:${user.avatar}}"
|
|
276
|
+
const data = { user: { id: "42", avatar: "cat.png" } }
|
|
277
|
+
const users = { 42: "Jane Doe" }
|
|
278
|
+
const plugins = { avatar: src => `<img src="${src}" />` }
|
|
279
|
+
|
|
280
|
+
// Pass 1: resolve ${} variables
|
|
281
|
+
const pass1 = varTpl(template, data)
|
|
282
|
+
assert.equal(pass1, 'Hi @{42}! Avatar: #{avatar:cat.png}')
|
|
283
|
+
|
|
284
|
+
// Pass 2: resolve #{} functions
|
|
285
|
+
const pass2 = fnTpl(pass1, plugins)
|
|
286
|
+
assert.equal(pass2, 'Hi @{42}! Avatar: <img src="cat.png" />')
|
|
287
|
+
|
|
288
|
+
// Pass 3: resolve @{} user references
|
|
289
|
+
const pass3 = userTpl(pass2, users)
|
|
290
|
+
assert.equal(pass3, 'Hi Jane Doe! Avatar: <img src="cat.png" />')
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it("four-pass: data → components → layout → final", () => {
|
|
294
|
+
// Four passes with simple string values to demonstrate N-pass depth.
|
|
295
|
+
// Note: function arguments are constrained by the path regex, so each
|
|
296
|
+
// pass that uses functions should receive simple alphanumeric args.
|
|
297
|
+
// The final assembly into complex output (HTML etc.) happens at the
|
|
298
|
+
// last function pass, or via variable passes which have no such limit.
|
|
299
|
+
const dataTpl = Tpl() // pass 1: ${}
|
|
300
|
+
const tagTpl = Tpl({ functions: true }) // pass 2: #{}
|
|
301
|
+
const wrapTpl = Tpl({ start: "@{", end: "}", functions: true })// pass 3: @{}
|
|
302
|
+
const frameTpl = Tpl({ start: "~(", end: ")" }) // pass 4: ~()
|
|
303
|
+
|
|
304
|
+
const template = "~(before)@{wrap:#{tag:${word}}}~(after)"
|
|
305
|
+
|
|
306
|
+
// Pass 1: resolve ${} — inject raw data
|
|
307
|
+
const p1 = dataTpl(template, { word: "hello" })
|
|
308
|
+
assert.equal(p1, "~(before)@{wrap:#{tag:hello}}~(after)")
|
|
309
|
+
|
|
310
|
+
// Pass 2: resolve #{} — tag function transforms the word
|
|
311
|
+
const p2 = tagTpl(p1, { tag: w => w.toUpperCase() })
|
|
312
|
+
assert.equal(p2, "~(before)@{wrap:HELLO}~(after)")
|
|
313
|
+
|
|
314
|
+
// Pass 3: resolve @{} — wrap function adds decoration
|
|
315
|
+
const p3 = wrapTpl(p2, { wrap: s => `[${s}]` })
|
|
316
|
+
assert.equal(p3, "~(before)[HELLO]~(after)")
|
|
317
|
+
|
|
318
|
+
// Pass 4: resolve ~() — inject frame variables
|
|
319
|
+
const p4 = frameTpl(p3, { before: ">>>", after: "<<<" })
|
|
320
|
+
assert.equal(p4, ">>>[HELLO]<<<")
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it("passes are order-independent — later delimiters survive earlier passes", () => {
|
|
324
|
+
const pass1 = Tpl()
|
|
325
|
+
const pass2 = Tpl({ functions: true })
|
|
326
|
+
|
|
327
|
+
// #{} tokens survive pass 1 because pass 1 only matches ${}
|
|
328
|
+
const template = "#{fn:${val}}"
|
|
329
|
+
const result1 = pass1(template, { val: "x" })
|
|
330
|
+
assert.equal(result1, "#{fn:x}")
|
|
331
|
+
|
|
332
|
+
// ${} tokens would NOT survive pass 2 if any remained — but they don't
|
|
333
|
+
const result2 = pass2(result1, { fn: v => v.toUpperCase() })
|
|
334
|
+
assert.equal(result2, "X")
|
|
335
|
+
})
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
// ─── Edge cases ─────────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
describe("edge cases", () => {
|
|
341
|
+
const tpl = Tpl()
|
|
342
|
+
|
|
343
|
+
it("handles variables at start of string", () => {
|
|
344
|
+
assert.equal(tpl("${x}!", { x: "hi" }), "hi!")
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it("handles variables at end of string", () => {
|
|
348
|
+
assert.equal(tpl("say ${x}", { x: "hi" }), "say hi")
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it("handles adjacent variables with no separator", () => {
|
|
352
|
+
assert.equal(tpl("${a}${b}", { a: "1", b: "2" }), "12")
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it("handles numeric values", () => {
|
|
356
|
+
assert.equal(tpl("count: ${n}", { n: 42 }), "count: 42")
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it("handles boolean values", () => {
|
|
360
|
+
assert.equal(tpl("${a} ${b}", { a: true, b: false }), "true false")
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it("handles underscore and $ in variable names", () => {
|
|
364
|
+
assert.equal(tpl("${_private}", { _private: "yes" }), "yes")
|
|
365
|
+
assert.equal(tpl("${$special}", { $special: "yes" }), "yes")
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it("does not match empty delimiters", () => {
|
|
369
|
+
// ${} has no valid path inside — regex requires at least one char
|
|
370
|
+
assert.equal(tpl("${}", {}), "${}")
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
// ─── Regex escaping in delimiters ───────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
describe("regex-safe delimiters", () => {
|
|
377
|
+
it("handles $( ) which contains regex special chars", () => {
|
|
378
|
+
const tpl = Tpl({ start: "$(", end: ")" })
|
|
379
|
+
assert.equal(tpl("$(name)", { name: "Jane" }), "Jane")
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it("handles [ ] which are regex character class markers", () => {
|
|
383
|
+
const tpl = Tpl({ start: "[", end: "]" })
|
|
384
|
+
assert.equal(tpl("[name]", { name: "Jane" }), "Jane")
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it("handles pipes | which are regex alternation", () => {
|
|
388
|
+
const tpl = Tpl({ start: "|", end: "|" })
|
|
389
|
+
assert.equal(tpl("|name|", { name: "Jane" }), "Jane")
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it("handles ${{ }} (double-brace like GitHub Actions)", () => {
|
|
393
|
+
const tpl = Tpl({ start: "${{", end: "}}" })
|
|
394
|
+
assert.equal(tpl("Hello ${{ name }}!", { name: "Jane" }), "Hello Jane!")
|
|
395
|
+
})
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
// ─── Independent instance isolation ─────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
describe("instance isolation", () => {
|
|
401
|
+
it("two instances with different delimiters don't interfere", () => {
|
|
402
|
+
const tplA = Tpl({ start: "${", end: "}" })
|
|
403
|
+
const tplB = Tpl({ start: "{{", end: "}}" })
|
|
404
|
+
|
|
405
|
+
assert.equal(tplA("${x} and {{y}}", { x: "A" }), "A and {{y}}")
|
|
406
|
+
assert.equal(tplB("${x} and {{y}}", { y: "B" }), "${x} and B")
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it("variable and function instances are independent", () => {
|
|
410
|
+
const varTpl = Tpl()
|
|
411
|
+
const fnTpl = Tpl({ functions: true })
|
|
412
|
+
|
|
413
|
+
// varTpl ignores #{} and fnTpl ignores ${}
|
|
414
|
+
assert.equal(varTpl("${x} #{fn}", { x: "hi" }), "hi #{fn}")
|
|
415
|
+
assert.equal(fnTpl("${x} #{fn}", { fn: () => "called" }), "${x} called")
|
|
416
|
+
})
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
// ─── Error messages ─────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
describe("error messages", () => {
|
|
422
|
+
const tpl = Tpl()
|
|
423
|
+
|
|
424
|
+
it("includes the variable path in the error", () => {
|
|
425
|
+
assert.throws(
|
|
426
|
+
() => tpl("${user.name}", { user: {} }),
|
|
427
|
+
err => err.includes("name") && err.includes("${user.name}")
|
|
428
|
+
)
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it("includes 'Missing function' for function mode errors", () => {
|
|
432
|
+
const fnTpl = Tpl({ functions: true })
|
|
433
|
+
assert.throws(
|
|
434
|
+
() => fnTpl("#{nope}", {}),
|
|
435
|
+
err => err.includes("Missing function")
|
|
436
|
+
)
|
|
437
|
+
})
|
|
438
|
+
})
|