npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

nano-var-template

v2.4.1

Published

The smallest and most robust safe variable template engine for JavaScript

Readme

nano-var-template

CI npm version

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 next

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.

This is the core idea. The package is small because it's finished, not because it's trivial.

Install

npm install nano-var-template

Quick 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, no new 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 with warn: 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 do Function.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 named toString — 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, and Object.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.
  • warn covers 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 with warn: 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 2

The 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 pass

If 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