···11+# Author Bio Rich Text Implementation Plan
22+33+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
44+55+**Goal:** Linkify URLs, `@mentions`, and `#tags` in author bios on the public author page, matching how Bluesky renders the same plain-text bio.
66+77+**Architecture:** A new pure module `src/lib/reader/rich-text.ts` runs `@atproto/api`'s `RichText.detectFacetsWithoutResolution()` (the same detection Bluesky uses, no network) over the bio and returns ordered `BioSegment`s. The author page maps those segments to text + anchors with Astro auto-escaping (no `set:html`).
88+99+**Tech Stack:** TypeScript, Astro (SSR), `@atproto/api` (`RichText`), Vitest.
1010+1111+**Reference:** Design doc `docs/superpowers/specs/2026-06-09-author-bio-richtext-design.md`.
1212+1313+**Conventions to match:** tab indentation; spaces inside parens/brackets (`( { ... } )`); single quotes; semicolons. Commit messages use plain imperative sentences (e.g. "Add …"), no `feat:` prefix, no Claude co-author. Commit with `--no-gpg-sign`.
1414+1515+---
1616+1717+### Task 1: `detectBioSegments` module (TDD)
1818+1919+**Files:**
2020+- Create: `src/lib/reader/rich-text.ts`
2121+- Test: `src/lib/reader/rich-text.test.ts`
2222+2323+- [ ] **Step 1: Write the failing test**
2424+2525+Create `src/lib/reader/rich-text.test.ts`:
2626+2727+```ts
2828+import { describe, expect, it } from 'vitest';
2929+import { detectBioSegments, safeHttpHref } from './rich-text';
3030+3131+describe( 'detectBioSegments', () => {
3232+ it( 'returns a single text segment for plain prose', () => {
3333+ expect( detectBioSegments( 'Just a writer.' ) ).toEqual( [
3434+ { type: 'text', text: 'Just a writer.' },
3535+ ] );
3636+ } );
3737+3838+ it( 'returns an empty array for blank input', () => {
3939+ expect( detectBioSegments( ' ' ) ).toEqual( [] );
4040+ } );
4141+4242+ it( 'linkifies a full URL and keeps trailing punctuation out of the link', () => {
4343+ expect( detectBioSegments( 'Visit https://example.com.' ) ).toEqual( [
4444+ { type: 'text', text: 'Visit ' },
4545+ { type: 'link', text: 'https://example.com', href: 'https://example.com' },
4646+ { type: 'text', text: '.' },
4747+ ] );
4848+ } );
4949+5050+ it( 'linkifies a bare domain with an https href and bare display text', () => {
5151+ expect( detectBioSegments( 'example.com' ) ).toEqual( [
5252+ { type: 'link', text: 'example.com', href: 'https://example.com' },
5353+ ] );
5454+ } );
5555+5656+ it( 'links a mention to its Bluesky profile by handle', () => {
5757+ expect( detectBioSegments( 'hi @alice.bsky.social' ) ).toEqual( [
5858+ { type: 'text', text: 'hi ' },
5959+ {
6060+ type: 'link',
6161+ text: '@alice.bsky.social',
6262+ href: 'https://bsky.app/profile/alice.bsky.social',
6363+ },
6464+ ] );
6565+ } );
6666+6767+ it( 'links a hashtag to its Bluesky hashtag page', () => {
6868+ expect( detectBioSegments( 'love #design' ) ).toEqual( [
6969+ { type: 'text', text: 'love ' },
7070+ { type: 'link', text: '#design', href: 'https://bsky.app/hashtag/design' },
7171+ ] );
7272+ } );
7373+7474+ it( 'orders mixed text, url, mention and tag segments correctly', () => {
7575+ expect(
7676+ detectBioSegments( 'me @alice.bsky.social #design example.com' )
7777+ ).toEqual( [
7878+ { type: 'text', text: 'me ' },
7979+ {
8080+ type: 'link',
8181+ text: '@alice.bsky.social',
8282+ href: 'https://bsky.app/profile/alice.bsky.social',
8383+ },
8484+ { type: 'text', text: ' ' },
8585+ { type: 'link', text: '#design', href: 'https://bsky.app/hashtag/design' },
8686+ { type: 'text', text: ' ' },
8787+ { type: 'link', text: 'example.com', href: 'https://example.com' },
8888+ ] );
8989+ } );
9090+} );
9191+9292+describe( 'safeHttpHref', () => {
9393+ it( 'passes http and https through unchanged', () => {
9494+ expect( safeHttpHref( 'https://example.com' ) ).toBe( 'https://example.com' );
9595+ expect( safeHttpHref( 'http://example.com/x' ) ).toBe( 'http://example.com/x' );
9696+ } );
9797+9898+ it( 'rejects non-http(s) schemes and garbage', () => {
9999+ expect( safeHttpHref( 'javascript:alert(1)' ) ).toBeNull();
100100+ expect( safeHttpHref( 'ftp://example.com' ) ).toBeNull();
101101+ expect( safeHttpHref( 'not a url' ) ).toBeNull();
102102+ } );
103103+} );
104104+```
105105+106106+> **Note on expected strings:** the link/href *intent* is the contract. We deliberately defer text-segment boundaries to `@atproto/api`'s tokenizer, which is the whole point of Approach 1. When you first run these tests, if the installed atproto version splits a *text* segment slightly differently (e.g. whitespace grouping), update the expected literals to match the actual atproto output — but the `link` segments, their display text, and their hrefs must match exactly as written. Do **not** change the implementation to force a particular text split.
107107+108108+- [ ] **Step 2: Run the test to verify it fails**
109109+110110+Run: `npx vitest run src/lib/reader/rich-text.test.ts`
111111+Expected: FAIL — `Failed to resolve import './rich-text'` (module does not exist yet).
112112+113113+(If `@atproto/api` is not installed, run `npm install` first.)
114114+115115+- [ ] **Step 3: Write the implementation**
116116+117117+Create `src/lib/reader/rich-text.ts`:
118118+119119+```ts
120120+/**
121121+ * Linkify a writer's Bluesky bio the way the Bluesky client does.
122122+ *
123123+ * `app.bsky.actor.profile.description` is plain text — profile records carry no `facets`
124124+ * (unlike `app.bsky.feed.post`). bsky.app computes the links at render time via
125125+ * `RichText.detectFacets()`. We do the same with `detectFacetsWithoutResolution()`, which
126126+ * needs no network: we link mentions by handle (not DID), so nothing has to be resolved.
127127+ * That also keeps this off the SSRF-guarded fetch path entirely. (Decision 0015.)
128128+ *
129129+ * Returns ordered segments; the caller renders them with auto-escaping, never `set:html`.
130130+ */
131131+import { RichText } from '@atproto/api';
132132+133133+export type BioSegment =
134134+ | { type: 'text'; text: string }
135135+ | { type: 'link'; text: string; href: string };
136136+137137+/**
138138+ * The original `uri` if it is an http(s) URL, else null. `detectFacets` should only ever
139139+ * produce http(s) links, but this guarantees a hostile PDS can't smuggle e.g. a
140140+ * `javascript:` URL through. Returns the raw uri (not a normalised `URL.href`) so display
141141+ * fidelity matches what the writer typed.
142142+ */
143143+export function safeHttpHref( uri: string ): string | null {
144144+ try {
145145+ const { protocol } = new URL( uri );
146146+ return protocol === 'http:' || protocol === 'https:' ? uri : null;
147147+ } catch {
148148+ return null;
149149+ }
150150+}
151151+152152+export function detectBioSegments( description: string ): BioSegment[] {
153153+ if ( ! description.trim() ) {
154154+ return [];
155155+ }
156156+157157+ const richText = new RichText( { text: description } );
158158+ richText.detectFacetsWithoutResolution();
159159+160160+ const segments: BioSegment[] = [];
161161+ for ( const segment of richText.segments() ) {
162162+ let href: string | null = null;
163163+164164+ if ( segment.isLink() && segment.link ) {
165165+ href = safeHttpHref( segment.link.uri );
166166+ } else if ( segment.isMention() ) {
167167+ const handle = segment.text.replace( /^@/, '' );
168168+ href = handle
169169+ ? `https://bsky.app/profile/${ encodeURIComponent( handle ) }`
170170+ : null;
171171+ } else if ( segment.isTag() ) {
172172+ const tag = segment.text.replace( /^#/, '' );
173173+ href = tag ? `https://bsky.app/hashtag/${ encodeURIComponent( tag ) }` : null;
174174+ }
175175+176176+ segments.push(
177177+ href
178178+ ? { type: 'link', text: segment.text, href }
179179+ : { type: 'text', text: segment.text }
180180+ );
181181+ }
182182+183183+ return segments;
184184+}
185185+```
186186+187187+- [ ] **Step 4: Run the test to verify it passes**
188188+189189+Run: `npx vitest run src/lib/reader/rich-text.test.ts`
190190+Expected: PASS (all cases). If only a *text*-segment boundary differs, adjust the test literals per the Step 1 note, then re-run to green.
191191+192192+- [ ] **Step 5: Type-check**
193193+194194+Run: `npm run check`
195195+Expected: no new type errors. (Confirms `RichText` imports cleanly in the SSR/node context — the one risk flagged in the design. If it pulls in browser-only code, isolate the import behind a thin wrapper and re-run.)
196196+197197+- [ ] **Step 6: Commit**
198198+199199+```bash
200200+git add src/lib/reader/rich-text.ts src/lib/reader/rich-text.test.ts
201201+git commit --no-gpg-sign -m "Add bio rich-text facet detection for author pages"
202202+```
203203+204204+---
205205+206206+### Task 2: Render linkified bio on the author page
207207+208208+**Files:**
209209+- Modify: `src/pages/[author]/index.astro` (frontmatter ~line 22-47; bio line 101; styles ~line 225-229)
210210+211211+- [ ] **Step 1: Import the detector**
212212+213213+In the frontmatter import block (after the existing `fetchActorProfile` import, ~line 6), add:
214214+215215+```astro
216216+import { detectBioSegments } from '../../lib/reader/rich-text';
217217+```
218218+219219+- [ ] **Step 2: Declare and populate `bioSegments`**
220220+221221+In the `let` declarations block (near line 22-25), add:
222222+223223+```astro
224224+let bioSegments: ReturnType< typeof detectBioSegments > = [];
225225+```
226226+227227+Inside the `if ( ! error && resolved ) {` block, after `initial` is set (~line 46), add:
228228+229229+```astro
230230+ bioSegments = profile.description ? detectBioSegments( profile.description ) : [];
231231+```
232232+233233+- [ ] **Step 3: Replace the plain-text bio line**
234234+235235+Replace line 101:
236236+237237+```astro
238238+ {profile!.description && <p class="author__bio">{profile!.description}</p>}
239239+```
240240+241241+with:
242242+243243+```astro
244244+ {bioSegments.length > 0 && (
245245+ <p class="author__bio">
246246+ {bioSegments.map( ( seg ) =>
247247+ seg.type === 'link' ? (
248248+ <a href={seg.href} target="_blank" rel="noopener noreferrer nofollow">
249249+ {seg.text}
250250+ </a>
251251+ ) : (
252252+ seg.text
253253+ )
254254+ )}
255255+ </p>
256256+ )}
257257+```
258258+259259+> Leave the `Base` `description={profile!.description ?? undefined}` prop (line 58) unchanged — the SEO meta description stays raw plain text.
260260+261261+- [ ] **Step 4: Style bio links**
262262+263263+In the `.author__bio` style rule (~line 225-229), add a following rule:
264264+265265+```css
266266+ .author__bio a {
267267+ color: var(--sun);
268268+ }
269269+```
270270+271271+- [ ] **Step 5: Type-check and build**
272272+273273+Run: `npm run check && npm run build`
274274+Expected: both succeed with no new errors. (There is no Astro component-render test harness in this repo; Task 1 unit tests cover the detection logic, and `check` + `build` verify the template wiring.)
275275+276276+- [ ] **Step 6: Manual verification (dev)**
277277+278278+Per AGENTS.md constraint 7, serve on `127.0.0.1`. Note: the dev server binds IPv6 — browse via `localhost`, not `curl 127.0.0.1` (see auto-memory `astro-dev-binds-ipv6-localhost`).
279279+280280+Run: `npm run dev`
281281+Visit an author page whose Bluesky bio contains a URL, `@mention`, and `#tag` (e.g. `http://localhost:<port>/@<handle>`). Confirm the bio shows clickable links pointing at the raw URL, `bsky.app/profile/<handle>`, and `bsky.app/hashtag/<tag>`, and that surrounding text renders normally.
282282+283283+- [ ] **Step 7: Commit**
284284+285285+```bash
286286+git add src/pages/[author]/index.astro
287287+git commit --no-gpg-sign -m "Render linkified bios on the author page"
288288+```
289289+290290+---
291291+292292+### Task 3: Docs — update contract comment and record the decision
293293+294294+**Files:**
295295+- Modify: `src/lib/reader/profile.ts:9`
296296+- Create: `docs/decisions/0015-author-bio-richtext.md`
297297+298298+- [ ] **Step 1: Update the `profile.ts` contract comment**
299299+300300+Replace line 9:
301301+302302+```ts
303303+ * `displayName`/`description` are plain text; callers render them as text, never as HTML.
304304+```
305305+306306+with:
307307+308308+```ts
309309+ * `displayName` is plain text. `description` is plain text in the record, but the author
310310+ * page linkifies it at render via `detectBioSegments` (rich-text.ts), mirroring Bluesky.
311311+ * Neither is ever injected as raw HTML. (Decision 0015.)
312312+```
313313+314314+- [ ] **Step 2: Write the decision record**
315315+316316+Create `docs/decisions/0015-author-bio-richtext.md`:
317317+318318+```markdown
319319+# 0015 — Linkified author bios from detected facets
320320+321321+## Context
322322+323323+Author pages show the writer's Bluesky bio (`app.bsky.actor.profile.description`). On
324324+bsky.app that bio has clickable URLs, `@mentions`, and `#tags`, but those links are **not
325325+in the PDS record** — profile descriptions are plain text and carry no `facets` (unlike
326326+`app.bsky.feed.post`). Bluesky's client computes them at render time with
327327+`RichText.detectFacets()`. We want the same on the author page.
328328+329329+## Options
330330+331331+1. **Hand-rolled regex detector.** Full control, no extra import, but "match Bluesky
332332+ fully" becomes a hand-maintained moving target (URL tokenizer subtleties, bare domains,
333333+ trailing punctuation, unicode tags) that silently drifts from bsky.app.
334334+2. **`@atproto/api` `RichText.detectFacets(agent)`.** Exact Bluesky behaviour *including*
335335+ handle→DID resolution — but resolution means network calls and new SSRF surface, for no
336336+ benefit since we link mentions by handle.
337337+3. **`@atproto/api` `RichText.detectFacetsWithoutResolution()`.** The same detection code
338338+ Bluesky runs, minus mention resolution. Pure, no network.
339339+340340+## Choice
341341+342342+Option 3. A pure module `src/lib/reader/rich-text.ts` exposes `detectBioSegments()`, which
343343+returns ordered `text`/`link` segments. The author page renders them with Astro
344344+auto-escaping (no `set:html`). Mentions link to `https://bsky.app/profile/<handle>`, tags
345345+to `https://bsky.app/hashtag/<tag>`, URLs to the detected uri. An http(s) scheme guard
346346+(`safeHttpHref`) drops any non-http(s) link as defence in depth.
347347+348348+## Why
349349+350350+- **Fidelity by construction.** Reusing atproto's detection means our links can't drift
351351+ from bsky.app's.
352352+- **No new read-path risk.** No network call, so the SSRF guard (AGENTS.md constraint 6a)
353353+ does not apply; no `set:html`, so the sanitiser (6b) does not apply — Astro escapes both
354354+ text and attributes, and the scheme guard blocks hostile URL schemes.
355355+- **No DID resolution needed** because mentions link by handle (we accept linking to
356356+ Bluesky rather than internal author pages for now).
357357+358358+## Scope
359359+360360+Author bios only. Publication descriptions, display names, and post excerpts are
361361+unchanged. Article body links are unaffected — they are authored HTML anchors in the block
362362+tree, never facets.
363363+```
364364+365365+- [ ] **Step 3: Commit**
366366+367367+```bash
368368+git add src/lib/reader/profile.ts docs/decisions/0015-author-bio-richtext.md
369369+git commit --no-gpg-sign -m "Document bio rich-text contract and record decision 0015"
370370+```
371371+372372+---
373373+374374+## Final verification
375375+376376+- [ ] Run the full suite: `npm run test`
377377+- [ ] Run: `npm run check && npm run build`
378378+- [ ] All green → ready for branch finish / PR.