Colophon
How this site works.
A personal site is a good excuse to try the things I’d hesitate to put in front of a client first. Here is what’s under this one, and why.
The stack
For eighteen years this was a WordPress blog on a server I looked after myself. It now runs onEmDash, an open-source CMS built on Astro, deployed as a single Cloudflare Worker. Content lives in D1 (SQLite at the edge), images inR2, and every page is rendered on request and then cached.
The move was scripted: WordPress exported every post, page and attachment, and EmDash’s importer converted the HTML to Portable Text, a structured JSON format for rich text. Every post kept its old address, so nothing anyone linked to in 2012 should 404. The link-log posts got a proper field for the page they point at, and old IFTTT short links were followed to where they really go.
There’s no server to patch and no PHP to update. Publishing still happens in a real editor, at/_emdash/admin, signed in with a passkey.
Search
Press ⌘K anywhere. Every post is indexed by FTS5, SQLite’s full-text engine, inside the same D1 database as the content, so there’s no second system to keep in sync.
Results are ranked with BM25. It scores a document higher when your words are rare across the whole archive (so “Erigon” counts for more than “server”), when they appear often in that document, and when the document is short enough that the matches aren’t an accident.
Full-text search is literal: by default every word must match, and nobody types like that. When a query comes back thin, the site drops common words, adds prefix matching and ORs the rest together.
-- what a loosened query looks like on its way to D1
SELECT … FROM fts_posts
WHERE fts_posts MATCH 'disk* OR space* OR ethereum* OR node*'
ORDER BY bm25(fts_posts)
LIMIT 18;It’s fast, free and good enough for ~600 posts. It doesn’t understand meaning, though, and it doesn’t forgive typos. That’s where the next two pieces come in.
Clef, and type-safe AI
Most AI writes text. Clef, a model Cloudflare released in October 2026, doesn’t write anything. You give it some state and a set oftyped questions, and it gives back a probability for every allowed answer. It follows the same “System One” API as TypeSafe’s Jev model, and runs on Workers AI, a binding away from this site.
There are three kinds of question:
- noul: yes or no, answered as the probability of yes.
- choice: pick from options you define, with a probability for each.
- score: rate against an ordered rubric, with a probability per level.
Because the model can only ever return one of your options, its output can’t be malformed and can’t invent a blog post that doesn’t exist. This site’s little client carries that guarantee into TypeScript, so the answer type is inferred from the question:
const answer = await decide({
state: { visitor_says: "my Ethereum node keeps running out of disk" },
questions: {
thread: {
type: "choice",
instructions: "Which part of the archive fits best?",
criteria: {
infra: "Servers, Docker, NGINX, nodes",
web3: "Ethereum, NFTs, on-chain art",
frontend: "CSS, JavaScript, responsive design",
},
},
covered: { type: "noul", instructions: "Does the archive cover this?" },
},
});
answer.answers.thread.choice; // "infra" | "web3" | "frontend"
answer.answers.thread.probabilities; // { infra: 0.94, web3: 0.05, frontend: 0.01 }
answer.answers.covered.noul; // 0.81The trick is a conditional type and a const generic, so option keys stay literal:
type AnswerFor<Q> =
Q extends ChoiceQuestion<infer K> ? ChoiceAnswer<K> // K = your option keys
: Q extends ScoreQuestion<infer L> ? ScoreAnswer<L>
: Q extends NoulQuestion ? NoulAnswer // probability of "yes"
: never;
export async function decide<const Qs extends Record<string, Question>>(input: {
state: unknown;
questions: Qs;
}): Promise<{ answers: { [Id in keyof Qs]: AnswerFor<Qs[Id]> } }>The reading compass
The compass on the home page chains two decisions around a search:
- Clef routes your words to one of seven threads in the archive, while FTS5 finds keyword matches.
- Recent posts from the winning thread join the matches, up to 28 candidates.
- Clef picks which candidate you should read first, a probability for each, and separately rates how well the archive covers your question at all.
The probabilities are drawn rather than printed: each one sets the weight of the type on Newsreader’s variable wght axis, from 200 for unlikely to 800 for certain. The whole thing reads a couple of thousand tokens, takes well under a second, and costs a fraction of a penny, because Clef charges nothing for output.
Reading compass
Not sure where to start?
Tell me what you’re into.
Your words go to Clef, a Cloudflare model that doesn’t write anything. It returns a probability for every option it was given. Those numbers set the weight of the type below.
- Front-end craft
- Web3 & on-chain
- Servers & DevOps
- Making things
- WordPress
- Life, cricket & surf
- The link log
Start here
How this answer was made
What’s next: Typesense
All search goes through one small function, searchPosts(), so the engine behind it can change without the pages noticing. Next up is Typesense, an open-source search engine that keeps its index in memory and is built for search-as-you-type:
- Typo tolerance. “resposive” still finds responsive design.
- Facets. Filter by year, category or link-versus-post, with live counts.
- Synonyms and ranking rules. Teach it that RWD means responsive web design.
- Hybrid search. Keyword and vector results blended in one query.
EmDash fires a hook whenever content is saved or deleted, which is exactly where a Typesense index gets kept up to date. When it lands, I’ll write up how it works.