Build Your Own Template Engine
EJS, Handlebars, Pug and Nunjucks all do the same three things: tokenize, parse, compile. Build a real one — loops, conditionals, partials, escaping by default — and see exactly where HTML escaping stops protecting you.
Build Your Own Template Engine
EJS, Handlebars, Pug, Nunjucks — every Node.js framework ships with a template engine choice. They all do roughly the same thing: take a string full of {{ placeholders }}, merge it with data, and spit out HTML. But the how is what separates a 5-line string-replace hack from something that runs at thousands of requests per second without re-parsing the same template repeatedly.
We're building a real one: variable interpolation, conditionals, loops, partials, HTML escaping by default. About 160 lines of JavaScript, no dependencies.
The Architecture
Three phases. Every template engine uses them.
Tokenize: scan the template string and split it into a flat list of tokens — raw text chunks, {{ }} expression tags, block opens and closes.
Parse: fold that flat list into a tree (AST). An {{#if}} token owns everything up to {{/if}} as its children.
Compile: walk the AST and emit a JavaScript function as a string, then new Function(code) it into an actual callable. Cache the result.
The important thing here is compile-once, render-many. Tokenizing and parsing are expensive string operations. You pay that cost exactly once per unique template string, then the cached function handles every subsequent render. This is why Handlebars's precompile step is such a win for production apps — the parse phase doesn't even happen at runtime.
Step 1: The Tokenizer
We need to chop the template string into tokens. Two types: raw TEXT and {{ }} tag expressions.
Tokenize the template string
// engine.js
const TAG_RE = /\{\{(.*?)\}\}/gs;
function tokenize(template) {
const tokens = [];
let pos = 0;
let match;
TAG_RE.lastIndex = 0; // never trust a shared /g regex's lastIndex
while ((match = TAG_RE.exec(template)) !== null) {
if (match.index > pos) {
tokens.push({ type: 'TEXT', value: template.slice(pos, match.index) });
}
tokens.push(classifyTag(match[1].trim()));
pos = TAG_RE.lastIndex;
}
if (pos < template.length) {
tokens.push({ type: 'TEXT', value: template.slice(pos) });
}
return tokens;
}
// A path is a dot-separated chain of keys. These characters make a tag either
// malformed or ambiguous, and every one of them used to render silently as ''.
// This is a syntax check for error quality, not a security boundary — the
// security boundary is JSON.stringify in the code generator.
const INVALID_IN_PATH = /[{}()[\]"'`;,\\/#\s]/;
function checkPath(raw, tag) {
if (raw === '') throw new SyntaxError('Empty tag: {{}}');
if (INVALID_IN_PATH.test(raw)) {
const hint = raw.startsWith('{')
? " — there is no {{{triple-stash}}} here; use {{& path }} for raw output"
: '';
throw new SyntaxError(`Invalid path in ${tag}: ${JSON.stringify(raw)}${hint}`);
}
return raw;
}
function classifyTag(raw) {
if (raw.startsWith('#if ')) return { type: 'IF_OPEN', expr: checkPath(raw.slice(4).trim(), '{{#if}}') };
if (raw.startsWith('#each ')) return { type: 'EACH_OPEN', expr: checkPath(raw.slice(6).trim(), '{{#each}}') };
if (raw === '/if') return { type: 'IF_CLOSE' };
if (raw === '/each') return { type: 'EACH_CLOSE' };
// {{else}} would otherwise be an ordinary lookup for a key called 'else':
// undefined, rendered as ''. Both branches of the if would then emit.
if (raw === 'else' || raw === '#else' || raw === '/else') {
throw new SyntaxError('{{else}} is not supported by this engine — see "What We Skipped"');
}
if (raw.startsWith('>')) return { type: 'PARTIAL', name: raw.slice(1).trim() };
if (raw.startsWith('!')) return { type: 'COMMENT' };
if (raw.startsWith('&')) return { type: 'EXPR', path: checkPath(raw.slice(1).trim(), '{{& }}'), escape: false };
return { type: 'EXPR', path: checkPath(raw, '{{ }}'), escape: true };
}TAG_RE matches everything between {{ and }}. The g flag enables repeated exec calls; s lets . match newlines. Each match gives us the tag content in match[1].
classifyTag is a prefix dispatch table with one rule that isn't about dispatch: anything it can't classify has to be an error, not a lookup. The fall-through case turns an unrecognised tag into a variable lookup, and a lookup that misses renders as an empty string, so every typo and every piece of syntax this engine doesn't have becomes silent wrong output. Three specimens, all of which this engine used to accept:
| Template | Without checkPath | With it |
|---|---|---|
{{{x}}} | renders "}" | SyntaxError: Invalid path in {{ }}: "{x" — there is no {{{triple-stash}}} here; use {{& path }} for raw output |
{{#if a}}Y{{else}}N{{/if}} | renders "YN" — both branches | SyntaxError: {{else}} is not supported by this engine |
a{{/foo}} | renders "a" | SyntaxError: Invalid path in {{ }}: "/foo" |
{{{x}}} is worth staring at. TAG_RE is greedy-free (.*?), so it matches the shortest thing between the first {{ and the first }} — which is {x. That becomes a lookup for a key literally named {x, which misses, which renders empty; the leftover } becomes a text token. Output: one closing brace. Anyone arriving from Handlebars or Mustache will type that on their first try.
The & prefix (borrowed from Mustache) is the opt-in unsafe raw output — more on why that matters when we get to escaping. Note that checkPath is a syntax check, for error quality. It is not what makes the engine injection-safe; that job belongs to JSON.stringify in the code generator, and it is tested separately.
TAG_RE is a module-level regex with the g flag, which means it carries mutable lastIndex state between calls. Running the loop to exhaustion resets it to 0, so for a long time the lastIndex = 0 line was insurance against nothing — there was no path out of that loop except exhaustion. checkPath changes that: classifyTag now throws, from inside the loop, leaving lastIndex pointing into the middle of the template that failed. Two renders, in one process, prove the cost:
const engine = new TemplateEngine();
try { engine.render('aaaa{{ ok }}bbbb{{ {bad }}', { ok: 'X' }); } catch {}
engine.render('Hi {{name}}!', { name: 'Al' });
// with TAG_RE.lastIndex = 0 → 'Hi Al!'
// without TAG_RE.lastIndex = 0 → 'Hi {{name}}!'The second template is fine. It renders its own source because the scan started 21 characters in and never saw the tag. Nothing throws, nothing logs, and the bug is in a different template from the one that caused it.
Step 2: The Parser
The token stream is flat. The AST needs to be nested. The parser's job is to fold IF_OPEN ... IF_CLOSE into a single If node with a body array.
This is recursive descent. parseNodes calls parseNode; parseNode calls parseNodes for block tags. They bottom out when all tokens are consumed.
Build the AST with recursive descent
function parse(tokens) {
let pos = 0;
function parseNodes(closeType) {
const nodes = [];
while (pos < tokens.length) {
const tok = tokens[pos];
if (tok.type === closeType) {
pos++; // consume the closing tag
return nodes;
}
nodes.push(parseNode());
}
if (closeType) throw new Error(`Unclosed block — expected ${closeType}`);
return nodes;
}
function parseNode() {
const tok = tokens[pos++];
switch (tok.type) {
case 'TEXT': return { type: 'Text', value: tok.value };
case 'EXPR': return { type: 'Expr', path: tok.path, escape: tok.escape };
case 'COMMENT': return { type: 'Comment' };
case 'PARTIAL': return { type: 'Partial', name: tok.name };
case 'IF_OPEN': return { type: 'If', cond: tok.expr, body: parseNodes('IF_CLOSE') };
case 'EACH_OPEN': return { type: 'Each', iter: tok.expr, body: parseNodes('EACH_CLOSE') };
default: throw new Error(`Unexpected token: ${tok.type}`);
}
}
return parseNodes(null);
}Trace through {{#if active}}online{{/if}}:
- Tokens:
[IF_OPEN(active), TEXT(online), IF_CLOSE] parseNodes(null)→ callsparseNode()parseNode()seesIF_OPEN, callsparseNodes('IF_CLOSE')parseNodes('IF_CLOSE')collects[Text('online')], hitsIF_CLOSE, incrementspos, returns- Result:
If { cond: 'active', body: [Text('online')] }
Nested blocks work because each recursive parseNodes call has its own closeType, so inner blocks are consumed before the outer one gets a chance to scan past them.
Step 3: Runtime Utilities
Before generating code, we need two functions that the compiled template will call at runtime: a path resolver for dot-notation lookups, and an HTML escape function.
Path resolution and HTML escaping
// Inside an #each block the current item is stashed on the context object
// under this symbol, so `{{ . }}` can find it without colliding with a
// real data key called '.'.
const CURRENT = Symbol('current');
// Path segments that would let a template walk out of the data and into the
// prototype chain — `{{ constructor.constructor }}` reaches Function.
const BLOCKED = new Set(['__proto__', 'constructor', 'prototype']);
// `in` throws on a primitive, and the whole point of CURRENT is to make
// {{ . }} work for primitives. Test for an object before using it.
const isObject = (v) => v !== null && (typeof v === 'object' || typeof v === 'function');
// Resolves dot-notation paths against a data context.
// 'user.address.city' → data.user.address.city
function resolve(data, path) {
if (path === '.' || path === 'this') {
return isObject(data) && CURRENT in data ? data[CURRENT] : data;
}
if (path.startsWith('@')) return data?.[path]; // loop variables: @index, @first, @last
const parts = path.split('.');
let val = data;
for (const part of parts) {
if (val == null) return undefined;
if (BLOCKED.has(part)) return undefined;
val = val[part];
}
return val;
}
// #each only means anything over a list. Fail with a message that names the
// tag instead of "(intermediate value).forEach is not a function".
function toList(value, iterName) {
if (value == null) return [];
if (Array.isArray(value)) return value;
// A string IS iterable, so the check below would happily walk it one
// character at a time. That is never what {{#each}} means.
if (typeof value === 'string') {
throw new TypeError(
`{{#each ${iterName}}} got a string. Iterating a string character by ` +
`character is almost certainly not what you meant — wrap it in an array if it is.`
);
}
if (typeof value[Symbol.iterator] === 'function') return Array.from(value);
throw new TypeError(
`{{#each ${iterName}}} expects an array or iterable, got ${typeof value}`
);
}
// Escapes HTML to prevent XSS. Every {{ expr }} passes through this.
const ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
function escape(val) {
if (val == null) return '';
return String(val).replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]);
}resolve handles missing paths gracefully — if user is undefined, user.address.city returns undefined rather than throwing a TypeError. Missing keys render as an empty string, not "undefined".
The isObject guard on the CURRENT branch is the fiddly one, and the mistake to avoid is writing data != null && CURRENT in data. in is an object operator: Symbol() in 'hello' is a TypeError: Cannot use 'in' operator to search for 'Symbol(current)' in hello, and 'hello' != null is true, so that version passes a string straight into in and throws. Which is a particular embarrassment here, because the entire reason the CURRENT symbol exists rather than a plain '.' key is to make {{ . }} work when the item is a primitive. render('{{.}}', 'hello') — the simplest use of the feature — threw a TypeError. isObject(data) is the guard the code meant to have.
toList has the same shape of bug in the other direction. A string satisfies typeof value[Symbol.iterator] === 'function', so {{#each name}} over "abc" iterated three characters and rendered them, cheerfully, instead of reaching the error the function was written to produce. Strings are the one iterable a template never means to iterate, so they get their own branch and their own message.
The escape function is why {{ userInput }} is safe in element text. Given { name: '<script>alert(1)</script>' }, the template <p>{{ name }}</p> renders exactly:
<p><script>alert(1)</script></p>Five characters, no more: &, <, >, ", '. The parentheses are still parentheses — they don't need escaping, because the payload can no longer open a tag. You bypass all of this with {{& trustedHtml }}, which is why that syntax is deliberately ugly.
Escaping five characters makes a value safe in element text and in a quoted attribute. It does not make it safe everywhere. Three contexts this engine cannot protect:
- Unquoted attributes.
<div class={{ cls }}>withcls = 'a onmouseover=alert(1)'renders<div class=a onmouseover=alert(1)>— a live event handler, because spaces and=aren't escaped and don't need to be if you quote your attributes. Always quote them. - Inside
<script>or<style>. HTML escaping is the wrong escaping there.<is not an escape sequence in JavaScript. - URL positions.
<a href="{{ url }}">withurl = 'javascript:alert(1)'passes through untouched — every character is already HTML-safe.
Real engines solve this with context-aware escaping (Go's html/template does it by parsing the surrounding HTML). A single escape() cannot.
Step 4: The Code Generator
This is the interesting part. We walk the AST and emit JavaScript as a string.
Generate JavaScript from the AST
function generateCode(ast) {
const lines = ['let __out = "";'];
function emit(nodes) {
for (const node of nodes) {
switch (node.type) {
case 'Text':
lines.push(`__out += ${JSON.stringify(node.value)};`);
break;
case 'Comment':
break; // discard
case 'Expr': {
const val = `__resolve(__data, ${JSON.stringify(node.path)})`;
lines.push(`__out += ${node.escape ? `__escape(${val})` : `(${val} ?? '')`};`);
break;
}
case 'Partial':
lines.push(`__out += __partial(${JSON.stringify(node.name)}, __data);`);
break;
case 'If':
lines.push(`if (__resolve(__data, ${JSON.stringify(node.cond)})) {`);
emit(node.body);
lines.push('}');
break;
case 'Each': {
const iter = JSON.stringify(node.iter);
// __parent has to be captured *outside* the callback — see below.
lines.push(`{ const __parent = __data;`);
lines.push(
`__toList(__resolve(__parent, ${iter}), ${iter})` +
`.forEach(function(__item, __i, __arr) {`
);
lines.push(
` const __data = Object.assign({}, __parent,` +
` { '@index': __i, '@first': __i === 0,` +
` '@last': __i === __arr.length - 1 },` +
` __item !== null && typeof __item === 'object' ? __item : {});`
);
lines.push(` __data[__CURRENT] = __item;`);
emit(node.body);
lines.push('}); }');
break;
}
}
}
}
emit(ast);
lines.push('return __out;');
return lines.join('\n');
}Let me trace what {{#each users}}{{ name }}{{/each}} compiles to:
let __out = "";
{ const __parent = __data;
__toList(__resolve(__parent, "users"), "users").forEach(function(__item, __i, __arr) {
const __data = Object.assign({}, __parent, { '@index': __i, '@first': __i === 0, '@last': __i === __arr.length - 1 }, __item !== null && typeof __item === 'object' ? __item : {});
__data[__CURRENT] = __item;
__out += __escape(__resolve(__data, "name"));
}); }
return __out;Three things about that generated code are load-bearing.
__parent exists because of the temporal dead zone. The obvious version is const __data = Object.assign({}, __data, ...) — read the parent, shadow it. That throws ReferenceError: Cannot access '__data' before initialization, because the inner const __data binding covers the entire callback body including its own initialiser. You have to copy the parent into a differently-named binding before the forEach, which is what the extra { const __parent = __data; block is for. The block also keeps sibling #each tags from redeclaring the same name in one scope.
The parent context is merged in, so outer keys stay visible. {{#each members}}...{{ total }}{{/each}} finds total on the root data. Without the merge, every key that isn't a property of the current item silently renders as an empty string — the worst kind of template bug, because nothing throws and the page just quietly loses a number. Precedence runs left to right in Object.assign: parent, then loop variables, then the item's own properties, so the item wins on a name collision.
This is a flattened context, not a context stack. There is no ../ to reach a shadowed parent key, and a 1000-item loop copies the parent's keys 1000 times. Handlebars keeps a real frame stack for exactly these reasons.
__data[__CURRENT] = __item is how {{ . }} and {{ this }} find the item. A plain key like '.' would work for objects but is unreachable for a primitive: resolve special-cases the path '.' before it ever indexes the context. The symbol gives resolve something to look for that no user data can collide with, which is what makes {{#each tags}}{{ . }}{{/each}} over ['js', 'node'] print jsnode rather than [object Object][object Object].
Every JSON.stringify in generateCode is a security control, not a formatting nicety. The generator is building a string that goes to new Function, so anything it interpolates unquoted becomes code. There are five of them — node.path, node.value, node.cond, node.iter, node.name — and each one embeds an AST field as a quoted literal, so a path like {{ a"+__out+"b }} compiles to a lookup for a weirdly-named key rather than to an expression.
Change one of them to a template literal and the engine becomes a remote code execution primitive:
// generateCode, with JSON.stringify(node.path) replaced by "${node.path}"
new TemplateEngine().render('{{ x"), globalThis.PWNED = 1, ("y }}', {});
// globalThis.PWNED === 1The compiled body for that is __out += __escape(__resolve(__data, "x"), globalThis.PWNED = 1, ("y")); — syntactically valid, so nothing complains. And here is the part worth internalising: that mutation passed every one of the engine's 22 assertions. All of them exercised well-formed templates, so none of them ever looked at what happens to a hostile one. The tests at the end of the article now pin each of the five JSON.stringify calls directly, by handing generateCode an adversarial AST and asserting that the path arrives at __resolve as a single literal string and that nothing executed.
Two layers, worth keeping straight: checkPath in the tokenizer refuses that template before codegen sees it, so the exploit above needs both a bad generateCode and a bypass of checkPath. But generateCode is exported and the AST is a public-ish structure, so it must be safe on its own. Defence in depth means each layer is tested as if it were the only one.
Step 5: Compilation and Caching
Now we assemble the engine. The compile method turns a template string into a cached render function. render is the one-call shortcut.
The TemplateEngine class
class TemplateEngine {
#cache = new Map();
#partials = new Map();
registerPartial(name, template) {
this.#partials.set(name, template);
return this; // chainable
}
compile(template) {
if (this.#cache.has(template)) return this.#cache.get(template);
const tokens = tokenize(template);
const ast = parse(tokens);
const code = generateCode(ast);
// new Function creates a function from a string.
// Arguments: parameter names, then the function body.
const compiled = new Function(
'__data', '__resolve', '__escape', '__partial', '__toList', '__CURRENT', code);
const self = this;
const renderFn = (data) =>
compiled(
data,
resolve,
escape,
(name, ctx) => {
// Silently rendering '' for an unregistered partial hides typos
// for as long as the template lives.
if (!self.#partials.has(name)) throw new Error(`Unknown partial: ${name}`);
return self.render(self.#partials.get(name), ctx);
},
toList,
CURRENT
);
this.#cache.set(template, renderFn);
return renderFn;
}
render(template, data = {}) {
return this.compile(template)(data);
}
}new Function('a', 'b', 'return a + b') is equivalent to function(a, b) { return a + b }. It's eval with function scope rather than local scope — the generated code can only see the parameters you pass it, not the variables around the new Function call. That containment is real but narrow.
new Function compiles a string into executable code, so this engine must only ever compile templates you control. Template text from a user, a database row an editor can write to, or a CMS field is code execution, not a formatting concern. The codegen here does escape everything that comes out of the AST, which is why {{ a"+__out+"b }} is harmless — but "I read the codegen and it looked airtight" is a much weaker guarantee than "attacker-supplied templates never reach compile()". Draw the line at the input, not at the escaping.
The __partial helper recursively calls this.render with the current context. Partials are just templates — nothing special structurally, just looked up by name.
The cache key is the template string itself. Same string → same compiled function, no re-parsing. If you're dynamically building template strings (please don't), you'll get cache pollution. Use static template strings.
Testing It
Run the demo
// demo.js
const engine = new TemplateEngine();
engine.registerPartial('user-card', `
<div class="card">
<strong>{{ name }}</strong>
<span>{{ role }}</span>
</div>`);
const template = `
<section>
{{! This comment won't appear in the output }}
{{#each members}}
<div class="row">
{{> user-card}}
{{#if @first}}<span class="badge">First!</span>{{/if}}
<small>Item {{ @index }} of {{ total }}</small>
</div>
{{/each}}
{{#if hasFooter}}
<footer>{{ footer }}</footer>
{{/if}}
</section>`;
const html = engine.render(template, {
members: [
{ name: 'Alice', role: 'Admin' },
{ name: 'Bob', role: 'Member' },
],
total: 2,
hasFooter: true,
footer: '<b>Built with our engine</b>',
});
console.log(html);Run it: node demo.js
The {{ footer }} value contains raw HTML, but it'll render as escaped text:
<b>Built with our engine</b>. To render it unescaped: {{& footer }}.
That prints the two cards, the First! badge on Alice only, Item 0 of 2 / Item 1 of 2, and an escaped footer.
Eyeballing output stops working about ten minutes into a template engine, so here is a harness that actually fails:
// engine-tests.js
const { TemplateEngine, generateCode } = require('./engine');
// console.assert() logs and continues in Node — a broken engine would still
// reach the "all passed" line. Throw.
const assert = (cond, msg) => { if (!cond) throw new Error('FAILED: ' + msg); };
const eq = (actual, expected, msg) =>
assert(actual === expected, `${msg} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
const e = () => new TemplateEngine();
// interpolation and paths
eq(e().render('Hi {{name}}!', { name: 'Al' }), 'Hi Al!', 'simple interpolation');
eq(e().render('{{user.address.city}}', { user: { address: { city: 'Pune' } } }), 'Pune', 'nested path');
eq(e().render('[{{nope}}]', {}), '[]', 'missing key renders empty');
eq(e().render('[{{a.b.c.d}}]', {}), '[]', 'deep miss does not throw');
eq(e().render('[{{v}}]', { v: null }), '[]', 'null renders empty');
eq(e().render('[{{v}}]', { v: 0 }), '[0]', 'zero is not falsy-blanked');
// escaping
eq(e().render('{{x}}', { x: `<a href="x">&'` }), '<a href="x">&'', 'all five characters');
eq(e().render('<p>{{name}}</p>', { name: '<script>alert(1)</script>' }),
'<p><script>alert(1)</script></p>', 'script payload is inert');
eq(e().render('{{& x}}', { x: '<b>hi</b>' }), '<b>hi</b>', 'the & prefix opts out');
eq(e().render('[{{constructor.name}}]', {}), '[]', 'no walking the prototype chain');
// conditionals and loops
eq(e().render('{{#if a}}Y{{/if}}', { a: 1 }), 'Y', 'if true');
eq(e().render('{{#if a}}Y{{/if}}', { a: 0 }), '', 'if false');
eq(e().render('{{#each xs}}[{{.}}]{{/each}}', { xs: [1, 2] }), '[1][2]', '{{.}} over primitives');
eq(e().render('{{#each xs}}{{@index}}{{#if @last}}!{{/if}}{{/each}}', { xs: ['a', 'b'] }), '01!', 'loop variables');
eq(e().render('{{#each xs}}{{@index}}/{{total}} {{/each}}', { xs: ['a', 'b'], total: 2 }),
'0/2 1/2 ', 'parent scope is visible inside a loop');
eq(e().render('{{#each rows}}{{#each cells}}{{.}}{{/each}}|{{/each}}',
{ rows: [{ cells: [1, 2] }, { cells: [3] }] }), '12|3|', 'nested loops');
eq(e().render('{{#each nope}}x{{/each}}', {}), '', 'each over undefined is empty, not a crash');
// errors that should be errors
const throws = (fn, re, msg) => {
try { fn(); } catch (err) { assert(re.test(err.message), `${msg} — wrong message: ${err.message}`); return; }
throw new Error('FAILED: expected a throw — ' + msg);
};
throws(() => e().render('{{#if a}}Y', {}), /Unclosed block/, 'unclosed if');
throws(() => e().render('a{{/if}}', {}), /Unexpected token/, 'stray close tag');
throws(() => e().render('{{#each o}}x{{/each}}', { o: { a: 1 } }), /expects an array/, 'each over an object');
throws(() => e().render('{{> nope}}', {}), /Unknown partial/, 'unregistered partial');
// {{.}} and {{this}} against a primitive root context — the reason CURRENT exists
eq(e().render('{{.}}', 'hello'), 'hello', '{{.}} with a primitive root context');
eq(e().render('{{this}}', 'hi'), 'hi', '{{this}} with a primitive root context');
eq(e().render('{{.}}', 42), '42', '{{.}} with a number root context');
eq(e().render('{{.}}', '<b>'), '<b>', '{{.}} is still escaped');
eq(e().render('[{{.}}]', null), '[]', '{{.}} with a null context');
// Syntax this engine does not have must say so, not render something
throws(() => e().render('{{{x}}}', { x: 'v' }), /no \{\{\{triple-stash\}\}\}/, 'triple-stash is rejected');
throws(() => e().render('{{#if a}}Y{{else}}N{{/if}}', { a: 1 }), /\{\{else\}\} is not supported/, '{{else}} is rejected');
throws(() => e().render('{{#else}}', {}), /\{\{else\}\} is not supported/, '{{#else}} is rejected too');
throws(() => e().render('a{{/foo}}', {}), /Invalid path/, 'an unknown close tag is rejected');
throws(() => e().render('{{}}', {}), /Empty tag/, 'an empty tag is rejected');
throws(() => e().render('{{#each s}}x{{/each}}', { s: 'abc' }), /got a string/, 'each over a string is rejected');
eq(e().render('{{#each s}}[{{.}}]{{/each}}', { s: new Set([1, 2]) }), '[1][2]', 'each over a non-array iterable still works');
eq(e().render('{{data-id}}', { 'data-id': 7 }), '7', 'an unusual but harmless key still resolves');
// A tokenizer throw must not poison the shared regex's lastIndex
{
const engine = new TemplateEngine();
try { engine.render('aaaa{{ ok }}bbbb{{ {bad }}', { ok: 'X' }); } catch { /* expected */ }
eq(engine.render('Hi {{name}}!', { name: 'Al' }), 'Hi Al!',
'a throw mid-tokenize does not make the next template skip its first tag');
}
// The code generator must quote everything it embeds — including for an AST
// that did not come from tokenize(). This is the actual security boundary, and
// it needs testing on its own: checkPath would refuse these templates upstream.
{
const evil = 'x"), globalThis.PWNED = 1, ("y';
const run = (ast) => {
const paths = [];
const fn = new Function('__data', '__resolve', '__escape', '__partial', '__toList', '__CURRENT',
generateCode(ast));
delete globalThis.PWNED;
const out = fn({}, (d, p) => { paths.push(p); return undefined; }, (v) => (v ?? ''),
(n) => { paths.push(n); return ''; }, () => [], Symbol('c'));
return { out, paths, pwned: globalThis.PWNED };
};
const expr = run([{ type: 'Expr', path: evil, escape: true }]);
eq(expr.paths.length, 1, 'codegen: an Expr path produces exactly one lookup');
eq(expr.paths[0], evil, 'codegen: the path arrives at __resolve as one literal string');
assert(expr.pwned === undefined, 'codegen: nothing inside an Expr path executes');
const text = run([{ type: 'Text', value: '";globalThis.PWNED=1;__out+="' }]);
eq(text.out, '";globalThis.PWNED=1;__out+="', 'codegen: Text is emitted as a literal');
assert(text.pwned === undefined, 'codegen: nothing inside a Text node executes');
const cond = run([{ type: 'If', cond: evil, body: [] }]);
eq(cond.paths[0], evil, 'codegen: an If condition is a literal path too');
assert(cond.pwned === undefined, 'codegen: nothing inside an If condition executes');
const each = run([{ type: 'Each', iter: evil, body: [] }]);
eq(each.paths[0], evil, 'codegen: an Each iterable is a literal path too');
assert(each.pwned === undefined, 'codegen: nothing inside an Each iterable executes');
const partial = run([{ type: 'Partial', name: evil }]);
eq(partial.paths[0], evil, 'codegen: a Partial name is a literal too');
assert(partial.pwned === undefined, 'codegen: nothing inside a Partial name executes');
}
// caching
{
const engine = new TemplateEngine();
assert(engine.compile('{{a}}') === engine.compile('{{a}}'), 'same string → same compiled function');
}
// The canary. If assert() ever stops throwing, everything above is decoration.
let harnessWorks = false;
try { assert(false, 'canary'); } catch { harnessWorks = true; }
assert(harnessWorks, 'assert() does not throw');
console.log('All assertions passed.');node engine-tests.js — it exits non-zero on the first failure, which is the entire point.
Ten one-line mutations of engine.js, and which assertion notices:
| Mutation | Caught by |
|---|---|
JSON.stringify(node.path) → `"${node.path}"` | codegen: the path arrives at __resolve as one literal string |
JSON.stringify(node.value) → template literal | codegen: Text is emitted as a literal |
JSON.stringify(node.cond) → template literal | codegen: an If condition is a literal path too |
JSON.stringify(node.iter) → template literal | TypeError: "y".forEach is not a function |
JSON.stringify(node.name) → template literal | codegen: a Partial name is a literal too |
drop checkPath on a plain {{ }} | triple-stash is rejected |
drop the {{else}} rejection | {{else}} is rejected |
drop the string branch in toList | each over a string is rejected |
isObject(data) → data != null | TypeError: Cannot use 'in' operator ... in hello |
drop TAG_RE.lastIndex = 0 | a throw mid-tokenize does not make the next template skip its first tag |
Ten for ten. Before the last three sections of assertions existed, the first five of those mutations — every one of the JSON.stringify calls, i.e. the entire injection defence — passed the suite untouched.
You can also inspect what gets generated for any template:
const { tokenize, parse, generateCode } = require('./engine');
const tpl = `Hello {{ name }}!`;
const code = generateCode(parse(tokenize(tpl)));
console.log(code);
// let __out = "";
// __out += "Hello ";
// __out += __escape(__resolve(__data, "name"));
// __out += "!";
// return __out;The Full Implementation
Everything in one file:
// engine.js
// ─── Tokenizer ───────────────────────────────────────────────────────────────
const TAG_RE = /\{\{(.*?)\}\}/gs;
function tokenize(template) {
const tokens = [];
let pos = 0;
let match;
TAG_RE.lastIndex = 0; // never trust a shared /g regex's lastIndex
while ((match = TAG_RE.exec(template)) !== null) {
if (match.index > pos) tokens.push({ type: 'TEXT', value: template.slice(pos, match.index) });
tokens.push(classifyTag(match[1].trim()));
pos = TAG_RE.lastIndex;
}
if (pos < template.length) tokens.push({ type: 'TEXT', value: template.slice(pos) });
return tokens;
}
// A path is a dot-separated chain of keys. These characters make a tag either
// malformed or ambiguous, and every one of them used to render silently as ''.
// This is a syntax check for error quality, not a security boundary — the
// security boundary is JSON.stringify in the code generator.
const INVALID_IN_PATH = /[{}()[\]"'`;,\\/#\s]/;
function checkPath(raw, tag) {
if (raw === '') throw new SyntaxError('Empty tag: {{}}');
if (INVALID_IN_PATH.test(raw)) {
const hint = raw.startsWith('{')
? " — there is no {{{triple-stash}}} here; use {{& path }} for raw output"
: '';
throw new SyntaxError(`Invalid path in ${tag}: ${JSON.stringify(raw)}${hint}`);
}
return raw;
}
function classifyTag(raw) {
if (raw.startsWith('#if ')) return { type: 'IF_OPEN', expr: checkPath(raw.slice(4).trim(), '{{#if}}') };
if (raw.startsWith('#each ')) return { type: 'EACH_OPEN', expr: checkPath(raw.slice(6).trim(), '{{#each}}') };
if (raw === '/if') return { type: 'IF_CLOSE' };
if (raw === '/each') return { type: 'EACH_CLOSE' };
// {{else}} would otherwise be an ordinary lookup for a key called 'else':
// undefined, rendered as ''. Both branches of the if would then emit.
if (raw === 'else' || raw === '#else' || raw === '/else') {
throw new SyntaxError('{{else}} is not supported by this engine — see "What We Skipped"');
}
if (raw.startsWith('>')) return { type: 'PARTIAL', name: raw.slice(1).trim() };
if (raw.startsWith('!')) return { type: 'COMMENT' };
if (raw.startsWith('&')) return { type: 'EXPR', path: checkPath(raw.slice(1).trim(), '{{& }}'), escape: false };
return { type: 'EXPR', path: checkPath(raw, '{{ }}'), escape: true };
}
// ─── Parser ──────────────────────────────────────────────────────────────────
function parse(tokens) {
let pos = 0;
function parseNodes(closeType) {
const nodes = [];
while (pos < tokens.length) {
const tok = tokens[pos];
if (tok.type === closeType) { pos++; return nodes; }
nodes.push(parseNode());
}
if (closeType) throw new Error(`Unclosed block — expected ${closeType}`);
return nodes;
}
function parseNode() {
const tok = tokens[pos++];
switch (tok.type) {
case 'TEXT': return { type: 'Text', value: tok.value };
case 'EXPR': return { type: 'Expr', path: tok.path, escape: tok.escape };
case 'COMMENT': return { type: 'Comment' };
case 'PARTIAL': return { type: 'Partial', name: tok.name };
case 'IF_OPEN': return { type: 'If', cond: tok.expr, body: parseNodes('IF_CLOSE') };
case 'EACH_OPEN': return { type: 'Each', iter: tok.expr, body: parseNodes('EACH_CLOSE') };
default: throw new Error(`Unexpected token: ${tok.type}`);
}
}
return parseNodes(null);
}
// ─── Runtime Helpers ─────────────────────────────────────────────────────────
// Inside #each the current item is stashed on the context under CURRENT.
const CURRENT = Symbol('current');
const BLOCKED = new Set(['__proto__', 'constructor', 'prototype']);
// `in` throws on a primitive; {{ . }} over a primitive is the whole point.
const isObject = (v) => v !== null && (typeof v === 'object' || typeof v === 'function');
function resolve(data, path) {
if (path === '.' || path === 'this') {
return isObject(data) && CURRENT in data ? data[CURRENT] : data;
}
if (path.startsWith('@')) return data?.[path];
const parts = path.split('.');
let val = data;
for (const part of parts) {
if (val == null) return undefined;
if (BLOCKED.has(part)) return undefined; // no walking to Function via constructor
val = val[part];
}
return val;
}
// #each only makes sense over a list. Fail loudly instead of "forEach is not a function".
function toList(value, iterName) {
if (value == null) return [];
if (Array.isArray(value)) return value;
// A string is iterable; iterating one character at a time is never the intent.
if (typeof value === 'string') {
throw new TypeError(
`{{#each ${iterName}}} got a string. Iterating a string character by ` +
`character is almost certainly not what you meant — wrap it in an array if it is.`
);
}
if (typeof value[Symbol.iterator] === 'function') return Array.from(value);
throw new TypeError(`{{#each ${iterName}}} expects an array or iterable, got ${typeof value}`);
}
const ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
function escape(val) {
if (val == null) return '';
return String(val).replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]);
}
// ─── Code Generator ──────────────────────────────────────────────────────────
function generateCode(ast) {
const lines = ['let __out = "";'];
function emit(nodes) {
for (const node of nodes) {
switch (node.type) {
case 'Text':
lines.push(`__out += ${JSON.stringify(node.value)};`); break;
case 'Comment': break;
case 'Expr': {
const val = `__resolve(__data, ${JSON.stringify(node.path)})`;
lines.push(`__out += ${node.escape ? `__escape(${val})` : `(${val} ?? '')`};`); break;
}
case 'Partial':
lines.push(`__out += __partial(${JSON.stringify(node.name)}, __data);`); break;
case 'If':
lines.push(`if (__resolve(__data, ${JSON.stringify(node.cond)})) {`);
emit(node.body);
lines.push('}'); break;
case 'Each': {
const iter = JSON.stringify(node.iter);
lines.push(`{ const __parent = __data;`);
lines.push(`__toList(__resolve(__parent, ${iter}), ${iter}).forEach(function(__item, __i, __arr) {`);
lines.push(` const __data = Object.assign({}, __parent,`);
lines.push(` { '@index': __i, '@first': __i === 0, '@last': __i === __arr.length - 1 },`);
lines.push(` __item !== null && typeof __item === 'object' ? __item : {});`);
lines.push(` __data[__CURRENT] = __item;`);
emit(node.body);
lines.push('}); }'); break;
}
}
}
}
emit(ast);
lines.push('return __out;');
return lines.join('\n');
}
// ─── Engine ──────────────────────────────────────────────────────────────────
class TemplateEngine {
#cache = new Map();
#partials = new Map();
registerPartial(name, template) {
this.#partials.set(name, template);
return this;
}
compile(template) {
if (this.#cache.has(template)) return this.#cache.get(template);
const tokens = tokenize(template);
const ast = parse(tokens);
const code = generateCode(ast);
const compiled = new Function(
'__data', '__resolve', '__escape', '__partial', '__toList', '__CURRENT', code);
const self = this;
const renderFn = (data) =>
compiled(data, resolve, escape, (name, ctx) => {
if (!self.#partials.has(name)) throw new Error(`Unknown partial: ${name}`);
return self.render(self.#partials.get(name), ctx);
}, toList, CURRENT);
this.#cache.set(template, renderFn);
return renderFn;
}
render(template, data = {}) {
return this.compile(template)(data);
}
}
module.exports = { TemplateEngine, tokenize, parse, generateCode };About 160 lines of actual code. No dependencies. Handles variable interpolation, nested paths, HTML escaping, conditionals, loops with @index/@first/@last and a visible parent scope, comments, and partials — and refuses, loudly, the four pieces of Handlebars syntax it does not implement.
What We Skipped
-
{{else}}— The most-requested gap, and note the spelling: Handlebars and Mustache both write it{{else}}, with no#. This engine rejects{{else}},{{#else}}and{{/else}}with aSyntaxErrorrather than treating them as variable lookups, because the old behaviour was to render both branches of theifand say nothing. To actually implement it: add anELSEtoken type; in the parser, split theIfnode's body intoconsequentandalternateat theELSEtoken; the code generator emitsif (...) { ... } else { ... }. -
{{{triple-stash}}}— Also rejected rather than supported, for the same reason:TAG_RE's non-greedy.*?made{{{x}}}a lookup for a key named{xplus a stray}, so it rendered a single closing brace. Raw output here is{{& x }}. Supporting the triple form means tokenizing{{{before{{, which is one more branch in the tokenizer and a secondescape: falsepath. -
Helpers and filters —
{{ date | formatDate }}or{{ capitalize name }}. Wire a helper registry intoTemplateEngineand update the code generator to call__helpers[name](args, ctx)for recognized helper expressions. -
{{#with}}— Pushes a new scope.{{#with user}}{{ name }}{{/with}}makes__datatheuserobject inside the block. Just another node type in the AST, another case in the code generator. -
new Functionand CSP — Strict Content Security Policies blockevalandnew Function. If that's your environment, you need either a precompile build step (generate the function to disk,requireit at runtime) or an AST-walking interpreter that skips code generation entirely. Slower, but CSP-safe. -
Async helpers — The emitted code is synchronous. If you need
awaitin a helper, the compiled function needs to beasync,emitneeds to track async nodes, and every caller needs toawait engine.render(...). Non-trivial but doable. -
Source maps — When the generated JS throws, the stack trace points into the code string at line N, not into your template at line N. Production engines maintain a position table from generated-code lines back to template lines for useful error messages.
-
A real context stack — Ours flattens the parent context into every loop iteration. That makes outer keys visible, which is what you want, but it costs a copy of the parent's keys per iteration and it makes a shadowed parent key permanently unreachable. Handlebars keeps frames and gives you
../to walk up them. -
Context-aware escaping — The one gap in this engine that is a security property rather than a feature. See the callout in Step 3: a single
escape()is correct for element text and quoted attributes and wrong for three other positions. Getting this right means the code generator has to know what kind of HTML surrounds each{{ }}, which means the tokenizer has to understand HTML, which is a much bigger engine.
A checklist if you're picking a template engine rather than writing one:
- Does it escape by default? Opt-out escaping (
{{& x }}) is safe; opt-in escaping is one forgotten call away from an incident. - Is the escaping context-aware, or one function? If it's one function, your review burden is "every unquoted attribute and every
{{ }}inside<script>". - Can it precompile? If it needs
new Functionat runtime, a strict CSP will break it in production and not in dev. - Where do templates come from? If any answer involves user input,
new Function-based compilation is off the table entirely. - What happens on a missing key? Silent empty string is convenient and hides bugs. Some engines can be configured to throw. Pick deliberately.
The Mustache spec is the cheapest way to see how many edge cases you just skipped — it ships as YAML test fixtures, so you can point them at your own engine and watch it fail.
Comments (0)
No comments yet. Be the first to share your thoughts!