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; -} ---- - -
-
- {/* Main carousel container */} - -
-
- - - - diff --git a/src/components/ImageRotator.astro b/src/components/ImageRotator.astro index 466521bf..ddf67054 100644 --- a/src/components/ImageRotator.astro +++ b/src/components/ImageRotator.astro @@ -24,6 +24,8 @@ const { if (!images || images.length === 0) { return null; } + +const canAutoRotate = autoRotate && images.length > 1; ---
) } + + + { + canAutoRotate && ( + + ) + }
@@ -177,6 +213,31 @@ if (!images || images.length === 0) { @apply absolute top-4 right-4 rounded-full bg-black/50 px-3 py-1 text-sm font-medium text-white; } + .rotator-playback { + @apply absolute top-4 left-4 flex items-center justify-center rounded-full bg-black/50 p-2 text-white transition-colors duration-200 hover:bg-black/70; + min-height: 44px; + min-width: 44px; + } + + /* The display utility above would otherwise beat the UA rule for [hidden]. */ + .rotator-playback[hidden] { + display: none; + } + + .rotator-playback:focus-visible { + outline: 2px solid rgb(var(--color-accent)); + outline-offset: 2px; + } + + .rotator-playback-label { + @apply sr-only; + } + + .rotator-playback[data-playing='true'] .rotator-icon-play, + .rotator-playback[data-playing='false'] .rotator-icon-pause { + display: none; + } + .thumbnail-container { @apply mt-4 flex gap-2 overflow-x-auto pb-2; } @@ -230,6 +291,15 @@ if (!images || images.length === 0) { private autoRotate: boolean; private rotationSpeed: number; private rotationInterval: number | null; + private playbackBtn: HTMLButtonElement | null; + private reducedMotion: MediaQueryList; + // The reader's explicit choice. Nothing but the playback button changes it, + // so moving the pointer away never resumes a slideshow they paused. + private userPaused = false; + // Transient reasons to hold: pointer over the rotator, keyboard focus inside + // it, or a backgrounded tab. + private hovered = false; + private focused = false; constructor(container: HTMLElement) { this.container = container; @@ -239,6 +309,8 @@ if (!images || images.length === 0) { this.autoRotate = container.dataset.autoRotate === 'true'; this.rotationSpeed = parseInt(container.dataset.rotationSpeed || '5000'); this.rotationInterval = null; + this.playbackBtn = container.querySelector('.rotator-playback'); + this.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); this.init(); } @@ -270,13 +342,70 @@ if (!images || images.length === 0) { } private setupAutoRotation(): void { - if (this.autoRotate && this.images.length > 1) { - this.startAutoRotation(); + if (!this.autoRotate || this.images.length <= 1) return; + + // Readers who ask for reduced motion start paused rather than losing the + // control entirely; they can still opt in. + this.userPaused = this.reducedMotion.matches; + + this.container.addEventListener('mouseenter', () => { + this.hovered = true; + this.syncRotation(); + }); + this.container.addEventListener('mouseleave', () => { + this.hovered = false; + this.syncRotation(); + }); + // Focus inside the rotator holds rotation so a keyboard reader is not + // moved out from under themselves. The playback control is excluded, or + // pressing Play would be cancelled by the focus it just received. + const holdsFocus = (node: EventTarget | null): boolean => + node instanceof Node && this.container.contains(node) && node !== this.playbackBtn; + + this.container.addEventListener('focusin', (event) => { + this.focused = holdsFocus(event.target); + this.syncRotation(); + }); + this.container.addEventListener('focusout', (event) => { + this.focused = holdsFocus((event as FocusEvent).relatedTarget); + this.syncRotation(); + }); + document.addEventListener('visibilitychange', () => this.syncRotation()); + this.reducedMotion.addEventListener('change', (event) => { + // A reader turning the preference on mid-visit should stop advancing. + if (event.matches) this.userPaused = true; + this.syncRotation(); + }); - // Pause auto-rotation on hover - this.container.addEventListener('mouseenter', () => this.stopAutoRotation()); - this.container.addEventListener('mouseleave', () => this.startAutoRotation()); + if (this.playbackBtn) { + this.playbackBtn.hidden = false; + this.playbackBtn.addEventListener('click', () => { + this.userPaused = !this.userPaused; + this.syncRotation(); + }); } + + this.syncRotation(); + } + + private shouldRotate(): boolean { + return !this.userPaused && !this.hovered && !this.focused && !document.hidden; + } + + private syncRotation(): void { + if (this.shouldRotate()) this.startAutoRotation(); + else this.stopAutoRotation(); + this.updatePlaybackControl(); + } + + // The button reports the reader's own choice, not the transient hover/focus + // holds, so hovering never makes the label flicker to "Play". + private updatePlaybackControl(): void { + if (!this.playbackBtn) return; + const playing = !this.userPaused; + this.playbackBtn.dataset.playing = playing ? 'true' : 'false'; + const label = this.playbackBtn.querySelector('.rotator-playback-label'); + if (label) label.textContent = playing ? 'Pause slideshow' : 'Play slideshow'; } private startAutoRotation(): void { @@ -342,8 +471,11 @@ if (!images || images.length === 0) { } // Initialize all image rotators on the page - document.addEventListener('DOMContentLoaded', () => { + function initializeRotators() { const rotators = document.querySelectorAll('.image-rotator'); rotators.forEach((rotator) => new ImageRotator(rotator as HTMLElement)); - }); + } + if (document.readyState === 'loading') + document.addEventListener('DOMContentLoaded', initializeRotators, { once: true }); + else initializeRotators(); diff --git a/src/components/LazyPosts.astro b/src/components/LazyPosts.astro deleted file mode 100644 index 255cf804..00000000 --- a/src/components/LazyPosts.astro +++ /dev/null @@ -1,209 +0,0 @@ ---- -import PostCard from './PostCard.astro'; - -interface Props { - posts: any[]; - initialCount?: number; - loadMoreCount?: number; -} - -const { posts, initialCount = 6, loadMoreCount = 6 } = Astro.props; ---- - -
-
- { - posts.map((post, index) => ( -
- -
- )) - } -
- - { - posts.length > initialCount && ( -
-
- Showing {initialCount} of {posts.length} writings ( - {Math.round((initialCount / posts.length) * 100)}% complete) -
- - - ) - } -
- - - - diff --git a/src/components/SearchBar.astro b/src/components/SearchBar.astro index b1480308..a3cb7225 100644 --- a/src/components/SearchBar.astro +++ b/src/components/SearchBar.astro @@ -52,12 +52,18 @@ const searchId = `search-${Math.random().toString(36).slice(2, 11)}`;
+ +

+

+