A CLI for publishing standard.site documents to ATProto

feat: add deletion

+404 -310
+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, ··· 25 26 } from "../lib/markdown"; 26 27 import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; 27 28 import { exitOnCancel } from "../lib/prompts"; 28 - import { createNote, updateNote, findPostsWithStaleLinks, type NoteOptions } from "../extensions/remanso" 29 + import { 30 + createNote, 31 + updateNote, 32 + deleteNote, 33 + findPostsWithStaleLinks, 34 + type NoteOptions, 35 + } from "../extensions/remanso"; 36 + import { fileExists } from "../lib/utils"; 29 37 30 38 export const publishCommand = command({ 31 39 name: "publish", ··· 159 167 }); 160 168 s.stop(`Found ${posts.length} posts`); 161 169 170 + // Detect deleted files: state entries whose local files no longer exist 171 + const scannedPaths = new Set( 172 + posts.map((p) => path.relative(configDir, p.filePath)), 173 + ); 174 + const deletedEntries: Array<{ filePath: string; atUri: string }> = []; 175 + for (const [filePath, postState] of Object.entries(state.posts)) { 176 + if (!scannedPaths.has(filePath) && postState.atUri) { 177 + // Check if the file truly doesn't exist (not just excluded by ignore patterns) 178 + const absolutePath = path.resolve(configDir, filePath); 179 + 180 + // If file exists but wasn't scanned (e.g. draft or ignored) — skip 181 + if (!(await fileExists(absolutePath))) { 182 + deletedEntries.push({ filePath, atUri: postState.atUri }); 183 + } 184 + } 185 + } 186 + 187 + // Shared agent — created lazily, reused across deletion and publishing 188 + let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 189 + async function getAgent(): Promise< 190 + Awaited<ReturnType<typeof createAgent>> 191 + > { 192 + if (agent) return agent; 193 + 194 + if (!credentials) { 195 + throw new Error("credentials not found"); 196 + } 197 + 198 + const connectingTo = 199 + credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; 200 + s.start(`Connecting as ${connectingTo}...`); 201 + try { 202 + agent = await createAgent(credentials); 203 + s.stop(`Logged in as ${agent.did}`); 204 + return agent; 205 + } catch (error) { 206 + s.stop("Failed to login"); 207 + log.error(`Failed to login: ${error}`); 208 + process.exit(1); 209 + } 210 + } 211 + 162 212 // Determine which posts need publishing 163 213 const postsToPublish: Array<{ 164 214 post: BlogPost; ··· 256 306 return; 257 307 } 258 308 259 - // Create agent 260 - const connectingTo = 261 - credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; 262 - s.start(`Connecting as ${connectingTo}...`); 263 - let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 264 - try { 265 - agent = await createAgent(credentials); 266 - s.stop(`Logged in as ${agent.did}`); 267 - } catch (error) { 268 - s.stop("Failed to login"); 269 - log.error(`Failed to login: ${error}`); 270 - process.exit(1); 309 + // Ensure agent is connected 310 + await getAgent(); 311 + 312 + if (!agent) { 313 + throw new Error("agent is not connected"); 271 314 } 272 315 273 316 // Publish posts ··· 290 333 }> = []; 291 334 292 335 for (const { post, action } of postsToPublish) { 293 - const trimmedContent = post.content.trim() 294 - const titleMatch = trimmedContent.match(/^# (.+)$/m) 295 - const title = titleMatch ? titleMatch[1] : post.frontmatter.title 296 - s.start(`Publishing: ${title}`); 336 + const trimmedContent = post.content.trim(); 337 + const titleMatch = trimmedContent.match(/^# (.+)$/m); 338 + const title = titleMatch ? titleMatch[1] : post.frontmatter.title; 339 + s.start(`Publishing: ${title}`); 297 340 298 - // Init publish date 299 - if (!post.frontmatter.publishDate) { 300 - const [publishDate] = new Date().toISOString().split("T") 301 - post.frontmatter.publishDate = publishDate! 302 - } 341 + // Init publish date 342 + if (!post.frontmatter.publishDate) { 343 + const [publishDate] = new Date().toISOString().split("T"); 344 + post.frontmatter.publishDate = publishDate!; 345 + } 303 346 304 347 try { 305 348 // Handle cover image upload ··· 470 513 471 514 // Summary 472 515 log.message("\n---"); 516 + if (deletedEntries.length > 0) { 517 + log.info(`Deleted: ${deletedEntries.length}`); 518 + } 473 519 log.info(`Published: ${publishedCount}`); 474 520 log.info(`Updated: ${updatedCount}`); 475 521 if (bskyPostCount > 0) {
+1 -2
packages/cli/src/components/sequoia-comments.js
··· 588 588 this.commentsContainer = container; 589 589 this.state = { type: "loading" }; 590 590 this.abortController = null; 591 - 592 591 } 593 592 594 593 static get observedAttributes() { ··· 701 700 </div> 702 701 `; 703 702 if (this.hide) { 704 - this.commentsContainer.style.display = 'none'; 703 + this.commentsContainer.style.display = "none"; 705 704 } 706 705 break; 707 706
+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 }
+19 -9
packages/cli/src/lib/atproto.ts
··· 248 248 const pathPrefix = config.pathPrefix || "/posts"; 249 249 const postPath = `${pathPrefix}/${post.slug}`; 250 250 const publishDate = new Date(post.frontmatter.publishDate); 251 - const trimmedContent = post.content.trim() 251 + const trimmedContent = post.content.trim(); 252 252 const textContent = getTextContent(post, config.textContentField); 253 - const titleMatch = trimmedContent.match(/^# (.+)$/m) 254 - const title = titleMatch ? titleMatch[1] : post.frontmatter.title 253 + const titleMatch = trimmedContent.match(/^# (.+)$/m); 254 + const title = titleMatch ? titleMatch[1] : post.frontmatter.title; 255 255 256 256 const record: Record<string, unknown> = { 257 257 $type: "site.standard.document", ··· 302 302 303 303 const pathPrefix = config.pathPrefix || "/posts"; 304 304 const postPath = `${pathPrefix}/${post.slug}`; 305 - 305 + 306 306 const publishDate = new Date(post.frontmatter.publishDate); 307 - const trimmedContent = post.content.trim() 307 + const trimmedContent = post.content.trim(); 308 308 const textContent = getTextContent(post, config.textContentField); 309 - const titleMatch = trimmedContent.match(/^# (.+)$/m) 310 - const title = titleMatch ? titleMatch[1] : post.frontmatter.title 309 + const titleMatch = trimmedContent.match(/^# (.+)$/m); 310 + const title = titleMatch ? titleMatch[1] : post.frontmatter.title; 311 311 312 312 const record: Record<string, unknown> = { 313 313 $type: "site.standard.document", ··· 385 385 limit: 100, 386 386 cursor, 387 387 }); 388 - 388 + 389 389 for (const record of response.data.records) { 390 - if (!isDocumentRecord(record.value)) { 390 + if (!isDocumentRecord(record.value)) { 391 391 continue; 392 392 } 393 393 ··· 542 542 collection: parsed.collection, 543 543 rkey: parsed.rkey, 544 544 record, 545 + }); 546 + } 547 + 548 + export async function deleteRecord(agent: Agent, atUri: string): Promise<void> { 549 + const parsed = parseAtUri(atUri); 550 + if (!parsed) throw new Error(`Invalid atUri format: ${atUri}`); 551 + await agent.com.atproto.repo.deleteRecord({ 552 + repo: parsed.did, 553 + collection: parsed.collection, 554 + rkey: parsed.rkey, 545 555 }); 546 556 } 547 557
+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
··· 21 21 const match = content.match(frontmatterRegex); 22 22 23 23 if (!match) { 24 - const [, titleMatch] = content.trim().match(/^# (.+)$/m) || [] 25 - const title = titleMatch ?? "" 26 - const [publishDate] = new Date().toISOString().split("T") 24 + const [, titleMatch] = content.trim().match(/^# (.+)$/m) || []; 25 + const title = titleMatch ?? ""; 26 + const [publishDate] = new Date().toISOString().split("T"); 27 27 28 - return { 29 - frontmatter: { 30 - title, 31 - publishDate: publishDate ?? "" 32 - }, 33 - body: content, 34 - rawFrontmatter: { 35 - title: 36 - publishDate 37 - } 38 - } 28 + return { 29 + frontmatter: { 30 + title, 31 + publishDate: publishDate ?? "", 32 + }, 33 + body: content, 34 + rawFrontmatter: { 35 + title: publishDate, 36 + }, 37 + }; 39 38 } 40 39 41 40 const delimiter = match[1]; ··· 397 396 const afterEnd = rawContent.slice(frontmatterEndIndex); 398 397 399 398 return `${beforeEnd}${atUriEntry}\n${afterEnd}`; 399 + } 400 + 401 + export function removeFrontmatterAtUri(rawContent: string): string { 402 + const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n/; 403 + const match = rawContent.match(frontmatterRegex); 404 + if (!match) return rawContent; 405 + 406 + const delimiter = match[1]; 407 + const frontmatterStr = match[2] ?? ""; 408 + 409 + // Remove the atUri line 410 + const lines = frontmatterStr 411 + .split("\n") 412 + .filter((line) => !line.match(/^\s*atUri\s*[=:]\s*/)); 413 + 414 + // Check if remaining frontmatter has any non-empty lines 415 + const hasContent = lines.some((line) => line.trim() !== ""); 416 + 417 + const afterFrontmatter = rawContent.slice(match[0].length); 418 + 419 + if (!hasContent) { 420 + // Remove entire frontmatter block, trim leading newlines 421 + return afterFrontmatter.replace(/^\n+/, ""); 422 + } 423 + 424 + return `${delimiter}\n${lines.join("\n")}\n${delimiter}\n${afterFrontmatter}`; 400 425 } 401 426 402 427 export function stripMarkdownForText(markdown: string): string {
+3 -3
packages/cli/src/lib/types.ts
··· 85 85 86 86 export interface PostFrontmatter { 87 87 title: string; 88 - theme?: string 89 - fontFamily?: string 90 - fontSize?: number 88 + theme?: string; 89 + fontFamily?: string; 90 + fontSize?: number; 91 91 description?: string; 92 92 publishDate: string; 93 93 tags?: 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 + }