# Build a Static Blog Generator (Python)

> Build your own static site generator in Python - parse Markdown posts with frontmatter, render them through templates you wrote yourself, generate an index, tag pages, and an RSS feed, then deploy the finished site for free.


---

# Build a Static Blog Generator (Python)

Jekyll, Hugo, Eleventy, Astro - the static site generator is one of the most-reinvented tools in software, and there's a reason: the idea is small enough to hold in your head, and the result is a real website you actually own. This project is you joining that tradition. You'll write a Python program that reads a folder of Markdown posts and produces a complete, deployable blog - every page, the index, tag pages, and an RSS feed - as plain HTML files.

No framework. No magic. When you're done, there won't be a single line of your blog's machinery you didn't write and can't explain.

This one runs **on your machine.** You'll create a real project folder, install one library into a virtual environment, and run your generator from the terminal - the same way you'd run any tool you ship.

## Why build one instead of downloading one

Because a static site generator is the rare project where the *concepts* are worth more than the artifact. Building one teaches you, hands-on:

- how frontmatter and Markdown parsing actually work (the format half the dev world writes in),
- what a template engine really does under the hood (it's smaller than you think),
- why "build once, serve files" beats "run code on every request" for most content sites,
- and what a deploy actually *is* when there's no server involved.

And unlike a toy, you can genuinely use the result. Plenty of real blogs run on a build script about this size.

```mermaid
flowchart LR
  P[posts/*.md] --> B[build.py]
  T[templates/*.html] --> B
  S[static/style.css] --> B
  B --> O[site/ - finished HTML]
  O --> H[any static host]
```

## The stack

| Piece | What it is | Why we use it |
|-------|-----------|---------------|
| Python 3.10+ | The language you already know | The whole generator is one script |
| `markdown` | A Markdown-to-HTML library | Writing a Markdown parser is its own project; converting is not the lesson here |
| Python's standard library | `pathlib`, `datetime`, `http.server`, `email.utils` | Everything else - templates, the feed, the dev server - needs nothing extra |

That's the entire dependency list: one package. The template engine, the RSS feed, and the dev server are all things you'll build from the standard library, because seeing how small they really are is half the point.

## What you'll need

- **Python 3.10 or newer.** Check with `python --version` (or `python3 --version` on macOS/Linux). If you don't have it, grab it from python.org.
- A text editor and a terminal.
- For the final phase: a free GitHub or Netlify account, if you want the blog on the actual internet.

Rough time: a focused afternoon or two evenings, four to five hours if you read as you go.

## What you'll learn

- The build-time vs request-time mental model that explains the entire static site world
- Parsing structured text: frontmatter blocks, key-value metadata, Markdown bodies
- How template substitution works - by writing a template engine in four lines
- Generating derived pages: an index, per-tag pages, and a standards-compliant RSS feed
- Running a local dev server with an automatic rebuild loop
- Deploying a folder of files to free hosting, and the one path gotcha that bites everyone

## The phases

1. **What a Generator Actually Is, and Setup** - the build-time mental model, the project folder, your first posts, and a script that finds them.
2. **Parsing Posts: Frontmatter and Markdown** - split metadata from body, turn Markdown into HTML, and fail loudly on bad posts.
3. **Templates Without a Framework** - write a four-line template engine and generate a real HTML page per post.
4. **The Index, Tags, and an RSS Feed** - the pages that make it a blog instead of a pile of files.
5. **A Dev Server with Auto-Rebuild** - serve the site locally and rebuild the moment you save a file.
6. **Deploying, and Where to Take It** - put the folder on the internet for free, and the real list of what to build next.

Each phase ends with something that runs. By phase 3 you can open a generated post in your browser. By phase 6 your blog has a URL. Let's set it up.


---

# What a Generator Actually Is, and Setup

Before writing any code, you need the one mental model the entire project rests on. It's small, and once it clicks, every static site tool you'll ever meet - Jekyll, Hugo, Eleventy - stops being mysterious.

## Build time vs request time

**What a dynamic site does.** When someone visits a WordPress blog, a program wakes up *at that moment*: it queries a database for the post, runs it through a theme, assembles HTML, and sends it back. That work happens on **every single request**, which is why a dynamic site needs a server running code 24/7, a database, security patches, and a hosting bill.

**What a static site generator does.** It does the exact same assembly work - read content, apply a template, produce HTML - but **once, on your machine, before anything is deployed**. The output is a folder of finished `.html` files. The web host's only job is handing those files out, which is the one thing web servers are effortlessly good at.

That's the whole idea. The trade is explicit: you give up per-visitor behavior (comments, logins, "personalized for you") and in exchange the site is fast, free to host, and has nothing running that can crash or be hacked.

💡 **Key point:** a static site generator is not a kind of website. It's a *program that runs at build time* and writes a website out as files. `build.py`, the script you're about to write, **is** a static site generator - the same species as Hugo, just smaller.

📝 **Terminology:** the moment your generator runs is **build time**. The moment a visitor loads a page is **request time**. At request time, a static site does nothing at all - the work was already done.

## Make the project

Open a terminal, make a folder, and set up an isolated Python environment inside it:

```bash
mkdir myblog
cd myblog
python -m venv .venv
```

`python -m venv .venv` creates a **virtual environment** - a private copy of Python in the `.venv` folder, so the library we install lands in this project instead of polluting your system Python. Activate it:

```bash
# macOS / Linux:
source .venv/bin/activate

# Windows (PowerShell or cmd):
.venv\Scripts\activate
```

Your prompt grows a `(.venv)` prefix - that's how you know pip now installs into the project. You'll need to re-activate in every new terminal you open for this project.

Now install the one dependency:

```console
(.venv) $ pip install markdown
Collecting markdown
  Downloading markdown-3.8.2-py3-none-any.whl (106 kB)
Installing collected packages: markdown
Successfully installed markdown-3.8.2
```

Your version number may differ - that's fine. The `markdown` package converts Markdown text to HTML. That's the only job we're outsourcing: writing a correct Markdown parser is a multi-week project of its own, and it isn't the thing this build teaches.

Create the folders the generator will read from:

```bash
mkdir posts templates static
```

Which gives you this layout:

```text
myblog/
  .venv/         <- the virtual environment (never touch it)
  build.py       <- the generator - you write this next
  posts/         <- your writing, one .md file per post
  templates/     <- HTML skeletons (phase 3)
  static/        <- CSS, copied through untouched (phase 3)
  site/          <- the OUTPUT - generated, never edited by hand
```

`site/` doesn't exist yet. The build creates it - and that's a rule worth tattooing somewhere: **you never edit anything in `site/`**, because the next build will overwrite it. Source lives in `posts/`, `templates/`, and `static/`; everything in `site/` is disposable.

## Write two posts

A generator with nothing to generate is hard to test. Posts are Markdown files with a metadata block at the top. Create `posts/2026-06-28-hello-world.md`:

```markdown
---
title: Hello, World
date: 2026-06-28
tags: meta
---

This is the first post on a blog generated by a Python script I wrote
myself. It is plain **Markdown** in a plain text file.

Things this blog does not have:

- a database
- an admin panel
- a monthly bill
```

And a second one, `posts/2026-07-02-why-static-sites.md`:

````markdown
---
title: Why I Went Static
date: 2026-07-02
tags: meta, web
---

My last blog ran on a database, a PHP runtime, and a prayer. This one
is a folder of files.

Static means there is nothing to patch on a Tuesday night, and nothing
that falls over when a post gets popular.

```python
# nothing stops me from putting code in a post, either
print("hello from a code block")
```
````

The block between the `---` lines is called **frontmatter** - metadata about the post that isn't part of the post. The filename starts with the date so posts sort naturally in a file listing; the rest of the filename will become the post's URL later.

📝 **Terminology:** frontmatter is the `key: value` block fenced by `---` at the top of a content file. Jekyll popularized it, and now it's everywhere - this platform's own guides use it. You'll write the parser for it in phase 2.

## The first build.py

Start the generator with the smallest thing that proves the pipeline: find the posts. Create `build.py` in the project root:

```python
from pathlib import Path

POSTS_DIR = Path("posts")


def main():
    posts = sorted(POSTS_DIR.glob("*.md"))
    print(f"Found {len(posts)} post(s):")
    for path in posts:
        print(" -", path.name)


if __name__ == "__main__":
    main()
```

Two things here carry through the whole project. `Path("posts")` is `pathlib`, the standard library's way of handling files - `POSTS_DIR.glob("*.md")` means "every `.md` file in that folder," and it returns them in no guaranteed order, which is why we `sorted()` them. And the `if __name__ == "__main__":` guard means "only run `main()` when this file is executed directly, not when it's imported" - that guard becomes important in phase 5, when another script imports this one.

Run it:

```console
(.venv) $ python build.py
Found 2 post(s):
 - 2026-06-28-hello-world.md
 - 2026-07-02-why-static-sites.md
```

*What just happened:* Python globbed the `posts/` folder, found your two files, and listed them. Unimpressive on purpose - but the pipeline's first segment (source folder → script) is real and verified. Everything from here on is teaching this script what to *do* with those files.

## What you have now

A project folder with an isolated environment, one installed library, two real Markdown posts, and a script that finds them. Next phase, the script learns to actually read a post: split the frontmatter from the body, parse the metadata, and convert the Markdown to HTML - the input half of the generator, done properly.

Quick check that the mental model stuck before you move on:

```quiz
[
  {
    "q": "When does a static site generator do its work?",
    "choices": ["On every visitor request, like WordPress", "Once, at build time, before anything is deployed", "Continuously, on a schedule in the cloud"],
    "answer": 1,
    "explain": "The generator runs once on your machine and writes finished HTML files. At request time, the host serves those files as-is - no code runs per visitor."
  },
  {
    "q": "What will the site/ folder hold, and how should you treat it?",
    "choices": ["Your Markdown sources - edit freely", "Server configuration files", "The generated output - never edit it by hand, the next build overwrites it"],
    "answer": 2,
    "explain": "site/ is disposable output. Source of truth lives in posts/, templates/, and static/."
  },
  {
    "q": "What is frontmatter?",
    "choices": ["The key: value metadata block between --- lines at the top of a content file", "The first paragraph of a post", "An HTML template header"],
    "answer": 0,
    "explain": "It carries data about the post (title, date, tags) that isn't part of the post's body text."
  }
]
```


---

# Parsing Posts: Frontmatter and Markdown

Your generator can find posts. Now it learns to read one - and "read" means three separate jobs stacked in one file: peel the frontmatter off the top, turn its `key: value` lines into data, and convert the Markdown body to HTML. By the end of this phase, `build.py` turns every post file into a clean Python dictionary with everything later phases need.

## What a post file actually is

Look at one of your posts as a machine would:

```text
---                          <- marker: frontmatter starts
title: Hello, World          <- metadata, key: value per line
date: 2026-06-28
tags: meta
---                          <- marker: frontmatter ends
                             <- everything below: the body, in Markdown
This is the first post...
```

So parsing a post is: split the text at the first two `---` markers, treat the middle as metadata, treat the rest as Markdown. Python's `str.split` can do the splitting in one line - if you know its second argument.

## Splitting on the markers

`text.split("---", 2)` splits on `---` **at most twice**, producing three pieces: whatever is before the first marker (an empty string, since the file starts with it), the frontmatter, and the whole rest of the file.

That `2` is doing quiet, important work. Markdown uses `---` for horizontal rules, so a post body might legitimately contain another `---`. Without the limit, that rule would be treated as a third split point and shred your body. With `maxsplit=2`, the split stops after the frontmatter fence, and any later `---` stays safely inside the body.

⚠️ **Gotcha:** this parser assumes the file *starts with* `---` on the first line. A post that's missing its frontmatter - or has a stray blank line above it - shouldn't limp through and publish a broken page. It should stop the build with a message naming the file. A build tool's job is to fail loudly at build time so nothing fails silently in front of readers.

## Parsing the key: value lines

Frontmatter lines look like `title: Hello, World`. The tempting parse is `line.split(":")` - and it's wrong, because a title like `Docker: A Love Story` contains a colon and would be cut in half. The right tool is `str.partition(":")`, which splits on the **first** colon only and always gives back three pieces: key, the colon, value.

For `tags`, we'll use a comma-separated convention (`tags: meta, web`) and split it into a real Python list.

## Converting the Markdown

The `markdown` library does the body. Watch it work before wiring it in - start a Python session:

```console
(.venv) $ python
>>> import markdown
>>> markdown.markdown("This is **bold** and this is `code`.")
'<p>This is <strong>bold</strong> and this is <code>code</code>.</p>'
>>> exit()
```

*What just happened:* one function call, Markdown in, HTML out. That string of HTML is exactly what we'll pour into a template in phase 3.

⚠️ **Gotcha:** out of the box, the `markdown` library follows the *original* 2004 Markdown spec - which means fenced code blocks (` ``` `) are **not supported by default**. Your second post has one. The fix is one argument: `markdown.markdown(body, extensions=["fenced_code"])`. Forget it, and your code blocks render as garbled paragraphs with backticks in them - a classic first-hour bug with this library.

## The new build.py

Replace the contents of `build.py` with this:

```python
from datetime import datetime
from pathlib import Path

import markdown

POSTS_DIR = Path("posts")


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 main():
    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:
        tags = ", ".join(post["tags"])
        print(f"{post['date']:%Y-%m-%d}  {post['title']}  [{tags}]")


if __name__ == "__main__":
    main()
```

Walk through `parse_post`, because it's the heart of the input side:

- `read_text(encoding="utf-8")` reads the whole file as text. Always name the encoding - on Windows, the default can be something else entirely, and an em-dash in a post becomes mojibake.
- The `startswith` check plus `raise ValueError` is the loud failure from above. The error names the file, so future-you knows which post to fix.
- The metadata loop: `partition(":")` splits each line at the first colon; `.strip()` on both sides forgives stray spaces.
- `meta.get("title", path.stem)` - a missing title falls back to the filename. But `meta["date"]` uses square brackets **on purpose**: a post without a date can't be sorted, so a missing date should crash with a `KeyError` naming the problem, not get silently guessed.
- `datetime.strptime(meta["date"], "%Y-%m-%d")` parses `2026-06-28` into a real `datetime` object. Real dates sort correctly and can be reformatted for display later; strings that merely look like dates eventually betray you.
- `path.stem` is the filename without `.md` - `2026-06-28-hello-world` - and becomes the **slug**, the post's future URL name.
- The tags line splits on commas and drops empties, so `tags: meta, web`, `tags: meta,web`, and no tags line at all are all handled.

📝 **Terminology:** a **slug** is the URL-safe identifier for a piece of content - the `hello-world` part of `/hello-world.html`. Ours comes straight from the filename, so filenames should stay lowercase-with-hyphens.

`main()` now parses every post and sorts by date, newest first - which is exactly the order the index page will want in phase 4.

## Run it

```console
(.venv) $ python build.py
2026-07-02  Why I Went Static  [meta, web]
2026-06-28  Hello, World  [meta]
```

*What just happened:* both files were parsed into dictionaries - real dates, real tag lists, HTML bodies ready and waiting - and printed newest-first. The HTML is in there too; it's simply not printed. If you're curious, add a `print(post["html"][:80])` line for one run and look at it.

Now prove the loud failure works, because untested error handling is decoration. Create `posts/broken.md` containing only the word `oops`, and run the build:

```console
(.venv) $ python build.py
Traceback (most recent call last):
  ...
ValueError: broken.md: no frontmatter block at the top
```

The build refused, and the message names the file. Delete `posts/broken.md` and re-run to see the clean output again.

## What you have now

A generator that reads every post into structured data: title, date, tags, slug, and a converted HTML body - with malformed posts stopped at the door. What it doesn't have is anywhere to *put* that HTML. That's phase 3: templates, and the four-line engine that fills them.

Two of these questions are about the exact bugs this phase defused - worth thirty seconds:

```quiz
[
  {
    "q": "Why does the parser use text.split(\"---\", 2) instead of text.split(\"---\")?",
    "choices": ["It limits splitting to the first two markers, so a --- horizontal rule later in the body is left alone", "It splits the text into exactly two pieces", "It is faster for large files"],
    "answer": 0,
    "explain": "maxsplit=2 yields three pieces: before the frontmatter, the frontmatter, and the entire rest of the file - later --- lines stay in the body."
  },
  {
    "q": "Your posts use fenced code blocks. What does the markdown library need to render them?",
    "choices": ["Nothing - fences work out of the box", "The \"fenced_code\" extension passed to markdown.markdown()", "A separate pip package"],
    "answer": 1,
    "explain": "By default the library follows the original Markdown spec, which predates fences. extensions=[\"fenced_code\"] turns them on."
  },
  {
    "q": "A post is missing its date: line. What does this build do, and why is that the right call?",
    "choices": ["Uses today's date so the build succeeds", "Skips the post silently", "Crashes with a KeyError - a build tool should fail loudly at build time, not publish something wrong"],
    "answer": 2,
    "explain": "meta[\"date\"] uses square brackets deliberately. A guessed date or a silently missing post is a bug you find weeks later; a crash naming the file is one you fix in ten seconds."
  }
]
```


---

# 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."
  }
]
```


---

# The Index, Tags, and an RSS Feed

You have pages, but no front door. A visitor landing on your site right now gets a directory listing, which says "file server," not "blog." This phase generates the three kinds of *derived* pages - pages built not from one post but from knowledge of **all** posts: the index, per-tag pages, and an RSS feed. This is also the phase where you'll feel exactly where a four-line template engine stops being enough, which is a lesson worth having in your bones.

## The index - and the wall our engine hits

The front page is a heading plus a list of links, newest first. Create `templates/index.html`:

```html
<h1>{{ heading }}</h1>
<ul class="post-list">
{{ items }}
</ul>
```

Notice what this template *doesn't* do: it doesn't loop over posts. It can't. Our `render()` replaces placeholders; it has no concept of "repeat this bit for each item." A real engine would write `{% for post in posts %}` right in the template - that loop syntax is precisely what Jinja2 sells.

Our no-nonsense workaround: build the repeated part in Python, where loops live, and hand the finished block to the template as one value.

## Your turn: post_list_items

You already have every piece this needs. You built `post["slug"]`, `post["title"]`, and `post["date"]` back in phase 2, and `parse_post`'s tags line already builds-and-joins a list in one f-string loop - this is the same shape.

```python
def post_list_items(posts):
    # your turn: for each post, build one <li> line shaped like
    #   <li><a href="/SLUG.html">TITLE</a> <span class="date">YYYY-MM-DD</span></li>
    # then join them all with newlines
    return ""
```

An `f"..."` per post, `list.append`, and `"\n".join(...)` at the end - nothing new. My version is in the full file below; run the build and open `http://localhost:8000` and the front page's list is exactly what this function generated.

💡 **Key point:** this is the line where you now know *when* to reach for Jinja2 - the moment your templates need repetition or conditionals, not before. Until then, an engine you fully understand beats one you don't. For a blog this size, the workaround costs eight lines.

The template takes a `heading` instead of hardcoding "All posts" for a reason that pays off immediately: tag pages are the *same shape* - a heading and a list of posts - so one template serves both.

## Tag pages

A tag page is "the index, filtered." The build collects a mapping of tag → posts, then renders the index template once per tag into `site/tags/<tag>.html`:

```python
    tags = {}
    for post in posts:
        for tag in post["tags"]:
            tags.setdefault(tag, []).append(post)
```

`setdefault(tag, [])` means "give me the list for this tag, creating an empty one first if it's new" - the standard idiom for building a dict of lists without checking `if tag in tags` every time.

⚠️ **Gotcha:** the tag becomes a *filename*. `tags: web dev` would produce `site/tags/web dev.html` - a URL with a space in it, which works but looks broken and gets percent-encoded into `web%20dev`. Keep tags to single lowercase words (`python`, `web`, `meta`). Slugifying multi-word tags properly is on the extensions list in phase 6.

## The RSS feed

**What RSS actually is.** A machine-readable table of contents for your site: one XML file listing your recent posts with titles, links, and dates. Feed readers and other sites poll it to learn you've published. It's the oldest open "subscribe" mechanism on the web, it never died, and a blog without one is invisible to the people most likely to actually follow you.

An RSS document looks like this - a `<channel>` describing the site, containing `<item>`s describing posts:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
  <title>My Blog</title>
  <link>https://example.com</link>
  <description>Posts from my hand-built blog.</description>
  <item>
    <title>Why I Went Static</title>
    <link>https://example.com/2026-07-02-why-static-sites.html</link>
    <guid>https://example.com/2026-07-02-why-static-sites.html</guid>
    <pubDate>Thu, 02 Jul 2026 00:00:00 -0000</pubDate>
  </item>
</channel>
</rss>
```

Two details in there are where hand-rolled feeds usually go wrong, and the standard library has the answer to both:

- **`pubDate` must be RFC 822 format** - `Thu, 02 Jul 2026 00:00:00 -0000`, the ancient email date style. Don't hand-format it; `email.utils.format_datetime()` exists precisely because this format is fussy (English day names, specific ordering, a zone offset).
- **Titles must be XML-escaped.** Browsers forgive broken HTML; XML parsers do not. One post titled `Cats & Dogs` with a raw `&` makes the *entire feed* invalid, and every subscriber's reader silently stops updating. `xml.sax.saxutils.escape()` converts `&`, `<`, and `>` into their safe entities.

📝 **Terminology:** `<guid>` is the item's permanent unique identifier - readers use it to remember which items you've already seen. Using the post's URL as its guid is the standard move.

## The complete build.py

This is the full file as it stands at the end of this phase - everything above `post_list_items` is unchanged from phase 3:

```python
import shutil
from datetime import datetime
from email.utils import format_datetime
from pathlib import Path
from xml.sax.saxutils import escape

import markdown

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

SITE_NAME = "My Blog"
SITE_URL = "https://example.com"  # change this when you deploy (phase 6)


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 post_list_items(posts):
    items = []
    for post in posts:
        items.append(
            f'<li><a href="/{post["slug"]}.html">{post["title"]}</a>'
            f' <span class="date">{post["date"]:%Y-%m-%d}</span></li>'
        )
    return "\n".join(items)


def build_feed(posts):
    items = []
    for post in posts[:10]:
        url = f"{SITE_URL}/{post['slug']}.html"
        items.append(
            "  <item>\n"
            f"    <title>{escape(post['title'])}</title>\n"
            f"    <link>{url}</link>\n"
            f"    <guid>{url}</guid>\n"
            f"    <pubDate>{format_datetime(post['date'])}</pubDate>\n"
            "  </item>"
        )
    return (
        '<?xml version="1.0" encoding="UTF-8"?>\n'
        '<rss version="2.0">\n'
        "<channel>\n"
        f"  <title>{escape(SITE_NAME)}</title>\n"
        f"  <link>{SITE_URL}</link>\n"
        "  <description>Posts from my hand-built blog.</description>\n"
        + "\n".join(items)
        + "\n</channel>\n</rss>\n"
    )


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")
    index_template = load_template("index.html")

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

    # one page per post
    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)

    # the front page
    listing = render(index_template, {
        "heading": "All posts",
        "items": post_list_items(posts),
    })
    page = render(base, {"title": SITE_NAME, "content": listing})
    write_page("index.html", page)

    # one page per tag
    tags = {}
    for post in posts:
        for tag in post["tags"]:
            tags.setdefault(tag, []).append(post)
    for tag, tagged in sorted(tags.items()):
        listing = render(index_template, {
            "heading": f'Posts tagged "{tag}"',
            "items": post_list_items(tagged),
        })
        page = render(base, {"title": f"Tag: {tag}", "content": listing})
        write_page(f"tags/{tag}.html", page)

    # the feed
    write_page("feed.xml", build_feed(posts))


if __name__ == "__main__":
    build()
```

A few deliberate choices to notice:

- `posts[:10]` in the feed - RSS convention is recent posts, not your life's work. Readers that want the archive have the site.
- The feed reuses the already-sorted `posts` list, so items come out newest-first, which is what readers expect.
- Tag pages sort alphabetically (`sorted(tags.items())`) so rebuilds are deterministic - the same input always produces byte-identical output, which makes diffs of `site/` meaningful.

## Run it

```console
(.venv) $ python build.py
built  site/2026-07-02-why-static-sites.html
built  site/2026-06-28-hello-world.html
built  site/index.html
built  site/tags/meta.html
built  site/tags/web.html
built  site/feed.xml
```

Serve it again - `python -m http.server 8000 --directory site` - and open `http://localhost:8000`. No more directory listing: your front page lists both posts, newest on top, and each link works. Try `http://localhost:8000/tags/web.html` - only the post tagged `web` appears. And `http://localhost:8000/feed.xml` shows the feed (some browsers render XML as a collapsible tree; that's the browser being helpful, the bytes are yours).

*What just happened:* the build now produces every kind of page a small blog needs - six files from two posts, all derived from the same parsed data, all regenerated from scratch in a fraction of a second.

## What you have now

A complete blog: posts, a front page, tag archives, and a subscribable feed. The remaining friction is workflow - that build-serve-refresh dance every time you edit. Phase 5 collapses it into "save the file, refresh the browser."

Three checks on the load-bearing ideas:

```quiz
[
  {
    "q": "Why is the index's list of <li> lines built in Python instead of in the template?",
    "choices": ["HTML lists cannot be templated", "Our four-line engine has no loops - repetition is exactly the feature real engines like Jinja2 add on top of substitution", "Python string building is faster than templating"],
    "answer": 1,
    "explain": "render() only substitutes placeholders. The moment templates need loops or conditionals is the moment a real template engine earns its dependency."
  },
  {
    "q": "Why must post titles go through escape() in the RSS feed?",
    "choices": ["To convert them to lowercase", "XML parsers are strict - one raw & or < in a title invalidates the whole feed and subscribers silently stop getting updates", "RSS requires base64-encoded titles"],
    "answer": 1,
    "explain": "Browsers forgive malformed HTML; feed readers do not forgive malformed XML. escape() turns &, <, and > into safe entities."
  },
  {
    "q": "What format does RSS's pubDate use, and what produces it here?",
    "choices": ["ISO 8601 (2026-07-02), via datetime.isoformat()", "A Unix timestamp, via time.time()", "RFC 822 style (Thu, 02 Jul 2026 00:00:00 -0000), via email.utils.format_datetime()"],
    "answer": 2,
    "explain": "RSS inherited the old email date format. format_datetime() exists in the standard library precisely so you never hand-build it."
  }
]
```


---

# A Dev Server with Auto-Rebuild

Right now your writing loop is: edit a post, switch to the terminal, run `python build.py`, switch to the browser, refresh. Four steps to see one typo fixed. Every serious static site tool ships a dev server that collapses this to "save, refresh" - and in this phase you build that: one script that serves `site/` and rebuilds automatically whenever a source file changes.

First, a question you may have been sitting on since phase 3.

## Why not just double-click the HTML file?

Try it: open `site/index.html` straight from your file manager. The text is there - and the styling is gone.

The page isn't broken. The stylesheet link is `/style.css`, a root-relative path meaning "from the root of the site." Served over HTTP, the root is your `site/` folder, and the link resolves to `http://localhost:8000/style.css`. Opened as a file, the "root" is the root of your *disk*, so the browser looks for `C:\style.css` or `/style.css` on your drive - which doesn't exist.

You could switch every link to relative paths, but then pages inside `site/tags/` would need `../style.css` while pages at the top level need `style.css`, and every template would have to know how deep its page lives. Root-relative paths plus a real server is the arrangement every static site tool settles on. So: a real server, always - it's one import away.

## The plan

`serve.py` does three things:

1. Build the site once at startup.
2. Start a background thread that checks the source folders twice a second and rebuilds when anything changed.
3. Serve `site/` over HTTP in the foreground until you press Ctrl+C.

```mermaid
sequenceDiagram
  participant E as You (editor)
  participant W as Watcher thread
  participant B as build()
  participant Br as Browser
  E->>W: save posts/draft.md
  W->>B: mtime changed - rebuild
  B-->>W: site/ regenerated
  Br->>Br: you refresh - new page is there
```

The "did anything change?" trick is modification times. Every file has an **mtime** - the timestamp of its last write, which `path.stat().st_mtime` reads. Snapshot every source file's mtime into a dict; if a later snapshot differs in any way - a file edited, added, or deleted - something changed, rebuild.

This is *polling*: asking "anything new?" on a timer, the approach we dismissed for chat apps. For a dev tool watching a few dozen local files, it's the right call - checking 20 mtimes twice a second is work your machine won't notice, and the alternative (OS-level file notifications via the `watchdog` package) buys you a dependency and platform quirks for no visible gain at this size. If your blog ever has thousands of files, that's when `watchdog` earns its slot.

## serve.py

Create `serve.py` next to `build.py`:

```python
import http.server
import threading
import time
from functools import partial

import build

WATCH_DIRS = [build.POSTS_DIR, build.TEMPLATES_DIR, build.STATIC_DIR]
PORT = 8000


def snapshot():
    stamps = {}
    for folder in WATCH_DIRS:
        for path in folder.rglob("*"):
            if path.is_file():
                stamps[path] = path.stat().st_mtime
    return stamps


def watch():
    last = snapshot()
    while True:
        time.sleep(0.5)
        current = snapshot()
        if current != last:
            last = current
            print("Change detected - rebuilding...")
            try:
                build.build()
            except Exception as error:
                print("Build failed:", error)


def main():
    build.build()
    threading.Thread(target=watch, daemon=True).start()

    handler = partial(http.server.SimpleHTTPRequestHandler,
                      directory=str(build.SITE_DIR))
    server = http.server.ThreadingHTTPServer(("127.0.0.1", PORT), handler)
    print(f"Serving on http://127.0.0.1:{PORT} - Ctrl+C to stop")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nStopped.")


if __name__ == "__main__":
    main()
```

The load-bearing lines:

- **`import build`** - your generator is now a *library* as well as a script. This works cleanly because of the `if __name__ == "__main__":` guard you wrote back in phase 1: importing a module runs its top-level code, and without the guard, `import build` would kick off a build as a side effect of the import itself. With it, `serve.py` gets the functions and constants (`build.build`, `build.SITE_DIR`) and stays in control of *when* building happens. That guard you've been dutifully typing finally cashes its check.
- **`snapshot()`** walks every file under the three source folders (`rglob("*")` is glob, recursive) and maps each path to its mtime. Comparing two dicts with `!=` catches edits (value differs), new files (new key), and deletions (missing key) in one clean comparison.
- **`watch()` wraps the rebuild in try/except** - and this one matters. Mid-edit, your post *will* be momentarily malformed; you'll save half-written frontmatter and `parse_post` will raise, exactly as designed. Without the except, that kills the watcher thread silently and rebuilds just... stop, with no clue why. With it, you see `Build failed: draft.md: no frontmatter block at the top`, finish your edit, save, and the next cycle rebuilds cleanly.
- **`daemon=True`** marks the watcher as a background thread that Python should *not* wait for at exit. When Ctrl+C stops the server, the process ends and the daemon thread dies with it. Without it, the process would hang at exit, waiting forever on a thread whose loop never returns.
- **`partial(SimpleHTTPRequestHandler, directory=...)`** - the standard library's file server, told to serve `site/` instead of the current folder. `ThreadingHTTPServer` handles each request in its own thread so a slow request can't block the rest; both have shipped with Python since 3.7.

One constraint to know: `build.py` uses relative paths (`Path("posts")`), so run `serve.py` from the project root - `python serve.py` while sitting in `myblog/`, not from elsewhere.

## Run it

```console
(.venv) $ python serve.py
built  site/2026-07-02-why-static-sites.html
built  site/2026-06-28-hello-world.html
built  site/index.html
built  site/tags/meta.html
built  site/tags/web.html
built  site/feed.xml
Serving on http://127.0.0.1:8000 - Ctrl+C to stop
```

Open `http://127.0.0.1:8000`, then - leaving the server running - edit a post: change the title in `posts/2026-06-28-hello-world.md` to `Hello, World (edited)` and save. Within half a second, the server terminal prints:

```console
Change detected - rebuilding...
built  site/2026-07-02-why-static-sites.html
built  site/2026-06-28-hello-world.html
built  site/index.html
built  site/tags/meta.html
built  site/tags/web.html
built  site/feed.xml
127.0.0.1 - - [06/Jul/2026 14:32:07] "GET / HTTP/1.1" 200 -
```

Refresh the browser: the new title is on the index and the post page. (Those `GET ... 200` lines are the request log `SimpleHTTPRequestHandler` prints for every page it serves - normal, and occasionally useful.)

*What just happened:* the watcher noticed the post's mtime change, called the same `build()` your command line calls, and the server - which knows nothing about builds, it serves whatever's in `site/` - handed out the fresh files on your next refresh. Save, refresh. The loop you'll actually write in.

Change the title back, watch it rebuild again, and leave the server running - you'll write with it from now on.

## What you have now

A writing workflow: `python serve.py`, open localhost, and edit - the site rebuilds itself as you save, and broken saves report themselves instead of silently jamming the loop. The blog is done and pleasant to work on. All that's left is the part your friends can see: putting `site/` on the internet.

Three questions - the first one explains a bug you'll hit in other projects too:

```quiz
[
  {
    "q": "Why does the CSS vanish when you open site/index.html directly from the file system?",
    "choices": ["Browsers refuse to load CSS from local disk", "Root-relative paths like /style.css resolve against the root of your drive under file://, not against the site folder", "The build forgot to copy the stylesheet"],
    "answer": 1,
    "explain": "Over HTTP, / means \"top of the served site.\" Under file://, it means \"top of the disk\" - where there is no style.css. That's why static site tools always ship a dev server."
  },
  {
    "q": "Why doesn't `import build` at the top of serve.py trigger a site build by itself?",
    "choices": ["Python never executes code during an import", "build() is only called inside `if __name__ == \"__main__\":`, which is false when the module is imported", "serve.py suppresses build's output"],
    "answer": 1,
    "explain": "Importing runs a module's top-level code. The __main__ guard keeps the build call out of that path, so importers get the functions and decide when to call them."
  },
  {
    "q": "What does daemon=True on the watcher thread change?",
    "choices": ["The thread gets higher CPU priority", "Python exits without waiting for it - so Ctrl+C stops the server and the watcher dies with the process instead of hanging it", "The thread restarts automatically if it crashes"],
    "answer": 1,
    "explain": "The watcher's loop never returns. As a normal thread it would keep the process alive forever after Ctrl+C; as a daemon it's abandoned at exit."
  }
]
```


---

# Deploying, and Where to Take It

Here's the payoff of the build-time mental model from phase 1: your entire blog is the `site/` folder. Not "the app plus a database plus environment variables" - a folder of files. Deploying means getting that folder onto any computer that hands files out over HTTP, and because that's the cheapest thing a host can possibly do, several companies do it for free.

Two good routes below. The first takes two minutes; the second is the one you'll keep.

## Route one: Netlify Drop (two minutes, zero tools)

Netlify's "Drop" page deploys a folder you drag onto it.

1. Run a fresh build so `site/` is current: `python build.py`.
2. Go to `app.netlify.com/drop` and sign in (free account).
3. Drag the `site/` folder from your file manager onto the page.

That's the deploy. Netlify uploads the files and gives you a live URL like `https://amazing-curie-1a2b3c.netlify.app` - open it on your phone, send it to a friend. Root-relative links work unchanged, because your site sits at the root of that domain.

