DEV Community

mrnoyy
mrnoyy

Posted on

A Static Site Generator in Python When the Framework Is Too Much

Some sites are a data file and a template. Reaching for a full framework there means a build toolchain, a dependency tree, and a monthly upgrade tax for something that is genuinely for item in data: render.

Here is the shape of a generator small enough to read in one sitting.

The pieces

content/       # JSON or YAML, one file per entity
templates/     # Jinja2
static/        # copied verbatim
build.py
Enter fullscreen mode Exit fullscreen mode

jinja2 is the only real dependency. Everything else is standard library.

from pathlib import Path
from jinja2 import Environment, FileSystemLoader
import json, shutil

OUT = Path("dist")
env = Environment(
    loader=FileSystemLoader("templates"),
    autoescape=True,
    trim_blocks=True,
    lstrip_blocks=True,
)

def render(template_name, out_path, **context):
    html = env.get_template(template_name).render(**context)
    target = OUT / out_path
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(html, encoding="utf-8")
Enter fullscreen mode Exit fullscreen mode

autoescape=True from the first line. Turning it on later, after templates have been written assuming raw output, means auditing every one of them.

Decision 1: rebuild everything, every time

No incremental builds, no dependency graph, no cache invalidation. Delete dist/ and regenerate.

At a few hundred pages this takes a second or two. Incremental builds are where generators go to become frameworks: you need to know that changing a shared partial invalidates every page that includes it, and getting that subtly wrong produces stale pages that are miserable to debug. Full rebuilds are always correct.

if OUT.exists():
    shutil.rmtree(OUT)
OUT.mkdir()
Enter fullscreen mode Exit fullscreen mode

Decision 2: validate before rendering

A missing key in a template renders as an empty string. Jinja will not stop you, so you ship a page with a blank title and find out later.

REQUIRED = {"slug", "title", "description"}

def load_items():
    items = []
    for path in sorted(Path("content").glob("*.json")):
        data = json.loads(path.read_text(encoding="utf-8"))
        missing = REQUIRED - data.keys()
        if missing:
            raise SystemExit(f"{path.name}: missing {', '.join(sorted(missing))}")
        items.append(data)
    return items
Enter fullscreen mode Exit fullscreen mode

Fail the build. A crashed build is a five-second fix; a silently wrong page is a week of not noticing.

Also check for duplicate slugs. Two entries with the same slug means one overwrites the other and the build reports success.

Decision 3: URLs are computed in one place

def url_for(item):
    return f"/reviews/{item['slug']}/"
Enter fullscreen mode Exit fullscreen mode

Templates call url_for, the sitemap calls url_for, the canonical tag calls url_for. When you change your URL structure — and you will — it changes in one function instead of in nine templates and a sitemap script that you forget about.

Write the directory form, reviews/slug/index.html, so the trailing-slash URL works on any host without a redirect.

The sitemap comes from disk

After the build, walk what was actually written:

def write_sitemap(base_url):
    urls = []
    for path in sorted(OUT.rglob("index.html")):
        rel = path.parent.relative_to(OUT).as_posix()
        urls.append(f"{base_url}/{rel}/" if rel != "." else f"{base_url}/")
    # ... write the XML
Enter fullscreen mode Exit fullscreen mode

Generating it from the content list instead means that if a page failed to render, your sitemap still advertises it. Reading the output directory cannot make that mistake.

Static files, and the cheap win

shutil.copytree("static", OUT / "static", dirs_exist_ok=True)
Enter fullscreen mode Exit fullscreen mode

robots.txt and anything else that needs to sit at the root goes in there too.

One thing worth doing before you call it done: minify nothing, but do check your image sizes. A generator this small will happily copy a 3MB PNG into dist/ and the page you carefully kept dependency-free will still take four seconds to load.

When this is the wrong tool

Be honest about the boundary. If you need interactive components, incremental builds because the content set is large, a plugin ecosystem, or other people contributing who expect familiar conventions — use the framework. This approach works because it is small, and it stops working the moment it is not.


The whole build script for the site I use this on is under 300 lines including the sitemap and a feed. I have not upgraded a dependency in it for a long time, which is most of the point.

Top comments (0)