A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing
at v0.2.1 577 lines 14 kB view raw
1import { 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 } from "./markdown"; 6import type { 7 BlobObject, 8 BlogPost, 9 Credentials, 10 PublisherConfig, 11 StrongRef, 12} from "./types"; 13 14async function fileExists(filePath: string): Promise<boolean> { 15 try { 16 await fs.access(filePath); 17 return true; 18 } catch { 19 return false; 20 } 21} 22 23export async function resolveHandleToPDS(handle: string): Promise<string> { 24 // First, resolve the handle to a DID 25 let did: string; 26 27 if (handle.startsWith("did:")) { 28 did = handle; 29 } else { 30 // Try to resolve handle via Bluesky API 31 const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 32 const resolveResponse = await fetch(resolveUrl); 33 if (!resolveResponse.ok) { 34 throw new Error("Could not resolve handle"); 35 } 36 const resolveData = (await resolveResponse.json()) as { did: string }; 37 did = resolveData.did; 38 } 39 40 // Now resolve the DID to get the PDS URL from the DID document 41 let pdsUrl: string | undefined; 42 43 if (did.startsWith("did:plc:")) { 44 // Fetch DID document from plc.directory 45 const didDocUrl = `https://plc.directory/${did}`; 46 const didDocResponse = await fetch(didDocUrl); 47 if (!didDocResponse.ok) { 48 throw new Error("Could not fetch DID document"); 49 } 50 const didDoc = (await didDocResponse.json()) as { 51 service?: Array<{ id: string; type: string; serviceEndpoint: string }>; 52 }; 53 54 // Find the PDS service endpoint 55 const pdsService = didDoc.service?.find( 56 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 57 ); 58 pdsUrl = pdsService?.serviceEndpoint; 59 } else if (did.startsWith("did:web:")) { 60 // For did:web, fetch the DID document from the domain 61 const domain = did.replace("did:web:", ""); 62 const didDocUrl = `https://${domain}/.well-known/did.json`; 63 const didDocResponse = await fetch(didDocUrl); 64 if (!didDocResponse.ok) { 65 throw new Error("Could not fetch DID document"); 66 } 67 const didDoc = (await didDocResponse.json()) as { 68 service?: Array<{ id: string; type: string; serviceEndpoint: string }>; 69 }; 70 71 const pdsService = didDoc.service?.find( 72 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 73 ); 74 pdsUrl = pdsService?.serviceEndpoint; 75 } 76 77 if (!pdsUrl) { 78 throw new Error("Could not find PDS URL for user"); 79 } 80 81 return pdsUrl; 82} 83 84export interface CreatePublicationOptions { 85 url: string; 86 name: string; 87 description?: string; 88 iconPath?: string; 89 showInDiscover?: boolean; 90} 91 92export async function createAgent(credentials: Credentials): Promise<AtpAgent> { 93 const agent = new AtpAgent({ service: credentials.pdsUrl }); 94 95 await agent.login({ 96 identifier: credentials.identifier, 97 password: credentials.password, 98 }); 99 100 return agent; 101} 102 103export async function uploadImage( 104 agent: AtpAgent, 105 imagePath: string, 106): Promise<BlobObject | undefined> { 107 if (!(await fileExists(imagePath))) { 108 return undefined; 109 } 110 111 try { 112 const imageBuffer = await fs.readFile(imagePath); 113 const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream"; 114 115 const response = await agent.com.atproto.repo.uploadBlob( 116 new Uint8Array(imageBuffer), 117 { 118 encoding: mimeType, 119 }, 120 ); 121 122 return { 123 $type: "blob", 124 ref: { 125 $link: response.data.blob.ref.toString(), 126 }, 127 mimeType, 128 size: imageBuffer.byteLength, 129 }; 130 } catch (error) { 131 console.error(`Error uploading image ${imagePath}:`, error); 132 return undefined; 133 } 134} 135 136export async function resolveImagePath( 137 ogImage: string, 138 imagesDir: string | undefined, 139 contentDir: string, 140): Promise<string | null> { 141 // Try multiple resolution strategies 142 const filename = path.basename(ogImage); 143 144 // 1. If imagesDir is specified, look there 145 if (imagesDir) { 146 const imagePath = path.join(imagesDir, filename); 147 if (await fileExists(imagePath)) { 148 const stat = await fs.stat(imagePath); 149 if (stat.size > 0) { 150 return imagePath; 151 } 152 } 153 } 154 155 // 2. Try the ogImage path directly (if it's absolute) 156 if (path.isAbsolute(ogImage)) { 157 return ogImage; 158 } 159 160 // 3. Try relative to content directory 161 const contentRelative = path.join(contentDir, ogImage); 162 if (await fileExists(contentRelative)) { 163 const stat = await fs.stat(contentRelative); 164 if (stat.size > 0) { 165 return contentRelative; 166 } 167 } 168 169 return null; 170} 171 172export async function createDocument( 173 agent: AtpAgent, 174 post: BlogPost, 175 config: PublisherConfig, 176 coverImage?: BlobObject, 177): Promise<string> { 178 const pathPrefix = config.pathPrefix || "/posts"; 179 const postPath = `${pathPrefix}/${post.slug}`; 180 const publishDate = new Date(post.frontmatter.publishDate); 181 182 // Determine textContent: use configured field from frontmatter, or fallback to markdown body 183 let textContent: string; 184 if ( 185 config.textContentField && 186 post.rawFrontmatter?.[config.textContentField] 187 ) { 188 textContent = String(post.rawFrontmatter[config.textContentField]); 189 } else { 190 textContent = stripMarkdownForText(post.content); 191 } 192 193 const record: Record<string, unknown> = { 194 $type: "site.standard.document", 195 title: post.frontmatter.title, 196 site: config.publicationUri, 197 path: postPath, 198 textContent: textContent.slice(0, 10000), 199 publishedAt: publishDate.toISOString(), 200 canonicalUrl: `${config.siteUrl}${postPath}`, 201 }; 202 203 if (post.frontmatter.description) { 204 record.description = post.frontmatter.description; 205 } 206 207 if (coverImage) { 208 record.coverImage = coverImage; 209 } 210 211 if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 212 record.tags = post.frontmatter.tags; 213 } 214 215 const response = await agent.com.atproto.repo.createRecord({ 216 repo: agent.session!.did, 217 collection: "site.standard.document", 218 record, 219 }); 220 221 return response.data.uri; 222} 223 224export async function updateDocument( 225 agent: AtpAgent, 226 post: BlogPost, 227 atUri: string, 228 config: PublisherConfig, 229 coverImage?: BlobObject, 230): Promise<void> { 231 // Parse the atUri to get the collection and rkey 232 // Format: at://did:plc:xxx/collection/rkey 233 const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 234 if (!uriMatch) { 235 throw new Error(`Invalid atUri format: ${atUri}`); 236 } 237 238 const [, , collection, rkey] = uriMatch; 239 240 const pathPrefix = config.pathPrefix || "/posts"; 241 const postPath = `${pathPrefix}/${post.slug}`; 242 const publishDate = new Date(post.frontmatter.publishDate); 243 244 // Determine textContent: use configured field from frontmatter, or fallback to markdown body 245 let textContent: string; 246 if ( 247 config.textContentField && 248 post.rawFrontmatter?.[config.textContentField] 249 ) { 250 textContent = String(post.rawFrontmatter[config.textContentField]); 251 } else { 252 textContent = stripMarkdownForText(post.content); 253 } 254 255 const record: Record<string, unknown> = { 256 $type: "site.standard.document", 257 title: post.frontmatter.title, 258 site: config.publicationUri, 259 path: postPath, 260 textContent: textContent.slice(0, 10000), 261 publishedAt: publishDate.toISOString(), 262 canonicalUrl: `${config.siteUrl}${postPath}`, 263 }; 264 265 if (post.frontmatter.description) { 266 record.description = post.frontmatter.description; 267 } 268 269 if (coverImage) { 270 record.coverImage = coverImage; 271 } 272 273 if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 274 record.tags = post.frontmatter.tags; 275 } 276 277 await agent.com.atproto.repo.putRecord({ 278 repo: agent.session!.did, 279 collection: collection!, 280 rkey: rkey!, 281 record, 282 }); 283} 284 285export function parseAtUri( 286 atUri: string, 287): { did: string; collection: string; rkey: string } | null { 288 const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 289 if (!match) return null; 290 return { 291 did: match[1]!, 292 collection: match[2]!, 293 rkey: match[3]!, 294 }; 295} 296 297export interface DocumentRecord { 298 $type: "site.standard.document"; 299 title: string; 300 site: string; 301 path: string; 302 textContent: string; 303 publishedAt: string; 304 canonicalUrl?: string; 305 description?: string; 306 coverImage?: BlobObject; 307 tags?: string[]; 308 location?: string; 309} 310 311export interface ListDocumentsResult { 312 uri: string; 313 cid: string; 314 value: DocumentRecord; 315} 316 317export async function listDocuments( 318 agent: AtpAgent, 319 publicationUri?: string, 320): Promise<ListDocumentsResult[]> { 321 const documents: ListDocumentsResult[] = []; 322 let cursor: string | undefined; 323 324 do { 325 const response = await agent.com.atproto.repo.listRecords({ 326 repo: agent.session!.did, 327 collection: "site.standard.document", 328 limit: 100, 329 cursor, 330 }); 331 332 for (const record of response.data.records) { 333 const value = record.value as unknown as DocumentRecord; 334 335 // If publicationUri is specified, only include documents from that publication 336 if (publicationUri && value.site !== publicationUri) { 337 continue; 338 } 339 340 documents.push({ 341 uri: record.uri, 342 cid: record.cid, 343 value, 344 }); 345 } 346 347 cursor = response.data.cursor; 348 } while (cursor); 349 350 return documents; 351} 352 353export async function createPublication( 354 agent: AtpAgent, 355 options: CreatePublicationOptions, 356): Promise<string> { 357 let icon: BlobObject | undefined; 358 359 if (options.iconPath) { 360 icon = await uploadImage(agent, options.iconPath); 361 } 362 363 const record: Record<string, unknown> = { 364 $type: "site.standard.publication", 365 url: options.url, 366 name: options.name, 367 createdAt: new Date().toISOString(), 368 }; 369 370 if (options.description) { 371 record.description = options.description; 372 } 373 374 if (icon) { 375 record.icon = icon; 376 } 377 378 if (options.showInDiscover !== undefined) { 379 record.preferences = { 380 showInDiscover: options.showInDiscover, 381 }; 382 } 383 384 const response = await agent.com.atproto.repo.createRecord({ 385 repo: agent.session!.did, 386 collection: "site.standard.publication", 387 record, 388 }); 389 390 return response.data.uri; 391} 392 393// --- Bluesky Post Creation --- 394 395export interface CreateBlueskyPostOptions { 396 title: string; 397 description?: string; 398 canonicalUrl: string; 399 coverImage?: BlobObject; 400 publishedAt: string; // Used as createdAt for the post 401} 402 403/** 404 * Count graphemes in a string (for Bluesky's 300 grapheme limit) 405 */ 406function countGraphemes(str: string): number { 407 // Use Intl.Segmenter if available, otherwise fallback to spread operator 408 if (typeof Intl !== "undefined" && Intl.Segmenter) { 409 const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 410 return [...segmenter.segment(str)].length; 411 } 412 return [...str].length; 413} 414 415/** 416 * Truncate a string to a maximum number of graphemes 417 */ 418function truncateToGraphemes(str: string, maxGraphemes: number): string { 419 if (typeof Intl !== "undefined" && Intl.Segmenter) { 420 const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 421 const segments = [...segmenter.segment(str)]; 422 if (segments.length <= maxGraphemes) return str; 423 return `${segments 424 .slice(0, maxGraphemes - 3) 425 .map((s) => s.segment) 426 .join("")}...`; 427 } 428 // Fallback 429 const chars = [...str]; 430 if (chars.length <= maxGraphemes) return str; 431 return `${chars.slice(0, maxGraphemes - 3).join("")}...`; 432} 433 434/** 435 * Create a Bluesky post with external link embed 436 */ 437export async function createBlueskyPost( 438 agent: AtpAgent, 439 options: CreateBlueskyPostOptions, 440): Promise<StrongRef> { 441 const { title, description, canonicalUrl, coverImage, publishedAt } = options; 442 443 // Build post text: title + description + URL 444 // Max 300 graphemes for Bluesky posts 445 const MAX_GRAPHEMES = 300; 446 447 let postText: string; 448 const urlPart = `\n\n${canonicalUrl}`; 449 const urlGraphemes = countGraphemes(urlPart); 450 451 if (description) { 452 // Try: title + description + URL 453 const fullText = `${title}\n\n${description}${urlPart}`; 454 if (countGraphemes(fullText) <= MAX_GRAPHEMES) { 455 postText = fullText; 456 } else { 457 // Truncate description to fit 458 const availableForDesc = 459 MAX_GRAPHEMES - 460 countGraphemes(title) - 461 countGraphemes("\n\n") - 462 urlGraphemes - 463 countGraphemes("\n\n"); 464 if (availableForDesc > 10) { 465 const truncatedDesc = truncateToGraphemes( 466 description, 467 availableForDesc, 468 ); 469 postText = `${title}\n\n${truncatedDesc}${urlPart}`; 470 } else { 471 // Just title + URL 472 postText = `${title}${urlPart}`; 473 } 474 } 475 } else { 476 // Just title + URL 477 postText = `${title}${urlPart}`; 478 } 479 480 // Final truncation if still too long (shouldn't happen but safety check) 481 if (countGraphemes(postText) > MAX_GRAPHEMES) { 482 postText = truncateToGraphemes(postText, MAX_GRAPHEMES); 483 } 484 485 // Calculate byte indices for the URL facet 486 const encoder = new TextEncoder(); 487 const urlStartInText = postText.lastIndexOf(canonicalUrl); 488 const beforeUrl = postText.substring(0, urlStartInText); 489 const byteStart = encoder.encode(beforeUrl).length; 490 const byteEnd = byteStart + encoder.encode(canonicalUrl).length; 491 492 // Build facets for the URL link 493 const facets = [ 494 { 495 index: { 496 byteStart, 497 byteEnd, 498 }, 499 features: [ 500 { 501 $type: "app.bsky.richtext.facet#link", 502 uri: canonicalUrl, 503 }, 504 ], 505 }, 506 ]; 507 508 // Build external embed 509 const embed: Record<string, unknown> = { 510 $type: "app.bsky.embed.external", 511 external: { 512 uri: canonicalUrl, 513 title: title.substring(0, 500), // Max 500 chars for title 514 description: (description || "").substring(0, 1000), // Max 1000 chars for description 515 }, 516 }; 517 518 // Add thumbnail if coverImage is available 519 if (coverImage) { 520 (embed.external as Record<string, unknown>).thumb = coverImage; 521 } 522 523 // Create the post record 524 const record: Record<string, unknown> = { 525 $type: "app.bsky.feed.post", 526 text: postText, 527 facets, 528 embed, 529 createdAt: new Date(publishedAt).toISOString(), 530 }; 531 532 const response = await agent.com.atproto.repo.createRecord({ 533 repo: agent.session!.did, 534 collection: "app.bsky.feed.post", 535 record, 536 }); 537 538 return { 539 uri: response.data.uri, 540 cid: response.data.cid, 541 }; 542} 543 544/** 545 * Add bskyPostRef to an existing document record 546 */ 547export async function addBskyPostRefToDocument( 548 agent: AtpAgent, 549 documentAtUri: string, 550 bskyPostRef: StrongRef, 551): Promise<void> { 552 const parsed = parseAtUri(documentAtUri); 553 if (!parsed) { 554 throw new Error(`Invalid document URI: ${documentAtUri}`); 555 } 556 557 // Fetch existing record 558 const existingRecord = await agent.com.atproto.repo.getRecord({ 559 repo: parsed.did, 560 collection: parsed.collection, 561 rkey: parsed.rkey, 562 }); 563 564 // Add bskyPostRef to the record 565 const updatedRecord = { 566 ...(existingRecord.data.value as Record<string, unknown>), 567 bskyPostRef, 568 }; 569 570 // Update the record 571 await agent.com.atproto.repo.putRecord({ 572 repo: parsed.did, 573 collection: parsed.collection, 574 rkey: parsed.rkey, 575 record: updatedRecord, 576 }); 577}