One follow-up worth doing: your feed still says `https://example.com`. Set `SITE_URL` in `build.py` to your real URL, rebuild, and drag the folder in again. Every future deploy is that same motion - build, drag. Fine for a while; annoying forever. Hence route two.

## Route two: GitHub Pages (the durable setup)

GitHub Pages serves a folder straight out of a Git repository - push to deploy, history included, free. Getting there involves two adjustments to the project and one warning that saves you a bad evening.

⚠️ **Gotcha - the subpath problem, read before creating the repo.** A GitHub Pages site for a repo named `myblog` lives at `https://<username>.github.io/myblog/` - in a *subfolder* of the domain. Your links are root-relative, so `/style.css` would resolve to `https://<username>.github.io/style.css` - above your site, where nothing exists. Unstyled pages, dead links, and the classic "works locally, broken on Pages" report. The clean escape: **name the repository `<username>.github.io`** (your actual GitHub username). That special name serves the site at the domain root, `https://<username>.github.io/`, and every link keeps working exactly as it does locally. Supporting arbitrary repo names needs a configurable base-URL prefix on every link - a real feature, listed under extensions below.

**Adjustment one: output to `docs/`.** Pages can serve from a folder named `docs` on your main branch - which lets one repository hold both your source *and* your published site. One line in `build.py`:

