A CLI for publishing standard.site documents to ATProto
sequoia.pub
standard
site
lexicon
cli
publishing
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}