this repo has no description

feat: add deletion

authored by

Julien Calixte and committed by
Julien Calixte
0cfe0839 e87f4f1c

+402 -307
+1 -9
packages/cli/src/commands/init.ts
··· 17 17 import { createAgent, createPublication } from "../lib/atproto"; 18 18 import { selectCredential } from "../lib/credential-select"; 19 19 import type { FrontmatterMapping, BlueskyConfig } from "../lib/types"; 20 - 21 - async function fileExists(filePath: string): Promise<boolean> { 22 - try { 23 - await fs.access(filePath); 24 - return true; 25 - } catch { 26 - return false; 27 - } 28 - } 20 + import { fileExists } from "../lib/utils"; 29 21 30 22 const onCancel = () => { 31 23 outro("Setup cancelled");
+68 -22
packages/cli/src/commands/publish.ts
··· 17 17 resolveImagePath, 18 18 createBlueskyPost, 19 19 addBskyPostRefToDocument, 20 + deleteRecord, 20 21 } from "../lib/atproto"; 21 22 import { 22 23 scanContentDirectory, ··· 26 27 } from "../lib/markdown"; 27 28 import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; 28 29 import { exitOnCancel } from "../lib/prompts"; 29 - import { createNote, updateNote, findPostsWithStaleLinks, type NoteOptions } from "../extensions/remanso" 30 + import { 31 + createNote, 32 + updateNote, 33 + deleteNote, 34 + findPostsWithStaleLinks, 35 + type NoteOptions, 36 + } from "../extensions/remanso"; 37 + import { fileExists } from "../lib/utils"; 30 38 31 39 export const publishCommand = command({ 32 40 name: "publish", ··· 160 168 }); 161 169 s.stop(`Found ${posts.length} posts`); 162 170 171 + // Detect deleted files: state entries whose local files no longer exist 172 + const scannedPaths = new Set( 173 + posts.map((p) => path.relative(configDir, p.filePath)), 174 + ); 175 + const deletedEntries: Array<{ filePath: string; atUri: string }> = []; 176 + for (const [filePath, postState] of Object.entries(state.posts)) { 177 + if (!scannedPaths.has(filePath) && postState.atUri) { 178 + // Check if the file truly doesn't exist (not just excluded by ignore patterns) 179 + const absolutePath = path.resolve(configDir, filePath); 180 + 181 + // If file exists but wasn't scanned (e.g. draft or ignored) — skip 182 + if (!(await fileExists(absolutePath))) { 183 + deletedEntries.push({ filePath, atUri: postState.atUri }); 184 + } 185 + } 186 + } 187 + 188 + // Shared agent — created lazily, reused across deletion and publishing 189 + let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 190 + async function getAgent(): Promise< 191 + Awaited<ReturnType<typeof createAgent>> 192 + > { 193 + if (agent) return agent; 194 + 195 + if (!credentials) { 196 + throw new Error("credentials not found"); 197 + } 198 + 199 + const connectingTo = 200 + credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; 201 + s.start(`Connecting as ${connectingTo}...`); 202 + try { 203 + agent = await createAgent(credentials); 204 + s.stop(`Logged in as ${agent.did}`); 205 + return agent; 206 + } catch (error) { 207 + s.stop("Failed to login"); 208 + log.error(`Failed to login: ${error}`); 209 + process.exit(1); 210 + } 211 + } 212 + 163 213 // Determine which posts need publishing 164 214 const postsToPublish: Array<{ 165 215 post: BlogPost; ··· 261 311 return; 262 312 } 263 313 264 - // Create agent 265 - const connectingTo = 266 - credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; 267 - s.start(`Connecting as ${connectingTo}...`); 268 - let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 269 - try { 270 - agent = await createAgent(credentials); 271 - s.stop(`Logged in as ${agent.did}`); 272 - } catch (error) { 273 - s.stop("Failed to login"); 274 - log.error(`Failed to login: ${error}`); 275 - process.exit(1); 314 + // Ensure agent is connected 315 + await getAgent(); 316 + 317 + if (!agent) { 318 + throw new Error("agent is not connected"); 276 319 } 277 320 278 321 // Publish posts ··· 295 338 }> = []; 296 339 297 340 for (const { post, action } of postsToPublish) { 298 - const trimmedContent = post.content.trim() 299 - const titleMatch = trimmedContent.match(/^# (.+)$/m) 300 - const title = titleMatch ? titleMatch[1] : post.frontmatter.title 301 - s.start(`Publishing: ${title}`); 341 + const trimmedContent = post.content.trim(); 342 + const titleMatch = trimmedContent.match(/^# (.+)$/m); 343 + const title = titleMatch ? titleMatch[1] : post.frontmatter.title; 344 + s.start(`Publishing: ${title}`); 302 345 303 - // Init publish date 304 - if (!post.frontmatter.publishDate) { 305 - const [publishDate] = new Date().toISOString().split("T") 306 - post.frontmatter.publishDate = publishDate! 307 - } 346 + // Init publish date 347 + if (!post.frontmatter.publishDate) { 348 + const [publishDate] = new Date().toISOString().split("T"); 349 + post.frontmatter.publishDate = publishDate!; 350 + } 308 351 309 352 try { 310 353 // Handle cover image upload ··· 475 518 476 519 // Summary 477 520 log.message("\n---"); 521 + if (deletedEntries.length > 0) { 522 + log.info(`Deleted: ${deletedEntries.length}`); 523 + } 478 524 log.info(`Published: ${publishedCount}`); 479 525 log.info(`Updated: ${updatedCount}`); 480 526 if (bskyPostCount > 0) {
+8 -33
packages/cli/src/extensions/remanso.test.ts
··· 31 31 32 32 test("rewrites published link to remanso atUri", () => { 33 33 const posts = [ 34 - makePost( 35 - "other-post", 36 - "at://did:plc:abc/site.standard.document/abc123", 37 - ), 34 + makePost("other-post", "at://did:plc:abc/site.standard.document/abc123"), 38 35 ]; 39 36 const content = "See [my post](./other-post)"; 40 37 expect(resolveInternalLinks(content, posts)).toBe( ··· 60 57 61 58 test("handles .md extension in link path", () => { 62 59 const posts = [ 63 - makePost( 64 - "guide", 65 - "at://did:plc:abc/site.standard.document/guide123", 66 - ), 60 + makePost("guide", "at://did:plc:abc/site.standard.document/guide123"), 67 61 ]; 68 62 const content = "Read the [guide](guide.md)"; 69 63 expect(resolveInternalLinks(content, posts)).toBe( ··· 73 67 74 68 test("handles nested slug matching", () => { 75 69 const posts = [ 76 - makePost( 77 - "blog/my-post", 78 - "at://did:plc:abc/site.standard.document/rkey1", 79 - ), 70 + makePost("blog/my-post", "at://did:plc:abc/site.standard.document/rkey1"), 80 71 ]; 81 72 const content = "See [post](my-post)"; 82 73 expect(resolveInternalLinks(content, posts)).toBe( ··· 86 77 87 78 test("does not rewrite image embeds", () => { 88 79 const posts = [ 89 - makePost( 90 - "photo", 91 - "at://did:plc:abc/site.standard.document/photo1", 92 - ), 80 + makePost("photo", "at://did:plc:abc/site.standard.document/photo1"), 93 81 ]; 94 82 const content = "![alt](photo)"; 95 83 expect(resolveInternalLinks(content, posts)).toBe("![alt](photo)"); ··· 97 85 98 86 test("does not rewrite @mention links", () => { 99 87 const posts = [ 100 - makePost( 101 - "mention", 102 - "at://did:plc:abc/site.standard.document/m1", 103 - ), 88 + makePost("mention", "at://did:plc:abc/site.standard.document/m1"), 104 89 ]; 105 90 const content = "@[name](mention)"; 106 91 expect(resolveInternalLinks(content, posts)).toBe("@[name](mention)"); ··· 108 93 109 94 test("handles multiple links in same content", () => { 110 95 const posts = [ 111 - makePost( 112 - "published", 113 - "at://did:plc:abc/site.standard.document/pub1", 114 - ), 96 + makePost("published", "at://did:plc:abc/site.standard.document/pub1"), 115 97 makePost("unpublished"), 116 98 ]; 117 99 const content = ··· 123 105 124 106 test("handles index path normalization", () => { 125 107 const posts = [ 126 - makePost( 127 - "docs", 128 - "at://did:plc:abc/site.standard.document/docs1", 129 - ), 108 + makePost("docs", "at://did:plc:abc/site.standard.document/docs1"), 130 109 ]; 131 110 const content = "See [docs](./docs/index)"; 132 111 expect(resolveInternalLinks(content, posts)).toBe( ··· 218 197 content: "Check out [post](my-post)", 219 198 }), 220 199 ]; 221 - const result = findPostsWithStaleLinks( 222 - posts, 223 - ["blog/my-post"], 224 - new Set(), 225 - ); 200 + const result = findPostsWithStaleLinks(posts, ["blog/my-post"], new Set()); 226 201 expect(result).toHaveLength(1); 227 202 }); 228 203
+229 -211
packages/cli/src/extensions/remanso.ts
··· 1 - import type { Agent } from "@atproto/api" 2 - import * as fs from "node:fs/promises" 3 - import * as path from "node:path" 4 - import mimeTypes from "mime-types" 5 - import type { BlogPost, BlobObject } from "../lib/types" 1 + import type { Agent } from "@atproto/api"; 2 + import * as fs from "node:fs/promises"; 3 + import * as path from "node:path"; 4 + import mimeTypes from "mime-types"; 5 + import type { BlogPost, BlobObject } from "../lib/types"; 6 6 7 - const LEXICON = "space.remanso.note" 8 - const MAX_CONTENT = 10000 7 + const LEXICON = "space.remanso.note"; 8 + const MAX_CONTENT = 10000; 9 9 10 10 interface ImageRecord { 11 - image: BlobObject 12 - alt?: string 11 + image: BlobObject; 12 + alt?: string; 13 13 } 14 14 15 15 export interface NoteOptions { 16 - contentDir: string 17 - imagesDir?: string 18 - allPosts: BlogPost[] 16 + contentDir: string; 17 + imagesDir?: string; 18 + allPosts: BlogPost[]; 19 19 } 20 20 21 21 async function fileExists(filePath: string): Promise<boolean> { 22 - try { 23 - await fs.access(filePath) 24 - return true 25 - } catch { 26 - return false 27 - } 22 + try { 23 + await fs.access(filePath); 24 + return true; 25 + } catch { 26 + return false; 27 + } 28 28 } 29 29 30 30 export function isLocalPath(url: string): boolean { 31 - return ( 32 - !url.startsWith("http://") && 33 - !url.startsWith("https://") && 34 - !url.startsWith("#") && 35 - !url.startsWith("mailto:") 36 - ) 31 + return ( 32 + !url.startsWith("http://") && 33 + !url.startsWith("https://") && 34 + !url.startsWith("#") && 35 + !url.startsWith("mailto:") 36 + ); 37 37 } 38 38 39 39 function getImageCandidates( 40 - src: string, 41 - postFilePath: string, 42 - contentDir: string, 43 - imagesDir?: string, 40 + src: string, 41 + postFilePath: string, 42 + contentDir: string, 43 + imagesDir?: string, 44 44 ): string[] { 45 - const candidates = [ 46 - path.resolve(path.dirname(postFilePath), src), 47 - path.resolve(contentDir, src), 48 - ] 49 - if (imagesDir) { 50 - candidates.push(path.resolve(imagesDir, src)) 51 - const baseName = path.basename(imagesDir) 52 - const idx = src.indexOf(baseName) 53 - if (idx !== -1) { 54 - const after = src.substring(idx + baseName.length).replace(/^[/\\]/, "") 55 - candidates.push(path.resolve(imagesDir, after)) 56 - } 57 - } 58 - return candidates 45 + const candidates = [ 46 + path.resolve(path.dirname(postFilePath), src), 47 + path.resolve(contentDir, src), 48 + ]; 49 + if (imagesDir) { 50 + candidates.push(path.resolve(imagesDir, src)); 51 + const baseName = path.basename(imagesDir); 52 + const idx = src.indexOf(baseName); 53 + if (idx !== -1) { 54 + const after = src.substring(idx + baseName.length).replace(/^[/\\]/, ""); 55 + candidates.push(path.resolve(imagesDir, after)); 56 + } 57 + } 58 + return candidates; 59 59 } 60 60 61 61 async function uploadBlob( 62 - agent: Agent, 63 - candidates: string[], 62 + agent: Agent, 63 + candidates: string[], 64 64 ): Promise<BlobObject | undefined> { 65 - for (const filePath of candidates) { 66 - if (!(await fileExists(filePath))) continue 65 + for (const filePath of candidates) { 66 + if (!(await fileExists(filePath))) continue; 67 67 68 - try { 69 - const imageBuffer = await fs.readFile(filePath) 70 - if (imageBuffer.byteLength === 0) continue 71 - const mimeType = mimeTypes.lookup(filePath) || "application/octet-stream" 72 - const response = await agent.com.atproto.repo.uploadBlob( 73 - new Uint8Array(imageBuffer), 74 - { encoding: mimeType }, 75 - ) 76 - return { 77 - $type: "blob", 78 - ref: { $link: response.data.blob.ref.toString() }, 79 - mimeType, 80 - size: imageBuffer.byteLength, 81 - } 82 - } catch {} 83 - } 84 - return undefined 68 + try { 69 + const imageBuffer = await fs.readFile(filePath); 70 + if (imageBuffer.byteLength === 0) continue; 71 + const mimeType = mimeTypes.lookup(filePath) || "application/octet-stream"; 72 + const response = await agent.com.atproto.repo.uploadBlob( 73 + new Uint8Array(imageBuffer), 74 + { encoding: mimeType }, 75 + ); 76 + return { 77 + $type: "blob", 78 + ref: { $link: response.data.blob.ref.toString() }, 79 + mimeType, 80 + size: imageBuffer.byteLength, 81 + }; 82 + } catch {} 83 + } 84 + return undefined; 85 85 } 86 86 87 87 async function processImages( 88 - agent: Agent, 89 - content: string, 90 - postFilePath: string, 91 - contentDir: string, 92 - imagesDir?: string, 88 + agent: Agent, 89 + content: string, 90 + postFilePath: string, 91 + contentDir: string, 92 + imagesDir?: string, 93 93 ): Promise<{ content: string; images: ImageRecord[] }> { 94 - const images: ImageRecord[] = [] 95 - const uploadCache = new Map<string, BlobObject>() 96 - let processedContent = content 94 + const images: ImageRecord[] = []; 95 + const uploadCache = new Map<string, BlobObject>(); 96 + let processedContent = content; 97 97 98 - const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g 99 - const matches = [...content.matchAll(imageRegex)] 98 + const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; 99 + const matches = [...content.matchAll(imageRegex)]; 100 100 101 - for (const match of matches) { 102 - const fullMatch = match[0] 103 - const alt = match[1] ?? "" 104 - const src = match[2]! 105 - if (!isLocalPath(src)) continue 101 + for (const match of matches) { 102 + const fullMatch = match[0]; 103 + const alt = match[1] ?? ""; 104 + const src = match[2]!; 105 + if (!isLocalPath(src)) continue; 106 106 107 - let blob = uploadCache.get(src) 108 - if (!blob) { 109 - const candidates = getImageCandidates(src, postFilePath, contentDir, imagesDir) 110 - blob = await uploadBlob(agent, candidates) 111 - if (!blob) continue 112 - uploadCache.set(src, blob) 113 - } 107 + let blob = uploadCache.get(src); 108 + if (!blob) { 109 + const candidates = getImageCandidates( 110 + src, 111 + postFilePath, 112 + contentDir, 113 + imagesDir, 114 + ); 115 + blob = await uploadBlob(agent, candidates); 116 + if (!blob) continue; 117 + uploadCache.set(src, blob); 118 + } 114 119 115 - images.push({ image: blob, alt: alt || undefined }) 116 - processedContent = processedContent.replace( 117 - fullMatch, 118 - `![${alt}](${blob.ref.$link})`, 119 - ) 120 - } 120 + images.push({ image: blob, alt: alt || undefined }); 121 + processedContent = processedContent.replace( 122 + fullMatch, 123 + `![${alt}](${blob.ref.$link})`, 124 + ); 125 + } 121 126 122 - return { content: processedContent, images } 127 + return { content: processedContent, images }; 123 128 } 124 129 125 130 export function resolveInternalLinks( 126 - content: string, 127 - allPosts: BlogPost[], 131 + content: string, 132 + allPosts: BlogPost[], 128 133 ): string { 129 - const linkRegex = /(?<![!@])\[([^\]]+)\]\(([^)]+)\)/g 134 + const linkRegex = /(?<![!@])\[([^\]]+)\]\(([^)]+)\)/g; 130 135 131 - return content.replace(linkRegex, (fullMatch, text, url) => { 132 - if (!isLocalPath(url)) return fullMatch 136 + return content.replace(linkRegex, (fullMatch, text, url) => { 137 + if (!isLocalPath(url)) return fullMatch; 133 138 134 - // Normalize to a slug-like string for comparison 135 - const normalized = url 136 - .replace(/^\.?\/?/, "") 137 - .replace(/\/?$/, "") 138 - .replace(/\.mdx?$/, "") 139 - .replace(/\/index$/, "") 139 + // Normalize to a slug-like string for comparison 140 + const normalized = url 141 + .replace(/^\.?\/?/, "") 142 + .replace(/\/?$/, "") 143 + .replace(/\.mdx?$/, "") 144 + .replace(/\/index$/, ""); 140 145 141 - const matchedPost = allPosts.find((p) => { 142 - if (!p.frontmatter.atUri) return false 143 - return ( 144 - p.slug === normalized || 145 - p.slug.endsWith(`/${normalized}`) || 146 - normalized.endsWith(`/${p.slug}`) 147 - ) 148 - }) 146 + const matchedPost = allPosts.find((p) => { 147 + if (!p.frontmatter.atUri) return false; 148 + return ( 149 + p.slug === normalized || 150 + p.slug.endsWith(`/${normalized}`) || 151 + normalized.endsWith(`/${p.slug}`) 152 + ); 153 + }); 149 154 150 - if (!matchedPost) return text 155 + if (!matchedPost) return text; 151 156 152 - const noteUri = matchedPost.frontmatter.atUri!.replace( 153 - /\/[^/]+\/([^/]+)$/, 154 - `/space.remanso.note/$1`, 155 - ) 156 - return `[${text}](${noteUri})` 157 - }) 157 + const noteUri = matchedPost.frontmatter.atUri!.replace( 158 + /\/[^/]+\/([^/]+)$/, 159 + `/space.remanso.note/$1`, 160 + ); 161 + return `[${text}](${noteUri})`; 162 + }); 158 163 } 159 164 160 165 async function processNoteContent( 161 - agent: Agent, 162 - post: BlogPost, 163 - options: NoteOptions, 166 + agent: Agent, 167 + post: BlogPost, 168 + options: NoteOptions, 164 169 ): Promise<{ content: string; images: ImageRecord[] }> { 165 - let content = post.content.trim() 170 + let content = post.content.trim(); 166 171 167 - content = resolveInternalLinks(content, options.allPosts) 172 + content = resolveInternalLinks(content, options.allPosts); 168 173 169 - const result = await processImages( 170 - agent, content, post.filePath, options.contentDir, options.imagesDir, 171 - ) 174 + const result = await processImages( 175 + agent, 176 + content, 177 + post.filePath, 178 + options.contentDir, 179 + options.imagesDir, 180 + ); 172 181 173 - return result 182 + return result; 174 183 } 175 184 176 185 function parseRkey(atUri: string): string { 177 - const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/) 178 - if (!uriMatch) { 179 - throw new Error(`Invalid atUri format: ${atUri}`) 180 - } 181 - return uriMatch[3]! 186 + const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 187 + if (!uriMatch) { 188 + throw new Error(`Invalid atUri format: ${atUri}`); 189 + } 190 + return uriMatch[3]!; 182 191 } 183 192 184 193 async function buildNoteRecord( 185 - agent: Agent, 186 - post: BlogPost, 187 - options: NoteOptions, 194 + agent: Agent, 195 + post: BlogPost, 196 + options: NoteOptions, 188 197 ): Promise<Record<string, unknown>> { 189 - const publishDate = new Date(post.frontmatter.publishDate).toISOString() 190 - const trimmedContent = post.content.trim() 191 - const titleMatch = trimmedContent.match(/^# (.+)$/m) 192 - const title = titleMatch ? titleMatch[1] : post.frontmatter.title 198 + const publishDate = new Date(post.frontmatter.publishDate).toISOString(); 199 + const trimmedContent = post.content.trim(); 200 + const titleMatch = trimmedContent.match(/^# (.+)$/m); 201 + const title = titleMatch ? titleMatch[1] : post.frontmatter.title; 193 202 194 - const { content, images } = await processNoteContent(agent, post, options) 203 + const { content, images } = await processNoteContent(agent, post, options); 195 204 196 - const record: Record<string, unknown> = { 197 - $type: LEXICON, 198 - title, 199 - content: content.slice(0, MAX_CONTENT), 200 - createdAt: publishDate, 201 - publishedAt: publishDate, 202 - } 205 + const record: Record<string, unknown> = { 206 + $type: LEXICON, 207 + title, 208 + content: content.slice(0, MAX_CONTENT), 209 + createdAt: publishDate, 210 + publishedAt: publishDate, 211 + }; 203 212 204 - if (images.length > 0) { 205 - record.images = images 206 - } 213 + if (images.length > 0) { 214 + record.images = images; 215 + } 207 216 208 - if (post.frontmatter.theme) { 209 - record.theme = post.frontmatter.theme 210 - } 217 + if (post.frontmatter.theme) { 218 + record.theme = post.frontmatter.theme; 219 + } 211 220 212 - if (post.frontmatter.fontSize) { 213 - record.fontSize = post.frontmatter.fontSize 214 - } 221 + if (post.frontmatter.fontSize) { 222 + record.fontSize = post.frontmatter.fontSize; 223 + } 215 224 216 - if (post.frontmatter.fontFamily) { 217 - record.fontFamily = post.frontmatter.fontFamily 218 - } 225 + if (post.frontmatter.fontFamily) { 226 + record.fontFamily = post.frontmatter.fontFamily; 227 + } 219 228 220 - return record 229 + return record; 230 + } 231 + 232 + export async function deleteNote(agent: Agent, atUri: string): Promise<void> { 233 + const rkey = parseRkey(atUri); 234 + await agent.com.atproto.repo.deleteRecord({ 235 + repo: agent.did!, 236 + collection: LEXICON, 237 + rkey, 238 + }); 221 239 } 222 240 223 241 export async function createNote( 224 - agent: Agent, 225 - post: BlogPost, 226 - atUri: string, 227 - options: NoteOptions, 242 + agent: Agent, 243 + post: BlogPost, 244 + atUri: string, 245 + options: NoteOptions, 228 246 ): Promise<void> { 229 - const rkey = parseRkey(atUri) 230 - const record = await buildNoteRecord(agent, post, options) 247 + const rkey = parseRkey(atUri); 248 + const record = await buildNoteRecord(agent, post, options); 231 249 232 - await agent.com.atproto.repo.createRecord({ 233 - repo: agent.did!, 234 - collection: LEXICON, 235 - record, 236 - rkey, 237 - validate: false, 238 - }) 250 + await agent.com.atproto.repo.createRecord({ 251 + repo: agent.did!, 252 + collection: LEXICON, 253 + record, 254 + rkey, 255 + validate: false, 256 + }); 239 257 } 240 258 241 259 export async function updateNote( 242 - agent: Agent, 243 - post: BlogPost, 244 - atUri: string, 245 - options: NoteOptions, 260 + agent: Agent, 261 + post: BlogPost, 262 + atUri: string, 263 + options: NoteOptions, 246 264 ): Promise<void> { 247 - const rkey = parseRkey(atUri) 248 - const record = await buildNoteRecord(agent, post, options) 265 + const rkey = parseRkey(atUri); 266 + const record = await buildNoteRecord(agent, post, options); 249 267 250 - await agent.com.atproto.repo.putRecord({ 251 - repo: agent.did!, 252 - collection: LEXICON, 253 - rkey: rkey!, 254 - record, 255 - validate: false, 256 - }) 268 + await agent.com.atproto.repo.putRecord({ 269 + repo: agent.did!, 270 + collection: LEXICON, 271 + rkey: rkey!, 272 + record, 273 + validate: false, 274 + }); 257 275 } 258 276 259 277 export function findPostsWithStaleLinks( 260 - allPosts: BlogPost[], 261 - newSlugs: string[], 262 - excludeFilePaths: Set<string>, 278 + allPosts: BlogPost[], 279 + newSlugs: string[], 280 + excludeFilePaths: Set<string>, 263 281 ): BlogPost[] { 264 - const linkRegex = /(?<![!@])\[([^\]]+)\]\(([^)]+)\)/g 282 + const linkRegex = /(?<![!@])\[([^\]]+)\]\(([^)]+)\)/g; 265 283 266 - return allPosts.filter((post) => { 267 - if (excludeFilePaths.has(post.filePath)) return false 268 - if (!post.frontmatter.atUri) return false 269 - if (post.frontmatter.draft) return false 284 + return allPosts.filter((post) => { 285 + if (excludeFilePaths.has(post.filePath)) return false; 286 + if (!post.frontmatter.atUri) return false; 287 + if (post.frontmatter.draft) return false; 270 288 271 - const matches = [...post.content.matchAll(linkRegex)] 272 - return matches.some((match) => { 273 - const url = match[2]! 274 - if (!isLocalPath(url)) return false 289 + const matches = [...post.content.matchAll(linkRegex)]; 290 + return matches.some((match) => { 291 + const url = match[2]!; 292 + if (!isLocalPath(url)) return false; 275 293 276 - const normalized = url 277 - .replace(/^\.?\/?/, "") 278 - .replace(/\/?$/, "") 279 - .replace(/\.mdx?$/, "") 280 - .replace(/\/index$/, "") 294 + const normalized = url 295 + .replace(/^\.?\/?/, "") 296 + .replace(/\/?$/, "") 297 + .replace(/\.mdx?$/, "") 298 + .replace(/\/index$/, ""); 281 299 282 - return newSlugs.some( 283 - (slug) => 284 - slug === normalized || 285 - slug.endsWith(`/${normalized}`) || 286 - normalized.endsWith(`/${slug}`), 287 - ) 288 - }) 289 - }) 300 + return newSlugs.some( 301 + (slug) => 302 + slug === normalized || 303 + slug.endsWith(`/${normalized}`) || 304 + normalized.endsWith(`/${slug}`), 305 + ); 306 + }); 307 + }); 290 308 }
+18 -8
packages/cli/src/lib/atproto.ts
··· 251 251 config.pathTemplate, 252 252 ); 253 253 const publishDate = new Date(post.frontmatter.publishDate); 254 - const trimmedContent = post.content.trim() 254 + const trimmedContent = post.content.trim(); 255 255 const textContent = getTextContent(post, config.textContentField); 256 - const titleMatch = trimmedContent.match(/^# (.+)$/m) 257 - const title = titleMatch ? titleMatch[1] : post.frontmatter.title 256 + const titleMatch = trimmedContent.match(/^# (.+)$/m); 257 + const title = titleMatch ? titleMatch[1] : post.frontmatter.title; 258 258 259 259 const record: Record<string, unknown> = { 260 260 $type: "site.standard.document", ··· 309 309 config.pathTemplate, 310 310 ); 311 311 const publishDate = new Date(post.frontmatter.publishDate); 312 - const trimmedContent = post.content.trim() 312 + const trimmedContent = post.content.trim(); 313 313 const textContent = getTextContent(post, config.textContentField); 314 - const titleMatch = trimmedContent.match(/^# (.+)$/m) 315 - const title = titleMatch ? titleMatch[1] : post.frontmatter.title 314 + const titleMatch = trimmedContent.match(/^# (.+)$/m); 315 + const title = titleMatch ? titleMatch[1] : post.frontmatter.title; 316 316 317 317 // Fetch existing record to preserve PDS-side fields (e.g. bskyPostRef) 318 318 const existingResponse = await agent.com.atproto.repo.getRecord({ ··· 399 399 limit: 100, 400 400 cursor, 401 401 }); 402 - 402 + 403 403 for (const record of response.data.records) { 404 - if (!isDocumentRecord(record.value)) { 404 + if (!isDocumentRecord(record.value)) { 405 405 continue; 406 406 } 407 407 ··· 556 556 collection: parsed.collection, 557 557 rkey: parsed.rkey, 558 558 record, 559 + }); 560 + } 561 + 562 + export async function deleteRecord(agent: Agent, atUri: string): Promise<void> { 563 + const parsed = parseAtUri(atUri); 564 + if (!parsed) throw new Error(`Invalid atUri format: ${atUri}`); 565 + await agent.com.atproto.repo.deleteRecord({ 566 + repo: parsed.did, 567 + collection: parsed.collection, 568 + rkey: parsed.rkey, 559 569 }); 560 570 } 561 571
+26 -7
packages/cli/src/lib/markdown.test.ts
··· 239 239 }); 240 240 241 241 test("falls back to filepath when slugField not found in frontmatter", () => { 242 - const slug = getSlugFromOptions("blog/my-post.md", {}, { slugField: "slug" }); 242 + const slug = getSlugFromOptions( 243 + "blog/my-post.md", 244 + {}, 245 + { slugField: "slug" }, 246 + ); 243 247 expect(slug).toBe("blog/my-post"); 244 248 }); 245 249 ··· 320 324 --- 321 325 Body`; 322 326 323 - const result = updateFrontmatterWithAtUri(content, "at://did:plc:abc/post/123"); 327 + const result = updateFrontmatterWithAtUri( 328 + content, 329 + "at://did:plc:abc/post/123", 330 + ); 324 331 expect(result).toContain('atUri: "at://did:plc:abc/post/123"'); 325 332 expect(result).toContain("title: My Post"); 326 333 }); ··· 331 338 +++ 332 339 Body`; 333 340 334 - const result = updateFrontmatterWithAtUri(content, "at://did:plc:abc/post/123"); 341 + const result = updateFrontmatterWithAtUri( 342 + content, 343 + "at://did:plc:abc/post/123", 344 + ); 335 345 expect(result).toContain('atUri = "at://did:plc:abc/post/123"'); 336 346 }); 337 347 338 348 test("creates frontmatter with atUri when none exists", () => { 339 349 const content = "# My Post\n\nSome body text"; 340 350 341 - const result = updateFrontmatterWithAtUri(content, "at://did:plc:abc/post/123"); 351 + const result = updateFrontmatterWithAtUri( 352 + content, 353 + "at://did:plc:abc/post/123", 354 + ); 342 355 expect(result).toContain('atUri: "at://did:plc:abc/post/123"'); 343 356 expect(result).toContain("---"); 344 357 expect(result).toContain("# My Post\n\nSome body text"); ··· 351 364 --- 352 365 Body`; 353 366 354 - const result = updateFrontmatterWithAtUri(content, "at://did:plc:new/post/999"); 367 + const result = updateFrontmatterWithAtUri( 368 + content, 369 + "at://did:plc:new/post/999", 370 + ); 355 371 expect(result).toContain('atUri: "at://did:plc:new/post/999"'); 356 372 expect(result).not.toContain("old"); 357 373 }); ··· 363 379 +++ 364 380 Body`; 365 381 366 - const result = updateFrontmatterWithAtUri(content, "at://did:plc:new/post/999"); 382 + const result = updateFrontmatterWithAtUri( 383 + content, 384 + "at://did:plc:new/post/999", 385 + ); 367 386 expect(result).toContain('atUri = "at://did:plc:new/post/999"'); 368 387 expect(result).not.toContain("old"); 369 388 }); ··· 436 455 }; 437 456 expect(getTextContent(post)).toBe("Heading\n\nParagraph"); 438 457 }); 439 - }); 458 + });
+39 -14
packages/cli/src/lib/markdown.ts
··· 23 23 const match = content.match(frontmatterRegex); 24 24 25 25 if (!match) { 26 - const [, titleMatch] = content.trim().match(/^# (.+)$/m) || [] 27 - const title = titleMatch ?? "" 28 - const [publishDate] = new Date().toISOString().split("T") 26 + const [, titleMatch] = content.trim().match(/^# (.+)$/m) || []; 27 + const title = titleMatch ?? ""; 28 + const [publishDate] = new Date().toISOString().split("T"); 29 29 30 - return { 31 - frontmatter: { 32 - title, 33 - publishDate: publishDate ?? "" 34 - }, 35 - body: content, 36 - rawFrontmatter: { 37 - title: 38 - publishDate 39 - } 40 - } 30 + return { 31 + frontmatter: { 32 + title, 33 + publishDate: publishDate ?? "", 34 + }, 35 + body: content, 36 + rawFrontmatter: { 37 + title: publishDate, 38 + }, 39 + }; 41 40 } 42 41 43 42 const delimiter = match[1]; ··· 383 382 const afterEnd = rawContent.slice(frontmatterEndIndex); 384 383 385 384 return `${beforeEnd}${atUriEntry}\n${afterEnd}`; 385 + } 386 + 387 + export function removeFrontmatterAtUri(rawContent: string): string { 388 + const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n/; 389 + const match = rawContent.match(frontmatterRegex); 390 + if (!match) return rawContent; 391 + 392 + const delimiter = match[1]; 393 + const frontmatterStr = match[2] ?? ""; 394 + 395 + // Remove the atUri line 396 + const lines = frontmatterStr 397 + .split("\n") 398 + .filter((line) => !line.match(/^\s*atUri\s*[=:]\s*/)); 399 + 400 + // Check if remaining frontmatter has any non-empty lines 401 + const hasContent = lines.some((line) => line.trim() !== ""); 402 + 403 + const afterFrontmatter = rawContent.slice(match[0].length); 404 + 405 + if (!hasContent) { 406 + // Remove entire frontmatter block, trim leading newlines 407 + return afterFrontmatter.replace(/^\n+/, ""); 408 + } 409 + 410 + return `${delimiter}\n${lines.join("\n")}\n${delimiter}\n${afterFrontmatter}`; 386 411 } 387 412 388 413 export function stripMarkdownForText(markdown: string): string {
+3 -3
packages/cli/src/lib/types.ts
··· 86 86 87 87 export interface PostFrontmatter { 88 88 title: string; 89 - theme?: string 90 - fontFamily?: string 91 - fontSize?: number 89 + theme?: string; 90 + fontFamily?: string; 91 + fontSize?: number; 92 92 description?: string; 93 93 bskyPost?: string; 94 94 publishDate: string;
+10
packages/cli/src/lib/utils.ts
··· 1 + import * as fs from "node:fs/promises"; 2 + 3 + export async function fileExists(filePath: string): Promise<boolean> { 4 + try { 5 + await fs.access(filePath); 6 + return true; 7 + } catch { 8 + return false; 9 + } 10 + }