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