Templates Without a Framework
Every post now exists as a dictionary with an HTML body. But an HTML fragment isn't a web page - it needs a <head>, a stylesheet, a site header, all the wrapping that's identical on every page. Writing that wrapper into every post by hand is exactly the repetition computers exist to remove. The tool for the job is a template: an HTML file with holes in it, plus something that fills the holes.
Frameworks like Jinja2 and Handlebars are that "something," industrial-strength. Here's the secret this phase is built on: at their core, they do string replacement. So instead of installing one, you're going to write the core yourself - it's four lines - and earn the understanding of what those libraries actually do for you.
The four-line engine
What a template engine actually is. A function that takes text containing placeholders and a dictionary of values, finds each placeholder, and substitutes the value. Everything else - loops, conditionals, inheritance, escaping - is layered on top of that core.
Ours uses the same {{ name }} placeholder style as Jinja2, and it's this:
=
=
return
For each key in the context dictionary, find {{ key }} in the text and replace it with the value. That's it. That's a template engine.
⚠️ Gotcha: this engine does literal string replacement - it matches characters, not meaning. {{title}} (no spaces) or {{ title }} (two spaces) will not match and will pass through into your page as visible mustache braces. That spacing tolerance is one of the real things Jinja2 buys you. Ours costs four lines; the price is typing {{ title }} with exactly one space each side. If you ever see literal {{ ... }} in a generated page, this is why.
The templates
Two files. First templates/base.html - the wrapper every page shares:
{{ title }} — My Blog
My Blog
{{ content }}
Generated by build.py
Then templates/post.html - the shape of one post, which will be rendered first and then poured into {{ content }} above:
{{ title }}
{{ date }} · {{ tags }}
{{ body }}
Note the stylesheet link: /style.css, with a leading slash. That's a root-relative path - "from the top of the site" - and it will matter twice: once in phase 4 (tag pages live in a subfolder, and root-relative links keep working from there) and once in phase 5 (it's why double-clicking a generated file shows an unstyled page).
And a small stylesheet, static/style.css, so the result doesn't look like 1996:
}
}
}
}
}
}
}
}
}
The new build.py
Here's the complete file - parse_post is unchanged from phase 2; everything from render down is new:
=
=
=
=
=
, , =
=
, , =
=
return
=
=
return
return
return f
= /
=
=
=
=
=
The pieces worth slowing down for:
build()starts by deletingsite/. Brutal, and correct. If you rename or delete a post, a build that only adds files would leave the old page lying around, still published, forever. Wiping and regenerating meanssite/always exactly mirrors your sources. This is also why the "never hand-editsite/" rule exists - your edits would live until the next build, then vanish.- Rendering is two passes, inside-out. First the post's data goes into
post.html, producing a finished<article>. Then that article becomes thecontentvalue poured intobase.html. Template nesting in every real engine is this same idea with more machinery. pretty_datesidesteps a real portability trap. The obvious format code for "day without leading zero" is%-d- which crashes on Windows, where the spelling is%#d, which crashes everywhere else. Neither is in the C standard, so neither is portable.d.dayis a plain Python integer and works on every OS. This one bites people in production logging code, not just blogs.write_pagecreates parent folders withmkdir(parents=True, exist_ok=True)- unnecessary today, but phase 4 writes intosite/tags/, and this function is already ready.main()is gone, replaced bybuild(). Same guard at the bottom; the name now says what it does, and phase 5 will import and call it by that name.
Run it and look at it
(.venv) $ python build.py
built site/2026-07-02-why-static-sites.html
built site/2026-06-28-hello-world.html
Two real web pages exist. To view them properly, serve the folder - the standard library has a one-line web server built in:
(.venv) $ python -m http.server 8000 --directory site
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
Open http://localhost:8000 in your browser. You'll see a bare file listing - there's no index.html yet; that's literally the next phase - but click either post and there it is: your title as a styled heading, the date line, bold text, the code block from post two sitting in its gray box. Every byte of that page passed through functions you wrote.
What just happened: the build read Markdown, rendered it through two nested templates, and wrote finished HTML; the server is just handing the files out. Stop the server with Ctrl+C when you're done looking.
What you have now
A real generator: posts in, styled standalone pages out, with a clean-slate build and a template engine you can explain to anyone in one sentence. What's missing is the connective tissue - a front page, tag pages, a feed. That's phase 4, and it's where this stops being a converter and becomes a blog.
Three questions on the ideas that carry forward:
[
{
"q": "What does a template engine fundamentally do?",
"choices": ["Compiles HTML into Python bytecode", "Finds placeholders in text and replaces them with your data", "Serves files over HTTP"],
"answer": 1,
"explain": "Loops, inheritance, and auto-escaping in Jinja2 are all layers on top of that same core: substitute values into text."
},
{
"q": "Why does build() delete the entire site/ folder before rebuilding?",
"choices": ["To save disk space", "So pages from renamed or deleted posts don't linger as stale published files", "Because write_text cannot overwrite existing files"],
"answer": 1,
"explain": "A build that only adds files can never remove one. Wipe-and-regenerate guarantees site/ exactly mirrors your sources."
},
{
"q": "Why does pretty_date use d.day instead of the %-d format code?",
"choices": ["%-d is slower", "%-d only works on Unix and %#d only works on Windows - neither is portable, while d.day is a plain integer everywhere", "%-d pads the day with zeros"],
"answer": 1,
"explain": "Both spellings are platform-specific extensions to strftime. Pulling .day off the datetime object avoids the trap entirely."
}
]
Check your understanding 3 questions
1. What does a template engine fundamentally do?
2. Why does build() delete the entire site/ folder before rebuilding?
3. Why does pretty_date use d.day instead of the %-d format code?