Teardown

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. check catches 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 _redirects has 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.