chore: merge main into chore/fronmatter-config-updates
266986ee
12 file(s) · +695 −213
| 1 | + | ## [0.2.0] - 2026-02-01 |
|
| 2 | + | ||
| 3 | + | ### 🚀 Features |
|
| 4 | + | ||
| 5 | + | - Added bskyPostRef |
|
| 6 | + | - Added draft field to frontmatter config |
|
| 7 | + | ||
| 8 | + | ### ⚙️ Miscellaneous Tasks |
|
| 9 | + | ||
| 10 | + | - Update blog post |
|
| 11 | + | - Fix blog build error |
|
| 12 | + | - Adjust blog post |
|
| 13 | + | - Updated docs |
|
| 14 | + | - Version bump |
|
| 15 | + | ## [0.1.1] - 2026-01-31 |
|
| 16 | + | ||
| 17 | + | ### 🐛 Bug Fixes |
|
| 18 | + | ||
| 19 | + | - Fix tangled url to repo |
|
| 20 | + | ||
| 21 | + | ### ⚙️ Miscellaneous Tasks |
|
| 22 | + | ||
| 23 | + | - Merge branch 'main' into feat/blog-post |
|
| 24 | + | - Updated blog post |
|
| 25 | + | - Updated date |
|
| 26 | + | - Added publishing |
|
| 27 | + | - Spelling and grammar |
|
| 28 | + | - Updated package scripts |
|
| 29 | + | - Refactored codebase to use node and fs instead of bun |
|
| 30 | + | - Version bump |
|
| 31 | + | ## [0.1.0] - 2026-01-30 |
|
| 32 | + | ||
| 33 | + | ### 🚀 Features |
|
| 34 | + | ||
| 35 | + | - Init |
|
| 36 | + | - Added blog post |
|
| 37 | + | ||
| 38 | + | ### ⚙️ Miscellaneous Tasks |
|
| 39 | + | ||
| 40 | + | - Updated package.json |
|
| 41 | + | - Cleaned up commands and libs |
|
| 42 | + | - Updated init commands |
|
| 43 | + | - Updated greeting |
|
| 44 | + | - Updated readme |
|
| 45 | + | - Link updates |
|
| 46 | + | - Version bump |
|
| 47 | + | - Added hugo support through frontmatter parsing |
|
| 48 | + | - Version bump |
|
| 49 | + | - Updated docs |
|
| 50 | + | - Adapted inject.ts pattern |
|
| 51 | + | - Updated docs |
|
| 52 | + | - Version bump" |
|
| 53 | + | - Updated package scripts |
|
| 54 | + | - Updated scripts |
|
| 55 | + | - Added ignore field to config |
|
| 56 | + | - Udpate docs |
|
| 57 | + | - Version bump |
|
| 58 | + | - Added tags to flow |
|
| 59 | + | - Added ability to exit during init flow |
|
| 60 | + | - Version bump |
|
| 61 | + | - Updated docs |
|
| 62 | + | - Updated links |
|
| 63 | + | - Updated docs |
|
| 64 | + | - Initial refactor |
|
| 65 | + | - Checkpoint |
|
| 66 | + | - Refactored mapping |
|
| 67 | + | - Docs updates |
|
| 68 | + | - Docs updates |
|
| 69 | + | - Version bump |
| 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 | - | <iframe width="560" height="315" src="https://www.youtube.com/embed/sxursUHq5kw?si=aZSCmkMdYPiYns8u" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> |
|
| 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> |
|
| 28 | 37 | ||
| 29 | 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. |
|
| 30 | 39 |
| 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 | ||
| 44 | 51 | | `publishDate` | `string` | Yes | `"publishDate"`, `"pubDate"`, `"date"`, `"createdAt"`, `"created_at"` | Publication date | |
|
| 45 | 52 | | `coverImage` | `string` | No | `"ogImage"` | Cover image filename | |
|
| 46 | 53 | | `tags` | `string[]` | No | `"tags"` | Post tags/categories | |
|
| 54 | + | | `draft` | `boolean` | No | `"draft"` | If `true`, post is skipped during publish | |
|
| 47 | 55 | ||
| 48 | 56 | ### Example |
|
| 49 | 57 | ||
| 54 | 62 | publishDate: 2024-01-15 |
|
| 55 | 63 | ogImage: cover.jpg |
|
| 56 | 64 | tags: [welcome, intro] |
|
| 65 | + | draft: false |
|
| 57 | 66 | --- |
|
| 58 | 67 | ``` |
|
| 59 | 68 | ||
| 65 | 74 | { |
|
| 66 | 75 | "frontmatter": { |
|
| 67 | 76 | "publishDate": "date", |
|
| 68 | - | "coverImage": "thumbnail" |
|
| 77 | + | "coverImage": "thumbnail", |
|
| 78 | + | "draft": "private" |
|
| 69 | 79 | } |
|
| 70 | 80 | } |
|
| 71 | 81 | ``` |
|
| 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. |
|
| 47 | + | ||
| 48 | + | ## Draft Posts |
|
| 49 | + | ||
| 50 | + | Posts with `draft: true` in their frontmatter are automatically skipped during publishing. This lets you work on content without accidentally publishing it. |
|
| 51 | + | ||
| 52 | + | ```yaml |
|
| 53 | + | --- |
|
| 54 | + | title: Work in Progress |
|
| 55 | + | draft: true |
|
| 56 | + | --- |
|
| 57 | + | ``` |
|
| 58 | + | ||
| 59 | + | If your framework uses a different field name (like `private` or `hidden`), configure it in `sequoia.json`: |
|
| 60 | + | ||
| 61 | + | ```json |
|
| 62 | + | { |
|
| 63 | + | "frontmatter": { |
|
| 64 | + | "draft": "private" |
|
| 65 | + | } |
|
| 66 | + | } |
|
| 67 | + | ``` |
|
| 30 | 68 | ||
| 31 | 69 | ## Troubleshooting |
|
| 32 | 70 | ||
| 1 | 1 | { |
|
| 2 | 2 | "name": "sequoia-cli", |
|
| 3 | - | "version": "0.1.1", |
|
| 3 | + | "version": "0.2.0", |
|
| 4 | 4 | "type": "module", |
|
| 5 | 5 | "bin": { |
|
| 6 | 6 | "sequoia": "dist/index.js" |
| 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 { |
|
| 138 | 138 | defaultValue: "tags", |
|
| 139 | 139 | placeholder: "tags, categories, keywords, etc.", |
|
| 140 | 140 | }), |
|
| 141 | + | draftField: () => |
|
| 142 | + | text({ |
|
| 143 | + | message: "Field name for draft status:", |
|
| 144 | + | defaultValue: "draft", |
|
| 145 | + | placeholder: "draft, private, hidden, etc.", |
|
| 146 | + | }), |
|
| 141 | 147 | }, |
|
| 142 | 148 | { onCancel }, |
|
| 143 | 149 | ); |
|
| 149 | 155 | ["publishDate", frontmatterConfig.dateField, "publishDate"], |
|
| 150 | 156 | ["coverImage", frontmatterConfig.coverField, "ogImage"], |
|
| 151 | 157 | ["tags", frontmatterConfig.tagsField, "tags"], |
|
| 158 | + | ["draft", frontmatterConfig.draftField, "draft"], |
|
| 152 | 159 | ]; |
|
| 153 | 160 | ||
| 154 | 161 | const builtMapping = fieldMappings.reduce<FrontmatterMapping>( |
|
| 263 | 270 | publicationUri = uri as string; |
|
| 264 | 271 | } |
|
| 265 | 272 | ||
| 273 | + | // Bluesky posting configuration |
|
| 274 | + | const enableBluesky = await confirm({ |
|
| 275 | + | message: "Enable automatic Bluesky posting when publishing?", |
|
| 276 | + | initialValue: false, |
|
| 277 | + | }); |
|
| 278 | + | ||
| 279 | + | if (enableBluesky === Symbol.for("cancel")) { |
|
| 280 | + | onCancel(); |
|
| 281 | + | } |
|
| 282 | + | ||
| 283 | + | let blueskyConfig: BlueskyConfig | undefined; |
|
| 284 | + | if (enableBluesky) { |
|
| 285 | + | const maxAgeDaysInput = await text({ |
|
| 286 | + | message: "Maximum age (in days) for posts to be shared on Bluesky:", |
|
| 287 | + | defaultValue: "7", |
|
| 288 | + | placeholder: "7", |
|
| 289 | + | validate: (value) => { |
|
| 290 | + | const num = parseInt(value, 10); |
|
| 291 | + | if (isNaN(num) || num < 1) { |
|
| 292 | + | return "Please enter a positive number"; |
|
| 293 | + | } |
|
| 294 | + | }, |
|
| 295 | + | }); |
|
| 296 | + | ||
| 297 | + | if (maxAgeDaysInput === Symbol.for("cancel")) { |
|
| 298 | + | onCancel(); |
|
| 299 | + | } |
|
| 300 | + | ||
| 301 | + | const maxAgeDays = parseInt(maxAgeDaysInput as string, 10); |
|
| 302 | + | blueskyConfig = { |
|
| 303 | + | enabled: true, |
|
| 304 | + | ...(maxAgeDays !== 7 && { maxAgeDays }), |
|
| 305 | + | }; |
|
| 306 | + | } |
|
| 307 | + | ||
| 266 | 308 | // Get PDS URL from credentials (already loaded earlier) |
|
| 267 | 309 | const pdsUrl = credentials?.pdsUrl; |
|
| 268 | 310 | ||
| 277 | 319 | publicationUri, |
|
| 278 | 320 | pdsUrl, |
|
| 279 | 321 | frontmatter: frontmatterMapping, |
|
| 322 | + | bluesky: blueskyConfig, |
|
| 280 | 323 | }); |
|
| 281 | 324 | ||
| 282 | 325 | const configPath = path.join(process.cwd(), "sequoia.json"); |
|
| 3 | 3 | import { select, spinner, log } from "@clack/prompts"; |
|
| 4 | 4 | import * as path from "path"; |
|
| 5 | 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"; |
|
| 6 | + | import { |
|
| 7 | + | loadCredentials, |
|
| 8 | + | listCredentials, |
|
| 9 | + | getCredentials, |
|
| 10 | + | } from "../lib/credentials"; |
|
| 11 | + | import { |
|
| 12 | + | createAgent, |
|
| 13 | + | createDocument, |
|
| 14 | + | updateDocument, |
|
| 15 | + | uploadImage, |
|
| 16 | + | resolveImagePath, |
|
| 17 | + | createBlueskyPost, |
|
| 18 | + | addBskyPostRefToDocument, |
|
| 19 | + | } from "../lib/atproto"; |
|
| 8 | 20 | import { |
|
| 9 | - | scanContentDirectory, |
|
| 10 | - | getContentHash, |
|
| 11 | - | updateFrontmatterWithAtUri, |
|
| 21 | + | scanContentDirectory, |
|
| 22 | + | getContentHash, |
|
| 23 | + | updateFrontmatterWithAtUri, |
|
| 12 | 24 | } from "../lib/markdown"; |
|
| 13 | - | import type { BlogPost, BlobObject } from "../lib/types"; |
|
| 25 | + | import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; |
|
| 14 | 26 | import { exitOnCancel } from "../lib/prompts"; |
|
| 15 | 27 | ||
| 16 | 28 | export const publishCommand = command({ |
|
| 17 | - | name: "publish", |
|
| 18 | - | description: "Publish content to ATProto", |
|
| 19 | - | args: { |
|
| 20 | - | force: flag({ |
|
| 21 | - | long: "force", |
|
| 22 | - | short: "f", |
|
| 23 | - | description: "Force publish all posts, ignoring change detection", |
|
| 24 | - | }), |
|
| 25 | - | dryRun: flag({ |
|
| 26 | - | long: "dry-run", |
|
| 27 | - | short: "n", |
|
| 28 | - | description: "Preview what would be published without making changes", |
|
| 29 | - | }), |
|
| 30 | - | }, |
|
| 31 | - | handler: async ({ force, dryRun }) => { |
|
| 32 | - | // Load config |
|
| 33 | - | const configPath = await findConfig(); |
|
| 34 | - | if (!configPath) { |
|
| 35 | - | log.error("No publisher.config.ts found. Run 'publisher init' first."); |
|
| 36 | - | process.exit(1); |
|
| 37 | - | } |
|
| 29 | + | name: "publish", |
|
| 30 | + | description: "Publish content to ATProto", |
|
| 31 | + | args: { |
|
| 32 | + | force: flag({ |
|
| 33 | + | long: "force", |
|
| 34 | + | short: "f", |
|
| 35 | + | description: "Force publish all posts, ignoring change detection", |
|
| 36 | + | }), |
|
| 37 | + | dryRun: flag({ |
|
| 38 | + | long: "dry-run", |
|
| 39 | + | short: "n", |
|
| 40 | + | description: "Preview what would be published without making changes", |
|
| 41 | + | }), |
|
| 42 | + | }, |
|
| 43 | + | handler: async ({ force, dryRun }) => { |
|
| 44 | + | // Load config |
|
| 45 | + | const configPath = await findConfig(); |
|
| 46 | + | if (!configPath) { |
|
| 47 | + | log.error("No publisher.config.ts found. Run 'publisher init' first."); |
|
| 48 | + | process.exit(1); |
|
| 49 | + | } |
|
| 38 | 50 | ||
| 39 | - | const config = await loadConfig(configPath); |
|
| 40 | - | const configDir = path.dirname(configPath); |
|
| 51 | + | const config = await loadConfig(configPath); |
|
| 52 | + | const configDir = path.dirname(configPath); |
|
| 41 | 53 | ||
| 42 | - | log.info(`Site: ${config.siteUrl}`); |
|
| 43 | - | log.info(`Content directory: ${config.contentDir}`); |
|
| 54 | + | log.info(`Site: ${config.siteUrl}`); |
|
| 55 | + | log.info(`Content directory: ${config.contentDir}`); |
|
| 44 | 56 | ||
| 45 | - | // Load credentials |
|
| 46 | - | let credentials = await loadCredentials(config.identity); |
|
| 57 | + | // Load credentials |
|
| 58 | + | let credentials = await loadCredentials(config.identity); |
|
| 47 | 59 | ||
| 48 | - | // If no credentials resolved, check if we need to prompt for identity selection |
|
| 49 | - | if (!credentials) { |
|
| 50 | - | const identities = await listCredentials(); |
|
| 51 | - | if (identities.length === 0) { |
|
| 52 | - | log.error("No credentials found. Run 'sequoia auth' first."); |
|
| 53 | - | log.info("Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables."); |
|
| 54 | - | process.exit(1); |
|
| 55 | - | } |
|
| 60 | + | // If no credentials resolved, check if we need to prompt for identity selection |
|
| 61 | + | if (!credentials) { |
|
| 62 | + | const identities = await listCredentials(); |
|
| 63 | + | if (identities.length === 0) { |
|
| 64 | + | log.error("No credentials found. Run 'sequoia auth' first."); |
|
| 65 | + | log.info( |
|
| 66 | + | "Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.", |
|
| 67 | + | ); |
|
| 68 | + | process.exit(1); |
|
| 69 | + | } |
|
| 56 | 70 | ||
| 57 | - | // Multiple identities exist but none selected - prompt user |
|
| 58 | - | log.info("Multiple identities found. Select one to use:"); |
|
| 59 | - | const selected = exitOnCancel(await select({ |
|
| 60 | - | message: "Identity:", |
|
| 61 | - | options: identities.map(id => ({ value: id, label: id })), |
|
| 62 | - | })); |
|
| 71 | + | // Multiple identities exist but none selected - prompt user |
|
| 72 | + | log.info("Multiple identities found. Select one to use:"); |
|
| 73 | + | const selected = exitOnCancel( |
|
| 74 | + | await select({ |
|
| 75 | + | message: "Identity:", |
|
| 76 | + | options: identities.map((id) => ({ value: id, label: id })), |
|
| 77 | + | }), |
|
| 78 | + | ); |
|
| 63 | 79 | ||
| 64 | - | credentials = await getCredentials(selected); |
|
| 65 | - | if (!credentials) { |
|
| 66 | - | log.error("Failed to load selected credentials."); |
|
| 67 | - | process.exit(1); |
|
| 68 | - | } |
|
| 80 | + | credentials = await getCredentials(selected); |
|
| 81 | + | if (!credentials) { |
|
| 82 | + | log.error("Failed to load selected credentials."); |
|
| 83 | + | process.exit(1); |
|
| 84 | + | } |
|
| 69 | 85 | ||
| 70 | - | log.info(`Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`); |
|
| 71 | - | } |
|
| 86 | + | log.info( |
|
| 87 | + | `Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`, |
|
| 88 | + | ); |
|
| 89 | + | } |
|
| 72 | 90 | ||
| 73 | - | // Resolve content directory |
|
| 74 | - | const contentDir = path.isAbsolute(config.contentDir) |
|
| 75 | - | ? config.contentDir |
|
| 76 | - | : path.join(configDir, config.contentDir); |
|
| 91 | + | // Resolve content directory |
|
| 92 | + | const contentDir = path.isAbsolute(config.contentDir) |
|
| 93 | + | ? config.contentDir |
|
| 94 | + | : path.join(configDir, config.contentDir); |
|
| 77 | 95 | ||
| 78 | - | const imagesDir = config.imagesDir |
|
| 79 | - | ? path.isAbsolute(config.imagesDir) |
|
| 80 | - | ? config.imagesDir |
|
| 81 | - | : path.join(configDir, config.imagesDir) |
|
| 82 | - | : undefined; |
|
| 96 | + | const imagesDir = config.imagesDir |
|
| 97 | + | ? path.isAbsolute(config.imagesDir) |
|
| 98 | + | ? config.imagesDir |
|
| 99 | + | : path.join(configDir, config.imagesDir) |
|
| 100 | + | : undefined; |
|
| 83 | 101 | ||
| 84 | - | // Load state |
|
| 85 | - | const state = await loadState(configDir); |
|
| 102 | + | // Load state |
|
| 103 | + | const state = await loadState(configDir); |
|
| 86 | 104 | ||
| 87 | - | // Scan for posts |
|
| 88 | - | const s = spinner(); |
|
| 89 | - | s.start("Scanning for posts..."); |
|
| 90 | - | const posts = await scanContentDirectory(contentDir, { |
|
| 91 | - | frontmatterMapping: config.frontmatter, |
|
| 92 | - | ignorePatterns: config.ignore, |
|
| 93 | - | slugSource: config.slugSource, |
|
| 94 | - | slugField: config.slugField, |
|
| 95 | - | removeIndexFromSlug: config.removeIndexFromSlug, |
|
| 96 | - | }); |
|
| 97 | - | s.stop(`Found ${posts.length} posts`); |
|
| 105 | + | // Scan for posts |
|
| 106 | + | const s = spinner(); |
|
| 107 | + | s.start("Scanning for posts..."); |
|
| 108 | + | const posts = await scanContentDirectory(contentDir, { |
|
| 109 | + | frontmatterMapping: config.frontmatter, |
|
| 110 | + | ignorePatterns: config.ignore, |
|
| 111 | + | slugSource: config.slugSource, |
|
| 112 | + | slugField: config.slugField, |
|
| 113 | + | removeIndexFromSlug: config.removeIndexFromSlug, |
|
| 114 | + | }); |
|
| 115 | + | s.stop(`Found ${posts.length} posts`); |
|
| 98 | 116 | ||
| 99 | - | // Determine which posts need publishing |
|
| 100 | - | const postsToPublish: Array<{ |
|
| 101 | - | post: BlogPost; |
|
| 102 | - | action: "create" | "update"; |
|
| 103 | - | reason: string; |
|
| 104 | - | }> = []; |
|
| 117 | + | // Determine which posts need publishing |
|
| 118 | + | const postsToPublish: Array<{ |
|
| 119 | + | post: BlogPost; |
|
| 120 | + | action: "create" | "update"; |
|
| 121 | + | reason: string; |
|
| 122 | + | }> = []; |
|
| 123 | + | const draftPosts: BlogPost[] = []; |
|
| 105 | 124 | ||
| 106 | - | for (const post of posts) { |
|
| 107 | - | const contentHash = await getContentHash(post.rawContent); |
|
| 108 | - | const relativeFilePath = path.relative(configDir, post.filePath); |
|
| 109 | - | const postState = state.posts[relativeFilePath]; |
|
| 125 | + | for (const post of posts) { |
|
| 126 | + | // Skip draft posts |
|
| 127 | + | if (post.frontmatter.draft) { |
|
| 128 | + | draftPosts.push(post); |
|
| 129 | + | continue; |
|
| 130 | + | } |
|
| 110 | 131 | ||
| 111 | - | if (force) { |
|
| 112 | - | postsToPublish.push({ |
|
| 113 | - | post, |
|
| 114 | - | action: post.frontmatter.atUri ? "update" : "create", |
|
| 115 | - | reason: "forced", |
|
| 116 | - | }); |
|
| 117 | - | } else if (!postState) { |
|
| 118 | - | // New post |
|
| 119 | - | postsToPublish.push({ |
|
| 120 | - | post, |
|
| 121 | - | action: "create", |
|
| 122 | - | reason: "new post", |
|
| 123 | - | }); |
|
| 124 | - | } else if (postState.contentHash !== contentHash) { |
|
| 125 | - | // Changed post |
|
| 126 | - | postsToPublish.push({ |
|
| 127 | - | post, |
|
| 128 | - | action: post.frontmatter.atUri ? "update" : "create", |
|
| 129 | - | reason: "content changed", |
|
| 130 | - | }); |
|
| 131 | - | } |
|
| 132 | - | } |
|
| 132 | + | const contentHash = await getContentHash(post.rawContent); |
|
| 133 | + | const relativeFilePath = path.relative(configDir, post.filePath); |
|
| 134 | + | const postState = state.posts[relativeFilePath]; |
|
| 133 | 135 | ||
| 134 | - | if (postsToPublish.length === 0) { |
|
| 135 | - | log.success("All posts are up to date. Nothing to publish."); |
|
| 136 | - | return; |
|
| 137 | - | } |
|
| 136 | + | if (force) { |
|
| 137 | + | postsToPublish.push({ |
|
| 138 | + | post, |
|
| 139 | + | action: post.frontmatter.atUri ? "update" : "create", |
|
| 140 | + | reason: "forced", |
|
| 141 | + | }); |
|
| 142 | + | } else if (!postState) { |
|
| 143 | + | // New post |
|
| 144 | + | postsToPublish.push({ |
|
| 145 | + | post, |
|
| 146 | + | action: "create", |
|
| 147 | + | reason: "new post", |
|
| 148 | + | }); |
|
| 149 | + | } else if (postState.contentHash !== contentHash) { |
|
| 150 | + | // Changed post |
|
| 151 | + | postsToPublish.push({ |
|
| 152 | + | post, |
|
| 153 | + | action: post.frontmatter.atUri ? "update" : "create", |
|
| 154 | + | reason: "content changed", |
|
| 155 | + | }); |
|
| 156 | + | } |
|
| 157 | + | } |
|
| 138 | 158 | ||
| 139 | - | log.info(`\n${postsToPublish.length} posts to publish:\n`); |
|
| 140 | - | for (const { post, action, reason } of postsToPublish) { |
|
| 141 | - | const icon = action === "create" ? "+" : "~"; |
|
| 142 | - | log.message(` ${icon} ${post.frontmatter.title} (${reason})`); |
|
| 143 | - | } |
|
| 159 | + | if (draftPosts.length > 0) { |
|
| 160 | + | log.info( |
|
| 161 | + | `Skipping ${draftPosts.length} draft post${draftPosts.length === 1 ? "" : "s"}`, |
|
| 162 | + | ); |
|
| 163 | + | } |
|
| 144 | 164 | ||
| 145 | - | if (dryRun) { |
|
| 146 | - | log.info("\nDry run complete. No changes made."); |
|
| 147 | - | return; |
|
| 148 | - | } |
|
| 165 | + | if (postsToPublish.length === 0) { |
|
| 166 | + | log.success("All posts are up to date. Nothing to publish."); |
|
| 167 | + | return; |
|
| 168 | + | } |
|
| 149 | 169 | ||
| 150 | - | // Create agent |
|
| 151 | - | s.start(`Connecting to ${credentials.pdsUrl}...`); |
|
| 152 | - | let agent; |
|
| 153 | - | try { |
|
| 154 | - | agent = await createAgent(credentials); |
|
| 155 | - | s.stop(`Logged in as ${agent.session?.handle}`); |
|
| 156 | - | } catch (error) { |
|
| 157 | - | s.stop("Failed to login"); |
|
| 158 | - | log.error(`Failed to login: ${error}`); |
|
| 159 | - | process.exit(1); |
|
| 160 | - | } |
|
| 170 | + | log.info(`\n${postsToPublish.length} posts to publish:\n`); |
|
| 161 | 171 | ||
| 162 | - | // Publish posts |
|
| 163 | - | let publishedCount = 0; |
|
| 164 | - | let updatedCount = 0; |
|
| 165 | - | let errorCount = 0; |
|
| 172 | + | // Bluesky posting configuration |
|
| 173 | + | const blueskyEnabled = config.bluesky?.enabled ?? false; |
|
| 174 | + | const maxAgeDays = config.bluesky?.maxAgeDays ?? 7; |
|
| 175 | + | const cutoffDate = new Date(); |
|
| 176 | + | cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays); |
|
| 166 | 177 | ||
| 167 | - | for (const { post, action } of postsToPublish) { |
|
| 168 | - | s.start(`Publishing: ${post.frontmatter.title}`); |
|
| 178 | + | for (const { post, action, reason } of postsToPublish) { |
|
| 179 | + | const icon = action === "create" ? "+" : "~"; |
|
| 180 | + | const relativeFilePath = path.relative(configDir, post.filePath); |
|
| 181 | + | const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; |
|
| 169 | 182 | ||
| 170 | - | try { |
|
| 171 | - | // Handle cover image upload |
|
| 172 | - | let coverImage: BlobObject | undefined; |
|
| 173 | - | if (post.frontmatter.ogImage) { |
|
| 174 | - | const imagePath = await resolveImagePath( |
|
| 175 | - | post.frontmatter.ogImage, |
|
| 176 | - | imagesDir, |
|
| 177 | - | contentDir |
|
| 178 | - | ); |
|
| 183 | + | let bskyNote = ""; |
|
| 184 | + | if (blueskyEnabled) { |
|
| 185 | + | if (existingBskyPostRef) { |
|
| 186 | + | bskyNote = " [bsky: exists]"; |
|
| 187 | + | } else { |
|
| 188 | + | const publishDate = new Date(post.frontmatter.publishDate); |
|
| 189 | + | if (publishDate < cutoffDate) { |
|
| 190 | + | bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`; |
|
| 191 | + | } else { |
|
| 192 | + | bskyNote = " [bsky: will post]"; |
|
| 193 | + | } |
|
| 194 | + | } |
|
| 195 | + | } |
|
| 179 | 196 | ||
| 180 | - | if (imagePath) { |
|
| 181 | - | log.info(` Uploading cover image: ${path.basename(imagePath)}`); |
|
| 182 | - | coverImage = await uploadImage(agent, imagePath); |
|
| 183 | - | if (coverImage) { |
|
| 184 | - | log.info(` Uploaded image blob: ${coverImage.ref.$link}`); |
|
| 185 | - | } |
|
| 186 | - | } else { |
|
| 187 | - | log.warn(` Cover image not found: ${post.frontmatter.ogImage}`); |
|
| 188 | - | } |
|
| 189 | - | } |
|
| 197 | + | log.message(` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}`); |
|
| 198 | + | } |
|
| 190 | 199 | ||
| 191 | - | // Track atUri and content for state saving |
|
| 192 | - | let atUri: string; |
|
| 193 | - | let contentForHash: string; |
|
| 200 | + | if (dryRun) { |
|
| 201 | + | if (blueskyEnabled) { |
|
| 202 | + | log.info(`\nBluesky posting: enabled (max age: ${maxAgeDays} days)`); |
|
| 203 | + | } |
|
| 204 | + | log.info("\nDry run complete. No changes made."); |
|
| 205 | + | return; |
|
| 206 | + | } |
|
| 194 | 207 | ||
| 195 | - | if (action === "create") { |
|
| 196 | - | atUri = await createDocument(agent, post, config, coverImage); |
|
| 197 | - | s.stop(`Created: ${atUri}`); |
|
| 208 | + | // Create agent |
|
| 209 | + | s.start(`Connecting to ${credentials.pdsUrl}...`); |
|
| 210 | + | let agent; |
|
| 211 | + | try { |
|
| 212 | + | agent = await createAgent(credentials); |
|
| 213 | + | s.stop(`Logged in as ${agent.session?.handle}`); |
|
| 214 | + | } catch (error) { |
|
| 215 | + | s.stop("Failed to login"); |
|
| 216 | + | log.error(`Failed to login: ${error}`); |
|
| 217 | + | process.exit(1); |
|
| 218 | + | } |
|
| 198 | 219 | ||
| 199 | - | // Update frontmatter with atUri |
|
| 200 | - | const updatedContent = updateFrontmatterWithAtUri(post.rawContent, atUri); |
|
| 201 | - | await fs.writeFile(post.filePath, updatedContent); |
|
| 202 | - | log.info(` Updated frontmatter in ${path.basename(post.filePath)}`); |
|
| 220 | + | // Publish posts |
|
| 221 | + | let publishedCount = 0; |
|
| 222 | + | let updatedCount = 0; |
|
| 223 | + | let errorCount = 0; |
|
| 224 | + | let bskyPostCount = 0; |
|
| 203 | 225 | ||
| 204 | - | // Use updated content (with atUri) for hash so next run sees matching hash |
|
| 205 | - | contentForHash = updatedContent; |
|
| 206 | - | publishedCount++; |
|
| 207 | - | } else { |
|
| 208 | - | atUri = post.frontmatter.atUri!; |
|
| 209 | - | await updateDocument(agent, post, atUri, config, coverImage); |
|
| 210 | - | s.stop(`Updated: ${atUri}`); |
|
| 226 | + | for (const { post, action } of postsToPublish) { |
|
| 227 | + | s.start(`Publishing: ${post.frontmatter.title}`); |
|
| 211 | 228 | ||
| 212 | - | // For updates, rawContent already has atUri |
|
| 213 | - | contentForHash = post.rawContent; |
|
| 214 | - | updatedCount++; |
|
| 215 | - | } |
|
| 229 | + | try { |
|
| 230 | + | // Handle cover image upload |
|
| 231 | + | let coverImage: BlobObject | undefined; |
|
| 232 | + | if (post.frontmatter.ogImage) { |
|
| 233 | + | const imagePath = await resolveImagePath( |
|
| 234 | + | post.frontmatter.ogImage, |
|
| 235 | + | imagesDir, |
|
| 236 | + | contentDir, |
|
| 237 | + | ); |
|
| 216 | 238 | ||
| 217 | - | // Update state (use relative path from config directory) |
|
| 218 | - | const contentHash = await getContentHash(contentForHash); |
|
| 219 | - | const relativeFilePath = path.relative(configDir, post.filePath); |
|
| 220 | - | state.posts[relativeFilePath] = { |
|
| 221 | - | contentHash, |
|
| 222 | - | atUri, |
|
| 223 | - | lastPublished: new Date().toISOString(), |
|
| 224 | - | slug: post.slug, |
|
| 225 | - | }; |
|
| 226 | - | } catch (error) { |
|
| 227 | - | const errorMessage = error instanceof Error ? error.message : String(error); |
|
| 228 | - | s.stop(`Error publishing "${path.basename(post.filePath)}"`); |
|
| 229 | - | log.error(` ${errorMessage}`); |
|
| 230 | - | errorCount++; |
|
| 231 | - | } |
|
| 232 | - | } |
|
| 239 | + | if (imagePath) { |
|
| 240 | + | log.info(` Uploading cover image: ${path.basename(imagePath)}`); |
|
| 241 | + | coverImage = await uploadImage(agent, imagePath); |
|
| 242 | + | if (coverImage) { |
|
| 243 | + | log.info(` Uploaded image blob: ${coverImage.ref.$link}`); |
|
| 244 | + | } |
|
| 245 | + | } else { |
|
| 246 | + | log.warn(` Cover image not found: ${post.frontmatter.ogImage}`); |
|
| 247 | + | } |
|
| 248 | + | } |
|
| 249 | + | ||
| 250 | + | // Track atUri, content for state saving, and bskyPostRef |
|
| 251 | + | let atUri: string; |
|
| 252 | + | let contentForHash: string; |
|
| 253 | + | let bskyPostRef: StrongRef | undefined; |
|
| 254 | + | const relativeFilePath = path.relative(configDir, post.filePath); |
|
| 255 | + | ||
| 256 | + | // Check if bskyPostRef already exists in state |
|
| 257 | + | const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; |
|
| 258 | + | ||
| 259 | + | if (action === "create") { |
|
| 260 | + | atUri = await createDocument(agent, post, config, coverImage); |
|
| 261 | + | s.stop(`Created: ${atUri}`); |
|
| 262 | + | ||
| 263 | + | // Update frontmatter with atUri |
|
| 264 | + | const updatedContent = updateFrontmatterWithAtUri( |
|
| 265 | + | post.rawContent, |
|
| 266 | + | atUri, |
|
| 267 | + | ); |
|
| 268 | + | await fs.writeFile(post.filePath, updatedContent); |
|
| 269 | + | log.info(` Updated frontmatter in ${path.basename(post.filePath)}`); |
|
| 270 | + | ||
| 271 | + | // Use updated content (with atUri) for hash so next run sees matching hash |
|
| 272 | + | contentForHash = updatedContent; |
|
| 273 | + | publishedCount++; |
|
| 274 | + | } else { |
|
| 275 | + | atUri = post.frontmatter.atUri!; |
|
| 276 | + | await updateDocument(agent, post, atUri, config, coverImage); |
|
| 277 | + | s.stop(`Updated: ${atUri}`); |
|
| 233 | 278 | ||
| 234 | - | // Save state |
|
| 235 | - | await saveState(configDir, state); |
|
| 279 | + | // For updates, rawContent already has atUri |
|
| 280 | + | contentForHash = post.rawContent; |
|
| 281 | + | updatedCount++; |
|
| 282 | + | } |
|
| 236 | 283 | ||
| 237 | - | // Summary |
|
| 238 | - | log.message("\n---"); |
|
| 239 | - | log.info(`Published: ${publishedCount}`); |
|
| 240 | - | log.info(`Updated: ${updatedCount}`); |
|
| 241 | - | if (errorCount > 0) { |
|
| 242 | - | log.warn(`Errors: ${errorCount}`); |
|
| 243 | - | } |
|
| 244 | - | }, |
|
| 284 | + | // Create Bluesky post if enabled and conditions are met |
|
| 285 | + | if (blueskyEnabled) { |
|
| 286 | + | if (existingBskyPostRef) { |
|
| 287 | + | log.info(` Bluesky post already exists, skipping`); |
|
| 288 | + | bskyPostRef = existingBskyPostRef; |
|
| 289 | + | } else { |
|
| 290 | + | const publishDate = new Date(post.frontmatter.publishDate); |
|
| 291 | + | ||
| 292 | + | if (publishDate < cutoffDate) { |
|
| 293 | + | log.info( |
|
| 294 | + | ` Post is older than ${maxAgeDays} days, skipping Bluesky post`, |
|
| 295 | + | ); |
|
| 296 | + | } else { |
|
| 297 | + | // Create Bluesky post |
|
| 298 | + | try { |
|
| 299 | + | const pathPrefix = config.pathPrefix || "/posts"; |
|
| 300 | + | const canonicalUrl = `${config.siteUrl}${pathPrefix}/${post.slug}`; |
|
| 301 | + | ||
| 302 | + | bskyPostRef = await createBlueskyPost(agent, { |
|
| 303 | + | title: post.frontmatter.title, |
|
| 304 | + | description: post.frontmatter.description, |
|
| 305 | + | canonicalUrl, |
|
| 306 | + | coverImage, |
|
| 307 | + | publishedAt: post.frontmatter.publishDate, |
|
| 308 | + | }); |
|
| 309 | + | ||
| 310 | + | // Update document record with bskyPostRef |
|
| 311 | + | await addBskyPostRefToDocument(agent, atUri, bskyPostRef); |
|
| 312 | + | log.info(` Created Bluesky post: ${bskyPostRef.uri}`); |
|
| 313 | + | bskyPostCount++; |
|
| 314 | + | } catch (bskyError) { |
|
| 315 | + | const errorMsg = |
|
| 316 | + | bskyError instanceof Error |
|
| 317 | + | ? bskyError.message |
|
| 318 | + | : String(bskyError); |
|
| 319 | + | log.warn(` Failed to create Bluesky post: ${errorMsg}`); |
|
| 320 | + | } |
|
| 321 | + | } |
|
| 322 | + | } |
|
| 323 | + | } |
|
| 324 | + | ||
| 325 | + | // Update state (use relative path from config directory) |
|
| 326 | + | const contentHash = await getContentHash(contentForHash); |
|
| 327 | + | state.posts[relativeFilePath] = { |
|
| 328 | + | contentHash, |
|
| 329 | + | atUri, |
|
| 330 | + | lastPublished: new Date().toISOString(), |
|
| 331 | + | slug: post.slug, |
|
| 332 | + | bskyPostRef, |
|
| 333 | + | }; |
|
| 334 | + | } catch (error) { |
|
| 335 | + | const errorMessage = |
|
| 336 | + | error instanceof Error ? error.message : String(error); |
|
| 337 | + | s.stop(`Error publishing "${path.basename(post.filePath)}"`); |
|
| 338 | + | log.error(` ${errorMessage}`); |
|
| 339 | + | errorCount++; |
|
| 340 | + | } |
|
| 341 | + | } |
|
| 342 | + | ||
| 343 | + | // Save state |
|
| 344 | + | await saveState(configDir, state); |
|
| 345 | + | ||
| 346 | + | // Summary |
|
| 347 | + | log.message("\n---"); |
|
| 348 | + | log.info(`Published: ${publishedCount}`); |
|
| 349 | + | log.info(`Updated: ${updatedCount}`); |
|
| 350 | + | if (bskyPostCount > 0) { |
|
| 351 | + | log.info(`Bluesky posts: ${bskyPostCount}`); |
|
| 352 | + | } |
|
| 353 | + | if (errorCount > 0) { |
|
| 354 | + | log.warn(`Errors: ${errorCount}`); |
|
| 355 | + | } |
|
| 356 | + | }, |
|
| 245 | 357 | }); |
| 33 | 33 | ||
| 34 | 34 | > https://tangled.org/stevedylan.dev/sequoia |
|
| 35 | 35 | `, |
|
| 36 | - | version: "0.1.1", |
|
| 36 | + | version: "0.2.0", |
|
| 37 | 37 | cmds: { |
|
| 38 | 38 | auth: authCommand, |
|
| 39 | 39 | init: initCommand, |
| 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> { |
|
| 375 | 375 | ||
| 376 | 376 | return response.data.uri; |
|
| 377 | 377 | } |
|
| 378 | + | ||
| 379 | + | // --- Bluesky Post Creation --- |
|
| 380 | + | ||
| 381 | + | export interface CreateBlueskyPostOptions { |
|
| 382 | + | title: string; |
|
| 383 | + | description?: string; |
|
| 384 | + | canonicalUrl: string; |
|
| 385 | + | coverImage?: BlobObject; |
|
| 386 | + | publishedAt: string; // Used as createdAt for the post |
|
| 387 | + | } |
|
| 388 | + | ||
| 389 | + | /** |
|
| 390 | + | * Count graphemes in a string (for Bluesky's 300 grapheme limit) |
|
| 391 | + | */ |
|
| 392 | + | function countGraphemes(str: string): number { |
|
| 393 | + | // Use Intl.Segmenter if available, otherwise fallback to spread operator |
|
| 394 | + | if (typeof Intl !== "undefined" && Intl.Segmenter) { |
|
| 395 | + | const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); |
|
| 396 | + | return [...segmenter.segment(str)].length; |
|
| 397 | + | } |
|
| 398 | + | return [...str].length; |
|
| 399 | + | } |
|
| 400 | + | ||
| 401 | + | /** |
|
| 402 | + | * Truncate a string to a maximum number of graphemes |
|
| 403 | + | */ |
|
| 404 | + | function truncateToGraphemes(str: string, maxGraphemes: number): string { |
|
| 405 | + | if (typeof Intl !== "undefined" && Intl.Segmenter) { |
|
| 406 | + | const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); |
|
| 407 | + | const segments = [...segmenter.segment(str)]; |
|
| 408 | + | if (segments.length <= maxGraphemes) return str; |
|
| 409 | + | return segments.slice(0, maxGraphemes - 3).map(s => s.segment).join("") + "..."; |
|
| 410 | + | } |
|
| 411 | + | // Fallback |
|
| 412 | + | const chars = [...str]; |
|
| 413 | + | if (chars.length <= maxGraphemes) return str; |
|
| 414 | + | return chars.slice(0, maxGraphemes - 3).join("") + "..."; |
|
| 415 | + | } |
|
| 416 | + | ||
| 417 | + | /** |
|
| 418 | + | * Create a Bluesky post with external link embed |
|
| 419 | + | */ |
|
| 420 | + | export async function createBlueskyPost( |
|
| 421 | + | agent: AtpAgent, |
|
| 422 | + | options: CreateBlueskyPostOptions |
|
| 423 | + | ): Promise<StrongRef> { |
|
| 424 | + | const { title, description, canonicalUrl, coverImage, publishedAt } = options; |
|
| 425 | + | ||
| 426 | + | // Build post text: title + description + URL |
|
| 427 | + | // Max 300 graphemes for Bluesky posts |
|
| 428 | + | const MAX_GRAPHEMES = 300; |
|
| 429 | + | ||
| 430 | + | let postText: string; |
|
| 431 | + | const urlPart = `\n\n${canonicalUrl}`; |
|
| 432 | + | const urlGraphemes = countGraphemes(urlPart); |
|
| 433 | + | ||
| 434 | + | if (description) { |
|
| 435 | + | // Try: title + description + URL |
|
| 436 | + | const fullText = `${title}\n\n${description}${urlPart}`; |
|
| 437 | + | if (countGraphemes(fullText) <= MAX_GRAPHEMES) { |
|
| 438 | + | postText = fullText; |
|
| 439 | + | } else { |
|
| 440 | + | // Truncate description to fit |
|
| 441 | + | const availableForDesc = MAX_GRAPHEMES - countGraphemes(title) - countGraphemes("\n\n") - urlGraphemes - countGraphemes("\n\n"); |
|
| 442 | + | if (availableForDesc > 10) { |
|
| 443 | + | const truncatedDesc = truncateToGraphemes(description, availableForDesc); |
|
| 444 | + | postText = `${title}\n\n${truncatedDesc}${urlPart}`; |
|
| 445 | + | } else { |
|
| 446 | + | // Just title + URL |
|
| 447 | + | postText = `${title}${urlPart}`; |
|
| 448 | + | } |
|
| 449 | + | } |
|
| 450 | + | } else { |
|
| 451 | + | // Just title + URL |
|
| 452 | + | postText = `${title}${urlPart}`; |
|
| 453 | + | } |
|
| 454 | + | ||
| 455 | + | // Final truncation if still too long (shouldn't happen but safety check) |
|
| 456 | + | if (countGraphemes(postText) > MAX_GRAPHEMES) { |
|
| 457 | + | postText = truncateToGraphemes(postText, MAX_GRAPHEMES); |
|
| 458 | + | } |
|
| 459 | + | ||
| 460 | + | // Calculate byte indices for the URL facet |
|
| 461 | + | const encoder = new TextEncoder(); |
|
| 462 | + | const urlStartInText = postText.lastIndexOf(canonicalUrl); |
|
| 463 | + | const beforeUrl = postText.substring(0, urlStartInText); |
|
| 464 | + | const byteStart = encoder.encode(beforeUrl).length; |
|
| 465 | + | const byteEnd = byteStart + encoder.encode(canonicalUrl).length; |
|
| 466 | + | ||
| 467 | + | // Build facets for the URL link |
|
| 468 | + | const facets = [ |
|
| 469 | + | { |
|
| 470 | + | index: { |
|
| 471 | + | byteStart, |
|
| 472 | + | byteEnd, |
|
| 473 | + | }, |
|
| 474 | + | features: [ |
|
| 475 | + | { |
|
| 476 | + | $type: "app.bsky.richtext.facet#link", |
|
| 477 | + | uri: canonicalUrl, |
|
| 478 | + | }, |
|
| 479 | + | ], |
|
| 480 | + | }, |
|
| 481 | + | ]; |
|
| 482 | + | ||
| 483 | + | // Build external embed |
|
| 484 | + | const embed: Record<string, unknown> = { |
|
| 485 | + | $type: "app.bsky.embed.external", |
|
| 486 | + | external: { |
|
| 487 | + | uri: canonicalUrl, |
|
| 488 | + | title: title.substring(0, 500), // Max 500 chars for title |
|
| 489 | + | description: (description || "").substring(0, 1000), // Max 1000 chars for description |
|
| 490 | + | }, |
|
| 491 | + | }; |
|
| 492 | + | ||
| 493 | + | // Add thumbnail if coverImage is available |
|
| 494 | + | if (coverImage) { |
|
| 495 | + | (embed.external as Record<string, unknown>).thumb = coverImage; |
|
| 496 | + | } |
|
| 497 | + | ||
| 498 | + | // Create the post record |
|
| 499 | + | const record: Record<string, unknown> = { |
|
| 500 | + | $type: "app.bsky.feed.post", |
|
| 501 | + | text: postText, |
|
| 502 | + | facets, |
|
| 503 | + | embed, |
|
| 504 | + | createdAt: new Date(publishedAt).toISOString(), |
|
| 505 | + | }; |
|
| 506 | + | ||
| 507 | + | const response = await agent.com.atproto.repo.createRecord({ |
|
| 508 | + | repo: agent.session!.did, |
|
| 509 | + | collection: "app.bsky.feed.post", |
|
| 510 | + | record, |
|
| 511 | + | }); |
|
| 512 | + | ||
| 513 | + | return { |
|
| 514 | + | uri: response.data.uri, |
|
| 515 | + | cid: response.data.cid, |
|
| 516 | + | }; |
|
| 517 | + | } |
|
| 518 | + | ||
| 519 | + | /** |
|
| 520 | + | * Add bskyPostRef to an existing document record |
|
| 521 | + | */ |
|
| 522 | + | export async function addBskyPostRefToDocument( |
|
| 523 | + | agent: AtpAgent, |
|
| 524 | + | documentAtUri: string, |
|
| 525 | + | bskyPostRef: StrongRef |
|
| 526 | + | ): Promise<void> { |
|
| 527 | + | const parsed = parseAtUri(documentAtUri); |
|
| 528 | + | if (!parsed) { |
|
| 529 | + | throw new Error(`Invalid document URI: ${documentAtUri}`); |
|
| 530 | + | } |
|
| 531 | + | ||
| 532 | + | // Fetch existing record |
|
| 533 | + | const existingRecord = await agent.com.atproto.repo.getRecord({ |
|
| 534 | + | repo: parsed.did, |
|
| 535 | + | collection: parsed.collection, |
|
| 536 | + | rkey: parsed.rkey, |
|
| 537 | + | }); |
|
| 538 | + | ||
| 539 | + | // Add bskyPostRef to the record |
|
| 540 | + | const updatedRecord = { |
|
| 541 | + | ...(existingRecord.data.value as Record<string, unknown>), |
|
| 542 | + | bskyPostRef, |
|
| 543 | + | }; |
|
| 544 | + | ||
| 545 | + | // Update the record |
|
| 546 | + | await agent.com.atproto.repo.putRecord({ |
|
| 547 | + | repo: parsed.did, |
|
| 548 | + | collection: parsed.collection, |
|
| 549 | + | rkey: parsed.rkey, |
|
| 550 | + | record: updatedRecord, |
|
| 551 | + | }); |
|
| 552 | + | } |
|
| 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"; |
|
| 80 | 80 | slugField?: string; |
|
| 81 | 81 | removeIndexFromSlug?: boolean; |
|
| 82 | 82 | textContentField?: string; |
|
| 83 | + | bluesky?: BlueskyConfig; |
|
| 83 | 84 | }): string { |
|
| 84 | 85 | const config: Record<string, unknown> = { |
|
| 85 | 86 | siteUrl: options.siteUrl, |
|
| 130 | 131 | ||
| 131 | 132 | if (options.textContentField) { |
|
| 132 | 133 | config.textContentField = options.textContentField; |
|
| 134 | + | if (options.bluesky) { |
|
| 135 | + | config.bluesky = options.bluesky; |
|
| 133 | 136 | } |
|
| 134 | 137 | ||
| 135 | 138 | return JSON.stringify(config, null, 2); |
|
| 148 | 148 | const tagsField = mapping?.tags || "tags"; |
|
| 149 | 149 | frontmatter.tags = raw[tagsField] || raw.tags; |
|
| 150 | 150 | ||
| 151 | + | // Draft mapping |
|
| 152 | + | const draftField = mapping?.draft || "draft"; |
|
| 153 | + | const draftValue = raw[draftField] ?? raw.draft; |
|
| 154 | + | if (draftValue !== undefined) { |
|
| 155 | + | frontmatter.draft = draftValue === true || draftValue === "true"; |
|
| 156 | + | } |
|
| 157 | + | ||
| 151 | 158 | // Always preserve atUri (internal field) |
|
| 152 | 159 | frontmatter.atUri = raw.atUri; |
|
| 153 | 160 |
| 4 | 4 | publishDate?: string; // Field name for publish date (default: "publishDate", also checks "pubDate", "date", "createdAt", "created_at") |
|
| 5 | 5 | coverImage?: string; // Field name for cover image (default: "ogImage") |
|
| 6 | 6 | tags?: string; // Field name for tags (default: "tags") |
|
| 7 | + | draft?: string; // Field name for draft status (default: "draft") |
|
| 8 | + | } |
|
| 9 | + | ||
| 10 | + | // Strong reference for Bluesky post (com.atproto.repo.strongRef) |
|
| 11 | + | export interface StrongRef { |
|
| 12 | + | uri: string; // at:// URI format |
|
| 13 | + | cid: string; // Content ID |
|
| 14 | + | } |
|
| 15 | + | ||
| 16 | + | // Bluesky posting configuration |
|
| 17 | + | export interface BlueskyConfig { |
|
| 18 | + | enabled: boolean; |
|
| 19 | + | maxAgeDays?: number; // Only post if published within N days (default: 7) |
|
| 7 | 20 | } |
|
| 8 | 21 | ||
| 9 | 22 | export interface PublisherConfig { |
|
| 22 | 35 | slugField?: string; // Frontmatter field to use when slugSource is "frontmatter" (default: "slug") |
|
| 23 | 36 | removeIndexFromSlug?: boolean; // Remove "/index" or "/_index" suffix from paths (default: false) |
|
| 24 | 37 | textContentField?: string; // Frontmatter field to use for textContent instead of markdown body |
|
| 38 | + | bluesky?: BlueskyConfig; // Optional Bluesky posting configuration |
|
| 25 | 39 | } |
|
| 26 | 40 | ||
| 27 | 41 | export interface Credentials { |
|
| 37 | 51 | tags?: string[]; |
|
| 38 | 52 | ogImage?: string; |
|
| 39 | 53 | atUri?: string; |
|
| 54 | + | draft?: boolean; |
|
| 40 | 55 | } |
|
| 41 | 56 | ||
| 42 | 57 | export interface BlogPost { |
|
| 68 | 83 | atUri?: string; |
|
| 69 | 84 | lastPublished?: string; |
|
| 70 | 85 | slug?: string; // The generated slug for this post (used by inject command) |
|
| 86 | + | bskyPostRef?: StrongRef; // Reference to corresponding Bluesky post |
|
| 71 | 87 | } |
|
| 72 | 88 | ||
| 73 | 89 | export interface PublicationRecord { |
|