The twin is the page
2026-07-19
Every page of this site lives as a markdown string inside the Worker's source file. For the guides and the blog posts that has always been literal. The Worker renders those pages from the markdown at request time, and a client that asks for text/markdown gets the same string untouched. Ten pages worked differently. The homepage, the services page, the validator and the other card-style pages were hand-written HTML, and the markdown lived beside them as a twin. Same content, two homes.
Two homes means every edit happens twice, and sooner or later one home gets the edit and the other does not. I knew that when I chose the layout. For a while the honest description of the arrangement was a sentence I never liked writing: a checker keeps the pairs in sync, and I am not sure that is the right call.
Six drifts nobody saw
The checker earned its keep before it retired. A parity gate went into the deploy checks the day before this conversion, comparing each hand-written page against its twin paragraph by paragraph. In its short life it found six real drifts, wording that had quietly diverged between the HTML and the markdown. None of them were visible by reading. I had read those pages many times.
One home for every sentence
This week the two homes became one. The ten card pages now render their prose from the twin at request time. Seventeen small helpers, under two hundred lines between them, read a named section of the twin and render its paragraphs into the page. The structure around the prose stays hand-built. The hero, the terminal demo, the validator form and the price cards are HTML that the markdown does not try to describe. Every sentence now lives once, and editing the twin is editing the page.
The gate that replaced the comparison
The parity comparison is gone, because there is nothing left to compare. What replaced it is a stricter check on the end state. Before every deploy a script walks each card function and fails the run if it finds a literal prose paragraph outside two named exceptions, if a twin section is neither rendered nor declared markdown-only, or if a function references a section its twin does not have. The gate is mutation-tested from both sides. A planted paragraph fails the run, and so does a misspelled section name. An exception that exists but goes unused fails it too, which keeps the exception list from rotting into a list of ghosts.
Proving it with rendered output
The gate was not enough on its own, because the risk in a rewrite like this is a rendering change nobody asked for. So every batch of the conversion shipped against a rendering harness. The same worker file runs in plain Node before and after the change, all ten pages and their markdown twins are snapshotted, and the outputs are diffed after normalization. The blog index came out byte-identical. The company page came out identical after normalization. Every other page changed only in ways the diff named, mostly apostrophes turning into HTML entities.
The harness also caught one real bug before it went anywhere. A sentence on the validator page named the fetched path with a placeholder domain written in URL form. As hand-written HTML it was inert text. Rendered through the markdown pipeline it matched the bare URL rule and became a link to a domain that does not exist. The fix was rewording the sentence without the URL shape. The general lesson: prose that moves into a markdown renderer starts playing by markdown's rules, and rendered output is the only place you see that.
What did not change
From the outside almost nothing moved. Both scanners read the site before and after the conversion and reported the same results. The homepage still serves a markdown version that is deliberately shorter than its HTML, and the layout is still code. That boundary is the honest one to draw. The markdown is the content contract, and the structure around it is the site's own business.
Two limits are worth stating plainly. The checks run on my machine before a deploy, and there is no CI behind them. And the blast radius of a bad edit is unchanged, because all content lives in template literals in module scope, so one stray interpolation marker would still take down every page rather than one. A build step would solve that differently, and at some size it wins. At this size, one file that renders itself is cheaper to keep honest.
If you want to check any of this, request any guide or blog post with Accept: text/markdown and diff the response against its string in worker.js. They match byte for byte.