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.
Scanned 9/9/2026
Install to Claude Code
npx -y skills add yanacuti1121/Yana-AI --skill template-engine-from-scratch --agent claude-codeInstalls 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 automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/yanacuti1121-template-engine-from-scratch-yana-ai)More formats (shields.io, HTML) on the badges page.
---
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.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!