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.

Rewire buildBskyPost onto assemblePostText; add lede + mentions + 300-grapheme guard

+76 -27
+46
src/lib/publish/records.test.ts
··· 353 353 expect( buildBskyPost( { ...base, thumb } ).embed.external.thumb ).toEqual( thumb ); 354 354 } ); 355 355 } ); 356 + 357 + describe( 'buildBskyPost — lede + mentions', () => { 358 + const URL = 'https://skypress.blog/@me/pub/3kabcde12345'; 359 + 360 + it( 'keeps the card description separate from the post body', () => { 361 + const post = buildBskyPost( { 362 + title: 'Title', 363 + articleUrl: URL, 364 + description: 'Card subtitle', 365 + createdAt: '2026-06-12T00:00:00.000Z', 366 + bodyLede: 'Body lede', 367 + } ); 368 + expect( post.text ).toBe( `Title\n\nBody lede\n\n${ URL }` ); 369 + expect( post.embed.external.description ).toBe( 'Card subtitle' ); 370 + } ); 371 + 372 + it( 'emits a mention facet for each cc-ed account', () => { 373 + const post = buildBskyPost( { 374 + title: 'Title', 375 + articleUrl: URL, 376 + description: 'd', 377 + createdAt: '2026-06-12T00:00:00.000Z', 378 + mentions: [ 379 + { did: 'did:plc:a', handle: 'alice.bsky.social', displayText: '@alice.bsky.social' }, 380 + ], 381 + } ); 382 + expect( post.text ).toContain( 'cc @alice.bsky.social' ); 383 + const hasMention = post.facets.some( 384 + ( f ) => f.features[ 0 ].$type === 'app.bsky.richtext.facet#mention' 385 + ); 386 + expect( hasMention ).toBe( true ); 387 + } ); 388 + 389 + it( 'throws when the assembled post exceeds 300 graphemes', () => { 390 + const longLede = 'x'.repeat( 320 ); 391 + expect( () => 392 + buildBskyPost( { 393 + title: 'Title', 394 + articleUrl: URL, 395 + description: 'd', 396 + createdAt: '2026-06-12T00:00:00.000Z', 397 + bodyLede: longLede, 398 + } ) 399 + ).toThrow( /300/ ); 400 + } ); 401 + } );
+30 -27
src/lib/publish/records.ts
··· 7 7 import type { BlockNode } from '../blocks/render'; 8 8 import type { BlobRefJson } from '../media/blob'; 9 9 import { parseBasicTheme, type BasicTheme } from './themes'; 10 + import { assemblePostText, type PostFacet } from './post-text'; 11 + import { graphemeLength } from './grapheme'; 12 + import type { Mention } from './mentions'; 10 13 11 14 /** 12 15 * Public origin for the stored publication + article URLs (and the Bluesky post link). ··· 221 224 }; 222 225 } 223 226 224 - /** A richtext link facet (`app.bsky.richtext.facet#link`) over a UTF-8 byte range of `text`. */ 225 - export interface BskyLinkFacet { 226 - $type: 'app.bsky.richtext.facet'; 227 - index: { byteStart: number; byteEnd: number }; 228 - features: Array< { $type: 'app.bsky.richtext.facet#link'; uri: string } >; 229 - } 230 - 231 227 /** A `com.atproto.repo.strongRef` as embedded in `external.associatedRefs` (Decision 0013). */ 232 228 export interface AssociatedRef extends StrongRef { 233 229 $type: 'com.atproto.repo.strongRef'; ··· 237 233 $type: 'app.bsky.feed.post'; 238 234 text: string; 239 235 createdAt: string; 240 - /** Always present: marks the article URL in `text` as a clickable link (Decision 0013). */ 241 - facets: BskyLinkFacet[]; 236 + /** Marks the article URL as a clickable link, plus a #mention facet per cc-ed account (Decision 0013). */ 237 + facets: PostFacet[]; 242 238 embed: { 243 239 $type: 'app.bsky.embed.external'; 244 240 external: { ··· 253 249 }; 254 250 } 255 251 256 - /** UTF-8 byte length — facet offsets are byte-based, not JS string-index based. */ 257 - function utf8ByteLength( value: string ): number { 258 - return new TextEncoder().encode( value ).length; 259 - } 252 + /** Bluesky rejects posts longer than this many graphemes. */ 253 + const BSKY_POST_MAX_GRAPHEMES = 300; 260 254 261 255 /** 262 - * Build the companion Bluesky post (Decision 0005). The article URL is appended to the text and 263 - * marked as a clickable link via a richtext facet; `associatedRefs` (the document + publication 264 - * strongRefs) are embedded so Bluesky renders the rich standard.site link card (Decision 0013). 256 + * Build the companion Bluesky post (Decision 0005). Body text + link/mention facets come 257 + * from `assemblePostText` (shared with the editor's live counter). `description` populates 258 + * ONLY the embed card subtitle; the writer-typed lede goes in the body via `bodyLede`. 259 + * `associatedRefs` drive the standard.site rich card (Decision 0013). 265 260 */ 266 261 export function buildBskyPost( input: { 267 262 title: string; 268 263 articleUrl: string; 269 264 createdAt: string; 270 265 description?: string; 266 + /** The writer-typed lede, placed in the post body (omitted when blank). */ 267 + bodyLede?: string; 268 + /** Accounts to cc + notify via #mention facets. */ 269 + mentions?: Mention[]; 271 270 /** Optional image blob for the card's `thumb` (the og:image fallback, Decision 0014). */ 272 271 thumb?: BlobRefJson; 273 272 /** Document + publication strongRefs, in that order, for the standard.site card. */ 274 273 associatedRefs?: StrongRef[]; 275 274 } ): BskyPostRecord { 276 - const text = `${ input.title }\n\n${ input.articleUrl }`; 277 - // The URL is the trailing segment of `text`; mark its byte range as a link facet. 278 - const byteStart = utf8ByteLength( `${ input.title }\n\n` ); 279 - const byteEnd = byteStart + utf8ByteLength( input.articleUrl ); 275 + const { text, facets } = assemblePostText( { 276 + title: input.title, 277 + articleUrl: input.articleUrl, 278 + bodyLede: input.bodyLede, 279 + mentions: input.mentions, 280 + } ); 281 + 282 + const length = graphemeLength( text ); 283 + if ( length > BSKY_POST_MAX_GRAPHEMES ) { 284 + throw new Error( 285 + `Bluesky post is ${ length } characters; the limit is ${ BSKY_POST_MAX_GRAPHEMES }. Shorten the subtitle or remove a mention.` 286 + ); 287 + } 288 + 280 289 return { 281 290 $type: 'app.bsky.feed.post', 282 291 text, 283 292 createdAt: input.createdAt, 284 - facets: [ 285 - { 286 - $type: 'app.bsky.richtext.facet', 287 - index: { byteStart, byteEnd }, 288 - features: [ { $type: 'app.bsky.richtext.facet#link', uri: input.articleUrl } ], 289 - }, 290 - ], 293 + facets, 291 294 embed: { 292 295 $type: 'app.bsky.embed.external', 293 296 external: {