A CLI for publishing standard.site documents to ATProto

Adds option for custom bskyPost text rather than just title, desc, and url. Also removes url from post text to save graphemes.

+18 -39
+1
packages/cli/src/commands/publish.ts
··· 359 359 bskyPostRef = await createBlueskyPost(agent, { 360 360 title: post.frontmatter.title, 361 361 description: post.frontmatter.description, 362 + bskyPost: post.frontmatter.bskyPost, 362 363 canonicalUrl, 363 364 coverImage, 364 365 publishedAt: post.frontmatter.publishDate,
+16 -39
packages/cli/src/lib/atproto.ts
··· 569 569 export interface CreateBlueskyPostOptions { 570 570 title: string; 571 571 description?: string; 572 + bskyPost?: string; 572 573 canonicalUrl: string; 573 574 coverImage?: BlobObject; 574 575 publishedAt: string; // Used as createdAt for the post ··· 612 613 agent: Agent, 613 614 options: CreateBlueskyPostOptions, 614 615 ): Promise<StrongRef> { 615 - const { title, description, canonicalUrl, coverImage, publishedAt } = options; 616 + const { title, description, bskyPost, canonicalUrl, coverImage, publishedAt } = options; 616 617 617 - // Build post text: title + description + URL 618 + // Build post text: title + description 618 619 // Max 300 graphemes for Bluesky posts 619 620 const MAX_GRAPHEMES = 300; 620 621 621 622 let postText: string; 622 - const urlPart = `\n\n${canonicalUrl}`; 623 - const urlGraphemes = countGraphemes(urlPart); 624 623 625 - if (description) { 626 - // Try: title + description + URL 627 - const fullText = `${title}\n\n${description}${urlPart}`; 624 + if (bskyPost) { 625 + // Custom bsky post overrides any default behavior 626 + postText = bskyPost; 627 + } 628 + else if (description) { 629 + // Try: title + description 630 + const fullText = `${title}\n\n${description}`; 628 631 if (countGraphemes(fullText) <= MAX_GRAPHEMES) { 629 632 postText = fullText; 630 633 } else { ··· 632 635 const availableForDesc = 633 636 MAX_GRAPHEMES - 634 637 countGraphemes(title) - 635 - countGraphemes("\n\n") - 636 - urlGraphemes - 637 638 countGraphemes("\n\n"); 638 639 if (availableForDesc > 10) { 639 640 const truncatedDesc = truncateToGraphemes( 640 641 description, 641 642 availableForDesc, 642 643 ); 643 - postText = `${title}\n\n${truncatedDesc}${urlPart}`; 644 + postText = `${title}\n\n${truncatedDesc}`; 644 645 } else { 645 - // Just title + URL 646 - postText = `${title}${urlPart}`; 646 + // Just title 647 + postText = `${title}`; 647 648 } 648 649 } 649 650 } else { 650 - // Just title + URL 651 - postText = `${title}${urlPart}`; 651 + // Just title 652 + postText = `${title}`; 652 653 } 653 654 654 - // Final truncation if still too long (shouldn't happen but safety check) 655 + // Final truncation in case title or bskyPost are longer than expected 655 656 if (countGraphemes(postText) > MAX_GRAPHEMES) { 656 657 postText = truncateToGraphemes(postText, MAX_GRAPHEMES); 657 658 } 658 659 659 - // Calculate byte indices for the URL facet 660 - const encoder = new TextEncoder(); 661 - const urlStartInText = postText.lastIndexOf(canonicalUrl); 662 - const beforeUrl = postText.substring(0, urlStartInText); 663 - const byteStart = encoder.encode(beforeUrl).length; 664 - const byteEnd = byteStart + encoder.encode(canonicalUrl).length; 665 - 666 - // Build facets for the URL link 667 - const facets = [ 668 - { 669 - index: { 670 - byteStart, 671 - byteEnd, 672 - }, 673 - features: [ 674 - { 675 - $type: "app.bsky.richtext.facet#link", 676 - uri: canonicalUrl, 677 - }, 678 - ], 679 - }, 680 - ]; 681 - 682 660 // Build external embed 683 661 const embed: Record<string, unknown> = { 684 662 $type: "app.bsky.embed.external", ··· 698 676 const record: Record<string, unknown> = { 699 677 $type: "app.bsky.feed.post", 700 678 text: postText, 701 - facets, 702 679 embed, 703 680 createdAt: new Date(publishedAt).toISOString(), 704 681 };
+1
packages/cli/src/lib/types.ts
··· 87 87 export interface PostFrontmatter { 88 88 title: string; 89 89 description?: string; 90 + bskyPost?: string; 90 91 publishDate: string; 91 92 tags?: string[]; 92 93 ogImage?: string;