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.

Add implementation plan for linkified author bios

+378
+378
docs/superpowers/plans/2026-06-09-author-bio-richtext.md
··· 1 + # Author Bio Rich Text Implementation Plan 2 + 3 + > **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. 4 + 5 + **Goal:** Linkify URLs, `@mentions`, and `#tags` in author bios on the public author page, matching how Bluesky renders the same plain-text bio. 6 + 7 + **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`). 8 + 9 + **Tech Stack:** TypeScript, Astro (SSR), `@atproto/api` (`RichText`), Vitest. 10 + 11 + **Reference:** Design doc `docs/superpowers/specs/2026-06-09-author-bio-richtext-design.md`. 12 + 13 + **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`. 14 + 15 + --- 16 + 17 + ### Task 1: `detectBioSegments` module (TDD) 18 + 19 + **Files:** 20 + - Create: `src/lib/reader/rich-text.ts` 21 + - Test: `src/lib/reader/rich-text.test.ts` 22 + 23 + - [ ] **Step 1: Write the failing test** 24 + 25 + Create `src/lib/reader/rich-text.test.ts`: 26 + 27 + ```ts 28 + import { describe, expect, it } from 'vitest'; 29 + import { detectBioSegments, safeHttpHref } from './rich-text'; 30 + 31 + describe( 'detectBioSegments', () => { 32 + it( 'returns a single text segment for plain prose', () => { 33 + expect( detectBioSegments( 'Just a writer.' ) ).toEqual( [ 34 + { type: 'text', text: 'Just a writer.' }, 35 + ] ); 36 + } ); 37 + 38 + it( 'returns an empty array for blank input', () => { 39 + expect( detectBioSegments( ' ' ) ).toEqual( [] ); 40 + } ); 41 + 42 + it( 'linkifies a full URL and keeps trailing punctuation out of the link', () => { 43 + expect( detectBioSegments( 'Visit https://example.com.' ) ).toEqual( [ 44 + { type: 'text', text: 'Visit ' }, 45 + { type: 'link', text: 'https://example.com', href: 'https://example.com' }, 46 + { type: 'text', text: '.' }, 47 + ] ); 48 + } ); 49 + 50 + it( 'linkifies a bare domain with an https href and bare display text', () => { 51 + expect( detectBioSegments( 'example.com' ) ).toEqual( [ 52 + { type: 'link', text: 'example.com', href: 'https://example.com' }, 53 + ] ); 54 + } ); 55 + 56 + it( 'links a mention to its Bluesky profile by handle', () => { 57 + expect( detectBioSegments( 'hi @alice.bsky.social' ) ).toEqual( [ 58 + { type: 'text', text: 'hi ' }, 59 + { 60 + type: 'link', 61 + text: '@alice.bsky.social', 62 + href: 'https://bsky.app/profile/alice.bsky.social', 63 + }, 64 + ] ); 65 + } ); 66 + 67 + it( 'links a hashtag to its Bluesky hashtag page', () => { 68 + expect( detectBioSegments( 'love #design' ) ).toEqual( [ 69 + { type: 'text', text: 'love ' }, 70 + { type: 'link', text: '#design', href: 'https://bsky.app/hashtag/design' }, 71 + ] ); 72 + } ); 73 + 74 + it( 'orders mixed text, url, mention and tag segments correctly', () => { 75 + expect( 76 + detectBioSegments( 'me @alice.bsky.social #design example.com' ) 77 + ).toEqual( [ 78 + { type: 'text', text: 'me ' }, 79 + { 80 + type: 'link', 81 + text: '@alice.bsky.social', 82 + href: 'https://bsky.app/profile/alice.bsky.social', 83 + }, 84 + { type: 'text', text: ' ' }, 85 + { type: 'link', text: '#design', href: 'https://bsky.app/hashtag/design' }, 86 + { type: 'text', text: ' ' }, 87 + { type: 'link', text: 'example.com', href: 'https://example.com' }, 88 + ] ); 89 + } ); 90 + } ); 91 + 92 + describe( 'safeHttpHref', () => { 93 + it( 'passes http and https through unchanged', () => { 94 + expect( safeHttpHref( 'https://example.com' ) ).toBe( 'https://example.com' ); 95 + expect( safeHttpHref( 'http://example.com/x' ) ).toBe( 'http://example.com/x' ); 96 + } ); 97 + 98 + it( 'rejects non-http(s) schemes and garbage', () => { 99 + expect( safeHttpHref( 'javascript:alert(1)' ) ).toBeNull(); 100 + expect( safeHttpHref( 'ftp://example.com' ) ).toBeNull(); 101 + expect( safeHttpHref( 'not a url' ) ).toBeNull(); 102 + } ); 103 + } ); 104 + ``` 105 + 106 + > **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. 107 + 108 + - [ ] **Step 2: Run the test to verify it fails** 109 + 110 + Run: `npx vitest run src/lib/reader/rich-text.test.ts` 111 + Expected: FAIL — `Failed to resolve import './rich-text'` (module does not exist yet). 112 + 113 + (If `@atproto/api` is not installed, run `npm install` first.) 114 + 115 + - [ ] **Step 3: Write the implementation** 116 + 117 + Create `src/lib/reader/rich-text.ts`: 118 + 119 + ```ts 120 + /** 121 + * Linkify a writer's Bluesky bio the way the Bluesky client does. 122 + * 123 + * `app.bsky.actor.profile.description` is plain text — profile records carry no `facets` 124 + * (unlike `app.bsky.feed.post`). bsky.app computes the links at render time via 125 + * `RichText.detectFacets()`. We do the same with `detectFacetsWithoutResolution()`, which 126 + * needs no network: we link mentions by handle (not DID), so nothing has to be resolved. 127 + * That also keeps this off the SSRF-guarded fetch path entirely. (Decision 0015.) 128 + * 129 + * Returns ordered segments; the caller renders them with auto-escaping, never `set:html`. 130 + */ 131 + import { RichText } from '@atproto/api'; 132 + 133 + export type BioSegment = 134 + | { type: 'text'; text: string } 135 + | { type: 'link'; text: string; href: string }; 136 + 137 + /** 138 + * The original `uri` if it is an http(s) URL, else null. `detectFacets` should only ever 139 + * produce http(s) links, but this guarantees a hostile PDS can't smuggle e.g. a 140 + * `javascript:` URL through. Returns the raw uri (not a normalised `URL.href`) so display 141 + * fidelity matches what the writer typed. 142 + */ 143 + export function safeHttpHref( uri: string ): string | null { 144 + try { 145 + const { protocol } = new URL( uri ); 146 + return protocol === 'http:' || protocol === 'https:' ? uri : null; 147 + } catch { 148 + return null; 149 + } 150 + } 151 + 152 + export function detectBioSegments( description: string ): BioSegment[] { 153 + if ( ! description.trim() ) { 154 + return []; 155 + } 156 + 157 + const richText = new RichText( { text: description } ); 158 + richText.detectFacetsWithoutResolution(); 159 + 160 + const segments: BioSegment[] = []; 161 + for ( const segment of richText.segments() ) { 162 + let href: string | null = null; 163 + 164 + if ( segment.isLink() && segment.link ) { 165 + href = safeHttpHref( segment.link.uri ); 166 + } else if ( segment.isMention() ) { 167 + const handle = segment.text.replace( /^@/, '' ); 168 + href = handle 169 + ? `https://bsky.app/profile/${ encodeURIComponent( handle ) }` 170 + : null; 171 + } else if ( segment.isTag() ) { 172 + const tag = segment.text.replace( /^#/, '' ); 173 + href = tag ? `https://bsky.app/hashtag/${ encodeURIComponent( tag ) }` : null; 174 + } 175 + 176 + segments.push( 177 + href 178 + ? { type: 'link', text: segment.text, href } 179 + : { type: 'text', text: segment.text } 180 + ); 181 + } 182 + 183 + return segments; 184 + } 185 + ``` 186 + 187 + - [ ] **Step 4: Run the test to verify it passes** 188 + 189 + Run: `npx vitest run src/lib/reader/rich-text.test.ts` 190 + 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. 191 + 192 + - [ ] **Step 5: Type-check** 193 + 194 + Run: `npm run check` 195 + 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.) 196 + 197 + - [ ] **Step 6: Commit** 198 + 199 + ```bash 200 + git add src/lib/reader/rich-text.ts src/lib/reader/rich-text.test.ts 201 + git commit --no-gpg-sign -m "Add bio rich-text facet detection for author pages" 202 + ``` 203 + 204 + --- 205 + 206 + ### Task 2: Render linkified bio on the author page 207 + 208 + **Files:** 209 + - Modify: `src/pages/[author]/index.astro` (frontmatter ~line 22-47; bio line 101; styles ~line 225-229) 210 + 211 + - [ ] **Step 1: Import the detector** 212 + 213 + In the frontmatter import block (after the existing `fetchActorProfile` import, ~line 6), add: 214 + 215 + ```astro 216 + import { detectBioSegments } from '../../lib/reader/rich-text'; 217 + ``` 218 + 219 + - [ ] **Step 2: Declare and populate `bioSegments`** 220 + 221 + In the `let` declarations block (near line 22-25), add: 222 + 223 + ```astro 224 + let bioSegments: ReturnType< typeof detectBioSegments > = []; 225 + ``` 226 + 227 + Inside the `if ( ! error && resolved ) {` block, after `initial` is set (~line 46), add: 228 + 229 + ```astro 230 + bioSegments = profile.description ? detectBioSegments( profile.description ) : []; 231 + ``` 232 + 233 + - [ ] **Step 3: Replace the plain-text bio line** 234 + 235 + Replace line 101: 236 + 237 + ```astro 238 + {profile!.description && <p class="author__bio">{profile!.description}</p>} 239 + ``` 240 + 241 + with: 242 + 243 + ```astro 244 + {bioSegments.length > 0 && ( 245 + <p class="author__bio"> 246 + {bioSegments.map( ( seg ) => 247 + seg.type === 'link' ? ( 248 + <a href={seg.href} target="_blank" rel="noopener noreferrer nofollow"> 249 + {seg.text} 250 + </a> 251 + ) : ( 252 + seg.text 253 + ) 254 + )} 255 + </p> 256 + )} 257 + ``` 258 + 259 + > Leave the `Base` `description={profile!.description ?? undefined}` prop (line 58) unchanged — the SEO meta description stays raw plain text. 260 + 261 + - [ ] **Step 4: Style bio links** 262 + 263 + In the `.author__bio` style rule (~line 225-229), add a following rule: 264 + 265 + ```css 266 + .author__bio a { 267 + color: var(--sun); 268 + } 269 + ``` 270 + 271 + - [ ] **Step 5: Type-check and build** 272 + 273 + Run: `npm run check && npm run build` 274 + 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.) 275 + 276 + - [ ] **Step 6: Manual verification (dev)** 277 + 278 + 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`). 279 + 280 + Run: `npm run dev` 281 + 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. 282 + 283 + - [ ] **Step 7: Commit** 284 + 285 + ```bash 286 + git add src/pages/[author]/index.astro 287 + git commit --no-gpg-sign -m "Render linkified bios on the author page" 288 + ``` 289 + 290 + --- 291 + 292 + ### Task 3: Docs — update contract comment and record the decision 293 + 294 + **Files:** 295 + - Modify: `src/lib/reader/profile.ts:9` 296 + - Create: `docs/decisions/0015-author-bio-richtext.md` 297 + 298 + - [ ] **Step 1: Update the `profile.ts` contract comment** 299 + 300 + Replace line 9: 301 + 302 + ```ts 303 + * `displayName`/`description` are plain text; callers render them as text, never as HTML. 304 + ``` 305 + 306 + with: 307 + 308 + ```ts 309 + * `displayName` is plain text. `description` is plain text in the record, but the author 310 + * page linkifies it at render via `detectBioSegments` (rich-text.ts), mirroring Bluesky. 311 + * Neither is ever injected as raw HTML. (Decision 0015.) 312 + ``` 313 + 314 + - [ ] **Step 2: Write the decision record** 315 + 316 + Create `docs/decisions/0015-author-bio-richtext.md`: 317 + 318 + ```markdown 319 + # 0015 — Linkified author bios from detected facets 320 + 321 + ## Context 322 + 323 + Author pages show the writer's Bluesky bio (`app.bsky.actor.profile.description`). On 324 + bsky.app that bio has clickable URLs, `@mentions`, and `#tags`, but those links are **not 325 + in the PDS record** — profile descriptions are plain text and carry no `facets` (unlike 326 + `app.bsky.feed.post`). Bluesky's client computes them at render time with 327 + `RichText.detectFacets()`. We want the same on the author page. 328 + 329 + ## Options 330 + 331 + 1. **Hand-rolled regex detector.** Full control, no extra import, but "match Bluesky 332 + fully" becomes a hand-maintained moving target (URL tokenizer subtleties, bare domains, 333 + trailing punctuation, unicode tags) that silently drifts from bsky.app. 334 + 2. **`@atproto/api` `RichText.detectFacets(agent)`.** Exact Bluesky behaviour *including* 335 + handle→DID resolution — but resolution means network calls and new SSRF surface, for no 336 + benefit since we link mentions by handle. 337 + 3. **`@atproto/api` `RichText.detectFacetsWithoutResolution()`.** The same detection code 338 + Bluesky runs, minus mention resolution. Pure, no network. 339 + 340 + ## Choice 341 + 342 + Option 3. A pure module `src/lib/reader/rich-text.ts` exposes `detectBioSegments()`, which 343 + returns ordered `text`/`link` segments. The author page renders them with Astro 344 + auto-escaping (no `set:html`). Mentions link to `https://bsky.app/profile/<handle>`, tags 345 + to `https://bsky.app/hashtag/<tag>`, URLs to the detected uri. An http(s) scheme guard 346 + (`safeHttpHref`) drops any non-http(s) link as defence in depth. 347 + 348 + ## Why 349 + 350 + - **Fidelity by construction.** Reusing atproto's detection means our links can't drift 351 + from bsky.app's. 352 + - **No new read-path risk.** No network call, so the SSRF guard (AGENTS.md constraint 6a) 353 + does not apply; no `set:html`, so the sanitiser (6b) does not apply — Astro escapes both 354 + text and attributes, and the scheme guard blocks hostile URL schemes. 355 + - **No DID resolution needed** because mentions link by handle (we accept linking to 356 + Bluesky rather than internal author pages for now). 357 + 358 + ## Scope 359 + 360 + Author bios only. Publication descriptions, display names, and post excerpts are 361 + unchanged. Article body links are unaffected — they are authored HTML anchors in the block 362 + tree, never facets. 363 + ``` 364 + 365 + - [ ] **Step 3: Commit** 366 + 367 + ```bash 368 + git add src/lib/reader/profile.ts docs/decisions/0015-author-bio-richtext.md 369 + git commit --no-gpg-sign -m "Document bio rich-text contract and record decision 0015" 370 + ``` 371 + 372 + --- 373 + 374 + ## Final verification 375 + 376 + - [ ] Run the full suite: `npm run test` 377 + - [ ] Run: `npm run check && npm run build` 378 + - [ ] All green → ready for branch finish / PR.