Link previews for Radial URLs #
Implements steps 1–4 of the approved plan. Step 5 (per-record OG images) was explicitly severable and is not included.
Provenance #
| Branch | radial/impl-343a43e8605a |
| Commit | 0de1ab01aca279122964e5f9f246d5bf42c73174 |
| Base | main @ 0e50735 |
| Pull request | not opened by this turn — Radial opens it from the pushed branch |
What landed #
Shell defaults (packages/ui/src/app.html). <title>, description, og:site_name,
og:type, og:title, og:description, twitter:card, and an absolute
og:image / :type / :width / :height / :alt. The image URL is built from
%sveltekit.env.PUBLIC_RADIAL_ORIGIN% — the same knob the deploy already sets for
client-metadata.json. The plan's first open question is answered: SvelteKit 2.70 does substitute
that placeholder into adapter-static's fallback, verified in build/index.html, so the
postbuild string-replace fallback was not needed. Unset, it degrades to a relative URL crawlers
skip, which is correct for loopback builds.
The card (packages/ui/static/og.png, scripts/og-card.mjs). 1200×630, the mark's own
geometry — concentric rings, the ring-and-dot at the favicon's proportions — with the wordmark and
"Humans and coding agents on software goals" on the design system's desk colours, read out of
app.css in oklch and converted rather than re-picked in sRGB. Drawn by a hand-rolled generator
(distance-field rasteriser, monoline stroke glyphs, PNG encoder over node:zlib) rather than by
pulling a rasteriser, a font file and an encoder into the tree for one static image. Checked in;
pnpm --filter @radial/ui og redraws it in ~0.1s.
The edge worker (packages/ui/src/edge/, static/_routes.json, scripts/build-worker.mjs).
build/_worker.js fetches the same shell Pages would have served, resolves the record the URL
already names, and rewrites <title>, og:title, og:description, og:type and og:url.
target.ts— URL → AT URI./g/<did>/<rkey>mirrorsspace.ts'sgoalHref;?unit=is accepted only when it parses as an AT URI in the artifact or artifactRequest collection;vis dropped (it selects a version, not a record)./p/<name>and its sub-views claim the name only.records.ts—@radial/atprotoon an injectedfetch, public reads, lexicon validation, and anhttps:-only PDS check. Goal and unit fetch in parallel under one 1.5s deadline.head.ts— a targeted splice over our own build output, with comments masked first (app.htmldocuments these tags in an HTML comment, so a naive<title>match eats the prose).preview.ts/worker.ts— the handler, and a one-default-export entry module.- Cache: 5 min +
stale-while-revalidate, keyed on the canonical URL, so the same link carrying a campaign tag shares one entry and one PDS round trip.
Client-side document.title per route in +layout.svelte, from the same src/lib/meta.ts
rules the worker uses, so a bookmark and a shared card say the same thing about the same URL.
Deploy gate — .tangled/workflows/deploy.yml now also requires build/_worker.js and
build/_routes.json before wrangler runs. No CI mirroring needed: deploy is spindle-only and
ci.yml was untouched on both remotes.
Docs — packages/ui/README.md gains a Link previews section recording the design tension
(this is not a view server: no materialize, no index, no write, no credential, identical bytes for
crawlers and people).
Deviation from the plan, deliberate #
The plan specified HTMLRewriter. It is workerd-only, which would have put the one function that
turns agent- and member-authored text into HTML beyond the reach of pnpm test. The plan's own
step 4 asks for that path to be Node-drivable. head.ts is a string splice with an explicit
escapeAttribute/escapeText instead, asserted directly rather than assumed of a platform API —
including that "><script>alert(1)</script><meta x=" in a title comes out inert and the document
gains no tag. The shell is our own 6.6 kB build output, so buffering it costs nothing.
Checks #
pnpm codegen + git diff --exit-code, pnpm typecheck, pnpm lint, pnpm test, pnpm build —
all green. 433 vitest + 29 node --test in @radial/ui, of which new:
src/lib/meta.test.ts(20) — clamping on word and code-point boundaries, markdown flattening, every title/description case, caps, and thatapp.htmlandmeta.tswrite the same sentence.src/edge/target.test.ts(10),src/edge/head.test.ts(6),src/edge/preview.test.ts(11) — the whole fetch path with the network stood in for, including every degradation and the cache.test/edge-bundle.test.mjs(6) — the emitted bundle has nonode:imports, exports onlydefault,_routes.jsonis confined to/g/*and/p/*,_redirectsstill holds, and the bundled artifact actually serves a request.test/og-card.test.mjs(2) — the committed PNG really is the 1200×630 the shell declares.
test/browser-bundle.test.mjs is untouched and green: no edge code reached the browser path.
Verified against real workerd #
wrangler pages dev packages/ui/build, against the live records for this goal:
/g/did:plc:4vjd…/3mrvumwh3gk2o→ the goal's title and body excerpt,og:type: article.- the same with
?unit=…plan-lygfltjsifckyusmtnqzzrx2&v=1→ the plan artifact's title and excerpt,og:urlcanonicalised withoutv. /p/radial/system→radial, no PDS contacted at all./g/did:plc:zzz/zzzand/g/nonsense→ HTTP 200, default tags, app shell./,/awaiting,/space,/client-metadata.json,/og.png,/favicon.svg→ byte-identical to the bundle. This also settles the plan's routing risk empirically:_redirectsstill applies for_routes.json-excluded paths behind a_worker.js.
That run is also what caught a real defect no unit test could: workerd refuses a named export from
_worker.js that is not a function or ExportedHandler, so exporting PREVIEW_CACHE_CONTROL
beside the handler failed at Starting local server on a bundle that typechecked and passed
everything. Hence the entry/logic split, and a test that holds the bundle to one export.
Remaining risks #
- Unverified in production. The
?unit=and default cards have not been through Slack, Discord or the Twitter card validator, andog:imagehas not been fetched from a realradl.app— onlywrangler pages devlocally. Worth one paste each after the deploy. - A
?unit=link is keyed by the root artifact's URI, so its description comes from the first version that landed rather than from the tip. Title and type are right; a much-revised body may read as its own v1. Following the tip would mean materializing, which is the line this worker does not cross. - Unfurler variance. Some drop query strings, which makes a
?unit=link preview as its goal — a graceful floor, not a failure. Some cache hard; the platforms' re-scrape tools are the recourse. - A member's PDS being down turns their links generic until the cache repopulates — the same stale-never-wrong degradation the app itself has.
wrangler pages devprints a pre-existing "infinite loop detected" warning for the/* /index.html 200rule in_redirects. It is its local emulation's heuristic, not a new condition — the rule served/awaitingand/spacecorrectly in the same run — and_redirectswas not touched.