This site: a static publishing platform

A personal portfolio and technical writing site built as a statically generated Astro project with a typed content layer, build-time search index and no runtime dependencies.

Role
Design and implementation
Status
Active
Year
2026
Stack
Astro, TypeScript, Tailwind CSS, Pagefind, Shiki, KaTeX

Overview

This repository is the site you are reading. It is a statically generated publishing platform: a portfolio, a blog and a search index, all produced at build time from Markdown and MDX files committed to Git.

Screenshot of the home page: a wordmark and navigation bar, the author's name and role, two call-to-action buttons, and a panel listing role, location and contact

There is no database, no CMS, no authentication and no application server. The deployment artefact is a directory of HTML, CSS, JavaScript, fonts and one generated search index.

Problem

Most personal sites drift in one of two directions. Either they are a page-builder portfolio with a blog bolted on, where every new article requires touching templating code, or they are an application with a content API, a database and a runtime dependency that has to be patched forever.

The problem to solve was narrower than either: publishing a new article should be a matter of creating one file, and the site should still produce excellent Core Web Vitals, complete accessibility and stable URLs years from now.

Constraints

  • Content first. Writing must be plain Markdown wherever possible; MDX is opt-in for the minority of posts that genuinely need components.
  • No runtime. No server, no database, no third-party script in the critical path.
  • Build-time validation. Invalid frontmatter must fail the build rather than publish a broken page.
  • Long-term maintenance. Every dependency has to justify itself, and the hosting target must stay portable.
  • Accessibility as a requirement, not a bonus. WCAG 2.2 AA quality, verified rather than asserted.

Architecture

src/content/**            Markdown and MDX (the source of truth)
        |
        v
src/content.config.ts     Zod schemas, draft and schedule rules
        |
        v
src/lib/*.ts              Pure domain logic (ordering, tags, related, TOC)
        |
        v
src/layouts/*.astro       Base, page, article and project shells
        |
        v
src/pages/**              Static routes
        |
        v
dist/                     HTML, CSS, fonts, images, Pagefind index

The important boundary is between src/lib/publication.ts, which is pure and unit tested, and src/lib/content.ts, which is the only module allowed to know about astro:content. Publication rules therefore have a single implementation and a real test suite instead of being re-derived in every page.

Technical decisions

Static generation with Astro, and no UI framework

The site ships no React, Vue or Svelte runtime. Every interactive feature - the theme toggle, the mobile menu, table-of-contents highlighting, the copy button and search - is either native HTML (<details>, <dialog>) or a few dozen lines of vanilla TypeScript.

Hydrating a component framework would have added tens of kilobytes to every page for behaviour that the platform already provides. The measurable cost was the deciding factor, not a preference.

Markdown processor: unified instead of the default

Astro 7 defaults to Sätteri, a fast native Markdown pipeline. This project switches to the unified processor because it needs three ecosystem plugins: remark-math with rehype-katex for mathematics, and rehype-autolink-headings for heading permalinks. That is a deliberate trade of build speed for the features the content requires.

Colour themes with light-dark() and no flash

Every colour token is declared once as --bg: light-dark(lightValue, darkValue), and :root sets color-scheme: light dark. The operating-system preference is therefore honoured by CSS alone, with no JavaScript and no duplicated token lists.

A single inline script, allowed by an exact SHA-256 hash in the Content Security Policy, narrows that to a saved explicit choice before the first paint. The hash is verified after every build by scripts/verify-csp.mjs, so the policy cannot silently fall out of sync with the script.

Two Markdown processors, one rendering path

Code is highlighted at build time by Shiki with two themes and defaultColor: false, which emits the palette as CSS custom properties. Light and dark code rendering is therefore CSS, not JavaScript.

Structural HTML wrappers at build time

Two small rehype plugins wrap tables and code blocks in scroll containers (src/lib/rehype-wrappers.ts). Doing it with CSS alone is not possible without changing the display type of a <table>, and doing it with client JavaScript would mean the layout is wrong until a script runs.

Tradeoffs

DecisionGainedGiven up
Static output, no serverTrivial hosting, no attack surface, no runtime patchingNo contact form, no per-request personalisation, rebuild to publish
unified Markdown processorMath and heading-anchor pluginsSätteri’s native build speed
No UI frameworkAlmost no client JavaScriptMore hand-written DOM code for search
Build-time ShikiNo highlighter in the browserRebuild required to change a theme
Satori-generated OG imagesEvery page has a correct social cardTwo build-time dependencies and vendored static fonts

Implementation notes

  • Draft and future-dated content is filtered by a single predicate, isPublished, which is passed the build’s production flag and start time explicitly so its production behaviour is testable.
  • Reading time is computed from the raw Markdown with code fences and JSX stripped, so it is deterministic and needs no manual maintenance.
  • KaTeX’s stylesheet is linked only on pages whose source contains maths, detected at build time.
  • astro check, Biome, Vitest, the production build and the CSP verification script all run in pnpm validate.

Results

The site is fully static, and the interactive JavaScript is limited to the features listed above. The search runtime and Pagefind itself are loaded only when a reader opens the search dialog.

Measured output is recorded in the repository’s build artefacts rather than asserted here; run pnpm build and inspect dist/ to check the current numbers.

Lessons learned

  • Keeping publication rules in a pure module was what made the interesting behaviour testable at all. Website logic is usually untestable because it is entangled with the framework’s data layer.
  • Reading the framework’s plugin pipeline instead of guessing its order prevented two real bugs: heading anchors leaking a # into generated heading text, and wrappers being applied before syntax highlighting had run.
  • light-dark() removed an entire category of theme code, including the flash-of-wrong -theme workaround that usually requires a blocking script.

Search this site