A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

Document bio rich-text contract and record decision 0015

+47 -1
+44
docs/decisions/0015-author-bio-richtext.md
··· 1 + # 0015 — Linkified author bios from detected facets 2 + 3 + ## Context 4 + 5 + Author pages show the writer's Bluesky bio (`app.bsky.actor.profile.description`). On 6 + bsky.app that bio has clickable URLs, `@mentions`, and `#tags`, but those links are **not 7 + in the PDS record** — profile descriptions are plain text and carry no `facets` (unlike 8 + `app.bsky.feed.post`). Bluesky's client computes them at render time with 9 + `RichText.detectFacets()`. We want the same on the author page. 10 + 11 + ## Options 12 + 13 + 1. **Hand-rolled regex detector.** Full control, no extra import, but "match Bluesky 14 + fully" becomes a hand-maintained moving target (URL tokenizer subtleties, bare domains, 15 + trailing punctuation, unicode tags) that silently drifts from bsky.app. 16 + 2. **`@atproto/api` `RichText.detectFacets(agent)`.** Exact Bluesky behaviour *including* 17 + handle→DID resolution — but resolution means network calls and new SSRF surface, for no 18 + benefit since we link mentions by handle. 19 + 3. **`@atproto/api` `RichText.detectFacetsWithoutResolution()`.** The same detection code 20 + Bluesky runs, minus mention resolution. Pure, no network. 21 + 22 + ## Choice 23 + 24 + Option 3. A pure module `src/lib/reader/rich-text.ts` exposes `detectBioSegments()`, which 25 + returns ordered `text`/`link` segments. The author page renders them with Astro 26 + auto-escaping (no `set:html`). Mentions link to `https://bsky.app/profile/<handle>`, tags 27 + to `https://bsky.app/hashtag/<tag>`, URLs to the detected uri. An http(s) scheme guard 28 + (`safeHttpHref`) drops any non-http(s) link as defence in depth. 29 + 30 + ## Why 31 + 32 + - **Fidelity by construction.** Reusing atproto's detection means our links can't drift 33 + from bsky.app's. 34 + - **No new read-path risk.** No network call, so the SSRF guard (AGENTS.md constraint 6a) 35 + does not apply; no `set:html`, so the sanitiser (6b) does not apply — Astro escapes both 36 + text and attributes, and the scheme guard blocks hostile URL schemes. 37 + - **No DID resolution needed** because mentions link by handle (we accept linking to 38 + Bluesky rather than internal author pages for now). 39 + 40 + ## Scope 41 + 42 + Author bios only. Publication descriptions, display names, and post excerpts are 43 + unchanged. Article body links are unaffected — they are authored HTML anchors in the block 44 + tree, never facets.
+3 -1
src/lib/reader/profile.ts
··· 6 6 * Bluesky appview — so the page has no third-party-service dependency. `getRecord` already 7 7 * guards the (DID-doc-derived) PDS host against SSRF and degrades to null on failure. 8 8 * 9 - * `displayName`/`description` are plain text; callers render them as text, never as HTML. 9 + * `displayName` is plain text. `description` is plain text in the record, but the author 10 + * page linkifies it at render via `detectBioSegments` (rich-text.ts), mirroring Bluesky. 11 + * Neither is ever injected as raw HTML. (Decision 0015.) 10 12 */ 11 13 import { getRecord } from './records'; 12 14 import { buildGetBlobUrl, type BlobRefJson } from '../media/blob';