tangled
alpha
login
or
join now
stevedylan.dev
/
sequoia
35
fork
atom
A CLI for publishing standard.site documents to ATProto
sequoia.pub
standard
site
lexicon
cli
publishing
35
fork
atom
overview
issues
5
pulls
1
pipelines
feat: added bskyPostRef
stevedylan.dev
1 month ago
82b6c852
3500615e
+310
-8
5 changed files
expand all
collapse all
unified
split
packages
cli
src
commands
init.ts
publish.ts
lib
atproto.ts
config.ts
types.ts
+37
-1
packages/cli/src/commands/init.ts
···
15
15
import { findConfig, generateConfigTemplate } from "../lib/config";
16
16
import { loadCredentials } from "../lib/credentials";
17
17
import { createAgent, createPublication } from "../lib/atproto";
18
18
-
import type { FrontmatterMapping } from "../lib/types";
18
18
+
import type { FrontmatterMapping, BlueskyConfig } from "../lib/types";
19
19
20
20
async function fileExists(filePath: string): Promise<boolean> {
21
21
try {
···
263
263
publicationUri = uri as string;
264
264
}
265
265
266
266
+
// Bluesky posting configuration
267
267
+
const enableBluesky = await confirm({
268
268
+
message: "Enable automatic Bluesky posting when publishing?",
269
269
+
initialValue: false,
270
270
+
});
271
271
+
272
272
+
if (enableBluesky === Symbol.for("cancel")) {
273
273
+
onCancel();
274
274
+
}
275
275
+
276
276
+
let blueskyConfig: BlueskyConfig | undefined;
277
277
+
if (enableBluesky) {
278
278
+
const maxAgeDaysInput = await text({
279
279
+
message: "Maximum age (in days) for posts to be shared on Bluesky:",
280
280
+
defaultValue: "7",
281
281
+
placeholder: "7",
282
282
+
validate: (value) => {
283
283
+
const num = parseInt(value, 10);
284
284
+
if (isNaN(num) || num < 1) {
285
285
+
return "Please enter a positive number";
286
286
+
}
287
287
+
},
288
288
+
});
289
289
+
290
290
+
if (maxAgeDaysInput === Symbol.for("cancel")) {
291
291
+
onCancel();
292
292
+
}
293
293
+
294
294
+
const maxAgeDays = parseInt(maxAgeDaysInput as string, 10);
295
295
+
blueskyConfig = {
296
296
+
enabled: true,
297
297
+
...(maxAgeDays !== 7 && { maxAgeDays }),
298
298
+
};
299
299
+
}
300
300
+
266
301
// Get PDS URL from credentials (already loaded earlier)
267
302
const pdsUrl = credentials?.pdsUrl;
268
303
···
277
312
publicationUri,
278
313
pdsUrl,
279
314
frontmatter: frontmatterMapping,
315
315
+
bluesky: blueskyConfig,
280
316
});
281
317
282
318
const configPath = path.join(process.cwd(), "sequoia.json");
+77
-5
packages/cli/src/commands/publish.ts
···
4
4
import * as path from "path";
5
5
import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
6
6
import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials";
7
7
-
import { createAgent, createDocument, updateDocument, uploadImage, resolveImagePath } from "../lib/atproto";
7
7
+
import { createAgent, createDocument, updateDocument, uploadImage, resolveImagePath, createBlueskyPost, addBskyPostRefToDocument } from "../lib/atproto";
8
8
import {
9
9
scanContentDirectory,
10
10
getContentHash,
11
11
updateFrontmatterWithAtUri,
12
12
} from "../lib/markdown";
13
13
-
import type { BlogPost, BlobObject } from "../lib/types";
13
13
+
import type { BlogPost, BlobObject, StrongRef } from "../lib/types";
14
14
import { exitOnCancel } from "../lib/prompts";
15
15
16
16
export const publishCommand = command({
···
131
131
}
132
132
133
133
log.info(`\n${postsToPublish.length} posts to publish:\n`);
134
134
+
135
135
+
// Bluesky posting configuration
136
136
+
const blueskyEnabled = config.bluesky?.enabled ?? false;
137
137
+
const maxAgeDays = config.bluesky?.maxAgeDays ?? 7;
138
138
+
const cutoffDate = new Date();
139
139
+
cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays);
140
140
+
134
141
for (const { post, action, reason } of postsToPublish) {
135
142
const icon = action === "create" ? "+" : "~";
136
136
-
log.message(` ${icon} ${post.frontmatter.title} (${reason})`);
143
143
+
const relativeFilePath = path.relative(configDir, post.filePath);
144
144
+
const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
145
145
+
146
146
+
let bskyNote = "";
147
147
+
if (blueskyEnabled) {
148
148
+
if (existingBskyPostRef) {
149
149
+
bskyNote = " [bsky: exists]";
150
150
+
} else {
151
151
+
const publishDate = new Date(post.frontmatter.publishDate);
152
152
+
if (publishDate < cutoffDate) {
153
153
+
bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`;
154
154
+
} else {
155
155
+
bskyNote = " [bsky: will post]";
156
156
+
}
157
157
+
}
158
158
+
}
159
159
+
160
160
+
log.message(` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}`);
137
161
}
138
162
139
163
if (dryRun) {
164
164
+
if (blueskyEnabled) {
165
165
+
log.info(`\nBluesky posting: enabled (max age: ${maxAgeDays} days)`);
166
166
+
}
140
167
log.info("\nDry run complete. No changes made.");
141
168
return;
142
169
}
···
157
184
let publishedCount = 0;
158
185
let updatedCount = 0;
159
186
let errorCount = 0;
187
187
+
let bskyPostCount = 0;
160
188
161
189
for (const { post, action } of postsToPublish) {
162
190
s.start(`Publishing: ${post.frontmatter.title}`);
···
182
210
}
183
211
}
184
212
185
185
-
// Track atUri and content for state saving
213
213
+
// Track atUri, content for state saving, and bskyPostRef
186
214
let atUri: string;
187
215
let contentForHash: string;
216
216
+
let bskyPostRef: StrongRef | undefined;
217
217
+
const relativeFilePath = path.relative(configDir, post.filePath);
218
218
+
219
219
+
// Check if bskyPostRef already exists in state
220
220
+
const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
188
221
189
222
if (action === "create") {
190
223
atUri = await createDocument(agent, post, config, coverImage);
···
208
241
updatedCount++;
209
242
}
210
243
244
244
+
// Create Bluesky post if enabled and conditions are met
245
245
+
if (blueskyEnabled) {
246
246
+
if (existingBskyPostRef) {
247
247
+
log.info(` Bluesky post already exists, skipping`);
248
248
+
bskyPostRef = existingBskyPostRef;
249
249
+
} else {
250
250
+
const publishDate = new Date(post.frontmatter.publishDate);
251
251
+
252
252
+
if (publishDate < cutoffDate) {
253
253
+
log.info(` Post is older than ${maxAgeDays} days, skipping Bluesky post`);
254
254
+
} else {
255
255
+
// Create Bluesky post
256
256
+
try {
257
257
+
const pathPrefix = config.pathPrefix || "/posts";
258
258
+
const canonicalUrl = `${config.siteUrl}${pathPrefix}/${post.slug}`;
259
259
+
260
260
+
bskyPostRef = await createBlueskyPost(agent, {
261
261
+
title: post.frontmatter.title,
262
262
+
description: post.frontmatter.description,
263
263
+
canonicalUrl,
264
264
+
coverImage,
265
265
+
publishedAt: post.frontmatter.publishDate,
266
266
+
});
267
267
+
268
268
+
// Update document record with bskyPostRef
269
269
+
await addBskyPostRefToDocument(agent, atUri, bskyPostRef);
270
270
+
log.info(` Created Bluesky post: ${bskyPostRef.uri}`);
271
271
+
bskyPostCount++;
272
272
+
} catch (bskyError) {
273
273
+
const errorMsg = bskyError instanceof Error ? bskyError.message : String(bskyError);
274
274
+
log.warn(` Failed to create Bluesky post: ${errorMsg}`);
275
275
+
}
276
276
+
}
277
277
+
}
278
278
+
}
279
279
+
211
280
// Update state (use relative path from config directory)
212
281
const contentHash = await getContentHash(contentForHash);
213
213
-
const relativeFilePath = path.relative(configDir, post.filePath);
214
282
state.posts[relativeFilePath] = {
215
283
contentHash,
216
284
atUri,
217
285
lastPublished: new Date().toISOString(),
286
286
+
bskyPostRef,
218
287
};
219
288
} catch (error) {
220
289
const errorMessage = error instanceof Error ? error.message : String(error);
···
231
300
log.message("\n---");
232
301
log.info(`Published: ${publishedCount}`);
233
302
log.info(`Updated: ${updatedCount}`);
303
303
+
if (bskyPostCount > 0) {
304
304
+
log.info(`Bluesky posts: ${bskyPostCount}`);
305
305
+
}
234
306
if (errorCount > 0) {
235
307
log.warn(`Errors: ${errorCount}`);
236
308
}
+176
-1
packages/cli/src/lib/atproto.ts
···
2
2
import * as fs from "fs/promises";
3
3
import * as path from "path";
4
4
import * as mimeTypes from "mime-types";
5
5
-
import type { Credentials, BlogPost, BlobObject, PublisherConfig } from "./types";
5
5
+
import type { Credentials, BlogPost, BlobObject, PublisherConfig, StrongRef } from "./types";
6
6
import { stripMarkdownForText } from "./markdown";
7
7
8
8
async function fileExists(filePath: string): Promise<boolean> {
···
352
352
353
353
return response.data.uri;
354
354
}
355
355
+
356
356
+
// --- Bluesky Post Creation ---
357
357
+
358
358
+
export interface CreateBlueskyPostOptions {
359
359
+
title: string;
360
360
+
description?: string;
361
361
+
canonicalUrl: string;
362
362
+
coverImage?: BlobObject;
363
363
+
publishedAt: string; // Used as createdAt for the post
364
364
+
}
365
365
+
366
366
+
/**
367
367
+
* Count graphemes in a string (for Bluesky's 300 grapheme limit)
368
368
+
*/
369
369
+
function countGraphemes(str: string): number {
370
370
+
// Use Intl.Segmenter if available, otherwise fallback to spread operator
371
371
+
if (typeof Intl !== "undefined" && Intl.Segmenter) {
372
372
+
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
373
373
+
return [...segmenter.segment(str)].length;
374
374
+
}
375
375
+
return [...str].length;
376
376
+
}
377
377
+
378
378
+
/**
379
379
+
* Truncate a string to a maximum number of graphemes
380
380
+
*/
381
381
+
function truncateToGraphemes(str: string, maxGraphemes: number): string {
382
382
+
if (typeof Intl !== "undefined" && Intl.Segmenter) {
383
383
+
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
384
384
+
const segments = [...segmenter.segment(str)];
385
385
+
if (segments.length <= maxGraphemes) return str;
386
386
+
return segments.slice(0, maxGraphemes - 3).map(s => s.segment).join("") + "...";
387
387
+
}
388
388
+
// Fallback
389
389
+
const chars = [...str];
390
390
+
if (chars.length <= maxGraphemes) return str;
391
391
+
return chars.slice(0, maxGraphemes - 3).join("") + "...";
392
392
+
}
393
393
+
394
394
+
/**
395
395
+
* Create a Bluesky post with external link embed
396
396
+
*/
397
397
+
export async function createBlueskyPost(
398
398
+
agent: AtpAgent,
399
399
+
options: CreateBlueskyPostOptions
400
400
+
): Promise<StrongRef> {
401
401
+
const { title, description, canonicalUrl, coverImage, publishedAt } = options;
402
402
+
403
403
+
// Build post text: title + description + URL
404
404
+
// Max 300 graphemes for Bluesky posts
405
405
+
const MAX_GRAPHEMES = 300;
406
406
+
407
407
+
let postText: string;
408
408
+
const urlPart = `\n\n${canonicalUrl}`;
409
409
+
const urlGraphemes = countGraphemes(urlPart);
410
410
+
411
411
+
if (description) {
412
412
+
// Try: title + description + URL
413
413
+
const fullText = `${title}\n\n${description}${urlPart}`;
414
414
+
if (countGraphemes(fullText) <= MAX_GRAPHEMES) {
415
415
+
postText = fullText;
416
416
+
} else {
417
417
+
// Truncate description to fit
418
418
+
const availableForDesc = MAX_GRAPHEMES - countGraphemes(title) - countGraphemes("\n\n") - urlGraphemes - countGraphemes("\n\n");
419
419
+
if (availableForDesc > 10) {
420
420
+
const truncatedDesc = truncateToGraphemes(description, availableForDesc);
421
421
+
postText = `${title}\n\n${truncatedDesc}${urlPart}`;
422
422
+
} else {
423
423
+
// Just title + URL
424
424
+
postText = `${title}${urlPart}`;
425
425
+
}
426
426
+
}
427
427
+
} else {
428
428
+
// Just title + URL
429
429
+
postText = `${title}${urlPart}`;
430
430
+
}
431
431
+
432
432
+
// Final truncation if still too long (shouldn't happen but safety check)
433
433
+
if (countGraphemes(postText) > MAX_GRAPHEMES) {
434
434
+
postText = truncateToGraphemes(postText, MAX_GRAPHEMES);
435
435
+
}
436
436
+
437
437
+
// Calculate byte indices for the URL facet
438
438
+
const encoder = new TextEncoder();
439
439
+
const urlStartInText = postText.lastIndexOf(canonicalUrl);
440
440
+
const beforeUrl = postText.substring(0, urlStartInText);
441
441
+
const byteStart = encoder.encode(beforeUrl).length;
442
442
+
const byteEnd = byteStart + encoder.encode(canonicalUrl).length;
443
443
+
444
444
+
// Build facets for the URL link
445
445
+
const facets = [
446
446
+
{
447
447
+
index: {
448
448
+
byteStart,
449
449
+
byteEnd,
450
450
+
},
451
451
+
features: [
452
452
+
{
453
453
+
$type: "app.bsky.richtext.facet#link",
454
454
+
uri: canonicalUrl,
455
455
+
},
456
456
+
],
457
457
+
},
458
458
+
];
459
459
+
460
460
+
// Build external embed
461
461
+
const embed: Record<string, unknown> = {
462
462
+
$type: "app.bsky.embed.external",
463
463
+
external: {
464
464
+
uri: canonicalUrl,
465
465
+
title: title.substring(0, 500), // Max 500 chars for title
466
466
+
description: (description || "").substring(0, 1000), // Max 1000 chars for description
467
467
+
},
468
468
+
};
469
469
+
470
470
+
// Add thumbnail if coverImage is available
471
471
+
if (coverImage) {
472
472
+
(embed.external as Record<string, unknown>).thumb = coverImage;
473
473
+
}
474
474
+
475
475
+
// Create the post record
476
476
+
const record: Record<string, unknown> = {
477
477
+
$type: "app.bsky.feed.post",
478
478
+
text: postText,
479
479
+
facets,
480
480
+
embed,
481
481
+
createdAt: new Date(publishedAt).toISOString(),
482
482
+
};
483
483
+
484
484
+
const response = await agent.com.atproto.repo.createRecord({
485
485
+
repo: agent.session!.did,
486
486
+
collection: "app.bsky.feed.post",
487
487
+
record,
488
488
+
});
489
489
+
490
490
+
return {
491
491
+
uri: response.data.uri,
492
492
+
cid: response.data.cid,
493
493
+
};
494
494
+
}
495
495
+
496
496
+
/**
497
497
+
* Add bskyPostRef to an existing document record
498
498
+
*/
499
499
+
export async function addBskyPostRefToDocument(
500
500
+
agent: AtpAgent,
501
501
+
documentAtUri: string,
502
502
+
bskyPostRef: StrongRef
503
503
+
): Promise<void> {
504
504
+
const parsed = parseAtUri(documentAtUri);
505
505
+
if (!parsed) {
506
506
+
throw new Error(`Invalid document URI: ${documentAtUri}`);
507
507
+
}
508
508
+
509
509
+
// Fetch existing record
510
510
+
const existingRecord = await agent.com.atproto.repo.getRecord({
511
511
+
repo: parsed.did,
512
512
+
collection: parsed.collection,
513
513
+
rkey: parsed.rkey,
514
514
+
});
515
515
+
516
516
+
// Add bskyPostRef to the record
517
517
+
const updatedRecord = {
518
518
+
...(existingRecord.data.value as Record<string, unknown>),
519
519
+
bskyPostRef,
520
520
+
};
521
521
+
522
522
+
// Update the record
523
523
+
await agent.com.atproto.repo.putRecord({
524
524
+
repo: parsed.did,
525
525
+
collection: parsed.collection,
526
526
+
rkey: parsed.rkey,
527
527
+
record: updatedRecord,
528
528
+
});
529
529
+
}
+6
-1
packages/cli/src/lib/config.ts
···
1
1
import * as fs from "fs/promises";
2
2
import * as path from "path";
3
3
-
import type { PublisherConfig, PublisherState, FrontmatterMapping } from "./types";
3
3
+
import type { PublisherConfig, PublisherState, FrontmatterMapping, BlueskyConfig } from "./types";
4
4
5
5
const CONFIG_FILENAME = "sequoia.json";
6
6
const STATE_FILENAME = ".sequoia-state.json";
···
76
76
pdsUrl?: string;
77
77
frontmatter?: FrontmatterMapping;
78
78
ignore?: string[];
79
79
+
bluesky?: BlueskyConfig;
79
80
}): string {
80
81
const config: Record<string, unknown> = {
81
82
siteUrl: options.siteUrl,
···
110
111
111
112
if (options.ignore && options.ignore.length > 0) {
112
113
config.ignore = options.ignore;
114
114
+
}
115
115
+
116
116
+
if (options.bluesky) {
117
117
+
config.bluesky = options.bluesky;
113
118
}
114
119
115
120
return JSON.stringify(config, null, 2);
+14
packages/cli/src/lib/types.ts
···
6
6
tags?: string; // Field name for tags (default: "tags")
7
7
}
8
8
9
9
+
// Strong reference for Bluesky post (com.atproto.repo.strongRef)
10
10
+
export interface StrongRef {
11
11
+
uri: string; // at:// URI format
12
12
+
cid: string; // Content ID
13
13
+
}
14
14
+
15
15
+
// Bluesky posting configuration
16
16
+
export interface BlueskyConfig {
17
17
+
enabled: boolean;
18
18
+
maxAgeDays?: number; // Only post if published within N days (default: 7)
19
19
+
}
20
20
+
9
21
export interface PublisherConfig {
10
22
siteUrl: string;
11
23
contentDir: string;
···
18
30
identity?: string; // Which stored identity to use (matches identifier)
19
31
frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings
20
32
ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"])
33
33
+
bluesky?: BlueskyConfig; // Optional Bluesky posting configuration
21
34
}
22
35
23
36
export interface Credentials {
···
62
75
contentHash: string;
63
76
atUri?: string;
64
77
lastPublished?: string;
78
78
+
bskyPostRef?: StrongRef; // Reference to corresponding Bluesky post
65
79
}
66
80
67
81
export interface PublicationRecord {