···11+# 0015 — Linkified author bios from detected facets
22+33+## Context
44+55+Author pages show the writer's Bluesky bio (`app.bsky.actor.profile.description`). On
66+bsky.app that bio has clickable URLs, `@mentions`, and `#tags`, but those links are **not
77+in the PDS record** — profile descriptions are plain text and carry no `facets` (unlike
88+`app.bsky.feed.post`). Bluesky's client computes them at render time with
99+`RichText.detectFacets()`. We want the same on the author page.
1010+1111+## Options
1212+1313+1. **Hand-rolled regex detector.** Full control, no extra import, but "match Bluesky
1414+ fully" becomes a hand-maintained moving target (URL tokenizer subtleties, bare domains,
1515+ trailing punctuation, unicode tags) that silently drifts from bsky.app.
1616+2. **`@atproto/api` `RichText.detectFacets(agent)`.** Exact Bluesky behaviour *including*
1717+ handle→DID resolution — but resolution means network calls and new SSRF surface, for no
1818+ benefit since we link mentions by handle.
1919+3. **`@atproto/api` `RichText.detectFacetsWithoutResolution()`.** The same detection code
2020+ Bluesky runs, minus mention resolution. Pure, no network.
2121+2222+## Choice
2323+2424+Option 3. A pure module `src/lib/reader/rich-text.ts` exposes `detectBioSegments()`, which
2525+returns ordered `text`/`link` segments. The author page renders them with Astro
2626+auto-escaping (no `set:html`). Mentions link to `https://bsky.app/profile/<handle>`, tags
2727+to `https://bsky.app/hashtag/<tag>`, URLs to the detected uri. An http(s) scheme guard
2828+(`safeHttpHref`) drops any non-http(s) link as defence in depth.
2929+3030+## Why
3131+3232+- **Fidelity by construction.** Reusing atproto's detection means our links can't drift
3333+ from bsky.app's.
3434+- **No new read-path risk.** No network call, so the SSRF guard (AGENTS.md constraint 6a)
3535+ does not apply; no `set:html`, so the sanitiser (6b) does not apply — Astro escapes both
3636+ text and attributes, and the scheme guard blocks hostile URL schemes.
3737+- **No DID resolution needed** because mentions link by handle (we accept linking to
3838+ Bluesky rather than internal author pages for now).
3939+4040+## Scope
4141+4242+Author bios only. Publication descriptions, display names, and post excerpts are
4343+unchanged. Article body links are unaffected — they are authored HTML anchors in the block
4444+tree, never facets.
+3-1
src/lib/reader/profile.ts
···66 * Bluesky appview — so the page has no third-party-service dependency. `getRecord` already
77 * guards the (DID-doc-derived) PDS host against SSRF and degrades to null on failure.
88 *
99- * `displayName`/`description` are plain text; callers render them as text, never as HTML.
99+ * `displayName` is plain text. `description` is plain text in the record, but the author
1010+ * page linkifies it at render via `detectBioSegments` (rich-text.ts), mirroring Bluesky.
1111+ * Neither is ever injected as raw HTML. (Decision 0015.)
1012 */
1113import { getRecord } from './records';
1214import { buildGetBlobUrl, type BlobRefJson } from '../media/blob';