All posts

How this blog works

A Markdown file, a push, and about nine seconds of CI. No database, no CMS, no framework.

metastatic-sites

Every page on this site is a file that existed before you asked for it. There is no server rendering anything, no database being queried, and no JavaScript framework rehydrating a shell. You requested HTML and got HTML.

This post is both an explanation and a test case — it exercises every feature the build supports, so if something renders wrong here, it is broken everywhere.

Writing a post

A post is one Markdown file in posts/, named YYYY-MM-DD-some-slug.md. The frontmatter is small on purpose:

yaml
---
title: How this blog works
description: A Markdown file, a push, and about nine seconds of CI.
date: 2026-08-03
tags: [meta, static-sites]
---

title and description are required, and the build fails without them. That is deliberate. description is doing four jobs at once: the excerpt on the index, the og:description in link previews, the summary in the RSS feed, and the <meta name="description"> that search engines quote. A post without one is a post that looks broken everywhere except the page itself.

Everything else is optional:

Field Default Notes
date Parsed from the filename YYYY-MM-DD, treated as UTC
tags none Free-form, become filter pills on the index
slug Filename minus the date prefix Set it to change a URL without renaming
draft false true builds locally, never publishes
canonical This page For a post cross-published somewhere else

Then git push. A workflow builds the site and deploys it to GitHub Pages, and about nine seconds later the post is live. There is no publish button because there is nothing to press it on.

What the build does

build.js is roughly three hundred lines and owns the whole pipeline. It reads the Markdown, renders it, and writes a directory:

bash
$ npm run build
Built 3 posts (1 draft), skipped 1 draft in 812ms → dist/

Along the way it produces the things a blog is expected to have but that are tedious to maintain by hand — an RSS feed, a sitemap, per-post Open Graph tags, and JSON-LD so search results show a real title and date instead of a guess.

The build is strict where being permissive would hurt later. Two posts that would publish to the same URL is an error, not a coin flip about which one wins. A date that does not parse is an error rather than an Invalid Date in the feed. The failure happens in CI, before anything is deployed.

Drafts

A post with draft: true is skipped entirely by a normal build. To see one:

bash
npm run serve   # builds with --drafts, serves dist/ on :8000

Drafts built this way are visibly marked, because the interesting failure mode is not “I forgot to publish it” — it is “I published it and did not notice.”

Code, and why it is not highlighted in your browser

Syntax highlighting runs at build time through Shiki, which uses the same TextMate grammars as VS Code. The browser downloads no highlighting library and runs no highlighting code.

The awkward part of build-time highlighting is usually dark mode: you have picked your colors before you know which theme the reader wants. Shiki can emit both at once, as a pair of custom properties on every token:

html
<span style="--shiki-light:#D73A49;--shiki-dark:#F97583">const</span>

The stylesheet then picks a side, keyed off the same class the tools family already toggles:

css
.shiki, .shiki span      { color: var(--shiki-light); }
html.dark .shiki span    { color: var(--shiki-dark); }

So switching theme recolors every code block instantly — no second stylesheet, no flash, no reflow, and nothing fetched.1

Sharing the tools’ chrome

The nav at the top of this page is not this repository’s. It is the same tools.js that every tool on calvinbrown.dev loads, pulled cross-origin from tools.calvinbrown.dev. Three tags in <head> and the blog is part of the family:

html
<link rel="stylesheet" href="https://tools.calvinbrown.dev/shared/tools.css">
<script src="https://tools.calvinbrown.dev/shared/registry.js"></script>
<script src="https://tools.calvinbrown.dev/shared/tools.js"></script>

That single include brings the nav, the dark-mode toggle, the clipboard helper behind every Copy button above, and analytics — all of it configured once, in a repository that is not this one.

The rule the tools follow is that measurement records structure, never content: which post, which tag, which button. The filter box on the index is a good illustration. It reports whether a search matched anything, because that is worth fixing. It does not report what was typed.

The blog adds a few events of its own in the same spirit — scroll depth in quarters, which tag was filtered, which code block was copied. Enough to know whether a post gets read past the first screen, which is the only question I actually have about any of this.

Footnotes

  1. The one cost is a slightly larger HTML file, since each token carries two hex values instead of one. On a post this size it is a few kilobytes before compression, which is a good trade for not shipping a highlighter.