A static blog generator in 180 lines
Markdown in, HTML out, nothing else. No plugins, no themes, no config file with forty keys.
Every static site generator I have ever adopted followed the same arc: delight, config, plugins, a themes directory, a breaking release, and finally a morning where I just wanted to fix a typo and instead spent forty minutes upgrading a toolchain. The generator was supposed to serve the writing. Somewhere along the way the writing started serving the generator.
So I wrote the dumbest one that could work. One Python file, no dependencies, about 180 lines. Markdown goes in, HTML comes out, and the whole thing fits in your head. This post walks through the interesting 40 of those lines.
The constraints are the feature
- No config file. Conventions instead: posts live in
posts/, the template istemplate.html, output goes tosite/. - No dependencies. Standard library only, so it still runs in ten years.
- No plugins. If I need a feature, I edit the script. The script is the plugin system.
The whole pipeline
def build():
posts = []
for path in sorted(glob("posts/*.md")):
meta, md = front_matter(read(path))
posts.append({**meta,
"slug": stem(path),
"html": markdown(md)})
posts.sort(key=lambda p: p["date"], reverse=True)
tpl = read("template.html")
for p in posts:
write(f"site/{p['slug']}.html", render(tpl, p))
write("site/index.html", render_index(posts))
write("site/feed.xml", render_rss(posts))
That is genuinely the whole architecture. Three passes: parse, sort, render. Everything else is detail.
Front matter without a YAML library
Post metadata is key-colon-value lines between two --- fences. A YAML parser is 20,000 lines; this is four:
def front_matter(text):
_, head, body = text.split("---", 2)
meta = dict(line.split(":", 1) for line in head.strip().splitlines())
return {k.strip(): v.strip() for k, v in meta.items()}, body
Does it handle nested lists, anchors and multi-line strings? No. Do my posts need those? Also no. Most dependency weight in a typical generator is paying for flexibility that no individual blog uses.
Templates are .replace()
The template engine is string substitution: {{title}} in, value out, one re.sub with a dict lookup. No loops, no conditionals in templates — if a page needs logic, the logic lives in Python where I can debug it. The index page and the RSS feed are just two more render functions, 24 and 14 lines respectively.
The line budget
| Part | Lines | Notes |
|---|---|---|
| Markdown subset → HTML | 58 | Headers, lists, links, code fences, emphasis |
| Front matter + file IO | 32 | The four lines above, plus errors that say which file |
| Templates + index | 24 | String substitution, nothing clever |
| RSS feed | 14 | Hand-assembled XML; validators approve |
| CLI + watch mode | 18 | build and serve, via stdlib http.server |
| Comments and air | 34 | Future-me reads this file too |
The markdown converter is the only part with real trade-offs: it covers the subset I write and nothing more. No tables-in-markdown, no footnote syntax. When a post needs a table I write HTML in the markdown file, and the converter passes raw HTML through untouched. That escape hatch removes 90% of the pressure to grow.
Full disclosure
The site you are reading is built by this script's slightly vainer cousin — same three-pass design, plus the sticker CSS baked into the template. The generator script ships in the site folder; stealing it is the intended use.
Software you can read in one sitting is software you still own. Everything larger owns you a little.