this repo has no description

chore: merge main into chore/fronmatter-config-updates

+695 -213
+69
CHANGELOG.md
··· 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
+10 -1
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 - <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
+12 -2
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 ··· 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 ```
+39 -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. 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
packages/cli/package.json
··· 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"
+44 -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 { ··· 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");
+316 -204
packages/cli/src/commands/publish.ts
··· 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 });
+1 -1
packages/cli/src/index.ts
··· 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,
+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> { ··· 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 + }
+4 -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"; ··· 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);
+7
packages/cli/src/lib/markdown.ts
··· 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
+16
packages/cli/src/lib/types.ts
··· 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 {