Template Engine: Blocks, Variables and Loops
HardAI-assisted coding · AI-Assisted Feature Work
Template Engine: Blocks, Variables and Loops
You've joined the team that owns templating, the small template engine behind transactional emails and receipts. Templates are stored by name, compiled when they are defined, and rendered against a JSON context. The engine currently handles plain text and {% if %} blocks only; a bug report is open against it, and three roadmap tickets are queued behind that.
Codebase
| File | What it does |
|---|---|
templating/api.py | run(operations) entry point and the Dispatcher that routes each operation |
templating/registry.py | TemplateRegistry: define, store, render and digest named templates |
templating/lexer.py | tokenize: source text -> text / variable / tag tokens (with line numbers) |
templating/parser.py | Parser: tokens -> node tree. Tags are routed through TAG_HANDLERS |
templating/nodes.py | Node dataclasses (TextNode, VarNode, IfNode) |
templating/renderer.py | Renderer: walks the tree. Node types are routed through NODE_RENDERERS |
templating/scope.py | Scope (variable lookup) and is_truthy |
templating/filters.py | FILTERS registry for {{ value|name }} filters (empty today) |
templating/errors.py | TemplateSyntaxError and TemplateRenderError |
tests/ | Existing tests (some of them currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh registry and returns exactly one result per operation. Errors are returned as "error: <message>" strings, never raised.
| Operation | Arguments | Returns | Errors |
|---|---|---|---|
define | name, source | true | any syntax error (see below) |
render | name, context | the rendered string | unknown template '<name>', or a render error |
render_digest | name, context | "<length>:<8 hex chars>": the length of the rendered text and the first 8 hex characters of its SHA-1 | same as render |
templates | none | sorted list of defined names | none |
nameandsourceare strings andcontextis a JSON object. A wrong number of arguments or a wrong type returns"error: invalid arguments"; an unknown operation name returns"error: unknown operation".- Redefining a name replaces the template. A
definethat fails leaves any previous definition of that name untouched.
Worked example
[["define", "hi", "Hi{% if vip %}, valued guest{% endif %}!"],
["render", "hi", {"vip": true}],
["render", "hi", {}],
["render", "nope", {}]]returns
[true, "Hi, valued guest!", "Hi!", "error: unknown template 'nope'"]Template syntax
{{ ... }}is a variable and{% ... %}is a tag. Whitespace just inside the delimiters is ignored. Everything else is literal text and is copied to the output unchanged.- Blocks (
ifnow,forin Part 3) are closed byendif/endfor, and may be nested. - Syntax errors are detected by
define. Each message ends withat line N, whereNis the 1-based line on which the offending tag starts (except "unclosed", see below):unterminated tag(a{{or{%with no closing}}/%}),empty expression({{ }}or{% %}),invalid expression,unknown tag '<name>',unexpected '<tag>'andunclosed '<tag>' opened. When several things are wrong, the first problem in document order is reported; "unclosed" is checked last and names the innermost block still open.
Part 1 — Nested blocks render the wrong text
Goal. Customer emails with an {% if %} inside another {% if %} sometimes drop or misplace text, and conditions on empty values take the wrong branch. Fix the engine so the rules below hold.
Rules
{% if path %},{% if not path %},{% else %}and{% endif %}work as in the existing tests. In Part 1pathis a top-level key of the context.- An
{% else %}or{% endif %}always applies to the innermost block that is still open. A block has at most one{% else %}. - An
{% endif %}or{% else %}with no matching open block isunexpected 'endif'/unexpected 'else'(at the tag's line). So is anyendif/elsethat carries extra text. - A condition is false when the value is missing,
null,false,0(or0.0),"",[]or{}. Everything else is true, including the string"0"and"false"and a list such as[0].
Example
[["define", "t", "[{% if a %}A{% if b %}B{% else %}b{% endif %}Z{% else %}a{% endif %}]"],
["render", "t", {"a": true, "b": false}],
["render", "t", {"a": false, "b": true}]]returns [true, "[AbZ]", "[a]"].
Part 2 — Variables with escaping and dotted paths
Goal. Support {{ path }} so templates can show data, safely.
Rules
- A path is one or more segments of letters, digits and
_, joined by.(for exampleuser.address.cityoritems.0.sku). Any other expression inside{{ }}is aninvalid expression.{% if %}conditions accept the same dotted paths. - The first segment is looked up in the context. Each later segment indexes into the value found so far: into an object by key, or into a list by a non-negative integer. If any step is impossible (no such key, index out of range, the value is a string/number/boolean/null) the whole path is missing.
- A missing path or a
nullvalue renders as the empty string. Strings render as they are,true/falserender astrue/false, and numbers render the way JSON writes them (42,2.5). - A list or an object cannot be shown: the render fails with
"error: '<path>' is not a scalar". - Every value written by
{{ }}is HTML-escaped:&becomes&,<<,>>,""and''. Literal template text is never escaped, and a value that already contains&is escaped again (&amp;). {{ path|raw }}(whitespace around|allowed) writes the value without escaping. Any other filter name is a syntax errorunknown filter '<name>'; more than one|is aninvalid expression. New filters should go through theFILTERSregistry.
Example
[["define", "c", "<p>{{ user.name }} ({{ user.tags.1 }}) {{ user.bio|raw }}</p>"],
["render", "c", {"user": {"name": "Tom & <Jerry>", "tags": ["a", "it's"], "bio": "<b>hi</b>"}}]]returns [true, "<p>Tom & <Jerry> (it's) <b>hi</b></p>"].
Part 3 — {% for %} loops
Goal. Let templates repeat a section for each item of a list.
Rules
{% for name in path %} ... {% endfor %}renders its body once per item of the list atpath, in order.nameis an identifier (letters, digits,_, not starting with a digit) and may not beloop; anything else malformed in the tag is aninvalid expression.- Inside the body
nameis bound to the current item and shadows any context key of the same name. After the loop (and in itselsesection) the earlier meaning ofnameis back. - Inside the body,
loopis an object withloop.index(1-based),loop.index0,loop.firstandloop.last(booleans), andloop.length. Loops nest: the innerloopshadows the outer one and the outer one is visible again after the inner loop ends. Aloopkey in the context is shadowed the same way and is untouched outside loops. - An optional
{% else %}before{% endfor %}renders when the list is empty, missing ornull. - If the value at
pathis anything else (object, string, number, boolean) the render fails with"error: cannot iterate over '<path>'". {% else %}belongs to the innermost open block, whether that is anifor afor; each end tag must close the innermost block of its own kind, otherwise it isunexpected 'endfor'/unexpected 'endif'.unclosed 'for' openedapplies as in Part 1.- Items can be anything: objects (
{{ user.name }}), lists ({{ row.0 }}), and so on, and all of Parts 1-2 keeps working inside loops.
Example
[["define", "l", "{% for x in xs %}{{ x }}{% if not loop.last %}, {% endif %}{% else %}none{% endfor %}"],
["render", "l", {"xs": ["a", "b", "c"]}],
["render", "l", {"xs": []}]]returns [true, "a, b, c", "none"].
Part 4 — Large renders are too slow (stretch)
Goal. Nightly statement jobs render nested loops that produce hundreds of kilobytes per call, and some render the same page thousands of times. Both take too long. Make rendering fast without changing any output.
Rules
- Output must be identical to Parts 1-3 for every input, including every error message.
- Rendering a template with about 75,000 loop iterations in total (for example three nested loops of 42 items) must finish well inside the time limit, and 250 renders of one mid-sized template (about 200 tags) must as well.
- Redefining a template must take effect immediately for later renders; a failed
definemust not change what renders.
Notes
lexer.py,errors.pyandnodes.pyare known-good apart from the additions the parts ask for.- You may use the AI assistant freely. Later parts must not break earlier ones; the hidden tests re-check every part. Parts 1-3 is a strong result; Part 4 is a stretch.
Example 1
- Input:
- [["define","t","[{% if a %}A{% if b %}B{% else %}b{% endif %}Z{% else %}a{% endif %}]"],["render","t",{"a":true,"b":false}],["render","t",{"a":false,"b":true}]]
- Output:
- [true,"[AbZ]","[a]"]
- Explanation
- With a true and b false, the inner else prints b and the outer text Z still follows. With a false only the outer else runs.
Example 2
- Input:
- [["define","c","<p>{{ user.name }} ({{ user.tags.1 }}) {{ user.bio|raw }}</p>"],["render","c",{"user":{"name":"Tom & <Jerry>","tags":["a","it's"],"bio":"<b>hi</b>"}}]]
- Output:
- [true,"<p>Tom & <Jerry> (it's) <b>hi</b></p>"]
- Explanation
- Dotted paths walk objects and list indexes; values are HTML-escaped unless the raw filter is used; template text is left alone.
Example 3
- Input:
- [["define","l","{% for x in xs %}{{ x }}{% if not loop.last %}, {% endif %}{% else %}none{% endfor %}"],["render","l",{"xs":["a","b","c"]}],["render","l",{"xs":[]}]]
- Output:
- [true,"a, b, c","none"]
- Explanation
- loop.last suppresses the trailing comma; the else section renders for an empty list.
Guidelines
- Python 3 standard library only
- Every operation returns exactly one value; errors are returned as "error: <message>" strings, never raised
- Syntax errors are found at define time and report the 1-based line of the offending tag
- Part 4 inputs render tens of thousands of loop iterations and hundreds of repeated renders