Capstone: Build a Static Site Generator

Python 3.14 Β· βœ“ verified by execution on 2026-08-01

This is the last lesson, so let’s build something real. A static site generator takes a folder of markdown files and turns it into a folder of HTML β€” the same job Jekyll, Hugo and Eleventy do, and the same job the site you are reading does.

Underneath the tooling, it is three steps: read, transform, write. We will build each stage separately and run it, then assemble them.

*.md files front matter render HTML template *.html out metadata collected -> index page
The whole pipeline: read each markdown file, split its metadata from its body, render the body to HTML, wrap it in a template, and write it out β€” collecting metadata along the way to build an index.

Stage 1 β€” Split Metadata From Content

Every post carries data about the post β€” title, date β€” separated from the body by --- fences. That block is called front matter.

Note line.partition(':'): unlike split, it always returns three parts, so a malformed line without a colon cannot crash the unpack.

python
def parse_front_matter(text):
    if not text.startswith('---'):
        return {}, text
    _, fm, body = text.split('---', 2)
    meta = {}
    for line in fm.strip().splitlines():
        key, _, value = line.partition(':')
        meta[key.strip()] = value.strip()
    return meta, body.strip()

page = '''---
title: Hello World
date: 2026-08-01
---
# My first post
'''

meta, body = parse_front_matter(page)
print(meta['title'])
print(meta['date'])
print(body)
Output
Hello World
2026-08-01
# My first post

The first line of that function is the one that matters in production. A file without front matter still has to build:

python
text = 'no front matter here'

def parse_front_matter(t):
    if not t.startswith('---'):
        return {}, t
    _, fm, body = t.split('---', 2)
    return {'raw': fm.strip()}, body.strip()

meta, body = parse_front_matter(text)
print(meta)
print(body)
Output
{}
no front matter here

Stage 2 β€” Render Markdown

Now the transformation. Headings first, then inline formatting. Watch the order inside the else branch: escape first, then insert tags. Do it the other way round and html.escape will mangle the <strong> you just added.

python
import re, html

def render(md):
    out = []
    for line in md.splitlines():
        if line.startswith('## '):
            out.append(f'<h2>{html.escape(line[3:])}</h2>')
        elif line.startswith('# '):
            out.append(f'<h1>{html.escape(line[2:])}</h1>')
        elif line.strip() == '':
            continue
        else:
            text = html.escape(line)
            text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text)
            text = re.sub(r'\*(.+?)\*', r'<em>\1</em>', text)
            out.append(f'<p>{text}</p>')
    return '\n'.join(out)

print(render('# Title\n\nHello **world** and *friends*.\n\n## Section\n\nDone.'))
Output
<h1>Title</h1>
<p>Hello <strong>world</strong> and <em>friends</em>.</p>
<h2>Section</h2>
<p>Done.</p>

The Two Bugs Hiding In Stage 2

The first is security. Every generator must escape, or a stray < silently eats the rest of the page:

python
import html

user_text = '<script>alert(1)</script>'
print(html.escape(user_text))
Output
&lt;script&gt;alert(1)&lt;/script&gt;

The second is the regex. .+? is non-greedy β€” it stops at the first closing marker. Drop the ? and it runs to the last one, merging two separate bold spans into a single mangled one. Run both and compare:

python
import re

text = '**one** and **two**'
print(re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text))
print(re.sub(r'\*\*(.+)\*\*', r'<b>\1</b>', text))
Output
<b>one</b> and <b>two</b>
<b>one** and **two</b>

That second line is a real bug that ships in hand-rolled markdown parsers constantly.

Stage 3 β€” Work Out Where Files Go

posts/hello-world.md has to become public/hello-world.html. pathlib does this without any string surgery:

python
from pathlib import Path

source = Path('content/posts/hello-world.md')
print(source.stem)
print(source.suffix)
print((Path('public') / source.with_suffix('.html').name).as_posix())
Output
hello-world
.md
public/hello-world.html

Stage 4 β€” Build the Index

While looping over files you collect each page’s metadata. That collection is your home page β€” sort it newest-first and you have a blog index:

python
pages = [
    {'title': 'Older', 'date': '2026-01-05'},
    {'title': 'Newest', 'date': '2026-08-01'},
    {'title': 'Middle', 'date': '2026-04-17'},
]

