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

Configure Feed

Select the types of activity you want to include in your feed.

at main 767 lines 20 kB view raw
1import { Agent, AtpAgent } from "@atproto/api"; 2import * as mimeTypes from "mime-types"; 3import * as fs from "node:fs/promises"; 4import * as path from "node:path"; 5import { stripMarkdownForText, resolvePostPath } from "./markdown"; 6import { getOAuthClient } from "./oauth-client"; 7import type { 8 BlobObject, 9 BlogPost, 10 Credentials, 11 PublicationRecord, 12 PublisherConfig, 13 StrongRef, 14} from "./types"; 15import { isAppPasswordCredentials, isOAuthCredentials } from "./types"; 16 17// https://standard.site/docs/lexicons/document/#optional-properties 18export const COVER_IMAGE_MAX_SIZE = 1024 * 1024 - 1; 19 20/** 21 * Type guard to check if a record value is a DocumentRecord 22 */ 23function isDocumentRecord(value: unknown): value is DocumentRecord { 24 if (!value || typeof value !== "object") return false; 25 const v = value as Record<string, unknown>; 26 return ( 27 v.$type === "site.standard.document" && 28 typeof v.title === "string" && 29 typeof v.site === "string" && 30 typeof v.path === "string" && 31 (v.textContent === undefined || typeof v.textContent === "string") && 32 typeof v.publishedAt === "string" && 33 (v.updatedAt === undefined || typeof v.updatedAt === "string") 34 ); 35} 36 37async function fileExists(filePath: string): Promise<boolean> { 38 try { 39 await fs.access(filePath); 40 return true; 41 } catch { 42 return false; 43 } 44} 45 46/** 47 * Resolve a handle to a DID 48 */ 49export async function resolveHandleToDid(handle: string): Promise<string> { 50 if (handle.startsWith("did:")) { 51 return handle; 52 } 53 54 // Try to resolve handle via Bluesky API 55 const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 56 const resolveResponse = await fetch(resolveUrl); 57 if (!resolveResponse.ok) { 58 throw new Error("Could not resolve handle"); 59 } 60 const resolveData = (await resolveResponse.json()) as { did: string }; 61 return resolveData.did; 62} 63 64export async function resolveHandleToPDS(handle: string): Promise<string> { 65 // First, resolve the handle to a DID 66 const did = await resolveHandleToDid(handle); 67 68 // Now resolve the DID to get the PDS URL from the DID document 69 let pdsUrl: string | undefined; 70 71 if (did.startsWith("did:plc:")) { 72 // Fetch DID document from plc.directory 73 const didDocUrl = `https://plc.directory/${did}`; 74 const didDocResponse = await fetch(didDocUrl); 75 if (!didDocResponse.ok) { 76 throw new Error("Could not fetch DID document"); 77 } 78 const didDoc = (await didDocResponse.json()) as { 79 service?: Array<{ id: string; type: string; serviceEndpoint: string }>; 80 }; 81 82 // Find the PDS service endpoint 83 const pdsService = didDoc.service?.find( 84 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 85 ); 86 pdsUrl = pdsService?.serviceEndpoint; 87 } else if (did.startsWith("did:web:")) { 88 // For did:web, fetch the DID document from the domain 89 const domain = did.replace("did:web:", ""); 90 const didDocUrl = `https://${domain}/.well-known/did.json`; 91 const didDocResponse = await fetch(didDocUrl); 92 if (!didDocResponse.ok) { 93 throw new Error("Could not fetch DID document"); 94 } 95 const didDoc = (await didDocResponse.json()) as { 96 service?: Array<{ id: string; type: string; serviceEndpoint: string }>; 97 }; 98 99 const pdsService = didDoc.service?.find( 100 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 101 ); 102 pdsUrl = pdsService?.serviceEndpoint; 103 } 104 105 if (!pdsUrl) { 106 throw new Error("Could not find PDS URL for user"); 107 } 108 109 return pdsUrl; 110} 111 112export interface CreatePublicationOptions { 113 url: string; 114 name: string; 115 description?: string; 116 iconPath?: string; 117 showInDiscover?: boolean; 118} 119 120export async function createAgent(credentials: Credentials): Promise<Agent> { 121 if (isOAuthCredentials(credentials)) { 122 // OAuth flow - restore session from stored tokens 123 const client = await getOAuthClient(); 124 try { 125 const oauthSession = await client.restore(credentials.did); 126 // Wrap the OAuth session in an Agent which provides the atproto API 127 return new Agent(oauthSession); 128 } catch (error) { 129 if (error instanceof Error) { 130 // Check for common OAuth errors 131 if ( 132 error.message.includes("expired") || 133 error.message.includes("revoked") 134 ) { 135 throw new Error( 136 `OAuth session expired or revoked. Please run 'sequoia login' to re-authenticate.`, 137 ); 138 } 139 } 140 throw error; 141 } 142 } 143 144 // App password flow 145 if (!isAppPasswordCredentials(credentials)) { 146 throw new Error("Invalid credential type"); 147 } 148 const agent = new AtpAgent({ service: credentials.pdsUrl }); 149 150 await agent.login({ 151 identifier: credentials.identifier, 152 password: credentials.password, 153 }); 154 155 return agent; 156} 157 158export async function uploadImage( 159 agent: Agent, 160 imagePath: string, 161): Promise<BlobObject | undefined> { 162 if (!(await fileExists(imagePath))) { 163 return undefined; 164 } 165 166 try { 167 const imageBuffer = await fs.readFile(imagePath); 168 const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream"; 169 170 const response = await agent.com.atproto.repo.uploadBlob( 171 new Uint8Array(imageBuffer), 172 { 173 encoding: mimeType, 174 }, 175 ); 176 177 return { 178 $type: "blob", 179 ref: { 180 $link: response.data.blob.ref.toString(), 181 }, 182 mimeType, 183 size: imageBuffer.byteLength, 184 }; 185 } catch (error) { 186 console.error(`Error uploading image ${imagePath}:`, error); 187 return undefined; 188 } 189} 190 191export async function resolveImagePath( 192 ogImage: string, 193 imagesDir: string | undefined, 194 contentDir: string, 195): Promise<string | undefined> { 196 // Try multiple resolution strategies 197 198 // 1. If imagesDir is specified, look there 199 if (imagesDir) { 200 // Get the base name of the images directory (e.g., "blog-images" from "public/blog-images") 201 const imagesDirBaseName = path.basename(imagesDir); 202 203 // Check if ogImage contains the images directory name and extract the relative path 204 // e.g., "/blog-images/other/file.png" with imagesDirBaseName "blog-images" -> "other/file.png" 205 const imagesDirIndex = ogImage.indexOf(imagesDirBaseName); 206 let relativePath: string; 207 208 if (imagesDirIndex !== -1) { 209 // Extract everything after "blog-images/" 210 const afterImagesDir = ogImage.substring( 211 imagesDirIndex + imagesDirBaseName.length, 212 ); 213 // Remove leading slash if present 214 relativePath = afterImagesDir.replace(/^[/\\]/, ""); 215 } else { 216 // Fall back to just the filename 217 relativePath = path.basename(ogImage); 218 } 219 220 const imagePath = path.join(imagesDir, relativePath); 221 if (await fileExists(imagePath)) { 222 const stat = await fs.stat(imagePath); 223 if (stat.size > 0) { 224 return imagePath; 225 } 226 } 227 } 228 229 // 2. Try the ogImage path directly (if it's absolute) 230 if (path.isAbsolute(ogImage)) { 231 return ogImage; 232 } 233 234 // 3. Try relative to content directory 235 const contentRelative = path.join(contentDir, ogImage); 236 if (await fileExists(contentRelative)) { 237 const stat = await fs.stat(contentRelative); 238 if (stat.size > 0) { 239 return contentRelative; 240 } 241 } 242 243 return null; 244} 245 246export async function createDocument( 247 agent: Agent, 248 post: BlogPost, 249 config: PublisherConfig, 250 coverImage?: BlobObject, 251): Promise<string> { 252 const postPath = resolvePostPath( 253 post, 254 config.pathPrefix, 255 config.pathTemplate, 256 ); 257 const publishDate = new Date(post.frontmatter.publishDate); 258 259 // Handle updatedAt: only set if explicitly provided in frontmatter 260 let updatedAt: Date | undefined; 261 if (post.frontmatter.updatedAt) { 262 updatedAt = new Date(post.frontmatter.updatedAt); 263 } 264 265 // Determine textContent (if enabled): use configured field from frontmatter, or fallback to markdown body 266 let textContent: string | null = null; 267 if ( 268 config.publishContent && 269 config.textContentField && 270 post.rawFrontmatter?.[config.textContentField] 271 ) { 272 textContent = String(post.rawFrontmatter[config.textContentField]); 273 } else if (config.publishContent) { 274 textContent = stripMarkdownForText(post.content); 275 } 276 277 const record: Record<string, unknown> = { 278 $type: "site.standard.document", 279 title: post.frontmatter.title, 280 site: config.publicationUri, 281 path: postPath, 282 textContent: textContent?.slice(0, 10000), 283 publishedAt: publishDate.toISOString(), 284 canonicalUrl: `${config.siteUrl}${postPath}`, 285 }; 286 287 if (updatedAt) { 288 record.updatedAt = updatedAt.toISOString(); 289 } 290 291 if (post.frontmatter.description) { 292 record.description = post.frontmatter.description; 293 } 294 295 if (coverImage) { 296 record.coverImage = coverImage; 297 } 298 299 if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 300 record.tags = post.frontmatter.tags; 301 } 302 303 const response = await agent.com.atproto.repo.createRecord({ 304 repo: agent.did!, 305 collection: "site.standard.document", 306 record, 307 }); 308 309 return response.data.uri; 310} 311 312export async function updateDocument( 313 agent: Agent, 314 post: BlogPost, 315 atUri: string, 316 config: PublisherConfig, 317 coverImage?: BlobObject, 318): Promise<void> { 319 // Parse the atUri to get the collection and rkey 320 // Format: at://did:plc:xxx/collection/rkey 321 const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 322 if (!uriMatch) { 323 throw new Error(`Invalid atUri format: ${atUri}`); 324 } 325 326 const [, , collection, rkey] = uriMatch; 327 328 const postPath = resolvePostPath( 329 post, 330 config.pathPrefix, 331 config.pathTemplate, 332 ); 333 const publishDate = new Date(post.frontmatter.publishDate); 334 335 // Handle updatedAt: only set if explicitly provided in frontmatter 336 const updatedAt = post.frontmatter.updatedAt 337 ? new Date(post.frontmatter.updatedAt) 338 : undefined; 339 340 // Determine textContent (if enabled): use configured field from frontmatter, or fallback to markdown body 341 let textContent: string | null = null; 342 if ( 343 config.publishContent && 344 config.textContentField && 345 post.rawFrontmatter?.[config.textContentField] 346 ) { 347 textContent = String(post.rawFrontmatter[config.textContentField]); 348 } else if (config.publishContent) { 349 textContent = stripMarkdownForText(post.content); 350 } 351 352 // Fetch existing record to preserve PDS-side fields (e.g. bskyPostRef) 353 const existingResponse = await agent.com.atproto.repo.getRecord({ 354 repo: agent.did!, 355 collection: collection!, 356 rkey: rkey!, 357 }); 358 const existingRecord = existingResponse.data.value as Record<string, unknown>; 359 360 const record: Record<string, unknown> = { 361 ...existingRecord, 362 $type: "site.standard.document", 363 title: post.frontmatter.title, 364 site: config.publicationUri, 365 path: postPath, 366 textContent: textContent?.slice(0, 10000), 367 publishedAt: publishDate.toISOString(), 368 canonicalUrl: `${config.siteUrl}${postPath}`, 369 }; 370 371 if (updatedAt) { 372 record.updatedAt = updatedAt.toISOString(); 373 } 374 375 if (post.frontmatter.description) { 376 record.description = post.frontmatter.description; 377 } 378 379 if (coverImage) { 380 record.coverImage = coverImage; 381 } 382 383 if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 384 record.tags = post.frontmatter.tags; 385 } 386 387 await agent.com.atproto.repo.putRecord({ 388 repo: agent.did!, 389 collection: collection!, 390 rkey: rkey!, 391 record, 392 }); 393} 394 395export function parseAtUri( 396 atUri: string, 397): { did: string; collection: string; rkey: string } | null { 398 const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 399 if (!match) return null; 400 return { 401 did: match[1]!, 402 collection: match[2]!, 403 rkey: match[3]!, 404 }; 405} 406 407export interface DocumentRecord { 408 $type: "site.standard.document"; 409 title: string; 410 site: string; 411 path: string; 412 textContent?: string; 413 publishedAt: string; 414 updatedAt?: string; 415 canonicalUrl?: string; 416 description?: string; 417 coverImage?: BlobObject; 418 tags?: string[]; 419 location?: string; 420} 421 422export interface ListDocumentsResult { 423 uri: string; 424 cid: string; 425 value: DocumentRecord; 426} 427 428export async function listDocuments( 429 agent: Agent, 430 publicationUri?: string, 431): Promise<ListDocumentsResult[]> { 432 const documents: ListDocumentsResult[] = []; 433 let cursor: string | undefined; 434 435 do { 436 const response = await agent.com.atproto.repo.listRecords({ 437 repo: agent.did!, 438 collection: "site.standard.document", 439 limit: 100, 440 cursor, 441 }); 442 443 for (const record of response.data.records) { 444 if (!isDocumentRecord(record.value)) { 445 continue; 446 } 447 448 // If publicationUri is specified, only include documents from that publication 449 if (publicationUri && record.value.site !== publicationUri) { 450 continue; 451 } 452 453 documents.push({ 454 uri: record.uri, 455 cid: record.cid, 456 value: record.value, 457 }); 458 } 459 460 cursor = response.data.cursor; 461 } while (cursor); 462 463 return documents; 464} 465 466export async function createPublication( 467 agent: Agent, 468 options: CreatePublicationOptions, 469): Promise<string> { 470 let icon: BlobObject | undefined; 471 472 if (options.iconPath) { 473 icon = await uploadImage(agent, options.iconPath); 474 } 475 476 const record: Record<string, unknown> = { 477 $type: "site.standard.publication", 478 url: options.url, 479 name: options.name, 480 createdAt: new Date().toISOString(), 481 }; 482 483 if (options.description) { 484 record.description = options.description; 485 } 486 487 if (icon) { 488 record.icon = icon; 489 } 490 491 if (options.showInDiscover !== undefined) { 492 record.preferences = { 493 showInDiscover: options.showInDiscover, 494 }; 495 } 496 497 const response = await agent.com.atproto.repo.createRecord({ 498 repo: agent.did!, 499 collection: "site.standard.publication", 500 record, 501 }); 502 503 return response.data.uri; 504} 505 506export interface GetPublicationResult { 507 uri: string; 508 cid: string; 509 value: PublicationRecord; 510} 511 512export async function getPublication( 513 agent: Agent, 514 publicationUri: string, 515): Promise<GetPublicationResult | null> { 516 const parsed = parseAtUri(publicationUri); 517 if (!parsed) { 518 return null; 519 } 520 521 try { 522 const response = await agent.com.atproto.repo.getRecord({ 523 repo: parsed.did, 524 collection: parsed.collection, 525 rkey: parsed.rkey, 526 }); 527 528 return { 529 uri: publicationUri, 530 cid: response.data.cid!, 531 value: response.data.value as unknown as PublicationRecord, 532 }; 533 } catch { 534 return null; 535 } 536} 537 538export interface UpdatePublicationOptions { 539 url?: string; 540 name?: string; 541 description?: string; 542 iconPath?: string; 543 showInDiscover?: boolean; 544} 545 546export async function updatePublication( 547 agent: Agent, 548 publicationUri: string, 549 options: UpdatePublicationOptions, 550 existingRecord: PublicationRecord, 551): Promise<void> { 552 const parsed = parseAtUri(publicationUri); 553 if (!parsed) { 554 throw new Error(`Invalid publication URI: ${publicationUri}`); 555 } 556 557 // Build updated record, preserving createdAt and $type 558 const record: Record<string, unknown> = { 559 $type: existingRecord.$type, 560 url: options.url ?? existingRecord.url, 561 name: options.name ?? existingRecord.name, 562 createdAt: existingRecord.createdAt, 563 }; 564 565 // Handle description - can be cleared with empty string 566 if (options.description !== undefined) { 567 if (options.description) { 568 record.description = options.description; 569 } 570 // If empty string, don't include description (clears it) 571 } else if (existingRecord.description) { 572 record.description = existingRecord.description; 573 } 574 575 // Handle icon - upload new if provided, otherwise keep existing 576 if (options.iconPath) { 577 const icon = await uploadImage(agent, options.iconPath); 578 if (icon) { 579 record.icon = icon; 580 } 581 } else if (existingRecord.icon) { 582 record.icon = existingRecord.icon; 583 } 584 585 // Handle preferences 586 if (options.showInDiscover !== undefined) { 587 record.preferences = { 588 showInDiscover: options.showInDiscover, 589 }; 590 } else if (existingRecord.preferences) { 591 record.preferences = existingRecord.preferences; 592 } 593 594 await agent.com.atproto.repo.putRecord({ 595 repo: parsed.did, 596 collection: parsed.collection, 597 rkey: parsed.rkey, 598 record, 599 }); 600} 601 602// --- Bluesky Post Creation --- 603 604export interface CreateBlueskyPostOptions { 605 title: string; 606 description?: string; 607 bskyPost?: string; 608 canonicalUrl: string; 609 coverImage?: BlobObject; 610 publishedAt: string; // Used as createdAt for the post 611} 612 613/** 614 * Count graphemes in a string (for Bluesky's 300 grapheme limit) 615 */ 616function countGraphemes(str: string): number { 617 // Use Intl.Segmenter if available, otherwise fallback to spread operator 618 if (typeof Intl !== "undefined" && Intl.Segmenter) { 619 const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 620 return [...segmenter.segment(str)].length; 621 } 622 return [...str].length; 623} 624 625/** 626 * Truncate a string to a maximum number of graphemes 627 */ 628function truncateToGraphemes(str: string, maxGraphemes: number): string { 629 if (typeof Intl !== "undefined" && Intl.Segmenter) { 630 const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 631 const segments = [...segmenter.segment(str)]; 632 if (segments.length <= maxGraphemes) return str; 633 return `${segments 634 .slice(0, maxGraphemes - 3) 635 .map((s) => s.segment) 636 .join("")}...`; 637 } 638 // Fallback 639 const chars = [...str]; 640 if (chars.length <= maxGraphemes) return str; 641 return `${chars.slice(0, maxGraphemes - 3).join("")}...`; 642} 643 644/** 645 * Create a Bluesky post with external link embed 646 */ 647export async function createBlueskyPost( 648 agent: Agent, 649 options: CreateBlueskyPostOptions, 650): Promise<StrongRef> { 651 const { 652 title, 653 description, 654 bskyPost, 655 canonicalUrl, 656 coverImage, 657 publishedAt, 658 } = options; 659 660 // Build post text: title + description 661 // Max 300 graphemes for Bluesky posts 662 const MAX_GRAPHEMES = 300; 663 664 let postText: string; 665 666 if (bskyPost) { 667 // Custom bsky post overrides any default behavior 668 postText = bskyPost; 669 } else if (description) { 670 // Try: title + description 671 const fullText = `${title}\n\n${description}`; 672 if (countGraphemes(fullText) <= MAX_GRAPHEMES) { 673 postText = fullText; 674 } else { 675 // Truncate description to fit 676 const availableForDesc = 677 MAX_GRAPHEMES - countGraphemes(title) - countGraphemes("\n\n"); 678 if (availableForDesc > 10) { 679 const truncatedDesc = truncateToGraphemes( 680 description, 681 availableForDesc, 682 ); 683 postText = `${title}\n\n${truncatedDesc}`; 684 } else { 685 // Just title 686 postText = `${title}`; 687 } 688 } 689 } else { 690 // Just title 691 postText = `${title}`; 692 } 693 694 // Final truncation in case title or bskyPost are longer than expected 695 if (countGraphemes(postText) > MAX_GRAPHEMES) { 696 postText = truncateToGraphemes(postText, MAX_GRAPHEMES); 697 } 698 699 // Build external embed 700 const embed: Record<string, unknown> = { 701 $type: "app.bsky.embed.external", 702 external: { 703 uri: canonicalUrl, 704 title: title.substring(0, 500), // Max 500 chars for title 705 description: (description || "").substring(0, 1000), // Max 1000 chars for description 706 }, 707 }; 708 709 // Add thumbnail if coverImage is available 710 if (coverImage) { 711 (embed.external as Record<string, unknown>).thumb = coverImage; 712 } 713 714 // Create the post record 715 const record: Record<string, unknown> = { 716 $type: "app.bsky.feed.post", 717 text: postText, 718 embed, 719 createdAt: new Date(publishedAt).toISOString(), 720 }; 721 722 const response = await agent.com.atproto.repo.createRecord({ 723 repo: agent.did!, 724 collection: "app.bsky.feed.post", 725 record, 726 }); 727 728 return { 729 uri: response.data.uri, 730 cid: response.data.cid, 731 }; 732} 733 734/** 735 * Add bskyPostRef to an existing document record 736 */ 737export async function addBskyPostRefToDocument( 738 agent: Agent, 739 documentAtUri: string, 740 bskyPostRef: StrongRef, 741): Promise<void> { 742 const parsed = parseAtUri(documentAtUri); 743 if (!parsed) { 744 throw new Error(`Invalid document URI: ${documentAtUri}`); 745 } 746 747 // Fetch existing record 748 const existingRecord = await agent.com.atproto.repo.getRecord({ 749 repo: parsed.did, 750 collection: parsed.collection, 751 rkey: parsed.rkey, 752 }); 753 754 // Add bskyPostRef to the record 755 const updatedRecord = { 756 ...(existingRecord.data.value as Record<string, unknown>), 757 bskyPostRef, 758 }; 759 760 // Update the record 761 await agent.com.atproto.repo.putRecord({ 762 repo: parsed.did, 763 collection: parsed.collection, 764 rkey: parsed.rkey, 765 record: updatedRecord, 766 }); 767}