Merge branch 'feat/simple-side-index': 2026-11-11 audit, audit fixes, specs 006 and 007 built, spec 008 written
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
commit
5ba0be8c74
59 files changed
+1945
-220
No files matched your search
+2
-1
@@ -22,7 +22,8 @@ ENV APP_VERSION=$APP_VERSION \
|
||||
PORT=8080 \
|
||||
POLL_INTERVAL=1h \
|
||||
SOURCES_INTERVAL=6h \
|
||||
CONTENT_BRANCH=main
|
||||
CONTENT_BRANCH=main \
|
||||
SITE_URL=https://lucaswinther.info
|
||||
|
||||
USER bun
|
||||
VOLUME /data
|
||||
|
||||
@@ -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 } },
|
||||
});
|
||||
@@ -38,6 +38,7 @@ services:
|
||||
CONTENT_REPO: /content
|
||||
POLL_INTERVAL: 30s
|
||||
SOURCES_FIXTURES: /sources
|
||||
SITE_URL: http://localhost:${SITE_PORT:-8080}
|
||||
ports:
|
||||
- "${SITE_PORT:-8080}:8080"
|
||||
volumes:
|
||||
|
||||
@@ -34,7 +34,10 @@ 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}
|
||||
# SITE_URL (canonical URLs, link previews, /sitemap.xml, the feeds) defaults to https://lucaswinther.info in
|
||||
# the image; set it only to serve the site under another address.
|
||||
volumes:
|
||||
- ./data:/data
|
||||
healthcheck:
|
||||
|
||||
@@ -4,7 +4,8 @@ How `portfolio-next` is built today. Update in the same commit as any structural
|
||||
|
||||
## Overview
|
||||
A two-gate personal site (work / off the clock) rendered to static HTML by Astro, with React only for
|
||||
small islands. One container runs `bun src/cli.ts serve`: it polls a separate content repo
|
||||
small islands. The start page loads no React at all: its behaviour and the preference switch are plain page scripts
|
||||
(spec 006). One container runs `bun src/cli.ts serve`: it polls a separate content repo
|
||||
(`portfolio-next-content`), builds the site in the background, swaps the finished build in atomically
|
||||
and serves it. Ghost posts, Trakt and Hardcover are fetched once per build. Behaviour: `docs/specs/001-site.md`;
|
||||
content format: `docs/CONTENT-CONTRACT.md`; deployment: `docs/DEPLOY.md`.
|
||||
@@ -15,7 +16,7 @@ content format: `docs/CONTENT-CONTRACT.md`; deployment: `docs/DEPLOY.md`.
|
||||
| `src/core/` | Pure, dependency-free logic shared by the build and the browser: AI levels and visibility (`ai.ts`), durations, issue numbering (`issues.ts`), `lazyImages` for Markdown bodies (`post-html.ts`), media extensions (`media.ts`), showcase ids (`showcases.ts`) |
|
||||
| `src/library/` | Reads and validates the content repo (`load.ts`, `frontmatter.ts`, `types.ts`, `errors.ts`); `RESERVED_PERSONAL_IDS` guards ids that would shadow a page |
|
||||
| `src/sources/` | Ghost, Trakt and Hardcover clients plus a JSON cache (`cache.ts`); `loadSources` honours `SOURCES_FIXTURES`. Trakt: last film (`history/movies?limit=1` + `ratings/movies`) and recent ratings; no "watching now". ListenBrainz (`LISTENBRAINZ_USER`, no token): the week's top track, counted from the listens |
|
||||
| `src/site/` | The Astro site: `pages/` (gate, `work/`, `off-the-clock/` with `issues/`, `everything`, `simple`, 404), `components/` (Astro parts, React islands, `showcases/`), `layouts/` (`Base`, `Work`, `Personal`), `styles/` (per-space CSS and tokens), `scripts/` (`preference.ts`), `data.ts` (`getSiteData`: content plus cached sources, never fetches), `post-html.ts` (`safePostHtml`, Ghost HTML allowlist on Bun's `HTMLRewriter`; build-time only) |
|
||||
| `src/site/` | The Astro site: `pages/` (gate, `work/`, `off-the-clock/` with `issues/`, `everything`, `simple`, 404, and `[...file].xml.ts` for `/sitemap.xml` and the three Atom feeds), `components/` (Astro parts, React islands, `showcases/`), `layouts/` (`Base`, `Work`, `Personal`), `styles/` (per-space CSS and tokens), `scripts/` (plain page scripts: `preference.ts`, `pref-switch.ts` for the plain-HTML `PreferenceSwitch.astro`, `filter-note.ts`, `gate.ts` for the start page), `data.ts` (`getSiteData`: content plus cached sources and `siteUrl` from `SITE_URL`, never fetches), `link-preview.ts` (`SITE_URL` parsing and each page's canonical/Open Graph/feed head tags, rendered by `Base.astro`), `feeds.ts` (the page list for the sitemap and each feed's entries), `xml.ts` (XML escaping, the Atom and sitemap writers; all three pure and build-only), `post-html.ts` (`safePostHtml`, Ghost HTML allowlist on Bun's `HTMLRewriter`; build-time only) |
|
||||
| `src/runtime/` | `build.ts` (validate, fetch sources once, run Astro, copy media), `refresh.ts` (poll git, timed rebuild, swap `live`), `serve.ts`, `static-server.ts` (`/status`, 404 page), `state.ts`, `git.ts` |
|
||||
| `src/cli.ts` | Entry point: `validate`, `build`, `preview`, `refresh`, `serve` |
|
||||
| `fixtures/` | Synthetic content (`content/`, `sparse/`) and source JSON (`sources/`); the only content in this repo |
|
||||
@@ -40,6 +41,9 @@ Hardcover ──────────┘ │ writes <cache>/sources.
|
||||
last cached data and `/status` names the failing source.
|
||||
- Pages read `<SITE_CACHE_DIR>/sources.json` through `getSiteData`; a missing file means every source
|
||||
reads "not fetched". `SITE_CONTENT_DIR` must be set (the CLI does) or `getSiteData` throws.
|
||||
- `SITE_URL` (the public origin; the image defaults it) reaches the pages as `siteUrl` in site data. Without it the
|
||||
build leaves out everything that needs an absolute URL: canonical, `og:url`, `og:image`, the sitemap, the feeds and
|
||||
the head links to them. An invalid value fails the build.
|
||||
- `CONTENT_REPO_TOKEN` is not passed to the build child; source credentials are.
|
||||
|
||||
## Invariants
|
||||
@@ -51,6 +55,9 @@ Hardcover ──────────┘ │ writes <cache>/sources.
|
||||
an element and attribute allowlist over a real tokenizer; links only http(s)/mailto/tel/relative; media
|
||||
`preload="none"`; iframes and images from hosts other than `GHOST_URL`'s become plain links.
|
||||
- Every page is complete HTML without JavaScript; islands only enhance. Nothing moves until the visitor acts.
|
||||
- The preference: the inline head script in `Base.astro` sets `data-pref` on `<html>` before first paint; the switch's
|
||||
inline `PAINT_SWITCH` presses the stored choice right after its markup; choosing fires `lw:pref` on `window`, which
|
||||
the switch, `FilterNote` and the React `ProjectList` follow.
|
||||
- No invented facts about the owner: unknown stays out or `draft: true`; an unknown AI level is shown as unknown.
|
||||
- The AI filter contract: `data-ai` only on elements whose level is not null; never `display: … !important` on `[data-ai]`.
|
||||
- The interest filter contract: every wall card carries `data-interests` (space-separated ids, primary first; live cards
|
||||
|
||||
@@ -167,6 +167,15 @@ An off-the-clock post whose slug is `issues`, or equals an item's id, is left ou
|
||||
(the page belongs to the issues or the item); change its slug in Ghost. Post HTML is cleaned: scripts and
|
||||
embeds become links, and only images hosted on the Ghost site itself are shown inline.
|
||||
|
||||
## Link previews and feeds
|
||||
|
||||
Nothing new to write: a shared link's preview uses the page's existing title and description. A project's
|
||||
`image` and an item's first image in `media` become the preview picture (`alt` included), unless it is an SVG:
|
||||
link previews need a raster image, and png or jpg is the safest. Other pages show none.
|
||||
The feeds (`/feed.xml`, `/work/feed.xml`, `/off-the-clock/feed.xml`) list projects, posts and items with their
|
||||
title, date and a short summary (a project's `summary`, a post's excerpt, an item's `line`) and the AI level where
|
||||
there is one. Drafts never appear. All of this needs the image's `SITE_URL` (`docs/DEPLOY.md`).
|
||||
|
||||
## What the validator checks
|
||||
|
||||
`contract` is 1 or 2; IDs match `a-z0-9-`; dates are real calendar days as `YYYY-MM-DD`; required fields exist;
|
||||
|
||||
@@ -335,3 +335,61 @@ 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:`.
|
||||
|
||||
## 2026-11-11 — The start page ships no React; one plain preference switch
|
||||
**Status:** accepted
|
||||
**Context:** Spec 006, from the 2026-11-11 audit (finding 3). The start page downloaded the React runtime
|
||||
(`client.*.js`, 213 KB, 66 KB gzip) for two islands that needed none of it: `Gate.tsx` rendered nothing, and
|
||||
`PreferenceSwitch.tsx` was three buttons whose pressed state flickered from "any way" to the stored choice until
|
||||
hydration.
|
||||
**Decision:** The gate's behaviour is a plain page script (`src/site/scripts/gate.ts`, loaded by a processed
|
||||
`<script>` in `index.astro`), with its key handling and readout text as pure, unit-tested functions. The switch is
|
||||
`PreferenceSwitch.astro` on every page that has it (start page footer, work top bar): static markup rendered with
|
||||
"any way" pressed and the 2021 line `hidden`, then an inline script (`PAINT_SWITCH`, a string in `pref-switch.ts`,
|
||||
no imports) that presses the stored choice from the `data-pref` the head script already set, before first paint; a
|
||||
page script handles clicks and follows `lw:pref`. Buttons carry `data-pref-value`. A unit test runs `PAINT_SWITCH`
|
||||
against a fake document and checks it draws what `paintSwitch` draws.
|
||||
**Alternatives:** a plain switch on the gate and the React one on `/work/` (two copies would drift); pressed styles
|
||||
keyed on `html[data-pref]` in CSS (no inline script, but every pressed-state rule written twice and `aria-pressed`
|
||||
still wrong until a script runs); the 2021 line inserted by script rather than present and `hidden` (a bigger inline
|
||||
script).
|
||||
**Consequences:** The 2021 line is in the HTML of every page with the switch, hidden unless "by hand only" (`hidden`
|
||||
keeps it out of view, the tab order and the accessibility tree). The start page's JS is a few small first-party
|
||||
modules. Pages that keep React islands still load the runtime.
|
||||
|
||||
## 2026-11-11 — Link previews, a sitemap and Atom feeds from `SITE_URL`, written by hand
|
||||
**Status:** accepted
|
||||
**Context:** Spec 007, from the 2026-11-11 audit (findings 8 and 9). Shared links showed a bare URL, search engines
|
||||
had no canonical URL or sitemap, and there was no way to follow the site.
|
||||
**Decision:** The public address comes from `SITE_URL`, an http(s) origin; the image sets it by default, and an
|
||||
invalid value (a path, a query, another scheme) fails the build rather than ending up in every preview. Without it,
|
||||
everything that needs an absolute URL is left out and nothing guesses one. The head tags, the Atom feeds and the
|
||||
sitemap are pure functions in `src/site/` (`link-preview.ts`, `feeds.ts`, `xml.ts`), not `src/core`, because the
|
||||
browser never needs them. One endpoint, `pages/[...file].xml.ts`, writes `/sitemap.xml`, `/feed.xml`,
|
||||
`/work/feed.xml` and `/off-the-clock/feed.xml`; its `getStaticPaths` returns nothing without `SITE_URL`, so no file
|
||||
is written. Details the spec left open: `og:title` is the page's whole `<title>`; `og:image:alt` is added when the
|
||||
image has alt text; an SVG is not used as `og:image` (Facebook, LinkedIn and X take raster images only, and a large
|
||||
card with an unusable image is worse than a small one); `twitter:card` is `summary_large_image` exactly when there
|
||||
is an `og:image`. A feed's `<id>` is
|
||||
its own URL, its title the title of the page it follows (`site.name`, `Work · …`, `Off the clock · …`), its one
|
||||
`<author>` the site name. An entry's summary is the project summary, the post excerpt or the item's `line`, left out
|
||||
when empty; its category is the AI level (`unknown` included, as the site shows it) with the site's label. Ties on a
|
||||
date are ordered by path. An empty feed's `updated` is `1970-01-01T00:00:00Z`, so it stays stable. No `<lastmod>`
|
||||
in the sitemap: no content date says when a page last changed.
|
||||
**Alternatives:** `@astrojs/sitemap` and `@astrojs/rss` (new dependencies for a few lines each; RSS rather than
|
||||
Atom); the request's host as the address (guesses, and wrong behind the proxy); an endpoint per file (four files
|
||||
that only differ in one line); stripping the ` · site name` suffix for `og:title` (new wording).
|
||||
**Consequences:** A page's `<body>` does not change. Feeds are served as `application/xml` by the static server
|
||||
(Bun's type for `.xml`), which feed readers accept. A new page type must be added to `sitePaths` in `feeds.ts`; a
|
||||
build test compares the sitemap with the files Astro wrote, so a missing one fails.
|
||||
@@ -45,10 +45,19 @@ both stacks to `site.yml` and the payload table in `server/README.md` like the o
|
||||
| `HARDCOVER_TOKEN` | secret `hardcover_token` |
|
||||
| `LISTENBRAINZ_USER` | `stereotypicalcat` (not a secret; without it the Listening cell is left out) |
|
||||
| `VIRTUAL_HOST`, `LETSENCRYPT_HOST` | `lucaswinther.info` |
|
||||
| `SITE_URL` | `https://lucaswinther.info`, the image's default: nothing to set. The public origin (no path) for canonical URLs, link previews, `/sitemap.xml` and the feeds. Set it if the site moves; unset (empty) leaves all of those out. An invalid value fails the build (shown in `/status`). |
|
||||
|
||||
`SOURCES_FIXTURES=<dir>` (used by the smoke test and `compose.local.yml`) reads the fixture JSON in
|
||||
`fixtures/sources` instead of calling the services. Do not set it in production.
|
||||
|
||||
### Sitemap and feeds
|
||||
|
||||
With `SITE_URL` set, each build writes `/sitemap.xml` and three Atom feeds: `/feed.xml` (everything),
|
||||
`/work/feed.xml` and `/off-the-clock/feed.xml`; every page links its feed in the head. There is no `robots.txt` in
|
||||
the build because Cloudflare serves a managed one. Tell search engines about the sitemap once: submit
|
||||
`https://lucaswinther.info/sitemap.xml` in Google Search Console (and Bing Webmaster Tools), or add the line
|
||||
`Sitemap: https://lucaswinther.info/sitemap.xml` to the robots.txt in Cloudflare if it allows custom lines.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Bot user and token**: a Gitea user that is a read-only collaborator on `portfolio-next-content`
|
||||
|
||||
+97
-35
@@ -1,44 +1,106 @@
|
||||
# Handoff — 2026-11-10 (after spec 002)
|
||||
# Handoff — 2026-11-11 (after the audit session)
|
||||
|
||||
Where development stopped, and how to pick it up. Delete or rewrite this file when the next session starts.
|
||||
Everything a new agent needs from the 2026-11-11 session is in this file or in a file it points to.
|
||||
|
||||
## State
|
||||
## State in one paragraph
|
||||
|
||||
- **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).
|
||||
- **Specs 003 and 004 are merged into `main` and pushed** (2026-11-10, owner-approved): last film from Trakt and the
|
||||
week's top track from ListenBrainz in the "right now" strip, alternating film posters, 4:3 band photo, song cards
|
||||
without the record. `bun test` 184 pass, Playwright 129 pass / 9 skipped. Production needs
|
||||
`LISTENBRAINZ_USER=stereotypicalcat` (`docs/DEPLOY.md`). The Trakt history/ratings requests haven't run live yet.
|
||||
Content repo `main` is at `2b89137` (Summer Blooms Without You plays from Spotify).
|
||||
- **Spec 002 (items in more than one interest, soundtrack tray, band photo, whole-cover comics) is merged into `main`
|
||||
and pushed** (2026-11-10, owner-approved). `bun test` 168 pass, typecheck clean.
|
||||
- **Content repo** `portfolio-next-content` (`~/portfolio-next-content`) merged into `main` and **pushed** (head `dd325d6`):
|
||||
`contract: 2`, five new items (Dear Evan Hansen, A Whisker Away, Always Never, Summer Blooms Without You,
|
||||
Saltlakridserne: photo only, no link, as the owner said the Facebook URL isn't important; a landscape header photo
|
||||
would crop better than the portrait one).
|
||||
Dear Evan Hansen's tray plays "Part Of Me" (Broadway Cast Recording, Deluxe) on Spotify, the owner's pick. Deploy order: app first, then this content (`docs/DEPLOY.md`).
|
||||
- Spec 001 and its follow-ups are merged into `main` and pushed; not deployed (owner wires homelab, OpenBao, DNS).
|
||||
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 the whole app was audited (`docs/audits/2026-11-11-audit.md`, 24 findings, a
|
||||
status on each row); a stack of six branches fixed nine of them and added two built specs and one unbuilt spec. The
|
||||
owner approved the merge and the push the same day: **everything below is on `main` and on Gitea.** The audit's status
|
||||
column names the branch each fix was made on; those branches are merged.
|
||||
|
||||
## Next steps
|
||||
## What was merged on 2026-11-11 (one stack, each branch built on the one above)
|
||||
|
||||
1. Owner works through `docs/TODO.md` → "Before go-live (owner)": real project dates, years, `intro` and
|
||||
`personal_intro`, AU projects' AI levels and un-drafting, comic images, Junkwraith quote check, CV PDF,
|
||||
Ghost/Trakt/Hardcover keys (and a public Trakt profile), DNS cutover, the blog-redirect decision.
|
||||
2. First live build with real keys: check `/status` → `sources`. The Ghost, Trakt and Hardcover modules
|
||||
(`src/sources/`) have never run against the live APIs. `HARDCOVER_QUERY` is the most likely to need fixing.
|
||||
3. Agent-doable backlog: `docs/TODO.md` → "Next" and "Review follow-ups".
|
||||
| # | Branch | What it holds | Verified |
|
||||
|---|---|---|---|
|
||||
| 1 | `chore/audit-2026-11-11` | The audit report; a pointer in `docs/TODO.md` | docs only |
|
||||
| 2 | `fix/audit-quick-wins` | Findings 2 (no fonts inlined into CSS), 6 (Ghost fetched page by page), 7, 18, 19, 21 | typecheck, `bun test` 189 |
|
||||
| 3 | `chore/specs-006-007` | Specs 006 and 007 | docs only |
|
||||
| 4 | `feat/start-page-without-react` | Spec 006: the start page ships no React; one plain preference switch on every page (finding 3) | typecheck, `bun test` 201, Playwright 133 passed / 9 skipped |
|
||||
| 5 | `feat/link-previews-sitemap-feed` | Spec 007: canonical + Open Graph tags, `/sitemap.xml`, three Atom feeds, `SITE_URL` (findings 8, 9) | typecheck, `bun test` 236, Playwright 133 / 9 |
|
||||
| 6 | `feat/simple-side-index` | Spec 008 and its plan (**not built**), the `/simple` mockups, this handoff | docs and mockups only |
|
||||
|
||||
Specs 006 and 007 were implemented by Opus 5.5 subagents from the specs; the main session re-ran typecheck and
|
||||
`bun test` on each branch and checked the built output. The Playwright numbers are the subagents' runs.
|
||||
If Playwright shows "Page crashed", the machine is short of memory: rerun with `--workers=2`.
|
||||
|
||||
Before the merge, on the tip: typecheck clean, `bun test` 236 pass (after one unexplained hang of a fixture build under
|
||||
heavy machine load, see `docs/TODO.md` → Tests), Playwright 133 passed / 9 skipped, and the Docker image built and
|
||||
passed `scripts/smoke-test.sh` locally.
|
||||
|
||||
**Not verified anywhere:** how LinkedIn and Bluesky render the previews; the cassette's play
|
||||
button (no fixture or real song has an audio file; it was checked by hand in a browser).
|
||||
|
||||
## After the merge
|
||||
|
||||
- The plans for 006 and 007 were removed before the merge; `docs/plans/008-simple-side-index.md` stays (not built).
|
||||
- CI builds and publishes the image from `main`; production picks up a new image through the homelab config
|
||||
(`~/lucwin-config/config`, the owner's; off limits to agents without asking). Check the live `/status`
|
||||
(`runningAppVersion`) to see which commit runs, and `/sitemap.xml` and `/feed.xml` once it is the new one.
|
||||
- Merges are local, no pull requests, never force-push. The owner approves each merge and each push himself; an
|
||||
approval for one does not cover the next.
|
||||
|
||||
## Waiting for the owner
|
||||
|
||||
- **Finding 5, `color-scheme: dark` on the work theme.** A one-line change, kept out of the branches because he looks
|
||||
at visual changes before they are committed. It is in `git stash` (`stash@{0}`, "audit #5 …") and is exactly this, in
|
||||
`src/site/styles/work.css` line 2: `[data-theme='work'] { scroll-padding-top: 72px; color-scheme: dark; }`.
|
||||
Headless Chrome does not draw scrollbars, so the screenshots (`~/work-color-scheme-before.png`, `-after.png`) only
|
||||
show that the page content is unchanged; he has to look at `/work/` in a real browser (scrollbar, search field).
|
||||
- **A changed Playwright check from spec 006.** The "The 2021 site was all by hand →" line is now always in the HTML but
|
||||
`hidden`; the test in `e2e/preference.spec.ts` that asserted the link was absent now asserts it is not visible and not
|
||||
in the accessibility tree. Equivalent for visitors, but beyond what the spec allowed ("adapted only where they wait
|
||||
for an island"), so he was asked to confirm. No answer yet.
|
||||
- **Spec 007 extras beyond the spec:** an SVG is never used as `og:image` (social sites take raster images only),
|
||||
`og:image:alt` is written when there is alt text, a malformed `SITE_URL` fails the build. Reported to him; no objection, no explicit yes.
|
||||
- **Spec 008 calls** (in the spec, marked [call]): no `contract` bump for the optional `pins` block although
|
||||
`docs/CONTENT-CONTRACT.md` asks for one on a format change; off-the-clock pins are items only; project rows show the AI
|
||||
level instead of a date; the AI preference hides rows on `/simple` too. He answered "sounds good" to the summary.
|
||||
- **Owner-only audit findings:** 1 (`www.lucaswinther.info` returns a Cloudflare 525) and 4 (Cloudflare raises `/media/`
|
||||
browser caching to 20 h; fix in Cloudflare or by hashing media URLs in the app).
|
||||
|
||||
## Spec 008 — `/simple` side index with pins: approved, on hold
|
||||
|
||||
- The owner chose mockup **A** and asked for pins; then said: "Write the spec and commit it, but dont complete it."
|
||||
A build that had just started was stopped; only the plan was committed (`docs/plans/008-simple-side-index.md`,
|
||||
nothing ticked, no code). **Do not start it until he says so.**
|
||||
- Design truth: `mockups/simple-side-a.html` (a fragment, see its header comment) and
|
||||
`mockups/shots/simple-side-a-1440.png`, `-390.png`. The mockup has no project rows; the spec adds up to three under
|
||||
"work". Its label colour `#a8927f` on the oxblood `#3a1d1a` has not been contrast-checked.
|
||||
- When it is built: he looks at the rendered page before the page and its CSS are committed (the plan says which parts
|
||||
may be committed first). Preview with a temporary copy of the real content that has the two pins
|
||||
(`gacha-event-tracker`, `saltlakridserne`); the real content repo gets its `pins` block from the owner afterwards.
|
||||
|
||||
## Open audit findings (see the audit file for evidence and fixes)
|
||||
|
||||
Open and agent-doable: 10 (print styles for `/simple`, visual), 11 (CI check in the content repo), 12 (publish on push;
|
||||
touches deployment, ask first), 13 (hover lag hypothesis, to try in the hover lab), 14 (prerender on hover),
|
||||
15 (`theme-color`), 16 (refresh race: needs a test seam in `src/runtime/refresh.ts`, none was found without one),
|
||||
17, 20, 22, 23. Waiting or owner-only: 1, 4, 5, 24.
|
||||
|
||||
## How the owner works (learned or confirmed this session)
|
||||
|
||||
- Once he approves a design, carry it through spec → plan → execution without review stops and report the decisions
|
||||
at the end. **Exception he set himself:** visual changes are rendered and shown before they are committed.
|
||||
- On 2026-11-11 he said: "Once specs are written, use Opus 5.5 to complete them." The main session wrote the specs and
|
||||
the briefs, Opus 5.5 subagents built them (one at a time, stacked branches, because they share `Base.astro`), and the
|
||||
main session checked each result before starting the next.
|
||||
- He judges by eye and reacts best to real rendered mockups on a build of the real content. He reaches this machine
|
||||
over WireGuard at `10.20.20.2`; previews are served with `bun src/cli.ts preview <dir> 4392` and screenshots go in `~/`.
|
||||
- Never invent facts about him. The 13 projects' dates are placeholders (2021-01-07…19), so nothing should present a
|
||||
"latest project" by date as fact.
|
||||
- The real content is `~/portfolio-next-content` (separate repo, head `2b89137`); agents do not change it unasked.
|
||||
Build it locally with `SOURCES_FIXTURES=fixtures/sources bun src/cli.ts build ~/portfolio-next-content <out> <cache>`.
|
||||
|
||||
## Where things are
|
||||
|
||||
- Decisions and why: `docs/DECISIONS.md`. Map and invariants: `docs/ARCHITECTURE.md`. Agent rules and commands: `AGENTS.md`. Content format: `docs/CONTENT-CONTRACT.md`. Taste log and every mockup verdict: `docs/inspiration.md`.
|
||||
- Chosen mockups (visual truth): `mockups/nav-d.html`, `mockups/tech-r3-c.html`, `mockups/personal-r2-b.html`. The sister repo `~/commission-compass` is the pattern for runtime, CI and deploy.
|
||||
- Local scratch, git-ignored, safe to delete: `.superpowers/` (brainstorm mockup sessions and the finished plan's ledger `sdd/001-site/`; the repo's permission rules block agents from `rm -r`, so delete it by hand). Same for old build output in `.tmp/`.
|
||||
|
||||
## Working with the owner
|
||||
|
||||
- Once he approves a design, carry it through spec → plan → execution without review stops, and report decisions at the end.
|
||||
- He reacts best to real mockups; taste lines: calm until touched, no loading screens or game feel, nothing selling, no AI-default look. Not too sleek: he picked `tech-r3-c` over the polished round 4.
|
||||
- Never invent facts about him; unknowns stay out or go in as drafts.
|
||||
- He reaches this machine over WireGuard at `10.20.20.2` (the visual companion was served there).
|
||||
- Decisions and why: `docs/DECISIONS.md`. Map and invariants: `docs/ARCHITECTURE.md`. Rules and commands: `AGENTS.md`.
|
||||
Content format: `docs/CONTENT-CONTRACT.md`. Deploy: `docs/DEPLOY.md`. Taste log and every mockup verdict:
|
||||
`docs/inspiration.md`. Backlog: `docs/TODO.md` (its "Before go-live (owner)" list is still his).
|
||||
- Chosen mockups (visual truth): `mockups/nav-d.html`, `mockups/tech-r3-c.html`, `mockups/personal-r2-b.html`; for
|
||||
spec 008 also `mockups/simple-side-a.html`. The sister repo `~/commission-compass` is the pattern for runtime, CI and deploy.
|
||||
- Local scratch, git-ignored, safe to delete: `.superpowers/`, `.tmp/` (including the subagents' `spec006-*` and
|
||||
`spec007-*` builds). Screenshots from this session are in `~/` (`spec006-*.png`, `simple-mock-*.png`,
|
||||
`work-color-scheme-*.png`); the ones that matter are also in `mockups/shots/`.
|
||||
+7
-3
@@ -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/<slug>/`) to `/work/writing/<slug>/` (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.
|
||||
@@ -20,6 +20,7 @@ Small, specific, independently actionable. Remove only when actually done.
|
||||
- [ ] Reword the 2021 `portfolio` project summary if wanted ("The 2021 version of this portfolio…", derived from its `info.json`).
|
||||
|
||||
## Next
|
||||
- [ ] Work through `docs/audits/2026-11-11-audit.md` (24 findings from the 2026-11-11 audit; each row carries its own status).
|
||||
- [ ] Start page: the owner feels the personal half lag on hover (2026-11-10). Findings so far: his browser gets ~70–80 fps
|
||||
on a 144 Hz monitor even on a blank page (testufo: 70; Firefox `layout.frame_rate` is -1), so the cap is his machine,
|
||||
possibly load from other programs; recheck with nothing else running. The grain's live filter was a real cost and is
|
||||
@@ -27,8 +28,10 @@ Small, specific, independently actionable. Remove only when actually done.
|
||||
frame; a transform/clip-path lean would run on the compositor. Hover lab (local only): `.tmp/real-site/hover-lab.html`
|
||||
switches off each effect and shows fps; baseline `.tmp/real-site/fps.html`.
|
||||
- [ ] The strip's Listening cell replaced the newest pinned song (owner: "for now, then we can look at alternatives later"). The pinned song's note ("why ↘") now has no spot in the strip; look at alternatives.
|
||||
- [ ] Revisit the start page's "or wander through everything at once" pill (and its G shortcut). Hidden for now on the owner's request: `SHOW_EVERYTHING_PILL` in `src/site/pages/index.astro`. Its CSS, the seam positioning in `Gate.tsx` and the seam check in `e2e/gate.spec.ts` are kept; `/everything/` is still reachable from `/simple`.
|
||||
- [ ] Revisit the start page's "or wander through everything at once" pill (and its G shortcut). Hidden for now on the owner's request: `SHOW_EVERYTHING_PILL` in `src/site/pages/index.astro`. Its CSS, the seam positioning in `src/site/scripts/gate.ts` and the seam check in `e2e/gate.spec.ts` are kept; `/everything/` is still reachable from `/simple`.
|
||||
- [ ] Personal wall: a chip filter is not reflected in the URL, so a filtered view can't be shared.
|
||||
- [ ] Link previews: a site-wide preview image for pages without a picture of their own (the start page, `/work/`, posts, the wall, issues). A design call for the owner (spec 007 left it out); until then those pages share a plain `summary` card with no image.
|
||||
- [ ] Feeds: no visible link to `/feed.xml`, `/work/feed.xml` or `/off-the-clock/feed.xml` yet, only the `rel="alternate"` links in each head (spec 007). Where a visible one would go, if anywhere, is the owner's design call.
|
||||
- [ ] Film soundtrack, alternative design: mockup B (the soundtrack as a record tucked behind the poster, slides out on hover; `mockups/shots/spec-002-r1-cards.png`). The owner kept it as an idea for later or as an alternative to the tray.
|
||||
- [ ] Single-cover comic, alternative design: mockup G (ink-framed cover beside the text). J (cover leaning on the paper with the one-liner) "could be interesting but doesn't work yet". I (cover plus two panels) only with real, different page images — today's panel strip already does that when an item has three images.
|
||||
- [ ] Media images ship as-is: the build copies `media/` verbatim and cards use plain `<img>` (no `astro:assets`). Resize/convert at build (e.g. `sharp`, a new runtime dependency: ask the owner) or keep resizing by hand in the content repo.
|
||||
@@ -58,8 +61,9 @@ Site (work)
|
||||
- [ ] Filter boxes ignore `data-show`; "Latest writing" considers only the newest 3; `Showcase.astro` branches over the showcase ids by hand (Astro needs static island imports).
|
||||
Site (personal, issues, everything)
|
||||
- [ ] Personal wall: year dividers ignore the chip filter and the AI filter; dense-grid order differs from DOM order; own-audio path is untested (no audio fixture); a chip-filtered summary does not follow "show all".
|
||||
- [ ] `PreferenceSwitch`: the storage key is duplicated in the inline pre-paint script (on purpose, no imports there); the SSR pressed state flickers until hydration.
|
||||
- [ ] Preference: the storage key is duplicated in the inline pre-paint script in `Base.astro`, and `PAINT_SWITCH` in `scripts/pref-switch.ts` mirrors `paintSwitch` (both on purpose, no imports there; a unit test keeps the painters in step). The pressed-state flicker until hydration is gone (spec 006: the switch is plain HTML painted before first paint).
|
||||
Tests
|
||||
- [ ] `test/site/fixture-sites.ts`: the fixture build child has no timeout. On 2026-11-11, with the machine at load ~15, one `bun test` run hung in `bun src/cli.ts build fixtures/sparse` (idle, 5 minutes) until the 300 s `beforeAll` limit failed the run; three reruns of the build and two of the suite passed in 12 s. Not reproduced, cause unknown.
|
||||
- [ ] e2e: helpers hard-code `localhost:4378`; the third-party check is not re-run after the play click; "nothing animates" uses a `getAnimations` snapshot; the no-JS featured count is hard-coded to 2; sparse specs lack positive controls; the YouTube exemption matches by substring; `bun test` can contend with an overlapping Playwright server.
|
||||
- [ ] Missing automated tests: sheet history, keys and focus; filter toggling on `/everything/`; dead-anchor and live-region regressions; a date-independent issue test; the CLI `build` path; the CSS filter contract and pre-paint script.
|
||||
- [ ] Gemini allow `run_shell_command(bun test)` may not match argument forms (cosmetic).
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Audit — 2026-11-11
|
||||
|
||||
A read of the whole app (`src/`, `deploy/`, CI, docs), a build of the real content repo with fixture
|
||||
sources, and a look at the live site (`https://lucaswinther.info`, behind Cloudflare). Only findings that
|
||||
were **not** already in `docs/TODO.md` are listed. Baseline on `main` at `0462ab8`: typecheck clean,
|
||||
`bun test` 186 pass; Playwright was not run.
|
||||
|
||||
Status column: **open**, **fixed** (with the branch), or **owner** (not fixable in this repo).
|
||||
Update the status here when a finding is dealt with; delete the file once every row is fixed or moved to `docs/TODO.md`.
|
||||
|
||||
## How things were measured
|
||||
|
||||
- Build: `SOURCES_FIXTURES=fixtures/sources bun src/cli.ts build ~/portfolio-next-content <out> <cache>`; sizes with `wc -c` and `gzip -9`.
|
||||
- Origin headers: `bun src/cli.ts preview <out>` and `curl -I`.
|
||||
- Live: `curl -I` against `lucaswinther.info` and `www.lucaswinther.info`, and `GET /status`.
|
||||
|
||||
## Broken or wrong today
|
||||
|
||||
| # | 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`. | 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. | fixed (`feat/start-page-without-react`, spec 006) |
|
||||
| 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=<hash>` 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`. | 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
|
||||
|
||||
| # | Finding | Fix | Status |
|
||||
|---|---|---|---|
|
||||
| 8 | Shared links have no preview: the head has a title and description only. No Open Graph tags, no canonical URL, no sitemap, and Astro has no `site` set. | `site` in the Astro config, canonical + `og:*` in `Base.astro`, a sitemap. An image per page is a design question. Needs a spec. | fixed (`feat/link-previews-sitemap-feed`, spec 007) |
|
||||
| 9 | No feed. At 1–3 new items a month there is no way to follow the wall or the writing. | An Atom feed (wall + writing), linked with `rel="alternate"`; nothing visible changes. Needs a spec. | fixed (`feat/link-previews-sitemap-feed`, spec 007) |
|
||||
| 10 | `/simple` has no print styles, though it is the page someone would print or save as a PDF. | `@media print` in `simple.css`. Visual: owner looks. | open |
|
||||
| 11 | The content repo has no check. A broken commit never goes live and the only signal is `/status`. | A CI job in `portfolio-next-content` that runs the published image's `site validate`. | open |
|
||||
| 12 | A content push waits up to `POLL_INTERVAL` (1 h) unless someone runs `site refresh` on the host. | A Gitea webhook to a secret-guarded refresh endpoint, or a content-repo CI step. Touches deployment: ask first. | open |
|
||||
|
||||
## Ideas
|
||||
|
||||
| # | Idea | Notes | Status |
|
||||
|---|---|---|---|
|
||||
| 13 | Hover lag on the start page (hypothesis, **not tested**): the grain layer (`.art::before`), the work side's fade (`.tech::after`) and its grid are all sized to their half, so every frame of the `flex-grow` lean re-rasterises them. | Give those layers a fixed width (about 60vw, clipped by the half's `overflow: hidden`) so their size is constant during the lean; try it in the hover lab before any transform rewrite. Also: the crosshair writes `top`/`left` and `innerHTML` on every `mousemove`; a transform is cheaper. | open |
|
||||
| 14 | Prerender on hover: a speculation rule that loads `/work/` or `/off-the-clock/` while the visitor leans toward a half, so entering is instant in Chromium browsers. | Fires only after the visitor acts; first-party only. Firefox ignores it. | open |
|
||||
| 15 | Per-space `theme-color`, so phone browser chrome tints warm dark on work and cream off the clock. | One `<meta>` per theme in `Base.astro`. | open |
|
||||
|
||||
## Small things
|
||||
|
||||
| # | Finding | Status |
|
||||
|---|---|---|
|
||||
| 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/<uuid>` 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. | 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. | 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 |
|
||||
|
||||
## Checked and fine
|
||||
|
||||
- Path handling in `static-server.ts` (traversal, symlinks, `//host` redirects) and the Ghost HTML allowlist in `src/site/post-html.ts`.
|
||||
- `CONTENT_REPO_TOKEN` stays out of argv, URLs, logs and the build child.
|
||||
- A stale source is shown honestly ("synced N d ago"); a broken content commit freezing source refreshes is already documented in `docs/DEPLOY.md`.
|
||||
- Compression: Cloudflare serves Brotli, so the origin's lack of it does not reach visitors.
|
||||
@@ -210,6 +210,16 @@ he moved and cropped the cover himself; using the cover's own lettering was trie
|
||||
became a stack: the polaroid tilted further (7°) over the cassette, the life note tucked underneath the polaroid
|
||||
("fine for now", though its LIFE label is partly covered); hovering any scrap brings it to the front.
|
||||
|
||||
## `/simple` side panel (2026-11-11)
|
||||
|
||||
The owner asked for the left side of `/simple` to be more useful: it only had the two links and the (currently unused)
|
||||
"everything at once". Two rendered variants on a build of the real content: **A** each way in shows what is behind it
|
||||
(counts with the by-hand / AI split, newest writing, newest on the wall, reading / listening / last film) and **B** a
|
||||
dated "Lately" list across both sides (`mockups/simple-side-a.html`, `simple-side-b.html`, shots in `mockups/shots/`).
|
||||
**Picked: A** ("Simple-a works best"). He added: "Would be nice to be able to pin some stuff that should show … e.g. the
|
||||
gacha-event-tracker for work and Saltlakridserne for off-the-clock, and then have the app fill in the rest by newest."
|
||||
That is `docs/specs/008-simple-side-index.md` (approved, not built yet on his request).
|
||||
|
||||
## Decisions so far (summary, 2026-11-09)
|
||||
|
||||
- **Start page:** `mockups/nav-d.html`, with two gates (work on the left, off the clock on the right) and a small "wander through everything at once" link to the garden (`nav-c.html` idea).
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
status: approved
|
||||
---
|
||||
|
||||
# Plan 008 — `/simple` side index with pins
|
||||
|
||||
Spec: `docs/specs/008-simple-side-index.md`. Mockup: `mockups/simple-side-a.html`,
|
||||
`mockups/shots/simple-side-a-1440.png`, `mockups/shots/simple-side-a-390.png`.
|
||||
**On hold:** the owner asked on 2026-11-11 for the spec to be committed but not built yet. Start only when he says so.
|
||||
Commands: `bun test`, `bun run typecheck`, `bunx playwright test`.
|
||||
|
||||
## 1. Pins in `site.yml` (committed)
|
||||
- [ ] `test/library/load.test.ts`: pins load (order kept); no `pins` → none; errors for a non-list, a non-id
|
||||
entry, an unknown key, an unknown id, a draft's id, a duplicate, a project id under `off_the_clock`. Watch fail.
|
||||
- [ ] `src/library/types.ts`: `Site.pins?: { work: string[]; offTheClock: string[] }`.
|
||||
- [ ] `src/library/load.ts`: shape check in `loadSite`; id check after projects and items load. Watch pass.
|
||||
- [ ] `fixtures/content/site.yml`: pins (a project that is not newest, an item that is not newest); loader test asserts them.
|
||||
- [ ] `docs/CONTENT-CONTRACT.md`: the `pins` block. Commit `feat(library): pins in site.yml`.
|
||||
|
||||
## 2. Pin-then-newest selection (committed)
|
||||
- [ ] `test/core/pins.test.ts`: order, overflow (first pins win), no duplicates, no pins, unknown pins skipped,
|
||||
newest by date regardless of input order. Watch fail.
|
||||
- [ ] `src/core/pins.ts`: `pinThenNewest(list, pins, n)`. Pure. Watch pass. Commit `feat(core): pin-then-newest selection`.
|
||||
|
||||
## 3. The side panel (NOT committed: owner looks first)
|
||||
- [ ] `test/site/build-pages.test.ts`: counts line, pinned project first, pinned item first, latest writing,
|
||||
right-now rows, `data-ai` only where the level is not null, sparse fixture has no empty labels/blocks,
|
||||
still two scripts. Watch fail.
|
||||
- [ ] `src/site/pages/simple.astro`, `src/site/styles/simple.css`: build the panel as in the mockup. Watch pass.
|
||||
- [ ] Contrast of new text colours on `#3a1d1a` ≥ 4.5:1.
|
||||
- [ ] typecheck, `bun test`, `bunx playwright test`.
|
||||
- [ ] Screenshots: real content with pins (1440, 390), sparse fixture once.
|
||||
- [ ] Docs (uncommitted): `DESIGN.md` `/simple` section, `docs/DECISIONS.md` entry, spec status.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
status: implemented
|
||||
---
|
||||
|
||||
# 006 — The start page ships no React; one preference switch for every page
|
||||
|
||||
From the 2026-11-11 audit (`docs/audits/2026-11-11-audit.md`, finding 3). The owner approved writing and building
|
||||
this on 2026-11-11 without a further review stop. Choices marked **[call]** were made while writing the spec, not by
|
||||
the owner. Extends `docs/specs/001-site.md` (start page, AI preference).
|
||||
|
||||
## Why
|
||||
The start page is the most visited page and downloads the React runtime (`client.*.js`, 213 KB, 66 KB gzip) for two
|
||||
islands that need none of it: `Gate.tsx` renders nothing and only attaches listeners, and `PreferenceSwitch.tsx` is
|
||||
three buttons. `/simple`-like weight is the goal for the front door; React stays where it earns its place (project
|
||||
list and sheet, chips, players, showcases, `/everything/` filters).
|
||||
|
||||
## What
|
||||
- The start page (`/`) loads no React: no `astro-island`, no request for the React runtime or renderer. Its
|
||||
behaviour comes from plain page scripts.
|
||||
- Everything the gate does today still works, unchanged for the visitor: ←/→ lean a side, Enter goes in, Esc lets
|
||||
go; the crosshair and readout on the work half under `(hover: hover)`; the cassette's play button (hidden until
|
||||
the script runs, play/pause label and state, reels spin only while playing); the hidden "everything at once" pill
|
||||
keeps working if it is switched back on (`SHOW_EVERYTHING_PILL`, its G key and seam position).
|
||||
- The preference switch ("projects made: any way / not fully by AI / by hand only") becomes one plain-HTML-plus-script
|
||||
implementation used on every page that has it (start page footer, work top bar). **[call]** One implementation
|
||||
rather than a plain one for the gate and a React one for work: two copies of the same control would drift.
|
||||
- Same markup contract as today: `.pref-switch` with `role="group"` and `aria-label="Projects made"`, the label,
|
||||
three `button`s with `aria-pressed`, and the "The 2021 site was all by hand →" line shown only for "by hand only".
|
||||
- Same behaviour: choosing stores the preference (`lw:projects-made`), sets `data-pref` on `<html>`, and fires
|
||||
`lw:pref` with the value; the switch follows `lw:pref` fired by anything else. Storage being blocked never breaks
|
||||
a click.
|
||||
- Hidden without JavaScript, as today.
|
||||
- The pressed button looks pressed from the first paint, with no flicker from "any way" to the stored choice
|
||||
(today's React switch flickers until hydration; `docs/TODO.md`). `aria-pressed` may be set by the script.
|
||||
- Nothing else about the pages changes: same HTML structure, classes and styles, so the pages look the same.
|
||||
|
||||
## Not in this spec
|
||||
- The hover lag (audit 13), prerender on hover (14) and any visual change to the gate.
|
||||
- Removing React from other pages.
|
||||
|
||||
## Acceptance
|
||||
- Build test: the fixture start page contains no `astro-island` and references no file of the React runtime; the
|
||||
work page still hydrates its islands.
|
||||
- Build test: every `.pref-switch` in the built pages has the markup contract above.
|
||||
- Unit tests for any new pure logic.
|
||||
- Playwright: the existing gate, preference, a11y and no-JS specs pass, adapted only where they wait for an island
|
||||
that no longer exists (never weakened in what they check); a new check that a stored preference shows as pressed on
|
||||
the start page and on `/work/`.
|
||||
- `bun test`, typecheck and Playwright pass. The start page and `/work/` are screenshotted at 1440 and 390 wide before
|
||||
and after and compared; any difference is reported to the owner before merge.
|
||||
- `docs/ARCHITECTURE.md`, `docs/DECISIONS.md` and `docs/TODO.md` (the flicker item) updated in the same commits.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
status: implemented
|
||||
---
|
||||
|
||||
# 007 — Link previews, a sitemap and feeds
|
||||
|
||||
From the 2026-11-11 audit (`docs/audits/2026-11-11-audit.md`, findings 8 and 9). The owner approved writing and
|
||||
building this on 2026-11-11 without a further review stop. Choices marked **[call]** were made while writing the
|
||||
spec, not by the owner; they are the things to check first.
|
||||
|
||||
## Why
|
||||
A link to the site shared on LinkedIn, Bluesky or in a chat shows a bare URL: the pages carry a title and a
|
||||
description and nothing else. Search engines get no canonical URL and no sitemap. And with 1–3 new things a month
|
||||
there is no way to follow the site. None of this may change how a page looks.
|
||||
|
||||
## What
|
||||
|
||||
### The site's address
|
||||
- The public address comes from `SITE_URL` (e.g. `https://lucaswinther.info`). **[call]** The image sets it as a
|
||||
default, like its other settings, so the homelab config needs no change; `docs/DEPLOY.md` documents it.
|
||||
- Without `SITE_URL` (local builds, tests that don't set it) the build still succeeds and everything that needs an
|
||||
absolute URL is left out: canonical, `og:url`, `og:image`, the sitemap and the feeds. Nothing guesses an address.
|
||||
- Fixtures and tests use a synthetic address (`https://example.invalid`), never the real one.
|
||||
|
||||
### Link previews (every page)
|
||||
- `<link rel="canonical">` with the page's absolute URL (trailing slash, as served).
|
||||
- Open Graph: `og:title`, `og:description`, `og:url`, `og:site_name` (the site name), `og:type` (`article` for a
|
||||
project, a post and a personal item's own page; `website` otherwise), and `twitter:card`.
|
||||
- `og:image` only where the page already has a picture of its own from the content repo: a project's `image`, a
|
||||
personal item's first image. **[call]** No generated or site-wide preview image: that is a design decision for the
|
||||
owner (noted in `docs/TODO.md`). With an image the card is `summary_large_image`, otherwise `summary`.
|
||||
- Titles and descriptions are the ones the pages already have. No new copy is written; nothing is invented.
|
||||
- The 404 page gets none of this and is marked `noindex`.
|
||||
|
||||
### Sitemap
|
||||
- `/sitemap.xml` lists every built page except the 404, with absolute URLs. **[call]** Written by hand as a page
|
||||
endpoint; no new dependency.
|
||||
- No `robots.txt` is added: Cloudflare serves a managed one in production. **[call]** `docs/DEPLOY.md` gets a line
|
||||
on submitting the sitemap or adding a `Sitemap:` line there.
|
||||
|
||||
### Feeds
|
||||
- Atom feeds, newest first: `/feed.xml` (everything), `/work/feed.xml` (projects and work writing) and
|
||||
`/off-the-clock/feed.xml` (the wall: items and off-the-clock posts). **[call]** Three feeds, because the two
|
||||
audiences are different people.
|
||||
- An entry has the title, the page's absolute URL (also its id), the date, a short summary (the project summary, the
|
||||
post excerpt, the item's line) and, where the thing has an AI level, that level as a category. No full bodies.
|
||||
Drafts and anything the site itself leaves out never appear.
|
||||
- Stable output: the feed's `updated` is its newest entry's date, not the build time, so a rebuild with unchanged
|
||||
content produces the same bytes. Dates are whole days (`T00:00:00Z`).
|
||||
- Each page links its feed in the head (`rel="alternate" type="application/atom+xml"`): work pages the work feed,
|
||||
off-the-clock pages the wall's, the start page, `/everything/` and `/simple` the combined one.
|
||||
- **[call]** No visible feed link is added anywhere; where one would go is the owner's design call (`docs/TODO.md`).
|
||||
- Live cards (Trakt, Hardcover, ListenBrainz) are not feed entries.
|
||||
|
||||
## Not in this spec
|
||||
- A preview image design, JSON-LD, `theme-color` (audit 15), hashed media URLs (audit 4).
|
||||
|
||||
## Acceptance
|
||||
- Build tests on the fixture site built with `SITE_URL=https://example.invalid`: canonical and Open Graph tags on a
|
||||
page of each kind, `og:image` present exactly where the page has its own image, the 404's `noindex`; the sitemap
|
||||
lists exactly the built pages; each feed is well-formed XML with the expected entries, order, ids and categories,
|
||||
and escapes titles safely.
|
||||
- Build test without `SITE_URL`: the build succeeds, with no canonical, sitemap or feeds and no broken links to them.
|
||||
- Feed XML and the head tags are produced by pure, unit-tested functions.
|
||||
- Rendered pages are byte-identical in `<body>` before and after (nothing visible changes).
|
||||
- `bun test`, typecheck and Playwright pass; `docs/ARCHITECTURE.md`, `docs/DECISIONS.md`, `docs/DEPLOY.md`,
|
||||
`docs/CONTENT-CONTRACT.md` (if content authors need to know anything) and `docs/TODO.md` updated in the same commits.
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
status: approved
|
||||
---
|
||||
|
||||
# 008 — `/simple`: the side becomes an index, with pins
|
||||
|
||||
Designed with the owner on 2026-11-11 from two rendered mockups of the side panel; he chose variant A ("each way in
|
||||
shows what's behind it"; mockup `mockups/simple-side-a.html`, screenshots
|
||||
`mockups/shots/simple-side-a-1440.png` and `-390.png`; the rejected variant B is beside them) and asked for pins:
|
||||
"pin some stuff … e.g. the gacha-event-tracker for work and Saltlakridserne for off-the-clock, and then have the app
|
||||
fill in the rest by newest". Extends `docs/specs/001-site.md` §9. Choices marked **[call]** were made while writing
|
||||
the spec, not by the owner.
|
||||
|
||||
**Not built yet.** The owner approved the design and this spec on 2026-11-11 ("sounds good") and asked for it to be
|
||||
written and committed but not completed for now. The plan is `docs/plans/008-simple-side-index.md`, nothing ticked.
|
||||
|
||||
## Why
|
||||
The left side of `/simple` held two links and a lot of empty oxblood. It should say what is behind each link, with
|
||||
real things from the site, and let the owner choose which things lead. The page stays the short, static one:
|
||||
same split, same colours and type, no JavaScript, nothing that moves.
|
||||
|
||||
## What
|
||||
|
||||
### The side panel (top to bottom)
|
||||
- Name and tagline, as today.
|
||||
- **work** (the link and its small line, as today), then under it:
|
||||
- one line of counts: the number of projects and how many are by hand, some AI, made with AI and not recorded, each
|
||||
with the site's AI mark. A level with no projects is left out; no projects, no line.
|
||||
- up to **3 projects**: pinned ones first, then the newest by date. Each row is a link to the project's page with
|
||||
its AI level (mark and label) at the right. **[call]** The AI level rather than a date: the site is about being
|
||||
honest which is which, and project dates are not all real yet.
|
||||
- "latest writing": the newest work post with its date, when there is one.
|
||||
- **off the clock** (link and line as today), then "newest on the wall": up to **3 things**, pinned first, then the
|
||||
newest by date, each a link with its kind and date at the right (as in the mockup: "film · 10 Nov"; the year is
|
||||
added when it is not the current year).
|
||||
- **Right now**: reading (Hardcover), listening (ListenBrainz) and last film (Trakt), the same data as the
|
||||
off-the-clock strip, as plain text rows. A row without data is left out; with none, the block is left out.
|
||||
- Foot: "the start page" and "everything in one list" (the `/everything/` link, quieter and second, as in the mockup).
|
||||
- Rows for the owner's own work carry `data-ai`, so the visitor's "projects made" preference hides them here as it
|
||||
does everywhere else. **[call]** A hidden row is not replaced by another, and the counts line is not filtered.
|
||||
- Phones: the side comes first and keeps its lists, as in the approved phone mockup.
|
||||
|
||||
### Pins (content)
|
||||
- `site.yml` gets an optional block:
|
||||
```yaml
|
||||
pins:
|
||||
work: [gacha-event-tracker] # project ids
|
||||
off_the_clock: [saltlakridserne] # item ids
|
||||
```
|
||||
- Order in the list is the order shown. More pins than places: the first ones win. Fewer: the rest is filled with
|
||||
the newest that are not already pinned. No `pins`: newest only.
|
||||
- Validation, with every problem listed like the rest of the content: each list must be a list of ids; an id must be
|
||||
a published (non-draft) project or item of that side; no id twice.
|
||||
- **[call]** Off-the-clock pins are items only, not Ghost posts: the content check runs without the blog, so a post
|
||||
slug could not be validated. Posts still appear when they are among the newest.
|
||||
- **[call]** No `contract` bump: the block is optional and an older site ignores it harmlessly (the same way
|
||||
`personal_intro` was added). `docs/CONTENT-CONTRACT.md` asks for a bump on a format change, so this is the first
|
||||
thing for the owner to overrule.
|
||||
- The fixtures get pins so the tests cover them; the real content repo is not touched by this work.
|
||||
|
||||
## Not in this spec
|
||||
- Using pins anywhere else (the start page, the wall), print styles for `/simple` (audit 10).
|
||||
|
||||
## Acceptance
|
||||
- Unit tests: the pin-then-newest selection (order, overflow, no duplicates, no pins); validation errors for a bad
|
||||
list, an unknown id, a draft's id, a duplicate, a project id under `off_the_clock`.
|
||||
- Build tests on the fixture site: the side shows the counts, the pinned project first and the pinned item first,
|
||||
the latest writing, the right-now rows from the fixture sources; the sparse fixture (one project, no items, no
|
||||
sources) renders without empty headings or blocks. `/simple` still ships no script except the two it has today.
|
||||
- Contrast of every new text colour on the oxblood is at least 4.5:1.
|
||||
- `bun test`, typecheck and Playwright pass. The page is rendered at 1440 and 390 wide and shown to the owner before
|
||||
the page and its styles are committed.
|
||||
- `docs/CONTENT-CONTRACT.md`, `docs/ARCHITECTURE.md` (if a module changes), `docs/DECISIONS.md`, `DESIGN.md` (the
|
||||
`/simple` page is not described there yet) updated with the code.
|
||||
+4
-3
@@ -1,5 +1,8 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { blockThirdParty, hydrated } from './helpers.ts';
|
||||
import { blockThirdParty } from './helpers.ts';
|
||||
|
||||
// The gate's behaviour is a plain module script (spec 006); module scripts have run by the time page.goto
|
||||
// resolves on `load`, so there is no island to wait for.
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await blockThirdParty(page);
|
||||
@@ -18,7 +21,6 @@ test('the everything pill is hidden for now, and G does nothing', async ({ page,
|
||||
await page.goto('/');
|
||||
await expect(page.locator('#garden-link')).toHaveCount(0);
|
||||
test.skip(!!isMobile, 'keyboard shortcut');
|
||||
await hydrated(page, 'Gate');
|
||||
await page.keyboard.press('g');
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
@@ -27,7 +29,6 @@ test('the everything pill is hidden for now, and G does nothing', async ({ page,
|
||||
test('arrows lean and Enter enters', async ({ page, isMobile }) => {
|
||||
test.skip(!!isMobile, 'keyboard shortcut');
|
||||
await page.goto('/');
|
||||
await hydrated(page, 'Gate');
|
||||
await expect(async () => {
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(page.locator('#gate')).toHaveClass(/lean-art/, { timeout: 500 });
|
||||
|
||||
+3
-2
@@ -55,10 +55,11 @@ export async function hydrated(page: Page, ...names: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
// Open /work/ with the sheet, the list and the preference switch live.
|
||||
// Open /work/ with the sheet, the list and the preference switch live. The switch is a plain module
|
||||
// script (spec 006), which has run by the time goto resolves on `load`.
|
||||
export async function openWork(page: Page) {
|
||||
await page.goto('/work/');
|
||||
await hydrated(page, 'PreferenceSwitch', 'ProjectSheet', 'ProjectList');
|
||||
await hydrated(page, 'ProjectSheet', 'ProjectList');
|
||||
await page.evaluate(() => scrollTo(0, 0));
|
||||
}
|
||||
|
||||
|
||||
+29
-1
@@ -25,7 +25,9 @@ test('featured hidden panels show "show anyway"; archive link appears', async ({
|
||||
await openWork(page);
|
||||
await expect(page.locator('.filtered-note')).toHaveCount(2);
|
||||
await expect(page.locator('.filtered-note').first()).toBeHidden();
|
||||
await expect(page.locator('a[href="https://old.lucaswinther.info/"]')).toHaveCount(0);
|
||||
// The archive line is in the HTML but hidden (spec 006): none of it may show, to the eye or to assistive tech.
|
||||
await expect(page.locator('a[href="https://old.lucaswinther.info/"]:visible')).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: 'The 2021 site was all by hand →' })).toHaveCount(0);
|
||||
await chooseByHand(page);
|
||||
const visibleNotes = page.locator('.filtered-note:visible');
|
||||
await expect(visibleNotes).toHaveCount(1); // only the some-ai featured project
|
||||
@@ -176,3 +178,29 @@ test('start page: the work half says what the filter hides too', async ({ page }
|
||||
await expect(page.locator('#tech .t-row[data-ai="unknown"]')).toBeVisible();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-pref', 'by-hand-only');
|
||||
});
|
||||
|
||||
for (const path of ['/', '/work/']) {
|
||||
test(`a stored preference shows as pressed on ${path} from the first paint`, async ({ page }) => {
|
||||
await setPref(page, 'by-hand-only');
|
||||
// Block the page's own script files: what shows now comes from the HTML and its inline scripts alone,
|
||||
// i.e. what the first paint has (spec 006: no flicker from "any way" to the stored choice).
|
||||
await page.route('**/_astro/*.js', (r) => r.abort());
|
||||
await page.goto(path);
|
||||
const sw = page.locator('.pref-switch').first();
|
||||
await expect(sw.getByRole('button', { name: 'by hand only' })).toHaveAttribute('aria-pressed', 'true');
|
||||
for (const name of ['any way', 'not fully by AI']) {
|
||||
await expect(sw.getByRole('button', { name, exact: true })).toHaveAttribute('aria-pressed', 'false');
|
||||
}
|
||||
await expect(sw.getByRole('link', { name: 'The 2021 site was all by hand →' })).toBeVisible();
|
||||
|
||||
// With its script, the switch works on top of that.
|
||||
await page.unroute('**/_astro/*.js');
|
||||
await page.reload();
|
||||
await sw.getByRole('button', { name: 'any way' }).click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-pref', 'any');
|
||||
await expect(sw.getByRole('button', { name: 'any way' })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(sw.getByRole('button', { name: 'by hand only' })).toHaveAttribute('aria-pressed', 'false');
|
||||
await expect(sw.getByRole('link', { name: 'The 2021 site was all by hand →' })).toBeHidden();
|
||||
expect(await page.evaluate((k) => localStorage.getItem(k), PREF_KEY)).toBe('any');
|
||||
});
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
@@ -0,0 +1,48 @@
|
||||
<!-- /simple side panel, variant A (2026-11-11). CHOSEN by the owner (spec 008).
|
||||
A fragment, not a page: the <aside> replaces .sm-side in a BUILT /simple/index.html and the <style> goes before </head>
|
||||
(it sits on top of src/site/styles/simple.css). Screenshots: mockups/shots/simple-side-a-1440.png, -390.png.
|
||||
Throwaway markup: match the look, do not copy the code. "[newest post title]" is a stand-in; the reading / listening /
|
||||
last film values were copied from the live site on 2026-11-11. -->
|
||||
<style>
|
||||
.sm-ways{margin-top:40px;gap:22px}
|
||||
.way{display:grid}
|
||||
.sm-in{list-style:none;margin:6px 0 0 42px;padding:0;font-size:14px;line-height:1.45;color:var(--cream-dim)}
|
||||
.sm-in li{padding:3px 0}
|
||||
.sm-in li.k{margin-top:8px;font:600 10.5px var(--sans);letter-spacing:.12em;text-transform:uppercase;color:#a8927f}
|
||||
.sm-in a,.sm-late a{color:var(--cream);text-decoration:none;border-bottom:1px solid rgba(242,231,215,.28)}
|
||||
.sm-in time{margin-left:8px;font-size:12.5px;color:#a8927f;font-variant-numeric:tabular-nums}
|
||||
.sm-in span{color:var(--cream)}
|
||||
.mk{display:inline-block;width:.62em;height:.62em;border-radius:50%;border:1.4px solid var(--cream-dim);margin:0 .3em 0 .7em;vertical-align:.02em}
|
||||
.mk.ai{background:var(--cream-dim)}
|
||||
.sm-rn{display:grid;grid-template-columns:84px minmax(0,1fr);gap:6px 14px;margin:36px 0 0 42px;font-size:14px}
|
||||
.sm-rn dt{font:600 10.5px/1.9 var(--sans);letter-spacing:.12em;text-transform:uppercase;color:#a8927f}
|
||||
.sm-rn dd{margin:0;color:var(--cream)} .sm-rn dd span{color:var(--cream-dim);margin-left:6px;font-size:13px}
|
||||
.sm-late{margin:40px 0 0}
|
||||
.sm-late h2{display:flex;align-items:center;gap:12px;margin:0 0 10px;font:600 11px var(--sans);letter-spacing:.12em;text-transform:uppercase;color:#a8927f}
|
||||
.sm-late h2::after{content:"";flex:1;height:1px;background:rgba(242,231,215,.18)}
|
||||
.sm-late ol{list-style:none;margin:0;padding:0}
|
||||
.sm-late li{display:grid;grid-template-columns:58px minmax(0,1fr) auto;gap:12px;align-items:baseline;padding:8px 0;border-bottom:1px solid rgba(242,231,215,.12);font-size:15.5px}
|
||||
.sm-late li a{justify-self:start;font-family:var(--serif);font-size:17px}
|
||||
.sm-late time,.sm-late li span{font-size:12.5px;color:#a8927f;font-variant-numeric:tabular-nums}
|
||||
.sm-rnl{margin:16px 0 0;font-size:14px;line-height:1.55;color:var(--cream-dim);max-width:30em} .sm-rnl b{font-weight:400;color:var(--cream)}
|
||||
@media (hover:hover){.sm-in a:hover,.sm-late a:hover{border-color:var(--saffron)}}
|
||||
@media (max-width:900px){.sm-rn,.sm-in{margin-left:42px}}
|
||||
.sm-ways .sm-in a{display:inline;padding:0}.sm-ways .sm-in a::before{content:none}.sm-in li{display:flex;justify-content:space-between;gap:12px;align-items:baseline}.sm-in li:first-child{justify-content:flex-start;gap:0}.sm-rn{margin-top:28px}</style>
|
||||
<aside class="sm-side"><h1>Lucas Winther</h1><p class="sm-line">Computer Scientist with passion for development.</p>
|
||||
<nav class="sm-ways" aria-label="The two spaces">
|
||||
<div class="way"><a href="/work/"><span class="n">work</span><span class="d">projects, writing, the CV in full</span></a>
|
||||
<ul class="sm-in">
|
||||
<li><span>13 projects</span> <i class="mk hand" aria-hidden="true"></i> 11 by hand <i class="mk ai" aria-hidden="true"></i> 2 made with AI</li>
|
||||
<li class="k">latest writing</li>
|
||||
<li><a href="/work/writing/synthetic-work-post/">[newest post title]</a> <time>14 Mar</time></li>
|
||||
</ul></div>
|
||||
<div class="way"><a href="/off-the-clock/"><span class="n">off the clock</span><span class="d">things I love and things I think</span></a>
|
||||
<ul class="sm-in">
|
||||
<li class="k">newest on the wall</li>
|
||||
<li><a href="/off-the-clock/a-whisker-away/">A Whisker Away</a> <time>film · 10 Nov</time></li>
|
||||
<li><a href="/off-the-clock/always-never/">Always Never</a> <time>comic · 10 Nov</time></li>
|
||||
<li><a href="/off-the-clock/saltlakridserne/">Saltlakridserne</a> <time>music · 10 Nov</time></li>
|
||||
</ul></div>
|
||||
</nav>
|
||||
<dl class="sm-rn"><dt>reading</dt><dd>Wolvenheart, Vol. 1 <span>Mark London</span></dd><dt>listening</dt><dd>New Commandments <span>Moon Walker</span></dd><dt>last film</dt><dd>Backrooms <span>7 Aug</span></dd></dl>
|
||||
<p class="sm-more"><a href="/">the start page</a> · <a href="/everything/">everything in one list</a></p></aside>
|
||||
@@ -0,0 +1,45 @@
|
||||
<!-- /simple side panel, variant B (2026-11-11). Rejected (owner chose A).
|
||||
A fragment, not a page: the <aside> replaces .sm-side in a BUILT /simple/index.html and the <style> goes before </head>
|
||||
(it sits on top of src/site/styles/simple.css). Screenshots: mockups/shots/simple-side-b-1440.png, -390.png.
|
||||
Throwaway markup: match the look, do not copy the code. "[newest post title]" is a stand-in; the reading / listening /
|
||||
last film values were copied from the live site on 2026-11-11. -->
|
||||
<style>
|
||||
.sm-ways{margin-top:40px;gap:22px}
|
||||
.way{display:grid}
|
||||
.sm-in{list-style:none;margin:6px 0 0 42px;padding:0;font-size:14px;line-height:1.45;color:var(--cream-dim)}
|
||||
.sm-in li{padding:3px 0}
|
||||
.sm-in li.k{margin-top:8px;font:600 10.5px var(--sans);letter-spacing:.12em;text-transform:uppercase;color:#a8927f}
|
||||
.sm-in a,.sm-late a{color:var(--cream);text-decoration:none;border-bottom:1px solid rgba(242,231,215,.28)}
|
||||
.sm-in time{margin-left:8px;font-size:12.5px;color:#a8927f;font-variant-numeric:tabular-nums}
|
||||
.sm-in span{color:var(--cream)}
|
||||
.mk{display:inline-block;width:.62em;height:.62em;border-radius:50%;border:1.4px solid var(--cream-dim);margin:0 .3em 0 .7em;vertical-align:.02em}
|
||||
.mk.ai{background:var(--cream-dim)}
|
||||
.sm-rn{display:grid;grid-template-columns:84px minmax(0,1fr);gap:6px 14px;margin:36px 0 0 42px;font-size:14px}
|
||||
.sm-rn dt{font:600 10.5px/1.9 var(--sans);letter-spacing:.12em;text-transform:uppercase;color:#a8927f}
|
||||
.sm-rn dd{margin:0;color:var(--cream)} .sm-rn dd span{color:var(--cream-dim);margin-left:6px;font-size:13px}
|
||||
.sm-late{margin:40px 0 0}
|
||||
.sm-late h2{display:flex;align-items:center;gap:12px;margin:0 0 10px;font:600 11px var(--sans);letter-spacing:.12em;text-transform:uppercase;color:#a8927f}
|
||||
.sm-late h2::after{content:"";flex:1;height:1px;background:rgba(242,231,215,.18)}
|
||||
.sm-late ol{list-style:none;margin:0;padding:0}
|
||||
.sm-late li{display:grid;grid-template-columns:58px minmax(0,1fr) auto;gap:12px;align-items:baseline;padding:8px 0;border-bottom:1px solid rgba(242,231,215,.12);font-size:15.5px}
|
||||
.sm-late li a{justify-self:start;font-family:var(--serif);font-size:17px}
|
||||
.sm-late time,.sm-late li span{font-size:12.5px;color:#a8927f;font-variant-numeric:tabular-nums}
|
||||
.sm-rnl{margin:16px 0 0;font-size:14px;line-height:1.55;color:var(--cream-dim);max-width:30em} .sm-rnl b{font-weight:400;color:var(--cream)}
|
||||
@media (hover:hover){.sm-in a:hover,.sm-late a:hover{border-color:var(--saffron)}}
|
||||
@media (max-width:900px){.sm-rn,.sm-in{margin-left:42px}}
|
||||
</style>
|
||||
<aside class="sm-side"><h1>Lucas Winther</h1><p class="sm-line">Computer Scientist with passion for development.</p>
|
||||
<nav class="sm-ways" aria-label="The two spaces">
|
||||
<a href="/work/"><span class="n">work</span><span class="d">13 projects, 11 of them by hand · writing · the CV in full</span></a>
|
||||
<a href="/off-the-clock/"><span class="n">off the clock</span><span class="d">5 things on the wall this year · things I love and things I think</span></a>
|
||||
</nav>
|
||||
<section class="sm-late" aria-labelledby="late"><h2 id="late">Lately</h2>
|
||||
<ol>
|
||||
<li><time>10 Nov</time><a href="/off-the-clock/a-whisker-away/">A Whisker Away</a><span>film</span></li>
|
||||
<li><time>10 Nov</time><a href="/off-the-clock/always-never/">Always Never</a><span>comic</span></li>
|
||||
<li><time>10 Nov</time><a href="/off-the-clock/saltlakridserne/">Saltlakridserne</a><span>music</span></li>
|
||||
<li><time>14 Mar</time><a href="/work/writing/synthetic-work-post/">[newest post title]</a><span>writing</span></li>
|
||||
</ol>
|
||||
<p class="sm-rnl">Reading <b>Wolvenheart, Vol. 1</b>, listening to <b>New Commandments</b>, last watched <b>Backrooms</b>.</p>
|
||||
</section>
|
||||
<p class="sm-more"><a href="/">the start page</a> · <a href="/everything/">everything in one list</a></p></aside>
|
||||
@@ -22,7 +22,7 @@ expected=$(git -C "$work/content" rev-parse HEAD)
|
||||
# drops -c / GIT_CONFIG_* settings before upload-pack, so safe.directory must come from a config file.
|
||||
printf '[safe]\n\tdirectory = *\n' > "$work/smoke.gitconfig"
|
||||
chmod -R a+rX "$work"
|
||||
docker create --name "$name" -e CONTENT_REPO=/content -e SOURCES_FIXTURES=/sources \
|
||||
docker create --name "$name" -e CONTENT_REPO=/content -e SOURCES_FIXTURES=/sources -e SITE_URL=https://example.invalid \
|
||||
-e GIT_CONFIG_GLOBAL=/smoke.gitconfig "$image" >/dev/null
|
||||
tar -C "$work" --owner=0 --group=0 -c content sources smoke.gitconfig | docker cp -q - "$name:/"
|
||||
docker start "$name" >/dev/null
|
||||
@@ -45,5 +45,6 @@ for path in / /work/ /off-the-clock/; do
|
||||
get "$path" >/dev/null || { echo "smoke: $path did not return 200"; docker logs "$name"; exit 1; }
|
||||
done
|
||||
get / | grep -q 'Example Person' || { echo "smoke: / did not render the fixture content"; exit 1; }
|
||||
get /sitemap.xml | grep -q 'https://example.invalid/work/' || { echo "smoke: /sitemap.xml missing or without SITE_URL"; exit 1; }
|
||||
get /status | grep -q '"sources": *{' || { echo "smoke: /status has no sources object"; get /status; exit 1; }
|
||||
echo "smoke: ok ($expected)"
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
// "projects made: any way / not fully by AI / by hand only" (spec 006): plain HTML, a first-paint inline script that
|
||||
// presses the stored choice, and a page script for clicks and `lw:pref` (scripts/pref-switch.ts). Hidden by CSS
|
||||
// without JavaScript (base.css). Rendered with "any way" pressed; the inline script corrects it before first paint.
|
||||
import { PAINT_SWITCH, PREF_OPTIONS } from '../scripts/pref-switch.ts';
|
||||
---
|
||||
<div class="pref-switch" role="group" aria-label="Projects made"><span class="pref-label">projects made:</span><span class="pref-seg">{PREF_OPTIONS.map((o) => <button type="button" data-pref-value={o.value} aria-pressed={String(o.value === 'any')}>{o.label}</button>)}</span><p class="pref-archive" hidden>Prefer work made without AI? <a href="https://old.lucaswinther.info/">The 2021 site was all by hand →</a></p></div>
|
||||
<script is:inline set:html={PAINT_SWITCH}></script>
|
||||
<script>
|
||||
import { initPrefSwitches } from '../scripts/pref-switch.ts';
|
||||
initPrefSwitches();
|
||||
</script>
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { parsePreference, type Preference } from '../../core/ai.ts';
|
||||
import { applyPreference, browserStorage } from '../scripts/preference.ts';
|
||||
|
||||
const OPTIONS: { value: Preference; label: string }[] = [
|
||||
{ value: 'any', label: 'any way' },
|
||||
{ value: 'not-fully-ai', label: 'not fully by AI' },
|
||||
{ value: 'by-hand-only', label: 'by hand only' },
|
||||
];
|
||||
|
||||
export default function PreferenceSwitch() {
|
||||
const [pref, setPref] = useState<Preference>('any');
|
||||
|
||||
useEffect(() => {
|
||||
setPref(parsePreference(document.documentElement.dataset.pref));
|
||||
// Other controls on the page (e.g. "show all" under the project list) may change it too.
|
||||
const sync = (e: Event) => setPref(parsePreference((e as CustomEvent).detail));
|
||||
window.addEventListener('lw:pref', sync);
|
||||
return () => window.removeEventListener('lw:pref', sync);
|
||||
}, []);
|
||||
|
||||
function choose(value: Preference) {
|
||||
applyPreference(document.documentElement, browserStorage(), value);
|
||||
setPref(value);
|
||||
window.dispatchEvent(new CustomEvent('lw:pref', { detail: value }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pref-switch" role="group" aria-label="Projects made">
|
||||
<span className="pref-label">projects made:</span>
|
||||
<span className="pref-seg">
|
||||
{OPTIONS.map((o) => (
|
||||
<button key={o.value} type="button" aria-pressed={pref === o.value} onClick={() => choose(o.value)}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
{pref === 'by-hand-only' && (
|
||||
<p className="pref-archive">
|
||||
Prefer work made without AI? <a href="https://old.lucaswinther.info/">The 2021 site was all by hand →</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
// Adds behaviour to the start page; the HTML already works as two plain links without it.
|
||||
// Arrow keys lean toward a side, Enter goes in, Esc lets go, G opens everything. Under (hover: hover)
|
||||
// the work side shows a crosshair readout. The song sleeve plays only after the visitor presses play.
|
||||
export default function Gate() {
|
||||
useEffect(() => {
|
||||
const gate = document.getElementById('gate');
|
||||
if (!gate) return;
|
||||
const tech = document.getElementById('tech');
|
||||
const art = document.getElementById('art');
|
||||
const garden = document.getElementById('garden-link') as HTMLAnchorElement | null;
|
||||
const hrefOf = (side: string) => gate.querySelector<HTMLAnchorElement>(`[data-enter="${side}"]`)?.href;
|
||||
let lean: 'work' | 'personal' | null = null;
|
||||
|
||||
function setLean(s: typeof lean) {
|
||||
lean = s;
|
||||
gate!.classList.toggle('lean-tech', s === 'work');
|
||||
gate!.classList.toggle('lean-art', s === 'personal');
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (t?.closest?.('input, textarea, select') || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (e.key === 'ArrowLeft') setLean('work');
|
||||
else if (e.key === 'ArrowRight') setLean('personal');
|
||||
else if (e.key === 'Escape') setLean(null);
|
||||
else if (e.key === 'Enter' && lean && !t?.closest?.('a, button')) {
|
||||
const href = hrefOf(lean);
|
||||
if (href) location.href = href;
|
||||
} else if ((e.key === 'g' || e.key === 'G') && garden) location.href = garden.href;
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
|
||||
// The garden pill sits on the seam, wherever the sides have leaned to.
|
||||
function placeGarden() {
|
||||
if (!garden || !tech) return;
|
||||
garden.style.left = window.innerWidth > 820 ? `${tech.offsetWidth}px` : '';
|
||||
}
|
||||
placeGarden();
|
||||
const ro = typeof ResizeObserver === 'undefined' || !tech ? null : new ResizeObserver(placeGarden);
|
||||
if (tech) ro?.observe(tech);
|
||||
window.addEventListener('resize', placeGarden);
|
||||
|
||||
// Crosshair readout, only for a visitor who hovers.
|
||||
const cleanups: Array<() => void> = [];
|
||||
if (tech && window.matchMedia('(hover: hover)').matches) {
|
||||
const h = document.getElementById('xh-h');
|
||||
const v = document.getElementById('xh-v');
|
||||
const ro2 = document.getElementById('readout');
|
||||
for (const el of [h, v, ro2]) el?.removeAttribute('hidden');
|
||||
const move = (e: MouseEvent) => {
|
||||
const r = tech.getBoundingClientRect();
|
||||
const x = Math.round(e.clientX - r.left);
|
||||
const y = Math.round(e.clientY - r.top);
|
||||
if (h) h.style.top = `${y}px`;
|
||||
if (v) v.style.left = `${x}px`;
|
||||
if (ro2) ro2.innerHTML = `x ${String(x).padStart(3, '0')} · y ${String(y).padStart(3, '0')}<br>cell ${Math.floor(x / 24)},${Math.floor(y / 24)}`;
|
||||
};
|
||||
tech.addEventListener('mousemove', move);
|
||||
cleanups.push(() => tech.removeEventListener('mousemove', move));
|
||||
}
|
||||
|
||||
// Song sleeve: the play button exists in the HTML but stays hidden until this runs.
|
||||
const plays = art ? Array.from(art.querySelectorAll<HTMLButtonElement>('[data-play]')) : [];
|
||||
for (const btn of plays) {
|
||||
btn.hidden = false;
|
||||
const sleeve = btn.closest('.tape');
|
||||
const audio = sleeve?.querySelector('audio');
|
||||
if (!sleeve || !audio) continue;
|
||||
const sync = () => {
|
||||
const on = !audio.paused;
|
||||
sleeve.classList.toggle('playing', on);
|
||||
btn.textContent = on ? '❚❚' : '▶';
|
||||
btn.setAttribute('aria-label', on ? 'Pause' : 'Play');
|
||||
};
|
||||
// play() rejects when the file is missing or the browser refuses; put the button back to "Play".
|
||||
const click = () => {
|
||||
if (audio.paused) audio.play().catch(() => { audio.pause(); sync(); });
|
||||
else audio.pause();
|
||||
};
|
||||
btn.addEventListener('click', click);
|
||||
audio.addEventListener('play', sync);
|
||||
audio.addEventListener('pause', sync);
|
||||
audio.addEventListener('ended', sync);
|
||||
cleanups.push(() => btn.removeEventListener('click', click));
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('resize', placeGarden);
|
||||
ro?.disconnect();
|
||||
cleanups.forEach((f) => f());
|
||||
};
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import PreferenceSwitch from '../PreferenceSwitch.tsx';
|
||||
import PreferenceSwitch from '../PreferenceSwitch.astro';
|
||||
interface Props { email: string; github: string; linkedin: string }
|
||||
const { email, github, linkedin } = Astro.props;
|
||||
---
|
||||
@@ -7,5 +7,5 @@ const { email, github, linkedin } = Astro.props;
|
||||
<span class="foot-links">
|
||||
<a href={`mailto:${email}`}>{email}</a> · <a href={github}>GitHub</a> · <a href={linkedin}>LinkedIn</a> · <a href="/simple/">/simple</a>
|
||||
</span>
|
||||
<PreferenceSwitch client:idle />
|
||||
<PreferenceSwitch />
|
||||
</footer>
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import PreferenceSwitch from '../PreferenceSwitch.tsx';
|
||||
import PreferenceSwitch from '../PreferenceSwitch.astro';
|
||||
interface Props { name: string; onIndex: boolean; hasWriting: boolean; hasFeatured: boolean; hasList: boolean; hasCv: boolean }
|
||||
const { name, onIndex, hasWriting, hasFeatured, hasList, hasCv } = Astro.props;
|
||||
const at = (hash: string) => (onIndex ? hash : `/work/${hash}`);
|
||||
@@ -17,6 +17,6 @@ const links = [
|
||||
<a class="who" href="/work/">{name}</a><span>/ work</span>
|
||||
</div>
|
||||
<nav class="nav" aria-label="Sections">{links.map((l) => <a href={l.href}>{l.label}</a>)}</nav>
|
||||
<div class="pref"><PreferenceSwitch client:idle /></div>
|
||||
<div class="pref"><PreferenceSwitch /></div>
|
||||
</div>
|
||||
</header>
|
||||
+6
-1
@@ -5,6 +5,7 @@ import { marked } from 'marked';
|
||||
import { buildIssues, type Issue } from '../core/issues.ts';
|
||||
import type { AiLevel } from '../core/ai.ts';
|
||||
import { safePostHtml } from './post-html.ts';
|
||||
import { parseSiteUrl } from './link-preview.ts';
|
||||
import { ContentError } from '../library/errors.ts';
|
||||
import { loadContent, RESERVED_PERSONAL_IDS } from '../library/load.ts';
|
||||
import type { Content, Item } from '../library/types.ts';
|
||||
@@ -32,6 +33,7 @@ export type SiteData = Content & {
|
||||
everything: EverythingEntry[];
|
||||
warnings: string[]; // for the build log
|
||||
ghostHost?: string; // GHOST_URL's host: the only host post images may load from
|
||||
siteUrl?: string; // SITE_URL as an origin; unset means no canonical, og:url, og:image, sitemap or feeds
|
||||
};
|
||||
|
||||
export const md = (src: string): string => marked.parse(src.trim(), { gfm: true, async: false });
|
||||
@@ -95,6 +97,7 @@ export async function loadSiteData(
|
||||
cacheDir: string,
|
||||
today: string = new Date().toISOString().slice(0, 10),
|
||||
ghostUrl: string | undefined = process.env.GHOST_URL,
|
||||
siteUrlRaw: string | undefined = process.env.SITE_URL,
|
||||
): Promise<SiteData> {
|
||||
const { content, errors } = await loadContent(contentDir);
|
||||
if (!content) throw new ContentError(errors);
|
||||
@@ -102,6 +105,7 @@ export async function loadSiteData(
|
||||
const warnings = [...reservedPostWarnings(read.posts), ...clashingPostWarnings(read.posts, content.items)];
|
||||
const itemIds = new Set(content.items.map((i) => i.id));
|
||||
const ghostHost = hostOf(ghostUrl);
|
||||
const siteUrl = parseSiteUrl(siteUrlRaw);
|
||||
// Every page renders post HTML from here, so it is made safe once (see site/post-html.ts).
|
||||
const posts = read.posts
|
||||
.filter((p) => !isReserved(p) && !(p.side === 'off-the-clock' && itemIds.has(p.slug)))
|
||||
@@ -138,6 +142,7 @@ export async function loadSiteData(
|
||||
everything,
|
||||
warnings,
|
||||
ghostHost,
|
||||
...(siteUrl && { siteUrl }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,7 +154,7 @@ export function getSiteData(): Promise<SiteData> {
|
||||
return Promise.reject(new Error('SITE_CONTENT_DIR is not set; build with `bun src/cli.ts build`.'));
|
||||
}
|
||||
const cacheDir = process.env.SITE_CACHE_DIR ?? '.cache/site';
|
||||
const key = JSON.stringify([contentDir, cacheDir]);
|
||||
const key = JSON.stringify([contentDir, cacheDir, process.env.SITE_URL ?? '']);
|
||||
let p = memo.get(key);
|
||||
if (!p) {
|
||||
p = loadSiteData(contentDir, cacheDir);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Which pages the site builds (for the sitemap) and what goes in each feed (spec 007), from site data.
|
||||
// Pure: the XML itself comes from xml.ts. The page list mirrors the routes in pages/; a build test
|
||||
// checks it against the files Astro actually wrote.
|
||||
import type { AiLevel } from '../core/ai.ts';
|
||||
import { AI_LABEL } from '../core/ai.ts';
|
||||
import { issueUrl } from './components/issues/issue.ts';
|
||||
import type { SiteData } from './data.ts';
|
||||
import { absoluteUrl } from './link-preview.ts';
|
||||
import { atomFeed, sitemapXml } from './xml.ts';
|
||||
|
||||
export type FeedName = 'all' | 'work' | 'off-the-clock';
|
||||
|
||||
/** Each feed's file and the page it follows; its title is that page's own title. */
|
||||
export const FEEDS: Record<FeedName, { path: string; page: string; title: (siteName: string) => string }> = {
|
||||
all: { path: '/feed.xml', page: '/', title: (name) => name },
|
||||
work: { path: '/work/feed.xml', page: '/work/', title: (name) => `Work · ${name}` },
|
||||
'off-the-clock': { path: '/off-the-clock/feed.xml', page: '/off-the-clock/', title: (name) => `Off the clock · ${name}` },
|
||||
};
|
||||
|
||||
/** The `rel="alternate"` feed a page links: its path and title. */
|
||||
export const feedLink = (name: FeedName, siteName: string) => ({ path: FEEDS[name].path, title: FEEDS[name].title(siteName) });
|
||||
|
||||
type Data = Pick<SiteData, 'projects' | 'workPosts' | 'personal' | 'issues'>;
|
||||
|
||||
/** Every page the site builds except the 404, root-relative with the trailing slash it is served with. */
|
||||
export function sitePaths(data: Data): string[] {
|
||||
return [
|
||||
'/', '/work/', '/off-the-clock/', '/off-the-clock/issues/', '/everything/', '/simple/',
|
||||
...data.projects.map((p) => `/work/${p.id}/`),
|
||||
...data.workPosts.map((p) => `/work/writing/${p.slug}/`),
|
||||
...data.personal.map((e) => `/off-the-clock/${'slug' in e ? e.slug : e.id}/`),
|
||||
...data.issues.map((i) => issueUrl(i.id)),
|
||||
];
|
||||
}
|
||||
|
||||
export type FeedEntry = { title: string; path: string; date: string; summary?: string; ai: AiLevel | null };
|
||||
|
||||
const withSummary = (text: string | undefined) => (text?.trim() ? { summary: text.trim() } : {});
|
||||
|
||||
/** A feed's entries, newest first (ties by path, so the order never depends on load order). */
|
||||
export function feedEntries(data: Data, name: FeedName): FeedEntry[] {
|
||||
const work: FeedEntry[] = name === 'off-the-clock' ? [] : [
|
||||
...data.projects.map((p) => ({ title: p.title, path: `/work/${p.id}/`, date: p.date, ...withSummary(p.summary), ai: p.ai })),
|
||||
...data.workPosts.map((p) => ({ title: p.title, path: `/work/writing/${p.slug}/`, date: p.date, ...withSummary(p.excerpt), ai: p.ai })),
|
||||
];
|
||||
const personal: FeedEntry[] = name === 'work' ? [] : data.personal.map((e) =>
|
||||
'slug' in e
|
||||
? { title: e.title, path: `/off-the-clock/${e.slug}/`, date: e.date, ...withSummary(e.excerpt), ai: e.ai }
|
||||
: { title: e.title, path: `/off-the-clock/${e.id}/`, date: e.date, ...withSummary(e.line), ai: e.ai });
|
||||
return [...work, ...personal].sort((a, b) => b.date.localeCompare(a.date) || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* The XML files to write, keyed by path without `.xml` (the `[...file].xml.ts` route's parameter). None
|
||||
* without a site address: a sitemap or feed needs absolute URLs.
|
||||
*/
|
||||
export function xmlFiles(data: Data & Pick<SiteData, 'site' | 'siteUrl'>): Record<string, () => string> {
|
||||
const siteUrl = data.siteUrl;
|
||||
if (!siteUrl) return {};
|
||||
const abs = (path: string) => absoluteUrl(siteUrl, path);
|
||||
const feed = (name: FeedName) => () => atomFeed({
|
||||
title: FEEDS[name].title(data.site.name),
|
||||
selfUrl: abs(FEEDS[name].path),
|
||||
pageUrl: abs(FEEDS[name].page),
|
||||
author: data.site.name,
|
||||
entries: feedEntries(data, name).map((e) => ({
|
||||
title: e.title,
|
||||
url: abs(e.path),
|
||||
date: e.date,
|
||||
...(e.summary && { summary: e.summary }),
|
||||
...(e.ai && { category: { term: e.ai, label: AI_LABEL[e.ai] } }),
|
||||
})),
|
||||
});
|
||||
return {
|
||||
sitemap: () => sitemapXml(sitePaths(data).map(abs)),
|
||||
feed: feed('all'),
|
||||
'work/feed': feed('work'),
|
||||
'off-the-clock/feed': feed('off-the-clock'),
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import personalFonts from '../styles/fonts-personal.css?url';
|
||||
import geistSans400 from '@fontsource/geist-sans/files/geist-sans-latin-400-normal.woff2?url';
|
||||
import geistSans600 from '@fontsource/geist-sans/files/geist-sans-latin-600-normal.woff2?url';
|
||||
import geistMono400 from '@fontsource/geist-mono/files/geist-mono-latin-400-normal.woff2?url';
|
||||
import { getSiteData } from '../data';
|
||||
import { feedLink, type FeedName } from '../feeds.ts';
|
||||
import { previewTags } from '../link-preview.ts';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -15,8 +18,22 @@ interface Props {
|
||||
theme: 'gate' | 'work' | 'personal' | 'neutral';
|
||||
/** Lets a page tune its theme's tokens in CSS (`[data-page='simple']`). */
|
||||
page?: string;
|
||||
/** Link previews (spec 007): `article` for a project, a post or an item's own page. */
|
||||
type?: 'website' | 'article';
|
||||
/** The page's own picture from the content repo (a media file), for og:image. */
|
||||
image?: { file: string; alt: string } | undefined;
|
||||
/** The feed the page links in its head. */
|
||||
feed?: FeedName;
|
||||
/** Keeps the page out of search engines and previews (the 404). */
|
||||
noindex?: boolean;
|
||||
}
|
||||
const { title, description, theme, page } = Astro.props;
|
||||
const { title, description, theme, page, type = 'website', image, feed, noindex = false } = Astro.props;
|
||||
const { site, siteUrl } = await getSiteData();
|
||||
const preview = previewTags({
|
||||
siteUrl, siteName: site.name, path: Astro.url.pathname, title, description, type, noindex,
|
||||
...(image && { image: { path: `/media/${image.file}`, alt: image.alt } }),
|
||||
...(feed && { feed: feedLink(feed, site.name) }),
|
||||
});
|
||||
|
||||
// Only the fonts this theme needs (self-hosted via @fontsource). ?url keeps the other themes' CSS unlinked.
|
||||
const fontCss = { neutral: neutralFonts, gate: gateFonts, work: workFonts, personal: personalFonts }[theme];
|
||||
@@ -30,6 +47,7 @@ const preloads = theme === 'work' ? [geistSans400, geistSans600, geistMono400] :
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
{preview.map(({ tag, attrs }) => (tag === 'link' ? <link {...attrs} /> : <meta {...attrs} />))}
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
{preloads.map((href) => <link rel="preload" href={href} as="font" type="font/woff2" crossorigin />)}
|
||||
<link rel="stylesheet" href={fontCss} />
|
||||
|
||||
@@ -5,11 +5,17 @@ import EdgeToWork from '../components/personal/EdgeToWork.astro';
|
||||
import type { Site } from '../../library/types.ts';
|
||||
import '../styles/personal.css';
|
||||
import '../styles/personal-cards.css';
|
||||
interface Props { title: string; description: string; site: Site }
|
||||
const { title, description, site } = Astro.props;
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
site: Site;
|
||||
type?: 'website' | 'article' | undefined;
|
||||
image?: { file: string; alt: string } | undefined;
|
||||
}
|
||||
const { title, description, site, type, image } = Astro.props;
|
||||
const { links } = site;
|
||||
---
|
||||
<Base title={title} description={description} theme="personal">
|
||||
<Base title={title} description={description} theme="personal" type={type} image={image} feed="off-the-clock">
|
||||
<EdgeToWork />
|
||||
<header class="bar">
|
||||
<div class="in">
|
||||
|
||||
@@ -13,11 +13,13 @@ interface Props {
|
||||
site: Site;
|
||||
onIndex?: boolean;
|
||||
sections: WorkSections;
|
||||
type?: 'website' | 'article' | undefined;
|
||||
image?: { file: string; alt: string } | undefined;
|
||||
}
|
||||
const { title, description, site, onIndex = false, sections } = Astro.props;
|
||||
const { title, description, site, onIndex = false, sections, type, image } = Astro.props;
|
||||
const { links } = site;
|
||||
---
|
||||
<Base title={title} description={description} theme="work">
|
||||
<Base title={title} description={description} theme="work" type={type} image={image} feed="work">
|
||||
<EdgeToPersonal />
|
||||
<TopBar name={site.name} onIndex={onIndex} hasFeatured={sections.featured} hasList={sections.list} hasWriting={sections.writing} hasCv={sections.cv} />
|
||||
<main class="work">
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// What a page puts in its head for link previews and search engines (spec 007): canonical URL, Open
|
||||
// Graph, the Twitter card type and the page's feed. Pure: returns the tags as data and Base.astro renders
|
||||
// them (Astro escapes the attribute values). Anything that needs an absolute URL is left out when the
|
||||
// site has no address (`SITE_URL` unset): nothing guesses one.
|
||||
|
||||
/**
|
||||
* The site's public address from `SITE_URL`: an http(s) origin without the trailing slash, or undefined
|
||||
* when unset or blank. Throws on anything else (a path, a query, another scheme), because every page
|
||||
* links root-relative and a wrong address would end up in every preview and feed.
|
||||
*/
|
||||
export function parseSiteUrl(raw: string | undefined): string | undefined {
|
||||
const value = raw?.trim();
|
||||
if (!value) return undefined;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`SITE_URL must be an absolute http(s) address like https://example.org, got "${value}"`);
|
||||
}
|
||||
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.pathname !== '/' || url.search || url.hash || value.endsWith('?') || value.endsWith('#')) {
|
||||
throw new Error(`SITE_URL must be just an http(s) origin like https://example.org (no path, query or fragment), got "${value}"`);
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
/** `path` (root-relative) on the site at `siteUrl`, percent-encoded where needed. */
|
||||
export const absoluteUrl = (siteUrl: string, path: string): string => new URL(path, `${siteUrl}/`).href;
|
||||
|
||||
export type HeadTag = { tag: 'link' | 'meta'; attrs: Record<string, string> };
|
||||
|
||||
export type PreviewInput = {
|
||||
siteUrl: string | undefined;
|
||||
siteName: string;
|
||||
path: string; // the page's root-relative URL as served, e.g. /work/demo/
|
||||
title: string;
|
||||
description: string;
|
||||
type: 'website' | 'article';
|
||||
image?: { path: string; alt: string }; // the page's own picture, root-relative (an SVG is left out)
|
||||
feed?: { path: string; title: string };
|
||||
noindex?: boolean;
|
||||
};
|
||||
|
||||
export function previewTags(p: PreviewInput): HeadTag[] {
|
||||
if (p.noindex) return [{ tag: 'meta', attrs: { name: 'robots', content: 'noindex' } }];
|
||||
const meta = (property: string, content: string): HeadTag => ({ tag: 'meta', attrs: { property, content } });
|
||||
const abs = p.siteUrl ? (path: string) => absoluteUrl(p.siteUrl!, path) : undefined;
|
||||
// Facebook, LinkedIn and X take raster images only; an SVG would make a broken large card.
|
||||
const image = abs && p.image && !/\.svg$/i.test(p.image.path) ? p.image : undefined;
|
||||
return [
|
||||
...(abs ? [{ tag: 'link', attrs: { rel: 'canonical', href: abs(p.path) } } as HeadTag] : []),
|
||||
meta('og:title', p.title),
|
||||
meta('og:description', p.description),
|
||||
meta('og:type', p.type),
|
||||
meta('og:site_name', p.siteName),
|
||||
...(abs ? [meta('og:url', abs(p.path))] : []),
|
||||
...(abs && image ? [meta('og:image', abs(image.path))] : []),
|
||||
...(image && image.alt ? [meta('og:image:alt', image.alt)] : []),
|
||||
{ tag: 'meta', attrs: { name: 'twitter:card', content: image ? 'summary_large_image' : 'summary' } },
|
||||
...(abs && p.feed
|
||||
? [{ tag: 'link', attrs: { rel: 'alternate', type: 'application/atom+xml', title: p.feed.title, href: abs(p.feed.path) } } as HeadTag]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import Placeholder404 from '../components/Placeholder404.astro';
|
||||
import { getSiteData } from '../data';
|
||||
|
||||
const { site } = await getSiteData();
|
||||
---
|
||||
<Base title="Page not found · Lucas Winther" description="This page does not exist." theme="neutral">
|
||||
<Base title={`Page not found · ${site.name}`} description="This page does not exist." theme="neutral" noindex>
|
||||
<Placeholder404 />
|
||||
</Base>
|
||||
@@ -0,0 +1,17 @@
|
||||
// The sitemap (/sitemap.xml) and the Atom feeds (/feed.xml, /work/feed.xml, /off-the-clock/feed.xml), spec 007.
|
||||
// Without SITE_URL there are no paths, so none of these files is written.
|
||||
import type { APIRoute, GetStaticPaths } from 'astro';
|
||||
import { getSiteData } from '../data.ts';
|
||||
import { xmlFiles } from '../feeds.ts';
|
||||
|
||||
export const getStaticPaths = (async () => {
|
||||
const files = xmlFiles(await getSiteData());
|
||||
return Object.keys(files).map((file) => ({ params: { file } }));
|
||||
}) satisfies GetStaticPaths;
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const render = xmlFiles(await getSiteData())[params.file ?? ''];
|
||||
if (!render) return new Response(null, { status: 404 });
|
||||
const type = params.file === 'sitemap' ? 'application/xml' : 'application/atom+xml';
|
||||
return new Response(render(), { headers: { 'Content-Type': `${type}; charset=utf-8` } });
|
||||
};
|
||||
@@ -41,8 +41,7 @@ const years = [...new Set(everything.map((e) => e.date.slice(0, 4)))];
|
||||
const inYear = (y: string) => everything.filter((e) => e.date.startsWith(y));
|
||||
const sideCount = (s: string) => everything.filter((e) => e.side === s).length;
|
||||
---
|
||||
<Base title={`Everything at once · ${site.name}`} description={`Everything on ${first}'s site in one list: projects and writing from work, and everything from off the clock.`} theme="neutral" page="everything">
|
||||
<script is:inline>document.documentElement.classList.add('js');</script>
|
||||
<Base title={`Everything at once · ${site.name}`} description={`Everything on ${first}'s site in one list: projects and writing from work, and everything from off the clock.`} theme="neutral" page="everything" feed="all">
|
||||
<header class="ev-bar">
|
||||
<div class="ev-in">
|
||||
<a class="ev-back" href="/" title="back to the start page">← gate</a>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import AiMark from '../components/AiMark.astro';
|
||||
import Gate from '../components/gate/Gate.tsx';
|
||||
import GateSide from '../components/gate/GateSide.astro';
|
||||
import GateFooter from '../components/gate/GateFooter.astro';
|
||||
import FilterNote from '../components/FilterNote.astro';
|
||||
@@ -39,11 +38,11 @@ const newestEntry = personal[0];
|
||||
|
||||
const [first, ...rest] = site.name.split(' ');
|
||||
const surname = rest.join(' ');
|
||||
// The "or wander through everything at once" pill (and its G shortcut, which reads the pill's link) is
|
||||
// The "or wander through everything at once" pill (and its G shortcut in scripts/gate.ts, which reads the pill's link) is
|
||||
// hidden for now, on the owner's request; see docs/TODO.md. /everything/ itself stays up.
|
||||
const SHOW_EVERYTHING_PILL = false;
|
||||
---
|
||||
<Base title={site.name} description={site.tagline || `${site.name}: work and off the clock.`} theme="gate">
|
||||
<Base title={site.name} description={site.tagline || `${site.name}: work and off the clock.`} theme="gate" feed="all">
|
||||
<main class="gate" id="gate">
|
||||
<h1 class="sr-only">{site.name}</h1>
|
||||
<GateSide side="work" href="/work/" label="Work: technical portfolio" enter="enter work">
|
||||
@@ -135,5 +134,9 @@ const SHOW_EVERYTHING_PILL = false;
|
||||
</main>
|
||||
|
||||
<GateFooter email={site.links.email} github={site.links.github} linkedin={site.links.linkedin} />
|
||||
<Gate client:idle />
|
||||
<script>
|
||||
// The gate's keys, crosshair and cassette (spec 006: a plain page script, no React on the start page).
|
||||
import { initGate } from '../scripts/gate.ts';
|
||||
initGate();
|
||||
</script>
|
||||
</Base>
|
||||
@@ -2,7 +2,7 @@
|
||||
// An item's (or an off-the-clock Ghost post's) own page: the same card as on the wall, already open.
|
||||
import PersonalLayout from '../../layouts/Personal.astro';
|
||||
import Card from '../../components/personal/Card.astro';
|
||||
import { toEntries, type Entry } from '../../components/personal/entries.ts';
|
||||
import { images, toEntries, type Entry } from '../../components/personal/entries.ts';
|
||||
import { getSiteData } from '../../data';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
@@ -19,8 +19,9 @@ const { entry, newer, older } = Astro.props as { entry: Entry; newer: Entry | nu
|
||||
const { site } = await getSiteData();
|
||||
const first = site.name.split(' ')[0] ?? site.name;
|
||||
const description = entry.line || entry.excerpt || entry.title;
|
||||
const picture = images(entry)[0]; // the link preview's image (spec 007)
|
||||
---
|
||||
<PersonalLayout title={`${entry.title} · ${site.name}`} description={description.slice(0, 200)} site={site}>
|
||||
<PersonalLayout title={`${entry.title} · ${site.name}`} description={description.slice(0, 200)} site={site} type="article" image={picture?.file ? { file: picture.file, alt: picture.alt } : undefined}>
|
||||
<div class="item-page">
|
||||
<a class="up" href="/off-the-clock/">← the whole wall</a>
|
||||
{entry.ai && (
|
||||
|
||||
@@ -22,7 +22,7 @@ const contact = [
|
||||
links.bluesky && { label: 'Bluesky', text: host(links.bluesky), href: links.bluesky },
|
||||
].filter((c): c is { label: string; text: string; href: string } => !!c);
|
||||
---
|
||||
<Base title={`${site.name} · the short version`} description={`${site.name}: ${site.tagline}`} theme="neutral" page="simple">
|
||||
<Base title={`${site.name} · the short version`} description={`${site.name}: ${site.tagline}`} theme="neutral" page="simple" feed="all">
|
||||
<div class="sm">
|
||||
<aside class="sm-side">
|
||||
<h1>{site.name}</h1>
|
||||
|
||||
@@ -16,6 +16,8 @@ const { site, projects, workPosts } = await getSiteData();
|
||||
<WorkLayout
|
||||
title={`${project.title} · ${site.name}`}
|
||||
description={project.summary}
|
||||
type="article"
|
||||
image={project.image}
|
||||
site={site}
|
||||
sections={workSections(site, projects, workPosts)}
|
||||
>
|
||||
|
||||
@@ -17,6 +17,7 @@ const topic = topicTag(post.tags);
|
||||
<WorkLayout
|
||||
title={`${post.title} · ${site.name}`}
|
||||
description={post.excerpt || post.title}
|
||||
type="article"
|
||||
site={site}
|
||||
sections={workSections(site, projects, workPosts)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// Adds behaviour to the start page; the HTML already works as two plain links without it (spec 006: a plain page
|
||||
// script, no React). Arrow keys lean toward a side, Enter goes in, Esc lets go, G opens everything (only while its
|
||||
// pill is on the page). Under (hover: hover) the work side shows a crosshair readout. The song sleeve plays only
|
||||
// after the visitor presses play.
|
||||
|
||||
export type Lean = 'work' | 'personal' | null;
|
||||
export type GateKey = {
|
||||
key: string;
|
||||
metaKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
altKey: boolean;
|
||||
/** Focus is in an input, textarea or select. */
|
||||
inField: boolean;
|
||||
/** Focus is on a link or button (Enter belongs to it). */
|
||||
onControl: boolean;
|
||||
};
|
||||
export type GateAction = { lean: Lean } | { go: 'work' | 'personal' | 'everything' } | null;
|
||||
|
||||
export function keyAction(k: GateKey, lean: Lean, hasEverything: boolean): GateAction {
|
||||
if (k.inField || k.metaKey || k.ctrlKey || k.altKey) return null;
|
||||
if (k.key === 'ArrowLeft') return { lean: 'work' };
|
||||
if (k.key === 'ArrowRight') return { lean: 'personal' };
|
||||
if (k.key === 'Escape') return { lean: null };
|
||||
if (k.key === 'Enter') return lean && !k.onControl ? { go: lean } : null;
|
||||
if ((k.key === 'g' || k.key === 'G') && hasEverything) return { go: 'everything' };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The crosshair readout: position in the work half, padded, and the 24px grid cell under the pointer. */
|
||||
export function readoutText(x: number, y: number): string {
|
||||
const pad = (n: number) => String(n).padStart(3, '0');
|
||||
return `x ${pad(x)} · y ${pad(y)}<br>cell ${Math.floor(x / 24)},${Math.floor(y / 24)}`;
|
||||
}
|
||||
|
||||
/* ---------- browser ---------- */
|
||||
|
||||
export function initGate() {
|
||||
const gate = document.getElementById('gate');
|
||||
if (!gate) return;
|
||||
const tech = document.getElementById('tech');
|
||||
const art = document.getElementById('art');
|
||||
const garden = document.getElementById('garden-link') as HTMLAnchorElement | null;
|
||||
const hrefOf = (side: 'work' | 'personal') => gate.querySelector<HTMLAnchorElement>(`[data-enter="${side}"]`)?.href;
|
||||
let lean: Lean = null;
|
||||
|
||||
function setLean(s: Lean) {
|
||||
lean = s;
|
||||
gate!.classList.toggle('lean-tech', s === 'work');
|
||||
gate!.classList.toggle('lean-art', s === 'personal');
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const t = e.target as HTMLElement | null;
|
||||
const action = keyAction(
|
||||
{
|
||||
key: e.key, metaKey: e.metaKey, ctrlKey: e.ctrlKey, altKey: e.altKey,
|
||||
inField: !!t?.closest?.('input, textarea, select'), onControl: !!t?.closest?.('a, button'),
|
||||
},
|
||||
lean,
|
||||
!!garden,
|
||||
);
|
||||
if (!action) return;
|
||||
if ('lean' in action) setLean(action.lean);
|
||||
else {
|
||||
const href = action.go === 'everything' ? garden?.href : hrefOf(action.go);
|
||||
if (href) location.href = href;
|
||||
}
|
||||
});
|
||||
|
||||
// The garden pill sits on the seam, wherever the sides have leaned to.
|
||||
function placeGarden() {
|
||||
if (!garden || !tech) return;
|
||||
garden.style.left = window.innerWidth > 820 ? `${tech.offsetWidth}px` : '';
|
||||
}
|
||||
placeGarden();
|
||||
if (tech && typeof ResizeObserver !== 'undefined') new ResizeObserver(placeGarden).observe(tech);
|
||||
window.addEventListener('resize', placeGarden);
|
||||
|
||||
// Crosshair readout, only for a visitor who hovers.
|
||||
if (tech && window.matchMedia('(hover: hover)').matches) {
|
||||
const h = document.getElementById('xh-h');
|
||||
const v = document.getElementById('xh-v');
|
||||
const readout = document.getElementById('readout');
|
||||
for (const el of [h, v, readout]) el?.removeAttribute('hidden');
|
||||
tech.addEventListener('mousemove', (e) => {
|
||||
const r = tech.getBoundingClientRect();
|
||||
const x = Math.round(e.clientX - r.left);
|
||||
const y = Math.round(e.clientY - r.top);
|
||||
if (h) h.style.top = `${y}px`;
|
||||
if (v) v.style.left = `${x}px`;
|
||||
if (readout) readout.innerHTML = readoutText(x, y);
|
||||
});
|
||||
}
|
||||
|
||||
// Song sleeve: the play button exists in the HTML but stays hidden until this runs.
|
||||
const plays = art ? Array.from(art.querySelectorAll<HTMLButtonElement>('[data-play]')) : [];
|
||||
for (const btn of plays) {
|
||||
btn.hidden = false;
|
||||
const sleeve = btn.closest('.tape');
|
||||
const audio = sleeve?.querySelector('audio');
|
||||
if (!sleeve || !audio) continue;
|
||||
const sync = () => {
|
||||
const on = !audio.paused;
|
||||
sleeve.classList.toggle('playing', on);
|
||||
btn.textContent = on ? '❚❚' : '▶';
|
||||
btn.setAttribute('aria-label', on ? 'Pause' : 'Play');
|
||||
};
|
||||
// play() rejects when the file is missing or the browser refuses; put the button back to "Play".
|
||||
btn.addEventListener('click', () => {
|
||||
if (audio.paused) audio.play().catch(() => { audio.pause(); sync(); });
|
||||
else audio.pause();
|
||||
});
|
||||
audio.addEventListener('play', sync);
|
||||
audio.addEventListener('pause', sync);
|
||||
audio.addEventListener('ended', sync);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// The "projects made" switch (PreferenceSwitch.astro): one plain implementation for every page that has it.
|
||||
// Choosing stores the preference, sets data-pref on <html> and fires `lw:pref`; the switch follows `lw:pref`
|
||||
// fired by anything else. The pressed state is painted before first paint by PAINT_SWITCH (see below).
|
||||
import { parsePreference, type Preference } from '../../core/ai.ts';
|
||||
import { applyPreference, browserStorage } from './preference.ts';
|
||||
|
||||
export const PREF_OPTIONS: ReadonlyArray<{ value: Preference; label: string }> = [
|
||||
{ value: 'any', label: 'any way' },
|
||||
{ value: 'not-fully-ai', label: 'not fully by AI' },
|
||||
{ value: 'by-hand-only', label: 'by hand only' },
|
||||
];
|
||||
|
||||
/** Presses the button for `pref` and shows the 2021-site line only for "by hand only". */
|
||||
export function paintSwitch(el: Element, pref: Preference) {
|
||||
for (const b of el.querySelectorAll('button[data-pref-value]')) {
|
||||
b.setAttribute('aria-pressed', String(b.getAttribute('data-pref-value') === pref));
|
||||
}
|
||||
const archive = el.querySelector<HTMLElement>('.pref-archive');
|
||||
if (archive) archive.hidden = pref !== 'by-hand-only';
|
||||
}
|
||||
|
||||
// Runs inline right after the switch markup, so the stored choice is pressed from the first paint. It reads the
|
||||
// data-pref the head script in Base.astro already validated, and cannot import: it mirrors paintSwitch (a unit
|
||||
// test keeps the two in step).
|
||||
export const PAINT_SWITCH =
|
||||
"(function(){var p=document.documentElement.dataset.pref||'any';var s=document.querySelectorAll('.pref-switch');" +
|
||||
"for(var i=0;i<s.length;i++){var b=s[i].querySelectorAll('button[data-pref-value]');" +
|
||||
"for(var j=0;j<b.length;j++)b[j].setAttribute('aria-pressed',String(b[j].getAttribute('data-pref-value')===p));" +
|
||||
"var a=s[i].querySelector('.pref-archive');if(a)a.hidden=p!=='by-hand-only';}})();";
|
||||
|
||||
/* ---------- browser ---------- */
|
||||
|
||||
const PREF_EVENT = 'lw:pref';
|
||||
|
||||
export function initPrefSwitches() {
|
||||
const switches = [...document.querySelectorAll('.pref-switch')];
|
||||
const paintAll = (pref: Preference) => switches.forEach((s) => paintSwitch(s, pref));
|
||||
paintAll(parsePreference(document.documentElement.dataset.pref));
|
||||
for (const s of switches) {
|
||||
for (const b of s.querySelectorAll<HTMLButtonElement>('button[data-pref-value]')) {
|
||||
b.addEventListener('click', () => {
|
||||
const pref = applyPreference(document.documentElement, browserStorage(), parsePreference(b.dataset.prefValue));
|
||||
window.dispatchEvent(new CustomEvent(PREF_EVENT, { detail: pref }));
|
||||
});
|
||||
}
|
||||
}
|
||||
// Our own clicks arrive here too; so does "show all" or anything else that changes the preference.
|
||||
window.addEventListener(PREF_EVENT, (e) => paintAll(parsePreference((e as CustomEvent).detail)));
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// The sitemap and the Atom feeds as strings (spec 007). Pure: no I/O, no dates from the clock, so the
|
||||
// same content gives the same bytes on every rebuild. Build-only; the browser never loads this.
|
||||
|
||||
/** Everything outside XML 1.0's Char production (control characters, lone surrogates, U+FFFE/U+FFFF). */
|
||||
const NOT_XML_CHAR = /[^\t\n\r\u0020-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/gu;
|
||||
|
||||
/** Text or attribute value for XML: drops characters XML cannot carry, escapes `& < > " '`. */
|
||||
export function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(NOT_XML_CHAR, '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
const PROLOG = '<?xml version="1.0" encoding="utf-8"?>\n';
|
||||
/** An empty feed still needs an `updated`; a fixed one keeps it stable. */
|
||||
const NO_DATE = '1970-01-01';
|
||||
|
||||
const day = (date: string): string => {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`feed entry date must be YYYY-MM-DD, got "${date}"`);
|
||||
return `${date}T00:00:00Z`;
|
||||
};
|
||||
|
||||
export type AtomEntry = {
|
||||
title: string;
|
||||
url: string; // absolute; also the entry's id
|
||||
date: string; // YYYY-MM-DD
|
||||
summary?: string;
|
||||
category?: { term: string; label: string };
|
||||
};
|
||||
|
||||
export type AtomFeed = {
|
||||
title: string;
|
||||
selfUrl: string; // the feed's own absolute URL; also its id
|
||||
pageUrl: string; // the HTML page the feed follows
|
||||
author: string;
|
||||
entries: AtomEntry[];
|
||||
};
|
||||
|
||||
/** An Atom 1.0 feed, newest entry first; the feed's `updated` is its newest entry's day. */
|
||||
export function atomFeed(feed: AtomFeed): string {
|
||||
const entries = [...feed.entries].sort((a, b) => b.date.localeCompare(a.date));
|
||||
const e = escapeXml;
|
||||
const lines = [
|
||||
'<feed xmlns="http://www.w3.org/2005/Atom">',
|
||||
` <title>${e(feed.title)}</title>`,
|
||||
` <id>${e(feed.selfUrl)}</id>`,
|
||||
` <link rel="self" type="application/atom+xml" href="${e(feed.selfUrl)}"/>`,
|
||||
` <link rel="alternate" type="text/html" href="${e(feed.pageUrl)}"/>`,
|
||||
` <updated>${day(entries[0]?.date ?? NO_DATE)}</updated>`,
|
||||
` <author><name>${e(feed.author)}</name></author>`,
|
||||
...entries.flatMap((x) => [
|
||||
' <entry>',
|
||||
` <title>${e(x.title)}</title>`,
|
||||
` <id>${e(x.url)}</id>`,
|
||||
` <link rel="alternate" type="text/html" href="${e(x.url)}"/>`,
|
||||
` <updated>${day(x.date)}</updated>`,
|
||||
...(x.summary ? [` <summary type="text">${e(x.summary)}</summary>`] : []),
|
||||
...(x.category ? [` <category term="${e(x.category.term)}" label="${e(x.category.label)}"/>`] : []),
|
||||
' </entry>',
|
||||
]),
|
||||
'</feed>',
|
||||
];
|
||||
return `${PROLOG}${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
/** A sitemap listing `urls` (absolute) in the order given. */
|
||||
export function sitemapXml(urls: string[]): string {
|
||||
const lines = [
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
...urls.map((u) => ` <url><loc>${escapeXml(u)}</loc></url>`),
|
||||
'</urlset>',
|
||||
];
|
||||
return `${PROLOG}${lines.join('\n')}\n`;
|
||||
}
|
||||
+14
-4
@@ -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<string, string | undefined>, f: typeof fetch): Promise<Post[]> {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
import { loadSources } from '../../src/sources/index.ts';
|
||||
import { loadSiteData, type SiteData } from '../../src/site/data.ts';
|
||||
import { feedEntries } from '../../src/site/feeds.ts';
|
||||
import { build, page } from './fixture-sites.ts';
|
||||
import { child, childrenNamed, parseXml } from './xml-check.ts';
|
||||
|
||||
// Spec 007: the fixture site built with a synthetic SITE_URL. One item's title is made hostile (in a copy of
|
||||
// the fixtures) so escaping is checked end to end.
|
||||
const SITE = 'https://example.invalid';
|
||||
const HOSTILE = `Edge & <case> "quoted" it's`;
|
||||
let out: string;
|
||||
let data: SiteData;
|
||||
|
||||
beforeAll(async () => {
|
||||
const content = mkdtempSync(join(tmpdir(), 'site-content-'));
|
||||
cpSync('fixtures/content', content, { recursive: true });
|
||||
const item = join(content, 'items', 'edge-long-title.md');
|
||||
writeFileSync(item, readFileSync(item, 'utf8').replace(/^title: .*$/m, `title: '${HOSTILE.replace(/'/g, "''")}'`));
|
||||
out = mkdtempSync(join(tmpdir(), 'site-build-meta-'));
|
||||
build(content, out, { SOURCES_FIXTURES: 'fixtures/sources', SITE_URL: SITE });
|
||||
|
||||
const cacheDir = mkdtempSync(join(tmpdir(), 'site-cache-'));
|
||||
const sources = await loadSources({ cacheDir, env: { SOURCES_FIXTURES: 'fixtures/sources' } });
|
||||
writeFileSync(join(cacheDir, 'sources.json'), JSON.stringify(sources));
|
||||
data = await loadSiteData(content, cacheDir, undefined, undefined, SITE);
|
||||
}, 300_000);
|
||||
|
||||
const decode = (s: string) =>
|
||||
s.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||
|
||||
type Tag = Record<string, string>;
|
||||
/** The <link> and <meta> tags of a page's head, attributes decoded. */
|
||||
function headTags(html: string): Tag[] {
|
||||
const head = html.slice(html.indexOf('<head>'), html.indexOf('</head>'));
|
||||
// Attribute values are double-quoted and may hold a raw `<` or `>` (valid HTML), so match them whole.
|
||||
return [...head.matchAll(/<(link|meta)\b((?:\s+[\w:-]+(?:="[^"]*")?)*)\s*\/?>/g)].map((m) => {
|
||||
const attrs: Tag = { tag: m[1]! };
|
||||
for (const a of m[2]!.matchAll(/([\w:-]+)="([^"]*)"/g)) attrs[a[1]!] = decode(a[2]!);
|
||||
return attrs;
|
||||
});
|
||||
}
|
||||
const prop = (tags: Tag[], name: string) => tags.filter((t) => t.property === name || t.name === name).map((t) => t.content);
|
||||
const links = (tags: Tag[], rel: string) => tags.filter((t) => t.tag === 'link' && t.rel === rel);
|
||||
const titleOf = (html: string) => decode(html.match(/<title>([^<]*)<\/title>/)![1]!);
|
||||
|
||||
/** Every built HTML page as its served path; the 404 (`404.html`) as `/404/`. */
|
||||
function builtPages(root: string): string[] {
|
||||
const found: string[] = [];
|
||||
const walk = (dir: string) => {
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) walk(join(dir, e.name));
|
||||
else if (e.name === 'index.html') found.push(relative(root, dir) ? `/${relative(root, dir).split(sep).join('/')}/` : '/');
|
||||
else if (e.name.endsWith('.html')) found.push(`/${e.name.replace(/\.html$/, '')}/`);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return found.sort();
|
||||
}
|
||||
const fileOf = (path: string) => join(path.slice(1), 'index.html');
|
||||
|
||||
describe('link previews on every page', () => {
|
||||
test('canonical and og:url are the page address; og:title and og:description are its title and description', () => {
|
||||
const pages = builtPages(out).filter((p) => p !== '/404/');
|
||||
expect(pages.length).toBeGreaterThan(20);
|
||||
for (const p of pages) {
|
||||
const html = page(out, fileOf(p));
|
||||
const tags = headTags(html);
|
||||
expect(links(tags, 'canonical').map((l) => l.href)).toEqual([`${SITE}${p}`]);
|
||||
expect(prop(tags, 'og:url')).toEqual([`${SITE}${p}`]);
|
||||
expect(prop(tags, 'og:title')).toEqual([titleOf(html)]);
|
||||
expect(prop(tags, 'og:description')).toEqual(prop(tags, 'description'));
|
||||
expect(prop(tags, 'og:site_name')).toEqual([data.site.name]);
|
||||
expect(prop(tags, 'robots')).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test('og:image exactly where the page has its own (raster) image, with the large card', () => {
|
||||
const expected = new Map<string, string>();
|
||||
for (const p of data.projects) if (p.image) expected.set(`/work/${p.id}/`, p.image.file);
|
||||
for (const i of data.items) {
|
||||
const img = i.media.find((m) => m.file && /\.(png|jpe?g|gif|webp|avif|svg)$/i.test(m.file));
|
||||
if (img && !img.file!.endsWith('.svg')) expected.set(`/off-the-clock/${i.id}/`, img.file!);
|
||||
}
|
||||
// The fixtures have both: pages whose first image is a PNG, and pages whose first image is an SVG.
|
||||
expect(expected.size).toBeGreaterThanOrEqual(3);
|
||||
for (const p of builtPages(out).filter((x) => x !== '/404/')) {
|
||||
const tags = headTags(page(out, fileOf(p)));
|
||||
const file = expected.get(p);
|
||||
expect({ p, image: prop(tags, 'og:image') }).toEqual({ p, image: file ? [`${SITE}/media/${file}`] : [] });
|
||||
expect({ p, card: prop(tags, 'twitter:card') }).toEqual({ p, card: [file ? 'summary_large_image' : 'summary'] });
|
||||
}
|
||||
});
|
||||
|
||||
test('a page of each kind: its type and its feed', () => {
|
||||
const kinds: [string, 'website' | 'article', string][] = [
|
||||
['/', 'website', '/feed.xml'],
|
||||
['/everything/', 'website', '/feed.xml'],
|
||||
['/simple/', 'website', '/feed.xml'],
|
||||
['/work/', 'website', '/work/feed.xml'],
|
||||
['/work/sample-garden-bot/', 'article', '/work/feed.xml'],
|
||||
['/work/fixture-dashboard/', 'article', '/work/feed.xml'],
|
||||
['/work/writing/synthetic-work-post/', 'article', '/work/feed.xml'],
|
||||
['/off-the-clock/', 'website', '/off-the-clock/feed.xml'],
|
||||
['/off-the-clock/fixture-comic/', 'article', '/off-the-clock/feed.xml'],
|
||||
['/off-the-clock/edge-long-title/', 'article', '/off-the-clock/feed.xml'],
|
||||
['/off-the-clock/synthetic-essay/', 'article', '/off-the-clock/feed.xml'],
|
||||
['/off-the-clock/issues/', 'website', '/off-the-clock/feed.xml'],
|
||||
['/off-the-clock/issues/2025-h2/', 'website', '/off-the-clock/feed.xml'],
|
||||
];
|
||||
for (const [p, type, feed] of kinds) {
|
||||
const tags = headTags(page(out, fileOf(p)));
|
||||
expect({ p, type: prop(tags, 'og:type') }).toEqual({ p, type: [type] });
|
||||
const alt = links(tags, 'alternate');
|
||||
expect({ p, feeds: alt.map((l) => [l.type, l.href]) }).toEqual({ p, feeds: [['application/atom+xml', `${SITE}${feed}`]] });
|
||||
expect(existsSync(join(out, feed.slice(1)))).toBe(true);
|
||||
}
|
||||
// A hostile title reaches the head as text.
|
||||
const hostile = headTags(page(out, fileOf('/off-the-clock/edge-long-title/')));
|
||||
expect(prop(hostile, 'og:title')).toEqual([`${HOSTILE} · ${data.site.name}`]);
|
||||
// Astro leaves `<` raw inside a double-quoted attribute (valid HTML); it must never appear as markup.
|
||||
expect(page(out, fileOf('/off-the-clock/edge-long-title/')).replace(/="[^"]*"/g, '=""')).not.toContain('<case>');
|
||||
});
|
||||
|
||||
test('the 404 is noindex and carries no preview, canonical or feed', () => {
|
||||
const tags = headTags(page(out, '404.html'));
|
||||
expect(prop(tags, 'robots')).toEqual(['noindex']);
|
||||
expect(tags.some((t) => t.property?.startsWith('og:') || t.name === 'twitter:card')).toBe(false);
|
||||
expect(links(tags, 'canonical')).toEqual([]);
|
||||
expect(links(tags, 'alternate')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sitemap', () => {
|
||||
test('lists exactly the built pages, except the 404, with absolute URLs', () => {
|
||||
const root = parseXml(page(out, 'sitemap.xml'));
|
||||
const locs = childrenNamed(root, 'url').map((u) => child(u, 'loc')!.text);
|
||||
expect(new Set(locs).size).toBe(locs.length);
|
||||
expect([...locs].sort()).toEqual(builtPages(out).filter((p) => p !== '/404/').map((p) => `${SITE}${p}`).sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('feeds', () => {
|
||||
const cases = [
|
||||
['feed.xml', 'all', (d) => d.site.name],
|
||||
['work/feed.xml', 'work', (d) => `Work · ${d.site.name}`],
|
||||
['off-the-clock/feed.xml', 'off-the-clock', (d) => `Off the clock · ${d.site.name}`],
|
||||
] as const satisfies readonly (readonly [string, 'all' | 'work' | 'off-the-clock', (d: SiteData) => string])[];
|
||||
|
||||
test('each is well-formed Atom with the expected entries, order, ids, dates and categories', () => {
|
||||
for (const [file, name, title] of cases) {
|
||||
const root = parseXml(page(out, file));
|
||||
expect(child(root, 'title')!.text).toBe(title(data));
|
||||
expect(child(root, 'id')!.text).toBe(`${SITE}/${file}`);
|
||||
const want = feedEntries(data, name);
|
||||
expect(want.length).toBeGreaterThan(0);
|
||||
expect(child(root, 'updated')!.text).toBe(`${want[0]!.date}T00:00:00Z`);
|
||||
const entries = childrenNamed(root, 'entry');
|
||||
expect(entries.map((e) => child(e, 'id')!.text)).toEqual(want.map((w) => `${SITE}${w.path}`));
|
||||
expect(entries.map((e) => child(e, 'link')!.attrs.href)).toEqual(want.map((w) => `${SITE}${w.path}`));
|
||||
expect(entries.map((e) => child(e, 'title')!.text)).toEqual(want.map((w) => w.title));
|
||||
expect(entries.map((e) => child(e, 'updated')!.text)).toEqual(want.map((w) => `${w.date}T00:00:00Z`));
|
||||
expect(entries.map((e) => child(e, 'category')?.attrs.term ?? null)).toEqual(want.map((w) => w.ai));
|
||||
expect(entries.map((e) => child(e, 'summary')?.text)).toEqual(want.map((w) => w.summary));
|
||||
// Every entry is a page the site built.
|
||||
for (const w of want) expect(existsSync(join(out, fileOf(w.path)))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('the hostile title is escaped and reads back as written', () => {
|
||||
const raw = page(out, 'off-the-clock/feed.xml');
|
||||
expect(raw).not.toContain('<case>');
|
||||
const titles = childrenNamed(parseXml(raw), 'entry').map((e) => child(e, 'title')!.text);
|
||||
expect(titles).toContain(HOSTILE);
|
||||
expect(childrenNamed(parseXml(page(out, 'feed.xml')), 'entry').map((e) => child(e, 'title')!.text)).toContain(HOSTILE);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { issueId } from '../../src/core/issues.ts';
|
||||
import { loadContent } from '../../src/library/load.ts';
|
||||
import { buildFixtureSites, page } from './fixture-sites.ts';
|
||||
import { PAINT_SWITCH } from '../../src/site/scripts/pref-switch.ts';
|
||||
|
||||
// Builds the fixture sites once and checks the issue pages, /everything/ and /simple/.
|
||||
let out: string;
|
||||
@@ -84,6 +85,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('<title>Page not found · Example Person</title>');
|
||||
});
|
||||
|
||||
test('simple page has no script tags except analytics and preference', () => {
|
||||
const html = page(out, 'simple/index.html');
|
||||
const scripts = [...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/g)];
|
||||
@@ -111,3 +124,105 @@ describe('issues, everything and simple', () => {
|
||||
expect(html).not.toContain('undefined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('preference switch', () => {
|
||||
const htmlFiles = (root: string): string[] =>
|
||||
(readdirSync(root, { recursive: true }) as string[]).filter((f) => f.endsWith('.html')).map((f) => join(root, f));
|
||||
|
||||
test('every .pref-switch has the markup contract and is painted before first paint', () => {
|
||||
const withSwitch: string[] = [];
|
||||
for (const file of htmlFiles(out)) {
|
||||
const html = readFileSync(file, 'utf8');
|
||||
const switches = [...html.matchAll(/<div\b[^>]*\bclass="pref-switch"[^>]*>[\s\S]*?<\/div>/g)];
|
||||
if (switches.length === 0) continue;
|
||||
withSwitch.push(file.slice(out.length + 1));
|
||||
expect(switches.length).toBe(1);
|
||||
const m = switches[0]!;
|
||||
const sw = m[0];
|
||||
expect(sw).toMatch(/^<div\b[^>]*\brole="group"/);
|
||||
expect(sw).toMatch(/^<div\b[^>]*\baria-label="Projects made"/);
|
||||
expect(sw).toContain('<span class="pref-label">projects made:</span>');
|
||||
const buttons = [...sw.matchAll(/<button\b([^>]*)>([^<]*)<\/button>/g)];
|
||||
expect(buttons.map((b) => b[2])).toEqual(['any way', 'not fully by AI', 'by hand only']);
|
||||
expect(buttons.map((b) => b[1]!.match(/data-pref-value="([^"]+)"/)?.[1])).toEqual(['any', 'not-fully-ai', 'by-hand-only']);
|
||||
for (const b of buttons) {
|
||||
expect(b[1]).toContain('type="button"');
|
||||
expect(b[1]).toMatch(/aria-pressed="(true|false)"/);
|
||||
}
|
||||
// The 2021 line is in the HTML but hidden; the paint script shows it for "by hand only".
|
||||
expect(sw).toMatch(/<p\b[^>]*\bclass="pref-archive"[^>]*\bhidden\b[^>]*>Prefer work made without AI\? <a href="https:\/\/old\.lucaswinther\.info\/">The 2021 site was all by hand →<\/a><\/p>/);
|
||||
// Right after the markup: the inline script that presses the stored choice before first paint.
|
||||
const after = html.slice(m.index! + sw.length).match(/^\s*<script\b[^>]*>([\s\S]*?)<\/script>/);
|
||||
expect(after?.[1]).toBe(PAINT_SWITCH);
|
||||
}
|
||||
expect(withSwitch).toContain('index.html');
|
||||
expect(withSwitch).toContain(join('work', 'index.html'));
|
||||
// The switch is no React island anywhere.
|
||||
for (const file of htmlFiles(out)) expect(readFileSync(file, 'utf8')).not.toMatch(/component-url="[^"]*PreferenceSwitch/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('start page without React (spec 006)', () => {
|
||||
// Every JS file a page loads: its module scripts and island URLs, followed through their imports.
|
||||
function scriptsOf(root: string, path: string): Map<string, string> {
|
||||
const html = page(root, path);
|
||||
const queue = [
|
||||
...[...html.matchAll(/<script\b[^>]*\bsrc="(\/[^"]+)"/g)].map((m) => m[1]!),
|
||||
...[...html.matchAll(/\b(?:component|renderer)-url="(\/[^"]+)"/g)].map((m) => m[1]!),
|
||||
];
|
||||
const seen = new Map<string, string>();
|
||||
while (queue.length) {
|
||||
const url = queue.shift()!;
|
||||
if (seen.has(url)) continue;
|
||||
const src = readFileSync(join(root, url), 'utf8');
|
||||
seen.set(url, src);
|
||||
for (const m of src.matchAll(/(?:\bfrom|\bimport)\s*\(?\s*["'](\.{1,2}\/[^"']+\.js)["']/g)) queue.push(join(dirname(url), m[1]!));
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
const isReact = (src: string) => /react\.(transitional\.)?element|Minified React error/.test(src);
|
||||
|
||||
test('the start page has no island and loads nothing of the React runtime', () => {
|
||||
const html = page(out, 'index.html');
|
||||
expect(html.match(/<astro-island\b[^>]*>/g) ?? []).toEqual([]);
|
||||
expect(html.includes('renderer-url')).toBe(false);
|
||||
const scripts = scriptsOf(out, 'index.html');
|
||||
expect(scripts.size).toBeGreaterThan(0); // its behaviour comes from plain page scripts
|
||||
for (const [url, src] of scripts) {
|
||||
expect(url).not.toMatch(/\/(client|react|jsx-runtime)\./);
|
||||
expect(isReact(src), url).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('the work page still hydrates its islands with React', () => {
|
||||
const html = page(out, 'work/index.html');
|
||||
expect(html).toMatch(/<astro-island\b[^>]*component-url="[^"]*ProjectList/);
|
||||
expect([...scriptsOf(out, 'work/index.html').values()].some(isReact)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('without SITE_URL (spec 007)', () => {
|
||||
// Both fixture sites here are built without an address; build-meta.test.ts builds one with it.
|
||||
const files = (root: string): string[] =>
|
||||
readdirSync(root, { recursive: true, encoding: 'utf8' }).filter((f) => !f.startsWith('media'));
|
||||
|
||||
test('the build succeeds with no sitemap, no feeds and nothing in a head that needs an absolute URL', () => {
|
||||
for (const root of [out, sparseOut]) {
|
||||
expect(files(root).filter((f) => f.endsWith('.xml'))).toEqual([]);
|
||||
const pages = files(root).filter((f) => f.endsWith('.html'));
|
||||
expect(pages.length).toBeGreaterThan(root === out ? 20 : 5);
|
||||
for (const f of pages) {
|
||||
const html = page(root, f);
|
||||
const head = html.slice(0, html.indexOf('</head>'));
|
||||
expect(head).not.toContain('rel="canonical"');
|
||||
expect(head).not.toContain('og:url');
|
||||
expect(head).not.toContain('og:image');
|
||||
expect(head).not.toContain('application/atom+xml');
|
||||
expect(html).not.toMatch(/(feed|sitemap)\.xml/);
|
||||
// The preview text needs no address, so it is still there (except on the 404).
|
||||
if (f === '404.html') expect(head).toContain('<meta name="robots" content="noindex">');
|
||||
else expect(head).toMatch(/<meta property="og:title" content="[^"]+">/);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadSources } from '../../src/sources/index.ts';
|
||||
import { loadSiteData, type SiteData } from '../../src/site/data.ts';
|
||||
import { FEEDS, feedEntries, sitePaths, xmlFiles } from '../../src/site/feeds.ts';
|
||||
import { child, childrenNamed, parseXml } from './xml-check.ts';
|
||||
|
||||
const SITE = 'https://example.invalid';
|
||||
let data: SiteData;
|
||||
let noAddress: SiteData;
|
||||
|
||||
beforeAll(async () => {
|
||||
const cacheDir = mkdtempSync(join(tmpdir(), 'site-cache-'));
|
||||
const sources = await loadSources({ cacheDir, env: { SOURCES_FIXTURES: 'fixtures/sources' } });
|
||||
writeFileSync(join(cacheDir, 'sources.json'), JSON.stringify(sources));
|
||||
data = await loadSiteData('fixtures/content', cacheDir, '2026-01-15', undefined, `${SITE}/`);
|
||||
noAddress = await loadSiteData('fixtures/content', cacheDir, '2026-01-15', undefined, '');
|
||||
});
|
||||
|
||||
describe('site address in site data', () => {
|
||||
test('comes from the parameter (SITE_URL by default), normalised; blank means none', () => {
|
||||
expect(data.siteUrl).toBe(SITE);
|
||||
expect(noAddress.siteUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sitePaths', () => {
|
||||
test('every page the site builds, except the 404, once each', () => {
|
||||
const paths = sitePaths(data);
|
||||
expect(new Set(paths).size).toBe(paths.length);
|
||||
expect(paths.slice(0, 6)).toEqual(['/', '/work/', '/off-the-clock/', '/off-the-clock/issues/', '/everything/', '/simple/']);
|
||||
for (const p of data.projects) expect(paths).toContain(`/work/${p.id}/`);
|
||||
for (const p of data.workPosts) expect(paths).toContain(`/work/writing/${p.slug}/`);
|
||||
for (const i of data.items) expect(paths).toContain(`/off-the-clock/${i.id}/`);
|
||||
for (const p of data.sources.posts.filter((x) => x.side === 'off-the-clock')) expect(paths).toContain(`/off-the-clock/${p.slug}/`);
|
||||
for (const i of data.issues) expect(paths).toContain(`/off-the-clock/issues/${i.id}/`);
|
||||
expect(paths.length).toBe(6 + data.projects.length + data.workPosts.length + data.personal.length + data.issues.length);
|
||||
expect(paths.some((p) => p.includes('404'))).toBe(false);
|
||||
expect(paths.some((p) => p.includes('draft'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feedEntries', () => {
|
||||
test('work: projects and work writing, newest first, with their summaries and levels', () => {
|
||||
const entries = feedEntries(data, 'work');
|
||||
expect(entries.length).toBe(data.projects.length + data.workPosts.length);
|
||||
const dates = entries.map((e) => e.date);
|
||||
expect([...dates].sort().reverse()).toEqual(dates);
|
||||
const project = data.projects[0]!;
|
||||
expect(entries).toContainEqual({ title: project.title, path: `/work/${project.id}/`, date: project.date, summary: project.summary, ai: project.ai });
|
||||
const post = data.workPosts[0]!;
|
||||
expect(entries).toContainEqual({ title: post.title, path: `/work/writing/${post.slug}/`, date: post.date, summary: post.excerpt, ai: post.ai });
|
||||
expect(entries.some((e) => e.path.startsWith('/off-the-clock/'))).toBe(false);
|
||||
});
|
||||
|
||||
test("off the clock: the wall's items and off-the-clock posts; an item's line is its summary", () => {
|
||||
const entries = feedEntries(data, 'off-the-clock');
|
||||
expect(entries.length).toBe(data.personal.length);
|
||||
expect(entries.every((e) => e.path.startsWith('/off-the-clock/'))).toBe(true);
|
||||
const withLine = data.items.find((i) => i.line)!;
|
||||
expect(entries.find((e) => e.path === `/off-the-clock/${withLine.id}/`)).toEqual({
|
||||
title: withLine.title, path: `/off-the-clock/${withLine.id}/`, date: withLine.date, summary: withLine.line, ai: withLine.ai,
|
||||
});
|
||||
const noLine = data.items.find((i) => !i.line && i.ai === null)!;
|
||||
expect(entries.find((e) => e.path === `/off-the-clock/${noLine.id}/`)).toEqual({
|
||||
title: noLine.title, path: `/off-the-clock/${noLine.id}/`, date: noLine.date, ai: null,
|
||||
});
|
||||
// Live cards (Trakt, Hardcover, ListenBrainz) are not entries.
|
||||
const live = [data.sources.trakt.lastFilm?.title, ...data.sources.hardcover.reading.map((r) => r.title)].filter(Boolean);
|
||||
for (const t of live) expect(entries.some((e) => e.title === t)).toBe(false);
|
||||
});
|
||||
|
||||
test('all: both, and the same things /everything/ lists; never drafts', () => {
|
||||
const all = feedEntries(data, 'all');
|
||||
expect(all.length).toBe(data.everything.length);
|
||||
expect(new Set(all.map((e) => e.path))).toEqual(new Set(data.everything.map((e) => e.url)));
|
||||
expect(all.some((e) => /draft/i.test(e.path) || /draft/i.test(e.title))).toBe(false);
|
||||
// Ties on the date are ordered by path, so the order never depends on load order.
|
||||
for (let i = 1; i < all.length; i++) {
|
||||
const [a, b] = [all[i - 1]!, all[i]!];
|
||||
expect(a.date > b.date || (a.date === b.date && a.path < b.path)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('xmlFiles', () => {
|
||||
test('without a site address there are none', () => {
|
||||
expect(xmlFiles(noAddress)).toEqual({});
|
||||
});
|
||||
|
||||
test('the sitemap and three feeds, at their paths', () => {
|
||||
const files = xmlFiles(data);
|
||||
expect(Object.keys(files).sort()).toEqual(['feed', 'off-the-clock/feed', 'sitemap', 'work/feed']);
|
||||
expect(FEEDS.all.path).toBe('/feed.xml');
|
||||
expect(FEEDS.work.path).toBe('/work/feed.xml');
|
||||
expect(FEEDS['off-the-clock'].path).toBe('/off-the-clock/feed.xml');
|
||||
const sitemap = parseXml(files.sitemap!());
|
||||
expect(childrenNamed(sitemap, 'url').map((u) => child(u, 'loc')!.text)).toEqual(sitePaths(data).map((p) => `${SITE}${p}`));
|
||||
const work = parseXml(files['work/feed']!());
|
||||
expect(child(work, 'title')!.text).toBe(`Work · ${data.site.name}`);
|
||||
expect(child(work, 'id')!.text).toBe(`${SITE}/work/feed.xml`);
|
||||
const first = childrenNamed(work, 'entry')[0]!;
|
||||
const newest = feedEntries(data, 'work')[0]!;
|
||||
expect(child(first, 'id')!.text).toBe(`${SITE}${newest.path}`);
|
||||
expect(child(first, 'category')!.attrs.term).toBe(newest.ai!);
|
||||
expect(child(parseXml(files.feed!()), 'title')!.text).toBe(data.site.name);
|
||||
expect(child(parseXml(files['off-the-clock/feed']!()), 'title')!.text).toBe(`Off the clock · ${data.site.name}`);
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,11 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Builds the fixture sites with the real CLI (a child process keeps env and cwd changes out of the
|
||||
// test runner), for tests that check the rendered HTML.
|
||||
const SOURCE_ENV = ['GHOST_URL', 'GHOST_CONTENT_KEY', 'TRAKT_CLIENT_ID', 'HARDCOVER_TOKEN', 'SOURCES_FIXTURES'];
|
||||
// test runner), for tests that check the rendered HTML. Source settings and SITE_URL from the shell are
|
||||
// cleared; a test passes what it needs.
|
||||
const SOURCE_ENV = ['GHOST_URL', 'GHOST_CONTENT_KEY', 'TRAKT_CLIENT_ID', 'HARDCOVER_TOKEN', 'SOURCES_FIXTURES', 'SITE_URL'];
|
||||
|
||||
function build(content: string, target: string, extraEnv: Record<string, string>) {
|
||||
export function build(content: string, target: string, extraEnv: Record<string, string>) {
|
||||
const env: Record<string, string | undefined> = { ...process.env };
|
||||
for (const k of SOURCE_ENV) delete env[k];
|
||||
const r = Bun.spawnSync(['bun', 'src/cli.ts', 'build', content, target, mkdtempSync(join(tmpdir(), 'site-build-cache-'))], {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { keyAction, readoutText, type GateKey } from '../../src/site/scripts/gate.ts';
|
||||
|
||||
const key = (k: string, more: Partial<GateKey> = {}): GateKey => ({
|
||||
key: k, metaKey: false, ctrlKey: false, altKey: false, inField: false, onControl: false, ...more,
|
||||
});
|
||||
|
||||
test('arrows lean a side, Esc lets go', () => {
|
||||
expect(keyAction(key('ArrowLeft'), null, false)).toEqual({ lean: 'work' });
|
||||
expect(keyAction(key('ArrowRight'), 'work', false)).toEqual({ lean: 'personal' });
|
||||
expect(keyAction(key('Escape'), 'personal', false)).toEqual({ lean: null });
|
||||
});
|
||||
|
||||
test('Enter goes into the leaned side, unless nothing leans or focus is on a link or button', () => {
|
||||
expect(keyAction(key('Enter'), 'work', false)).toEqual({ go: 'work' });
|
||||
expect(keyAction(key('Enter'), 'personal', false)).toEqual({ go: 'personal' });
|
||||
expect(keyAction(key('Enter'), null, false)).toBeNull();
|
||||
expect(keyAction(key('Enter', { onControl: true }), 'work', false)).toBeNull();
|
||||
});
|
||||
|
||||
test('G opens everything only while its pill is on the page', () => {
|
||||
expect(keyAction(key('g'), null, true)).toEqual({ go: 'everything' });
|
||||
expect(keyAction(key('G'), 'work', true)).toEqual({ go: 'everything' });
|
||||
expect(keyAction(key('g'), null, false)).toBeNull();
|
||||
});
|
||||
|
||||
test('typing in a field or holding a modifier does nothing', () => {
|
||||
expect(keyAction(key('ArrowLeft', { inField: true }), null, true)).toBeNull();
|
||||
for (const mod of ['metaKey', 'ctrlKey', 'altKey'] as const) {
|
||||
expect(keyAction(key('ArrowLeft', { [mod]: true }), null, true)).toBeNull();
|
||||
expect(keyAction(key('g', { [mod]: true }), null, true)).toBeNull();
|
||||
}
|
||||
expect(keyAction(key('x'), 'work', true)).toBeNull();
|
||||
});
|
||||
|
||||
test('the crosshair readout pads the position and names the 24px cell', () => {
|
||||
expect(readoutText(7, 120)).toBe('x 007 · y 120<br>cell 0,5');
|
||||
expect(readoutText(1234, 0)).toBe('x 1234 · y 000<br>cell 51,0');
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { absoluteUrl, parseSiteUrl, previewTags, type PreviewInput } from '../../src/site/link-preview.ts';
|
||||
|
||||
describe('parseSiteUrl', () => {
|
||||
test('unset or blank means no address', () => {
|
||||
expect(parseSiteUrl(undefined)).toBeUndefined();
|
||||
expect(parseSiteUrl('')).toBeUndefined();
|
||||
expect(parseSiteUrl(' ')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('an http(s) origin, without the trailing slash', () => {
|
||||
expect(parseSiteUrl('https://example.invalid')).toBe('https://example.invalid');
|
||||
expect(parseSiteUrl(' https://example.invalid/ ')).toBe('https://example.invalid');
|
||||
expect(parseSiteUrl('http://localhost:8080/')).toBe('http://localhost:8080');
|
||||
expect(parseSiteUrl('HTTPS://Example.Invalid')).toBe('https://example.invalid');
|
||||
});
|
||||
|
||||
test('anything else is refused loudly rather than guessed at', () => {
|
||||
for (const bad of ['example.invalid', 'ftp://example.invalid', 'https://example.invalid/sub/', 'https://example.invalid/?a=1', 'https://example.invalid/#x']) {
|
||||
expect(() => parseSiteUrl(bad)).toThrow(/SITE_URL/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('absoluteUrl', () => {
|
||||
test('joins the site address and a root-relative path', () => {
|
||||
expect(absoluteUrl('https://example.invalid', '/')).toBe('https://example.invalid/');
|
||||
expect(absoluteUrl('https://example.invalid', '/work/a/')).toBe('https://example.invalid/work/a/');
|
||||
expect(absoluteUrl('https://example.invalid', '/media/a b.png')).toBe('https://example.invalid/media/a%20b.png');
|
||||
});
|
||||
});
|
||||
|
||||
const base: PreviewInput = {
|
||||
siteUrl: 'https://example.invalid',
|
||||
siteName: 'Example Person',
|
||||
path: '/work/demo/',
|
||||
title: 'Demo · Example Person',
|
||||
description: 'A demo project.',
|
||||
type: 'article',
|
||||
feed: { path: '/work/feed.xml', title: 'Work · Example Person' },
|
||||
};
|
||||
const render = (input: PreviewInput) => previewTags(input).map(({ tag, attrs }) => [tag, attrs] as const);
|
||||
|
||||
describe('previewTags', () => {
|
||||
test('a page without its own image: canonical, Open Graph, a summary card, its feed', () => {
|
||||
expect(render(base)).toEqual([
|
||||
['link', { rel: 'canonical', href: 'https://example.invalid/work/demo/' }],
|
||||
['meta', { property: 'og:title', content: 'Demo · Example Person' }],
|
||||
['meta', { property: 'og:description', content: 'A demo project.' }],
|
||||
['meta', { property: 'og:type', content: 'article' }],
|
||||
['meta', { property: 'og:site_name', content: 'Example Person' }],
|
||||
['meta', { property: 'og:url', content: 'https://example.invalid/work/demo/' }],
|
||||
['meta', { name: 'twitter:card', content: 'summary' }],
|
||||
['link', { rel: 'alternate', type: 'application/atom+xml', title: 'Work · Example Person', href: 'https://example.invalid/work/feed.xml' }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a page with its own image: og:image (and its alt) and a large card', () => {
|
||||
const tags = render({ ...base, image: { path: '/media/shot.png', alt: 'A screenshot' } });
|
||||
expect(tags).toContainEqual(['meta', { property: 'og:image', content: 'https://example.invalid/media/shot.png' }]);
|
||||
expect(tags).toContainEqual(['meta', { property: 'og:image:alt', content: 'A screenshot' }]);
|
||||
expect(tags).toContainEqual(['meta', { name: 'twitter:card', content: 'summary_large_image' }]);
|
||||
expect(tags.filter(([, a]) => a.name === 'twitter:card')).toHaveLength(1);
|
||||
// An image without alt text gets no og:image:alt.
|
||||
expect(render({ ...base, image: { path: '/media/shot.png', alt: '' } }).some(([, a]) => a.property === 'og:image:alt')).toBe(false);
|
||||
});
|
||||
|
||||
test('an SVG is no preview image (link previews take raster images only): no og:image, a summary card', () => {
|
||||
const tags = render({ ...base, image: { path: '/media/drawing.SVG', alt: 'A drawing' } });
|
||||
expect(tags.some(([, a]) => a.property?.startsWith('og:image'))).toBe(false);
|
||||
expect(tags).toContainEqual(['meta', { name: 'twitter:card', content: 'summary' }]);
|
||||
});
|
||||
|
||||
test('without a site address: nothing absolute, so no canonical, og:url, og:image or feed link', () => {
|
||||
const tags = render({ ...base, siteUrl: undefined, image: { path: '/media/shot.png', alt: 'A screenshot' } });
|
||||
expect(tags).toEqual([
|
||||
['meta', { property: 'og:title', content: 'Demo · Example Person' }],
|
||||
['meta', { property: 'og:description', content: 'A demo project.' }],
|
||||
['meta', { property: 'og:type', content: 'article' }],
|
||||
['meta', { property: 'og:site_name', content: 'Example Person' }],
|
||||
['meta', { name: 'twitter:card', content: 'summary' }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a page without a feed links none', () => {
|
||||
expect(render({ ...base, feed: undefined }).some(([, a]) => a.rel === 'alternate')).toBe(false);
|
||||
});
|
||||
|
||||
test('noindex (the 404): only the robots tag, nothing else', () => {
|
||||
expect(render({ ...base, image: { path: '/media/shot.png', alt: 'x' }, noindex: true })).toEqual([
|
||||
['meta', { name: 'robots', content: 'noindex' }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { PAINT_SWITCH, PREF_OPTIONS, paintSwitch } from '../../src/site/scripts/pref-switch.ts';
|
||||
|
||||
// A stand-in for the rendered switch: buttons carrying data-pref-value, and the archive line.
|
||||
function fakeSwitch() {
|
||||
const buttons = PREF_OPTIONS.map((o) => {
|
||||
const attrs: Record<string, string> = { 'data-pref-value': o.value, 'aria-pressed': String(o.value === 'any') };
|
||||
return {
|
||||
attrs,
|
||||
getAttribute: (k: string) => attrs[k] ?? null,
|
||||
setAttribute: (k: string, v: string) => {
|
||||
attrs[k] = v;
|
||||
},
|
||||
};
|
||||
});
|
||||
const archive = { hidden: true };
|
||||
return {
|
||||
buttons,
|
||||
archive,
|
||||
querySelectorAll: (sel: string) => (sel === 'button[data-pref-value]' ? buttons : []),
|
||||
querySelector: (sel: string) => (sel === '.pref-archive' ? archive : null),
|
||||
pressed: () => buttons.filter((b) => b.attrs['aria-pressed'] === 'true').map((b) => b.attrs['data-pref-value']),
|
||||
};
|
||||
}
|
||||
|
||||
// The inline first-paint script, run against a fake document holding two switches.
|
||||
function runPaintScript(pref: string | undefined) {
|
||||
const switches = [fakeSwitch(), fakeSwitch()];
|
||||
const doc = {
|
||||
documentElement: { dataset: pref === undefined ? {} : { pref } },
|
||||
querySelectorAll: (sel: string) => (sel === '.pref-switch' ? switches : []),
|
||||
};
|
||||
new Function('document', PAINT_SWITCH)(doc);
|
||||
return switches;
|
||||
}
|
||||
|
||||
const CASES: Array<[string | undefined, string, boolean]> = [
|
||||
['any', 'any', true],
|
||||
['not-fully-ai', 'not-fully-ai', true],
|
||||
['by-hand-only', 'by-hand-only', false],
|
||||
[undefined, 'any', true], // storage blocked in the head script: no data-pref
|
||||
];
|
||||
|
||||
test('the options are the three preferences with their labels', () => {
|
||||
expect(PREF_OPTIONS.map((o) => [o.value, o.label])).toEqual([
|
||||
['any', 'any way'],
|
||||
['not-fully-ai', 'not fully by AI'],
|
||||
['by-hand-only', 'by hand only'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('the first-paint script presses the stored choice on every switch; the archive line only for by hand only', () => {
|
||||
for (const [pref, pressed, archiveHidden] of CASES) {
|
||||
for (const s of runPaintScript(pref)) {
|
||||
expect(s.pressed()).toEqual([pressed]);
|
||||
expect(s.archive.hidden).toBe(archiveHidden);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('paintSwitch draws the same state as the first-paint script', () => {
|
||||
for (const [pref, pressed, archiveHidden] of CASES) {
|
||||
if (pref === undefined) continue;
|
||||
const s = fakeSwitch();
|
||||
paintSwitch(s as unknown as Element, pref as 'any');
|
||||
expect(s.pressed()).toEqual([pressed]);
|
||||
expect(s.archive.hidden).toBe(archiveHidden);
|
||||
}
|
||||
});
|
||||
|
||||
test('the first-paint script imports nothing and stays tiny', () => {
|
||||
expect(PAINT_SWITCH).not.toMatch(/\bimport\b|\bexport\b|=>|\blet\b|\bconst\b/);
|
||||
expect(PAINT_SWITCH.length).toBeLessThan(600);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// A small, strict XML reader for tests: throws on anything that is not well-formed (unbalanced or
|
||||
// mis-nested tags, unquoted or duplicate attributes, a bare `&` or `<`, unknown entities, characters XML
|
||||
// forbids, text outside the root) and returns the element tree with entities decoded. Enough for the
|
||||
// sitemap and the feeds; no DTDs, CDATA or comments, because those files never contain any.
|
||||
export type XmlNode = { name: string; attrs: Record<string, string>; children: XmlNode[]; text: string };
|
||||
|
||||
const NAME = /^[A-Za-z_][\w.:-]*/;
|
||||
const ENTITY = /&(amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);/y;
|
||||
const FORBIDDEN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/;
|
||||
|
||||
function decode(raw: string, where: string): string {
|
||||
if (raw.includes('<')) throw new Error(`raw "<" in ${where}`);
|
||||
let out = '';
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
if (raw[i] !== '&') { out += raw[i]; continue; }
|
||||
ENTITY.lastIndex = i;
|
||||
const m = ENTITY.exec(raw);
|
||||
if (!m) throw new Error(`bare "&" in ${where}: ${raw.slice(i, i + 12)}`);
|
||||
const e = m[1]!;
|
||||
out += e === 'amp' ? '&' : e === 'lt' ? '<' : e === 'gt' ? '>' : e === 'quot' ? '"' : e === 'apos' ? "'"
|
||||
: String.fromCodePoint(e[1] === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10));
|
||||
i += m[0].length - 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseXml(src: string): XmlNode {
|
||||
if (FORBIDDEN.test(src)) throw new Error('character XML forbids');
|
||||
let i = 0;
|
||||
const prolog = src.match(/^<\?xml\s[^?]*\?>/);
|
||||
if (prolog) i = prolog[0].length;
|
||||
const stack: XmlNode[] = [];
|
||||
let root: XmlNode | undefined;
|
||||
while (i < src.length) {
|
||||
const lt = src.indexOf('<', i);
|
||||
const text = src.slice(i, lt === -1 ? src.length : lt);
|
||||
if (stack.length) stack.at(-1)!.text += decode(text, `<${stack.at(-1)!.name}>`);
|
||||
else if (text.trim()) throw new Error(`text outside the root: ${text.trim().slice(0, 20)}`);
|
||||
if (lt === -1) break;
|
||||
const gt = src.indexOf('>', lt);
|
||||
if (gt === -1) throw new Error('unclosed tag');
|
||||
const tag = src.slice(lt + 1, gt);
|
||||
i = gt + 1;
|
||||
if (tag.startsWith('/')) {
|
||||
const name = tag.slice(1).trim();
|
||||
const open = stack.pop();
|
||||
if (!open || open.name !== name) throw new Error(`</${name}> closes <${open?.name ?? 'nothing'}>`);
|
||||
continue;
|
||||
}
|
||||
const selfClosing = tag.endsWith('/');
|
||||
const body = selfClosing ? tag.slice(0, -1) : tag;
|
||||
const name = body.match(NAME)?.[0];
|
||||
if (!name) throw new Error(`bad tag <${tag}>`);
|
||||
const node: XmlNode = { name, attrs: {}, children: [], text: '' };
|
||||
let rest = body.slice(name.length);
|
||||
while (rest.trim()) {
|
||||
const a = rest.match(/^\s+([A-Za-z_][\w.:-]*)\s*=\s*("([^"]*)"|'([^']*)')/);
|
||||
if (!a) throw new Error(`bad attribute in <${name}>: ${rest.trim()}`);
|
||||
if (a[1]! in node.attrs) throw new Error(`duplicate attribute ${a[1]} in <${name}>`);
|
||||
node.attrs[a[1]!] = decode(a[3] ?? a[4] ?? '', `${name}@${a[1]}`);
|
||||
rest = rest.slice(a[0].length);
|
||||
}
|
||||
if (stack.length) stack.at(-1)!.children.push(node);
|
||||
else if (root) throw new Error('second root element');
|
||||
else root = node;
|
||||
if (!selfClosing) stack.push(node);
|
||||
}
|
||||
if (stack.length) throw new Error(`<${stack.at(-1)!.name}> is never closed`);
|
||||
if (!root) throw new Error('no root element');
|
||||
return root;
|
||||
}
|
||||
|
||||
export const childrenNamed = (n: XmlNode, name: string) => n.children.filter((c) => c.name === name);
|
||||
export const child = (n: XmlNode, name: string) => n.children.find((c) => c.name === name);
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { atomFeed, escapeXml, sitemapXml, type AtomFeed } from '../../src/site/xml.ts';
|
||||
import { child, childrenNamed, parseXml } from './xml-check.ts';
|
||||
|
||||
const HOSTILE = `Tom & Jerry <script>alert("hi")</script> it's 'quoted' ]]> &`;
|
||||
|
||||
describe('escapeXml', () => {
|
||||
test('escapes the five special characters', () => {
|
||||
expect(escapeXml(`a & b < c > d " e ' f`)).toBe('a & b < c > d " e ' f');
|
||||
});
|
||||
|
||||
test('drops characters XML 1.0 forbids, keeps tabs, newlines and other text', () => {
|
||||
expect(escapeXml('a\u0000b\u0007c\u000Bd\uFFFEe\tf\ng\rh ø 😀')).toBe('abcde\tf\ng\rh ø 😀');
|
||||
});
|
||||
|
||||
test('already escaped text is escaped again (no double-decoding)', () => {
|
||||
expect(escapeXml('&')).toBe('&amp;');
|
||||
});
|
||||
});
|
||||
|
||||
const feed = (over: Partial<AtomFeed> = {}): AtomFeed => ({
|
||||
title: 'Work · Example Person',
|
||||
selfUrl: 'https://example.invalid/work/feed.xml',
|
||||
pageUrl: 'https://example.invalid/work/',
|
||||
author: 'Example Person',
|
||||
entries: [
|
||||
{ title: 'Older', url: 'https://example.invalid/work/older/', date: '2024-02-03', summary: 'The older one.' },
|
||||
{
|
||||
title: 'Newest', url: 'https://example.invalid/work/newest/', date: '2025-06-07', summary: 'The newest one.',
|
||||
category: { term: 'some-ai', label: 'some AI' },
|
||||
},
|
||||
{ title: 'Middle', url: 'https://example.invalid/work/middle/', date: '2024-11-30' },
|
||||
],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('atomFeed', () => {
|
||||
test('is a well-formed Atom feed with the feed-level fields', () => {
|
||||
const xml = atomFeed(feed());
|
||||
expect(xml.startsWith('<?xml version="1.0" encoding="utf-8"?>\n')).toBe(true);
|
||||
const root = parseXml(xml);
|
||||
expect(root.name).toBe('feed');
|
||||
expect(root.attrs.xmlns).toBe('http://www.w3.org/2005/Atom');
|
||||
expect(child(root, 'title')!.text).toBe('Work · Example Person');
|
||||
expect(child(root, 'id')!.text).toBe('https://example.invalid/work/feed.xml');
|
||||
const links = childrenNamed(root, 'link').map((l) => l.attrs);
|
||||
expect(links).toContainEqual({ rel: 'self', type: 'application/atom+xml', href: 'https://example.invalid/work/feed.xml' });
|
||||
expect(links).toContainEqual({ rel: 'alternate', type: 'text/html', href: 'https://example.invalid/work/' });
|
||||
expect(child(child(root, 'author')!, 'name')!.text).toBe('Example Person');
|
||||
});
|
||||
|
||||
test('entries are newest first; updated is the newest entry day, not the build time', () => {
|
||||
const root = parseXml(atomFeed(feed()));
|
||||
expect(child(root, 'updated')!.text).toBe('2025-06-07T00:00:00Z');
|
||||
const entries = childrenNamed(root, 'entry');
|
||||
expect(entries.map((e) => child(e, 'title')!.text)).toEqual(['Newest', 'Middle', 'Older']);
|
||||
expect(entries.map((e) => child(e, 'updated')!.text)).toEqual(['2025-06-07T00:00:00Z', '2024-11-30T00:00:00Z', '2024-02-03T00:00:00Z']);
|
||||
// Same input, same bytes.
|
||||
expect(atomFeed(feed())).toBe(atomFeed(feed()));
|
||||
});
|
||||
|
||||
test('an entry: its URL is its id and its link, a summary when there is one, the AI level as a category', () => {
|
||||
const [newest, middle] = childrenNamed(parseXml(atomFeed(feed())), 'entry');
|
||||
expect(child(newest!, 'id')!.text).toBe('https://example.invalid/work/newest/');
|
||||
expect(child(newest!, 'link')!.attrs).toEqual({ rel: 'alternate', type: 'text/html', href: 'https://example.invalid/work/newest/' });
|
||||
expect(child(newest!, 'summary')!.text).toBe('The newest one.');
|
||||
expect(child(newest!, 'summary')!.attrs.type).toBe('text');
|
||||
expect(childrenNamed(newest!, 'category').map((c) => c.attrs)).toEqual([{ term: 'some-ai', label: 'some AI' }]);
|
||||
expect(child(middle!, 'summary')).toBeUndefined();
|
||||
expect(child(middle!, 'category')).toBeUndefined();
|
||||
expect(child(newest!, 'content')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('hostile titles, summaries and names come through as text', () => {
|
||||
const xml = atomFeed(feed({
|
||||
title: HOSTILE, author: HOSTILE,
|
||||
entries: [{ title: HOSTILE, url: 'https://example.invalid/a?b=1&c="2"', date: '2025-01-01', summary: HOSTILE, category: { term: '"x"&', label: HOSTILE } }],
|
||||
}));
|
||||
expect(xml).not.toContain('<script>');
|
||||
const root = parseXml(xml);
|
||||
expect(child(root, 'title')!.text).toBe(HOSTILE);
|
||||
expect(child(child(root, 'author')!, 'name')!.text).toBe(HOSTILE);
|
||||
const entry = child(root, 'entry')!;
|
||||
expect(child(entry, 'title')!.text).toBe(HOSTILE);
|
||||
expect(child(entry, 'summary')!.text).toBe(HOSTILE);
|
||||
expect(child(entry, 'id')!.text).toBe('https://example.invalid/a?b=1&c="2"');
|
||||
expect(child(entry, 'link')!.attrs.href).toBe('https://example.invalid/a?b=1&c="2"');
|
||||
expect(child(entry, 'category')!.attrs).toEqual({ term: '"x"&', label: HOSTILE });
|
||||
});
|
||||
|
||||
test('an empty feed is still valid and stable', () => {
|
||||
const xml = atomFeed(feed({ entries: [] }));
|
||||
const root = parseXml(xml);
|
||||
expect(child(root, 'updated')!.text).toBe('1970-01-01T00:00:00Z');
|
||||
expect(childrenNamed(root, 'entry')).toEqual([]);
|
||||
});
|
||||
|
||||
test('a malformed date is refused rather than written', () => {
|
||||
expect(() => atomFeed(feed({ entries: [{ title: 'x', url: 'https://example.invalid/x/', date: '2025-1-1' }] }))).toThrow(/date/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sitemapXml', () => {
|
||||
test('lists every URL once, in order, escaped', () => {
|
||||
const urls = ['https://example.invalid/', 'https://example.invalid/work/', 'https://example.invalid/a?b=1&c=2'];
|
||||
const xml = sitemapXml(urls);
|
||||
expect(xml.startsWith('<?xml version="1.0" encoding="utf-8"?>\n')).toBe(true);
|
||||
const root = parseXml(xml);
|
||||
expect(root.name).toBe('urlset');
|
||||
expect(root.attrs.xmlns).toBe('http://www.sitemaps.org/schemas/sitemap/0.9');
|
||||
expect(childrenNamed(root, 'url').map((u) => child(u, 'loc')!.text)).toEqual(urls);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user