```python
SITE_DIR = Path("docs")
```

**Adjustment two: tell Pages not to run Jekyll.** GitHub Pages runs every site through Jekyll (its own static site generator - a colleague of the one you wrote) unless told otherwise, and Jekyll skips files and folders starting with underscores. Yours has none today, but the standard opt-out is an empty file named `.nojekyll` in the output, and having the build create it means you never think about this again. Add one line at the end of `build()`, next to the feed line:

```python
    (SITE_DIR / ".nojekyll").write_text("")
```

Also set `SITE_URL` to `https://<username>.github.io` while you're in the file, then rebuild:

```console
(.venv) $ python build.py
built  docs/2026-07-02-why-static-sites.html
built  docs/2026-06-28-hello-world.html
built  docs/index.html
built  docs/tags/meta.html
built  docs/tags/web.html
built  docs/feed.xml
```

Everything now lands in `docs/`, because every write in `build()` routes through `SITE_DIR` - one constant changed, the whole build followed. That's the payoff of never hardcoding a path twice.

**Now ship it.** Create a `.gitignore` so the environment stays out of the repo:

```text
.venv/
__pycache__/
```

Then the usual motions - create the empty repo named `<username>.github.io` on GitHub first, then:

```console
$ git init
$ git add .
$ git commit -m "A blog, and the generator that builds it"
$ git remote add origin https://github.com/<username>/<username>.github.io.git
$ git push -u origin main
```

