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
- Repository
- github.com/pwnholic/portfolio
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.

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 indexThe 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
| Decision | Gained | Given up |
|---|---|---|
| Static output, no server | Trivial hosting, no attack surface, no runtime patching | No contact form, no per-request personalisation, rebuild to publish |
unified Markdown processor | Math and heading-anchor plugins | Sätteri’s native build speed |
| No UI framework | Almost no client JavaScript | More hand-written DOM code for search |
| Build-time Shiki | No highlighter in the browser | Rebuild required to change a theme |
| Satori-generated OG images | Every page has a correct social card | Two 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 inpnpm 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.