Building a Template Engine: Parsing, Compiling & Rendering
Building a Web Framework
Chapter 4 ยท Building a Template Engine: Parsing, Compiling & Rendering
Chapter 3 closed on a real handler shape this course hadn't built yet: Response(render('page.html', title='Home')). Every handler written so far builds its own HTML with an f-string, which works for one line and falls apart the moment a real page needs a shared layout, a loop over real data, or a value nobody has separately checked for safety. This chapter builds that missing piece — a real, file-based TemplateEngine class this framework keeps and reuses from here on — and runs directly into the one design decision a template engine can't get wrong: what happens to a value the instant it lands inside real HTML.
The Basic Job: Substituting Into a Real Template File
A template engine's simplest possible form finds placeholders in a string and replaces them with real values — the same regex-substitution shape Chapter 2's own router used for dynamic path segments, applied to a whole document loaded from disk instead of a URL:
Genuinely working, genuinely unsafe the moment a real, user-controlled value reaches it.
The Real Security Problem: Naive Interpolation
.html file on disk and rendered with a real malicious context value, the output contains a genuine, executable <script> tag — not the text describing one. A profile display name, a comment, a search query echoed back — anything reaching render_basic() unescaped becomes a real, working cross-site scripting vector the instant it's rendered into an actual page.
Auto-Escaping by Default, With an Explicit Opt-Out
Every value gets HTML-escaped automatically unless it's deliberately marked as already safe — a plain str subclass is enough to carry that marker:
escape() unwrapped returns <script>alert(1)</script> — inert, visible text. Wrapping the identical characters as SafeString(...) first returns them completely untouched. Safety isn't a property of the value itself; it's a decision made once, deliberately, exactly at the one point a developer genuinely knows a value is already safe — never trusted implicitly by default.
A Real Node Tree, With Nested if/for
Real templates need loops and conditionals, and both can nest inside each other — an if inside a for, say. That needs a real parsed structure, not one flat regex pass. Four small node classes, each responsible only for rendering itself:
A tokenizer splits the raw source on {{ ... }} and {% ... %} boundaries; a small recursive-descent parser walks the tokens once, recursing into a fresh call for each if/for body and returning control the moment it hits the matching endif/endfor:
{% for item in items %}{% if item %}<li>{{ item }}</li>{% endif %}{% endfor %}, compiled once and rendered against {'items': ['a', '', 'b', None, 'c']}, produces exactly <li>a</li><li>b</li><li>c</li> — the two falsy items silently skipped, with the nested IfNode correctly evaluated fresh against each loop iteration's own local context.
Template Inheritance: extends & block, From Real Files
A shared layout lives in one parent file; a child file extends it and overrides only the regions it names:
child.html deliberately never mentions title at all — only content.
Resolving a child means reading both real files, extracting the child's own named blocks with one regex, and substituting each into the parent's matching block — before the merged result ever reaches compile_template(), so anything inside a block still gets the full real {{ }}/{% %} treatment:
Chaining the pieces manually, exactly the way a class will soon do internally:
child.html only ever defines content; its real, rendered output nonetheless shows <title>My Site</title>, taken straight from base.html's own default text, since extract_blocks() never found a title block in the child at all, and substitute_blocks()'s own .get(m.group(1), m.group(2)) falls back to the parent's original content the instant a key is genuinely missing. A child only has to say what's genuinely different about it.
Building the Real TemplateEngine Class
Everything above — reading a real file, resolving extends, compiling to a node tree — becomes one class that owns a template directory and caches the compiled result, parsed once per template name and reused on every later render, exactly the discipline Web Framework Internals' own Chapter 4 verified Django's and Jinja2's real engines both follow:
render() named its own template-name parameter name, matching Chapter 2's own Router.add_route(..., name=...) convention. It broke immediately, on the very first real call: engine.render('basic.html', name='World') raised TypeError: render() got multiple values for argument 'name' — 'basic.html' filled the positional name parameter, and the keyword name='World' (a completely ordinary, plausible context value) collided with it directly. Renaming the parameter to template_name fixed it outright. This is the exact reason Flask's own real render_template() never calls its own first parameter name either — name is too common a real context key for a template engine's own API to claim for itself.
_read_file() to count real calls, then rendering the same already-cached template five times in a row, adds exactly zero further file reads. The file is opened and parsed on the very first render only; every render after that walks the same in-memory node tree Chapter 4's own compiler already built.
A Real, Live-Verified Limitation: Stale Caching
Caching by name has an honest cost: once a template is compiled, editing its file on disk mid-run changes nothing a running server actually serves.
GET requests, sent to the same live wsgiref server, with the real template file edited on disk in between, both return the identical v1 output — confirmed by actually running the edit and the second request against a process that never restarted. This isn't a bug so much as an unavoidable consequence of caching correctly by name: nothing in TemplateEngine has any way of knowing the file underneath a cached name has changed. Fixing this — detecting a changed file and recompiling automatically — is exactly the real job of the live-reload development server this course builds in Chapter 8.
Wiring TemplateEngine Into Response
A handler now renders a real file, through the real engine, and wraps the result in a Response exactly the way Chapter 3's own closing line promised. A third, separate real template exercises everything at once — extending the same base.html, but this time overriding both of its blocks:
Served through the real wsgiref app and hit with genuine urllib.request calls:
base.html, the title and content blocks come from users.html, the empty-state {% if %} correctly fires only on the second route, the {% for %} loop correctly renders three real list items, and the third item's own literal <b>Rogue</b> string comes back fully escaped — confirming auto-escaping survives the full route-to-template pipeline, not just the isolated examples earlier in this chapter.
Where This Course Is Headed
Chapter 5 builds a real middleware pipeline — an onion-model chain wrapping every request and response, the mechanism a real framework would use to add authentication, logging, or CSRF protection around every route this chapter's own Router already dispatches to.
Hands-On Exercises
Extend this chapter's own IfNode and parse() function to support a real {% else %} branch โ {% if flag %}...{% else %}...{% endif %} โ and verify it against both a truthy and a falsy value for flag, plus a genuinely nested case with an if/else living inside a for loop.
๐ View solutionWrite a real render_bio_markdown(raw_text) function that escapes its input completely first, then converts **bold** markers on the now-safe text into real <strong> tags, returning the result as a SafeString โ and verify against a single real input containing both a **bold** marker and a literal <script> tag that only the bold marker becomes real markup while the script tag stays fully inert.
๐ View solutionReproduce this chapter's own stale-cache finding yourself against a real, live wsgiref server serving a route rendered through TemplateEngine: make one real request, edit the template file on disk while the server keeps running, make a second real request, and confirm both responses are identical โ then explain in your own words why get_template()'s own caching is what causes this, not a bug in compile_template() itself.
๐ View solutionChapter 4 Quick Reference
- The basic job โ read a real .html file, substitute {{ }} placeholders from a context dict
- The real security problem โ unescaped interpolation puts a real, live <script> tag straight into rendered output
- Auto-escaping by default โ escape() escapes everything except values explicitly wrapped as SafeString
- The node tree โ TextNode/VarNode/IfNode/ForNode, parsed once via a real recursive-descent tokenizer/parser supporting nested if/for
- extends/block inheritance โ child blocks substituted into the parent's raw text before compilation, verified falling back to the parent's own default for any block a child leaves untouched
- TemplateEngine โ real file loading, inheritance resolution, and compile-once caching in one class; a genuine name/template_name naming-collision bug found and fixed along the way
- Stale caching โ verified live: editing a cached template's file on disk doesn't change what a running server serves, until Chapter 8's own live-reload dev server fixes it
- Next chapter: A real onion-model middleware pipeline