diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index 5e9225a..e940655 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -17,6 +17,8 @@ import { resolveImagePath, createBlueskyPost, addBskyPostRefToDocument, + listDocuments, + deleteDocument, } from "../lib/atproto"; import { scanContentDirectory, @@ -24,8 +26,111 @@ import { updateFrontmatterWithAtUri, resolvePostPath, } from "../lib/markdown"; -import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; +import type { BlogPost, BlobObject, StrongRef, PublisherConfig } from "../lib/types"; import { exitOnCancel } from "../lib/prompts"; +import { stripMarkdownForText } from "../lib/markdown"; + +interface DocumentPayload { + title: string; + description?: string; + path: string; + textContent?: string; + publishedAt: string; + canonicalUrl: string; + tags?: string[]; + coverImage?: BlobObject; +} + +function generateDocumentPayload( + post: BlogPost, + config: PublisherConfig, + coverImage?: BlobObject, +): DocumentPayload { + const postPath = resolvePostPath(post, config.pathPrefix, config.pathTemplate); + const publishDate = new Date(post.frontmatter.publishDate); + + let textContent: string | null = null; + if ( + config.publishContent && + config.textContentField && + post.rawFrontmatter?.[config.textContentField] + ) { + textContent = String(post.rawFrontmatter[config.textContentField]); + } else if (config.publishContent) { + textContent = stripMarkdownForText(post.content); + } + + return { + title: post.frontmatter.title, + description: post.frontmatter.description, + path: postPath, + textContent: textContent?.slice(0, 10000), + publishedAt: publishDate.toISOString(), + canonicalUrl: `${config.siteUrl}${postPath}`, + tags: post.frontmatter.tags, + coverImage, + }; +} + +interface FieldDiff { + field: string; + oldValue: string; + newValue: string; +} + +type DiffStatus = "created" | "updated" | "deleted"; + +interface PostDiff { + status: DiffStatus; + post?: BlogPost; + pdsDoc?: Record; + localPayload?: DocumentPayload; + diffs: FieldDiff[]; + localFileExists: boolean; +} + +function computeDiff( + localPayload: DocumentPayload, + pdsDoc: Record, +): FieldDiff[] { + const diffs: FieldDiff[] = []; + + const fields: Array = [ + "title", + "description", + "path", + "publishedAt", + "canonicalUrl", + ]; + + for (const field of fields) { + const localValue = localPayload[field]; + const pdsValue = pdsDoc[field]; + + const localStr = localValue !== undefined ? String(localValue) : ""; + const pdsStr = pdsValue !== undefined ? String(pdsValue) : ""; + + if (localStr !== pdsStr) { + if (field === "textContent") { + diffs.push({ + field, + oldValue: pdsValue ? `${(pdsValue as string).length} chars` : "(none)", + newValue: localValue + ? `${(localValue as string).length} chars` + : "(none)", + }); + } else { + diffs.push({ + field, + oldValue: pdsStr || "(none)", + newValue: localStr || "(none)", + }); + } + } + } + + return diffs; +} export const publishCommand = command({ name: "publish", @@ -147,8 +252,35 @@ export const publishCommand = command({ // Load state const state = await loadState(configDir); - // Scan for posts + // Create spinner for operations const s = spinner(); + + // Create agent for fetching PDS docs + const connectingTo = + credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; + s.start(`Connecting as ${connectingTo}...`); + let agent: Awaited> | undefined; + try { + agent = await createAgent(credentials); + s.stop(`Logged in as ${agent.did}`); + } catch (error) { + s.stop("Failed to login"); + log.error(`Failed to login: ${error}`); + process.exit(1); + } + + // Fetch current PDS documents + s.start("Fetching documents from PDS..."); + const pdsDocs = await listDocuments(agent, config.publicationUri); + s.stop(`Found ${pdsDocs.length} documents on PDS`); + + // Build a map of path -> PDS doc + const pdsDocsByPath = new Map(); + for (const doc of pdsDocs) { + pdsDocsByPath.set(doc.value.path, doc); + } + + // Scan for posts s.start("Scanning for posts..."); const posts = await scanContentDirectory(contentDir, { frontmatterMapping: config.frontmatter, @@ -159,14 +291,58 @@ export const publishCommand = command({ }); s.stop(`Found ${posts.length} posts`); - // Determine which posts need publishing + // Determine which posts need publishing with diff detection const postsToPublish: Array<{ post: BlogPost; action: "create" | "update"; reason: string; + diffs: FieldDiff[]; + }> = []; + const deletions: Array<{ + postStateKey: string; + atUri: string; + title: string; }> = []; const draftPosts: BlogPost[] = []; + // Get local file paths + const localPaths = new Set(); + for (const post of posts) { + const postPath = resolvePostPath( + post, + config.pathPrefix, + config.pathTemplate, + ); + localPaths.add(postPath); + } + + // Detect deletions (posts in state that are no longer local or have changed path) + for (const [relativeFilePath, postState] of Object.entries(state.posts)) { + const postStateKey = postState.pdsUri || postState.atUri; + if (!postStateKey) continue; + + // Check if local file still exists + const localPost = posts.find( + (p) => path.relative(configDir, p.filePath) === relativeFilePath, + ); + + if (!localPost) { + // Local file was deleted + const pdsDoc = pdsDocsByPath.get( + Object.values(state.orphaned || {}).find( + (o) => o.atUri === postState.pdsUri, + )?.path || "", + ); + if (postState.pdsUri) { + deletions.push({ + postStateKey: relativeFilePath, + atUri: postState.pdsUri, + title: pdsDoc?.value.title || "(unknown)", + }); + } + } + } + for (const post of posts) { // Skip draft posts if (post.frontmatter.draft) { @@ -177,26 +353,64 @@ export const publishCommand = command({ const contentHash = await getContentHash(post.rawContent); const relativeFilePath = path.relative(configDir, post.filePath); const postState = state.posts[relativeFilePath]; + const postPath = resolvePostPath(post, config.pathPrefix, config.pathTemplate); + + // Generate expected document payload + const localPayload = generateDocumentPayload(post, config); + + // Check if there's a matching PDS doc + const pdsDoc = pdsDocsByPath.get(postPath); if (force) { postsToPublish.push({ post, action: post.frontmatter.atUri ? "update" : "create", reason: "forced", + diffs: [], }); - } else if (!postState) { + } else if (!postState && !pdsDoc) { // New post postsToPublish.push({ post, action: "create", reason: "new post", + diffs: [], }); - } else if (postState.contentHash !== contentHash) { - // Changed post + } else if (pdsDoc && postState) { + // Exists on PDS - check for changes + const diffs = computeDiff( + localPayload, + pdsDoc.value as unknown as Record, + ); + if (diffs.length > 0) { + postsToPublish.push({ + post, + action: "update", + reason: "content changed", + diffs, + }); + } else if (postState.contentHash !== contentHash) { + // Hash changed but no field diff (might be formatting only) + postsToPublish.push({ + post, + action: "update", + reason: "content changed", + diffs: [ + { + field: "content", + oldValue: "(same fields, hash differs)", + newValue: "(content reformatted)", + }, + ], + }); + } + } else if (!pdsDoc && postState) { + // In state but not on PDS - needs to be created postsToPublish.push({ post, - action: post.frontmatter.atUri ? "update" : "create", - reason: "content changed", + action: "create", + reason: "missing on PDS", + diffs: [], }); } } @@ -207,12 +421,13 @@ export const publishCommand = command({ ); } - if (postsToPublish.length === 0) { + if (postsToPublish.length === 0 && deletions.length === 0) { log.success("All posts are up to date. Nothing to publish."); return; } - log.info(`\n${postsToPublish.length} posts to publish:\n`); + // Display changes + log.message("\nChanges to publish:\n"); // Bluesky posting configuration const blueskyEnabled = config.bluesky?.enabled ?? false; @@ -220,66 +435,82 @@ export const publishCommand = command({ const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays); - for (const { post, action, reason } of postsToPublish) { - const icon = action === "create" ? "+" : "~"; - const relativeFilePath = path.relative(configDir, post.filePath); - const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; + // New posts + const newPosts = postsToPublish.filter((p) => p.action === "create"); + for (const { post, reason } of newPosts) { + log.message(` + New: ${post.frontmatter.title} (${reason})`); + } - let bskyNote = ""; - if (blueskyEnabled) { - if (existingBskyPostRef) { - bskyNote = " [bsky: exists]"; + // Updated posts + const updatedPosts = postsToPublish.filter((p) => p.action === "update"); + for (const { post, reason, diffs } of updatedPosts) { + log.message(` ~ Updated: ${post.frontmatter.title}`); + for (const diff of diffs) { + if (diff.field === "content") { + log.message(` - ${diff.field}: ${diff.oldValue} → ${diff.newValue}`); + } else if (diff.field === "textContent") { + log.message(` - ${diff.field}: (${diff.oldValue} → ${diff.newValue})`); } else { + log.message( + ` - ${diff.field}: '${diff.oldValue}' → '${diff.newValue}'`, + ); + } + } + } + + // Deletions + for (const { title } of deletions) { + log.message(` - Deleted: ${title}`); + log.message(` This post will be unpublished from PDS.`); + } + + // Bluesky posts count + let bskyPostCount = 0; + if (blueskyEnabled) { + for (const { post, action } of postsToPublish) { + const relativeFilePath = path.relative(configDir, post.filePath); + const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; + if (!existingBskyPostRef) { const publishDate = new Date(post.frontmatter.publishDate); - if (publishDate < cutoffDate) { - bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`; - } else { - bskyNote = " [bsky: will post]"; + if (publishDate >= cutoffDate) { + bskyPostCount++; } } } - - let postUrl = ""; - if (verbose) { - const postPath = resolvePostPath( - post, - config.pathPrefix, - config.pathTemplate, - ); - postUrl = `\n ${config.siteUrl}${postPath}`; + if (bskyPostCount > 0) { + log.message(`\nBluesky posts: ${bskyPostCount} will be created`); } - log.message( - ` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}${postUrl}`, + } + + // Prompt for confirmation if there are deletions + if (deletions.length > 0 && !dryRun) { + log.message(""); + const confirmed = exitOnCancel( + await select({ + message: "There are posts to delete. Continue?", + options: [ + { value: "yes", label: "Yes, publish including deletions" }, + { value: "no", label: "No, cancel" }, + ], + }), ); + + if (confirmed === "no") { + log.info("Publish cancelled."); + process.exit(0); + } } if (dryRun) { - if (blueskyEnabled) { - log.info(`\nBluesky posting: enabled (max age: ${maxAgeDays} days)`); - } log.info("\nDry run complete. No changes made."); return; } - // Create agent - const connectingTo = - credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; - s.start(`Connecting as ${connectingTo}...`); - let agent: Awaited> | undefined; - try { - agent = await createAgent(credentials); - s.stop(`Logged in as ${agent.did}`); - } catch (error) { - s.stop("Failed to login"); - log.error(`Failed to login: ${error}`); - process.exit(1); - } - // Publish posts let publishedCount = 0; let updatedCount = 0; let errorCount = 0; - let bskyPostCount = 0; + let actualBskyPostCount = 0; for (const { post, action } of postsToPublish) { s.start(`Publishing: ${post.frontmatter.title}`); @@ -368,7 +599,7 @@ export const publishCommand = command({ // Update document record with bskyPostRef await addBskyPostRefToDocument(agent, atUri, bskyPostRef); log.info(` Created Bluesky post: ${bskyPostRef.uri}`); - bskyPostCount++; + actualBskyPostCount++; } catch (bskyError) { const errorMsg = bskyError instanceof Error @@ -385,6 +616,7 @@ export const publishCommand = command({ state.posts[relativeFilePath] = { contentHash, atUri, + pdsUri: atUri, lastPublished: new Date().toISOString(), slug: post.slug, bskyPostRef, @@ -398,6 +630,28 @@ export const publishCommand = command({ } } + // Handle deletions + let deletedCount = 0; + if (deletions.length > 0) { + for (const { postStateKey, atUri } of deletions) { + try { + s.start(`Deleting: ${atUri}`); + await deleteDocument(agent, atUri); + s.stop(`Deleted: ${atUri}`); + + // Remove from state + delete state.posts[postStateKey]; + deletedCount++; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + s.stop(`Error deleting "${atUri}"`); + log.error(` ${errorMessage}`); + errorCount++; + } + } + } + // Save state await saveState(configDir, state); @@ -405,8 +659,11 @@ export const publishCommand = command({ log.message("\n---"); log.info(`Published: ${publishedCount}`); log.info(`Updated: ${updatedCount}`); - if (bskyPostCount > 0) { - log.info(`Bluesky posts: ${bskyPostCount}`); + if (deletedCount > 0) { + log.info(`Deleted: ${deletedCount}`); + } + if (actualBskyPostCount > 0) { + log.info(`Bluesky posts: ${actualBskyPostCount}`); } if (errorCount > 0) { log.warn(`Errors: ${errorCount}`); diff --git a/packages/cli/src/commands/sync.ts b/packages/cli/src/commands/sync.ts index 55489bd..6338d98 100644 --- a/packages/cli/src/commands/sync.ts +++ b/packages/cli/src/commands/sync.ts @@ -32,8 +32,13 @@ export const syncCommand = command({ short: "n", description: "Preview what would be synced without making changes", }), + strict: flag({ + long: "strict", + description: + "Filter out any PDS docs without matching local files on first sync", + }), }, - handler: async ({ updateFrontmatter, dryRun }) => { + handler: async ({ updateFrontmatter, dryRun, strict }) => { // Load config const configPath = await findConfig(); if (!configPath) { @@ -162,12 +167,23 @@ export const syncCommand = command({ // Load existing state const state = await loadState(configDir); const originalPostCount = Object.keys(state.posts).length; + const isFirstSync = originalPostCount === 0; // Track changes let matchedCount = 0; - let unmatchedCount = 0; + let orphanedCount = 0; const frontmatterUpdates: Array<{ filePath: string; atUri: string }> = []; + // Initialize orphaned if needed + if (!state.orphaned) { + state.orphaned = {}; + } + + // Clear orphaned on first sync (unless strict is used) + if (isFirstSync && !strict) { + state.orphaned = {}; + } + log.message("\nMatching documents to local files:\n"); for (const doc of documents) { @@ -184,12 +200,28 @@ export const syncCommand = command({ // Update state (use relative path from config directory) const contentHash = await getContentHash(localPost.rawContent); const relativeFilePath = path.relative(configDir, localPost.filePath); + const existingState = state.posts[relativeFilePath]; state.posts[relativeFilePath] = { - contentHash, + contentHash: existingState?.contentHash ?? contentHash, atUri: doc.uri, + pdsUri: doc.uri, lastPublished: doc.value.publishedAt, + slug: existingState?.slug, + bskyPostRef: existingState?.bskyPostRef, }; + // Remove from orphaned if it was there + if (state.orphaned) { + for (const [orphanedPath, orphanedDoc] of Object.entries( + state.orphaned, + )) { + if (orphanedDoc.path === docPath) { + delete state.orphaned[orphanedPath]; + break; + } + } + } + // Check if frontmatter needs updating if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) { frontmatterUpdates.push({ @@ -199,10 +231,18 @@ export const syncCommand = command({ log.message(` → Will update frontmatter`); } } else { - unmatchedCount++; + orphanedCount++; log.message(` ✗ ${doc.value.title} (no matching local file)`); log.message(` Path: ${docPath}`); log.message(` URI: ${doc.uri}`); + + // Track orphaned document + const orphanedKey = doc.uri; + state.orphaned![orphanedKey] = { + atUri: doc.uri, + title: doc.value.title, + path: docPath, + }; } log.message(""); } @@ -210,10 +250,11 @@ export const syncCommand = command({ // Summary log.message("---"); log.info(`Matched: ${matchedCount} documents`); - if (unmatchedCount > 0) { - log.warn( - `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`, - ); + if (orphanedCount > 0) { + log.warn(`Orphaned: ${orphanedCount} documents (exist on PDS but not locally)`); + for (const [key, orphan] of Object.entries(state.orphaned ?? {})) { + log.message(` - ${orphan.path} (${orphan.atUri})`); + } } if (dryRun) { diff --git a/packages/cli/src/lib/atproto.ts b/packages/cli/src/lib/atproto.ts index 832a291..4910639 100644 --- a/packages/cli/src/lib/atproto.ts +++ b/packages/cli/src/lib/atproto.ts @@ -369,6 +369,19 @@ export async function updateDocument( }); } +export async function deleteDocument(agent: Agent, atUri: string): Promise { + const parsed = parseAtUri(atUri); + if (!parsed) { + throw new Error(`Invalid atUri format: ${atUri}`); + } + + await agent.com.atproto.repo.deleteRecord({ + repo: agent.did!, + collection: parsed.collection, + rkey: parsed.rkey, + }); +} + export function parseAtUri( atUri: string, ): { did: string; collection: string; rkey: string } | null { diff --git a/packages/cli/src/lib/types.ts b/packages/cli/src/lib/types.ts index 09dedfa..62c6c87 100644 --- a/packages/cli/src/lib/types.ts +++ b/packages/cli/src/lib/types.ts @@ -118,6 +118,22 @@ export interface BlobObject { export interface PublisherState { posts: Record; + orphaned?: Record; +} + +export interface OrphanedDocument { + atUri: string; + title: string; + path: string; +} + +export interface PostState { + contentHash: string; + atUri?: string; + pdsUri?: string; + lastPublished?: string; + slug?: string; + bskyPostRef?: StrongRef; } export interface PostState {