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 design doc for linkified author bios

+129
+129
docs/superpowers/specs/2026-06-09-author-bio-richtext-design.md
··· 1 + # Author bio rich text (linkified bios) — design 2 + 3 + **Date:** 2026-06-09 4 + **Status:** Approved, ready for plan 5 + **Branch:** `bio-links-from-facets` 6 + 7 + ## Problem 8 + 9 + The public author page (`src/pages/[author]/index.astro`) renders the writer's Bluesky 10 + bio (`app.bsky.actor.profile` → `description`) as plain text. On bsky.app the same bio 11 + shows clickable links, `@mentions`, and `#tags` — but those are **not stored in the PDS 12 + record**. The `description` field is plain text; profile records carry no `facets` 13 + (unlike `app.bsky.feed.post`). Bluesky's client computes the links at render time by 14 + running `RichText.detectFacets()` over the description. 15 + 16 + We want the SkyPress author page to surface those same links. 17 + 18 + ## Scope 19 + 20 + - **In scope:** author bios only. 21 + - **Match Bluesky fully:** URLs (including bare domains), `@mentions`, `#tags`. 22 + - **Out of scope (for now):** publication descriptions, display names, post excerpts. 23 + Article body links are unaffected — they are authored HTML anchors in the block tree, 24 + never facets, and already render correctly. 25 + 26 + ## Decisions 27 + 28 + - **Detection: `@atproto/api`'s `RichText.detectFacetsWithoutResolution()`** — the exact 29 + code Bluesky's client runs, so detection fidelity (URL tokenizer, bare domains, mention 30 + and tag rules, trailing-punctuation handling) is guaranteed by construction. The 31 + `WithoutResolution` variant skips handle→DID resolution, which we don't need (we link by 32 + handle) and which keeps the path **network-free**. 33 + - **Mention links target Bluesky** (`https://bsky.app/profile/<handle>`). Truest to 34 + "match Bluesky fully", needs no DID resolution, and avoids dumping readers on empty 35 + SkyPress author pages for people who only exist on Bluesky. (Internal linking can be a 36 + later enhancement.) 37 + - **Tag links** target `https://bsky.app/hashtag/<tag>`. 38 + - **No `set:html`.** The module returns structured segments; the Astro template renders 39 + them with auto-escaping. We never build or inject an HTML string. 40 + 41 + ## Architecture 42 + 43 + ### `src/lib/reader/rich-text.ts` (new, pure, no network) 44 + 45 + ```ts 46 + export type BioSegment = 47 + | { type: 'text'; text: string } 48 + | { type: 'link'; text: string; href: string }; 49 + 50 + export function detectBioSegments(description: string): BioSegment[]; 51 + ``` 52 + 53 + Builds `new RichText({ text: description })`, calls `detectFacetsWithoutResolution()`, 54 + then walks `rt.segments()`: 55 + 56 + - **Link** segment → `{ type: 'link', text: <as typed>, href: segment.link.uri }`. 57 + `detectFacets` already prepends `https://` to bare domains, so `example.com` → 58 + text `example.com`, href `https://example.com`. 59 + - **Mention** segment → `href: https://bsky.app/profile/<handle>` (handle from the 60 + segment text with the leading `@` stripped). 61 + - **Tag** segment → `href: https://bsky.app/hashtag/<tag>`. 62 + - Anything else → `{ type: 'text' }`. 63 + 64 + **Scheme guard (defense in depth):** only emit a `link` segment when the resolved href 65 + scheme is `http:` or `https:`; otherwise fall back to a `text` segment. `detectFacets` 66 + should never produce another scheme, but this guarantees a hostile PDS can't smuggle a 67 + `javascript:` link through. 68 + 69 + Empty/whitespace input → `[]`. 70 + 71 + ### Rendering — `src/pages/[author]/index.astro` 72 + 73 + Replace the plain-text bio line with a map over segments: 74 + 75 + ```astro 76 + {bioSegments.length > 0 && ( 77 + <p class="author__bio"> 78 + {bioSegments.map((seg) => 79 + seg.type === 'link' ? ( 80 + <a href={seg.href} target="_blank" rel="noopener noreferrer nofollow">{seg.text}</a> 81 + ) : ( 82 + seg.text 83 + ) 84 + )} 85 + </p> 86 + )} 87 + ``` 88 + 89 + `bioSegments` is computed in the frontmatter from `profile.description`. 90 + 91 + ## Security 92 + 93 + - **No HTML injection.** Astro auto-escapes both `{seg.text}` (a bio containing `<script>` 94 + or `&` renders as literal text) and the `href` attribute. No `set:html`, so the 95 + sanitize path (constraint 6b) doesn't apply. 96 + - **No network call**, so the SSRF guard (constraint 6a) doesn't apply — 97 + `detectFacetsWithoutResolution()` is pure. 98 + - **Scheme guard** as above. 99 + - Links carry `rel="noopener noreferrer nofollow"` + `target="_blank"`, matching the 100 + existing handle link and appropriate for untrusted external URLs. 101 + 102 + ## Testing (TDD — tests first, must fail before implementation) 103 + 104 + `src/lib/reader/rich-text.test.ts`: 105 + 106 + - Plain text, no facets → one `text` segment, content unchanged. 107 + - Full URL → `link` with correct href + display text; trailing punctuation 108 + (`Visit https://example.com.`) stays out of the link. 109 + - Bare domain (`example.com`) → `link` with `https://` href, bare display text. 110 + - `@alice.bsky.social` → `link` to `https://bsky.app/profile/alice.bsky.social`. 111 + - `#design` → `link` to `https://bsky.app/hashtag/design`. 112 + - Mixed bio (text + URL + mention + tag) → correctly ordered segment array. 113 + - Scheme guard → a non-http(s) URI yields a `text` segment, not a `link`. 114 + - Empty/whitespace description → `[]`. 115 + 116 + Fidelity is inherent (we *use* `RichText`), so no separate fidelity-lock assertion is 117 + needed; the behavior tests are the contract. 118 + 119 + ## Docs 120 + 121 + - Update the contract comment in `src/lib/reader/profile.ts` — `description` is no longer 122 + "rendered as text, never HTML"; it is parsed into linkified segments at render. 123 + - Add `docs/decisions/0015-author-bio-richtext.md` recording the read-path call. 124 + 125 + ## Risk 126 + 127 + `@atproto/api`'s `RichText` must import cleanly in the SSR/node context (no `window` at 128 + import). The vitest run (node env) exercises this immediately; if it pulls anything 129 + browser-only, isolate the import. Not expected — `RichText` is a pure class.