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 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
- This engine even (optionally) throws descriptive errors if you type a variable path wrong. Catch or route them as you see fit.
3
+ The smallest safe variable template engine with N-pass composition.
5
4
 
6
- This template engine only handles variable injection into strings, just like ES6 backtick strings, but usable anywhere in your code. Designed specifically for userland. Let your users define i18n strings, etc. without worry that they'll hack you.
5
+ No `eval`. No `new Function`. No ES6 backtick injection. Just `String.replace()` with a configurable regex safe for userland input.
7
6
 
8
- Supports full paths in variables, not just shallow variables. i.e. user.data.name.first
7
+ ## Why this exists
9
8
 
10
- 805 bytes uncompressed source with comments.
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 is written in ES6 for Node and modern tools like Webpack. The code is so small and simple, feel free to refactor it for the browser or back to ES5 for IE11 / etc.
11
+ This gives you layered abstraction:
14
12
 
15
- Pull requests are always welcome.
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
- ## Install:
22
+ **This is the core idea.** The package is small because it's finished, not because it's trivial.
19
23
 
20
- NPM
21
- ```
22
- npm install nano-var-template
23
- ```
24
+ ## Install
24
25
 
25
- Yarn
26
26
  ```
27
- yarn add nano-var-template
27
+ npm install nano-var-template
28
28
  ```
29
29
 
30
- ## Usage:
30
+ ## Quick start
31
31
 
32
- ### Basic
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
- Output: Hello Jane Doe!
35
+ tpl("Hello ${name}!", { name: "Jane" })
36
+ // → "Hello Jane!"
40
37
  ```
41
38
 
42
- ### More practical
43
- ```
39
+ ## Variable substitution
40
+
41
+ Supports full nested paths:
42
+
43
+ ```js
44
44
  const tpl = require('nano-var-template')()
45
45
 
46
- let template = "Welcome to {{app}}. You are {{person.name.first}} {{person.name.last}}!"
47
-
48
- let data = {
49
- app: "Super App",
50
- person: {
51
- name: {
52
- first: "Jane",
53
- last: "Doe"
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
- Output: Welcome to Super App. You are Jane Doe!
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
- ### Change defaults to make it feel like ES6
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
- const tpl = require('nano-var-template')( {start: '${', end: '}'} )
68
- console.log(tpl("Hello ${user}!", {user: "Jane Doe"}))
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
- Output: Hello Jane Doe!
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
- ### Make a mistake and get easy to understand debug errors
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
- console.log(tpl("Hello ${user.name}!", {user: "Jane Doe"}))
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
- Output: Error: nano-var-template: 'name' missing in ${user.name}
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
- // You can pass options to replace allowed variables, and the start and end brackets...
2
- module.exports = (options = { start: "{{", end: "}}", path: "[a-z0-9_$][\\.a-z0-9_]*", warn: true}) => {
3
- const match = new RegExp(options.start + "\\s*(" + options.path + ")\\s*" + options.end, "gi")
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 strings...
25
+ // Merge the passed data into the template string we're sending back...
6
26
  return template.replace(match, (tag, token) => {
7
- let path = token.split("."), lookup = data
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 && options.warn) throw "nano-var-template: '" + path[i] + "' missing in " + tag
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": "1.0.0",
4
- "description": "The smallest *safe* variable template engine for Javascript",
5
- "main": "index.js",
6
- "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
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
- "author": "NIck Steele <njsteele@gmail.com>",
25
- "license": "MIT",
26
- "bugs": {
27
- "url": "https://github.com/onexdata/nano-var-template/issues"
28
- },
29
- "homepage": "https://github.com/onexdata/nano-var-template#readme"
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
+ })