# 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:

```python
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:

```html
<!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:

```html
<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:

```css
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; }
```

## Your turn: pretty_date

One more small piece before the full file. `build()` needs to turn a post's `datetime` into something readable under its title - not `2026-07-02`, but a real date. You already parsed those `datetime` objects back in phase 2, so nothing here is new syntax, just a function to write:

```python
def pretty_date(d):
    # your turn: return d formatted like "July 2, 2026" -
    # full month name, the day with no leading zero, then the year
    return ""
```

`f"{d:%B}"` (or `d.strftime("%B")`) gives the full month name; `d.day` and `d.year` are just plain integers, no format code needed for those. Passed `datetime(2026, 7, 2)`, it should come back as `"July 2, 2026"`. My version is in the file below - once you run the build and open a post page at `http://localhost:8000`, the date line under the title is exactly what this function produced.

## The new build.py

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

```python
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

```console
(.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:

```console
(.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:

```quiz
[
  {
    "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."
  }
]
```
