A blog with no build step
This site is a single hand-written HTML file served by Cloudflare Pages with
the build command set to None. No package.json, no lockfile, no
node_modules, nothing executing on push. That was deliberate, and I wanted
to add a blog without giving it up.
The obvious options both cost something. A real static site generator means adopting a dependency tree and a build that runs on every push. Rendering markdown in the browser means shipping a parser to every visitor and serving search engines an empty shell — and my CSP forbids external scripts anyway, so the parser would have to be vendored.
So: markdown in, HTML out, on my laptop, with the output committed. Pages keeps serving files it never had to build.
What the pipeline looks like
python tools/blog.py new "A blog with no build step"
# ...write the post...
python tools/blog.py build
git add -A && git commit && git push
build regenerates the post pages, the index, the feed, the sitemap, and the
four most-recent rows on the homepage. It is offline and deterministic: same
sources in, same bytes out.
The interesting constraint is that nothing enforces that I ran it. Pages deploys whatever is committed, so a post edited and pushed without a rebuild ships stale. That is inherent to the design, and the answer is a command that rebuilds in memory and diffs against disk:
python tools/blog.py check
It also enforces a few invariants that are otherwise things you just remember
to check: no generated page may reference a host outside Google Fonts, every
relative link must resolve to a file, and no generated URL may end in .html.
Two bugs that were already live
Writing the caching rules meant reading the Pages _headers documentation
properly for the first time, which surfaced two problems that had been in
production for months.
Duplicate headers are joined, not overridden
My _headers set Cache-Control three times under /* — once per commented
category, images and CSS and “other”, as if the comments scoped them:
/*
# Images
Cache-Control: public, max-age=31536000, immutable
# CSS and JavaScript
Cache-Control: public, max-age=31536000, immutable
I had assumed one of them won. They do not. When a header name appears more
than once across matching rules, Pages joins the values with a comma, so
every response carried a single Cache-Control with three max-age
directives in it. What a browser does with that is anyone’s guess.
There is also no way to override an inherited value from a more specific
rule, which means the fix is not reordering — it is removing Cache-Control
from /* entirely and scoping it to the paths that actually want it.
Extension patterns match nothing
Below that sat a rule intended to keep HTML fresh:
*.html
Cache-Control: public, max-age=3600
It was dead twice over. It is missing the leading slash Pages requires, and —
more fundamentally — Pages redirects .html paths to their extensionless
form. Every URL this site serves is /, /blog/, or /blog/<slug>/. None of
them end in .html, so a corrected /*.html would still match nothing.
The fix turned out to be deleting the rule. Pages’ default for HTML is
public, max-age=0, must-revalidate with an ETag, which is exactly right for
a page that changes whenever I publish.
| Path | Cache-Control | Why |
|---|---|---|
/, /blog/, /blog/<slug>/ |
(platform default) | changes on every post |
/assets/* |
max-age=31536000, immutable |
every reference is content-hashed |
/feed.xml, /sitemap.xml |
(platform default) | regenerated on every build |
The corollary for the generator: always emit trailing-slash directory URLs.
A link to /blog/foo.html costs a redirect on every single click, including
from the feed and the sitemap.
Syntax highlighting in two themes
The site is light by default and dark via a body.dark class, so highlighting
had to follow. Pygments emits static class names, which makes this easier than
expected — generate the token colours twice, under different ancestors:
light = HtmlFormatter(style="xcode").get_style_defs(".codehilite")
dark = HtmlFormatter(style="github-dark").get_style_defs("body.dark .codehilite")
body.dark .codehilite .k scores (0,3,1) against .codehilite .k at (0,2,0),
and source order agrees with specificity, so both mechanisms point the same
way and reordering the file cannot quietly break it.
One trap: get_style_defs() emits five rules that ignore the selector you
passed it. Four are line-number styling. The fifth is a bare
pre { line-height: 125% } that would have overridden the prose line-height
across the entire site. The generator drops any line that is not scoped to
the selector it asked for.
Worth remembering that a stylesheet generated by a library is still a stylesheet you are shipping. It gets read before it gets trusted.
What I gave up
Honest accounting:
- A rebuild is a manual step.
checkcatches a stale tree, but only if I run it. A pre-commit hook is the obvious next move. - No drafts on a server. Previewing means running the thing locally.
- The markdown sources are committed, so
_redirectshas to 404/content/*or they would be served raw.
What I kept: zero runtime dependencies, zero build minutes, a CSP I did not have to widen, and a site that is still just files.