chore: merge main into chore/fronmatter-config-updates 266986ee
Steve · 2026-02-01 05:35 12 file(s) · +695 −213
CHANGELOG.md (added) +69 −0
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
docs/docs/pages/blog/introducing-sequoia.mdx +10 −1
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
docs/docs/pages/config.mdx +12 −2
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
```
docs/docs/pages/publishing.mdx +39 −1
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
packages/cli/package.json +1 −1
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"
packages/cli/src/commands/init.ts +44 −1
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");
packages/cli/src/commands/publish.ts +316 −204
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
});
packages/cli/src/index.ts +1 −1
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,
packages/cli/src/lib/atproto.ts +176 −1
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 +
}
packages/cli/src/lib/config.ts +4 −1
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);
packages/cli/src/lib/markdown.ts +7 −0
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
packages/cli/src/lib/types.ts +16 −0
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 {