Personal site and blog. Astro, static output, deployed as a Cloudflare Worker
serving static assets — Workers Builds runs the build on every push to main.
There is no adapter: wrangler.toml just points [assets] at dist.
Redirects, cache headers and trailing-slash behaviour are therefore Cloudflare's
to enforce, and live in three places: public/_redirects, public/_headers
(both copied into dist and read from the asset root) and html_handling in
wrangler.toml. wrangler dev --local serves dist exactly as production
does, which is the only way to check any of them.
Rebuilt from the previous Next.js version with performance as the goal: the old site shipped ~129 kB gzip of JavaScript on every page before anything else, plus three.js eagerly on the home page and a bundler (Sandpack) inside a blog post. Weak devices ran out of memory. This version ships a few kB.
npm run dev # dev server on :4321
npm run build # static build to dist/
npm run preview # serve dist/Two pages:
/— full-bleed home: hero, brand strip, work bento, contact, recent writing./blog— vertical list of post cards. Clicking one expands it in place./blog/<slug>renders the same list with that post already open, so links are shareable and work with JavaScript disabled.
src/
assets/ images Astro optimises at build time
components/ .astro presentational components
mdx/ components available inside posts
content/blog/ posts (.mdx) + co-located _media/
layouts/Base html shell, <head>, SEO, JSON-LD
lib/ posts, build-time colour sampling, remark plugin
pages/ routes
scenes/ the 3D desk (React + react-three-fiber)
styles/global design tokens + base styles, hand-written
Zero JavaScript by default. Astro renders .astro components to HTML at
build time and ships no runtime. The only scripts on the site are the copy-email
button, the nav contact panel, the blog URL sync, Astro's link prefetch, and the
3D gate — a few kB in total.
The 3D desk is opt-in. src/components/Desk.astro holds a gate that checks
viewport width, prefers-reduced-motion, deviceMemory, hardwareConcurrency,
Save-Data and WebGL support, then waits for the element to come near the
viewport before dynamically importing the scene. A device that fails the gate
downloads none of three.js. The scene also pauses its render loop when
off-screen.
Nothing is fetched on the client that could be rendered. Blog posts are compiled into the page at build time, so expanding a card reveals HTML that is already there. Tag filtering is a radio group plus sibling selectors — no JS, no extra pages, no duplicated content.
Embeds defer their weight. YouTube is a facade — poster plus a play button,
with the iframe (and a megabyte of player JS) only loading on click. TikTok is a
real iframe with loading="lazy", so the browser fetches it when it nears the
viewport. Sandpack demos are React islands with client:visible: ~207 kB that
only the one post using them ever pays, and only on scroll.
Media is detected from plain markdown where possible. A YouTube or TikTok
URL on its own line becomes an embed.  becomes the <Video>
component, with the poster inferred as a same-named .jpg. Posts stay readable
as markdown rather than filling up with JSX.
Images go through src/, never public/. Anything under src/assets or
src/content/blog/_media gets responsive WebP variants and explicit dimensions,
so there's no layout shift. Files in public/ are served raw — only put things
there that must keep a fixed URL (the GLB model, its textures, videos).
Animated GIFs are converted to MP4 (ffmpeg -crf 30) and referenced with the
<Video> component. The two in the archive went from 15 MB and 3.7 MB to 413 kB
and 220 kB.
Card overlays are sampled, not guessed. src/lib/tint.ts reads the bottom
band of each card image with sharp at build time, averages it, pushes it light
or dark, and emits it as --tint with a contrasting --on-tint. That replaces
a flat black gradient that looked bad over pale images.
Create src/content/blog/<slug>.mdx:
---
title: "Title"
date: 2026-08-06
excerpt: "One line for the card and the meta description."
type: ["Post"]
image: "./_media/<slug>/cover.jpg"
---Put images in src/content/blog/_media/<slug>/ and reference them relatively
(./_media/<slug>/thing.webp). A YouTube or TikTok URL on its own line becomes
an embed automatically.
The frontmatter image is the one exception to WebP: it must be a plain,
undithered JPEG or PNG. It becomes og:image untouched (see
content.config.ts), and social scrapers — Bluesky's included — have always
been unreliable at decoding WebP; the site dithers a copy of this same source
for its own cards, but social never sees that dithered version. dithered: true in frontmatter is for the rare post whose source photo already arrived
dithered (DITHRPIX is the only one) — don't set it just because the source is
a JPEG.
- Remark plugins go on
markdown.processorviaunified()from@astrojs/markdown-remark. Passing them onmarkdownor tomdx()directly is deprecated and, for MDX, silently does nothing. - The 3D deps are listed in
vite.optimizeDeps.includebecause Vite's scanner can't see through the dynamic import in a<script>tag, and re-optimises mid-session without it. history.pushStateto a URL containing a fragment cancels an in-flight smooth scroll, in either order. The nav writes#workonscrollendinstead. There's no globalscroll-behavior: smootheither, so arriving at/#workfrom another page lands instantly rather than animating the whole document after the navigation.- Absolutely-positioned children of a rounded container need their own
border-radius: inherit. Relying on the parent'soverflow: hiddenalone leaves a sliver of image bleeding along the curve. - Blog filter state lives in
?tag=. The filtering is still pure CSS (:checked ~ .list); a few lines of JS only sync the radio to the URL, so with JS off the page simply shows everything. The radios must be siblings of the list for the selector to reach it.