A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing

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

authored by quillmatiq.com and committed by tangled.org 501c7bd6 f6d1e094

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