A calm place to write long-form, and publish it to the open social web.
skypress.blog/
1import type { Mention } from './mentions';
2
3/** A richtext facet over a UTF-8 byte range of the post text. */
4export interface PostFacet {
5 $type: 'app.bsky.richtext.facet';
6 index: { byteStart: number; byteEnd: number };
7 features: Array<
8 | { $type: 'app.bsky.richtext.facet#link'; uri: string }
9 | { $type: 'app.bsky.richtext.facet#mention'; did: string }
10 >;
11}
12
13const SEP = '\n\n';
14
15function utf8ByteLength( value: string ): number {
16 return new TextEncoder().encode( value ).length;
17}
18
19/**
20 * Build the companion Bluesky post's `text` and `facets`. The body is
21 * `title \n\n {lede?} \n\n cc @a @b \n\n {url}`, with the lede omitted when blank and the
22 * cc line omitted when there are no mentions. Byte offsets are computed during assembly so
23 * the link and mention facets always line up with the final UTF-8 string.
24 */
25export function assemblePostText( input: {
26 title: string;
27 articleUrl: string;
28 bodyLede?: string;
29 mentions?: Mention[];
30} ): { text: string; facets: PostFacet[] } {
31 const mentions = input.mentions ?? [];
32 const lede = input.bodyLede?.trim() ?? '';
33 const ccLine = mentions.length
34 ? `cc ${ mentions.map( ( m ) => `@${ m.handle }` ).join( ' ' ) }`
35 : '';
36
37 const segments: string[] = [ input.title ];
38 if ( lede ) {
39 segments.push( lede );
40 }
41 if ( ccLine ) {
42 segments.push( ccLine );
43 }
44 segments.push( input.articleUrl );
45
46 const text = segments.join( SEP );
47
48 // Byte start of each segment in the joined string.
49 const segByteStart: number[] = [];
50 let cursor = 0;
51 segments.forEach( ( segment, i ) => {
52 if ( i > 0 ) {
53 cursor += utf8ByteLength( SEP );
54 }
55 segByteStart[ i ] = cursor;
56 cursor += utf8ByteLength( segment );
57 } );
58
59 const facets: PostFacet[] = [];
60
61 // Mention facets (each @handle inside the cc line).
62 if ( ccLine ) {
63 // The cc line, when present, is always the second-to-last segment (before the URL).
64 const ccIndex = segments.length - 2;
65 let local = utf8ByteLength( 'cc ' );
66 mentions.forEach( ( mention, i ) => {
67 if ( i > 0 ) {
68 local += utf8ByteLength( ' ' );
69 }
70 const token = `@${ mention.handle }`;
71 const byteStart = segByteStart[ ccIndex ] + local;
72 facets.push( {
73 $type: 'app.bsky.richtext.facet',
74 index: { byteStart, byteEnd: byteStart + utf8ByteLength( token ) },
75 features: [ { $type: 'app.bsky.richtext.facet#mention', did: mention.did } ],
76 } );
77 local += utf8ByteLength( token );
78 } );
79 }
80
81 // Link facet (the article URL, always the last segment).
82 const urlIndex = segments.length - 1;
83 const urlStart = segByteStart[ urlIndex ];
84 facets.push( {
85 $type: 'app.bsky.richtext.facet',
86 index: { byteStart: urlStart, byteEnd: urlStart + utf8ByteLength( input.articleUrl ) },
87 features: [ { $type: 'app.bsky.richtext.facet#link', uri: input.articleUrl } ],
88 } );
89
90 return { text, facets };
91}