A CLI for publishing standard.site documents to ATProto

feat: sync with content from AT Proto to compare

+30 -25
+17 -2
packages/cli/src/commands/sync.ts
··· 13 13 import { 14 14 scanContentDirectory, 15 15 getContentHash, 16 + getTextContent, 16 17 updateFrontmatterWithAtUri, 17 18 resolvePostPath, 18 19 } from "../lib/markdown"; ··· 181 182 log.message(` URI: ${doc.uri}`); 182 183 log.message(` File: ${path.basename(localPost.filePath)}`); 183 184 184 - // Update state (use relative path from config directory) 185 - const contentHash = await getContentHash(localPost.rawContent); 185 + // Compare local text content with PDS text content to detect changes. 186 + // We must avoid storing the local rawContent hash blindly, because 187 + // that would make publish think nothing changed even when content 188 + // was modified since the last publish. 189 + const localTextContent = getTextContent( 190 + localPost, 191 + config.textContentField, 192 + ); 193 + const contentMatchesPDS = 194 + localTextContent.slice(0, 10000) === doc.value.textContent; 195 + 196 + // If local content matches PDS, store the local hash (up to date). 197 + // If it differs, store empty hash so publish detects the change. 198 + const contentHash = contentMatchesPDS 199 + ? await getContentHash(localPost.rawContent) 200 + : ""; 186 201 const relativeFilePath = path.relative(configDir, localPost.filePath); 187 202 state.posts[relativeFilePath] = { 188 203 contentHash,
+3 -23
packages/cli/src/lib/atproto.ts
··· 2 2 import * as mimeTypes from "mime-types"; 3 3 import * as fs from "node:fs/promises"; 4 4 import * as path from "node:path"; 5 - import { stripMarkdownForText, resolvePostPath } from "./markdown"; 5 + import { getTextContent, resolvePostPath } from "./markdown"; 6 6 import { getOAuthClient } from "./oauth-client"; 7 7 import type { 8 8 BlobObject, ··· 251 251 config.pathTemplate, 252 252 ); 253 253 const publishDate = new Date(post.frontmatter.publishDate); 254 - 255 - // Determine textContent: use configured field from frontmatter, or fallback to markdown body 256 - let textContent: string; 257 - if ( 258 - config.textContentField && 259 - post.rawFrontmatter?.[config.textContentField] 260 - ) { 261 - textContent = String(post.rawFrontmatter[config.textContentField]); 262 - } else { 263 - textContent = stripMarkdownForText(post.content); 264 - } 254 + const textContent = getTextContent(post, config.textContentField); 265 255 266 256 const record: Record<string, unknown> = { 267 257 $type: "site.standard.document", ··· 316 306 config.pathTemplate, 317 307 ); 318 308 const publishDate = new Date(post.frontmatter.publishDate); 319 - 320 - // Determine textContent: use configured field from frontmatter, or fallback to markdown body 321 - let textContent: string; 322 - if ( 323 - config.textContentField && 324 - post.rawFrontmatter?.[config.textContentField] 325 - ) { 326 - textContent = String(post.rawFrontmatter[config.textContentField]); 327 - } else { 328 - textContent = stripMarkdownForText(post.content); 329 - } 309 + const textContent = getTextContent(post, config.textContentField); 330 310 331 311 const record: Record<string, unknown> = { 332 312 $type: "site.standard.document",
+10
packages/cli/src/lib/markdown.ts
··· 435 435 .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines 436 436 .trim(); 437 437 } 438 + 439 + export function getTextContent( 440 + post: { content: string; rawFrontmatter?: Record<string, unknown> }, 441 + textContentField?: string, 442 + ): string { 443 + if (textContentField && post.rawFrontmatter?.[textContentField]) { 444 + return String(post.rawFrontmatter[textContentField]); 445 + } 446 + return stripMarkdownForText(post.content); 447 + }