How this site is built
This site is meant to feel like an old book: system serif fonts, a paper-grain background, a table of contents on the title page and a fleuron at the end of every post. Underneath it’s a small, fast web app. Here’s how the pieces fit together, and why I chose each one.
The shape of it
content/ Quarto sources: pages, blog posts, poems
scripts/ build-content.mjs, prerender.mjs
src/ main.js, style.css, scrolly.js
functions/c/ Cloudflare Pages Functions for the live sections
public/ fragments, CV PDF, images, data for interactive posts
There’s one HTML shell, one stylesheet and one small script. Every page is a fragment of HTML that gets swapped into the shell’s <main>.
Writing: Quarto, rendered bare
I write everything (research pages, R posts with code, poems) in Quarto. Quarto normally brings Bootstrap, a theme and its own JavaScript. Here it’s set to minimal: true and theme: none, so the output is plain semantic HTML and the site’s own stylesheet does all the styling.
R posts are frozen: their code is run once on my machine (with the package versions pinned by renv), and the results are stored with the source. That keeps the deploy fast and means the server never needs R.
The CV is a single cv.qmd that renders twice: to HTML for the site, and to a PDF through Typst. A 13-line Lua filter lays out each entry as a two-column date | details grid, so both versions match.
Building: from pages to fragments
scripts/build-content.mjs (about 200 lines of Node, no dependencies) turns Quarto’s output into the site:
- each page becomes a fragment under
/c/, with relative links rewritten so they work from any URL; - the blog and creative listings, reading times and the homepage’s table of contents (with live counts of poems, essays, talks and packages) are generated from the pages themselves;
- images are lazy-loaded and decoded off the main thread;
- old URLs from previous versions of the site get 301 redirects.
Then Vite builds the shell, and scripts/prerender.mjs writes a complete index.html for every route, with the right title, description and social-preview tags. The first visit to any page is a single HTML request that works even without JavaScript.
Navigating: htmx
After that first load, htmx takes over. Each navigation link carries an hx-get pointing at its fragment, so a click fetches only the new content (often a few kilobytes) and swaps it into <main>, updating the URL and history. There’s no client-side router and no framework, and the whole script, htmx included, is about 20 kB compressed. If a fragment ever fails to load, the link falls back to a normal page load.
htmx’s eval features are turned off, so no code can run from attributes.
The live bits: edge functions
Three parts of the homepage change on their own:
- Thoughts & musings are short notes I post from my phone. They come as JSON from a small Netlify function on a separate site and become a list on the homepage and a stack of postcards.
- On the shelf, in colour is a shelf of book spines, each striped with the colour palette of a photograph I’ve taken. A Cloudflare Pages Function builds the shelf as HTML, and htmx loads it only when it scrolls into view (
hx-trigger="revealed"). A photo is only downloaded when you take its book off the shelf. - Recently played comes from Spotify. Only the edge function talks to Spotify: it holds the credentials, and even the album covers are proxied, so your browser never contacts Spotify.
Each function caches its response at the edge for a few minutes, so the upstream services see at most a handful of requests no matter how many people visit. If one of them is down, its section simply stays empty.
Interactive posts
Some posts need more than HTML. Where the explainers point is a scrollytelling version of a thesis chapter, drawn with D3. Its code and data load only on that page: the script is split out at build time and imported when the page has a figure to draw, and the data (exported from the paper’s R results) is 13 kB compressed. The model’s prediction surface is drawn on a canvas, and everything else is SVG in data coordinates, so zooming in redraws a single transform.
Small choices that add up
- No trackers, no cookies, no web fonts. The fonts are the serifs already on your device (Charter, Iowan Old Style, Palatino), so text appears instantly and nothing is downloaded.
- The paper grain is a few hundred bytes of inline SVG noise, not an image.
- Dark mode follows your system setting. Every colour is a CSS variable with a dark counterpart, including the class colours in the interactive figures.
- Motion is optional. Page transitions, the postcards’ tilt and the scrollytelling zooms all switch off when your system asks for reduced motion.
- Accessibility is part of the design: a skip link, real headings and lists, labelled navigation, visible focus rings, and text alternatives for the colour shelf, which names each colour (“deep blue 34%”) for screen readers.
- Print gets its own stylesheet, mostly for the CV.
How it scores
I check the production build with Lighthouse (version 12) using its mobile preset, which simulates a mid-range phone on a slow 4G connection.
| Page | Performance | Accessibility | Best practices | SEO |
|---|---|---|---|---|
| Home | 100 | 100 | 96* | 100 |
| Research | 100 | 100 | 100 | 100 |
| CV | 100 | 100 | 100 | 100 |
| Where the explainers point | 99–100 | 100 | 100 | 100 |
| This post | 100 | 100 | 100 | 100 |
On the simulated phone, the largest element paints in 1.1 to 1.3 seconds, and nothing shifts as the page loads. With the desktop preset it’s under half a second.
*The one point lost on the homepage is a console error from my local test server: the thoughts feed only answers requests from this site’s own domain.
Getting there took a few fixes: a robots.txt and a sitemap generated at build time, canonical links, a link that only said “here”, and a figure legend whose text was too faint.
Hosting
The site is static files on Cloudflare Pages, plus the three small functions. The deploy only runs npm run build, which needs neither Quarto nor R, because the rendered fragments are committed with the source. Publishing a new poem means writing a .qmd, running one command and pushing.