A CLI for publishing standard.site documents to ATProto

fix: detect frontmatter-only changes in sync to prevent stale note records

Extend the PDS comparison in sync to cover title, description, tags, and
note-specific fields (theme, fontSize, fontFamily) in addition to body
text. Previously, changing only frontmatter and running sync would store
the current file hash, causing publish to skip the update.

The comparison logic is extracted into a matchesPDS helper for clarity.

+69 -10
+69 -10
packages/cli/src/commands/sync.ts
··· 9 getCredentials, 10 } from "../lib/credentials"; 11 import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; 12 import { createAgent, listDocuments } from "../lib/atproto"; 13 import { 14 scanContentDirectory, 15 getContentHash, ··· 18 resolvePostPath, 19 } from "../lib/markdown"; 20 import { exitOnCancel } from "../lib/prompts"; 21 22 export const syncCommand = command({ 23 name: "sync", ··· 182 log.message(` URI: ${doc.uri}`); 183 log.message(` File: ${path.basename(localPost.filePath)}`); 184 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 : "";
··· 9 getCredentials, 10 } from "../lib/credentials"; 11 import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; 12 + import type { Agent } from "@atproto/api"; 13 import { createAgent, listDocuments } from "../lib/atproto"; 14 + import type { ListDocumentsResult } from "../lib/atproto"; 15 + import type { BlogPost } from "../lib/types"; 16 import { 17 scanContentDirectory, 18 getContentHash, ··· 21 resolvePostPath, 22 } from "../lib/markdown"; 23 import { exitOnCancel } from "../lib/prompts"; 24 + 25 + async function matchesPDS( 26 + localPost: BlogPost, 27 + doc: ListDocumentsResult, 28 + agent: Agent, 29 + textContentField?: string, 30 + ): Promise<boolean> { 31 + // Compare body text content 32 + const localTextContent = getTextContent(localPost, textContentField); 33 + if (localTextContent.slice(0, 10000) !== doc.value.textContent) { 34 + return false; 35 + } 36 + 37 + // Compare document fields: title, description, tags 38 + const trimmedContent = localPost.content.trim(); 39 + const titleMatch = trimmedContent.match(/^# (.+)$/m); 40 + const localTitle = titleMatch ? titleMatch[1] : localPost.frontmatter.title; 41 + if (localTitle !== doc.value.title) return false; 42 + 43 + const localDescription = localPost.frontmatter.description || undefined; 44 + if (localDescription !== doc.value.description) return false; 45 + 46 + const localTags = 47 + localPost.frontmatter.tags && localPost.frontmatter.tags.length > 0 48 + ? localPost.frontmatter.tags 49 + : undefined; 50 + if (JSON.stringify(localTags) !== JSON.stringify(doc.value.tags)) { 51 + return false; 52 + } 53 + 54 + // Compare note-specific fields: theme, fontSize, fontFamily. 55 + // Fetch the space.remanso.note record to check these fields. 56 + const noteUriMatch = doc.uri.match(/^at:\/\/([^/]+)\/[^/]+\/(.+)$/); 57 + if (noteUriMatch) { 58 + const repo = noteUriMatch[1]!; 59 + const rkey = noteUriMatch[2]!; 60 + try { 61 + const noteResponse = await agent.com.atproto.repo.getRecord({ 62 + repo, 63 + collection: "space.remanso.note", 64 + rkey, 65 + }); 66 + const noteValue = noteResponse.data.value as Record<string, unknown>; 67 + if ( 68 + (localPost.frontmatter.theme || undefined) !== 69 + (noteValue.theme as string | undefined) || 70 + (localPost.frontmatter.fontSize || undefined) !== 71 + (noteValue.fontSize as number | undefined) || 72 + (localPost.frontmatter.fontFamily || undefined) !== 73 + (noteValue.fontFamily as string | undefined) 74 + ) { 75 + return false; 76 + } 77 + } catch { 78 + // Note record doesn't exist — treat as matching to avoid 79 + // forcing a re-publish of posts never published as notes. 80 + } 81 + } 82 + 83 + return true; 84 + } 85 86 export const syncCommand = command({ 87 name: "sync", ··· 246 log.message(` URI: ${doc.uri}`); 247 log.message(` File: ${path.basename(localPost.filePath)}`); 248 249 + // If local content matches PDS, store the local hash (up to date). 250 + // If it differs, store empty hash so publish detects the change. 251 + const contentMatchesPDS = await matchesPDS( 252 localPost, 253 + doc, 254 + agent, 255 config.textContentField, 256 ); 257 const contentHash = contentMatchesPDS 258 ? await getContentHash(localPost.rawContent) 259 : "";