nano-var-template
v2.4.1
Published
The smallest and most robust safe variable template engine for JavaScript
Maintainers
Readme
nano-var-template
The smallest safe variable template engine with N-pass composition.
No eval. No new Function. No ES6 backtick injection. Linear-time, no-backtracking scanning — safe for untrusted templates. (Untrusted data has its own rules; see the Security model.)
Why this exists
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.
This gives you layered abstraction:
Pass 1 ${} Raw data → ${user.name} → "Jane"
Pass 2 #{} Functions → #{avatar:jane.png} → "<img src='jane.png' />"
Pass 3 @{} User references → @{42} → "Jane Doe, Admin"
Pass N ~{} Whatever you need nextEach 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.
This is the core idea. The package is small because it's finished, not because it's trivial.
Install
npm install nano-var-templateQuick start
const tpl = require('nano-var-template')()
tpl("Hello ${name}!", { name: "Jane" })
// → "Hello Jane!"Variable substitution
Supports full nested paths:
const tpl = require('nano-var-template')()
const template = "Welcome to ${app}. You are ${person.name.first} ${person.name.last}!"
const data = {
app: "Super App",
person: {
name: { first: "Jane", last: "Doe" }
}
}
tpl(template, data)
// → "Welcome to Super App. You are Jane Doe!"Custom delimiters
// Vue/Angular style
const tpl = require('nano-var-template')({ start: '{{', end: '}}' })
tpl("Hello {{name}}!", { name: "Jane" })
// → "Hello Jane!"
// Anything you want
const tpl2 = require('nano-var-template')({ start: '@#[', end: ']#' })
tpl2("Hello @#[name]#!", { name: "Jane" })
// → "Hello Jane!"Functions (plugins)
Enable function mode to call named functions from templates. Everything after : is passed as the argument string:
const tpl = require('nano-var-template')({ functions: true })
const plugins = {
upper: s => s.toUpperCase(),
greet: name => `Welcome, ${name}!`,
badge: type => `<span class="badge badge-${type}">${type}</span>`
}
tpl("#{upper:hello}", plugins)
// → "HELLO"
tpl("#{greet:Jane}", plugins)
// → "Welcome, Jane!"
tpl("#{badge:admin}", plugins)
// → '<span class="badge badge-admin">admin</span>'Functions can be as complex as you need — any JavaScript function works. Split multiple arguments yourself:
const plugins = {
link: args => {
const [url, text] = args.split(',')
return `<a href="${url.trim()}">${text.trim()}</a>`
}
}
tpl("#{link:https://example.com, Click here}", plugins)
// → '<a href="https://example.com">Click here</a>'The argument is passed verbatim — everything between the first : and the
closing delimiter, whitespace included. The one thing an argument cannot
contain is the closing delimiter itself: #{parse:{"a":1}} ends at the first
}. If your arguments need }, give that pass a different closing delimiter
(), ], ]], ...) — the same rule as choosing delimiters between passes.
Namespaced plugins
Dotted names walk the plugin object, so related plugins can live under one
namespace. this is preserved, so methods can share state with their
namespace:
const tpl = require('nano-var-template')({ functions: true })
const plugins = {
format: {
date: s => `DATE(${s})`,
money: s => `$${s}`
}
}
tpl("#{format.date:2026} costs #{format.money:5}", plugins)
// → "DATE(2026) costs $5"Async functions
A plugin function can return a Promise — useful for i18n lookups, database reads, or any async work. When every function invoked by a template resolves synchronously, tpl() returns a plain string, exactly as before — nothing changes for existing sync-only usage. But if any invoked function returns a Promise, tpl() returns Promise<string> instead, resolving once every call has settled:
const tpl = require('nano-var-template')({ functions: true })
const plugins = {
userName: id => db.users.findById(id).then(u => u.name)
}
const result = tpl("Hi #{userName:42}!", plugins)
// result is a Promise<string> here, because userName() returned one
await result
// → "Hi Jane Doe!"Since you can't know in advance whether a given plugin set will resolve sync or async, treat the return value as possibly a Promise whenever any of your registered functions might be async: await Promise.resolve(tpl(...)) works either way.
N-pass composition
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:
Two-pass: variables then functions
const Tpl = require('nano-var-template')
const varTpl = Tpl()
const fnTpl = Tpl({ functions: true })
const template = "Hello #{greet:${name}}!"
const data = { name: "Jane" }
const plugins = { greet: name => `Welcome, ${name}` }
// Pass 1: resolve ${} variables
const pass1 = varTpl(template, data)
// → "Hello #{greet:Jane}!"
// Pass 2: resolve #{} functions (now with resolved data)
const pass2 = fnTpl(pass1, plugins)
// → "Hello Welcome, Jane!"Three-pass: variables, functions, and user references
const Tpl = require('nano-var-template')
const varTpl = Tpl()
const fnTpl = Tpl({ functions: true })
const userTpl = Tpl({ start: '@{', end: '}' })
const template = "Hi @{${user.id}}! Avatar: #{avatar:${user.avatar}}"
const data = { user: { id: '42', avatar: 'cat.png' } }
const users = { 42: 'Jane Doe' }
const plugins = { avatar: src => `<img src="${src}" />` }
const result = userTpl(fnTpl(varTpl(template, data), plugins), users)
// → 'Hi Jane Doe! Avatar: <img src="cat.png" />'N-pass: as many layers as you need
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.
const Tpl = require('nano-var-template')
const dataTpl = Tpl() // ${}
const tagTpl = Tpl({ functions: true }) // #{}
const wrapTpl = Tpl({ start: '@{', end: '}', functions: true }) // @{}
const frameTpl = Tpl({ start: '~(', end: ')' }) // ~()
const template = "~(before)@{wrap:#{tag:${word}}}~(after)"
// Each pass's argument can contain any characters (URLs, punctuation, even
// other passes' delimiters) - the only rule (see below) is that a pass's own
// output must not accidentally form markers a LATER pass will interpret.
let result = template
result = dataTpl(result, { word: "hello" }) // → "~(before)@{wrap:#{tag:hello}}~(after)"
result = tagTpl(result, { tag: w => w.toUpperCase() }) // → "~(before)@{wrap:HELLO}~(after)"
result = wrapTpl(result, { wrap: s => `[${s}]` }) // → "~(before)[HELLO]~(after)"
result = frameTpl(result, { before: ">>>", after: "<<<" }) // → ">>>[HELLO]<<<"Escaping
To output template syntax literally, put a backslash immediately before the start delimiter. Backslash pairs collapse (C-style parity), and backslashes anywhere else in a template are not special at all:
const tpl = require('nano-var-template')()
tpl("\\${name}", { name: "Jane" }) // → "${name}" (literal, not substituted)
tpl("\\\\${name}", { name: "Jane" }) // → "\\Jane" (literal backslash + value)
tpl("C:\\path ${name}", { name: "J" }) // → "C:\\path J" (stray backslashes untouched)(The doubled backslashes above are JavaScript string escapes — in a template
file you'd write \${name} and \\${name}.)
This works in both modes and with any custom delimiters: \{{name}},
\#{fn:arg}, etc. Escapes are consumed by the pass that owns that delimiter,
so an escaped ${ survives to render as text even when later passes run.
The parity rule applies to backslashes before every top-level occurrence
of the start delimiter, whether or not what follows parses as a tag —
\${not a tag renders as ${not a tag just like \${name} renders as
${name}. (Inside a function argument, everything is verbatim, backslashes
included — #{echo:\#{x}} passes the plugin the literal \#{x.)
tpl.escape(value)
Every instance carries an escape helper that neutralizes that instance's
start delimiter inside a value, so a delimiter the value contains can't
start a tag in that pass:
const fnTpl = require('nano-var-template')({ functions: true })
fnTpl.escape("#{setRole:admin}") // → "\\#{setRole:admin}"
fnTpl(fnTpl.escape("#{setRole:admin}") + " #{greet:Jo}", {
setRole: () => { throw new Error("never runs") },
greet: n => `hi ${n}`
}) // → "#{setRole:admin} hi Jo"escape() is best-effort defense-in-depth, not a complete sandbox. It has
three limits, all inherent to escaping a value without seeing its context:
- It only neutralizes complete delimiters. A value ending in a partial
delimiter fuses with adjacent text into a tag
escape()never saw:escape("100#")is"100#", and"100#" + "{setRole:x}"becomes a live#{setRole:x}. Two escaped-but-adjacent untrusted values can do this across their junction. - It only guards the start delimiter, not the end. Untrusted data placed
inside a hand-authored tag's argument (
#{fn:<data>}) can still inject a closing delimiter and truncate the tag. - Each pass needs its own escape, applied in nesting order:
p3.escape(p2.escape(value))for data entering before passes 2 and 3. A trailing-backslash value may also gain backslashes if it isn't immediately followed by that pass's delimiter.
For untrusted data, prefer the airtight approach in the Security model below
(inject it only in the last / a non-interpreted pass); reach for escape()
only when data genuinely must enter an earlier pass, and only for the
plugin-invocation channel.
Error handling
By default, missing variables throw descriptive errors:
const tpl = require('nano-var-template')()
tpl("Hello ${user.name}!", { user: {} })
// throws: "nano-var-template: 'name' missing in ${user.name}"Set warn: false to silently leave unresolved tokens in place:
const tpl = require('nano-var-template')({ warn: false })
tpl("Hello ${name}!", {})
// → "Hello ${name}!"Errors are real Error instances (err.message, err instanceof Error), not strings.
Security model
What "safe" means here, precisely:
- No code execution from templates. No
eval, nonew Function, no ES6 backtick interpolation. A template can only reference names; the only code that runs is the plugin functions you registered — plus any getters on the data object you pass in, which are read during lookup (a throwing getter throws through, even withwarn: false, since it's your own code). - Prototype internals are unreachable. Names that would resolve to
Object.prototype's own members —constructor,__proto__,toString,hasOwnProperty, etc. — resolve as missing in both modes, as doFunction.prototype's (call,apply,bind,caller) when the holder is a function. A template can never read engine internals or invoke something you didn't put there. The one exception is deliberate: an object's own key always wins, even one namedtoString— your own data (real-world JSON has such keys) is never blocked. Everything else on a prototype chain works normally: class getters, class methods used as plugins, andObject.create()layering all resolve as you'd expect. - Malformed input is linear-time. Template scanning does not backtrack, so a pathological or adversarial template (e.g. hundreds of KB of unclosed tags) costs milliseconds, not CPU-pinning seconds.
- No HTML escaping. Values are interpolated as-is. If you template into HTML with untrusted data (as opposed to untrusted templates), escape in your plugins or before passing data in — that's your layer, by design.
warncovers missing names, not malformed tags. An unclosed tag ("hi ${name") or an invalid name (#{not a name}) is not a tag at all — it passes through as literal text without throwing, even withwarn: true.
Untrusted data in multi-pass pipelines
Within a single pass, substituted values are never re-scanned — a variable
that resolves to the string "${admin}" stays literal text. Between passes
there is no such guarantee: whatever pass N writes into the string is markup
for pass N+1, including substituted data. If untrusted data flows through
an early pass and a later pass runs in function mode, that data can invoke
any plugin registered in the later pass, with arguments of its choosing:
const Tpl = require('nano-var-template')
const varTpl = Tpl() // pass 1: ${...}
const fnTpl = Tpl({ functions: true }) // pass 2: #{...}
const userInput = "#{setRole:admin}" // attacker-controlled data
const pass1 = varTpl("Hi ${name}", { name: userInput })
// → "Hi #{setRole:admin}" — now live markup for pass 2The airtight mitigation is ordering: inject untrusted data only in the last pass, or in a pass whose delimiters no later pass interprets. Then there is no downstream pass to weaponize it, and no escaping is needed:
// Resolve all function passes first, THEN drop untrusted data in a final
// variable pass. Nothing runs after it, so it can contain anything.
let out = fnTpl("Role: #{lookupRole:current}", { lookupRole: () => "guest" })
out = varTpl(out + " — note: ${note}", { note: userInput }) // safe: last passIf data genuinely must enter before a later function-mode pass, tpl.escape
is a partial defense for the plugin-invocation channel — escape once per later
pass, in nesting order:
// Untrusted data entering pass 1, ahead of function passes 2 (fnTpl) and 3 (atTpl):
const guarded = atTpl.escape(fnTpl.escape(userInput))
const safe = varTpl("Hi ${name}", { name: guarded })Read the tpl.escape limits above before relying on it — it does not cover a
value ending in a partial delimiter, nor data landing inside a tag's argument.
When in doubt, order the passes instead.
Options
const tpl = require('nano-var-template')({
start: '${', // Opening delimiter. Default '${' (variable mode) / '#{'
// (function mode). Use a non-empty, non-whitespace literal.
end: '}', // Closing delimiter (any string)
functions: false, // true = function mode (data object contains functions, not values)
path: '[a-zA-Z0-9_$][\\.a-zA-Z0-9_$]*', // Regex for allowed variable paths
// (in function mode: validates the function
// name, i.e. the part before the first ':').
// This is spliced into the matcher as RAW
// regex — an escape hatch. A careless custom
// pattern can reintroduce backtracking cost
// or interact with the escaping groups.
warn: true // true = throw on missing variables, false = leave token unchanged
})Design notes
Why is this package so small? Each pass is one well-defined operation: scan for this pass's markers → look up or invoke → substitute. Variable mode is a single String.replace; function mode is a linear indexOf scanner (no backtracking, so untrusted templates stay linear-time). The power comes from composing multiple instances, not from framework complexity.
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.
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.
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.
License
MIT
