Something went wrong. Try again.
A CLI for publishing standard.site documents to ATProto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237import * as fs from "node:fs/promises";import * as path from "node:path";import { log } from "@clack/prompts";import { getPublication, listDocuments, type createAgent } from "./atproto";import { loadState, saveState } from "./config";import { scanContentDirectory, getContentHash, updateFrontmatterWithAtUri, resolvePostPath,} from "./markdown";import type { PublisherConfig, PublisherState, StrongRef } from "./types";
export interface SyncOptions { updateFrontmatter?: boolean; dryRun?: boolean; quiet?: boolean;}
export interface SyncResult { state: PublisherState; matchedCount: number; unmatchedCount: number; frontmatterUpdatesApplied: number;}
/** * Core sync logic: fetches documents from PDS and matches them to local files, * updating state and optionally frontmatter. * * Used by both the `sync` command and auto-sync before `publish`. */export async function syncStateFromPDS( agent: Awaited<ReturnType<typeof createAgent>>, config: PublisherConfig, configDir: string, options: SyncOptions = {},): Promise<SyncResult> { const { updateFrontmatter = false, dryRun = false, quiet = false } = options;
// Fetch documents from PDS (filtered by publicationUri for multi-publication safety) const documents = await listDocuments(agent, config.publicationUri);
if (documents.length === 0) { if (!quiet) { log.info("No documents found for this publication."); } return { state: await loadState(configDir), matchedCount: 0, unmatchedCount: 0, frontmatterUpdatesApplied: 0, }; }
// Resolve content directory const contentDir = path.isAbsolute(config.contentDir) ? config.contentDir : path.join(configDir, config.contentDir);
const imagesDir = config.imagesDir ? path.isAbsolute(config.imagesDir) ? config.imagesDir : path.join(configDir, config.imagesDir) : undefined;
// Scan local posts const localPosts = await scanContentDirectory(contentDir, imagesDir, { frontmatterMapping: config.frontmatter, ignorePatterns: config.ignore, slugField: config.frontmatter?.slugField, removeIndexFromSlug: config.removeIndexFromSlug, stripDatePrefix: config.stripDatePrefix, });
// Build a map of path -> local post for matching const postsByPath = new Map<string, (typeof localPosts)[0]>(); for (const post of localPosts) { const postPath = resolvePostPath( post, config.pathPrefix, config.pathTemplate, ); postsByPath.set(postPath, post); }
// Load existing state const state = await loadState(configDir);
// Update the publication information for enhanced links. if (!state.publication) { state.publication = await syncPublication( agent, config.publicationUri, quiet, ); }
// Track changes let matchedCount = 0; let unmatchedCount = 0; let frontmatterUpdatesApplied = 0; const frontmatterUpdates: Array<{ filePath: string; atUri: string; relativeFilePath: string; }> = [];
if (!quiet) { log.message("\nMatching documents to local files:\n"); }
for (const doc of documents) { const docPath = doc.value.path; const localPost = postsByPath.get(docPath);
if (localPost) { matchedCount++; const relativeFilePath = path.relative(configDir, localPost.filePath);
if (!quiet) { log.message(` ✓ ${doc.value.title}`); log.message(` Path: ${docPath}`); log.message(` URI: ${doc.uri}`); log.message(` File: ${path.basename(localPost.filePath)}`); }
// Check if frontmatter needs updating const needsFrontmatterUpdate = updateFrontmatter && localPost.frontmatter.atUri !== doc.uri;
if (needsFrontmatterUpdate) { frontmatterUpdates.push({ filePath: localPost.filePath, atUri: doc.uri, relativeFilePath, }); if (!quiet) { log.message(` → Will update frontmatter`); } }
// Compute content hash — if we're updating frontmatter, hash the updated content // so the state matches what will be on disk after the update if (needsFrontmatterUpdate) { localPost.rawContent = updateFrontmatterWithAtUri( localPost.rawContent, doc.uri, ); } const contentHash = await getContentHash(localPost);
// Update state (preserve bskyPostRef from prior publishes) const existing = state.posts[relativeFilePath]; state.posts[relativeFilePath] = { contentHash, atUri: doc.uri, lastPublished: doc.value.publishedAt, slug: localPost.slug, ...(existing?.bskyPostRef ? { bskyPostRef: existing.bskyPostRef } : {}), }; } else { unmatchedCount++; if (!quiet) { log.message(` ✗ ${doc.value.title} (no matching local file)`); log.message(` Path: ${docPath}`); log.message(` URI: ${doc.uri}`); } } if (!quiet) { log.message(""); } }
// Summary (always show, even in quiet mode) if (!quiet) { log.message("---"); log.info(`Matched: ${matchedCount} documents`); if (unmatchedCount > 0) { log.warn( `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`, ); } }
if (dryRun) { if (!quiet) { log.info("\nDry run complete. No changes made."); } return { state, matchedCount, unmatchedCount, frontmatterUpdatesApplied: 0, }; }
// Save updated state await saveState(configDir, state);
// Update frontmatter files if (frontmatterUpdates.length > 0) { for (const { filePath, atUri } of frontmatterUpdates) { const content = await fs.readFile(filePath, "utf-8"); const updated = updateFrontmatterWithAtUri(content, atUri); await fs.writeFile(filePath, updated); if (!quiet) { log.message(` Updated: ${path.basename(filePath)}`); } } frontmatterUpdatesApplied = frontmatterUpdates.length; }
return { state, matchedCount, unmatchedCount, frontmatterUpdatesApplied };}
export async function syncPublication( agent: Awaited<ReturnType<typeof createAgent>>, publicationUri: string, quiet: boolean,): Promise<StrongRef> { const publicationRef = await getPublication(agent, publicationUri); if (!publicationRef) { if (!quiet) { log.error( `Publication ${publicationUri} not found. Update your publication record and try again.`, ); } process.exit(1); }
return { cid: publicationRef.cid, uri: publicationRef.uri, };}