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