A CLI for publishing standard.site documents to ATProto

feat: added bskyPostRef

authored by stevedylan.dev and committed by tangled.org 2ec4b3cb 47e12a9c

+310 -8
+37 -1
packages/cli/src/commands/init.ts
··· 15 import { findConfig, generateConfigTemplate } from "../lib/config"; 16 import { loadCredentials } from "../lib/credentials"; 17 import { createAgent, createPublication } from "../lib/atproto"; 18 - import type { FrontmatterMapping } from "../lib/types"; 19 20 async function fileExists(filePath: string): Promise<boolean> { 21 try { ··· 263 publicationUri = uri as string; 264 } 265 266 // Get PDS URL from credentials (already loaded earlier) 267 const pdsUrl = credentials?.pdsUrl; 268 ··· 277 publicationUri, 278 pdsUrl, 279 frontmatter: frontmatterMapping, 280 }); 281 282 const configPath = path.join(process.cwd(), "sequoia.json");
··· 15 import { findConfig, generateConfigTemplate } from "../lib/config"; 16 import { loadCredentials } from "../lib/credentials"; 17 import { createAgent, createPublication } from "../lib/atproto"; 18 + import type { FrontmatterMapping, BlueskyConfig } from "../lib/types"; 19 20 async function fileExists(filePath: string): Promise<boolean> { 21 try { ··· 263 publicationUri = uri as string; 264 } 265 266 + // Bluesky posting configuration 267 + const enableBluesky = await confirm({ 268 + message: "Enable automatic Bluesky posting when publishing?", 269 + initialValue: false, 270 + }); 271 + 272 + if (enableBluesky === Symbol.for("cancel")) { 273 + onCancel(); 274 + } 275 + 276 + let blueskyConfig: BlueskyConfig | undefined; 277 + if (enableBluesky) { 278 + const maxAgeDaysInput = await text({ 279 + message: "Maximum age (in days) for posts to be shared on Bluesky:", 280 + defaultValue: "7", 281 + placeholder: "7", 282 + validate: (value) => { 283 + const num = parseInt(value, 10); 284 + if (isNaN(num) || num < 1) { 285 + return "Please enter a positive number"; 286 + } 287 + }, 288 + }); 289 + 290 + if (maxAgeDaysInput === Symbol.for("cancel")) { 291 + onCancel(); 292 + } 293 + 294 + const maxAgeDays = parseInt(maxAgeDaysInput as string, 10); 295 + blueskyConfig = { 296 + enabled: true, 297 + ...(maxAgeDays !== 7 && { maxAgeDays }), 298 + }; 299 + } 300 + 301 // Get PDS URL from credentials (already loaded earlier) 302 const pdsUrl = credentials?.pdsUrl; 303 ··· 312 publicationUri, 313 pdsUrl, 314 frontmatter: frontmatterMapping, 315 + bluesky: blueskyConfig, 316 }); 317 318 const configPath = path.join(process.cwd(), "sequoia.json");
+77 -5
packages/cli/src/commands/publish.ts
··· 4 import * as path from "path"; 5 import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 6 import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials"; 7 - import { createAgent, createDocument, updateDocument, uploadImage, resolveImagePath } from "../lib/atproto"; 8 import { 9 scanContentDirectory, 10 getContentHash, 11 updateFrontmatterWithAtUri, 12 } from "../lib/markdown"; 13 - import type { BlogPost, BlobObject } from "../lib/types"; 14 import { exitOnCancel } from "../lib/prompts"; 15 16 export const publishCommand = command({ ··· 131 } 132 133 log.info(`\n${postsToPublish.length} posts to publish:\n`); 134 for (const { post, action, reason } of postsToPublish) { 135 const icon = action === "create" ? "+" : "~"; 136 - log.message(` ${icon} ${post.frontmatter.title} (${reason})`); 137 } 138 139 if (dryRun) { 140 log.info("\nDry run complete. No changes made."); 141 return; 142 } ··· 157 let publishedCount = 0; 158 let updatedCount = 0; 159 let errorCount = 0; 160 161 for (const { post, action } of postsToPublish) { 162 s.start(`Publishing: ${post.frontmatter.title}`); ··· 182 } 183 } 184 185 - // Track atUri and content for state saving 186 let atUri: string; 187 let contentForHash: string; 188 189 if (action === "create") { 190 atUri = await createDocument(agent, post, config, coverImage); ··· 208 updatedCount++; 209 } 210 211 // Update state (use relative path from config directory) 212 const contentHash = await getContentHash(contentForHash); 213 - const relativeFilePath = path.relative(configDir, post.filePath); 214 state.posts[relativeFilePath] = { 215 contentHash, 216 atUri, 217 lastPublished: new Date().toISOString(), 218 }; 219 } catch (error) { 220 const errorMessage = error instanceof Error ? error.message : String(error); ··· 231 log.message("\n---"); 232 log.info(`Published: ${publishedCount}`); 233 log.info(`Updated: ${updatedCount}`); 234 if (errorCount > 0) { 235 log.warn(`Errors: ${errorCount}`); 236 }
··· 4 import * as path from "path"; 5 import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 6 import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials"; 7 + import { createAgent, createDocument, updateDocument, uploadImage, resolveImagePath, createBlueskyPost, addBskyPostRefToDocument } from "../lib/atproto"; 8 import { 9 scanContentDirectory, 10 getContentHash, 11 updateFrontmatterWithAtUri, 12 } from "../lib/markdown"; 13 + import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; 14 import { exitOnCancel } from "../lib/prompts"; 15 16 export const publishCommand = command({ ··· 131 } 132 133 log.info(`\n${postsToPublish.length} posts to publish:\n`); 134 + 135 + // Bluesky posting configuration 136 + const blueskyEnabled = config.bluesky?.enabled ?? false; 137 + const maxAgeDays = config.bluesky?.maxAgeDays ?? 7; 138 + const cutoffDate = new Date(); 139 + cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays); 140 + 141 for (const { post, action, reason } of postsToPublish) { 142 const icon = action === "create" ? "+" : "~"; 143 + const relativeFilePath = path.relative(configDir, post.filePath); 144 + const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; 145 + 146 + let bskyNote = ""; 147 + if (blueskyEnabled) { 148 + if (existingBskyPostRef) { 149 + bskyNote = " [bsky: exists]"; 150 + } else { 151 + const publishDate = new Date(post.frontmatter.publishDate); 152 + if (publishDate < cutoffDate) { 153 + bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`; 154 + } else { 155 + bskyNote = " [bsky: will post]"; 156 + } 157 + } 158 + } 159 + 160 + log.message(` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}`); 161 } 162 163 if (dryRun) { 164 + if (blueskyEnabled) { 165 + log.info(`\nBluesky posting: enabled (max age: ${maxAgeDays} days)`); 166 + } 167 log.info("\nDry run complete. No changes made."); 168 return; 169 } ··· 184 let publishedCount = 0; 185 let updatedCount = 0; 186 let errorCount = 0; 187 + let bskyPostCount = 0; 188 189 for (const { post, action } of postsToPublish) { 190 s.start(`Publishing: ${post.frontmatter.title}`); ··· 210 } 211 } 212 213 + // Track atUri, content for state saving, and bskyPostRef 214 let atUri: string; 215 let contentForHash: string; 216 + let bskyPostRef: StrongRef | undefined; 217 + const relativeFilePath = path.relative(configDir, post.filePath); 218 + 219 + // Check if bskyPostRef already exists in state 220 + const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; 221 222 if (action === "create") { 223 atUri = await createDocument(agent, post, config, coverImage); ··· 241 updatedCount++; 242 } 243 244 + // Create Bluesky post if enabled and conditions are met 245 + if (blueskyEnabled) { 246 + if (existingBskyPostRef) { 247 + log.info(` Bluesky post already exists, skipping`); 248 + bskyPostRef = existingBskyPostRef; 249 + } else { 250 + const publishDate = new Date(post.frontmatter.publishDate); 251 + 252 + if (publishDate < cutoffDate) { 253 + log.info(` Post is older than ${maxAgeDays} days, skipping Bluesky post`); 254 + } else { 255 + // Create Bluesky post 256 + try { 257 + const pathPrefix = config.pathPrefix || "/posts"; 258 + const canonicalUrl = `${config.siteUrl}${pathPrefix}/${post.slug}`; 259 + 260 + bskyPostRef = await createBlueskyPost(agent, { 261 + title: post.frontmatter.title, 262 + description: post.frontmatter.description, 263 + canonicalUrl, 264 + coverImage, 265 + publishedAt: post.frontmatter.publishDate, 266 + }); 267 + 268 + // Update document record with bskyPostRef 269 + await addBskyPostRefToDocument(agent, atUri, bskyPostRef); 270 + log.info(` Created Bluesky post: ${bskyPostRef.uri}`); 271 + bskyPostCount++; 272 + } catch (bskyError) { 273 + const errorMsg = bskyError instanceof Error ? bskyError.message : String(bskyError); 274 + log.warn(` Failed to create Bluesky post: ${errorMsg}`); 275 + } 276 + } 277 + } 278 + } 279 + 280 // Update state (use relative path from config directory) 281 const contentHash = await getContentHash(contentForHash); 282 state.posts[relativeFilePath] = { 283 contentHash, 284 atUri, 285 lastPublished: new Date().toISOString(), 286 + bskyPostRef, 287 }; 288 } catch (error) { 289 const errorMessage = error instanceof Error ? error.message : String(error); ··· 300 log.message("\n---"); 301 log.info(`Published: ${publishedCount}`); 302 log.info(`Updated: ${updatedCount}`); 303 + if (bskyPostCount > 0) { 304 + log.info(`Bluesky posts: ${bskyPostCount}`); 305 + } 306 if (errorCount > 0) { 307 log.warn(`Errors: ${errorCount}`); 308 }
+176 -1
packages/cli/src/lib/atproto.ts
··· 2 import * as fs from "fs/promises"; 3 import * as path from "path"; 4 import * as mimeTypes from "mime-types"; 5 - import type { Credentials, BlogPost, BlobObject, PublisherConfig } from "./types"; 6 import { stripMarkdownForText } from "./markdown"; 7 8 async function fileExists(filePath: string): Promise<boolean> { ··· 352 353 return response.data.uri; 354 }
··· 2 import * as fs from "fs/promises"; 3 import * as path from "path"; 4 import * as mimeTypes from "mime-types"; 5 + import type { Credentials, BlogPost, BlobObject, PublisherConfig, StrongRef } from "./types"; 6 import { stripMarkdownForText } from "./markdown"; 7 8 async function fileExists(filePath: string): Promise<boolean> { ··· 352 353 return response.data.uri; 354 } 355 + 356 + // --- Bluesky Post Creation --- 357 + 358 + export 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 + */ 369 + function 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 + */ 381 + function 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 + */ 397 + export 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 + */ 499 + export 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 + }
+6 -1
packages/cli/src/lib/config.ts
··· 1 import * as fs from "fs/promises"; 2 import * as path from "path"; 3 - import type { PublisherConfig, PublisherState, FrontmatterMapping } from "./types"; 4 5 const CONFIG_FILENAME = "sequoia.json"; 6 const STATE_FILENAME = ".sequoia-state.json"; ··· 76 pdsUrl?: string; 77 frontmatter?: FrontmatterMapping; 78 ignore?: string[]; 79 }): string { 80 const config: Record<string, unknown> = { 81 siteUrl: options.siteUrl, ··· 110 111 if (options.ignore && options.ignore.length > 0) { 112 config.ignore = options.ignore; 113 } 114 115 return JSON.stringify(config, null, 2);
··· 1 import * as fs from "fs/promises"; 2 import * as path from "path"; 3 + import type { PublisherConfig, PublisherState, FrontmatterMapping, BlueskyConfig } from "./types"; 4 5 const CONFIG_FILENAME = "sequoia.json"; 6 const STATE_FILENAME = ".sequoia-state.json"; ··· 76 pdsUrl?: string; 77 frontmatter?: FrontmatterMapping; 78 ignore?: string[]; 79 + bluesky?: BlueskyConfig; 80 }): string { 81 const config: Record<string, unknown> = { 82 siteUrl: options.siteUrl, ··· 111 112 if (options.ignore && options.ignore.length > 0) { 113 config.ignore = options.ignore; 114 + } 115 + 116 + if (options.bluesky) { 117 + config.bluesky = options.bluesky; 118 } 119 120 return JSON.stringify(config, null, 2);
+14
packages/cli/src/lib/types.ts
··· 6 tags?: string; // Field name for tags (default: "tags") 7 } 8 9 export interface PublisherConfig { 10 siteUrl: string; 11 contentDir: string; ··· 18 identity?: string; // Which stored identity to use (matches identifier) 19 frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings 20 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"]) 21 } 22 23 export interface Credentials { ··· 62 contentHash: string; 63 atUri?: string; 64 lastPublished?: string; 65 } 66 67 export interface PublicationRecord {
··· 6 tags?: string; // Field name for tags (default: "tags") 7 } 8 9 + // Strong reference for Bluesky post (com.atproto.repo.strongRef) 10 + export interface StrongRef { 11 + uri: string; // at:// URI format 12 + cid: string; // Content ID 13 + } 14 + 15 + // Bluesky posting configuration 16 + export interface BlueskyConfig { 17 + enabled: boolean; 18 + maxAgeDays?: number; // Only post if published within N days (default: 7) 19 + } 20 + 21 export interface PublisherConfig { 22 siteUrl: string; 23 contentDir: string; ··· 30 identity?: string; // Which stored identity to use (matches identifier) 31 frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings 32 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"]) 33 + bluesky?: BlueskyConfig; // Optional Bluesky posting configuration 34 } 35 36 export interface Credentials { ··· 75 contentHash: string; 76 atUri?: string; 77 lastPublished?: string; 78 + bskyPostRef?: StrongRef; // Reference to corresponding Bluesky post 79 } 80 81 export interface PublicationRecord {