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 editor @mentions + notifications

+1613
+1613
docs/superpowers/plans/2026-06-12-editor-mentions.md
··· 1 + # Editor @mentions + Bluesky mention notifications — 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:** Let a writer `@`-mention atproto users in the article body; render those mentions as profile links, and notify the mentioned users via `#mention` facets on the companion Bluesky post. 6 + 7 + **Architecture:** A registered `skypress/mention` rich-text format stores the mention inline in the block tree as `<a class="skypress-mention" href="https://bsky.app/profile/{handle}" data-did="{did}">@{handle}</a>` — the single source of truth. At publish, we scan blocks for these anchors and derive three outputs: (1) the companion `app.bsky.feed.post` body gains the lede + a `cc @a @b` line with `#mention` facets (the notification), (2) the `blog.skypress.content.gutenberg` content object gains a flat `mentions` list (interop), (3) the article render gets the link for free (verbatim content pass-through). The reader's sanitizer strips `data-did` but keeps the `class` + `href`, yielding a clean profile link. 8 + 9 + **Tech Stack:** TypeScript, Astro, React 18, `@automattic/isolated-block-editor@2.30.0`, `@wordpress/rich-text@7.24.0`, `@wordpress/block-editor@14.19.0`, `@wordpress/hooks`, `@atproto/api`, Vitest (jsdom). 10 + 11 + **Spec:** `docs/superpowers/specs/2026-06-12-editor-mentions-design.md` 12 + 13 + --- 14 + 15 + ## File Structure 16 + 17 + **New files:** 18 + - `src/lib/publish/grapheme.ts` — `graphemeLength()` util (Intl.Segmenter). 19 + - `src/lib/publish/grapheme.test.ts` 20 + - `src/lib/publish/mentions.ts` — `Mention` type + `collectMentions(blocks)`. 21 + - `src/lib/publish/mentions.test.ts` 22 + - `src/lib/publish/post-text.ts` — `assemblePostText()` (shared text+facet assembly). 23 + - `src/lib/publish/post-text.test.ts` 24 + - `src/lib/editor/mention-format.ts` — `registerMentionFormat()`. 25 + - `src/lib/editor/mention-format.test.ts` 26 + - `src/lib/editor/mention-autocompleter.ts` — completer + `registerMentionAutocompleter()`. 27 + - `src/lib/editor/mention-autocompleter.test.ts` 28 + - `docs/decisions/0019-editor-mentions.md` — decision record. 29 + 30 + **Modified files:** 31 + - `src/lib/publish/records.ts` — `buildBskyPost` (use `assemblePostText`, add `bodyLede`/`mentions`); `buildContentObject` + `buildDocumentRecord` (+ optional `mentions`); `GutenbergContent` type. 32 + - `src/lib/publish/publisher.ts` — wire `collectMentions` + `bodyLede` into `publish()` and `updateDocument()`. 33 + - `lexicons/blog.skypress.content.gutenberg.json` — document optional `mentions` field. 34 + - `src/components/SkyEditor.tsx` — call `registerMentionFormat()` + `registerMentionAutocompleter()`. 35 + - `src/components/PublishPanel.tsx` — grapheme counter + confirm-dialog mention disclosure. 36 + - `src/lib/blocks/render.test.ts` — fidelity assertion for a mention-bearing block. 37 + - `src/lib/reader/sanitize.test.ts` — assert `data-did` stripped, `class` + `href` kept. 38 + 39 + **Note on test command:** `npm run test` runs `vitest run` (all tests). To run one file: `npx vitest run src/lib/publish/grapheme.test.ts`. To run by name: `npx vitest run -t "graphemeLength"`. 40 + 41 + --- 42 + 43 + ## Phase 1 — Publish-side core (pure, fully TDD) 44 + 45 + This phase delivers the notification mechanism. Tests synthesize blocks containing mention anchors directly (the editor that produces them comes in Phase 2), so everything here is unit-testable now. 46 + 47 + ### Task 1: Grapheme length utility 48 + 49 + **Files:** 50 + - Create: `src/lib/publish/grapheme.ts` 51 + - Test: `src/lib/publish/grapheme.test.ts` 52 + 53 + - [ ] **Step 1: Write the failing test** 54 + 55 + ```typescript 56 + // src/lib/publish/grapheme.test.ts 57 + import { describe, expect, it } from 'vitest'; 58 + import { graphemeLength } from './grapheme'; 59 + 60 + describe( 'graphemeLength', () => { 61 + it( 'counts ASCII characters one-for-one', () => { 62 + expect( graphemeLength( 'hello' ) ).toBe( 5 ); 63 + } ); 64 + 65 + it( 'counts an emoji as a single grapheme', () => { 66 + // "👍" is 2 UTF-16 code units / 4 UTF-8 bytes but one grapheme. 67 + expect( graphemeLength( '👍' ) ).toBe( 1 ); 68 + } ); 69 + 70 + it( 'counts a multi-codepoint emoji cluster as one grapheme', () => { 71 + // Family emoji = several codepoints joined by ZWJ, still one grapheme. 72 + expect( graphemeLength( '👨‍👩‍👧' ) ).toBe( 1 ); 73 + } ); 74 + 75 + it( 'returns 0 for the empty string', () => { 76 + expect( graphemeLength( '' ) ).toBe( 0 ); 77 + } ); 78 + } ); 79 + ``` 80 + 81 + - [ ] **Step 2: Run test to verify it fails** 82 + 83 + Run: `npx vitest run src/lib/publish/grapheme.test.ts` 84 + Expected: FAIL — "Failed to resolve import './grapheme'". 85 + 86 + - [ ] **Step 3: Write minimal implementation** 87 + 88 + ```typescript 89 + // src/lib/publish/grapheme.ts 90 + 91 + /** 92 + * Count graphemes (user-perceived characters) the way Bluesky counts toward its 93 + * 300-character post limit — emoji and ZWJ clusters count as one, not as their 94 + * UTF-16 length or byte length. 95 + */ 96 + export function graphemeLength( text: string ): number { 97 + const segmenter = new Intl.Segmenter( undefined, { granularity: 'grapheme' } ); 98 + let count = 0; 99 + for ( const _segment of segmenter.segment( text ) ) { 100 + count++; 101 + } 102 + return count; 103 + } 104 + ``` 105 + 106 + - [ ] **Step 4: Run test to verify it passes** 107 + 108 + Run: `npx vitest run src/lib/publish/grapheme.test.ts` 109 + Expected: PASS (4 tests). 110 + 111 + - [ ] **Step 5: Commit** 112 + 113 + ```bash 114 + git add src/lib/publish/grapheme.ts src/lib/publish/grapheme.test.ts 115 + git commit --no-gpg-sign -m "Add graphemeLength util for Bluesky post limit" 116 + ``` 117 + 118 + --- 119 + 120 + ### Task 2: Collect mentions from the block tree 121 + 122 + `collectMentions` scans every string attribute of every block (recursively) for `<a data-did="…">` anchors — the mention markers — using `DOMParser` (available in both jsdom tests and the browser publish path). Dedupes by DID, preserves first-appearance order. 123 + 124 + **Files:** 125 + - Create: `src/lib/publish/mentions.ts` 126 + - Test: `src/lib/publish/mentions.test.ts` 127 + 128 + - [ ] **Step 1: Write the failing test** 129 + 130 + ```typescript 131 + // src/lib/publish/mentions.test.ts 132 + import { describe, expect, it } from 'vitest'; 133 + import { collectMentions } from './mentions'; 134 + import type { BlockNode } from '../blocks/render'; 135 + 136 + function para( content: string ): BlockNode { 137 + return { name: 'core/paragraph', attributes: { content }, innerBlocks: [] }; 138 + } 139 + 140 + const MENTION = ( 141 + handle: string, 142 + did: string 143 + ) => `<a class="skypress-mention" href="https://bsky.app/profile/${ handle }" data-did="${ did }">@${ handle }</a>`; 144 + 145 + describe( 'collectMentions', () => { 146 + it( 'extracts did, handle, and display text from a mention anchor', () => { 147 + const blocks = [ para( `Thanks ${ MENTION( 'alice.bsky.social', 'did:plc:alice' ) }!` ) ]; 148 + expect( collectMentions( blocks ) ).toEqual( [ 149 + { did: 'did:plc:alice', handle: 'alice.bsky.social', displayText: '@alice.bsky.social' }, 150 + ] ); 151 + } ); 152 + 153 + it( 'ignores ordinary links that are not mentions', () => { 154 + const blocks = [ para( 'See <a href="https://example.com">the docs</a>.' ) ]; 155 + expect( collectMentions( blocks ) ).toEqual( [] ); 156 + } ); 157 + 158 + it( 'dedupes by DID, keeping first-appearance order', () => { 159 + const blocks = [ 160 + para( `${ MENTION( 'bob.bsky.social', 'did:plc:bob' ) }` ), 161 + para( `${ MENTION( 'alice.bsky.social', 'did:plc:alice' ) }` ), 162 + para( `again ${ MENTION( 'bob.bsky.social', 'did:plc:bob' ) }` ), 163 + ]; 164 + expect( collectMentions( blocks ).map( ( m ) => m.handle ) ).toEqual( [ 165 + 'bob.bsky.social', 166 + 'alice.bsky.social', 167 + ] ); 168 + } ); 169 + 170 + it( 'recurses into innerBlocks', () => { 171 + const blocks: BlockNode[] = [ 172 + { 173 + name: 'core/quote', 174 + attributes: {}, 175 + innerBlocks: [ para( `${ MENTION( 'carol.bsky.social', 'did:plc:carol' ) }` ) ], 176 + }, 177 + ]; 178 + expect( collectMentions( blocks ).map( ( m ) => m.did ) ).toEqual( [ 'did:plc:carol' ] ); 179 + } ); 180 + 181 + it( 'returns [] when there are no blocks', () => { 182 + expect( collectMentions( [] ) ).toEqual( [] ); 183 + } ); 184 + } ); 185 + ``` 186 + 187 + - [ ] **Step 2: Run test to verify it fails** 188 + 189 + Run: `npx vitest run src/lib/publish/mentions.test.ts` 190 + Expected: FAIL — "Failed to resolve import './mentions'". 191 + 192 + - [ ] **Step 3: Write minimal implementation** 193 + 194 + ```typescript 195 + // src/lib/publish/mentions.ts 196 + import type { BlockNode } from '../blocks/render'; 197 + 198 + export interface Mention { 199 + did: string; 200 + handle: string; 201 + /** The visible mention text in the article (defaults to `@{handle}`). */ 202 + displayText: string; 203 + } 204 + 205 + const PROFILE_RE = /^https?:\/\/bsky\.app\/profile\/(.+)$/; 206 + 207 + /** 208 + * Scan a block tree for `skypress/mention` anchors (`<a data-did="…">`) and return 209 + * the mentioned accounts, deduped by DID in first-appearance order. These anchors are 210 + * the single source of truth for who gets cc'd on the companion Bluesky post and for 211 + * the document's flat `mentions` list. 212 + */ 213 + export function collectMentions( blocks: BlockNode[] ): Mention[] { 214 + const out: Mention[] = []; 215 + const seen = new Set< string >(); 216 + walk( blocks, out, seen ); 217 + return out; 218 + } 219 + 220 + function walk( blocks: BlockNode[], out: Mention[], seen: Set< string > ): void { 221 + for ( const block of blocks ) { 222 + for ( const value of Object.values( block.attributes ?? {} ) ) { 223 + if ( typeof value === 'string' && value.includes( 'data-did' ) ) { 224 + extractFrom( value, out, seen ); 225 + } 226 + } 227 + walk( block.innerBlocks ?? [], out, seen ); 228 + } 229 + } 230 + 231 + function extractFrom( html: string, out: Mention[], seen: Set< string > ): void { 232 + const doc = new DOMParser().parseFromString( html, 'text/html' ); 233 + for ( const anchor of Array.from( doc.querySelectorAll( 'a[data-did]' ) ) ) { 234 + const did = anchor.getAttribute( 'data-did' ); 235 + if ( ! did || seen.has( did ) ) { 236 + continue; 237 + } 238 + const href = anchor.getAttribute( 'href' ) ?? ''; 239 + const match = href.match( PROFILE_RE ); 240 + const text = anchor.textContent ?? ''; 241 + const handle = match ? match[ 1 ] : text.replace( /^@/, '' ); 242 + seen.add( did ); 243 + out.push( { did, handle, displayText: text || `@${ handle }` } ); 244 + } 245 + } 246 + ``` 247 + 248 + - [ ] **Step 4: Run test to verify it passes** 249 + 250 + Run: `npx vitest run src/lib/publish/mentions.test.ts` 251 + Expected: PASS (5 tests). 252 + 253 + - [ ] **Step 5: Commit** 254 + 255 + ```bash 256 + git add src/lib/publish/mentions.ts src/lib/publish/mentions.test.ts 257 + git commit --no-gpg-sign -m "Add collectMentions to scan blocks for mention anchors" 258 + ``` 259 + 260 + --- 261 + 262 + ### Task 3: Assemble post text + facets (shared) 263 + 264 + `assemblePostText` is the single source for the companion post's `text` and its `facets`. It is used by `buildBskyPost` (Task 4) and the grapheme counter (Task 9), so the on-screen count and the published post never drift. It computes UTF-8 byte offsets exactly during assembly (no fragile `indexOf`). 265 + 266 + **Files:** 267 + - Create: `src/lib/publish/post-text.ts` 268 + - Test: `src/lib/publish/post-text.test.ts` 269 + 270 + - [ ] **Step 1: Write the failing test** 271 + 272 + ```typescript 273 + // src/lib/publish/post-text.test.ts 274 + import { describe, expect, it } from 'vitest'; 275 + import { assemblePostText } from './post-text'; 276 + 277 + const URL = 'https://skypress.blog/@me/pub/3kabcde12345'; 278 + 279 + function bytes( s: string ): number { 280 + return new TextEncoder().encode( s ).length; 281 + } 282 + 283 + describe( 'assemblePostText', () => { 284 + it( 'with no lede or mentions, is title + URL (back-compat)', () => { 285 + const { text, facets } = assemblePostText( { title: 'Hello', articleUrl: URL } ); 286 + expect( text ).toBe( `Hello\n\n${ URL }` ); 287 + expect( facets ).toHaveLength( 1 ); 288 + const link = facets[ 0 ]; 289 + expect( link.features[ 0 ] ).toEqual( { 290 + $type: 'app.bsky.richtext.facet#link', 291 + uri: URL, 292 + } ); 293 + expect( link.index.byteStart ).toBe( bytes( 'Hello\n\n' ) ); 294 + expect( link.index.byteEnd ).toBe( bytes( 'Hello\n\n' ) + bytes( URL ) ); 295 + } ); 296 + 297 + it( 'includes the lede between title and URL when provided', () => { 298 + const { text } = assemblePostText( { 299 + title: 'Hello', 300 + articleUrl: URL, 301 + bodyLede: 'A short lede.', 302 + } ); 303 + expect( text ).toBe( `Hello\n\nA short lede.\n\n${ URL }` ); 304 + } ); 305 + 306 + it( 'adds a cc line with a mention facet per handle', () => { 307 + const { text, facets } = assemblePostText( { 308 + title: 'Hi', 309 + articleUrl: URL, 310 + mentions: [ 311 + { did: 'did:plc:a', handle: 'alice.bsky.social', displayText: '@alice.bsky.social' }, 312 + { did: 'did:plc:b', handle: 'bob.bsky.social', displayText: '@bob.bsky.social' }, 313 + ], 314 + } ); 315 + expect( text ).toBe( `Hi\n\ncc @alice.bsky.social @bob.bsky.social\n\n${ URL }` ); 316 + 317 + const mentions = facets.filter( 318 + ( f ) => f.features[ 0 ].$type === 'app.bsky.richtext.facet#mention' 319 + ); 320 + expect( mentions ).toHaveLength( 2 ); 321 + 322 + // @alice.bsky.social byte range must point exactly at that token. 323 + const ccPrefix = 'Hi\n\ncc '; 324 + const aliceStart = bytes( ccPrefix ); 325 + expect( mentions[ 0 ].index ).toEqual( { 326 + byteStart: aliceStart, 327 + byteEnd: aliceStart + bytes( '@alice.bsky.social' ), 328 + } ); 329 + expect( mentions[ 0 ].features[ 0 ] ).toEqual( { 330 + $type: 'app.bsky.richtext.facet#mention', 331 + did: 'did:plc:a', 332 + } ); 333 + } ); 334 + 335 + it( 'computes correct byte offsets with a multibyte title (emoji)', () => { 336 + const title = '🚀 Launch'; 337 + const { text, facets } = assemblePostText( { 338 + title, 339 + articleUrl: URL, 340 + mentions: [ 341 + { did: 'did:plc:a', handle: 'alice.bsky.social', displayText: '@alice.bsky.social' }, 342 + ], 343 + } ); 344 + const mention = facets.find( 345 + ( f ) => f.features[ 0 ].$type === 'app.bsky.richtext.facet#mention' 346 + )!; 347 + const prefix = `${ title }\n\ncc `; 348 + expect( mention.index.byteStart ).toBe( bytes( prefix ) ); 349 + // Sanity: slicing the UTF-8 buffer at the facet range yields the handle. 350 + const buf = new TextEncoder().encode( text ); 351 + const slice = new TextDecoder().decode( 352 + buf.slice( mention.index.byteStart, mention.index.byteEnd ) 353 + ); 354 + expect( slice ).toBe( '@alice.bsky.social' ); 355 + } ); 356 + } ); 357 + ``` 358 + 359 + - [ ] **Step 2: Run test to verify it fails** 360 + 361 + Run: `npx vitest run src/lib/publish/post-text.test.ts` 362 + Expected: FAIL — "Failed to resolve import './post-text'". 363 + 364 + - [ ] **Step 3: Write minimal implementation** 365 + 366 + ```typescript 367 + // src/lib/publish/post-text.ts 368 + import type { Mention } from './mentions'; 369 + 370 + /** A richtext facet over a UTF-8 byte range of the post text. */ 371 + export interface PostFacet { 372 + $type: 'app.bsky.richtext.facet'; 373 + index: { byteStart: number; byteEnd: number }; 374 + features: Array< 375 + | { $type: 'app.bsky.richtext.facet#link'; uri: string } 376 + | { $type: 'app.bsky.richtext.facet#mention'; did: string } 377 + >; 378 + } 379 + 380 + const SEP = '\n\n'; 381 + 382 + function utf8ByteLength( value: string ): number { 383 + return new TextEncoder().encode( value ).length; 384 + } 385 + 386 + /** 387 + * Build the companion Bluesky post's `text` and `facets`. The body is 388 + * `title \n\n {lede?} \n\n cc @a @b \n\n {url}`, with the lede omitted when blank and the 389 + * cc line omitted when there are no mentions. Byte offsets are computed during assembly so 390 + * the link and mention facets always line up with the final UTF-8 string. 391 + */ 392 + export function assemblePostText( input: { 393 + title: string; 394 + articleUrl: string; 395 + bodyLede?: string; 396 + mentions?: Mention[]; 397 + } ): { text: string; facets: PostFacet[] } { 398 + const mentions = input.mentions ?? []; 399 + const lede = input.bodyLede?.trim() ?? ''; 400 + const ccLine = mentions.length 401 + ? `cc ${ mentions.map( ( m ) => `@${ m.handle }` ).join( ' ' ) }` 402 + : ''; 403 + 404 + const segments: string[] = [ input.title ]; 405 + if ( lede ) { 406 + segments.push( lede ); 407 + } 408 + if ( ccLine ) { 409 + segments.push( ccLine ); 410 + } 411 + segments.push( input.articleUrl ); 412 + 413 + const text = segments.join( SEP ); 414 + 415 + // Byte start of each segment in the joined string. 416 + const segByteStart: number[] = []; 417 + let cursor = 0; 418 + segments.forEach( ( segment, i ) => { 419 + if ( i > 0 ) { 420 + cursor += utf8ByteLength( SEP ); 421 + } 422 + segByteStart[ i ] = cursor; 423 + cursor += utf8ByteLength( segment ); 424 + } ); 425 + 426 + const facets: PostFacet[] = []; 427 + 428 + // Mention facets (each @handle inside the cc line). 429 + if ( ccLine ) { 430 + const ccIndex = segments.indexOf( ccLine ); 431 + let local = utf8ByteLength( 'cc ' ); 432 + mentions.forEach( ( mention, i ) => { 433 + if ( i > 0 ) { 434 + local += utf8ByteLength( ' ' ); 435 + } 436 + const token = `@${ mention.handle }`; 437 + const byteStart = segByteStart[ ccIndex ] + local; 438 + facets.push( { 439 + $type: 'app.bsky.richtext.facet', 440 + index: { byteStart, byteEnd: byteStart + utf8ByteLength( token ) }, 441 + features: [ { $type: 'app.bsky.richtext.facet#mention', did: mention.did } ], 442 + } ); 443 + local += utf8ByteLength( token ); 444 + } ); 445 + } 446 + 447 + // Link facet (the article URL, always the last segment). 448 + const urlIndex = segments.length - 1; 449 + const urlStart = segByteStart[ urlIndex ]; 450 + facets.push( { 451 + $type: 'app.bsky.richtext.facet', 452 + index: { byteStart: urlStart, byteEnd: urlStart + utf8ByteLength( input.articleUrl ) }, 453 + features: [ { $type: 'app.bsky.richtext.facet#link', uri: input.articleUrl } ], 454 + } ); 455 + 456 + return { text, facets }; 457 + } 458 + ``` 459 + 460 + - [ ] **Step 4: Run test to verify it passes** 461 + 462 + Run: `npx vitest run src/lib/publish/post-text.test.ts` 463 + Expected: PASS (4 tests). 464 + 465 + - [ ] **Step 5: Commit** 466 + 467 + ```bash 468 + git add src/lib/publish/post-text.ts src/lib/publish/post-text.test.ts 469 + git commit --no-gpg-sign -m "Add assemblePostText: post body + link/mention facets" 470 + ``` 471 + 472 + --- 473 + 474 + ### Task 4: Rewire `buildBskyPost` onto `assemblePostText` 475 + 476 + Replace `buildBskyPost`'s inline text/facet construction with `assemblePostText`, and add optional `bodyLede` + `mentions`. The `description` parameter still feeds **only** the embed card. Existing callers/tests that pass neither `bodyLede` nor `mentions` get byte-identical output (title + URL + one link facet). 477 + 478 + **Files:** 479 + - Modify: `src/lib/publish/records.ts:256-310` (the `utf8ByteLength` helper + `buildBskyPost`) 480 + - Test: `src/lib/publish/records.test.ts` (add cases) 481 + 482 + - [ ] **Step 1: Write the failing test** (append to `src/lib/publish/records.test.ts`) 483 + 484 + ```typescript 485 + // Append inside src/lib/publish/records.test.ts (it already imports buildBskyPost). 486 + describe( 'buildBskyPost — lede + mentions', () => { 487 + const URL = 'https://skypress.blog/@me/pub/3kabcde12345'; 488 + 489 + it( 'keeps the card description separate from the post body', () => { 490 + const post = buildBskyPost( { 491 + title: 'Title', 492 + articleUrl: URL, 493 + description: 'Card subtitle', 494 + createdAt: '2026-06-12T00:00:00.000Z', 495 + bodyLede: 'Body lede', 496 + } ); 497 + expect( post.text ).toBe( `Title\n\nBody lede\n\n${ URL }` ); 498 + expect( post.embed.external.description ).toBe( 'Card subtitle' ); 499 + } ); 500 + 501 + it( 'emits a mention facet for each cc-ed account', () => { 502 + const post = buildBskyPost( { 503 + title: 'Title', 504 + articleUrl: URL, 505 + description: 'd', 506 + createdAt: '2026-06-12T00:00:00.000Z', 507 + mentions: [ 508 + { did: 'did:plc:a', handle: 'alice.bsky.social', displayText: '@alice.bsky.social' }, 509 + ], 510 + } ); 511 + expect( post.text ).toContain( 'cc @alice.bsky.social' ); 512 + const hasMention = post.facets.some( 513 + ( f ) => f.features[ 0 ].$type === 'app.bsky.richtext.facet#mention' 514 + ); 515 + expect( hasMention ).toBe( true ); 516 + } ); 517 + 518 + it( 'throws when the assembled post exceeds 300 graphemes', () => { 519 + const longLede = 'x'.repeat( 320 ); 520 + expect( () => 521 + buildBskyPost( { 522 + title: 'Title', 523 + articleUrl: URL, 524 + description: 'd', 525 + createdAt: '2026-06-12T00:00:00.000Z', 526 + bodyLede: longLede, 527 + } ) 528 + ).toThrow( /300/ ); 529 + } ); 530 + } ); 531 + ``` 532 + 533 + - [ ] **Step 2: Run test to verify it fails** 534 + 535 + Run: `npx vitest run src/lib/publish/records.test.ts -t "lede + mentions"` 536 + Expected: FAIL — `bodyLede`/`mentions` not accepted; no 300 guard. 537 + 538 + - [ ] **Step 3: Edit `buildBskyPost` in `src/lib/publish/records.ts`** 539 + 540 + Add imports near the top of the file (with the other imports): 541 + 542 + ```typescript 543 + import { assemblePostText } from './post-text'; 544 + import { graphemeLength } from './grapheme'; 545 + import type { Mention } from './mentions'; 546 + ``` 547 + 548 + Replace the existing `utf8ByteLength` helper and `buildBskyPost` function (the block currently at `records.ts:256-310`) with: 549 + 550 + ```typescript 551 + /** UTF-8 byte length — facet offsets are byte-based, not JS string-index based. */ 552 + function utf8ByteLength( value: string ): number { 553 + return new TextEncoder().encode( value ).length; 554 + } 555 + 556 + /** Bluesky rejects posts longer than this many graphemes. */ 557 + const BSKY_POST_MAX_GRAPHEMES = 300; 558 + 559 + /** 560 + * Build the companion Bluesky post (Decision 0005). Body text + link/mention facets come 561 + * from `assemblePostText` (shared with the editor's live counter). `description` populates 562 + * ONLY the embed card subtitle; the writer-typed lede goes in the body via `bodyLede`. 563 + * `associatedRefs` drive the standard.site rich card (Decision 0013). 564 + */ 565 + export function buildBskyPost( input: { 566 + title: string; 567 + articleUrl: string; 568 + createdAt: string; 569 + description?: string; 570 + /** The writer-typed lede, placed in the post body (omitted when blank). */ 571 + bodyLede?: string; 572 + /** Accounts to cc + notify via #mention facets. */ 573 + mentions?: Mention[]; 574 + /** Optional image blob for the card's `thumb` (the og:image fallback, Decision 0014). */ 575 + thumb?: BlobRefJson; 576 + /** Document + publication strongRefs, in that order, for the standard.site card. */ 577 + associatedRefs?: StrongRef[]; 578 + } ): BskyPostRecord { 579 + const { text, facets } = assemblePostText( { 580 + title: input.title, 581 + articleUrl: input.articleUrl, 582 + bodyLede: input.bodyLede, 583 + mentions: input.mentions, 584 + } ); 585 + 586 + const length = graphemeLength( text ); 587 + if ( length > BSKY_POST_MAX_GRAPHEMES ) { 588 + throw new Error( 589 + `Bluesky post is ${ length } characters; the limit is ${ BSKY_POST_MAX_GRAPHEMES }. Shorten the subtitle or remove a mention.` 590 + ); 591 + } 592 + 593 + return { 594 + $type: 'app.bsky.feed.post', 595 + text, 596 + createdAt: input.createdAt, 597 + facets, 598 + embed: { 599 + $type: 'app.bsky.embed.external', 600 + external: { 601 + uri: input.articleUrl, 602 + title: input.title, 603 + description: input.description ?? '', 604 + ...( input.thumb ? { thumb: input.thumb } : {} ), 605 + ...( input.associatedRefs && input.associatedRefs.length 606 + ? { 607 + associatedRefs: input.associatedRefs.map( ( ref ) => ( { 608 + $type: 'com.atproto.repo.strongRef' as const, 609 + uri: ref.uri, 610 + cid: ref.cid, 611 + } ) ), 612 + } 613 + : {} ), 614 + }, 615 + }, 616 + }; 617 + } 618 + ``` 619 + 620 + Then update the `BskyPostRecord.facets` type (around `records.ts:236-254`) so its `facets` accepts both feature kinds. Change the `facets` field type from `BskyLinkFacet[]` to `PostFacet[]`, importing the type: 621 + 622 + ```typescript 623 + import type { PostFacet } from './post-text'; 624 + ``` 625 + 626 + and in `interface BskyPostRecord` replace `facets: BskyLinkFacet[];` with `facets: PostFacet[];`. Leave the now-unused `BskyLinkFacet` interface in place only if other modules import it; otherwise delete it (grep first: `git grep -n BskyLinkFacet`). 627 + 628 + - [ ] **Step 4: Run the records tests** 629 + 630 + Run: `npx vitest run src/lib/publish/records.test.ts` 631 + Expected: PASS — new cases pass and the pre-existing `buildBskyPost` tests (title + URL, link-facet offsets) still pass. 632 + 633 + - [ ] **Step 5: Commit** 634 + 635 + ```bash 636 + git add src/lib/publish/records.ts src/lib/publish/records.test.ts 637 + git commit --no-gpg-sign -m "Rewire buildBskyPost onto assemblePostText; add lede + mentions + 300-grapheme guard" 638 + ``` 639 + 640 + --- 641 + 642 + ### Task 5: Store the flat `mentions` list in the content object 643 + 644 + **Files:** 645 + - Modify: `src/lib/publish/records.ts` (`GutenbergContent` type, `buildContentObject`, `buildDocumentRecord`) 646 + - Test: `src/lib/publish/records.test.ts` 647 + 648 + - [ ] **Step 1: Write the failing test** (append to `records.test.ts`) 649 + 650 + ```typescript 651 + describe( 'buildContentObject — mentions', () => { 652 + it( 'omits the mentions field when there are none', () => { 653 + const content = buildContentObject( BLOCKS ); 654 + expect( 'mentions' in content ).toBe( false ); 655 + } ); 656 + 657 + it( 'stores a flat {did, handle} list when mentions are passed', () => { 658 + const content = buildContentObject( BLOCKS, [ 659 + { did: 'did:plc:a', handle: 'alice.bsky.social', displayText: '@alice.bsky.social' }, 660 + ] ); 661 + expect( content.mentions ).toEqual( [ { did: 'did:plc:a', handle: 'alice.bsky.social' } ] ); 662 + } ); 663 + } ); 664 + ``` 665 + 666 + - [ ] **Step 2: Run test to verify it fails** 667 + 668 + Run: `npx vitest run src/lib/publish/records.test.ts -t "buildContentObject — mentions"` 669 + Expected: FAIL — `buildContentObject` takes one arg; `mentions` not on the type. 670 + 671 + - [ ] **Step 3: Edit `records.ts`** 672 + 673 + Find the `GutenbergContent` interface and add an optional field: 674 + 675 + ```typescript 676 + mentions?: Array< { did: string; handle: string } >; 677 + ``` 678 + 679 + Replace `buildContentObject` (currently `records.ts:141-143`) with: 680 + 681 + ```typescript 682 + export function buildContentObject( 683 + blocks: BlockNode[], 684 + mentions: Mention[] = [] 685 + ): GutenbergContent { 686 + return { 687 + $type: CONTENT_TYPE, 688 + version: CONTENT_VERSION, 689 + blocks, 690 + ...( mentions.length 691 + ? { mentions: mentions.map( ( m ) => ( { did: m.did, handle: m.handle } ) ) } 692 + : {} ), 693 + }; 694 + } 695 + ``` 696 + 697 + In `buildDocumentRecord` (currently `records.ts:195-222`), add `mentions?: Mention[]` to its input type and pass it through: 698 + 699 + ```typescript 700 + content: buildContentObject( input.blocks, input.mentions ), 701 + ``` 702 + 703 + - [ ] **Step 4: Run test to verify it passes** 704 + 705 + Run: `npx vitest run src/lib/publish/records.test.ts` 706 + Expected: PASS (all, including prior). 707 + 708 + - [ ] **Step 5: Commit** 709 + 710 + ```bash 711 + git add src/lib/publish/records.ts src/lib/publish/records.test.ts 712 + git commit --no-gpg-sign -m "Store flat mentions list in blog.skypress.content.gutenberg" 713 + ``` 714 + 715 + --- 716 + 717 + ### Task 6: Wire mentions + lede into `publish()` and `updateDocument()` 718 + 719 + **Files:** 720 + - Modify: `src/lib/publish/publisher.ts` (`publish()` at `:68-151`, `updateDocument()` below it) 721 + - Test: `src/lib/publish/publisher.test.ts` (create if absent; otherwise append) 722 + 723 + - [ ] **Step 1: Write the failing test** 724 + 725 + First check for an existing test file: `ls src/lib/publish/publisher.test.ts`. If it exists, append the `describe` below; if not, create it with this content: 726 + 727 + ```typescript 728 + // src/lib/publish/publisher.test.ts 729 + import { describe, expect, it, vi } from 'vitest'; 730 + import { publish } from './publisher'; 731 + import type { BlockNode } from '../blocks/render'; 732 + 733 + function mentionPara( handle: string, did: string ): BlockNode { 734 + return { 735 + name: 'core/paragraph', 736 + attributes: { 737 + content: `Hi <a class="skypress-mention" href="https://bsky.app/profile/${ handle }" data-did="${ did }">@${ handle }</a>`, 738 + }, 739 + innerBlocks: [], 740 + }; 741 + } 742 + 743 + /** A fake atproto Agent that records the records it is asked to write. */ 744 + function fakeAgent() { 745 + const created: Array< { collection: string; record: any } > = []; 746 + let n = 0; 747 + const repo = { 748 + createRecord: vi.fn( async ( { collection, record }: any ) => { 749 + created.push( { collection, record } ); 750 + n++; 751 + return { data: { uri: `at://did:plc:me/${ collection }/rkey${ n }`, cid: `cid${ n }` } }; 752 + } ), 753 + putRecord: vi.fn( async ( { collection, record }: any ) => { 754 + created.push( { collection, record } ); 755 + return { data: { uri: 'at://did:plc:me/doc/rkey', cid: 'cidX' } }; 756 + } ), 757 + }; 758 + return { agent: { com: { atproto: { repo } } } as any, created }; 759 + } 760 + 761 + describe( 'publish — mentions', () => { 762 + it( 'cc's mentioned accounts on the post and stores the flat list on the document', async () => { 763 + const { agent, created } = fakeAgent(); 764 + await publish( 765 + agent, 766 + { did: 'did:plc:me', handle: 'me.bsky.social' }, 767 + { 768 + title: 'Hello', 769 + description: 'My lede', 770 + blocks: [ mentionPara( 'alice.bsky.social', 'did:plc:alice' ) ], 771 + publicationUri: 'at://did:plc:me/site.standard.publication/pub', 772 + publicationCid: 'cidpub', 773 + publicationSlug: 'pub', 774 + } 775 + ); 776 + 777 + const post = created.find( ( c ) => c.collection === 'app.bsky.feed.post' )!.record; 778 + expect( post.text ).toContain( 'My lede' ); 779 + expect( post.text ).toContain( 'cc @alice.bsky.social' ); 780 + expect( 781 + post.facets.some( 782 + ( f: any ) => f.features[ 0 ].$type === 'app.bsky.richtext.facet#mention' 783 + ) 784 + ).toBe( true ); 785 + 786 + const doc = created.find( ( c ) => c.collection === 'site.standard.document' )!.record; 787 + expect( doc.content.mentions ).toEqual( [ 788 + { did: 'did:plc:alice', handle: 'alice.bsky.social' }, 789 + ] ); 790 + } ); 791 + } ); 792 + ``` 793 + 794 + - [ ] **Step 2: Run test to verify it fails** 795 + 796 + Run: `npx vitest run src/lib/publish/publisher.test.ts` 797 + Expected: FAIL — post body lacks the lede + cc line; `doc.content.mentions` undefined. 798 + 799 + - [ ] **Step 3: Edit `publisher.ts`** 800 + 801 + Add the import near the top: 802 + 803 + ```typescript 804 + import { collectMentions } from './mentions'; 805 + ``` 806 + 807 + In `publish()`, just after `const description = input.description?.trim() || deriveExcerpt( textContent );`, add: 808 + 809 + ```typescript 810 + const mentions = collectMentions( input.blocks ); 811 + // The post BODY uses the writer-typed lede only (never the derived excerpt). 812 + const bodyLede = input.description?.trim() || undefined; 813 + ``` 814 + 815 + In the **first** `buildDocumentRecord(...)` call (step 1, no `bskyPostRef`), add `mentions,` to the object. In the **third** `buildDocumentRecord(...)` call (step 3, with `bskyPostRef`), also add `mentions,`. 816 + 817 + In the `buildBskyPost(...)` call (step 2), add `bodyLede,` and `mentions,`: 818 + 819 + ```typescript 820 + buildBskyPost( { 821 + title: input.title, 822 + articleUrl, 823 + description, 824 + bodyLede, 825 + mentions, 826 + createdAt: now, 827 + thumb: input.coverImage ?? firstImageBlobRef( input.blocks ), 828 + associatedRefs: [ documentRef, publicationRef ], 829 + } ) 830 + ``` 831 + 832 + In `updateDocument()` (edits create no new post, so only the content list needs syncing): compute `const mentions = collectMentions( input.blocks );` and pass `mentions,` into its `buildDocumentRecord(...)` call. 833 + 834 + - [ ] **Step 4: Run test to verify it passes** 835 + 836 + Run: `npx vitest run src/lib/publish/publisher.test.ts` 837 + Expected: PASS. 838 + 839 + - [ ] **Step 5: Run the full publish suite + typecheck** 840 + 841 + Run: `npx vitest run src/lib/publish && npm run check` 842 + Expected: PASS / no type errors. 843 + 844 + - [ ] **Step 6: Commit** 845 + 846 + ```bash 847 + git add src/lib/publish/publisher.ts src/lib/publish/publisher.test.ts 848 + git commit --no-gpg-sign -m "Wire collectMentions + body lede into publish/updateDocument" 849 + ``` 850 + 851 + --- 852 + 853 + ### Task 7: Document the optional `mentions` field in the lexicon 854 + 855 + **Files:** 856 + - Modify: `lexicons/blog.skypress.content.gutenberg.json` 857 + 858 + - [ ] **Step 1: Edit the lexicon (additive — no version bump)** 859 + 860 + Add a `mentions` property after `blocks` in the `properties` object: 861 + 862 + ```json 863 + "mentions": { 864 + "type": "array", 865 + "description": "Optional, additive. Accounts mentioned in the body, mirrored from the inline `<a data-did>` anchors in the block tree. A flat discovery list for other appviews; SkyPress's own reader renders mentions from the inline anchors, not this field. Each item is `{ did: string, handle: string }`.", 866 + "items": { "type": "unknown" } 867 + } 868 + ``` 869 + 870 + - [ ] **Step 2: Verify it is valid JSON** 871 + 872 + Run: `node -e "JSON.parse(require('fs').readFileSync('lexicons/blog.skypress.content.gutenberg.json','utf8')); console.log('ok')"` 873 + Expected: `ok` 874 + 875 + - [ ] **Step 3: Commit** 876 + 877 + ```bash 878 + git add lexicons/blog.skypress.content.gutenberg.json 879 + git commit --no-gpg-sign -m "Document optional mentions field in gutenberg content lexicon" 880 + ``` 881 + 882 + --- 883 + 884 + ## Phase 2 — Editor (format + autocompleter) 885 + 886 + This phase makes mentions creatable. Pure pieces (the format's serialization, the completer's `getOptionCompletion`/`options`) are unit-tested; the in-editor wiring is verified with a manual browser smoke test. 887 + 888 + ### Task 8: Register the `skypress/mention` rich-text format 889 + 890 + **Files:** 891 + - Create: `src/lib/editor/mention-format.ts` 892 + - Test: `src/lib/editor/mention-format.test.ts` 893 + 894 + - [ ] **Step 1: Write the failing test** 895 + 896 + ```typescript 897 + // src/lib/editor/mention-format.test.ts 898 + import { describe, expect, it, beforeAll } from 'vitest'; 899 + import { create, toHTMLString, applyFormat } from '@wordpress/rich-text'; 900 + import { registerMentionFormat, MENTION_FORMAT } from './mention-format'; 901 + 902 + beforeAll( () => { 903 + registerMentionFormat(); 904 + } ); 905 + 906 + describe( 'skypress/mention format', () => { 907 + it( 'round-trips an anchor carrying href + data-did, keeping the text', () => { 908 + // Build a value "@alice.bsky.social" and apply the mention format across it. 909 + const value = create( { text: '@alice.bsky.social' } ); 910 + const formatted = applyFormat( 911 + value, 912 + { 913 + type: MENTION_FORMAT, 914 + attributes: { 915 + url: 'https://bsky.app/profile/alice.bsky.social', 916 + did: 'did:plc:alice', 917 + }, 918 + }, 919 + 0, 920 + value.text.length 921 + ); 922 + const html = toHTMLString( { value: formatted } ); 923 + expect( html ).toContain( 'href="https://bsky.app/profile/alice.bsky.social"' ); 924 + expect( html ).toContain( 'data-did="did:plc:alice"' ); 925 + expect( html ).toContain( '@alice.bsky.social' ); 926 + } ); 927 + 928 + it( 'is idempotent — calling register twice does not throw', () => { 929 + expect( () => registerMentionFormat() ).not.toThrow(); 930 + } ); 931 + } ); 932 + ``` 933 + 934 + - [ ] **Step 2: Run test to verify it fails** 935 + 936 + Run: `npx vitest run src/lib/editor/mention-format.test.ts` 937 + Expected: FAIL — "Failed to resolve import './mention-format'". 938 + 939 + - [ ] **Step 3: Write the implementation** 940 + 941 + ```typescript 942 + // src/lib/editor/mention-format.ts 943 + import { registerFormatType, getFormatType } from '@wordpress/rich-text'; 944 + 945 + export const MENTION_FORMAT = 'skypress/mention'; 946 + 947 + /** 948 + * Register the `skypress/mention` rich-text format. It serializes to 949 + * `<a class="skypress-mention" href="{profile}" data-did="{did}">@{handle}</a>`. 950 + * The `class` survives the reader's sanitizer (so it can be styled and identified); 951 + * `data-did` is the publish-time marker (`collectMentions`) and is stripped from public 952 + * HTML by `sanitize.ts`. Inserted by the `@` autocompleter, not a toolbar button. 953 + */ 954 + export function registerMentionFormat(): void { 955 + if ( getFormatType( MENTION_FORMAT ) ) { 956 + return; 957 + } 958 + registerFormatType( MENTION_FORMAT, { 959 + title: 'Mention', 960 + tagName: 'a', 961 + className: 'skypress-mention', 962 + attributes: { 963 + url: 'href', 964 + did: 'data-did', 965 + }, 966 + edit() { 967 + return null; 968 + }, 969 + } ); 970 + } 971 + ``` 972 + 973 + - [ ] **Step 4: Run test to verify it passes** 974 + 975 + Run: `npx vitest run src/lib/editor/mention-format.test.ts` 976 + Expected: PASS (2 tests). 977 + 978 + > If the format object requires non-null `edit`, the `edit() { return null; }` above satisfies the type. If `toHTMLString` drops `data-did`, confirm the `attributes` map uses `did: 'data-did'` exactly (a `data-*` target is allowed). 979 + 980 + - [ ] **Step 5: Commit** 981 + 982 + ```bash 983 + git add src/lib/editor/mention-format.ts src/lib/editor/mention-format.test.ts 984 + git commit --no-gpg-sign -m "Register skypress/mention rich-text format" 985 + ``` 986 + 987 + --- 988 + 989 + ### Task 9: Mention autocompleter (`@` trigger) 990 + 991 + **Files:** 992 + - Create: `src/lib/editor/mention-autocompleter.ts` 993 + - Test: `src/lib/editor/mention-autocompleter.test.ts` 994 + 995 + The completer is a plain object. `getOptionCompletion` is pure and fully testable; `options` is tested with an injected lookup. The actual hook registration is a thin `addFilter` verified by smoke test in Task 11. 996 + 997 + - [ ] **Step 1: Write the failing test** 998 + 999 + ```typescript 1000 + // src/lib/editor/mention-autocompleter.test.ts 1001 + import { describe, expect, it, vi } from 'vitest'; 1002 + import { createElement } from '@wordpress/element'; 1003 + import { renderToString } from 'react-dom/server'; 1004 + import { createMentionCompleter } from './mention-autocompleter'; 1005 + import type { ActorPreview } from '../landing/actor-lookup'; 1006 + 1007 + const ALICE: ActorPreview = { 1008 + did: 'did:plc:alice', 1009 + handle: 'alice.bsky.social', 1010 + displayName: 'Alice', 1011 + avatar: null, 1012 + }; 1013 + 1014 + describe( 'mention autocompleter', () => { 1015 + it( 'has an @ trigger prefix', () => { 1016 + const completer = createMentionCompleter( async () => null ); 1017 + expect( completer.triggerPrefix ).toBe( '@' ); 1018 + } ); 1019 + 1020 + it( 'returns the looked-up actor as the only option', async () => { 1021 + const lookup = vi.fn( async () => ALICE ); 1022 + const completer = createMentionCompleter( lookup ); 1023 + const options = await completer.options( 'alice' ); 1024 + expect( options ).toEqual( [ ALICE ] ); 1025 + expect( lookup ).toHaveBeenCalledWith( 'alice' ); 1026 + } ); 1027 + 1028 + it( 'returns no options for an empty query without calling lookup', async () => { 1029 + const lookup = vi.fn( async () => ALICE ); 1030 + const completer = createMentionCompleter( lookup ); 1031 + expect( await completer.options( '' ) ).toEqual( [] ); 1032 + expect( lookup ).not.toHaveBeenCalled(); 1033 + } ); 1034 + 1035 + it( 'inserts a mention anchor with href + data-did on completion', () => { 1036 + const completer = createMentionCompleter( async () => null ); 1037 + const completion = completer.getOptionCompletion( ALICE ); 1038 + expect( completion.action ).toBe( 'replace' ); 1039 + const html = renderToString( completion.value ); 1040 + expect( html ).toContain( 'href="https://bsky.app/profile/alice.bsky.social"' ); 1041 + expect( html ).toContain( 'data-did="did:plc:alice"' ); 1042 + expect( html ).toContain( '@alice.bsky.social' ); 1043 + } ); 1044 + } ); 1045 + ``` 1046 + 1047 + - [ ] **Step 2: Run test to verify it fails** 1048 + 1049 + Run: `npx vitest run src/lib/editor/mention-autocompleter.test.ts` 1050 + Expected: FAIL — "Failed to resolve import './mention-autocompleter'". 1051 + 1052 + - [ ] **Step 3: Write the implementation** 1053 + 1054 + ```typescript 1055 + // src/lib/editor/mention-autocompleter.ts 1056 + import { createElement } from '@wordpress/element'; 1057 + import { addFilter } from '@wordpress/hooks'; 1058 + import { lookupActor, type ActorPreview } from '../landing/actor-lookup'; 1059 + 1060 + type LookupFn = ( query: string ) => Promise< ActorPreview | null >; 1061 + 1062 + export interface MentionCompletion { 1063 + action: 'replace'; 1064 + value: ReturnType< typeof createElement >; 1065 + } 1066 + 1067 + /** 1068 + * A block-editor autocompleter for `@mentions`. `options` queries the public Bluesky 1069 + * AppView (reusing `actor-lookup`); selecting an account inserts the `skypress/mention` 1070 + * anchor (class + href + data-did), with the DID resolved once, here, at pick time. 1071 + */ 1072 + export function createMentionCompleter( lookup: LookupFn = lookupActor ) { 1073 + return { 1074 + name: 'skypress/mention', 1075 + triggerPrefix: '@', 1076 + async options( query: string ): Promise< ActorPreview[] > { 1077 + const trimmed = query.trim(); 1078 + if ( ! trimmed ) { 1079 + return []; 1080 + } 1081 + const found = await lookup( trimmed ); 1082 + return found ? [ found ] : []; 1083 + }, 1084 + getOptionKeywords( option: ActorPreview ): string[] { 1085 + return [ option.handle, option.displayName ?? '' ].filter( Boolean ); 1086 + }, 1087 + getOptionLabel( option: ActorPreview ) { 1088 + return createElement( 1089 + 'span', 1090 + { className: 'skypress-mention-option' }, 1091 + option.avatar 1092 + ? createElement( 'img', { 1093 + src: option.avatar, 1094 + alt: '', 1095 + width: 20, 1096 + height: 20, 1097 + className: 'skypress-mention-option__avatar', 1098 + } ) 1099 + : null, 1100 + createElement( 1101 + 'span', 1102 + { className: 'skypress-mention-option__name' }, 1103 + option.displayName ?? option.handle 1104 + ), 1105 + createElement( 1106 + 'span', 1107 + { className: 'skypress-mention-option__handle' }, 1108 + `@${ option.handle }` 1109 + ) 1110 + ); 1111 + }, 1112 + getOptionCompletion( option: ActorPreview ): MentionCompletion { 1113 + return { 1114 + action: 'replace', 1115 + value: createElement( 1116 + 'a', 1117 + { 1118 + href: `https://bsky.app/profile/${ option.handle }`, 1119 + className: 'skypress-mention', 1120 + 'data-did': option.did, 1121 + }, 1122 + `@${ option.handle }` 1123 + ), 1124 + }; 1125 + }, 1126 + }; 1127 + } 1128 + 1129 + let registered = false; 1130 + 1131 + /** Add the mention completer to every RichText instance in the editor. */ 1132 + export function registerMentionAutocompleter(): void { 1133 + if ( registered ) { 1134 + return; 1135 + } 1136 + registered = true; 1137 + const completer = createMentionCompleter(); 1138 + addFilter( 1139 + 'editor.Autocomplete.completers', 1140 + 'skypress/mention-autocompleter', 1141 + ( completers: unknown[] ) => [ ...completers, completer ] 1142 + ); 1143 + } 1144 + ``` 1145 + 1146 + > The test imports `renderToString` from `react-dom/server` (already a dep via React 18). If it is not resolvable in jsdom, switch the completion assertions to inspect `completion.value.props` directly (`props.href`, `props[ 'data-did' ]`, `props.children`). 1147 + 1148 + - [ ] **Step 4: Run test to verify it passes** 1149 + 1150 + Run: `npx vitest run src/lib/editor/mention-autocompleter.test.ts` 1151 + Expected: PASS (4 tests). 1152 + 1153 + - [ ] **Step 5: Commit** 1154 + 1155 + ```bash 1156 + git add src/lib/editor/mention-autocompleter.ts src/lib/editor/mention-autocompleter.test.ts 1157 + git commit --no-gpg-sign -m "Add @ mention autocompleter (actor-lookup backed)" 1158 + ``` 1159 + 1160 + --- 1161 + 1162 + ### Task 10: Wire format + autocompleter into the editor 1163 + 1164 + **Files:** 1165 + - Modify: `src/components/SkyEditor.tsx` 1166 + 1167 + - [ ] **Step 1: Locate the block-registration call** 1168 + 1169 + Run: `git grep -n "registerSkyPressBlocks\|IsolatedBlockEditor" src/components/SkyEditor.tsx` 1170 + This shows where blocks are registered/the editor mounts. 1171 + 1172 + - [ ] **Step 2: Add the registrations** 1173 + 1174 + At the top of `SkyEditor.tsx`, add imports: 1175 + 1176 + ```typescript 1177 + import { registerMentionFormat } from '../lib/editor/mention-format'; 1178 + import { registerMentionAutocompleter } from '../lib/editor/mention-autocompleter'; 1179 + ``` 1180 + 1181 + Wherever `registerSkyPressBlocks()` is invoked (module init or an effect), call both new registrars immediately after it: 1182 + 1183 + ```typescript 1184 + registerSkyPressBlocks(); 1185 + registerMentionFormat(); 1186 + registerMentionAutocompleter(); 1187 + ``` 1188 + 1189 + If `registerSkyPressBlocks()` is called inside a `useEffect`/`useMemo`, place the two new calls in the same scope so they run once, client-side, with the editor. 1190 + 1191 + - [ ] **Step 3: Typecheck + full test run** 1192 + 1193 + Run: `npm run check && npm run test` 1194 + Expected: no type errors; all tests pass. 1195 + 1196 + - [ ] **Step 4: Commit** 1197 + 1198 + ```bash 1199 + git add src/components/SkyEditor.tsx 1200 + git commit --no-gpg-sign -m "Register mention format + autocompleter in SkyEditor" 1201 + ``` 1202 + 1203 + --- 1204 + 1205 + ### Task 11: Manual browser smoke test (editor) 1206 + 1207 + No code. Verifies the in-editor wiring that unit tests can't reach. Use the Chrome DevTools MCP (project preference for smoke tests). 1208 + 1209 + - [ ] **Step 1: Start the dev server** 1210 + 1211 + Run: `npm run dev` (serve on `http://127.0.0.1:<port>` — atproto loopback requirement). Sign in to the Studio (smoke-test account: `@jeherve.com`). 1212 + 1213 + - [ ] **Step 2: Trigger the autocomplete** 1214 + 1215 + In the article body, type `@jeherve` and wait. Expected: a dropdown lists the matching account (avatar + name + `@handle`). 1216 + 1217 + - [ ] **Step 3: Insert and inspect** 1218 + 1219 + Select the account. Expected: the text becomes `@jeherve.com` styled as a link. In DevTools, evaluate the editor's saved blocks (or inspect the DOM node) and confirm the anchor is `<a class="skypress-mention" href="https://bsky.app/profile/jeherve.com" data-did="did:…">`. 1220 + 1221 + - [ ] **Step 4: Record the result** 1222 + 1223 + Note pass/fail in the task. If the dropdown never appears, the `editor.Autocomplete.completers` filter name differs in this `@wordpress/block-editor` build — check `node_modules/@wordpress/block-editor` for the autocomplete hook name and adjust `registerMentionAutocompleter`. Re-run Task 9's tests (unaffected) and repeat this smoke test. 1224 + 1225 + --- 1226 + 1227 + ## Phase 3 — UI (counter + disclosure) 1228 + 1229 + ### Task 12: Grapheme counter + confirm-dialog disclosure in PublishPanel 1230 + 1231 + PublishPanel already holds `title`, `description` (the lede), `blocks`, `identity`, and the selected target (slug). It is the right home for both the live post-length counter and the mention disclosure. 1232 + 1233 + **Files:** 1234 + - Modify: `src/components/PublishPanel.tsx` 1235 + - Test: `src/components/PublishPanel.test.tsx` (create if absent) 1236 + 1237 + - [ ] **Step 1: Write the failing test** 1238 + 1239 + ```typescript 1240 + // src/components/PublishPanel.test.tsx 1241 + import { describe, expect, it } from 'vitest'; 1242 + import { computePostPreview } from './PublishPanel'; 1243 + import type { BlockNode } from '../lib/blocks/render'; 1244 + 1245 + function mentionPara( handle: string, did: string ): BlockNode { 1246 + return { 1247 + name: 'core/paragraph', 1248 + attributes: { 1249 + content: `Hi <a class="skypress-mention" href="https://bsky.app/profile/${ handle }" data-did="${ did }">@${ handle }</a>`, 1250 + }, 1251 + innerBlocks: [], 1252 + }; 1253 + } 1254 + 1255 + describe( 'computePostPreview', () => { 1256 + it( 'reports the mentioned handles and a grapheme count', () => { 1257 + const preview = computePostPreview( { 1258 + title: 'Hello', 1259 + lede: 'A lede', 1260 + blocks: [ mentionPara( 'alice.bsky.social', 'did:plc:alice' ) ], 1261 + handle: 'me.bsky.social', 1262 + slug: 'pub', 1263 + } ); 1264 + expect( preview.handles ).toEqual( [ '@alice.bsky.social' ] ); 1265 + expect( preview.graphemes ).toBeGreaterThan( 0 ); 1266 + expect( preview.overLimit ).toBe( false ); 1267 + } ); 1268 + 1269 + it( 'flags overLimit when the assembled post exceeds 300 graphemes', () => { 1270 + const preview = computePostPreview( { 1271 + title: 'Hello', 1272 + lede: 'x'.repeat( 320 ), 1273 + blocks: [], 1274 + handle: 'me.bsky.social', 1275 + slug: 'pub', 1276 + } ); 1277 + expect( preview.overLimit ).toBe( true ); 1278 + } ); 1279 + } ); 1280 + ``` 1281 + 1282 + - [ ] **Step 2: Run test to verify it fails** 1283 + 1284 + Run: `npx vitest run src/components/PublishPanel.test.tsx` 1285 + Expected: FAIL — `computePostPreview` not exported. 1286 + 1287 + - [ ] **Step 3: Add `computePostPreview` + use it in `PublishPanel.tsx`** 1288 + 1289 + Add imports at the top: 1290 + 1291 + ```typescript 1292 + import { collectMentions } from '../lib/publish/mentions'; 1293 + import { assemblePostText } from '../lib/publish/post-text'; 1294 + import { graphemeLength } from '../lib/publish/grapheme'; 1295 + import { canonicalArticleUrl } from '../lib/publish/records'; 1296 + ``` 1297 + 1298 + Add this exported pure helper (near the top of the module, before the component). A TID rkey is a fixed 13 chars; using a placeholder of that length makes the counter match the real published URL length. 1299 + 1300 + ```typescript 1301 + const POST_GRAPHEME_LIMIT = 300; 1302 + const RKEY_PLACEHOLDER = 'aaaaaaaaaaaaa'; // 13 chars, TID length 1303 + 1304 + export interface PostPreview { 1305 + graphemes: number; 1306 + overLimit: boolean; 1307 + handles: string[]; 1308 + } 1309 + 1310 + /** Live preview of the companion Bluesky post: its grapheme count and who gets cc'd. */ 1311 + export function computePostPreview( input: { 1312 + title: string; 1313 + lede: string; 1314 + blocks: BlockNode[]; 1315 + handle: string; 1316 + slug: string; 1317 + } ): PostPreview { 1318 + const mentions = collectMentions( input.blocks ); 1319 + const articleUrl = canonicalArticleUrl( input.handle, input.slug, RKEY_PLACEHOLDER ); 1320 + const { text } = assemblePostText( { 1321 + title: input.title, 1322 + articleUrl, 1323 + bodyLede: input.lede, 1324 + mentions, 1325 + } ); 1326 + return { 1327 + graphemes: graphemeLength( text ), 1328 + overLimit: graphemeLength( text ) > POST_GRAPHEME_LIMIT, 1329 + handles: mentions.map( ( m ) => `@${ m.handle }` ), 1330 + }; 1331 + } 1332 + ``` 1333 + 1334 + Inside the component, compute the preview from current props/target (use the resolved handle from `identity` and the selected target slug; guard for `null`/no target): 1335 + 1336 + ```typescript 1337 + const previewHandle = identity.handle ?? identity.did; 1338 + const preview = target 1339 + ? computePostPreview( { 1340 + title, 1341 + lede: description, 1342 + blocks, 1343 + handle: previewHandle, 1344 + slug: target.slug, 1345 + } ) 1346 + : null; 1347 + ``` 1348 + 1349 + Render a counter line near the publish button (e.g. just above it): 1350 + 1351 + ```tsx 1352 + { preview && ( 1353 + <p 1354 + className={ `publish__count${ preview.overLimit ? ' publish__count--over' : '' }` } 1355 + aria-live="polite" 1356 + > 1357 + Bluesky post: { preview.graphemes }/300 1358 + { preview.overLimit ? ' — shorten the subtitle to publish' : '' } 1359 + </p> 1360 + ) } 1361 + ``` 1362 + 1363 + Disable submit when over the limit — extend the existing `canSubmit`: 1364 + 1365 + ```typescript 1366 + const canSubmit = 1367 + title.trim().length > 0 && blocks.length > 0 && hasTarget && ! preview?.overLimit; 1368 + ``` 1369 + 1370 + In the confirm dialog (`phase === 'confirm'`), add the disclosure sentence when there are mentions. Inside the existing `<p className="publish__warning">`, after the current text, add: 1371 + 1372 + ```tsx 1373 + { preview && preview.handles.length > 0 && ( 1374 + <> 1375 + { ' ' } 1376 + It will notify{ ' ' } 1377 + <strong>{ preview.handles.join( ', ' ) }</strong>. 1378 + </> 1379 + ) } 1380 + ``` 1381 + 1382 + Ensure `BlockNode` is imported in `PublishPanel.tsx` (it deals in `BlockInstance[]` today; the preview helper accepts `BlockNode[]`, which `normalizeBlocks` produces — pass `normalizeBlocks(blocks)` into `computePostPreview` if the live `blocks` prop is `BlockInstance[]`). Add at the call site: 1383 + 1384 + ```typescript 1385 + import { normalizeBlocks } from '../lib/publish/records'; 1386 + // ... 1387 + blocks: normalizeBlocks( blocks ), 1388 + ``` 1389 + 1390 + - [ ] **Step 4: Run test to verify it passes** 1391 + 1392 + Run: `npx vitest run src/components/PublishPanel.test.tsx` 1393 + Expected: PASS (2 tests). 1394 + 1395 + - [ ] **Step 5: Typecheck** 1396 + 1397 + Run: `npm run check` 1398 + Expected: no type errors. 1399 + 1400 + - [ ] **Step 6: Commit** 1401 + 1402 + ```bash 1403 + git add src/components/PublishPanel.tsx src/components/PublishPanel.test.tsx 1404 + git commit --no-gpg-sign -m "Add Bluesky post grapheme counter + mention disclosure to PublishPanel" 1405 + ``` 1406 + 1407 + --- 1408 + 1409 + ### Task 13: Style the counter and mention option (CSS) 1410 + 1411 + **Files:** 1412 + - Modify: the stylesheet that defines `.publish__*` and `.studio__*` (run `git grep -ln "publish__button\|studio__lede" src/` to locate it — likely a global CSS or `.astro` `<style>`). 1413 + 1414 + - [ ] **Step 1: Add minimal styles** 1415 + 1416 + Add rules for the new classes, matching the existing token usage in that file: 1417 + 1418 + ```css 1419 + .publish__count { 1420 + font-size: 0.8rem; 1421 + opacity: 0.7; 1422 + } 1423 + .publish__count--over { 1424 + color: #b00020; 1425 + opacity: 1; 1426 + } 1427 + .skypress-mention-option { 1428 + display: flex; 1429 + align-items: center; 1430 + gap: 0.4rem; 1431 + } 1432 + .skypress-mention-option__avatar { 1433 + border-radius: 50%; 1434 + } 1435 + .skypress-mention-option__handle { 1436 + opacity: 0.6; 1437 + } 1438 + .skypress-mention { 1439 + /* Rendered mention link in the article — inherits link styling; hook for theming. */ 1440 + text-decoration: underline; 1441 + } 1442 + ``` 1443 + 1444 + - [ ] **Step 2: Commit** 1445 + 1446 + ```bash 1447 + git add <the-stylesheet-path> 1448 + git commit --no-gpg-sign -m "Style post-length counter and mention autocomplete option" 1449 + ``` 1450 + 1451 + --- 1452 + 1453 + ## Phase 4 — Locks & docs 1454 + 1455 + ### Task 14: Lock render fidelity for a mention-bearing block 1456 + 1457 + **Files:** 1458 + - Modify: `src/lib/blocks/render.test.ts` 1459 + 1460 + - [ ] **Step 1: Add a fidelity assertion** 1461 + 1462 + In the existing `describe( 'renderBlocks — fidelity vs @wordpress/blocks.serialize()', …)`, add a test. It registers the mention format so `serialize()` reproduces the anchor, then asserts `renderBlocks` matches `serialize` for a paragraph containing a mention. 1463 + 1464 + ```typescript 1465 + it( 'reproduces a paragraph containing a skypress mention', async () => { 1466 + const { registerMentionFormat } = await import( '../editor/mention-format' ); 1467 + registerMentionFormat(); 1468 + 1469 + const tree = [ 1470 + createBlock( 'core/paragraph', { 1471 + content: 1472 + 'Thanks <a class="skypress-mention" href="https://bsky.app/profile/alice.bsky.social" data-did="did:plc:alice">@alice.bsky.social</a>!', 1473 + } ), 1474 + ]; 1475 + 1476 + const expected = normalize( stripDelimiters( serializeBlocks( tree ) ) ); 1477 + const actual = normalize( renderBlocks( tree ) ); 1478 + expect( actual ).toBe( expected ); 1479 + } ); 1480 + ``` 1481 + 1482 + - [ ] **Step 2: Run the fidelity test** 1483 + 1484 + Run: `npx vitest run src/lib/blocks/render.test.ts` 1485 + Expected: PASS. If it fails because `serialize()` drops `data-did` from the paragraph content, the mention must be modeled as a registered format for serialization to retain the attribute — confirm Task 8 ran and the format is registered before `serializeBlocks` here (the dynamic import + `registerMentionFormat()` above ensures that). 1486 + 1487 + - [ ] **Step 3: Commit** 1488 + 1489 + ```bash 1490 + git add src/lib/blocks/render.test.ts 1491 + git commit --no-gpg-sign -m "Lock render fidelity for mention-bearing blocks" 1492 + ``` 1493 + 1494 + --- 1495 + 1496 + ### Task 15: Lock sanitizer behavior for mention anchors 1497 + 1498 + **Files:** 1499 + - Modify: `src/lib/reader/sanitize.test.ts` 1500 + 1501 + - [ ] **Step 1: Add the assertion** 1502 + 1503 + ```typescript 1504 + it( 'keeps a mention link (class + href) but strips its data-did', () => { 1505 + const dirty = 1506 + '<p>Hi <a class="skypress-mention" href="https://bsky.app/profile/alice.bsky.social" data-did="did:plc:alice">@alice.bsky.social</a></p>'; 1507 + const clean = sanitizeArticleHtml( dirty ); 1508 + expect( clean ).toContain( 'class="skypress-mention"' ); 1509 + expect( clean ).toContain( 'href="https://bsky.app/profile/alice.bsky.social"' ); 1510 + expect( clean ).not.toContain( 'data-did' ); 1511 + expect( clean ).toContain( '@alice.bsky.social' ); 1512 + } ); 1513 + ``` 1514 + 1515 + - [ ] **Step 2: Run the sanitizer tests** 1516 + 1517 + Run: `npx vitest run src/lib/reader/sanitize.test.ts` 1518 + Expected: PASS — the existing `allowedAttributes` (`a: [ 'href', 'title', 'rel', 'target' ]`, `'*': [ 'class' ]`) already produces this; this test locks it so a future allowlist change can't silently start leaking `data-did`. 1519 + 1520 + - [ ] **Step 3: Commit** 1521 + 1522 + ```bash 1523 + git add src/lib/reader/sanitize.test.ts 1524 + git commit --no-gpg-sign -m "Lock sanitizer: mention link keeps class+href, drops data-did" 1525 + ``` 1526 + 1527 + --- 1528 + 1529 + ### Task 16: Decision record 1530 + 1531 + **Files:** 1532 + - Create: `docs/decisions/0019-editor-mentions.md` (confirm the next free number first: `ls docs/decisions | tail`) 1533 + 1534 + - [ ] **Step 1: Write the decision** 1535 + 1536 + ```markdown 1537 + # 0019 — Editor @mentions and Bluesky mention notifications 1538 + 1539 + ## Context 1540 + 1541 + Writers want to mention atproto users in an article and have those users notified. 1542 + Bluesky only generates a mention notification when it indexes an `app.bsky.feed.post` 1543 + whose `facets` contain `app.bsky.richtext.facet#mention`; the AppView indexes per 1544 + collection and never scans custom records (`site.standard.document`) for facets. 1545 + 1546 + ## Decision 1547 + 1548 + - **Notifications ride the companion post.** Publish appends a `cc @a @b` line (plus the 1549 + writer's lede) to the `app.bsky.feed.post` body and attaches a `#mention` facet per 1550 + handle. A facet on the document record would be inert. 1551 + - **Storage is inline.** Mentions are a registered `skypress/mention` rich-text format 1552 + serializing to `<a class="skypress-mention" href="https://bsky.app/profile/{handle}" 1553 + data-did="{did}">@{handle}</a>`. This is the single source of truth; `collectMentions` 1554 + derives the cc list and the document's flat `mentions` list from it at publish. 1555 + - **Link target is Bluesky.** Correct for every mentionable user; no SkyPress-profile 404s. 1556 + - **Interop is the inline anchors + a flat `mentions` list** on 1557 + `blog.skypress.content.gutenberg`, not a byte-offset facet (no flat-text host fits our 1558 + block content model). The reader renders the mention from the inline anchor; the 1559 + sanitizer keeps `class` + `href` and strips `data-did`. 1560 + - **Body only.** Lede-field mentions, edit-time notifications, and SkyPress-internal 1561 + mention links are out of scope. 1562 + 1563 + ## Consequences 1564 + 1565 + - The post body can approach Bluesky's 300-grapheme limit; PublishPanel shows a live count 1566 + and blocks publish over the limit, with a backstop guard in `buildBskyPost`. 1567 + - Adding the format means a `render.ts` fidelity assertion and a sanitizer lock test. 1568 + - DIDs are resolved once, at pick time, and baked into the block — publish never 1569 + re-resolves. 1570 + ``` 1571 + 1572 + - [ ] **Step 2: Commit** 1573 + 1574 + ```bash 1575 + git add docs/decisions/0019-editor-mentions.md 1576 + git commit --no-gpg-sign -m "Record decision 0019: editor mentions + notifications" 1577 + ``` 1578 + 1579 + --- 1580 + 1581 + ### Task 17: Update contract comments + final verification 1582 + 1583 + **Files:** 1584 + - Modify: `src/lib/publish/records.ts` (comment on `buildBskyPost`/`BskyPostRecord`), `src/components/PublishPanel.tsx` (the confirm-dialog comment). 1585 + 1586 + - [ ] **Step 1: Refresh stale contract comments** 1587 + 1588 + In `records.ts`, ensure the `BskyPostRecord` / `buildBskyPost` doc comments state the body is `title + lede + cc line + URL` (not just title + URL) and that `facets` may include `#mention` features. In `PublishPanel.tsx`, update the component doc comment to note the dialog discloses notified accounts and that publish is blocked over 300 graphemes. 1589 + 1590 + - [ ] **Step 2: Full verification** 1591 + 1592 + Run: `npm run test && npm run check && npm run build` 1593 + Expected: all tests pass, no type errors, build succeeds (verifies reading pages still don't import `@wordpress/*` — the editor-only modules stay in the island). 1594 + 1595 + - [ ] **Step 3: Commit** 1596 + 1597 + ```bash 1598 + git add src/lib/publish/records.ts src/components/PublishPanel.tsx 1599 + git commit --no-gpg-sign -m "Refresh contract comments for mention-aware publish" 1600 + ``` 1601 + 1602 + - [ ] **Step 4: Manual end-to-end smoke (Chrome DevTools MCP)** 1603 + 1604 + With `npm run dev` on `http://127.0.0.1:<port>`, signed in as the smoke-test account: write a short article, `@`-mention a real account, observe the counter, open the publish confirm dialog (verify "It will notify @…"), publish, then check the published `app.bsky.feed.post` (via the record JSON viewer or `bsky.app`) shows the `cc` line and that the mentioned account received a notification. Record the outcome. 1605 + 1606 + --- 1607 + 1608 + ## Self-Review (completed during planning) 1609 + 1610 + - **Spec coverage:** autocomplete (Tasks 8–11) · Bluesky-profile link target (Tasks 8–9) · body-only surface (editor wiring, Task 10) · explicit `skypress/mention` format w/ inline DID (Task 8) · inline-anchor interop + flat `mentions` list, no facet lexicon (Tasks 5, 7) · lede into post body, no derived excerpt (Tasks 3, 6) · `cc` line + `#mention` facets (Tasks 3–4, 6) · 300-grapheme guard + counter (Tasks 1, 4, 12) · confirm-dialog disclosure (Task 12) · render fidelity (Task 14) · sanitize strips `data-did` (Task 15) · decision + lexicon + comment docs (Tasks 7, 16, 17). All spec sections map to a task. 1611 + - **Placeholders:** none — every code step has full code; the one verification-dependent spot (autocomplete filter name) has a concrete fallback in Task 11. 1612 + - **Type consistency:** `Mention { did, handle, displayText }` (Task 2) is consumed unchanged by `assemblePostText` (Task 3), `buildBskyPost`/`buildContentObject` (Tasks 4–5), `publish` (Task 6), and `computePostPreview` (Task 12). `PostFacet` (Task 3) is the type used by `BskyPostRecord.facets` (Task 4). `MENTION_FORMAT`/`registerMentionFormat` (Task 8) and `createMentionCompleter`/`registerMentionAutocompleter` (Task 9) are referenced consistently in Tasks 10/14. 1613 + ```