···11+# Author bio rich text (linkified bios) — design
22+33+**Date:** 2026-06-09
44+**Status:** Approved, ready for plan
55+**Branch:** `bio-links-from-facets`
66+77+## Problem
88+99+The public author page (`src/pages/[author]/index.astro`) renders the writer's Bluesky
1010+bio (`app.bsky.actor.profile` → `description`) as plain text. On bsky.app the same bio
1111+shows clickable links, `@mentions`, and `#tags` — but those are **not stored in the PDS
1212+record**. The `description` field is plain text; profile records carry no `facets`
1313+(unlike `app.bsky.feed.post`). Bluesky's client computes the links at render time by
1414+running `RichText.detectFacets()` over the description.
1515+1616+We want the SkyPress author page to surface those same links.
1717+1818+## Scope
1919+2020+- **In scope:** author bios only.
2121+- **Match Bluesky fully:** URLs (including bare domains), `@mentions`, `#tags`.
2222+- **Out of scope (for now):** publication descriptions, display names, post excerpts.
2323+ Article body links are unaffected — they are authored HTML anchors in the block tree,
2424+ never facets, and already render correctly.
2525+2626+## Decisions
2727+2828+- **Detection: `@atproto/api`'s `RichText.detectFacetsWithoutResolution()`** — the exact
2929+ code Bluesky's client runs, so detection fidelity (URL tokenizer, bare domains, mention
3030+ and tag rules, trailing-punctuation handling) is guaranteed by construction. The
3131+ `WithoutResolution` variant skips handle→DID resolution, which we don't need (we link by
3232+ handle) and which keeps the path **network-free**.
3333+- **Mention links target Bluesky** (`https://bsky.app/profile/<handle>`). Truest to
3434+ "match Bluesky fully", needs no DID resolution, and avoids dumping readers on empty
3535+ SkyPress author pages for people who only exist on Bluesky. (Internal linking can be a
3636+ later enhancement.)
3737+- **Tag links** target `https://bsky.app/hashtag/<tag>`.
3838+- **No `set:html`.** The module returns structured segments; the Astro template renders
3939+ them with auto-escaping. We never build or inject an HTML string.
4040+4141+## Architecture
4242+4343+### `src/lib/reader/rich-text.ts` (new, pure, no network)
4444+4545+```ts
4646+export type BioSegment =
4747+ | { type: 'text'; text: string }
4848+ | { type: 'link'; text: string; href: string };
4949+5050+export function detectBioSegments(description: string): BioSegment[];
5151+```
5252+5353+Builds `new RichText({ text: description })`, calls `detectFacetsWithoutResolution()`,
5454+then walks `rt.segments()`:
5555+5656+- **Link** segment → `{ type: 'link', text: <as typed>, href: segment.link.uri }`.
5757+ `detectFacets` already prepends `https://` to bare domains, so `example.com` →
5858+ text `example.com`, href `https://example.com`.
5959+- **Mention** segment → `href: https://bsky.app/profile/<handle>` (handle from the
6060+ segment text with the leading `@` stripped).
6161+- **Tag** segment → `href: https://bsky.app/hashtag/<tag>`.
6262+- Anything else → `{ type: 'text' }`.
6363+6464+**Scheme guard (defense in depth):** only emit a `link` segment when the resolved href
6565+scheme is `http:` or `https:`; otherwise fall back to a `text` segment. `detectFacets`
6666+should never produce another scheme, but this guarantees a hostile PDS can't smuggle a
6767+`javascript:` link through.
6868+6969+Empty/whitespace input → `[]`.
7070+7171+### Rendering — `src/pages/[author]/index.astro`
7272+7373+Replace the plain-text bio line with a map over segments:
7474+7575+```astro
7676+{bioSegments.length > 0 && (
7777+ <p class="author__bio">
7878+ {bioSegments.map((seg) =>
7979+ seg.type === 'link' ? (
8080+ <a href={seg.href} target="_blank" rel="noopener noreferrer nofollow">{seg.text}</a>
8181+ ) : (
8282+ seg.text
8383+ )
8484+ )}
8585+ </p>
8686+)}
8787+```
8888+8989+`bioSegments` is computed in the frontmatter from `profile.description`.
9090+9191+## Security
9292+9393+- **No HTML injection.** Astro auto-escapes both `{seg.text}` (a bio containing `<script>`
9494+ or `&` renders as literal text) and the `href` attribute. No `set:html`, so the
9595+ sanitize path (constraint 6b) doesn't apply.
9696+- **No network call**, so the SSRF guard (constraint 6a) doesn't apply —
9797+ `detectFacetsWithoutResolution()` is pure.
9898+- **Scheme guard** as above.
9999+- Links carry `rel="noopener noreferrer nofollow"` + `target="_blank"`, matching the
100100+ existing handle link and appropriate for untrusted external URLs.
101101+102102+## Testing (TDD — tests first, must fail before implementation)
103103+104104+`src/lib/reader/rich-text.test.ts`:
105105+106106+- Plain text, no facets → one `text` segment, content unchanged.
107107+- Full URL → `link` with correct href + display text; trailing punctuation
108108+ (`Visit https://example.com.`) stays out of the link.
109109+- Bare domain (`example.com`) → `link` with `https://` href, bare display text.
110110+- `@alice.bsky.social` → `link` to `https://bsky.app/profile/alice.bsky.social`.
111111+- `#design` → `link` to `https://bsky.app/hashtag/design`.
112112+- Mixed bio (text + URL + mention + tag) → correctly ordered segment array.
113113+- Scheme guard → a non-http(s) URI yields a `text` segment, not a `link`.
114114+- Empty/whitespace description → `[]`.
115115+116116+Fidelity is inherent (we *use* `RichText`), so no separate fidelity-lock assertion is
117117+needed; the behavior tests are the contract.
118118+119119+## Docs
120120+121121+- Update the contract comment in `src/lib/reader/profile.ts` — `description` is no longer
122122+ "rendered as text, never HTML"; it is parsed into linkified segments at render.
123123+- Add `docs/decisions/0015-author-bio-richtext.md` recording the read-path call.
124124+125125+## Risk
126126+127127+`@atproto/api`'s `RichText` must import cleanly in the SSR/node context (no `window` at
128128+import). The vitest run (node env) exercises this immediately; if it pulls anything
129129+browser-only, isolate the import. Not expected — `RichText` is a pure class.