- CV header with highlights and keeping-up summary replaces the intro and Now strip - "Projects I'm proudest of": up to six featured panels; screenshots muted until hover - "Also made" list; searching it also lists matching courses - Record section: recognition & volunteering, courses by topic - /work/learning/: searchable course list (plain script), in the sitemap - Top bar: Projects, Record, Writing, CV; no Bluesky link in the work footer - Highlights: only the placement takes the accent (owner, after the preview) Owner looked at the rendered preview on 2026-11-12 before this commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7.5 KiB
Architecture
How portfolio-next is built today. Update in the same commit as any structural change.
Overview
A two-gate personal site (work / off the clock) rendered to static HTML by Astro, with React only for
small islands. The start page loads no React at all: its behaviour and the preference switch are plain page scripts
(spec 006). One container runs bun src/cli.ts serve: it polls a separate content repo
(portfolio-next-content), builds the site in the background, swaps the finished build in atomically
and serves it. Ghost posts, Trakt and Hardcover are fetched once per build. Behaviour: docs/specs/001-site.md;
content format: docs/CONTENT-CONTRACT.md; deployment: docs/DEPLOY.md.
Map
| Path | Responsibility |
|---|---|
src/core/ |
Pure, dependency-free logic shared by the build and the browser: AI levels and visibility (ai.ts), durations, issue numbering (issues.ts), lazyImages for Markdown bodies (post-html.ts), media extensions (media.ts), showcase ids (showcases.ts), course summary, search and formatting (courses.ts) |
src/library/ |
Reads and validates the content repo (load.ts, frontmatter.ts, types.ts, errors.ts), including the optional recognition.yml and courses.yml; RESERVED_PERSONAL_IDS guards ids that would shadow a page |
src/sources/ |
Ghost, Trakt and Hardcover clients plus a JSON cache (cache.ts); loadSources honours SOURCES_FIXTURES. Trakt: last film (history/movies?limit=1 + ratings/movies) and recent ratings; no "watching now". ListenBrainz (LISTENBRAINZ_USER, no token): the week's top track, counted from the listens |
src/site/ |
The Astro site: pages/ (gate, work/ with learning/[...path].astro for /work/learning/, built only when there are courses, off-the-clock/ with issues/, everything, simple, 404, and [...file].xml.ts for /sitemap.xml and the three Atom feeds), components/ (Astro parts, React islands, showcases/), layouts/ (Base, Work, Personal), styles/ (per-space CSS and tokens), scripts/ (plain page scripts: learning-filter.ts for /work/learning/, preference.ts, pref-switch.ts for the plain-HTML PreferenceSwitch.astro, filter-note.ts, gate.ts for the start page), data.ts (getSiteData: content plus cached sources and siteUrl from SITE_URL, never fetches), link-preview.ts (SITE_URL parsing and each page's canonical/Open Graph/feed head tags, rendered by Base.astro), feeds.ts (the page list for the sitemap and each feed's entries), xml.ts (XML escaping, the Atom and sitemap writers; all three pure and build-only), post-html.ts (safePostHtml, Ghost HTML allowlist on Bun's HTMLRewriter; build-time only) |
src/runtime/ |
build.ts (validate, fetch sources once, run Astro, copy media), refresh.ts (poll git, timed rebuild, swap live), serve.ts, static-server.ts (/status, 404 page), state.ts, git.ts |
src/cli.ts |
Entry point: validate, build, preview, refresh, serve |
fixtures/ |
Synthetic content (content/, sparse/) and source JSON (sources/); the only content in this repo |
test/ |
bun test suites by area (core, library, sources, runtime, site, cli) |
e2e/ |
Playwright specs against a built fixture site on port 4378 (playwright.config.ts) |
deploy/ |
Example compose files and Ansible playbook for the homelab config repo |
scripts/ |
smoke-test.sh (image builds and serves fixture content), archive-old-site.sh |
archive/2021/ |
The frozen 2021 site, served separately as the "by hand only" archive |
mockups/, docs/inspiration.md |
The approved visual source of truth and the owner's taste log; extra_info.txt is his real work history |
public/ |
Copied to the site root as is (favicon.svg) |
The /work/ page (spec 009)
pages/work/index.astro composes, top to bottom: WorkHeader.astro (CV header: site.yml cv/now/links, highlights
from recognition.yml, keeping up from courses.yml via courseSummary), Featured.astro (up to six panels,
featLayout in sections.ts), the ProjectList.tsx island ("Also made"; it receives the courses' plain fields and
shows matching ones while searching, course-search.ts), Record.astro (#record), then Writing and
WorkHistory.astro. workSections in sections.ts says which sections exist (and whether /work/learning/ is built)
so the top bar and links never point at something missing. record.ts holds the seal SVG and the small view helpers;
CourseSumLine.astro is the summary line shared by the record and the learning page.
Data flow
content repo (git) ─┐
Ghost / Trakt / ├─> buildSite: validate → loadSources (once) → Astro → copy media
Hardcover ──────────┘ │ writes <cache>/sources.json, <out>/sources-status.json
v
builds/<id> ──atomic swap──> live ──> static server (+ /status)
refreshpolls the content repo everyPOLL_INTERVAL, and rebuilds the same commit when the live build isSOURCES_INTERVALold. A broken commit never replaceslive; a source outage builds from the last cached data and/statusnames the failing source.- Pages read
<SITE_CACHE_DIR>/sources.jsonthroughgetSiteData; a missing file means every source reads "not fetched".SITE_CONTENT_DIRmust be set (the CLI does) orgetSiteDatathrows. SITE_URL(the public origin; the image defaults it) reaches the pages assiteUrlin site data. Without it the build leaves out everything that needs an absolute URL: canonical,og:url,og:image, the sitemap, the feeds and the head links to them. An invalid value fails the build.CONTENT_REPO_TOKENis not passed to the build child; source credentials are.
Invariants
src/coreis pure and imports nothing fromsrc/library,src/sources,src/siteor Node/Bun APIs, because the browser bundles it too.- No network during Astro rendering: only
buildSitefetches, once, before rendering. - Nothing third-party loads before the visitor acts, except the analytics script. Fonts are self-hosted;
song/video embeds load on click. Ghost post HTML is made safe once in
loadSiteData(safePostHtml): an element and attribute allowlist over a real tokenizer; links only http(s)/mailto/tel/relative; mediapreload="none"; iframes and images from hosts other thanGHOST_URL's become plain links. - Every page is complete HTML without JavaScript; islands only enhance. Nothing moves until the visitor acts.
- The preference: the inline head script in
Base.astrosetsdata-prefon<html>before first paint; the switch's inlinePAINT_SWITCHpresses the stored choice right after its markup; choosing fireslw:prefonwindow, which the switch,FilterNoteand the ReactProjectListfollow. - No invented facts about the owner: unknown stays out or
draft: true; an unknown AI level is shown as unknown. - The AI filter contract:
data-aionly on elements whose level is not null; neverdisplay: … !importanton[data-ai]. - The interest filter contract: every wall card carries
data-interests(space-separated ids, primary first; live cards their one id).Chips.tsxfilters by membership and the server-side chip counts count a card under each id.
Known rough edges
Tracked in docs/TODO.md: unverified live API shapes, low-contrast mockup tokens, /everything/ filter
counts, issue-page lead entry sizing, the start page clipping at 821–1056px, and the review follow-ups listed under "Review follow-ups".