Janith Wanniarachchi
← Blog

How this site is built

· 5 min read

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:

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.

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:

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

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.