A CLI for publishing standard.site documents to ATProto
sequoia.pub
standard
site
lexicon
cli
publishing
1import { AtpAgent } from "@atproto/api";
2import * as mimeTypes from "mime-types";
3import * as fs from "node:fs/promises";
4import * as path from "node:path";
5import { stripMarkdownForText } from "./markdown";
6import type {
7 BlobObject,
8 BlogPost,
9 Credentials,
10 PublisherConfig,
11 StrongRef,
12} from "./types";
13
14async function fileExists(filePath: string): Promise<boolean> {
15 try {
16 await fs.access(filePath);
17 return true;
18 } catch {
19 return false;
20 }
21}
22
23export async function resolveHandleToPDS(handle: string): Promise<string> {
24 // First, resolve the handle to a DID
25 let did: string;
26
27 if (handle.startsWith("did:")) {
28 did = handle;
29 } else {
30 // Try to resolve handle via Bluesky API
31 const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
32 const resolveResponse = await fetch(resolveUrl);
33 if (!resolveResponse.ok) {
34 throw new Error("Could not resolve handle");
35 }
36 const resolveData = (await resolveResponse.json()) as { did: string };
37 did = resolveData.did;
38 }
39
40 // Now resolve the DID to get the PDS URL from the DID document
41 let pdsUrl: string | undefined;
42
43 if (did.startsWith("did:plc:")) {
44 // Fetch DID document from plc.directory
45 const didDocUrl = `https://plc.directory/${did}`;
46 const didDocResponse = await fetch(didDocUrl);
47 if (!didDocResponse.ok) {
48 throw new Error("Could not fetch DID document");
49 }
50 const didDoc = (await didDocResponse.json()) as {
51 service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
52 };
53
54 // Find the PDS service endpoint
55 const pdsService = didDoc.service?.find(
56 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
57 );
58 pdsUrl = pdsService?.serviceEndpoint;
59 } else if (did.startsWith("did:web:")) {
60 // For did:web, fetch the DID document from the domain
61 const domain = did.replace("did:web:", "");
62 const didDocUrl = `https://${domain}/.well-known/did.json`;
63 const didDocResponse = await fetch(didDocUrl);
64 if (!didDocResponse.ok) {
65 throw new Error("Could not fetch DID document");
66 }
67 const didDoc = (await didDocResponse.json()) as {
68 service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
69 };
70
71 const pdsService = didDoc.service?.find(
72 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
73 );
74 pdsUrl = pdsService?.serviceEndpoint;
75 }
76
77 if (!pdsUrl) {
78 throw new Error("Could not find PDS URL for user");
79 }
80
81 return pdsUrl;
82}
83
84export interface CreatePublicationOptions {
85 url: string;
86 name: string;
87 description?: string;
88 iconPath?: string;
89 showInDiscover?: boolean;
90}
91
92export async function createAgent(credentials: Credentials): Promise<AtpAgent> {
93 const agent = new AtpAgent({ service: credentials.pdsUrl });
94
95 await agent.login({
96 identifier: credentials.identifier,
97 password: credentials.password,
98 });
99
100 return agent;
101}
102
103export async function uploadImage(
104 agent: AtpAgent,
105 imagePath: string,
106): Promise<BlobObject | undefined> {
107 if (!(await fileExists(imagePath))) {
108 return undefined;
109 }
110
111 try {
112 const imageBuffer = await fs.readFile(imagePath);
113 const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream";
114
115 const response = await agent.com.atproto.repo.uploadBlob(
116 new Uint8Array(imageBuffer),
117 {
118 encoding: mimeType,
119 },
120 );
121
122 return {
123 $type: "blob",
124 ref: {
125 $link: response.data.blob.ref.toString(),
126 },
127 mimeType,
128 size: imageBuffer.byteLength,
129 };
130 } catch (error) {
131 console.error(`Error uploading image ${imagePath}:`, error);
132 return undefined;
133 }
134}
135
136export async function resolveImagePath(
137 ogImage: string,
138 imagesDir: string | undefined,
139 contentDir: string,
140): Promise<string | null> {
141 // Try multiple resolution strategies
142 const filename = path.basename(ogImage);
143
144 // 1. If imagesDir is specified, look there
145 if (imagesDir) {
146 const imagePath = path.join(imagesDir, filename);
147 if (await fileExists(imagePath)) {
148 const stat = await fs.stat(imagePath);
149 if (stat.size > 0) {
150 return imagePath;
151 }
152 }
153 }
154
155 // 2. Try the ogImage path directly (if it's absolute)
156 if (path.isAbsolute(ogImage)) {
157 return ogImage;
158 }
159
160 // 3. Try relative to content directory
161 const contentRelative = path.join(contentDir, ogImage);
162 if (await fileExists(contentRelative)) {
163 const stat = await fs.stat(contentRelative);
164 if (stat.size > 0) {
165 return contentRelative;
166 }
167 }
168
169 return null;
170}
171
172export async function createDocument(
173 agent: AtpAgent,
174 post: BlogPost,
175 config: PublisherConfig,
176 coverImage?: BlobObject,
177): Promise<string> {
178 const pathPrefix = config.pathPrefix || "/posts";
179 const postPath = `${pathPrefix}/${post.slug}`;
180 const publishDate = new Date(post.frontmatter.publishDate);
181
182 // Determine textContent: use configured field from frontmatter, or fallback to markdown body
183 let textContent: string;
184 if (
185 config.textContentField &&
186 post.rawFrontmatter?.[config.textContentField]
187 ) {
188 textContent = String(post.rawFrontmatter[config.textContentField]);
189 } else {
190 textContent = stripMarkdownForText(post.content);
191 }
192
193 const record: Record<string, unknown> = {
194 $type: "site.standard.document",
195 title: post.frontmatter.title,
196 site: config.publicationUri,
197 path: postPath,
198 textContent: textContent.slice(0, 10000),
199 publishedAt: publishDate.toISOString(),
200 canonicalUrl: `${config.siteUrl}${postPath}`,
201 };
202
203 if (post.frontmatter.description) {
204 record.description = post.frontmatter.description;
205 }
206
207 if (coverImage) {
208 record.coverImage = coverImage;
209 }
210
211 if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
212 record.tags = post.frontmatter.tags;
213 }
214
215 const response = await agent.com.atproto.repo.createRecord({
216 repo: agent.session!.did,
217 collection: "site.standard.document",
218 record,
219 });
220
221 return response.data.uri;
222}
223
224export async function updateDocument(
225 agent: AtpAgent,
226 post: BlogPost,
227 atUri: string,
228 config: PublisherConfig,
229 coverImage?: BlobObject,
230): Promise<void> {
231 // Parse the atUri to get the collection and rkey
232 // Format: at://did:plc:xxx/collection/rkey
233 const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
234 if (!uriMatch) {
235 throw new Error(`Invalid atUri format: ${atUri}`);
236 }
237
238 const [, , collection, rkey] = uriMatch;
239
240 const pathPrefix = config.pathPrefix || "/posts";
241 const postPath = `${pathPrefix}/${post.slug}`;
242 const publishDate = new Date(post.frontmatter.publishDate);
243
244 // Determine textContent: use configured field from frontmatter, or fallback to markdown body
245 let textContent: string;
246 if (
247 config.textContentField &&
248 post.rawFrontmatter?.[config.textContentField]
249 ) {
250 textContent = String(post.rawFrontmatter[config.textContentField]);
251 } else {
252 textContent = stripMarkdownForText(post.content);
253 }
254
255 const record: Record<string, unknown> = {
256 $type: "site.standard.document",
257 title: post.frontmatter.title,
258 site: config.publicationUri,
259 path: postPath,
260 textContent: textContent.slice(0, 10000),
261 publishedAt: publishDate.toISOString(),
262 canonicalUrl: `${config.siteUrl}${postPath}`,
263 };
264
265 if (post.frontmatter.description) {
266 record.description = post.frontmatter.description;
267 }
268
269 if (coverImage) {
270 record.coverImage = coverImage;
271 }
272
273 if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
274 record.tags = post.frontmatter.tags;
275 }
276
277 await agent.com.atproto.repo.putRecord({
278 repo: agent.session!.did,
279 collection: collection!,
280 rkey: rkey!,
281 record,
282 });
283}
284
285export function parseAtUri(
286 atUri: string,
287): { did: string; collection: string; rkey: string } | null {
288 const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
289 if (!match) return null;
290 return {
291 did: match[1]!,
292 collection: match[2]!,
293 rkey: match[3]!,
294 };
295}
296
297export interface DocumentRecord {
298 $type: "site.standard.document";
299 title: string;
300 site: string;
301 path: string;
302 textContent: string;
303 publishedAt: string;
304 canonicalUrl?: string;
305 description?: string;
306 coverImage?: BlobObject;
307 tags?: string[];
308 location?: string;
309}
310
311export interface ListDocumentsResult {
312 uri: string;
313 cid: string;
314 value: DocumentRecord;
315}
316
317export async function listDocuments(
318 agent: AtpAgent,
319 publicationUri?: string,
320): Promise<ListDocumentsResult[]> {
321 const documents: ListDocumentsResult[] = [];
322 let cursor: string | undefined;
323
324 do {
325 const response = await agent.com.atproto.repo.listRecords({
326 repo: agent.session!.did,
327 collection: "site.standard.document",
328 limit: 100,
329 cursor,
330 });
331
332 for (const record of response.data.records) {
333 const value = record.value as unknown as DocumentRecord;
334
335 // If publicationUri is specified, only include documents from that publication
336 if (publicationUri && value.site !== publicationUri) {
337 continue;
338 }
339
340 documents.push({
341 uri: record.uri,
342 cid: record.cid,
343 value,
344 });
345 }
346
347 cursor = response.data.cursor;
348 } while (cursor);
349
350 return documents;
351}
352
353export async function createPublication(
354 agent: AtpAgent,
355 options: CreatePublicationOptions,
356): Promise<string> {
357 let icon: BlobObject | undefined;
358
359 if (options.iconPath) {
360 icon = await uploadImage(agent, options.iconPath);
361 }
362
363 const record: Record<string, unknown> = {
364 $type: "site.standard.publication",
365 url: options.url,
366 name: options.name,
367 createdAt: new Date().toISOString(),
368 };
369
370 if (options.description) {
371 record.description = options.description;
372 }
373
374 if (icon) {
375 record.icon = icon;
376 }
377
378 if (options.showInDiscover !== undefined) {
379 record.preferences = {
380 showInDiscover: options.showInDiscover,
381 };
382 }
383
384 const response = await agent.com.atproto.repo.createRecord({
385 repo: agent.session!.did,
386 collection: "site.standard.publication",
387 record,
388 });
389
390 return response.data.uri;
391}
392
393// --- Bluesky Post Creation ---
394
395export interface CreateBlueskyPostOptions {
396 title: string;
397 description?: string;
398 canonicalUrl: string;
399 coverImage?: BlobObject;
400 publishedAt: string; // Used as createdAt for the post
401}
402
403/**
404 * Count graphemes in a string (for Bluesky's 300 grapheme limit)
405 */
406function countGraphemes(str: string): number {
407 // Use Intl.Segmenter if available, otherwise fallback to spread operator
408 if (typeof Intl !== "undefined" && Intl.Segmenter) {
409 const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
410 return [...segmenter.segment(str)].length;
411 }
412 return [...str].length;
413}
414
415/**
416 * Truncate a string to a maximum number of graphemes
417 */
418function truncateToGraphemes(str: string, maxGraphemes: number): string {
419 if (typeof Intl !== "undefined" && Intl.Segmenter) {
420 const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
421 const segments = [...segmenter.segment(str)];
422 if (segments.length <= maxGraphemes) return str;
423 return `${segments
424 .slice(0, maxGraphemes - 3)
425 .map((s) => s.segment)
426 .join("")}...`;
427 }
428 // Fallback
429 const chars = [...str];
430 if (chars.length <= maxGraphemes) return str;
431 return `${chars.slice(0, maxGraphemes - 3).join("")}...`;
432}
433
434/**
435 * Create a Bluesky post with external link embed
436 */
437export async function createBlueskyPost(
438 agent: AtpAgent,
439 options: CreateBlueskyPostOptions,
440): Promise<StrongRef> {
441 const { title, description, canonicalUrl, coverImage, publishedAt } = options;
442
443 // Build post text: title + description + URL
444 // Max 300 graphemes for Bluesky posts
445 const MAX_GRAPHEMES = 300;
446
447 let postText: string;
448 const urlPart = `\n\n${canonicalUrl}`;
449 const urlGraphemes = countGraphemes(urlPart);
450
451 if (description) {
452 // Try: title + description + URL
453 const fullText = `${title}\n\n${description}${urlPart}`;
454 if (countGraphemes(fullText) <= MAX_GRAPHEMES) {
455 postText = fullText;
456 } else {
457 // Truncate description to fit
458 const availableForDesc =
459 MAX_GRAPHEMES -
460 countGraphemes(title) -
461 countGraphemes("\n\n") -
462 urlGraphemes -
463 countGraphemes("\n\n");
464 if (availableForDesc > 10) {
465 const truncatedDesc = truncateToGraphemes(
466 description,
467 availableForDesc,
468 );
469 postText = `${title}\n\n${truncatedDesc}${urlPart}`;
470 } else {
471 // Just title + URL
472 postText = `${title}${urlPart}`;
473 }
474 }
475 } else {
476 // Just title + URL
477 postText = `${title}${urlPart}`;
478 }
479
480 // Final truncation if still too long (shouldn't happen but safety check)
481 if (countGraphemes(postText) > MAX_GRAPHEMES) {
482 postText = truncateToGraphemes(postText, MAX_GRAPHEMES);
483 }
484
485 // Calculate byte indices for the URL facet
486 const encoder = new TextEncoder();
487 const urlStartInText = postText.lastIndexOf(canonicalUrl);
488 const beforeUrl = postText.substring(0, urlStartInText);
489 const byteStart = encoder.encode(beforeUrl).length;
490 const byteEnd = byteStart + encoder.encode(canonicalUrl).length;
491
492 // Build facets for the URL link
493 const facets = [
494 {
495 index: {
496 byteStart,
497 byteEnd,
498 },
499 features: [
500 {
501 $type: "app.bsky.richtext.facet#link",
502 uri: canonicalUrl,
503 },
504 ],
505 },
506 ];
507
508 // Build external embed
509 const embed: Record<string, unknown> = {
510 $type: "app.bsky.embed.external",
511 external: {
512 uri: canonicalUrl,
513 title: title.substring(0, 500), // Max 500 chars for title
514 description: (description || "").substring(0, 1000), // Max 1000 chars for description
515 },
516 };
517
518 // Add thumbnail if coverImage is available
519 if (coverImage) {
520 (embed.external as Record<string, unknown>).thumb = coverImage;
521 }
522
523 // Create the post record
524 const record: Record<string, unknown> = {
525 $type: "app.bsky.feed.post",
526 text: postText,
527 facets,
528 embed,
529 createdAt: new Date(publishedAt).toISOString(),
530 };
531
532 const response = await agent.com.atproto.repo.createRecord({
533 repo: agent.session!.did,
534 collection: "app.bsky.feed.post",
535 record,
536 });
537
538 return {
539 uri: response.data.uri,
540 cid: response.data.cid,
541 };
542}
543
544/**
545 * Add bskyPostRef to an existing document record
546 */
547export async function addBskyPostRefToDocument(
548 agent: AtpAgent,
549 documentAtUri: string,
550 bskyPostRef: StrongRef,
551): Promise<void> {
552 const parsed = parseAtUri(documentAtUri);
553 if (!parsed) {
554 throw new Error(`Invalid document URI: ${documentAtUri}`);
555 }
556
557 // Fetch existing record
558 const existingRecord = await agent.com.atproto.repo.getRecord({
559 repo: parsed.did,
560 collection: parsed.collection,
561 rkey: parsed.rkey,
562 });
563
564 // Add bskyPostRef to the record
565 const updatedRecord = {
566 ...(existingRecord.data.value as Record<string, unknown>),
567 bskyPostRef,
568 };
569
570 // Update the record
571 await agent.com.atproto.repo.putRecord({
572 repo: parsed.did,
573 collection: parsed.collection,
574 rkey: parsed.rkey,
575 record: updatedRecord,
576 });
577}