Files
portfolio-next/docs/ARCHITECTURE.md
T
Lucas WintherandClaude Opus 5.5 974b3eb0df feat(work): CV-first work page, six proud projects, record and courses (spec 009)
- 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>
2026-11-12 10:44:46 +01:00

7.5 KiB
Raw Blame History

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)
  • refresh polls the content repo every POLL_INTERVAL, and rebuilds the same commit when the live build is SOURCES_INTERVAL old. A broken commit never replaces live; a source outage builds from the last cached data and /status names the failing source.
  • Pages read <SITE_CACHE_DIR>/sources.json through getSiteData; a missing file means every source reads "not fetched". SITE_CONTENT_DIR must be set (the CLI does) or getSiteData throws.
  • SITE_URL (the public origin; the image defaults it) reaches the pages as siteUrl in 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_TOKEN is not passed to the build child; source credentials are.

Invariants

  • src/core is pure and imports nothing from src/library, src/sources, src/site or Node/Bun APIs, because the browser bundles it too.
  • No network during Astro rendering: only buildSite fetches, 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; media preload="none"; iframes and images from hosts other than GHOST_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.astro sets data-pref on <html> before first paint; the switch's inline PAINT_SWITCH presses the stored choice right after its markup; choosing fires lw:pref on window, which the switch, FilterNote and the React ProjectList follow.
  • 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-ai only on elements whose level is not null; never display: … !important on [data-ai].
  • The interest filter contract: every wall card carries data-interests (space-separated ids, primary first; live cards their one id). Chips.tsx filters 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".