Updated Jul 6, 2026

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:

def render(template, context):
    out = template
    for key, value in context.items():
        out = out.replace("{{ " + key + " }}", str(value))
    return out

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:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{ title }} — My Blog</title>
  <link rel="stylesheet" href="/style.css">
</head>
<body>
  <header><a href="/">My Blog</a></header>
  <main>
{{ content }}
  </main>
  <footer>Generated by build.py</footer>
</body>
</html>

Then templates/post.html - the shape of one post, which will be rendered first and then poured into {{ content }} above:

<article>
  <h1>{{ title }}</h1>
  <p class="meta">{{ date }} · {{ tags }}</p>
{{ body }}
</article>

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:

body {
  max-width: 42rem;
  margin: 0 auto;
  padding: 1.5rem;
  font-family: Georgia, serif;
  line-height: 1.6;
  color: #222;
}
header a { font-weight: bold; text-decoration: none; color: #222; }
h1 { line-height: 1.2; }
.meta, .date { color: #777; font-size: 0.9rem; }
pre { background: #f4f4f4; padding: 0.75rem; overflow-x: auto; }
a { color: #0b6e6e; }
ul.post-list { list-style: none; padding: 0; }
ul.post-list li { margin: 0.5rem 0; }
footer { margin-top: 3rem; color: #999; font-size: 0.85rem; }

The new build.py

Here's the complete file - parse_post is unchanged from phase 2; everything from render down is new:

import shutil
from datetime import datetime
from pathlib import Path

import markdown

POSTS_DIR = Path("posts")
TEMPLATES_DIR = Path("templates")
STATIC_DIR = Path("static")
SITE_DIR = Path("site")


def parse_post(path):
    text = path.read_text(encoding="utf-8")
    if not text.startswith("---"):
        raise ValueError(f"{path.name}: no frontmatter block at the top")
    _, front, body = text.split("---", 2)

    meta = {}
    for line in front.strip().splitlines():
        key, _, value = line.partition(":")
        meta[key.strip()] = value.strip()

    return {
        "title": meta.get("title", path.stem),
        "date": datetime.strptime(meta["date"], "%Y-%m-%d"),
        "tags": [t.strip() for t in meta.get("tags", "").split(",") if t.strip()],
        "slug": path.stem,
        "html": markdown.markdown(body, extensions=["fenced_code"]),
    }


def render(template, context):
    out = template
    for key, value in context.items():
        out = out.replace("{{ " + key + " }}", str(value))
    return out


def load_template(name):
    return (TEMPLATES_DIR / name).read_text(encoding="utf-8")


def pretty_date(d):
    return f"{d:%B} {d.day}, {d.year}"


def write_page(relative_path, text):
    out_path = SITE_DIR / relative_path
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(text, encoding="utf-8")
    print(f"built  {out_path.as_posix()}")


def build():
    if SITE_DIR.exists():
        shutil.rmtree(SITE_DIR)
    SITE_DIR.mkdir()
    shutil.copy(STATIC_DIR / "style.css", SITE_DIR / "style.css")

    base = load_template("base.html")
    post_template = load_template("post.html")

    posts = [parse_post(p) for p in sorted(POSTS_DIR.glob("*.md"))]
    posts.sort(key=lambda p: p["date"], reverse=True)

    for post in posts:
        article = render(post_template, {
            "title": post["title"],
            "date": pretty_date(post["date"]),
            "tags": ", ".join(post["tags"]),
            "body": post["html"],
        })
        page = render(base, {"title": post["title"], "content": article})
        write_page(f"{post['slug']}.html", page)


if __name__ == "__main__":
    build()

The pieces worth slowing down for:

  • build() starts by deleting site/. 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 means site/ always exactly mirrors your sources. This is also why the "never hand-edit site/" 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 the content value poured into base.html. Template nesting in every real engine is this same idea with more machinery.
  • pretty_date sidesteps 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.day is a plain Python integer and works on every OS. This one bites people in production logging code, not just blogs.
  • write_page creates parent folders with mkdir(parents=True, exist_ok=True) - unnecessary today, but phase 4 writes into site/tags/, and this function is already ready.
  • main() is gone, replaced by build(). 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?