Beta logo, with winged snake wrapped around

beta.vero.site

obviously, a held item you can use to Mega Evolve into a betaveros.
« home

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))")