From 07854c6a2b70e7e60ea333f57f75b902fd410e0d Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 11 Nov 2026 09:23:14 +0100 Subject: [PATCH] fix: stop inlining fonts into CSS, page the Ghost fetch, audit small fixes - vite assetsInlineLimit 0: fonts-work.css 42.8 KB -> 5.9 KB - Ghost posts fetched with limit=100&page=N instead of limit=all - 404 title uses site.name; drop the duplicate js-class script on /everything/ - deploy/compose.yml: LISTENBRAINZ_USER - HANDOFF and TODO: the site is live and the sources ran Audit findings 2, 6, 7, 18, 19, 21 (docs/audits/2026-11-11-audit.md). Co-Authored-By: Claude Fable 5.2 --- astro.config.mjs | 2 ++ deploy/compose.yml | 1 + docs/DECISIONS.md | 11 +++++++++++ docs/HANDOFF.md | 8 +++++++- docs/TODO.md | 2 +- docs/audits/2026-11-11-audit.md | 12 ++++++------ src/site/pages/404.astro | 5 ++++- src/site/pages/everything.astro | 1 - src/sources/ghost.ts | 18 ++++++++++++++---- test/site/build-pages.test.ts | 12 ++++++++++++ test/sources/sources.test.ts | 19 +++++++++++++++++++ 11 files changed, 77 insertions(+), 14 deletions(-) diff --git a/astro.config.mjs b/astro.config.mjs index 25aa506..fbddde6 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -7,4 +7,6 @@ export default defineConfig({ build: { format: 'directory' }, integrations: [react()], devToolbar: { enabled: false }, + // Never inline assets as base64: Vite's 4 KB default put unused font subsets into render-blocking CSS. + vite: { build: { assetsInlineLimit: 0 } }, }); diff --git a/deploy/compose.yml b/deploy/compose.yml index 17f1acf..aaabf67 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -34,6 +34,7 @@ services: GHOST_CONTENT_KEY: ${GHOST_CONTENT_KEY:?Ghost Content API key required} TRAKT_CLIENT_ID: ${TRAKT_CLIENT_ID:?Trakt client id required} TRAKT_USER: lucasw89 + LISTENBRAINZ_USER: stereotypicalcat HARDCOVER_TOKEN: ${HARDCOVER_TOKEN:?Hardcover API token required} volumes: - ./data:/data diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 4cb2680..f698fdb 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -335,3 +335,14 @@ sweeping the pointer (headless Chromium): personal half ~1750 → ~590 ms, work the inline SVG was chosen); no grain (owner kept the texture). **Consequences:** A mask image is not an LCP candidate; the phone LCP stays the collage's photo. + +## 2026-11-11 — No asset is inlined into CSS; Ghost posts are fetched page by page +**Status:** accepted +**Context:** The 2026-11-11 audit (`docs/audits/2026-11-11-audit.md`, findings 2 and 6). Vite inlines assets under 4 KB +as base64, so `fonts-work.css` carried six Geist Mono subsets (Cyrillic-ext, Vietnamese, symbols) that `unicode-range` +would otherwise never download: 42.8 KB of render-blocking CSS (28 KB gzip). The Ghost client asked for `limit=all`, +which Ghost 6 is believed to cap at 100 posts. +**Decision:** `vite.build.assetsInlineLimit: 0` in `astro.config.mjs` (`fonts-work.css` is now 5.9 KB, 1 KB gzip). +`fetchGhost` asks for `limit=100&page=N` and follows `meta.pagination.next`, up to 50 pages. +**Alternatives:** an inline-limit function that only exempts fonts (nothing else here is small enough to matter). +**Consequences:** Every imported asset is its own request; a test fails if a stylesheet contains `url(data:`. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 77a5693..49bcc06 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,9 +1,15 @@ -# Handoff — 2026-11-10 (after spec 002) +# Handoff — 2026-11-11 (live; audit in progress) Where development stopped, and how to pick it up. Delete or rewrite this file when the next session starts. ## State +- **The site is live** at `lucaswinther.info` (behind Cloudflare) since the DNS cutover on 2026-11-10; the 2021 site is at + `old.lucaswinther.info`. On 2026-11-11 `/status` reported all four sources ok. Older lines below that say "not + deployed" or "never run live" describe the state before that. +- **Audit 2026-11-11**: `docs/audits/2026-11-11-audit.md` lists 24 findings with a status each. Fixed so far on + `fix/audit-quick-wins`: 2, 6, 7, 18, 19, 21. + - **Spec 005 is merged and pushed** (2026-11-10): the start page cassette shows a strip of the song cover with title and artist; the collage stacks (polaroid over cassette and note); the grain is a pre-rendered tile mask. The owner's hover-lag feel is written up in `docs/TODO.md` (his browser caps at ~70–80 fps). diff --git a/docs/TODO.md b/docs/TODO.md index d1a7579..f0a281b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -11,7 +11,7 @@ Small, specific, independently actionable. Remove only when actually done. - [ ] Write `personal_intro` in `site.yml` (content repo): the lede on the start page's right half and `/off-the-clock/`. Until then there is none (the mockup's "A wall of what's caught me lately…" line is no longer hard-coded). - [ ] Check the Junkwraith comic item's `line` by eye before un-drafting it (content repo `items/junkwraith.md`; transcribed from a Bluesky share). - [ ] Add the comic images (`media/`) and the CV PDF (`site.links.cv`, a filename under `media/`). -- [ ] Verify the Ghost, Trakt and Hardcover modules against the live services once keys exist. They were written from documented field names, never run live (`src/sources/ghost.ts`, `trakt.ts`, `hardcover.ts`). Expect `/status` to name any that fail; the site still builds. +- [x] Verify the Ghost, Trakt and Hardcover modules against the live services. Done 2026-11-11: the live `/status` reports `ghost`, `trakt`, `hardcover` and `listenbrainz` all ok, and the strip shows a book, a film with its rating and the week's track. Not yet seen live: a Hardcover book with page progress. - [ ] Make the Trakt profile public (the build uses no OAuth). If it must stay private, add OAuth in `src/sources/trakt.ts`. - [x] DNS cutover and the 2021 site's move to `old.lucaswinther.info`, per `docs/DEPLOY.md` (done 2026-11-10: the config repo's `portfolio` stack serves the 2021 site there). - [ ] Redirect the old blog URLs (`blog.lucaswinther.info//`) to `/work/writing//` (DEPLOY step 7), and decide what stays on the blog host. Post pages link "Read it on the blog →" to the Ghost URL, so that link must change or stop redirecting once the blog redirects. diff --git a/docs/audits/2026-11-11-audit.md b/docs/audits/2026-11-11-audit.md index 97eb111..164a590 100644 --- a/docs/audits/2026-11-11-audit.md +++ b/docs/audits/2026-11-11-audit.md @@ -19,12 +19,12 @@ Update the status here when a finding is dealt with; delete the file once every | # | Finding | Evidence | Fix | Status | |---|---|---|---|---| | 1 | `www.lucaswinther.info` returns a Cloudflare **525** (TLS handshake with the origin fails). The apex works. | `curl -I https://www.lucaswinther.info/` → `HTTP/2 525` | Homelab config or Cloudflare: a certificate and `VIRTUAL_HOST` for `www`, or a Cloudflare redirect rule to the apex. | owner | -| 2 | The work pages ship ~38 KB of fonts inside render-blocking CSS. Vite inlines assets under 4 KB as base64, so `fonts-work.css` carries six Geist Mono subsets (Cyrillic-ext, Vietnamese, symbols; two weights) that an English page never requests. | `fonts-work.*.css` 42.8 KB raw / 28 KB gzip, 8 `url(data:` faces; the other themes' font CSS is 8–11 KB with none. | `vite.build.assetsInlineLimit: 0` in `astro.config.mjs`. | open | +| 2 | The work pages ship ~38 KB of fonts inside render-blocking CSS. Vite inlines assets under 4 KB as base64, so `fonts-work.css` carries six Geist Mono subsets (Cyrillic-ext, Vietnamese, symbols; two weights) that an English page never requests. | `fonts-work.*.css` 42.8 KB raw / 28 KB gzip, 8 `url(data:` faces; the other themes' font CSS is 8–11 KB with none. | `vite.build.assetsInlineLimit: 0` in `astro.config.mjs`. | fixed (`fix/audit-quick-wins`) | | 3 | The start page downloads the React runtime (213 KB raw, 66 KB gzip) for almost nothing: `Gate.tsx` returns `null` and only attaches listeners; the only other island is the three-button preference switch. | `index.html` has two `astro-island`s, both with `renderer-url="/_astro/client.*.js"` (212,880 bytes). | Make both plain scripts on the start page (the switch stays a React island on the work pages, where React is loaded anyway). Needs a spec. | open | | 4 | Replaced media can stay stale for 20 hours. The origin sends `max-age=3600` for `/media/`; Cloudflare raises that to `max-age=72000` for browsers. File names carry no hash, so a new CV PDF or image under the same name keeps showing the old one. | `curl -I https://lucaswinther.info/media/mr-rowing.jpg` → `cache-control: public, max-age=72000`. | Hash media URLs at build (`/media/x.jpg?v=` or hashed copies), or set Cloudflare's Browser Cache TTL to "respect existing headers". | open | | 5 | Work pages are dark but never declare `color-scheme: dark` (`/everything/` and `/simple` do declare theirs), so native scrollbars and the search field's built-in parts render light on the dark page. | `grep color-scheme src/site/styles` → only `simple.css`, `everything.css`. | `color-scheme: dark` on `[data-theme='work']`. Visual: owner looks before commit. | open | -| 6 | The Ghost fetch uses `limit=all`. The blog runs Ghost 6.75; Ghost 6 is believed to cap that at 100 posts per request (not confirmed against the docs). Harmless under 100 posts, silent truncation above. | `src/sources/ghost.ts`; `GET blog…/ghost/api/admin/site/` → `"version":"6.75"`. | Page through `meta.pagination` with `limit=100`. | open | -| 7 | `docs/HANDOFF.md` and `docs/TODO.md` say the site is undeployed and that the source clients never ran live. The site is live and `/status` reports all four sources `ok`. | Live `/status` on 2026-11-11: `ghost`, `trakt`, `hardcover`, `listenbrainz` all `"ok": true`. | Bring both files up to date. | open | +| 6 | The Ghost fetch uses `limit=all`. The blog runs Ghost 6.75; Ghost 6 is believed to cap that at 100 posts per request (not confirmed against the docs). Harmless under 100 posts, silent truncation above. | `src/sources/ghost.ts`; `GET blog…/ghost/api/admin/site/` → `"version":"6.75"`. | Page through `meta.pagination` with `limit=100`. | fixed (`fix/audit-quick-wins`) | +| 7 | `docs/HANDOFF.md` and `docs/TODO.md` say the site is undeployed and that the source clients never ran live. The site is live and `/status` reports all four sources `ok`. | Live `/status` on 2026-11-11: `ghost`, `trakt`, `hardcover`, `listenbrainz` all `"ok": true`. | Bring both files up to date. | fixed (`fix/audit-quick-wins`) | ## Missing @@ -50,10 +50,10 @@ Update the status here when a finding is dealt with; delete the file once every |---|---|---| | 16 | `src/runtime/refresh.ts` ignores the commit `fetchSnapshot` returns and records the earlier `ls-remote` head, so `/status` can name a different commit than the one built when a push lands between the two. | open | | 17 | A build killed mid-run leaves `data/src/` behind; nothing cleans it (`removeStaleStaging` only covers the app's `.tmp/build-*`). | open | -| 18 | `deploy/compose.yml` has no `LISTENBRAINZ_USER`, though `docs/DEPLOY.md` lists it. | open | -| 19 | `src/site/pages/404.astro` hard-codes "Lucas Winther" in its title instead of `site.name`. | open | +| 18 | `deploy/compose.yml` has no `LISTENBRAINZ_USER`, though `docs/DEPLOY.md` lists it. | fixed (`fix/audit-quick-wins`) | +| 19 | `src/site/pages/404.astro` hard-codes "Lucas Winther" in its title instead of `site.name`. | fixed (`fix/audit-quick-wins`) | | 20 | The analytics script is baked into every build, so local previews and the image smoke test count as visits (e2e aborts the request). An env switch set only in production would stop that. | open | -| 21 | `everything.astro` adds the `js` class in its own inline script; `Base.astro` already does. | open | +| 21 | `everything.astro` adds the `js` class in its own inline script; `Base.astro` already does. | fixed (`fix/audit-quick-wins`) | | 22 | The origin sends no `ETag` or `Last-Modified`, so `no-cache` responses are never answered with a 304. Small today (HTML is 6–10 KB compressed and Cloudflare caches static files), cheap to add. | open | | 23 | `/status` is public and shows commit ids and the tail of the last build error. Low risk; say so in `docs/DEPLOY.md` or restrict it at the proxy. | open | | 24 | The live "Last film" cell read "watched 7 Aug" while the wall had a film dated 10 Nov. That is what the Trakt history says; noted only because visitors see both. | owner | diff --git a/src/site/pages/404.astro b/src/site/pages/404.astro index 1419a40..a0e419a 100644 --- a/src/site/pages/404.astro +++ b/src/site/pages/404.astro @@ -1,7 +1,10 @@ --- import Base from '../layouts/Base.astro'; import Placeholder404 from '../components/Placeholder404.astro'; +import { getSiteData } from '../data'; + +const { site } = await getSiteData(); --- - + diff --git a/src/site/pages/everything.astro b/src/site/pages/everything.astro index 8b70943..2a3befa 100644 --- a/src/site/pages/everything.astro +++ b/src/site/pages/everything.astro @@ -42,7 +42,6 @@ const inYear = (y: string) => everything.filter((e) => e.date.startsWith(y)); const sideCount = (s: string) => everything.filter((e) => e.side === s).length; --- -
← gate diff --git a/src/sources/ghost.ts b/src/sources/ghost.ts index fa255bd..5367062 100644 --- a/src/sources/ghost.ts +++ b/src/sources/ghost.ts @@ -33,13 +33,23 @@ export function parseGhost(json: unknown): Post[] { return posts.map((p) => postFromGhost(p as GhostPost)); } +const PAGE_SIZE = 100; +const MAX_PAGES = 50; // 5000 posts + export async function fetchGhost(env: Record, f: typeof fetch): Promise { const base = env.GHOST_URL?.replace(/\/+$/, ''); const key = env.GHOST_CONTENT_KEY; if (!base) throw new Error('GHOST_URL is not set'); if (!key) throw new Error('GHOST_CONTENT_KEY is not set'); - const url = `${base}/ghost/api/content/posts/?key=${encodeURIComponent(key)}&include=tags&formats=html&limit=all&filter=status:published`; - const res = await f(url); - if (!res.ok) throw new Error(`Ghost responded ${res.status}`); - return parseGhost(await res.json()); + // Page by page: Ghost 6 caps a request at 100 posts, so `limit=all` could silently cut the list short. + const posts: Post[] = []; + for (let page = 1; page <= MAX_PAGES; page++) { + const url = `${base}/ghost/api/content/posts/?key=${encodeURIComponent(key)}&include=tags&formats=html&limit=${PAGE_SIZE}&page=${page}&filter=status:published`; + const res = await f(url); + if (!res.ok) throw new Error(`Ghost responded ${res.status}`); + const json = (await res.json()) as { meta?: { pagination?: { next?: number | null } } }; + posts.push(...parseGhost(json)); + if (!json?.meta?.pagination?.next) break; + } + return posts; } diff --git a/test/site/build-pages.test.ts b/test/site/build-pages.test.ts index b1d972a..0d0e729 100644 --- a/test/site/build-pages.test.ts +++ b/test/site/build-pages.test.ts @@ -84,6 +84,18 @@ describe('issues, everything and simple', () => { expect(css).not.toMatch(/\[data-ai[^\]]*\][^{]*\{[^}]*display:[^;}]*!important/); }); + test('no font is inlined into a stylesheet', () => { + // Vite inlines small assets as base64; for fonts that puts unused subsets into render-blocking CSS. + const dir = join(out, '_astro'); + for (const f of readdirSync(dir).filter((n) => n.endsWith('.css'))) { + expect(`${f}: ${page(dir, f).includes('url(data:')}`).toBe(`${f}: false`); + } + }); + + test('the 404 page is titled with the site name', () => { + expect(page(out, '404.html')).toContain('Page not found · Example Person'); + }); + test('simple page has no script tags except analytics and preference', () => { const html = page(out, 'simple/index.html'); const scripts = [...html.matchAll(/]*)>([\s\S]*?)<\/script>/g)]; diff --git a/test/sources/sources.test.ts b/test/sources/sources.test.ts index a3c230e..310e872 100644 --- a/test/sources/sources.test.ts +++ b/test/sources/sources.test.ts @@ -51,6 +51,25 @@ function fixtureFetch(o: Opts = {}): typeof fetch { }) as typeof fetch; } +test('ghost posts are fetched page by page until the last page', async () => { + const post = (slug: string) => ({ slug, title: slug, published_at: '2025-01-01T00:00:00.000+00:00', url: `https://blog.example.test/${slug}/` }); + const pages = [ + { posts: [post('one'), post('two')], meta: { pagination: { page: 1, pages: 2, next: 2 } } }, + { posts: [post('three')], meta: { pagination: { page: 2, pages: 2, next: null } } }, + ]; + const asked: string[] = []; + const f = (async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + if (!url.pathname.endsWith('/ghost/api/content/posts/')) return new Response('', { status: 500 }); + asked.push(`${url.searchParams.get('limit')}/${url.searchParams.get('page')}`); + return Response.json(pages[Number(url.searchParams.get('page')) - 1] ?? { posts: [] }); + }) as typeof fetch; + const s = await loadSources({ cacheDir: dir, env, fetch: f }); + expect(s.status.ghost.ok).toBe(true); + expect(s.posts.map((p) => p.slug)).toEqual(['one', 'two', 'three']); + expect(asked).toEqual(['100/1', '100/2']); +}); + test('ghost tags decide side, kind and ai level', async () => { const s = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch() }); expect(s.status.ghost.ok).toBe(true);