ssg.py: How the Templates and Themes Actually Work
A few weeks ago I wrote about why I built my own static site generator instead of just installing one. That post was mostly the origin story: four sites, four different publishing quirks, and me deciding to reinvent the wheel on purpose. What it didn’t cover was the actually useful part, which is how you build a template and a theme once you’re sitting in front of ssg.py wanting a real site instead of a design decision.
So this is the follow-up. It’s also the last thing I needed to do before I felt comfortable making the code public, since “here’s a tool, good luck” is a worse gift than “here’s a tool, and here’s exactly how the pieces fit together.” The code is now up at github.com/okubax/ssg.py if you want to follow along in the actual source rather than take my word for any of this.
The four folders
Every site is the same four folders, whether it’s this blog or a school’s one-page site:
config.yml
content/
posts/
pages/
templates/
static/
content/posts/ is blog posts, content/pages/ is everything else that isn’t a post, both written as Markdown with a small YAML block on top. templates/ is HTML. static/ gets copied into the built site exactly as it is, no processing, which is where CSS, images and a favicon live. config.yml is site-wide settings, and it’s genuinely optional beyond existing: no posts, no pages, no static folder, the build just produces less. There’s nothing to register or opt into.
Front matter, and what it actually needs
A post looks like this:
---
title: "My post"
tags: [linux, notes]
---
The rest of the file is Markdown.
Notice there’s no date. For posts, both the date and the URL slug come from the filename by default, so 2026-09-20-my-post.md needs nothing else in front matter at all. You only add a date: field when you want to override the filename, and only add slug: when you want the URL to say something different from what the filename would naturally produce. A lot of my older posts on this site have filenames like nkurunziza_burundi.md with an explicit slug: why-history-will-judge-burundis-pierre-nkurunziza-harshly in front matter, because the actual published URL and my own internal filename were never meant to be the same string. Whatever key you put in front matter, recognised or not, shows up in a template as page.whatever_you_called_it, so a post can carry arbitrary extra data without touching any Python.
The template language
This is the part that surprises people, since a “tiny zero-dependency” tool sounds like it should mean crude string substitution. It doesn’t. Templates go through an actual tokenizer and a small recursive-descent parser, and the syntax is deliberately shaped like Jinja2, because there was no reason to invent new punctuation for a solved problem:
{{ variable }}
{{ variable | filter }}
{{ variable | filter(argument) }}
{% if condition %} ... {% elif other %} ... {% else %} ... {% endif %}
{% for item in items %} ... {% else %} ... {% endfor %}
{% include "partial/card.html" %}
{% extends "base.html" %}
{% block content %} ... {% endblock %}
Inside a loop you get loop.index, loop.first, loop.last, loop.length, loop.prev and loop.next, the same set Jinja gives you, because I kept reaching for them out of habit and it was easier to support them than to retrain myself.
extends/block is what makes a theme feel like a theme rather than a pile of copy-pasted HTML. One base.html owns the <head>, the header, the footer, and declares a {% block content %}. Every other template extends it and only fills in that one block:
<!-- templates/base.html -->
<!DOCTYPE html>
<html>
<head><title>{{ site.title }}</title></head>
<body>
{% block content %}{% endblock %}
</body>
</html>
<!-- templates/post.html -->
{% extends "base.html" %}
{% block content %}
<h1>{{ page.title }}</h1>
{{ content }}
{% endblock %}
That’s a genuinely complete, if plain, theme. Everything I’ve done to any of my four sites since has been more {% for %} loops, more filters, and more partials, never a change to that basic shape.
Filters, and adding your own
Filters are the | thing after a pipe, and there’s a normal Jinja-shaped set built in: date(fmt), slugify, truncatewords(n), join(sep, attr), default(fallback), striptags, and so on. The interesting part is how cheap it is to add one, because there’s no plugin interface to learn, just a decorator sitting in the same file as everything else:
@filter_('shout')
def _f_shout(v):
return str(v).upper() + '!'
Ncircular’s theme has a card_image filter that picks a hand-drawn map SVG based on a post’s category, and a cloud_size filter that buckets a tag’s post count into a CSS class for a tag cloud. Neither belongs in the generic tool, both took about five lines, and both are just Python functions sitting next to the ones I’ve had since the first version.
Archive pages appear when their template does
This is the convention I’m probably proudest of, because it replaces what would normally be a config flag with a much simpler rule: if templates/tag.html exists, tag archive pages get built. If it doesn’t, they don’t. Same for category.html, author.html, and period_archives.html for year/month pages. There’s no enable_tags: true to remember, because the presence of the file already tells the generator everything it needs to know.
Practically, this means growing a theme is additive. Start with base.html, index.html, and post.html, and you have a working blog. Decide six months later that you want author pages, drop in an author.html, and they show up on the next build with zero other configuration. This site went from “no tag pages” to “tag pages, with a full tag cloud on a Topics page” for ncircular without changing a single line in ssg.py itself, only templates.
What actually changes between my four sites
Concretely: okubax has a plain post list and no category system at all, since a personal blog doesn’t need one. Ncircular has a card-based homepage, a live news strip pulled from a separate RSS aggregator at build time, and country-flag-shaped SVG art per category. Elyon is a one-page site with no blog whatsoever, just page.html and a lot of CSS. The hug foundation site kept its existing Bootstrap-based design and just swapped what was generating the HTML underneath it. Same generator, same front-matter conventions, four completely different-looking results, because the generator’s job stops at “here is your data, here is a place to loop over it,” and everything past that point is CSS and template structure, which is exactly where I wanted the boundary to sit.
Try it
The repo has a small working example under example/, config, templates, two posts, a stylesheet, the lot, so you can see a full site’s worth of files in one place rather than piece them together from this post:
git clone https://github.com/okubax/ssg.py.git
cd ssg.py/example
python3 ../ssg.py build
python3 -m http.server -d output
The README covers the rest: every config key, the full filter list, and which template name maps to which kind of page. If you build something with it, or find a sharp edge, the repo’s issues are open.