Something went wrong. Try again.
A CLI for publishing standard.site documents to ATProto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768import { Agent, AtpAgent } from "@atproto/api";import * as mimeTypes from "mime-types";import * as fs from "node:fs/promises";import * as path from "node:path";import { stripMarkdownForText, resolvePostPath } from "./markdown";import { getOAuthClient } from "./oauth-client";import type { BlobObject, BlogPost, Credentials, PublicationRecord, PublisherConfig, StrongRef,} from "./types";import { isAppPasswordCredentials, isOAuthCredentials } from "./types";
// https://standard.site/docs/lexicons/document/#optional-propertiesexport const COVER_IMAGE_MAX_SIZE = 1024 * 1024 - 1;
/** * Type guard to check if a record value is a DocumentRecord */function isDocumentRecord(value: unknown): value is DocumentRecord { if (!value || typeof value !== "object") return false; const v = value as Record<string, unknown>; return ( v.$type === "site.standard.document" && typeof v.title === "string" && typeof v.site === "string" && typeof v.path === "string" && (v.textContent === undefined || typeof v.textContent === "string") && typeof v.publishedAt === "string" && (v.updatedAt === undefined || typeof v.updatedAt === "string") );}
async function fileExists(filePath: string): Promise<boolean> { try { await fs.access(filePath); return true; } catch { return false; }}
/** * Resolve a handle to a DID */export async function resolveHandleToDid(handle: string): Promise<string> { if (handle.startsWith("did:")) { return handle; }
// Try to resolve handle via Bluesky API const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; const resolveResponse = await fetch(resolveUrl); if (!resolveResponse.ok) { throw new Error("Could not resolve handle"); } const resolveData = (await resolveResponse.json()) as { did: string }; return resolveData.did;}
export async function resolveHandleToPDS(handle: string): Promise<string> { // First, resolve the handle to a DID const did = await resolveHandleToDid(handle);
// Now resolve the DID to get the PDS URL from the DID document let pdsUrl: string | undefined;
if (did.startsWith("did:plc:")) { // Fetch DID document from plc.directory const didDocUrl = `https://plc.directory/${did}`; const didDocResponse = await fetch(didDocUrl); if (!didDocResponse.ok) { throw new Error("Could not fetch DID document"); } const didDoc = (await didDocResponse.json()) as { service?: Array<{ id: string; type: string; serviceEndpoint: string }>; };
// Find the PDS service endpoint const pdsService = didDoc.service?.find( (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", ); pdsUrl = pdsService?.serviceEndpoint; } else if (did.startsWith("did:web:")) { // For did:web, fetch the DID document from the domain const domain = did.replace("did:web:", ""); const didDocUrl = `https://${domain}/.well-known/did.json`; const didDocResponse = await fetch(didDocUrl); if (!didDocResponse.ok) { throw new Error("Could not fetch DID document"); } const didDoc = (await didDocResponse.json()) as { service?: Array<{ id: string; type: string; serviceEndpoint: string }>; };
const pdsService = didDoc.service?.find( (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", ); pdsUrl = pdsService?.serviceEndpoint; }
if (!pdsUrl) { throw new Error("Could not find PDS URL for user"); }
return pdsUrl;}
export interface CreatePublicationOptions { url: string; name: string; description?: string; iconPath?: string; showInDiscover?: boolean;}
export async function createAgent(credentials: Credentials): Promise<Agent> { if (isOAuthCredentials(credentials)) { // OAuth flow - restore session from stored tokens const client = await getOAuthClient(); try { const oauthSession = await client.restore(credentials.did); // Wrap the OAuth session in an Agent which provides the atproto API return new Agent(oauthSession); } catch (error) { if (error instanceof Error) { // Check for common OAuth errors if ( error.message.includes("expired") || error.message.includes("revoked") ) { throw new Error( `OAuth session expired or revoked. Please run 'sequoia login' to re-authenticate.`, ); } } throw error; } }
// App password flow if (!isAppPasswordCredentials(credentials)) { throw new Error("Invalid credential type"); } const agent = new AtpAgent({ service: credentials.pdsUrl });
await agent.login({ identifier: credentials.identifier, password: credentials.password, });
return agent;}
export async function uploadImage( agent: Agent, imagePath: string,): Promise<BlobObject | undefined> { if (!(await fileExists(imagePath))) { return undefined; }
try { const imageBuffer = await fs.readFile(imagePath); const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream";
const response = await agent.com.atproto.repo.uploadBlob( new Uint8Array(imageBuffer), { encoding: mimeType, }, );
return { $type: "blob", ref: { $link: response.data.blob.ref.toString(), }, mimeType, size: imageBuffer.byteLength, }; } catch (error) { console.error(`Error uploading image ${imagePath}:`, error); return undefined; }}
export async function resolveImagePath( ogImage: string, imagesDir: string | undefined, contentDir: string,): Promise<string | undefined> { // Try multiple resolution strategies
// 1. If imagesDir is specified, look there if (imagesDir) { // Get the base name of the images directory (e.g., "blog-images" from "public/blog-images") const imagesDirBaseName = path.basename(imagesDir);
// Check if ogImage contains the images directory name and extract the relative path // e.g., "/blog-images/other/file.png" with imagesDirBaseName "blog-images" -> "other/file.png" const imagesDirIndex = ogImage.indexOf(imagesDirBaseName); let relativePath: string;
if (imagesDirIndex !== -1) { // Extract everything after "blog-images/" const afterImagesDir = ogImage.substring( imagesDirIndex + imagesDirBaseName.length, ); // Remove leading slash if present relativePath = afterImagesDir.replace(/^[/\\]/, ""); } else { // Fall back to just the filename relativePath = path.basename(ogImage); }
const imagePath = path.join(imagesDir, relativePath); if (await fileExists(imagePath)) { const stat = await fs.stat(imagePath); if (stat.size > 0) { return imagePath; } } }
// 2. Try the ogImage path directly (if it's absolute) if (path.isAbsolute(ogImage)) { return ogImage; }
// 3. Try relative to content directory const contentRelative = path.join(contentDir, ogImage); if (await fileExists(contentRelative)) { const stat = await fs.stat(contentRelative); if (stat.size > 0) { return contentRelative; } }
return null;}
export async function createDocument( agent: Agent, post: BlogPost, config: PublisherConfig, coverImage?: BlobObject,): Promise<string> { const postPath = resolvePostPath( post, config.pathPrefix, config.pathTemplate, ); const publishDate = new Date(post.frontmatter.publishDate);
// Handle updatedAt: only set if explicitly provided in frontmatter let updatedAt: Date | undefined; if (post.frontmatter.updatedAt) { updatedAt = new Date(post.frontmatter.updatedAt); }
// Determine textContent (if enabled): use configured field from frontmatter, or fallback to markdown body let textContent: string | null = null; if ( config.publishContent && config.textContentField && post.rawFrontmatter?.[config.textContentField] ) { textContent = String(post.rawFrontmatter[config.textContentField]); } else if (config.publishContent) { textContent = stripMarkdownForText(post.content); }
const record: Record<string, unknown> = { $type: "site.standard.document", title: post.frontmatter.title, site: config.publicationUri, path: postPath, textContent: textContent?.slice(0, 10000), publishedAt: publishDate.toISOString(), canonicalUrl: `${config.siteUrl}${postPath}`, };
if (updatedAt) { record.updatedAt = updatedAt.toISOString(); }
if (post.frontmatter.description) { record.description = post.frontmatter.description; }
if (coverImage) { record.coverImage = coverImage; }
if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { record.tags = post.frontmatter.tags; }
const response = await agent.com.atproto.repo.createRecord({ repo: agent.did!, collection: "site.standard.document", record, });
return response.data.uri;}
export async function updateDocument( agent: Agent, post: BlogPost, atUri: string, config: PublisherConfig, coverImage?: BlobObject,): Promise<void> { // Parse the atUri to get the collection and rkey // Format: at://did:plc:xxx/collection/rkey const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); if (!uriMatch) { throw new Error(`Invalid atUri format: ${atUri}`); }
const [, , collection, rkey] = uriMatch;
const postPath = resolvePostPath( post, config.pathPrefix, config.pathTemplate, ); const publishDate = new Date(post.frontmatter.publishDate);
// Handle updatedAt: only set if explicitly provided in frontmatter const updatedAt = post.frontmatter.updatedAt ? new Date(post.frontmatter.updatedAt) : undefined;
// Determine textContent (if enabled): use configured field from frontmatter, or fallback to markdown body let textContent: string | null = null; if ( config.publishContent && config.textContentField && post.rawFrontmatter?.[config.textContentField] ) { textContent = String(post.rawFrontmatter[config.textContentField]); } else if (config.publishContent) { textContent = stripMarkdownForText(post.content); }
// Fetch existing record to preserve PDS-side fields (e.g. bskyPostRef) const existingResponse = await agent.com.atproto.repo.getRecord({ repo: agent.did!, collection: collection!, rkey: rkey!, }); const existingRecord = existingResponse.data.value as Record<string, unknown>;
const record: Record<string, unknown> = { ...existingRecord, $type: "site.standard.document", title: post.frontmatter.title, site: config.publicationUri, path: postPath, textContent: textContent?.slice(0, 10000), publishedAt: publishDate.toISOString(), canonicalUrl: `${config.siteUrl}${postPath}`, };
if (updatedAt) { record.updatedAt = updatedAt.toISOString(); }
if (post.frontmatter.description) { record.description = post.frontmatter.description; }
if (coverImage) { record.coverImage = coverImage; }
if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { record.tags = post.frontmatter.tags; }
await agent.com.atproto.repo.putRecord({ repo: agent.did!, collection: collection!, rkey: rkey!, record, });}
export function parseAtUri( atUri: string,): { did: string; collection: string; rkey: string } | null { const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); if (!match) return null; return { did: match[1]!, collection: match[2]!, rkey: match[3]!, };}
export interface DocumentRecord { $type: "site.standard.document"; title: string; site: string; path: string; textContent?: string; publishedAt: string; updatedAt?: string; canonicalUrl?: string; description?: string; coverImage?: BlobObject; tags?: string[]; location?: string;}
export interface ListDocumentsResult { uri: string; cid: string; value: DocumentRecord;}
export async function listDocuments( agent: Agent, publicationUri?: string,): Promise<ListDocumentsResult[]> { const documents: ListDocumentsResult[] = []; let cursor: string | undefined;
do { const response = await agent.com.atproto.repo.listRecords({ repo: agent.did!, collection: "site.standard.document", limit: 100, cursor, });
for (const record of response.data.records) { if (!isDocumentRecord(record.value)) { continue; }
// If publicationUri is specified, only include documents from that publication if (publicationUri && record.value.site !== publicationUri) { continue; }
documents.push({ uri: record.uri, cid: record.cid, value: record.value, }); }
cursor = response.data.cursor; } while (cursor);
return documents;}
export async function createPublication( agent: Agent, options: CreatePublicationOptions,): Promise<string> { let icon: BlobObject | undefined;
if (options.iconPath) { icon = await uploadImage(agent, options.iconPath); }
const record: Record<string, unknown> = { $type: "site.standard.publication", url: options.url, name: options.name, createdAt: new Date().toISOString(), };
if (options.description) { record.description = options.description; }
if (icon) { record.icon = icon; }
if (options.showInDiscover !== undefined) { record.preferences = { showInDiscover: options.showInDiscover, }; }
const response = await agent.com.atproto.repo.createRecord({ repo: agent.did!, collection: "site.standard.publication", record, });
return response.data.uri;}
export interface GetPublicationResult { uri: string; cid: string; value: PublicationRecord;}
export async function getPublication( agent: Agent, publicationUri: string,): Promise<GetPublicationResult | null> { const parsed = parseAtUri(publicationUri); if (!parsed) { return null; }
try { const response = await agent.com.atproto.repo.getRecord({ repo: parsed.did, collection: parsed.collection, rkey: parsed.rkey, });
return { uri: publicationUri, cid: response.data.cid!, value: response.data.value as unknown as PublicationRecord, }; } catch { return null; }}
export interface UpdatePublicationOptions { url?: string; name?: string; description?: string; iconPath?: string; showInDiscover?: boolean;}
export async function updatePublication( agent: Agent, publicationUri: string, options: UpdatePublicationOptions, existingRecord: PublicationRecord,): Promise<void> { const parsed = parseAtUri(publicationUri); if (!parsed) { throw new Error(`Invalid publication URI: ${publicationUri}`); }
// Build updated record, preserving createdAt and $type const record: Record<string, unknown> = { $type: existingRecord.$type, url: options.url ?? existingRecord.url, name: options.name ?? existingRecord.name, createdAt: existingRecord.createdAt, };
// Handle description - can be cleared with empty string if (options.description !== undefined) { if (options.description) { record.description = options.description; } // If empty string, don't include description (clears it) } else if (existingRecord.description) { record.description = existingRecord.description; }
// Handle icon - upload new if provided, otherwise keep existing if (options.iconPath) { const icon = await uploadImage(agent, options.iconPath); if (icon) { record.icon = icon; } } else if (existingRecord.icon) { record.icon = existingRecord.icon; }
// Handle preferences if (options.showInDiscover !== undefined) { record.preferences = { showInDiscover: options.showInDiscover, }; } else if (existingRecord.preferences) { record.preferences = existingRecord.preferences; }
await agent.com.atproto.repo.putRecord({ repo: parsed.did, collection: parsed.collection, rkey: parsed.rkey, record, });}
// --- Bluesky Post Creation ---
export interface CreateBlueskyPostOptions { title: string; description?: string; bskyPost?: string; canonicalUrl: string; coverImage?: BlobObject; publishedAt: string; // Used as createdAt for the post}
/** * Count graphemes in a string (for Bluesky's 300 grapheme limit) */function countGraphemes(str: string): number { // Use Intl.Segmenter if available, otherwise fallback to spread operator if (typeof Intl !== "undefined" && Intl.Segmenter) { const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); return [...segmenter.segment(str)].length; } return [...str].length;}
/** * Truncate a string to a maximum number of graphemes */function truncateToGraphemes(str: string, maxGraphemes: number): string { if (typeof Intl !== "undefined" && Intl.Segmenter) { const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); const segments = [...segmenter.segment(str)]; if (segments.length <= maxGraphemes) return str; return `${segments .slice(0, maxGraphemes - 3) .map((s) => s.segment) .join("")}...`; } // Fallback const chars = [...str]; if (chars.length <= maxGraphemes) return str; return `${chars.slice(0, maxGraphemes - 3).join("")}...`;}
/** * Create a Bluesky post with external link embed */export async function createBlueskyPost( agent: Agent, options: CreateBlueskyPostOptions,): Promise<StrongRef> { const { title, description, bskyPost, canonicalUrl, coverImage, publishedAt, } = options;
// Build post text: title + description // Max 300 graphemes for Bluesky posts const MAX_GRAPHEMES = 300;
let postText: string;
if (bskyPost) { // Custom bsky post overrides any default behavior postText = bskyPost; } else if (description) { // Try: title + description const fullText = `${title}\n\n${description}`; if (countGraphemes(fullText) <= MAX_GRAPHEMES) { postText = fullText; } else { // Truncate description to fit const availableForDesc = MAX_GRAPHEMES - countGraphemes(title) - countGraphemes("\n\n"); if (availableForDesc > 10) { const truncatedDesc = truncateToGraphemes( description, availableForDesc, ); postText = `${title}\n\n${truncatedDesc}`; } else { // Just title postText = `${title}`; } } } else { // Just title postText = `${title}`; }
// Final truncation in case title or bskyPost are longer than expected if (countGraphemes(postText) > MAX_GRAPHEMES) { postText = truncateToGraphemes(postText, MAX_GRAPHEMES); }
// Build external embed const embed: Record<string, unknown> = { $type: "app.bsky.embed.external", external: { uri: canonicalUrl, title: title.substring(0, 500), // Max 500 chars for title description: (description || "").substring(0, 1000), // Max 1000 chars for description }, };
// Add thumbnail if coverImage is available if (coverImage) { (embed.external as Record<string, unknown>).thumb = coverImage; }
// Create the post record const record: Record<string, unknown> = { $type: "app.bsky.feed.post", text: postText, embed, createdAt: new Date(publishedAt).toISOString(), };
const response = await agent.com.atproto.repo.createRecord({ repo: agent.did!, collection: "app.bsky.feed.post", record, });
return { uri: response.data.uri, cid: response.data.cid, };}
/** * Add bskyPostRef to an existing document record */export async function addBskyPostRefToDocument( agent: Agent, documentAtUri: string, bskyPostRef: StrongRef,): Promise<void> { const parsed = parseAtUri(documentAtUri); if (!parsed) { throw new Error(`Invalid document URI: ${documentAtUri}`); }
// Fetch existing record const existingRecord = await agent.com.atproto.repo.getRecord({ repo: parsed.did, collection: parsed.collection, rkey: parsed.rkey, });
// Add bskyPostRef to the record const updatedRecord = { ...(existingRecord.data.value as Record<string, unknown>), bskyPostRef, };
// Update the record await agent.com.atproto.repo.putRecord({ repo: parsed.did, collection: parsed.collection, rkey: parsed.rkey, record: updatedRecord, });}