···77import type { BlockNode } from '../blocks/render';
88import type { BlobRefJson } from '../media/blob';
99import { parseBasicTheme, type BasicTheme } from './themes';
1010+import { assemblePostText, type PostFacet } from './post-text';
1111+import { graphemeLength } from './grapheme';
1212+import type { Mention } from './mentions';
10131114/**
1215 * Public origin for the stored publication + article URLs (and the Bluesky post link).
···221224 };
222225}
223226224224-/** A richtext link facet (`app.bsky.richtext.facet#link`) over a UTF-8 byte range of `text`. */
225225-export interface BskyLinkFacet {
226226- $type: 'app.bsky.richtext.facet';
227227- index: { byteStart: number; byteEnd: number };
228228- features: Array< { $type: 'app.bsky.richtext.facet#link'; uri: string } >;
229229-}
230230-231227/** A `com.atproto.repo.strongRef` as embedded in `external.associatedRefs` (Decision 0013). */
232228export interface AssociatedRef extends StrongRef {
233229 $type: 'com.atproto.repo.strongRef';
···237233 $type: 'app.bsky.feed.post';
238234 text: string;
239235 createdAt: string;
240240- /** Always present: marks the article URL in `text` as a clickable link (Decision 0013). */
241241- facets: BskyLinkFacet[];
236236+ /** Marks the article URL as a clickable link, plus a #mention facet per cc-ed account (Decision 0013). */
237237+ facets: PostFacet[];
242238 embed: {
243239 $type: 'app.bsky.embed.external';
244240 external: {
···253249 };
254250}
255251256256-/** UTF-8 byte length — facet offsets are byte-based, not JS string-index based. */
257257-function utf8ByteLength( value: string ): number {
258258- return new TextEncoder().encode( value ).length;
259259-}
252252+/** Bluesky rejects posts longer than this many graphemes. */
253253+const BSKY_POST_MAX_GRAPHEMES = 300;
260254261255/**
262262- * Build the companion Bluesky post (Decision 0005). The article URL is appended to the text and
263263- * marked as a clickable link via a richtext facet; `associatedRefs` (the document + publication
264264- * strongRefs) are embedded so Bluesky renders the rich standard.site link card (Decision 0013).
256256+ * Build the companion Bluesky post (Decision 0005). Body text + link/mention facets come
257257+ * from `assemblePostText` (shared with the editor's live counter). `description` populates
258258+ * ONLY the embed card subtitle; the writer-typed lede goes in the body via `bodyLede`.
259259+ * `associatedRefs` drive the standard.site rich card (Decision 0013).
265260 */
266261export function buildBskyPost( input: {
267262 title: string;
268263 articleUrl: string;
269264 createdAt: string;
270265 description?: string;
266266+ /** The writer-typed lede, placed in the post body (omitted when blank). */
267267+ bodyLede?: string;
268268+ /** Accounts to cc + notify via #mention facets. */
269269+ mentions?: Mention[];
271270 /** Optional image blob for the card's `thumb` (the og:image fallback, Decision 0014). */
272271 thumb?: BlobRefJson;
273272 /** Document + publication strongRefs, in that order, for the standard.site card. */
274273 associatedRefs?: StrongRef[];
275274} ): BskyPostRecord {
276276- const text = `${ input.title }\n\n${ input.articleUrl }`;
277277- // The URL is the trailing segment of `text`; mark its byte range as a link facet.
278278- const byteStart = utf8ByteLength( `${ input.title }\n\n` );
279279- const byteEnd = byteStart + utf8ByteLength( input.articleUrl );
275275+ const { text, facets } = assemblePostText( {
276276+ title: input.title,
277277+ articleUrl: input.articleUrl,
278278+ bodyLede: input.bodyLede,
279279+ mentions: input.mentions,
280280+ } );
281281+282282+ const length = graphemeLength( text );
283283+ if ( length > BSKY_POST_MAX_GRAPHEMES ) {
284284+ throw new Error(
285285+ `Bluesky post is ${ length } characters; the limit is ${ BSKY_POST_MAX_GRAPHEMES }. Shorten the subtitle or remove a mention.`
286286+ );
287287+ }
288288+280289 return {
281290 $type: 'app.bsky.feed.post',
282291 text,
283292 createdAt: input.createdAt,
284284- facets: [
285285- {
286286- $type: 'app.bsky.richtext.facet',
287287- index: { byteStart, byteEnd },
288288- features: [ { $type: 'app.bsky.richtext.facet#link', uri: input.articleUrl } ],
289289- },
290290- ],
293293+ facets,
291294 embed: {
292295 $type: 'app.bsky.embed.external',
293296 external: {