A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing
at main 200 lines 5.3 kB view raw
1import * as fs from "node:fs/promises"; 2import * as path from "node:path"; 3import { log } from "@clack/prompts"; 4import { listDocuments, type createAgent } from "./atproto"; 5import { loadState, saveState } from "./config"; 6import { 7 scanContentDirectory, 8 getContentHash, 9 updateFrontmatterWithAtUri, 10 resolvePostPath, 11} from "./markdown"; 12import type { PublisherConfig, PublisherState } from "./types"; 13 14export interface SyncOptions { 15 updateFrontmatter?: boolean; 16 dryRun?: boolean; 17 quiet?: boolean; 18} 19 20export interface SyncResult { 21 state: PublisherState; 22 matchedCount: number; 23 unmatchedCount: number; 24 frontmatterUpdatesApplied: number; 25} 26 27/** 28 * Core sync logic: fetches documents from PDS and matches them to local files, 29 * updating state and optionally frontmatter. 30 * 31 * Used by both the `sync` command and auto-sync before `publish`. 32 */ 33export async function syncStateFromPDS( 34 agent: Awaited<ReturnType<typeof createAgent>>, 35 config: PublisherConfig, 36 configDir: string, 37 options: SyncOptions = {}, 38): Promise<SyncResult> { 39 const { updateFrontmatter = false, dryRun = false, quiet = false } = options; 40 41 // Fetch documents from PDS (filtered by publicationUri for multi-publication safety) 42 const documents = await listDocuments(agent, config.publicationUri); 43 44 if (documents.length === 0) { 45 if (!quiet) { 46 log.info("No documents found for this publication."); 47 } 48 return { 49 state: await loadState(configDir), 50 matchedCount: 0, 51 unmatchedCount: 0, 52 frontmatterUpdatesApplied: 0, 53 }; 54 } 55 56 // Resolve content directory 57 const contentDir = path.isAbsolute(config.contentDir) 58 ? config.contentDir 59 : path.join(configDir, config.contentDir); 60 61 // Scan local posts 62 const localPosts = await scanContentDirectory(contentDir, { 63 frontmatterMapping: config.frontmatter, 64 ignorePatterns: config.ignore, 65 slugField: config.frontmatter?.slugField, 66 removeIndexFromSlug: config.removeIndexFromSlug, 67 stripDatePrefix: config.stripDatePrefix, 68 }); 69 70 // Build a map of path -> local post for matching 71 const postsByPath = new Map<string, (typeof localPosts)[0]>(); 72 for (const post of localPosts) { 73 const postPath = resolvePostPath( 74 post, 75 config.pathPrefix, 76 config.pathTemplate, 77 ); 78 postsByPath.set(postPath, post); 79 } 80 81 // Load existing state 82 const state = await loadState(configDir); 83 84 // Track changes 85 let matchedCount = 0; 86 let unmatchedCount = 0; 87 let frontmatterUpdatesApplied = 0; 88 const frontmatterUpdates: Array<{ 89 filePath: string; 90 atUri: string; 91 relativeFilePath: string; 92 }> = []; 93 94 if (!quiet) { 95 log.message("\nMatching documents to local files:\n"); 96 } 97 98 for (const doc of documents) { 99 const docPath = doc.value.path; 100 const localPost = postsByPath.get(docPath); 101 102 if (localPost) { 103 matchedCount++; 104 const relativeFilePath = path.relative(configDir, localPost.filePath); 105 106 if (!quiet) { 107 log.message(`${doc.value.title}`); 108 log.message(` Path: ${docPath}`); 109 log.message(` URI: ${doc.uri}`); 110 log.message(` File: ${path.basename(localPost.filePath)}`); 111 } 112 113 // Check if frontmatter needs updating 114 const needsFrontmatterUpdate = 115 updateFrontmatter && localPost.frontmatter.atUri !== doc.uri; 116 117 if (needsFrontmatterUpdate) { 118 frontmatterUpdates.push({ 119 filePath: localPost.filePath, 120 atUri: doc.uri, 121 relativeFilePath, 122 }); 123 if (!quiet) { 124 log.message(` → Will update frontmatter`); 125 } 126 } 127 128 // Compute content hash — if we're updating frontmatter, hash the updated content 129 // so the state matches what will be on disk after the update 130 let contentHash: string; 131 if (needsFrontmatterUpdate) { 132 const updatedContent = updateFrontmatterWithAtUri( 133 localPost.rawContent, 134 doc.uri, 135 ); 136 contentHash = await getContentHash(updatedContent); 137 } else { 138 contentHash = await getContentHash(localPost.rawContent); 139 } 140 141 // Update state 142 state.posts[relativeFilePath] = { 143 contentHash, 144 atUri: doc.uri, 145 lastPublished: doc.value.publishedAt, 146 }; 147 } else { 148 unmatchedCount++; 149 if (!quiet) { 150 log.message(`${doc.value.title} (no matching local file)`); 151 log.message(` Path: ${docPath}`); 152 log.message(` URI: ${doc.uri}`); 153 } 154 } 155 if (!quiet) { 156 log.message(""); 157 } 158 } 159 160 // Summary (always show, even in quiet mode) 161 if (!quiet) { 162 log.message("---"); 163 log.info(`Matched: ${matchedCount} documents`); 164 if (unmatchedCount > 0) { 165 log.warn( 166 `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`, 167 ); 168 } 169 } 170 171 if (dryRun) { 172 if (!quiet) { 173 log.info("\nDry run complete. No changes made."); 174 } 175 return { 176 state, 177 matchedCount, 178 unmatchedCount, 179 frontmatterUpdatesApplied: 0, 180 }; 181 } 182 183 // Save updated state 184 await saveState(configDir, state); 185 186 // Update frontmatter files 187 if (frontmatterUpdates.length > 0) { 188 for (const { filePath, atUri } of frontmatterUpdates) { 189 const content = await fs.readFile(filePath, "utf-8"); 190 const updated = updateFrontmatterWithAtUri(content, atUri); 191 await fs.writeFile(filePath, updated); 192 if (!quiet) { 193 log.message(` Updated: ${path.basename(filePath)}`); 194 } 195 } 196 frontmatterUpdatesApplied = frontmatterUpdates.length; 197 } 198 199 return { state, matchedCount, unmatchedCount, frontmatterUpdatesApplied }; 200}