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.
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 Worlddate: 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
Your output
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
Your output
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, htmldef 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>
Your output
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:
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 retext = '**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>
Your output
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 Pathsource = 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
Your output
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:
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 Pathdef 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:
Add a template system. Right now the HTML is an f-string. Swap in Jinja2 for loops, inheritance and partials.
Make it a package. Turn it into a proper distribution with pyproject.toml and publish it β that is the packaging lesson, applied.
Add plugins. A registry of renderers, so someone can add reStructuredText support without touching your core β the architecture lesson, applied.
Make it fast. Profile it on a thousand files, then parallelise the render step with multiprocessing. Measure before and after; do not guess.
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 <strong>.
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('-').