From bc99270d594173732d9fbb58a7110c284e1fa72f Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Mon, 16 Feb 2026 21:24:49 +0100 Subject: [PATCH] feat: add deletion --- packages/cli/src/commands/init.ts | 10 +- packages/cli/src/commands/publish.ts | 92 +++- packages/cli/src/extensions/remanso.test.ts | 41 +- packages/cli/src/extensions/remanso.ts | 488 ++++++++++---------- packages/cli/src/lib/atproto.ts | 26 +- packages/cli/src/lib/markdown.test.ts | 33 +- packages/cli/src/lib/markdown.ts | 55 ++- packages/cli/src/lib/types.ts | 6 +- packages/cli/src/lib/utils.ts | 10 + 9 files changed, 428 insertions(+), 333 deletions(-) create mode 100644 packages/cli/src/lib/utils.ts diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 4b19b4e..283f9f8 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -17,15 +17,7 @@ import { loadCredentials, listAllCredentials } from "../lib/credentials"; import { createAgent, createPublication } from "../lib/atproto"; import { selectCredential } from "../lib/credential-select"; import type { FrontmatterMapping, BlueskyConfig } from "../lib/types"; - -async function fileExists(filePath: string): Promise { - try { - await fs.access(filePath); - return true; - } catch { - return false; - } -} +import { fileExists } from "../lib/utils"; const onCancel = () => { outro("Setup cancelled"); diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index f0168b8..9d66dd0 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -17,6 +17,7 @@ import { resolveImagePath, createBlueskyPost, addBskyPostRefToDocument, + deleteRecord, } from "../lib/atproto"; import { scanContentDirectory, @@ -26,7 +27,14 @@ import { } from "../lib/markdown"; import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; import { exitOnCancel } from "../lib/prompts"; -import { createNote, updateNote, findPostsWithStaleLinks, type NoteOptions } from "../extensions/remanso" +import { + createNote, + updateNote, + deleteNote, + findPostsWithStaleLinks, + type NoteOptions, +} from "../extensions/remanso"; +import { fileExists } from "../lib/utils"; export const publishCommand = command({ name: "publish", @@ -160,6 +168,48 @@ export const publishCommand = command({ }); s.stop(`Found ${posts.length} posts`); + // Detect deleted files: state entries whose local files no longer exist + const scannedPaths = new Set( + posts.map((p) => path.relative(configDir, p.filePath)), + ); + const deletedEntries: Array<{ filePath: string; atUri: string }> = []; + for (const [filePath, postState] of Object.entries(state.posts)) { + if (!scannedPaths.has(filePath) && postState.atUri) { + // Check if the file truly doesn't exist (not just excluded by ignore patterns) + const absolutePath = path.resolve(configDir, filePath); + + // If file exists but wasn't scanned (e.g. draft or ignored) — skip + if (!(await fileExists(absolutePath))) { + deletedEntries.push({ filePath, atUri: postState.atUri }); + } + } + } + + // Shared agent — created lazily, reused across deletion and publishing + let agent: Awaited> | undefined; + async function getAgent(): Promise< + Awaited> + > { + if (agent) return agent; + + if (!credentials) { + throw new Error("credentials not found"); + } + + const connectingTo = + credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; + s.start(`Connecting as ${connectingTo}...`); + try { + agent = await createAgent(credentials); + s.stop(`Logged in as ${agent.did}`); + return agent; + } catch (error) { + s.stop("Failed to login"); + log.error(`Failed to login: ${error}`); + process.exit(1); + } + } + // Determine which posts need publishing const postsToPublish: Array<{ post: BlogPost; @@ -261,18 +311,11 @@ export const publishCommand = command({ 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); + // Ensure agent is connected + await getAgent(); + + if (!agent) { + throw new Error("agent is not connected"); } // Publish posts @@ -295,16 +338,16 @@ export const publishCommand = command({ }> = []; for (const { post, action } of postsToPublish) { - const trimmedContent = post.content.trim() - const titleMatch = trimmedContent.match(/^# (.+)$/m) - const title = titleMatch ? titleMatch[1] : post.frontmatter.title - s.start(`Publishing: ${title}`); - - // Init publish date - if (!post.frontmatter.publishDate) { - const [publishDate] = new Date().toISOString().split("T") - post.frontmatter.publishDate = publishDate! - } + const trimmedContent = post.content.trim(); + const titleMatch = trimmedContent.match(/^# (.+)$/m); + const title = titleMatch ? titleMatch[1] : post.frontmatter.title; + s.start(`Publishing: ${title}`); + + // Init publish date + if (!post.frontmatter.publishDate) { + const [publishDate] = new Date().toISOString().split("T"); + post.frontmatter.publishDate = publishDate!; + } try { // Handle cover image upload @@ -474,6 +517,9 @@ export const publishCommand = command({ // Summary log.message("\n---"); + if (deletedEntries.length > 0) { + log.info(`Deleted: ${deletedEntries.length}`); + } log.info(`Published: ${publishedCount}`); log.info(`Updated: ${updatedCount}`); if (bskyPostCount > 0) { diff --git a/packages/cli/src/extensions/remanso.test.ts b/packages/cli/src/extensions/remanso.test.ts index 44628a6..9627c17 100644 --- a/packages/cli/src/extensions/remanso.test.ts +++ b/packages/cli/src/extensions/remanso.test.ts @@ -31,10 +31,7 @@ describe("resolveInternalLinks", () => { test("rewrites published link to remanso atUri", () => { const posts = [ - makePost( - "other-post", - "at://did:plc:abc/site.standard.document/abc123", - ), + makePost("other-post", "at://did:plc:abc/site.standard.document/abc123"), ]; const content = "See [my post](./other-post)"; expect(resolveInternalLinks(content, posts)).toBe( @@ -60,10 +57,7 @@ describe("resolveInternalLinks", () => { test("handles .md extension in link path", () => { const posts = [ - makePost( - "guide", - "at://did:plc:abc/site.standard.document/guide123", - ), + makePost("guide", "at://did:plc:abc/site.standard.document/guide123"), ]; const content = "Read the [guide](guide.md)"; expect(resolveInternalLinks(content, posts)).toBe( @@ -73,10 +67,7 @@ describe("resolveInternalLinks", () => { test("handles nested slug matching", () => { const posts = [ - makePost( - "blog/my-post", - "at://did:plc:abc/site.standard.document/rkey1", - ), + makePost("blog/my-post", "at://did:plc:abc/site.standard.document/rkey1"), ]; const content = "See [post](my-post)"; expect(resolveInternalLinks(content, posts)).toBe( @@ -86,10 +77,7 @@ describe("resolveInternalLinks", () => { test("does not rewrite image embeds", () => { const posts = [ - makePost( - "photo", - "at://did:plc:abc/site.standard.document/photo1", - ), + makePost("photo", "at://did:plc:abc/site.standard.document/photo1"), ]; const content = "![alt](photo)"; expect(resolveInternalLinks(content, posts)).toBe("![alt](photo)"); @@ -97,10 +85,7 @@ describe("resolveInternalLinks", () => { test("does not rewrite @mention links", () => { const posts = [ - makePost( - "mention", - "at://did:plc:abc/site.standard.document/m1", - ), + makePost("mention", "at://did:plc:abc/site.standard.document/m1"), ]; const content = "@[name](mention)"; expect(resolveInternalLinks(content, posts)).toBe("@[name](mention)"); @@ -108,10 +93,7 @@ describe("resolveInternalLinks", () => { test("handles multiple links in same content", () => { const posts = [ - makePost( - "published", - "at://did:plc:abc/site.standard.document/pub1", - ), + makePost("published", "at://did:plc:abc/site.standard.document/pub1"), makePost("unpublished"), ]; const content = @@ -123,10 +105,7 @@ describe("resolveInternalLinks", () => { test("handles index path normalization", () => { const posts = [ - makePost( - "docs", - "at://did:plc:abc/site.standard.document/docs1", - ), + makePost("docs", "at://did:plc:abc/site.standard.document/docs1"), ]; const content = "See [docs](./docs/index)"; expect(resolveInternalLinks(content, posts)).toBe( @@ -218,11 +197,7 @@ describe("findPostsWithStaleLinks", () => { content: "Check out [post](my-post)", }), ]; - const result = findPostsWithStaleLinks( - posts, - ["blog/my-post"], - new Set(), - ); + const result = findPostsWithStaleLinks(posts, ["blog/my-post"], new Set()); expect(result).toHaveLength(1); }); diff --git a/packages/cli/src/extensions/remanso.ts b/packages/cli/src/extensions/remanso.ts index 12b5abb..0d7a9fa 100644 --- a/packages/cli/src/extensions/remanso.ts +++ b/packages/cli/src/extensions/remanso.ts @@ -1,290 +1,308 @@ -import type { Agent } from "@atproto/api" -import * as fs from "node:fs/promises" -import * as path from "node:path" -import mimeTypes from "mime-types" -import type { BlogPost, BlobObject } from "../lib/types" +import type { Agent } from "@atproto/api"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import mimeTypes from "mime-types"; +import type { BlogPost, BlobObject } from "../lib/types"; -const LEXICON = "space.remanso.note" -const MAX_CONTENT = 10000 +const LEXICON = "space.remanso.note"; +const MAX_CONTENT = 10000; interface ImageRecord { - image: BlobObject - alt?: string + image: BlobObject; + alt?: string; } export interface NoteOptions { - contentDir: string - imagesDir?: string - allPosts: BlogPost[] + contentDir: string; + imagesDir?: string; + allPosts: BlogPost[]; } async function fileExists(filePath: string): Promise { - try { - await fs.access(filePath) - return true - } catch { - return false - } + try { + await fs.access(filePath); + return true; + } catch { + return false; + } } export function isLocalPath(url: string): boolean { - return ( - !url.startsWith("http://") && - !url.startsWith("https://") && - !url.startsWith("#") && - !url.startsWith("mailto:") - ) + return ( + !url.startsWith("http://") && + !url.startsWith("https://") && + !url.startsWith("#") && + !url.startsWith("mailto:") + ); } function getImageCandidates( - src: string, - postFilePath: string, - contentDir: string, - imagesDir?: string, + src: string, + postFilePath: string, + contentDir: string, + imagesDir?: string, ): string[] { - const candidates = [ - path.resolve(path.dirname(postFilePath), src), - path.resolve(contentDir, src), - ] - if (imagesDir) { - candidates.push(path.resolve(imagesDir, src)) - const baseName = path.basename(imagesDir) - const idx = src.indexOf(baseName) - if (idx !== -1) { - const after = src.substring(idx + baseName.length).replace(/^[/\\]/, "") - candidates.push(path.resolve(imagesDir, after)) - } - } - return candidates + const candidates = [ + path.resolve(path.dirname(postFilePath), src), + path.resolve(contentDir, src), + ]; + if (imagesDir) { + candidates.push(path.resolve(imagesDir, src)); + const baseName = path.basename(imagesDir); + const idx = src.indexOf(baseName); + if (idx !== -1) { + const after = src.substring(idx + baseName.length).replace(/^[/\\]/, ""); + candidates.push(path.resolve(imagesDir, after)); + } + } + return candidates; } async function uploadBlob( - agent: Agent, - candidates: string[], + agent: Agent, + candidates: string[], ): Promise { - for (const filePath of candidates) { - if (!(await fileExists(filePath))) continue - - try { - const imageBuffer = await fs.readFile(filePath) - if (imageBuffer.byteLength === 0) continue - const mimeType = mimeTypes.lookup(filePath) || "application/octet-stream" - const response = await agent.com.atproto.repo.uploadBlob( - new Uint8Array(imageBuffer), - { encoding: mimeType }, - ) - return { - $type: "blob", - ref: { $link: response.data.blob.ref.toString() }, - mimeType, - size: imageBuffer.byteLength, - } - } catch {} - } - return undefined + for (const filePath of candidates) { + if (!(await fileExists(filePath))) continue; + + try { + const imageBuffer = await fs.readFile(filePath); + if (imageBuffer.byteLength === 0) continue; + const mimeType = mimeTypes.lookup(filePath) || "application/octet-stream"; + const response = await agent.com.atproto.repo.uploadBlob( + new Uint8Array(imageBuffer), + { encoding: mimeType }, + ); + return { + $type: "blob", + ref: { $link: response.data.blob.ref.toString() }, + mimeType, + size: imageBuffer.byteLength, + }; + } catch {} + } + return undefined; } async function processImages( - agent: Agent, - content: string, - postFilePath: string, - contentDir: string, - imagesDir?: string, + agent: Agent, + content: string, + postFilePath: string, + contentDir: string, + imagesDir?: string, ): Promise<{ content: string; images: ImageRecord[] }> { - const images: ImageRecord[] = [] - const uploadCache = new Map() - let processedContent = content - - const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g - const matches = [...content.matchAll(imageRegex)] - - for (const match of matches) { - const fullMatch = match[0] - const alt = match[1] ?? "" - const src = match[2]! - if (!isLocalPath(src)) continue - - let blob = uploadCache.get(src) - if (!blob) { - const candidates = getImageCandidates(src, postFilePath, contentDir, imagesDir) - blob = await uploadBlob(agent, candidates) - if (!blob) continue - uploadCache.set(src, blob) - } - - images.push({ image: blob, alt: alt || undefined }) - processedContent = processedContent.replace( - fullMatch, - `![${alt}](${blob.ref.$link})`, - ) - } - - return { content: processedContent, images } + const images: ImageRecord[] = []; + const uploadCache = new Map(); + let processedContent = content; + + const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; + const matches = [...content.matchAll(imageRegex)]; + + for (const match of matches) { + const fullMatch = match[0]; + const alt = match[1] ?? ""; + const src = match[2]!; + if (!isLocalPath(src)) continue; + + let blob = uploadCache.get(src); + if (!blob) { + const candidates = getImageCandidates( + src, + postFilePath, + contentDir, + imagesDir, + ); + blob = await uploadBlob(agent, candidates); + if (!blob) continue; + uploadCache.set(src, blob); + } + + images.push({ image: blob, alt: alt || undefined }); + processedContent = processedContent.replace( + fullMatch, + `![${alt}](${blob.ref.$link})`, + ); + } + + return { content: processedContent, images }; } export function resolveInternalLinks( - content: string, - allPosts: BlogPost[], + content: string, + allPosts: BlogPost[], ): string { - const linkRegex = /(? { - if (!isLocalPath(url)) return fullMatch - - // Normalize to a slug-like string for comparison - const normalized = url - .replace(/^\.?\/?/, "") - .replace(/\/?$/, "") - .replace(/\.mdx?$/, "") - .replace(/\/index$/, "") - - const matchedPost = allPosts.find((p) => { - if (!p.frontmatter.atUri) return false - return ( - p.slug === normalized || - p.slug.endsWith(`/${normalized}`) || - normalized.endsWith(`/${p.slug}`) - ) - }) - - if (!matchedPost) return text - - const noteUri = matchedPost.frontmatter.atUri!.replace( - /\/[^/]+\/([^/]+)$/, - `/space.remanso.note/$1`, - ) - return `[${text}](${noteUri})` - }) + const linkRegex = /(? { + if (!isLocalPath(url)) return fullMatch; + + // Normalize to a slug-like string for comparison + const normalized = url + .replace(/^\.?\/?/, "") + .replace(/\/?$/, "") + .replace(/\.mdx?$/, "") + .replace(/\/index$/, ""); + + const matchedPost = allPosts.find((p) => { + if (!p.frontmatter.atUri) return false; + return ( + p.slug === normalized || + p.slug.endsWith(`/${normalized}`) || + normalized.endsWith(`/${p.slug}`) + ); + }); + + if (!matchedPost) return text; + + const noteUri = matchedPost.frontmatter.atUri!.replace( + /\/[^/]+\/([^/]+)$/, + `/space.remanso.note/$1`, + ); + return `[${text}](${noteUri})`; + }); } async function processNoteContent( - agent: Agent, - post: BlogPost, - options: NoteOptions, + agent: Agent, + post: BlogPost, + options: NoteOptions, ): Promise<{ content: string; images: ImageRecord[] }> { - let content = post.content.trim() + let content = post.content.trim(); - content = resolveInternalLinks(content, options.allPosts) + content = resolveInternalLinks(content, options.allPosts); - const result = await processImages( - agent, content, post.filePath, options.contentDir, options.imagesDir, - ) + const result = await processImages( + agent, + content, + post.filePath, + options.contentDir, + options.imagesDir, + ); - return result + return result; } function parseRkey(atUri: string): string { - const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/) - if (!uriMatch) { - throw new Error(`Invalid atUri format: ${atUri}`) - } - return uriMatch[3]! + const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); + if (!uriMatch) { + throw new Error(`Invalid atUri format: ${atUri}`); + } + return uriMatch[3]!; } async function buildNoteRecord( - agent: Agent, - post: BlogPost, - options: NoteOptions, + agent: Agent, + post: BlogPost, + options: NoteOptions, ): Promise> { - const publishDate = new Date(post.frontmatter.publishDate).toISOString() - const trimmedContent = post.content.trim() - const titleMatch = trimmedContent.match(/^# (.+)$/m) - const title = titleMatch ? titleMatch[1] : post.frontmatter.title - - const { content, images } = await processNoteContent(agent, post, options) - - const record: Record = { - $type: LEXICON, - title, - content: content.slice(0, MAX_CONTENT), - createdAt: publishDate, - publishedAt: publishDate, - } - - if (images.length > 0) { - record.images = images - } - - if (post.frontmatter.theme) { - record.theme = post.frontmatter.theme - } - - if (post.frontmatter.fontSize) { - record.fontSize = post.frontmatter.fontSize - } - - if (post.frontmatter.fontFamily) { - record.fontFamily = post.frontmatter.fontFamily - } - - return record + const publishDate = new Date(post.frontmatter.publishDate).toISOString(); + const trimmedContent = post.content.trim(); + const titleMatch = trimmedContent.match(/^# (.+)$/m); + const title = titleMatch ? titleMatch[1] : post.frontmatter.title; + + const { content, images } = await processNoteContent(agent, post, options); + + const record: Record = { + $type: LEXICON, + title, + content: content.slice(0, MAX_CONTENT), + createdAt: publishDate, + publishedAt: publishDate, + }; + + if (images.length > 0) { + record.images = images; + } + + if (post.frontmatter.theme) { + record.theme = post.frontmatter.theme; + } + + if (post.frontmatter.fontSize) { + record.fontSize = post.frontmatter.fontSize; + } + + if (post.frontmatter.fontFamily) { + record.fontFamily = post.frontmatter.fontFamily; + } + + return record; +} + +export async function deleteNote(agent: Agent, atUri: string): Promise { + const rkey = parseRkey(atUri); + await agent.com.atproto.repo.deleteRecord({ + repo: agent.did!, + collection: LEXICON, + rkey, + }); } export async function createNote( - agent: Agent, - post: BlogPost, - atUri: string, - options: NoteOptions, + agent: Agent, + post: BlogPost, + atUri: string, + options: NoteOptions, ): Promise { - const rkey = parseRkey(atUri) - const record = await buildNoteRecord(agent, post, options) - - await agent.com.atproto.repo.createRecord({ - repo: agent.did!, - collection: LEXICON, - record, - rkey, - validate: false, - }) + const rkey = parseRkey(atUri); + const record = await buildNoteRecord(agent, post, options); + + await agent.com.atproto.repo.createRecord({ + repo: agent.did!, + collection: LEXICON, + record, + rkey, + validate: false, + }); } export async function updateNote( - agent: Agent, - post: BlogPost, - atUri: string, - options: NoteOptions, + agent: Agent, + post: BlogPost, + atUri: string, + options: NoteOptions, ): Promise { - const rkey = parseRkey(atUri) - const record = await buildNoteRecord(agent, post, options) - - await agent.com.atproto.repo.putRecord({ - repo: agent.did!, - collection: LEXICON, - rkey: rkey!, - record, - validate: false, - }) + const rkey = parseRkey(atUri); + const record = await buildNoteRecord(agent, post, options); + + await agent.com.atproto.repo.putRecord({ + repo: agent.did!, + collection: LEXICON, + rkey: rkey!, + record, + validate: false, + }); } export function findPostsWithStaleLinks( - allPosts: BlogPost[], - newSlugs: string[], - excludeFilePaths: Set, + allPosts: BlogPost[], + newSlugs: string[], + excludeFilePaths: Set, ): BlogPost[] { - const linkRegex = /(? { - if (excludeFilePaths.has(post.filePath)) return false - if (!post.frontmatter.atUri) return false - if (post.frontmatter.draft) return false - - const matches = [...post.content.matchAll(linkRegex)] - return matches.some((match) => { - const url = match[2]! - if (!isLocalPath(url)) return false - - const normalized = url - .replace(/^\.?\/?/, "") - .replace(/\/?$/, "") - .replace(/\.mdx?$/, "") - .replace(/\/index$/, "") - - return newSlugs.some( - (slug) => - slug === normalized || - slug.endsWith(`/${normalized}`) || - normalized.endsWith(`/${slug}`), - ) - }) - }) + const linkRegex = /(? { + if (excludeFilePaths.has(post.filePath)) return false; + if (!post.frontmatter.atUri) return false; + if (post.frontmatter.draft) return false; + + const matches = [...post.content.matchAll(linkRegex)]; + return matches.some((match) => { + const url = match[2]!; + if (!isLocalPath(url)) return false; + + const normalized = url + .replace(/^\.?\/?/, "") + .replace(/\/?$/, "") + .replace(/\.mdx?$/, "") + .replace(/\/index$/, ""); + + return newSlugs.some( + (slug) => + slug === normalized || + slug.endsWith(`/${normalized}`) || + normalized.endsWith(`/${slug}`), + ); + }); + }); } diff --git a/packages/cli/src/lib/atproto.ts b/packages/cli/src/lib/atproto.ts index 499d995..da8580c 100644 --- a/packages/cli/src/lib/atproto.ts +++ b/packages/cli/src/lib/atproto.ts @@ -251,10 +251,10 @@ export async function createDocument( config.pathTemplate, ); const publishDate = new Date(post.frontmatter.publishDate); - const trimmedContent = post.content.trim() + const trimmedContent = post.content.trim(); const textContent = getTextContent(post, config.textContentField); - const titleMatch = trimmedContent.match(/^# (.+)$/m) - const title = titleMatch ? titleMatch[1] : post.frontmatter.title + const titleMatch = trimmedContent.match(/^# (.+)$/m); + const title = titleMatch ? titleMatch[1] : post.frontmatter.title; const record: Record = { $type: "site.standard.document", @@ -309,10 +309,10 @@ export async function updateDocument( config.pathTemplate, ); const publishDate = new Date(post.frontmatter.publishDate); - const trimmedContent = post.content.trim() + const trimmedContent = post.content.trim(); const textContent = getTextContent(post, config.textContentField); - const titleMatch = trimmedContent.match(/^# (.+)$/m) - const title = titleMatch ? titleMatch[1] : post.frontmatter.title + const titleMatch = trimmedContent.match(/^# (.+)$/m); + const title = titleMatch ? titleMatch[1] : post.frontmatter.title; const record: Record = { $type: "site.standard.document", @@ -390,9 +390,9 @@ export async function listDocuments( limit: 100, cursor, }); - + for (const record of response.data.records) { - if (!isDocumentRecord(record.value)) { + if (!isDocumentRecord(record.value)) { continue; } @@ -550,6 +550,16 @@ export async function updatePublication( }); } +export async function deleteRecord(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: parsed.did, + collection: parsed.collection, + rkey: parsed.rkey, + }); +} + // --- Bluesky Post Creation --- export interface CreateBlueskyPostOptions { diff --git a/packages/cli/src/lib/markdown.test.ts b/packages/cli/src/lib/markdown.test.ts index dd38587..394cd67 100644 --- a/packages/cli/src/lib/markdown.test.ts +++ b/packages/cli/src/lib/markdown.test.ts @@ -239,7 +239,11 @@ describe("getSlugFromOptions", () => { }); test("falls back to filepath when slugField not found in frontmatter", () => { - const slug = getSlugFromOptions("blog/my-post.md", {}, { slugField: "slug" }); + const slug = getSlugFromOptions( + "blog/my-post.md", + {}, + { slugField: "slug" }, + ); expect(slug).toBe("blog/my-post"); }); @@ -320,7 +324,10 @@ title: My Post --- Body`; - const result = updateFrontmatterWithAtUri(content, "at://did:plc:abc/post/123"); + const result = updateFrontmatterWithAtUri( + content, + "at://did:plc:abc/post/123", + ); expect(result).toContain('atUri: "at://did:plc:abc/post/123"'); expect(result).toContain("title: My Post"); }); @@ -331,14 +338,20 @@ title = My Post +++ Body`; - const result = updateFrontmatterWithAtUri(content, "at://did:plc:abc/post/123"); + const result = updateFrontmatterWithAtUri( + content, + "at://did:plc:abc/post/123", + ); expect(result).toContain('atUri = "at://did:plc:abc/post/123"'); }); test("creates frontmatter with atUri when none exists", () => { const content = "# My Post\n\nSome body text"; - const result = updateFrontmatterWithAtUri(content, "at://did:plc:abc/post/123"); + const result = updateFrontmatterWithAtUri( + content, + "at://did:plc:abc/post/123", + ); expect(result).toContain('atUri: "at://did:plc:abc/post/123"'); expect(result).toContain("---"); expect(result).toContain("# My Post\n\nSome body text"); @@ -351,7 +364,10 @@ atUri: "at://did:plc:old/post/000" --- Body`; - const result = updateFrontmatterWithAtUri(content, "at://did:plc:new/post/999"); + const result = updateFrontmatterWithAtUri( + content, + "at://did:plc:new/post/999", + ); expect(result).toContain('atUri: "at://did:plc:new/post/999"'); expect(result).not.toContain("old"); }); @@ -363,7 +379,10 @@ atUri = "at://did:plc:old/post/000" +++ Body`; - const result = updateFrontmatterWithAtUri(content, "at://did:plc:new/post/999"); + const result = updateFrontmatterWithAtUri( + content, + "at://did:plc:new/post/999", + ); expect(result).toContain('atUri = "at://did:plc:new/post/999"'); expect(result).not.toContain("old"); }); @@ -436,4 +455,4 @@ describe("getTextContent", () => { }; expect(getTextContent(post)).toBe("Heading\n\nParagraph"); }); -}); \ No newline at end of file +}); diff --git a/packages/cli/src/lib/markdown.ts b/packages/cli/src/lib/markdown.ts index a3e07cc..1a95382 100644 --- a/packages/cli/src/lib/markdown.ts +++ b/packages/cli/src/lib/markdown.ts @@ -21,21 +21,20 @@ export function parseFrontmatter( const match = content.match(frontmatterRegex); if (!match) { - const [, titleMatch] = content.trim().match(/^# (.+)$/m) || [] - const title = titleMatch ?? "" - const [publishDate] = new Date().toISOString().split("T") - - return { - frontmatter: { - title, - publishDate: publishDate ?? "" - }, - body: content, - rawFrontmatter: { - title: - publishDate - } - } + const [, titleMatch] = content.trim().match(/^# (.+)$/m) || []; + const title = titleMatch ?? ""; + const [publishDate] = new Date().toISOString().split("T"); + + return { + frontmatter: { + title, + publishDate: publishDate ?? "", + }, + body: content, + rawFrontmatter: { + title: publishDate, + }, + }; } const delimiter = match[1]; @@ -447,6 +446,32 @@ export function updateFrontmatterWithAtUri( return `${beforeEnd}${atUriEntry}\n${afterEnd}`; } +export function removeFrontmatterAtUri(rawContent: string): string { + const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n/; + const match = rawContent.match(frontmatterRegex); + if (!match) return rawContent; + + const delimiter = match[1]; + const frontmatterStr = match[2] ?? ""; + + // Remove the atUri line + const lines = frontmatterStr + .split("\n") + .filter((line) => !line.match(/^\s*atUri\s*[=:]\s*/)); + + // Check if remaining frontmatter has any non-empty lines + const hasContent = lines.some((line) => line.trim() !== ""); + + const afterFrontmatter = rawContent.slice(match[0].length); + + if (!hasContent) { + // Remove entire frontmatter block, trim leading newlines + return afterFrontmatter.replace(/^\n+/, ""); + } + + return `${delimiter}\n${lines.join("\n")}\n${delimiter}\n${afterFrontmatter}`; +} + export function stripMarkdownForText(markdown: string): string { return markdown .replace(/#{1,6}\s/g, "") // Remove headers diff --git a/packages/cli/src/lib/types.ts b/packages/cli/src/lib/types.ts index d58cf63..0a5188f 100644 --- a/packages/cli/src/lib/types.ts +++ b/packages/cli/src/lib/types.ts @@ -86,9 +86,9 @@ export function isAppPasswordCredentials( export interface PostFrontmatter { title: string; - theme?: string - fontFamily?: string - fontSize?: number + theme?: string; + fontFamily?: string; + fontSize?: number; description?: string; publishDate: string; tags?: string[]; diff --git a/packages/cli/src/lib/utils.ts b/packages/cli/src/lib/utils.ts new file mode 100644 index 0000000..1e0db08 --- /dev/null +++ b/packages/cli/src/lib/utils.ts @@ -0,0 +1,10 @@ +import * as fs from "node:fs/promises"; + +export async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} -- 2.51.2