From 135f7bc7b88313d9774371c0fa88ac20ce5f2d3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20Rodr=C3=ADguez=20Mart=C3=ADnez?= Date: Thu, 10 Sep 2026 12:39:09 -0400 Subject: [PATCH 1/2] (a11y) announce search outcomes, give readers autoplay control, pin the toolchain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R10 — SearchBar gets a polite live region and an aria-busy results container. Loading, result counts, and no-result outcomes are announced with the query; clearing empties the region so nothing stale is read back. R11 — ImageRotator gets a persistent play/pause button. Rotation now holds for an explicit reader pause, pointer hover, keyboard focus inside the rotator, and a backgrounded tab, tracked separately so moving the pointer away never resumes a slideshow the reader paused. Reduced motion starts paused and can be opted into; turning the preference on mid-visit pauses. R12 — Pin pnpm 12.3.4 via packageManager and Node 22.12.0 via .nvmrc, with CI reading both instead of repeating versions. Two package scripts were broken: `analyze` called @astrojs/vercel/static-build, which the adapter does not provide, and `performance` chained into it after a redundant build; both are removed. `astro preview` also fails here because the Vercel adapter delegates to the Vercel CLI, so `preview` now serves dist/client from scripts/serve-dist.mjs and `preview:vercel` keeps the old command. The Lighthouse scripts start and stop that server themselves rather than assuming one on port 4321. Corrected stale font, gate, and cache claims in the two docs, preserving their audit dates. R15 — Remove FeaturedWritingsRotator and LazyPosts. No static, dynamic, or MDX consumer references either; the repo has no import.meta.glob component loading. Local gate: 261 tests, astro check (0/0/0), ESLint, Prettier, build, frontmatter, structured-data, feeds, and Remark42 rewrite all pass. R10 and R11 were exercised in a browser against the built output; R11's reduced-motion path is code-verified only, as the automation surface cannot emulate the preference. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 6 +- .gitignore | 3 + .nvmrc | 1 + README.md | 52 +- docs/TECHNICAL-AUDIT.md | 51 +- docs/performance-optimization.md | 49 +- package.json | 14 +- scripts/lighthouse.mjs | 108 ++++ scripts/serve-dist.mjs | 127 ++++ src/components/FeaturedWritingsRotator.astro | 589 ------------------- src/components/ImageRotator.astro | 146 ++++- src/components/LazyPosts.astro | 209 ------- src/components/SearchBar.astro | 42 +- 13 files changed, 527 insertions(+), 870 deletions(-) create mode 100644 .nvmrc create mode 100644 scripts/lighthouse.mjs create mode 100644 scripts/serve-dist.mjs delete mode 100644 src/components/FeaturedWritingsRotator.astro delete mode 100644 src/components/LazyPosts.astro 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/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)}`;
+ +

+

+