Use when implementing a text/HTML template engine (lexer, parser, renderer) from first principles — not when just using Handlebars/Jinja/EJS in an app. Triggers on: 'write a template engine', 'build a templating language', 'implement mustache-style templates', 'template lexer and parser', 'safe string interpolation without eval'. Covers tokenizing, AST design, and safe (non-eval) rendering.
Installs into .claude/skills of the current project.
Are you the author of Template Engine From Scratch?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/yanacuti1121-template-engine-from-scratch)
---
name: template-engine-from-scratch
description: "Use when implementing a text/HTML template engine (lexer, parser, renderer) from first principles — not when just using Handlebars/Jinja/EJS in an app. Triggers on: 'write a template engine', 'build a templating language', 'implement mustache-style templates', 'template lexer and parser', 'safe string interpolation without eval'. Covers tokenizing, AST design, and safe (non-eval) rendering."
origin: yana-ai — synthesized from public design notes on Mustache/Handlebars/Jinja2 and community from-scratch tutorials indexed in codecrafters-io/build-your-own-x
license: Apache-2.0
version: 1.0.0
compatibility: yana-ai >= 0.43.2
---
# /template-engine-from-scratch
## When to Use
- Building a template engine (variable interpolation, conditionals, loops) from raw string input, for teaching or a custom DSL need.
- Deciding between logic-less (Mustache-style) vs logic-full (Jinja/EJS-style) template design for a new project.
- Understanding why `eval()`-based templating is unsafe and what the safe alternative actually does under the hood.
## Do NOT use for
- Choosing a template library for a real application — use `grammar-lexer-dsl` for the decision matrix among existing libraries (Handlebars, marked, etc.) instead of building one.
- General parser/compiler construction unrelated to templates — see `grammar-lexer-dsl` (DSL/grammar tooling) or `ast-code-manipulation` for broader AST work.
- Sanitizing untrusted HTML output — see `html-xss-filtering`/`dompurify-xss-prevention` for that specific concern; this skill covers the *engine*, not output sanitization policy.
---
## Design Decision: Logic-less vs Logic-full
```
Templates authored by non-programmers (marketing, docs)?
→ Logic-less (Mustache-style): only {{var}}, {{#section}}, {{^inverted}} —
no expressions, no arithmetic, forces logic to live in the calling code
Templates authored by developers, need conditionals/loops/filters inline?
→ Logic-full (Jinja/EJS-style): {% if %}, {% for %}, {{ expr | filter }} —
more expressive, but blurs the view/logic boundary if overused
```
## Step 1: Lexer (tokenize)
Scan the raw template string and emit a token stream. A minimal token set:
```
TEXT — raw characters outside any tag
VAR_OPEN — "{{" VAR_CLOSE — "}}"
BLOCK_OPEN — "{{#" BLOCK_CLOSE — "{{/"
IDENTIFIER — a variable/section name
```
Implementation is a straightforward state machine: scan forward accumulating `TEXT` until you hit `{{`, then switch to "inside tag" mode and accumulate until `}}`, classify the tag content (bare identifier = variable, `#name` = block open, `/name` = block close), emit the token, switch back to `TEXT` mode.
## Step 2: Parser (build an AST)
Turn the flat token stream into a tree so nested blocks (a loop containing a conditional) render correctly. Node types:
```
TextNode(string)
VariableNode(path) — path e.g. "user.name" for nested lookup
SectionNode(path, children[]) — loop or conditional body, resolved at render time
```
Parsing algorithm: recursive-descent, one function that consumes tokens until it hits a `BLOCK_CLOSE` matching the current open tag or end-of-input. A `SectionNode` recursively parses its own children the same way — this recursion is what makes nesting work "for free" once the base case (`TextNode`/`VariableNode`) is correct.
**Balance blocks explicitly**: track open block names on a stack as you parse; a `{{/name}}` that doesn't match the top of the stack (wrong name, or empty stack) is a template syntax error — report it, don't silently ignore the mismatch. An engine that tolerates mismatched blocks will render subtly wrong output instead of failing loudly.
## Step 3: Renderer (walk the AST against a context)
```
render(node, context):
TextNode → return node.string
VariableNode → return escape(lookup(node.path, context))
SectionNode → value = lookup(node.path, context)
if value is falsy/empty: render nothing (or {{^}} inverted branch)
if value is a list: render children once per item, item becomes new context
if value is truthy (not list): render children once, value merged into context
```
`lookup(path, context)` needs to walk dotted paths (`user.address.city`) against nested dicts/objects, and — critically — return a clearly-distinguishable "not found" sentinel rather than throwing, so a missing optional field renders as empty rather than crashing the whole render.
## Step 4: Safety — Never `eval()`
The unsafe shortcut is tempting: turn `{{ user.name }}` into `eval("user.name")` against a context object and let the host language's interpreter do variable lookup and even arbitrary logic-full expressions. **Don't** — a template string is often partially or fully user-influenced (a saved profile bio field rendered back into a page, a config value from an admin panel), and `eval` on that string is unrestricted code execution.
The correct approach is exactly Steps 1-3: a real (if minimal) lexer/parser/interpreter that only understands the specific grammar you defined (variable lookup, iteration, conditionals) — there is no code path from template text to arbitrary host-language execution, because the interpreter never calls `eval`/`Function()`/`exec()` on template content, only on your own fixed renderer code operating on data.
Escape output by default (`escape()` in Step 3 above HTML-encodes `< > & " '`) — provide an explicit "raw/unescaped" tag variant (Mustache uses `{{{triple}}}` for this) so unescaped output is an opt-in the template author chose, not the default behavior.
## What NOT to Do
- Don't use `eval()`/`Function()`/string-concatenation-into-code as a shortcut for expression evaluation — see Step 4; this is the single most common way a template engine becomes an RCE vector.
- Don't auto-escape only sometimes — inconsistent escaping is worse than never escaping, since callers stop trusting the default and add manual escaping that then double-escapes.
- Don't let unmatched block tags render silently — see Step 2's stack-balance check; a mismatched `{{/if}}` should be a parse error, not a rendering oddity discovered in production.
- Don't skip the AST step and try to render directly from the token stream with regex substitution — this is how nested blocks (a loop inside a conditional) break in subtle, hard-to-debug ways.