diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 45d9684a..057ed302 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -24,15 +24,15 @@ jobs:
restore-keys: |
${{ runner.os }}-social-images-
+ # No version here on purpose: action-setup reads package.json's
+ # packageManager field, so CI and local development cannot drift.
- name: Set up pnpm
uses: pnpm/action-setup@v4
- with:
- version: 10
- name: Set up Node.js
uses: actions/setup-node@v4
with:
- node-version: 22.12.0
+ node-version-file: .nvmrc
cache: pnpm
- name: Install dependencies
diff --git a/.gitignore b/.gitignore
index 924695de..54287707 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,3 +42,6 @@ docs/materials/*
# graphify knowledge-graph cache (regenerate with `graphify update .`)
graphify-out/
+
+# local Lighthouse output (pnpm run lighthouse / audit-performance)
+reports/
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 00000000..1d9b7831
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+22.12.0
diff --git a/README.md b/README.md
index 58e8f900..f393192e 100644
--- a/README.md
+++ b/README.md
@@ -45,18 +45,40 @@ English is the listing language. Spanish is a twin: language toggle, title searc
- Tailwind CSS
- [Sharp](https://sharp.pixelplumbing.com/) — used by the social-image step to resize AVIF sources to JPEG/PNG
+## Toolchain
+
+| Tool | Version | Source of truth |
+| ------- | --------------------- | ------------------------------------------ |
+| Node.js | 22.12.0 (`>=22.12.0`) | `.nvmrc`, `engines.node` in `package.json` |
+| pnpm | 12.3.4 | `packageManager` in `package.json` |
+
+CI reads both from those files — `actions/setup-node` uses `node-version-file: .nvmrc`
+and `pnpm/action-setup` uses the `packageManager` field — so there is no separate
+version to keep in sync in `.github/workflows/ci.yml`.
+
+Locally, `corepack enable` makes `pnpm` in this directory resolve to the pinned
+version. Without corepack, install pnpm 12 yourself; older majors may not
+understand every key in `pnpm-workspace.yaml`.
+
+`pnpm-workspace.yaml` also carries dependency `overrides` (tar, minimatch,
+fast-xml-parser, ajv, rollup, devalue). They are deliberate pins, not leftovers —
+review them against a fresh advisory check before changing or removing any.
+
## Quick start
```bash
git clone https://github.com/antoniwan/notes.git
cd notes
-pnpm install
+corepack enable
+pnpm install --frozen-lockfile
pnpm run dev
```
Then open `http://localhost:4321`.
-This repo expects **pnpm**; you can use npm or yarn if you change commands yourself.
+`--frozen-lockfile` is what CI runs; use it locally too unless you are
+deliberately changing dependencies. This repo expects **pnpm**; you can use npm
+or yarn if you change commands yourself.
## Environment variables (optional)
@@ -75,7 +97,8 @@ Remark42 uses `PUBLIC_REMARK42_HOST` and `PUBLIC_REMARK42_SITE_ID` when you turn
| ----------------------------------- | --------------------------------------------------------------------------------------------- |
| `pnpm run dev` | Dev server |
| `pnpm run build` | Builds social JPG/PNG from AVIF (skipped when fingerprints + files match), then `astro build` |
-| `pnpm run preview` | Serves the production build locally |
+| `pnpm run preview` | Serves prerendered `dist/client` on :4321 (no Vercel CLI needed; see caveat below) |
+| `pnpm run preview:vercel` | `astro preview` — needs the Vercel CLI installed |
| `pnpm test` | Vitest unit tests (publish filters, SEO routing, feed HTML, quotes helpers) |
| `pnpm run test:watch` | Vitest in watch mode |
| `pnpm changelog:since` | Commits + file groups since the previous version (for CHANGELOG drafts) |
@@ -91,13 +114,28 @@ Remark42 uses `PUBLIC_REMARK42_HOST` and `PUBLIC_REMARK42_SITE_ID` when you turn
| `pnpm run generate-favicons` | Favicon assets |
| `pnpm run sync-remark42-rewrite` | Regenerates the Remark42 rewrite in `vercel.json` from `REMARK42_UPSTREAM_ORIGIN` |
| `pnpm run check-remark42-rewrite` | CI check that `vercel.json`'s Remark42 rewrite matches `REMARK42_UPSTREAM_ORIGIN` |
-| `pnpm run analyze` | Runs `astro build` only (no social-image step), then Vercel static-build analysis |
-| `pnpm run lighthouse` | Lighthouse HTML report (start dev server first) |
-| `pnpm run performance` | Runs `pnpm run build`, then `pnpm run analyze` |
-| `pnpm run audit-performance` | Full `pnpm run build`, then Lighthouse performance JSON |
+| `pnpm run lighthouse` | Lighthouse HTML report against `dist/`; starts and stops its own preview server |
+| `pnpm run audit-performance` | Same, performance category only, JSON output |
CI’s format step **checks**; it does not rewrite or open a follow-up commit. After `pnpm install`, a pre-commit hook runs Prettier on staged files so commits already match that check.
+### Previewing and measuring a build locally
+
+`pnpm run preview` serves `dist/client` from `scripts/serve-dist.mjs`. It shows the
+prerendered pages and assets; it does **not** serve the on-demand `/api/quotes`
+route or apply any `vercel.json` redirect, rewrite, header, or compression rule.
+Verify host behavior against a deployment URL, not against this server.
+
+`astro preview` (`pnpm run preview:vercel`) is delegated to the Vercel CLI by
+`@astrojs/vercel`. Without that CLI installed it fails with "Preview server
+process exited before becoming ready", which is why it is no longer the default
+`preview` script.
+
+`pnpm run lighthouse` and `pnpm run audit-performance` start and stop that same
+static server themselves, so run `pnpm run build` first and nothing else. Reports
+land in `reports/` (gitignored). They measure localhost over plain HTTP — useful
+as a before/after baseline, not as field performance.
+
## Build (social images)
`pnpm run build` runs `scripts/generate-social-images.js` before `astro build`.
diff --git a/docs/TECHNICAL-AUDIT.md b/docs/TECHNICAL-AUDIT.md
index a4886d8b..90ee3501 100644
--- a/docs/TECHNICAL-AUDIT.md
+++ b/docs/TECHNICAL-AUDIT.md
@@ -7,6 +7,11 @@
This document is the system map the constitution already points at. It records what the product _is_ technically, where docs/code drift, and which gaps are closed vs deferred.
+**Revised 2026-09-10** (audit date above is preserved on purpose): the toolchain row
+in §2, the gate table in §7, and the cache note in §10 were corrected against the
+`codex/optimization-roadmap` work. Everything else still describes the 2026-07-28
+snapshot and has not been re-verified.
+
---
## 1. Product shape (one sentence)
@@ -17,17 +22,17 @@ Public field notes (essays, household recipes, book library) on a **hybrid Astro
## 2. Stack (truth)
-| Layer | Actual (resolved) | Docs that were wrong |
-| --------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------- |
-| Framework | **Astro 7.x** (`astro@7.1.4`) | README / constitution / specify-rules said Astro 6 |
-| Output | **Hybrid** — default static + `prerender = false` on quotes | README said “static output” |
-| Adapter | `@astrojs/vercel@11` | OK |
-| UI | Tailwind CSS 4 + Vite plugin; Astro Fonts (DM Sans, Fraunces, Literata, JetBrains Mono) | OK |
-| Content | MD/MDX via `@astrojs/mdx`, collection `blog` | OK |
-| Language | TypeScript 5.9 | OK |
-| Package manager | pnpm (CI: pnpm 10, Node 22.12) | OK |
-| Image | Sharp; AVIF sources → social JPEG/PNG | OK |
-| Analysis | `sentiment`, `reading-time`, brain-science utils | OK |
+| Layer | Actual (resolved) | Docs that were wrong |
+| --------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------- |
+| Framework | **Astro 7.x** (`astro@7.2.2` as of 2026-09-10) | README / constitution / specify-rules said Astro 6 |
+| Output | **Hybrid** — default static + `prerender = false` on quotes | README said “static output” |
+| Adapter | `@astrojs/vercel@11` | OK |
+| UI | Tailwind CSS 4 + Vite plugin; Astro Fonts (DM Sans, Fraunces, Source Serif 4, JetBrains Mono) | OK |
+| Content | MD/MDX via `@astrojs/mdx`, collection `blog` | OK |
+| Language | TypeScript 5.9 | OK |
+| Package manager | pnpm 12.3.4 (`packageManager`), Node 22.12.0 (`.nvmrc`); CI reads both | OK |
+| Image | Sharp; AVIF sources → social JPEG/PNG | OK |
+| Analysis | `sentiment`, `reading-time`, brain-science utils | OK |
---
@@ -114,16 +119,18 @@ No accounts. No server-side reading progress. Constitution principle IV applies.
## 7. Quality gates (actual vs claimed)
-| Gate | Status |
-| ----------------------------------- | ------------------------------------------------------- |
-| `pnpm run format:check` | CI |
-| `pnpm run check` | CI |
-| `pnpm run lint` | CI |
-| `pnpm run build` | CI (+ social image step) |
-| `pnpm run validate-feeds` | **Wired** — needs `dist/` from a prior build |
-| `pnpm run audit-frontmatter` | **Wired** — walks `src/content/p` |
-| `pnpm run validate-structured-data` | **Wired** — smoke-checks structured-data module surface |
-| Unit / e2e tests | **None** (constitution acknowledges this) |
+| Gate | Status |
+| ----------------------------------- | -------------------------------------------------------- |
+| `pnpm run format:check` | CI |
+| `pnpm run check` | CI |
+| `pnpm run lint` | CI |
+| `pnpm run build` | CI (+ social image step) |
+| `pnpm run validate-feeds` | CI, after the build (needs `dist/`) |
+| `pnpm run audit-frontmatter` | CI, before the build — walks `src/content/p` |
+| `pnpm run validate-structured-data` | CI — smoke-checks structured-data module surface only |
+| `pnpm run check-remark42-rewrite` | CI — `vercel.json` rewrite vs `REMARK42_UPSTREAM_ORIGIN` |
+| Unit tests (`pnpm test`) | CI — see §9 for coverage |
+| Browser / e2e tests | **None** — Playwright is installed but unconfigured |
---
@@ -168,7 +175,7 @@ Unit tests (`pnpm test`) cover publish filters, SEO routing, feed HTML sanitizat
## 10. Scaling risks (known)
-- Writing Insights lexicons live in `src/utils/brainScience/vocabulary.ts` (EN+ES). Flesch / word / sentence metrics + objective metrics + sentiment + posts fetch are memoized for the build process. Meta disk cache is versioned (v2); signature still ignores title and same-length body edits.
+- Writing Insights lexicons live in `src/utils/brainScience/vocabulary.ts` (EN+ES). Flesch / word / sentence metrics + objective metrics + sentiment + posts fetch are memoized for the build process. The meta disk cache is versioned (v3) and its signature hashes title + body plus `pubDate`, so same-length body edits and title changes invalidate (corrected 2026-09-10).
- Dual redirect tables invite drift (host rules stay on Vercel; path redirects in Astro).
- Graphify graph may lag HEAD; refresh with `graphify update .` after code changes.
diff --git a/docs/performance-optimization.md b/docs/performance-optimization.md
index 1af67e04..3bf3ecf0 100644
--- a/docs/performance-optimization.md
+++ b/docs/performance-optimization.md
@@ -2,7 +2,13 @@
## Overview
-This document outlines the comprehensive performance optimizations implemented for the Blog to improve First Contentful Paint (FCP) and Largest Contentful Paint (LCP) metrics, following Astro blog best practices.
+This document outlines the performance optimizations implemented for the Blog to improve First Contentful Paint (FCP) and Largest Contentful Paint (LCP) metrics, following Astro blog best practices.
+
+> **Accuracy note — 2026-09-10.** Parts of this guide describe intended practice
+> rather than verified current behavior, and no field Core Web Vitals data backs
+> the targets below. The font, testing-command, and image sections were corrected
+> against the code on this date. Treat the remaining sections as guidance to
+> re-verify before citing, and measure before claiming an improvement.
## Core Web Vitals Targets
@@ -15,26 +21,21 @@ This document outlines the comprehensive performance optimizations implemented f
### 1. Font Loading Optimization
-#### Critical Font Preloading
-
-- Preload critical fonts (Open Sans 400, 600, Source Serif Pro 600) using `@font-face` with `font-display: swap`
-- Implement font preloading in HTML head for above-the-fold content
-- Use system font fallbacks to prevent invisible text during font loading
+#### Fonts actually in use
-```css
-@font-face {
- font-family: 'Open Sans';
- font-weight: 400;
- font-display: swap;
- src: url('...') format('woff2');
-}
-```
+Fonts are configured through Astro's `fonts` integration in `astro.config.mjs`, not
+hand-written `@font-face` rules. The four families are **DM Sans**, **Fraunces**,
+**Source Serif 4**, and **JetBrains Mono**. (Earlier revisions of this document
+named Open Sans and Source Serif Pro; neither is in the project.)
-#### Font Loading Strategy
+Astro emits the `@font-face` rules, fallback metrics, and preload hints for the
+subsets it decides are needed. Do not add parallel `@font-face` declarations —
+they would compete with what the integration generates.
-- **Critical fonts**: Loaded immediately with preload
-- **Additional weights**: Loaded asynchronously with `display=swap`
-- **Fallback fonts**: System fonts used until custom fonts load
+A build copies 41 font files into the output. That is the number of files
+generated across families, weights, and subsets; it is **not** the number any
+single page downloads. Measure real font transfer per route before treating it as
+a problem.
### 2. Critical CSS Inlining
@@ -136,12 +137,14 @@ pnpm run lighthouse
pnpm run audit-performance
```
-#### Build Analysis
+`pnpm run lighthouse` writes an HTML report and `pnpm run audit-performance`
+writes performance-only JSON, both under `reports/` (gitignored). Each starts and
+stops its own `astro preview` server, so run `pnpm run build` first — they measure
+`dist/` and fail if it is missing.
-```bash
-pnpm run analyze
-pnpm run performance
-```
+The former `analyze` and `performance` scripts were removed on 2026-09-10: they
+shelled out to `@astrojs/vercel/static-build`, which the installed adapter does
+not provide, and `performance` ran the full build twice.
### Monitoring in Development
diff --git a/package.json b/package.json
index 90a74921..e00f28df 100644
--- a/package.json
+++ b/package.json
@@ -7,18 +7,22 @@
"url": "https://github.com/antoniwan/notes.git"
},
"homepage": "https://notes.antoniwan.online",
+ "packageManager": "pnpm@12.3.4",
+ "engines": {
+ "node": ">=22.12.0",
+ "pnpm": ">=12"
+ },
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "node scripts/generate-social-images.js && astro build",
- "preview": "astro preview",
+ "preview": "node scripts/serve-dist.mjs",
+ "preview:vercel": "astro preview",
"astro": "astro",
"generate-social-images": "node scripts/generate-social-images.js",
"generate-favicons": "node scripts/generate-favicons.js",
- "analyze": "astro build && npx @astrojs/vercel/static-build",
- "lighthouse": "npx lighthouse http://localhost:4321 --output=html --output-path=./lighthouse-report.html",
- "performance": "pnpm run build && pnpm run analyze",
- "audit-performance": "pnpm run build && npx lighthouse http://localhost:4321 --only-categories=performance --output=json --output-path=./performance-audit.json",
+ "lighthouse": "node scripts/lighthouse.mjs",
+ "audit-performance": "node scripts/lighthouse.mjs --performance-only",
"prepare": "simple-git-hooks",
"format": "prettier --write .",
"format:check": "prettier --check .",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0d3691c1..9cba005d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1,3 +1,104 @@
+---
+lockfileVersion: '9.0'
+
+importers:
+
+ .:
+ configDependencies: {}
+ packageManagerDependencies:
+ pnpm:
+ specifier: 12.3.4
+ version: 12.3.4
+
+packages:
+
+ '@pnpm/exe.darwin-arm64@12.3.4':
+ resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@pnpm/exe.darwin-x64@12.3.4':
+ resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
+ resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@pnpm/exe.linux-arm64@12.3.4':
+ resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@pnpm/exe.linux-x64-musl@12.3.4':
+ resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@pnpm/exe.linux-x64@12.3.4':
+ resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@pnpm/exe.win32-arm64@12.3.4':
+ resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@pnpm/exe.win32-x64@12.3.4':
+ resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==}
+ cpu: [x64]
+ os: [win32]
+
+ pnpm@12.3.4:
+ resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==}
+ engines: {node: '>=18.*'}
+ hasBin: true
+
+snapshots:
+
+ '@pnpm/exe.darwin-arm64@12.3.4':
+ optional: true
+
+ '@pnpm/exe.darwin-x64@12.3.4':
+ optional: true
+
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
+ optional: true
+
+ '@pnpm/exe.linux-arm64@12.3.4':
+ optional: true
+
+ '@pnpm/exe.linux-x64-musl@12.3.4':
+ optional: true
+
+ '@pnpm/exe.linux-x64@12.3.4':
+ optional: true
+
+ '@pnpm/exe.win32-arm64@12.3.4':
+ optional: true
+
+ '@pnpm/exe.win32-x64@12.3.4':
+ optional: true
+
+ pnpm@12.3.4:
+ optionalDependencies:
+ '@pnpm/exe.darwin-arm64': 12.3.4
+ '@pnpm/exe.darwin-x64': 12.3.4
+ '@pnpm/exe.linux-arm64': 12.3.4
+ '@pnpm/exe.linux-arm64-musl': 12.3.4
+ '@pnpm/exe.linux-x64': 12.3.4
+ '@pnpm/exe.linux-x64-musl': 12.3.4
+ '@pnpm/exe.win32-arm64': 12.3.4
+ '@pnpm/exe.win32-x64': 12.3.4
+
+---
lockfileVersion: '9.0'
settings:
diff --git a/scripts/lighthouse.mjs b/scripts/lighthouse.mjs
new file mode 100644
index 00000000..24122248
--- /dev/null
+++ b/scripts/lighthouse.mjs
@@ -0,0 +1,108 @@
+#!/usr/bin/env node
+/**
+ * Runs Lighthouse against a static server this script starts and stops itself.
+ *
+ * The previous `lighthouse` and `audit-performance` package scripts pointed at
+ * http://localhost:4321 and assumed somebody had already started a server there.
+ * When nothing was listening they failed with a connection error; worse, if an
+ * unrelated dev server happened to be on that port they measured whatever it was
+ * serving.
+ *
+ * Scope is the same as `scripts/serve-dist.mjs`: prerendered pages from
+ * `dist/client`, over plain HTTP, from localhost. It does not exercise
+ * `/api/quotes` or any Vercel redirect, rewrite, header, or compression, and it
+ * says nothing about real network conditions. Treat the numbers as a local
+ * comparison baseline, not as field performance.
+ *
+ * Usage:
+ * node scripts/lighthouse.mjs [--performance-only] [--path=/some/route]
+ *
+ * Lighthouse itself is not a project dependency. It is fetched on demand through
+ * `npx`, so this needs network access on first run.
+ */
+
+import { spawn } from 'node:child_process';
+import { mkdir } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+
+import { createDistServer, listen, repoRoot, staticRoot } from './serve-dist.mjs';
+
+const PORT = Number(process.env.LIGHTHOUSE_PORT ?? 4321);
+const HOST = '127.0.0.1';
+const isWindows = process.platform === 'win32';
+
+const args = process.argv.slice(2);
+const performanceOnly = args.includes('--performance-only');
+const pathArg = args.find((arg) => arg.startsWith('--path='));
+const targetPath = pathArg ? pathArg.slice('--path='.length) : '/';
+
+const outputPath = performanceOnly
+ ? resolve(repoRoot, 'reports/performance-audit.json')
+ : resolve(repoRoot, 'reports/lighthouse-report.html');
+
+function fail(message) {
+ console.error(`lighthouse: ${message}`);
+ process.exit(1);
+}
+
+// The target URL is concatenated into a shelled-out command on Windows, so keep
+// it to characters that cannot end the command or start another one.
+if (!/^\/[\w\-./?=&%]*$/.test(targetPath)) {
+ fail(`--path must be a plain site path, got: ${targetPath}`);
+}
+
+let server;
+
+function stopServer() {
+ server?.close();
+}
+
+async function main() {
+ await mkdir(dirname(outputPath), { recursive: true });
+
+ server = createDistServer();
+ const origin = await listen(server, { host: HOST, port: PORT }).catch((error) => {
+ const hint = error?.message?.includes('in use') ? ' Set LIGHTHOUSE_PORT to pick another.' : '';
+ fail(`${error?.message ?? error}${hint}`);
+ });
+
+ console.log(`lighthouse: serving ${staticRoot} at ${origin}`);
+
+ const lighthouseArgs = [
+ '--yes',
+ 'lighthouse',
+ `${origin}${targetPath}`,
+ performanceOnly ? '--only-categories=performance' : null,
+ performanceOnly ? '--output=json' : '--output=html',
+ `--output-path=${outputPath}`,
+ '--chrome-flags=--headless=new',
+ ].filter(Boolean);
+
+ const exitCode = await new Promise((done) => {
+ const lighthouse = spawn('npx', lighthouseArgs, {
+ cwd: repoRoot,
+ shell: isWindows,
+ stdio: 'inherit',
+ });
+ lighthouse.on('error', (error) => fail(`could not start Lighthouse: ${error.message}`));
+ lighthouse.on('close', done);
+ });
+
+ stopServer();
+
+ if (exitCode !== 0) fail(`Lighthouse exited with code ${exitCode}.`);
+ console.log(`lighthouse: report written to ${outputPath}`);
+}
+
+for (const signal of ['SIGINT', 'SIGTERM']) {
+ process.on(signal, () => {
+ stopServer();
+ process.exit(1);
+ });
+}
+process.on('exit', stopServer);
+
+main().catch((error) => {
+ stopServer();
+ fail(error?.message ?? String(error));
+});
diff --git a/scripts/serve-dist.mjs b/scripts/serve-dist.mjs
new file mode 100644
index 00000000..c4bc1bb3
--- /dev/null
+++ b/scripts/serve-dist.mjs
@@ -0,0 +1,127 @@
+#!/usr/bin/env node
+/**
+ * Serves the prerendered build output over plain HTTP.
+ *
+ * `astro preview` does not work in this repo: the Vercel adapter delegates
+ * preview to the Vercel CLI, which is not a project dependency, so the command
+ * fails with "Preview server process exited before becoming ready." This is the
+ * dependency-free substitute, and `scripts/lighthouse.mjs` reuses it.
+ *
+ * What it serves: the prerendered pages and assets in `dist/client`.
+ * What it does NOT reproduce: the on-demand `/api/quotes` route, Vercel's
+ * redirects, rewrites, headers, or compression. Host behavior has to be checked
+ * against a real deployment URL, not here.
+ *
+ * Usage:
+ * node scripts/serve-dist.mjs [--port=4321] [--host=127.0.0.1]
+ */
+
+import { createReadStream, existsSync, statSync } from 'node:fs';
+import { createServer } from 'node:http';
+import { dirname, extname, join, normalize, resolve, sep } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+export const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+export const staticRoot = resolve(repoRoot, 'dist/client');
+
+const CONTENT_TYPES = {
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'text/javascript; charset=utf-8',
+ '.mjs': 'text/javascript; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.xml': 'application/xml; charset=utf-8',
+ '.txt': 'text/plain; charset=utf-8',
+ '.svg': 'image/svg+xml',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.webp': 'image/webp',
+ '.avif': 'image/avif',
+ '.ico': 'image/x-icon',
+ '.woff': 'font/woff',
+ '.woff2': 'font/woff2',
+};
+
+/** Resolves a request path to a file inside the output, or null. */
+function resolveFile(urlPath) {
+ const decoded = decodeURIComponent(urlPath.split('?')[0].split('#')[0]);
+ // normalize() collapses "..", and the prefix check rejects anything that would
+ // still escape the output directory.
+ const candidate = resolve(staticRoot, `.${normalize(decoded)}`);
+ if (candidate !== staticRoot && !candidate.startsWith(staticRoot + sep)) return null;
+
+ if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
+
+ const indexFile = join(candidate, 'index.html');
+ if (existsSync(indexFile) && statSync(indexFile).isFile()) return indexFile;
+
+ return null;
+}
+
+export function createDistServer() {
+ if (!existsSync(staticRoot)) {
+ throw new Error(`no ${staticRoot}. Run \`pnpm run build\` first.`);
+ }
+
+ return createServer((request, response) => {
+ const file = resolveFile(request.url ?? '/');
+
+ if (!file) {
+ const notFound = join(staticRoot, '404.html');
+ if (existsSync(notFound)) {
+ response.writeHead(404, { 'content-type': CONTENT_TYPES['.html'] });
+ createReadStream(notFound).pipe(response);
+ return;
+ }
+ response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
+ response.end('Not found');
+ return;
+ }
+
+ response.writeHead(200, {
+ 'content-type': CONTENT_TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream',
+ // No caching, so a rebuild is visible on reload without a hard refresh.
+ 'cache-control': 'no-store',
+ });
+ createReadStream(file).pipe(response);
+ });
+}
+
+/** Starts the server, rejecting with a readable message on a busy port. */
+export function listen(server, { host, port }) {
+ return new Promise((ready, reject) => {
+ server.once('error', (error) => {
+ if (error?.code === 'EADDRINUSE') {
+ reject(new Error(`port ${port} is already in use.`));
+ return;
+ }
+ reject(error);
+ });
+ server.listen(port, host, () => ready(`http://${host}:${port}`));
+ });
+}
+
+const isDirectRun = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
+
+if (isDirectRun) {
+ const args = process.argv.slice(2);
+ const value = (name, fallback) => {
+ const found = args.find((arg) => arg.startsWith(`--${name}=`));
+ return found ? found.slice(name.length + 3) : fallback;
+ };
+ const host = value('host', '127.0.0.1');
+ const port = Number(value('port', process.env.PREVIEW_PORT ?? 4321));
+
+ try {
+ const origin = await listen(createDistServer(), { host, port });
+ console.log(`serve-dist: serving ${staticRoot}`);
+ console.log(`serve-dist: ${origin}`);
+ console.log(
+ 'serve-dist: prerendered pages only — /api/quotes and Vercel rules are not served.',
+ );
+ } catch (error) {
+ console.error(`serve-dist: ${error?.message ?? error}`);
+ process.exit(1);
+ }
+}
diff --git a/src/components/FeaturedWritingsRotator.astro b/src/components/FeaturedWritingsRotator.astro
deleted file mode 100644
index c0056442..00000000
--- a/src/components/FeaturedWritingsRotator.astro
+++ /dev/null
@@ -1,589 +0,0 @@
----
-import type { CollectionEntry } from 'astro:content';
-import FormattedDate from './FormattedDate.astro';
-import DefaultImage from './DefaultImage.astro';
-import { calculateReadingTimeFromMarkdown } from '../utils/readingTime';
-
-export interface Props {
- posts: CollectionEntry<'blog'>[];
- autoRotate?: boolean;
- rotationSpeed?: number;
- showNavigation?: boolean;
- className?: string;
-}
-
-const {
- posts,
- autoRotate = true,
- rotationSpeed = 6000,
- showNavigation = true,
- className = '',
-} = Astro.props;
-
-if (!posts || posts.length === 0) {
- return null;
-}
----
-
-
-