On GitHub: **Settings → Pages → Build and deployment → Deploy from a branch**, pick `main` and `/docs`, save. A minute or two later, `https://<username>.github.io` is your blog. From now on, publishing a post is: write the Markdown, `python build.py`, commit, push.

Notice what you're committing: source *and* output, together. Some people find committing generated files inelegant; the trade is that your repo needs no build step on GitHub's side - no Actions workflow, no CI configuration. For a personal blog built by a script you own, boring wins. When that bothers you enough, "build in CI" is on the list below.

## Where to take it next

The core is done, and it's genuinely yours. The real extension list, roughly in the order people want them:

| Want | What it takes |
|------|--------------|
| **Drafts** | A `draft: true` frontmatter key, and one `if` in `build()` that skips those posts. Ten minutes, very satisfying. |
| **Prettier URLs** | `/hello-world/` instead of `/2026-06-28-hello-world.html`: strip the date prefix from the slug and write each post as `slug/index.html` - `write_page` already creates folders. |
| **A base URL** | The fix for the subpath gotcha: a `BASE_URL` constant prefixed onto every generated link, so the site works under `/myblog/` too. |
| **Syntax highlighting** | Add `"codehilite"` to the extensions list and `pip install pygments`, then generate the highlight CSS with `pygmentize -S default -f html`. Your code blocks get real coloring at build time - no JavaScript. |
| **A sitemap** | `sitemap.xml` is a sibling of the RSS feed - same "loop posts, emit XML" shape. Search engines read it; `build_feed` is your template. |
| **Jinja2** | When templates need loops and conditionals badly enough, `pip install jinja2` and delete `render()`. You'll learn it in an hour, because you already know what it's doing underneath. |
| **Build in CI** | A GitHub Actions workflow that runs `build.py` on push, so the repo holds only source. The Pages docs cover the deploy action; your build stays one command. |
| **Incremental builds** | Only rebuild changed pages. Real complexity (dependency tracking - a template edit invalidates *everything*) for speed you won't need below hundreds of posts. Know it exists; skip it until it hurts. |

## What you built

A static site generator, from an empty folder: a frontmatter parser that fails loudly, a Markdown pipeline, a template engine you wrote in four lines and understand completely, an index, tag archives, a standards-compliant RSS feed, a dev server with an auto-rebuild loop, and a deployed site with a URL.

More than the blog, you built the mental model. Build time versus request time. Source versus artifact. Templates as substitution. Feeds as contracts strict enough to escape for. The next time you open a Hugo project or a `_config.yml`, you won't be reading incantations - you'll be reading design decisions by people who solved the same problems you solved this weekend, and you'll know why.
