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 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 27 - <div class="relative w-full" style={{paddingBottom: "73.17%"}}> 28 <iframe 29 - class="absolute left-0 top-0 h-full w-full" 30 src="https://www.youtube.com/embed/sxursUHq5kw" 31 title="YouTube video player" 32 frameborder="0" 33 - allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" 34 referrerpolicy="strict-origin-when-cross-origin" 35 allowfullscreen 36 ></iframe> 37 - </div> 38 39 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
··· 24 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 27 <iframe 28 + class="w-full" 29 + style={{aspectRatio: "16/9"}} 30 src="https://www.youtube.com/embed/sxursUHq5kw" 31 title="YouTube video player" 32 frameborder="0" 33 + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" 34 referrerpolicy="strict-origin-when-cross-origin" 35 allowfullscreen 36 ></iframe> 37 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. 39
+8 -1
docs/docs/pages/config.mdx
··· 15 | `identity` | `string` | No | - | Which stored identity to use | 16 | `frontmatter` | `object` | No | - | Custom frontmatter field mappings | 17 | `ignore` | `string[]` | No | - | Glob patterns for files to ignore | 18 19 ### Example 20 ··· 31 "frontmatter": { 32 "publishDate": "date" 33 }, 34 - "ignore": ["_index.md"] 35 } 36 ``` 37
··· 15 | `identity` | `string` | No | - | Which stored identity to use | 16 | `frontmatter` | `object` | No | - | Custom frontmatter field mappings | 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 | 21 22 ### Example 23 ··· 34 "frontmatter": { 35 "publishDate": "date" 36 }, 37 + "ignore": ["_index.md"], 38 + "bluesky": { 39 + "enabled": true, 40 + "maxAgeDays": 30 41 + } 42 } 43 ``` 44
+18 -1
docs/docs/pages/publishing.mdx
··· 10 sequoia publish --dry-run 11 ``` 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! 14 15 ```bash [Terminal] 16 sequoia publish ··· 27 ``` 28 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 ## Troubleshooting 32
··· 10 sequoia publish --dry-run 11 ``` 12 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 15 ```bash [Terminal] 16 sequoia publish ··· 27 ``` 28 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. 47 48 ## Troubleshooting 49
+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 {

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