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 collectMentions to scan blocks for mention anchors

+103
+53
src/lib/publish/mentions.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { collectMentions } from './mentions'; 3 + import type { BlockNode } from '../blocks/render'; 4 + 5 + function para( content: string ): BlockNode { 6 + return { name: 'core/paragraph', attributes: { content }, innerBlocks: [] }; 7 + } 8 + 9 + const MENTION = ( 10 + handle: string, 11 + did: string 12 + ) => `<a class="skypress-mention" href="https://bsky.app/profile/${ handle }" data-did="${ did }">@${ handle }</a>`; 13 + 14 + describe( 'collectMentions', () => { 15 + it( 'extracts did, handle, and display text from a mention anchor', () => { 16 + const blocks = [ para( `Thanks ${ MENTION( 'alice.bsky.social', 'did:plc:alice' ) }!` ) ]; 17 + expect( collectMentions( blocks ) ).toEqual( [ 18 + { did: 'did:plc:alice', handle: 'alice.bsky.social', displayText: '@alice.bsky.social' }, 19 + ] ); 20 + } ); 21 + 22 + it( 'ignores ordinary links that are not mentions', () => { 23 + const blocks = [ para( 'See <a href="https://example.com">the docs</a>.' ) ]; 24 + expect( collectMentions( blocks ) ).toEqual( [] ); 25 + } ); 26 + 27 + it( 'dedupes by DID, keeping first-appearance order', () => { 28 + const blocks = [ 29 + para( `${ MENTION( 'bob.bsky.social', 'did:plc:bob' ) }` ), 30 + para( `${ MENTION( 'alice.bsky.social', 'did:plc:alice' ) }` ), 31 + para( `again ${ MENTION( 'bob.bsky.social', 'did:plc:bob' ) }` ), 32 + ]; 33 + expect( collectMentions( blocks ).map( ( m ) => m.handle ) ).toEqual( [ 34 + 'bob.bsky.social', 35 + 'alice.bsky.social', 36 + ] ); 37 + } ); 38 + 39 + it( 'recurses into innerBlocks', () => { 40 + const blocks: BlockNode[] = [ 41 + { 42 + name: 'core/quote', 43 + attributes: {}, 44 + innerBlocks: [ para( `${ MENTION( 'carol.bsky.social', 'did:plc:carol' ) }` ) ], 45 + }, 46 + ]; 47 + expect( collectMentions( blocks ).map( ( m ) => m.did ) ).toEqual( [ 'did:plc:carol' ] ); 48 + } ); 49 + 50 + it( 'returns [] when there are no blocks', () => { 51 + expect( collectMentions( [] ) ).toEqual( [] ); 52 + } ); 53 + } );
+50
src/lib/publish/mentions.ts
··· 1 + import type { BlockNode } from '../blocks/render'; 2 + 3 + export interface Mention { 4 + did: string; 5 + handle: string; 6 + /** The visible mention text in the article (defaults to `@{handle}`). */ 7 + displayText: string; 8 + } 9 + 10 + const PROFILE_RE = /^https?:\/\/bsky\.app\/profile\/(.+)$/; 11 + 12 + /** 13 + * Scan a block tree for `skypress/mention` anchors (`<a data-did="…">`) and return 14 + * the mentioned accounts, deduped by DID in first-appearance order. These anchors are 15 + * the single source of truth for who gets cc'd on the companion Bluesky post and for 16 + * the document's flat `mentions` list. 17 + */ 18 + export function collectMentions( blocks: BlockNode[] ): Mention[] { 19 + const out: Mention[] = []; 20 + const seen = new Set< string >(); 21 + walk( blocks, out, seen ); 22 + return out; 23 + } 24 + 25 + function walk( blocks: BlockNode[], out: Mention[], seen: Set< string > ): void { 26 + for ( const block of blocks ) { 27 + for ( const value of Object.values( block.attributes ?? {} ) ) { 28 + if ( typeof value === 'string' && value.includes( 'data-did' ) ) { 29 + extractFrom( value, out, seen ); 30 + } 31 + } 32 + walk( block.innerBlocks ?? [], out, seen ); 33 + } 34 + } 35 + 36 + function extractFrom( html: string, out: Mention[], seen: Set< string > ): void { 37 + const doc = new DOMParser().parseFromString( html, 'text/html' ); 38 + for ( const anchor of Array.from( doc.querySelectorAll( 'a[data-did]' ) ) ) { 39 + const did = anchor.getAttribute( 'data-did' ); 40 + if ( ! did || seen.has( did ) ) { 41 + continue; 42 + } 43 + const href = anchor.getAttribute( 'href' ) ?? ''; 44 + const match = href.match( PROFILE_RE ); 45 + const text = anchor.textContent ?? ''; 46 + const handle = match ? match[ 1 ] : text.replace( /^@/, '' ); 47 + seen.add( did ); 48 + out.push( { did, handle, displayText: text || `@${ handle }` } ); 49 + } 50 + }