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
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")
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()
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
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']}/"
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
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)
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)