My Static Site Generator
In mid-2026 I discovered I don't enjoy having to set up a Ruby toolchain on every new computer for the sole purpose of getting Jekyll running again to hack on my personal website; but also that a bunch of other static site generators were both too complicated and too inflexible for my specific demands: an extremely flat site with extensionless URLs that have neither a trailing slash nor a .html. So I threw together my own. There are certainly bugs with the file-watching logic, but turns out it's good enough. (This is not intended to support a blog-like site with chronological posts.)
Then I made it render its own source code, because I can.
#!/usr/bin/env python3
"""
Minimal, idiosyncratic static site generator.
Layout:
src/ content: foo.md, raw foo.html, templated foo.j2.html, assets
templates/ Jinja layouts + includes (default.html, partials)
out/ generated site
Output is flat: src/foo.md → out/foo.html. Links are extensionless (/foo), and
I write them directly (there's no url templating helper); the prod server is
expected to be configured to serve them correctly (e.g., Apache MultiViews).
Source files are dispatched by filename:
*.md → parse frontmatter, render markdown, wrap in layout
(default layout is default.html)
*.j2.html → render through Jinja (can {% extends %}/{% include %})
everything else → copied byte-for-byte, including raw html and assets
Site config and dev port are inline constants below. No separate files.
Usage:
python build.py one-shot build
python build.py --serve rebuild on change + serve locally
Deps:
pip install markdown-it-py jinja2 watchfiles python-frontmatter pygments
"""
import os
import sys
import threading
from pathlib import Path
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
import frontmatter
from markdown_it import MarkdownIt
from jinja2 import Environment, FileSystemLoader
from watchfiles import watch, Change
from pygments import highlight as pygmentize
from pygments.lexers import get_lexer_by_name
from pygments.formatters import HtmlFormatter
from pygments.util import ClassNotFound
SRC = Path("src").resolve()
TPL = Path("templates").resolve()
OUT = Path("out").resolve()
MARKER = ".j2.html"
PORT = 1155
# Pygments style for fenced code blocks, emitted to out/pygments.css.
PYGMENTS_STYLE = "default"
site_config = {
"title": "betaveros's site",
"base_url": "https://beta.vero.site",
}
with open(__file__, "r", encoding="utf-8") as srcfile:
site_config["ssg"] = srcfile.read()
# nowrap: emit only the token <span>s; we supply our own <pre><code> wrapper so
# the block starts with "<pre" and markdown-it uses it verbatim (no double wrap).
_formatter = HtmlFormatter(style=PYGMENTS_STYLE, nowrap=True)
def highlight_code(code: str, lang: str, attrs):
"""markdown-it highlight hook: Pygments-highlight a fenced code block.
Returns "" for blocks with no/unknown language so markdown-it falls back to
its plain escaped <pre><code>. The .highlight class matches out/pygments.css.
"""
if not lang:
return ""
try:
lexer = get_lexer_by_name(lang)
except ClassNotFound:
return ""
body = pygmentize(code, lexer, _formatter)
return f'<pre class="highlight {attrs}"><code class="language-{lang}">{body}</code></pre>\n'
md = MarkdownIt("commonmark", {"html": True, "highlight": highlight_code})
env = Environment(loader=FileSystemLoader(TPL))
env.globals["site"] = site_config
def out_path(src: Path):
rel = src.relative_to(SRC)
if rel.name.endswith(MARKER):
rel = rel.with_name(rel.name.removesuffix(MARKER) + ".html")
elif src.suffix == ".md":
rel = rel.with_suffix(".html")
return OUT / rel
def build_one(src: Path):
if not src.is_file():
return
dst = out_path(src)
dst.parent.mkdir(parents=True, exist_ok=True)
if src.suffix == ".md":
post = frontmatter.loads(src.read_text())
content = env.from_string(post.content).render()
html = md.render(content)
layout = post.get("layout", "default")
dst.write_text(
env.get_template(f"{layout}.html").render(content=html, **post.metadata)
)
elif src.name.endswith(MARKER):
dst.write_text(env.from_string(src.read_text()).render())
else:
dst.write_bytes(src.read_bytes())
def write_pygments_css():
"""Stylesheet for highlighted blocks -- link /pygments.css from your layout."""
OUT.mkdir(parents=True, exist_ok=True)
css = HtmlFormatter(style=PYGMENTS_STYLE).get_style_defs(".highlight")
(OUT / "pygments.css").write_text(css)
def build_all():
write_pygments_css()
for src in SRC.rglob("*"):
build_one(src)
def rebuild(changes):
if any(TPL in Path(p).parents for _, p in changes):
# template changed, everything is potentially stale
build_all()
return
for change, p in changes:
src = Path(p)
if change == Change.deleted:
out_path(src).unlink(missing_ok=True)
else:
build_one(src)
class Handler(SimpleHTTPRequestHandler):
"""Serve /foo from foo.html, mimicking Apache MultiViews."""
def send_head(self):
p = self.path.split("?", 1)[0].rstrip("/")
fs = self.translate_path(p)
if p and not os.path.exists(fs) and os.path.exists(fs + ".html"):
self.path = p + ".html"
return super().send_head()
def make_server():
for port in range(PORT, PORT + 20):
try:
return ThreadingHTTPServer(
("127.0.0.1", port), partial(Handler, directory=str(OUT))
)
except OSError:
continue
raise SystemExit(f"no free port in {PORT}-{PORT + 19}")
if __name__ == "__main__":
build_all()
print("done initial build")
serving = "--serve" in sys.argv
if serving:
httpd = make_server()
threading.Thread(target=httpd.serve_forever, daemon=True).start()
print(
f"serving http://127.0.0.1:{httpd.server_address[1]}; watching {SRC} and {TPL}"
)
for changes in watch(SRC, TPL):
rebuild(changes)
print(f"rebuilt ({len(changes)} change(s))")