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

Add BlueSky Posting Support #4

merged opened by stevedylan.dev targeting main from feat/bsky-post-ref

Overview#

Adds a new config option that allows users to enable posting to BlueSky every time a new document is published. This uses the bskyPostRef field from site.standard.document.

Config Updates#

Example generated config with Bluesky enabled:

{
    "siteUrl": "https://example.com",
    "contentDir": "./content",
    "publicationUri": "at://did:plc:.../...",
    "bluesky": {
      "enabled": true,
      "maxAgeDays": 30
    }
  }
  • bluesky.enabled will determine if BlueSky posts will be created when a new document is published. If true, the document will be created first, then a BlueSky post will be made with the title, description, and the canonicalUrl of the post. If there is an image it will embed it into the post as well. All of the combined fields are limited to 300 characters.
  • bluesky.maxAgeDays determines how far back the user may want to create BlueSky posts. For example, if I have 40 blog posts and I'm using Sequoia for the first time, the last thing I want to do is publish all 40 blog posts on my feed and cause noise. This setting lets a user determine how far back they want to post for.

When sequoia publish --dry-run is used the CLI will print out which posts will be published to BlueSky, and which ones will not.

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:ia2zdnhjaokf5lazhxrmj6eu/sh.tangled.repo.pull/3mdr5eranql22
+339 -14
Diff #1
+3 -4
docs/docs/pages/blog/introducing-sequoia.mdx
··· 24 24 25 25 It's designed to be run inside your existing repo, build a one-time config, and then be part of your regular workflow by publishing content or updating existing content, all following the Standard.site lexicons. The best part? It's designed to be fully interoperable. It doesn't matter if you're using Astro, 11ty, Hugo, Svelte, Next, Gatsby, Zola, you name it. If it's a static blog with markdown, Sequoia will work (and if for some reason it doesn't, [open an issue!](https://tangled.org/stevedylan.dev/sequoia/issues/new)). Here's a quick demo of Sequoia in action: 26 26 27 - <div class="relative w-full" style={{paddingBottom: "73.17%"}}> 28 27 <iframe 29 - class="absolute left-0 top-0 h-full w-full" 28 + class="w-full" 29 + style={{aspectRatio: "16/9"}} 30 30 src="https://www.youtube.com/embed/sxursUHq5kw" 31 31 title="YouTube video player" 32 32 frameborder="0" 33 - allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" 33 + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" 34 34 referrerpolicy="strict-origin-when-cross-origin" 35 35 allowfullscreen 36 36 ></iframe> 37 - </div> 38 37 39 38 ATProto has proven to be one of the more exciting pieces of technology that has surfaced in the past few years, and it gives some of us hope for a web that is open once more. No more walled gardens, full control of our data, and connected through lexicons. 40 39
+8 -1
docs/docs/pages/config.mdx
··· 15 15 | `identity` | `string` | No | - | Which stored identity to use | 16 16 | `frontmatter` | `object` | No | - | Custom frontmatter field mappings | 17 17 | `ignore` | `string[]` | No | - | Glob patterns for files to ignore | 18 + | `bluesky` | `object` | No | - | Bluesky posting configuration | 19 + | `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents | 20 + | `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days | 18 21 19 22 ### Example 20 23 ··· 31 34 "frontmatter": { 32 35 "publishDate": "date" 33 36 }, 34 - "ignore": ["_index.md"] 37 + "ignore": ["_index.md"], 38 + "bluesky": { 39 + "enabled": true, 40 + "maxAgeDays": 30 41 + } 35 42 } 36 43 ``` 37 44
+18 -1
docs/docs/pages/publishing.mdx
··· 10 10 sequoia publish --dry-run 11 11 ``` 12 12 13 - This will print out the posts that it has discovered, what will be published, and how many. Once everything looks good, send it! 13 + This will print out the posts that it has discovered, what will be published, and how many. If Bluesky posting is enabled, it will also show which posts will be shared to Bluesky. Once everything looks good, send it! 14 14 15 15 ```bash [Terminal] 16 16 sequoia publish ··· 27 27 ``` 28 28 29 29 Sync will use your ATProto handle to look through all of the `standard.site.document` records on your PDS, and pull down the records that are for the publication in the config. 30 + 31 + ## Bluesky Posting 32 + 33 + Sequoia can automatically post to Bluesky when new documents are published. Enable this in your config: 34 + 35 + ```json 36 + { 37 + "bluesky": { 38 + "enabled": true, 39 + "maxAgeDays": 30 40 + } 41 + } 42 + ``` 43 + 44 + When enabled, each new document will create a Bluesky post with the title, description, and canonical URL. If a cover image exists, it will be embedded in the post. The combined content is limited to 300 characters. 45 + 46 + The `maxAgeDays` setting prevents flooding your feed when first setting up Sequoia. For example, if you have 40 existing blog posts, only those published within the last 30 days will be posted to Bluesky. 30 47 31 48 ## Troubleshooting 32 49
+37 -1
packages/cli/src/commands/init.ts
··· 15 15 import { findConfig, generateConfigTemplate } from "../lib/config"; 16 16 import { loadCredentials } from "../lib/credentials"; 17 17 import { createAgent, createPublication } from "../lib/atproto"; 18 - import type { FrontmatterMapping } from "../lib/types"; 18 + import type { FrontmatterMapping, BlueskyConfig } from "../lib/types"; 19 19 20 20 async function fileExists(filePath: string): Promise<boolean> { 21 21 try { ··· 263 263 publicationUri = uri as string; 264 264 } 265 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 + 266 301 // Get PDS URL from credentials (already loaded earlier) 267 302 const pdsUrl = credentials?.pdsUrl; 268 303 ··· 277 312 publicationUri, 278 313 pdsUrl, 279 314 frontmatter: frontmatterMapping, 315 + bluesky: blueskyConfig, 280 316 }); 281 317 282 318 const configPath = path.join(process.cwd(), "sequoia.json");
+77 -5
packages/cli/src/commands/publish.ts
··· 4 4 import * as path from "path"; 5 5 import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 6 6 import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials"; 7 - import { createAgent, createDocument, updateDocument, uploadImage, resolveImagePath } from "../lib/atproto"; 7 + import { createAgent, createDocument, updateDocument, uploadImage, resolveImagePath, createBlueskyPost, addBskyPostRefToDocument } from "../lib/atproto"; 8 8 import { 9 9 scanContentDirectory, 10 10 getContentHash, 11 11 updateFrontmatterWithAtUri, 12 12 } from "../lib/markdown"; 13 - import type { BlogPost, BlobObject } from "../lib/types"; 13 + import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; 14 14 import { exitOnCancel } from "../lib/prompts"; 15 15 16 16 export const publishCommand = command({ ··· 131 131 } 132 132 133 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 + 134 141 for (const { post, action, reason } of postsToPublish) { 135 142 const icon = action === "create" ? "+" : "~"; 136 - log.message(` ${icon} ${post.frontmatter.title} (${reason})`); 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}`); 137 161 } 138 162 139 163 if (dryRun) { 164 + if (blueskyEnabled) { 165 + log.info(`\nBluesky posting: enabled (max age: ${maxAgeDays} days)`); 166 + } 140 167 log.info("\nDry run complete. No changes made."); 141 168 return; 142 169 } ··· 157 184 let publishedCount = 0; 158 185 let updatedCount = 0; 159 186 let errorCount = 0; 187 + let bskyPostCount = 0; 160 188 161 189 for (const { post, action } of postsToPublish) { 162 190 s.start(`Publishing: ${post.frontmatter.title}`); ··· 182 210 } 183 211 } 184 212 185 - // Track atUri and content for state saving 213 + // Track atUri, content for state saving, and bskyPostRef 186 214 let atUri: string; 187 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; 188 221 189 222 if (action === "create") { 190 223 atUri = await createDocument(agent, post, config, coverImage); ··· 208 241 updatedCount++; 209 242 } 210 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 + 211 280 // Update state (use relative path from config directory) 212 281 const contentHash = await getContentHash(contentForHash); 213 - const relativeFilePath = path.relative(configDir, post.filePath); 214 282 state.posts[relativeFilePath] = { 215 283 contentHash, 216 284 atUri, 217 285 lastPublished: new Date().toISOString(), 286 + bskyPostRef, 218 287 }; 219 288 } catch (error) { 220 289 const errorMessage = error instanceof Error ? error.message : String(error); ··· 231 300 log.message("\n---"); 232 301 log.info(`Published: ${publishedCount}`); 233 302 log.info(`Updated: ${updatedCount}`); 303 + if (bskyPostCount > 0) { 304 + log.info(`Bluesky posts: ${bskyPostCount}`); 305 + } 234 306 if (errorCount > 0) { 235 307 log.warn(`Errors: ${errorCount}`); 236 308 }
+176 -1
packages/cli/src/lib/atproto.ts
··· 2 2 import * as fs from "fs/promises"; 3 3 import * as path from "path"; 4 4 import * as mimeTypes from "mime-types"; 5 - import type { Credentials, BlogPost, BlobObject, PublisherConfig } from "./types"; 5 + import type { Credentials, BlogPost, BlobObject, PublisherConfig, StrongRef } from "./types"; 6 6 import { stripMarkdownForText } from "./markdown"; 7 7 8 8 async function fileExists(filePath: string): Promise<boolean> { ··· 352 352 353 353 return response.data.uri; 354 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 1 import * as fs from "fs/promises"; 2 2 import * as path from "path"; 3 - import type { PublisherConfig, PublisherState, FrontmatterMapping } from "./types"; 3 + import type { PublisherConfig, PublisherState, FrontmatterMapping, BlueskyConfig } from "./types"; 4 4 5 5 const CONFIG_FILENAME = "sequoia.json"; 6 6 const STATE_FILENAME = ".sequoia-state.json"; ··· 76 76 pdsUrl?: string; 77 77 frontmatter?: FrontmatterMapping; 78 78 ignore?: string[]; 79 + bluesky?: BlueskyConfig; 79 80 }): string { 80 81 const config: Record<string, unknown> = { 81 82 siteUrl: options.siteUrl, ··· 110 111 111 112 if (options.ignore && options.ignore.length > 0) { 112 113 config.ignore = options.ignore; 114 + } 115 + 116 + if (options.bluesky) { 117 + config.bluesky = options.bluesky; 113 118 } 114 119 115 120 return JSON.stringify(config, null, 2);
+14
packages/cli/src/lib/types.ts
··· 6 6 tags?: string; // Field name for tags (default: "tags") 7 7 } 8 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 + 9 21 export interface PublisherConfig { 10 22 siteUrl: string; 11 23 contentDir: string; ··· 18 30 identity?: string; // Which stored identity to use (matches identifier) 19 31 frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings 20 32 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"]) 33 + bluesky?: BlueskyConfig; // Optional Bluesky posting configuration 21 34 } 22 35 23 36 export interface Credentials { ··· 62 75 contentHash: string; 63 76 atUri?: string; 64 77 lastPublished?: string; 78 + bskyPostRef?: StrongRef; // Reference to corresponding Bluesky post 65 79 } 66 80 67 81 export interface PublicationRecord {

History

2 rounds 0 comments
sign up or login to add to the discussion
3 commits
expand
chore: adjust blog post
feat: added bskyPostRef
chore: updated docs
expand 0 comments
pull request successfully merged
2 commits
expand
chore: adjust blog post
feat: added bskyPostRef
expand 0 comments