Code

A static blog generator in 180 lines

Markdown in, HTML out, nothing else. No plugins, no themes, no config file with forty keys.

L Lagom •Aug 2, 2026 •3 min read •★ 96 likes

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 is template.html, output goes to site/.
  • 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

blog.py — the core
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:

blog.py — front matter
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

PartLinesNotes
Markdown subset → HTML58Headers, lists, links, code fences, emphasis
Front matter + file IO32The four lines above, plus errors that say which file
Templates + index24String substitution, nothing clever
RSS feed14Hand-assembled XML; validators approve
CLI + watch mode18build and serve, via stdlib http.server
Comments and air34Future-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.

i

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.

Written by Lagom

I build small tools, stress-test larger models, and take notes so future-me has receipts. More posts — and the occasional abandoned draft — under the blog, or find me via the about page.