for page in sorted(pages, key=lambda p: p['date'], reverse=True):
    print(page['date'], page['title'])
Output
2026-08-01 Newest
2026-04-17 Middle
2026-01-05 Older

Assembling It

Each stage above ran on its own, which is exactly why the finished loop works first time. On a real filesystem the whole generator is roughly this:

from pathlib import Path

def build(src_dir='content', out_dir='public'):
    pages = []
    Path(out_dir).mkdir(exist_ok=True)

    for md_file in sorted(Path(src_dir).glob('*.md')):
        meta, body = parse_front_matter(md_file.read_text(encoding='utf-8'))
        html_body = render(body)
        out_path = Path(out_dir) / md_file.with_suffix('.html').name
        out_path.write_text(
            f"<!doctype html><title>{meta.get('title', '')}</title>{html_body}",
            encoding='utf-8',
        )
        pages.append({**meta, 'url': out_path.name})

    index = '\n'.join(
        f"<li><a href=\"{p['url']}\">{p['title']}</a></li>"
        for p in sorted(pages, key=lambda p: p.get('date', ''), reverse=True)
    )
    (Path(out_dir) / 'index.html').write_text(f'<ul>{index}</ul>', encoding='utf-8')
    return len(pages)
# build() writes public/*.html and public/index.html, and returns the page count

Thirty lines. No framework, no dependencies β€” just the standard library and the ideas from the last sixty lessons: file handling, comprehensions, regular expressions, pathlib, dictionaries, sorting with a key.

Where To Take It

You have finished the Python track, so here are the natural next moves β€” each one reuses a lesson you have already done:

Watch Out

Myth: A static site generator needs a template engine and a framework to be real. Reality: Read, transform, write. An f-string is a perfectly good template right up until you need loops and inheritance β€” and knowing exactly when you crossed that line is the mark of an expert.

Myth: Escaping is only for content other people submit. Reality: It is what stops your own code sample containing <div> from destroying the page. Escape by default; unescape deliberately.

Check yourself

Why use str.partition(':') rather than str.split(':') when parsing a front-matter line?

Reveal answer

partition always returns three parts, so a line with no colon cannot crash the unpack β€” partition() returns (before, sep, after) even when the separator is missing, so `key, _, value = line.partition(':')` never raises.

What does the greedy pattern r'\*\*(.+)\*\*' do to the text '**one** and **two**'?

Reveal answer

Matches from the first ** to the LAST **, merging both spans into one β€” Greedy .+ takes as much as possible, so it spans from the first marker to the last. The non-greedy .+? stops at the first closing marker.

When must html.escape() be applied?

Reveal answer

Before inserting your own tags, or it will escape the markup you just generated β€” Escape the raw text first, then wrap it in <strong> or <p>. Escaping afterwards would turn your own tags into visible &lt;strong&gt;.

Which pathlib attribute gives you 'hello-world' from Path('posts/hello-world.md')?

Reveal answer

.stem β€” .stem is the filename without its extension; .name keeps the extension and .suffix is just '.md'.

Challenges

Challenge 1 +75 XP

Write slugify(title) that lowercases the title, replaces every run of non-alphanumeric characters with a single hyphen, and strips leading and trailing hyphens. Print slugify('Hello, World! 2026').

python
  • Test 1 β€” expects "hello-world-2026\n"
Need a hint? (βˆ’25% XP)

Lowercase first, then re.sub(r'[^a-z0-9]+', '-', ...), then .strip('-').

Show solution (0 XP)
import re

def slugify(title):
    s = re.sub(r'[^a-z0-9]+', '-', title.lower())
    return s.strip('-')

print(slugify('Hello, World! 2026'))

Challenge 2 +100 XP

Print one line per page as '<date> - <title>', newest first.

python
  • Test 1 β€” expects "2026-09-09 - Gamma\n2026-05-20 - Beta\n2026-02-01 - Alpha\n"
Need a hint? (βˆ’25% XP)

sorted(pages, key=lambda p: p['date'], reverse=True), then an f-string.

Show solution (0 XP)
pages = [
    {'title': 'Alpha', 'date': '2026-02-01'},
    {'title': 'Gamma', 'date': '2026-09-09'},
    {'title': 'Beta', 'date': '2026-05-20'},
]

for page in sorted(pages, key=lambda p: p['date'], reverse=True):
    print(f"{page['date']} - {page['title']}")