Skip to main content

How This Site Is Built


This site started as one page. Hero, about, experience, education, projects, skills and contact, all stacked in a single scroll. That holds up fine for a CV in HTML. It stops holding up the moment you add a blog, because a post is not a section you scroll past on the way to something else.

So the site is a set of routes now. The interesting parts of that rebuild were the ones I did not expect to spend any time on.

Most of what I plan to write here comes out of working on something specific: a decision I got wrong in OpsPilot, something the thesis pipeline taught me about thresholds. A post needs to point at the project it came from, and that pointer needs to be checked by something other than my memory.

The blog and the projects both live in Astro content collections, and the link between them is a schema field:

const blog = defineCollection({
  loader: glob({ base: "./src/content/blog", pattern: "**/*.md" }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    tags: z.array(z.string()).default([]),
    relatedProjects: z.array(reference("projects")).default([]),
  }),
});

reference("projects") is the part doing the work. If I typo a slug in a post’s frontmatter the build fails rather than shipping the page, though less gracefully than I would like: it surfaces where the template dereferences the entry that was never found, not as a schema complaint naming the bad id. Without it I would ship a page with a dead card on it and find out when somebody told me. Moving projects out of a TypeScript data file into their own collection was the price of that, and it was worth paying, because the same schema now drives the project cards, the project detail pages, and the list of posts on each project.

Tailwind v4 keeps the design tokens in CSS

Tailwind v4 is configured in the stylesheet rather than in a JavaScript config file. The theme is a block of custom properties mapped onto utility names:

@theme inline {
  --color-bg: var(--ui-bg);
  --color-surface: var(--ui-surface);
  --color-border: var(--ui-border);
  --color-heading: var(--ui-heading);
  --color-accent: var(--ui-accent);
}

The --ui-* values are defined once on :root for the dark theme, then redefined under :root[data-theme="light"]. That is the entire light mode implementation. No component knows which theme is active, because no component names a color. They use bg-surface and text-heading, and the token underneath changes.

The benefit is that a color decision happens in exactly one file. The side effect I did not anticipate is that it makes mistakes obvious: an arbitrary value like bg-[#101311] sitting next to a bg-surface is almost always a token somebody forgot to add.

Letting the build do the work

Output is static, deployed to Cloudflare through @astrojs/cloudflare. master goes to production and every other branch gets its own preview build, which is how anything gets looked at before it is live.

One setting is worth naming:

adapter: cloudflare({ imageService: "compile" })

The adapter defaults to a passthrough that ships source images untouched, which is an easy way to serve a multi-megabyte PNG without noticing. compile moves that work to build time. For a static site that is the right trade every time, because the processing happens once on my machine instead of repeatedly on someone else’s connection.

The client router cost more than one line

For a while this site shipped no external JavaScript files at all. Navigation ran on the CSS @view-transition rule and a Speculation Rules block, both declarative, both free. It was a nice property to have. Then I used the site for a couple of weeks, decided I wanted real client-side navigation more than I wanted the property, added Astro’s ClientRouter, and spent an evening finding out what that actually means.

The component is one line. What it changes is that the document stops reloading, and every script on the site was written assuming that it would. Three things broke, and none of them showed up in a build log.

A script that has already run never runs again. Astro keys scripts by their source or their contents and skips repeats, so the listeners bound to the nav went stale the moment the nav markup was replaced. The fix is to initialise on astro:page-load, which fires on the first load and after every navigation.

The theme reset on every navigation. During a swap Astro copies the <html> attributes from the incoming document, and that markup is always the static data-theme="dark" default, so a reader on the light theme got flipped to dark by clicking a link. Adding data-astro-rerun to the theme script fixes it: the script runs again during the head swap, before anything paints.

The hero kept animating after I left the page. The background mesh is a canvas driven by requestAnimationFrame, and nothing cancelled it. Leaving the homepage left it drawing to a canvas that was no longer in the document, and coming back left it dead, because an ES module only ever evaluates once. Every listener in it now goes through an AbortController, and the frames are cancelled on astro:before-swap.

None of that is a complaint about the router. It is what a router is. The page stops being thrown away, so everything that quietly relied on the page being thrown away becomes yours to handle. The cost was about 16KB of JavaScript and one rule I now apply to every script here: it has to be able to start twice and stop cleanly.

What is not done

There is no search and no pagination, both of which start mattering somewhere north of twenty posts. The feed at /rss.xml covers everything and there are no per-tag feeds. The project detail pages are thinner than they should be. Every post shares one Open Graph image instead of getting its own.

This is the first project here I can change on a whim, which makes it the one most likely to keep changing.