A CLI for publishing standard.site documents to ATProto

feat: make litenote records self-sufficient with inline images and link cleanup

Upload local images as blobs and replace paths with CIDs in markdown content.
Remove links to unpublished notes to avoid dangling references.

+225 -36
+216 -33
packages/cli/src/commands/publish-lite.ts
··· 1 1 import { Agent } from "@atproto/api" 2 - import { BlogPost } from "../lib/types" 2 + import * as fs from "node:fs/promises" 3 + import * as path from "node:path" 4 + import mimeTypes from "mime-types" 5 + import { BlogPost, BlobObject } from "../lib/types" 3 6 4 7 const LEXICON = "space.litenote.note" 5 8 const MAX_CONTENT = 10000 6 9 10 + interface ImageRecord { 11 + image: BlobObject 12 + alt?: string 13 + } 14 + 15 + export interface NoteOptions { 16 + contentDir: string 17 + imagesDir?: string 18 + allPosts: BlogPost[] 19 + } 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 + } 29 + 30 + function isLocalPath(url: string): boolean { 31 + return ( 32 + !url.startsWith("http://") && 33 + !url.startsWith("https://") && 34 + !url.startsWith("#") && 35 + !url.startsWith("mailto:") 36 + ) 37 + } 38 + 39 + async function resolveLocalImagePath( 40 + src: string, 41 + postFilePath: string, 42 + contentDir: string, 43 + imagesDir?: string, 44 + ): Promise<string | null> { 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 + // Try stripping a leading directory that matches imagesDir basename 52 + const baseName = path.basename(imagesDir) 53 + const idx = src.indexOf(baseName) 54 + if (idx !== -1) { 55 + const after = src.substring(idx + baseName.length).replace(/^[/\\]/, "") 56 + candidates.push(path.resolve(imagesDir, after)) 57 + } 58 + } 59 + 60 + for (const candidate of candidates) { 61 + try { 62 + const stat = await fs.stat(candidate) 63 + if (stat.isFile() && stat.size > 0) return candidate 64 + } catch {} 65 + } 66 + return null 67 + } 68 + 69 + async function uploadBlob( 70 + agent: Agent, 71 + filePath: string, 72 + ): Promise<BlobObject | undefined> { 73 + if (!(await fileExists(filePath))) return undefined 74 + 75 + try { 76 + const imageBuffer = await fs.readFile(filePath) 77 + const mimeType = mimeTypes.lookup(filePath) || "application/octet-stream" 78 + const response = await agent.com.atproto.repo.uploadBlob( 79 + new Uint8Array(imageBuffer), 80 + { encoding: mimeType }, 81 + ) 82 + return { 83 + $type: "blob", 84 + ref: { $link: response.data.blob.ref.toString() }, 85 + mimeType, 86 + size: imageBuffer.byteLength, 87 + } 88 + } catch (error) { 89 + console.error(`Error uploading blob ${filePath}:`, error) 90 + return undefined 91 + } 92 + } 93 + 94 + async function processImages( 95 + agent: Agent, 96 + content: string, 97 + postFilePath: string, 98 + contentDir: string, 99 + imagesDir?: string, 100 + ): Promise<{ content: string; images: ImageRecord[] }> { 101 + const images: ImageRecord[] = [] 102 + const uploadCache = new Map<string, BlobObject>() 103 + let processedContent = content 104 + 105 + const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g 106 + const matches = [...content.matchAll(imageRegex)] 107 + 108 + for (const match of matches) { 109 + const fullMatch = match[0] 110 + const alt = match[1] ?? "" 111 + const src = match[2]! 112 + if (!isLocalPath(src)) continue 113 + 114 + const resolved = await resolveLocalImagePath( 115 + src, postFilePath, contentDir, imagesDir, 116 + ) 117 + if (!resolved) continue 118 + 119 + let blob = uploadCache.get(resolved) 120 + if (!blob) { 121 + blob = await uploadBlob(agent, resolved) 122 + if (!blob) continue 123 + uploadCache.set(resolved, blob) 124 + } 125 + 126 + images.push({ image: blob, alt: alt || undefined }) 127 + processedContent = processedContent.replace( 128 + fullMatch, 129 + `![${alt}](${blob.ref.$link})`, 130 + ) 131 + } 132 + 133 + return { content: processedContent, images } 134 + } 135 + 136 + function removeUnpublishedLinks( 137 + content: string, 138 + allPosts: BlogPost[], 139 + ): string { 140 + const linkRegex = /(?<!!)\[([^\]]+)\]\(([^)]+)\)/g 141 + 142 + return content.replace(linkRegex, (fullMatch, text, url) => { 143 + if (!isLocalPath(url)) return fullMatch 144 + 145 + // Normalize to a slug-like string for comparison 146 + const normalized = url 147 + .replace(/^\.?\/?/, "") 148 + .replace(/\/?$/, "") 149 + .replace(/\.mdx?$/, "") 150 + .replace(/\/index$/, "") 151 + 152 + const isPublished = allPosts.some((p) => { 153 + if (!p.frontmatter.atUri) return false 154 + return ( 155 + p.slug === normalized || 156 + p.slug.endsWith(`/${normalized}`) || 157 + normalized.endsWith(`/${p.slug}`) 158 + ) 159 + }) 160 + 161 + if (!isPublished) return text 162 + return fullMatch 163 + }) 164 + } 165 + 166 + async function processNoteContent( 167 + agent: Agent, 168 + post: BlogPost, 169 + options: NoteOptions, 170 + ): Promise<{ content: string; images: ImageRecord[] }> { 171 + let content = post.content.trim() 172 + 173 + content = removeUnpublishedLinks(content, options.allPosts) 174 + 175 + const result = await processImages( 176 + agent, content, post.filePath, options.contentDir, options.imagesDir, 177 + ) 178 + 179 + return result 180 + } 181 + 182 + function parseRkey(atUri: string): string { 183 + const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/) 184 + if (!uriMatch) { 185 + throw new Error(`Invalid atUri format: ${atUri}`) 186 + } 187 + return uriMatch[3]! 188 + } 189 + 7 190 export async function createNote( 8 191 agent: Agent, 9 192 post: BlogPost, 10 193 atUri: string, 194 + options: NoteOptions, 11 195 ): Promise<void> { 12 - // Parse the atUri to get the site.standard.document rkey 13 - // Format: at://did:plc:xxx/collection/rkey 14 - const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 15 - if (!uriMatch) { 16 - throw new Error(`Invalid atUri format: ${atUri}`); 17 - } 196 + const rkey = parseRkey(atUri) 197 + const publishDate = new Date(post.frontmatter.publishDate).toISOString() 198 + const trimmedContent = post.content.trim() 199 + const titleMatch = trimmedContent.match(/^# (.+)$/m) 200 + const title = titleMatch ? titleMatch[1] : post.frontmatter.title 18 201 19 - const [, , , rkey] = uriMatch; 20 - const publishDate = new Date(post.frontmatter.publishDate).toISOString(); 21 - const trimmedContent = post.content.trim(); 22 - const titleMatch = trimmedContent.match(/^# (.+)$/m); 23 - const title = titleMatch ? titleMatch[1] : post.frontmatter.title; 202 + const { content, images } = await processNoteContent(agent, post, options) 24 203 25 204 const record: Record<string, unknown> = { 26 205 $type: LEXICON, 27 206 title, 28 - content: trimmedContent.slice(0, MAX_CONTENT), 207 + content: content.slice(0, MAX_CONTENT), 29 208 createdAt: publishDate, 30 209 publishedAt: publishDate, 31 - }; 210 + } 211 + 212 + if (images.length > 0) { 213 + record.images = images 214 + } 32 215 33 - const response = await agent.com.atproto.repo.createRecord({ 216 + await agent.com.atproto.repo.createRecord({ 34 217 repo: agent.did!, 35 218 collection: LEXICON, 36 219 record, 37 220 rkey, 38 - validate: false 39 - }); 221 + validate: false, 222 + }) 40 223 } 41 224 42 225 export async function updateNote( 43 226 agent: Agent, 44 227 post: BlogPost, 45 228 atUri: string, 229 + options: NoteOptions, 46 230 ): Promise<void> { 47 - // Parse the atUri to get the rkey 48 - // Format: at://did:plc:xxx/collection/rkey 49 - const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 50 - if (!uriMatch) { 51 - throw new Error(`Invalid atUri format: ${atUri}`); 52 - } 231 + const rkey = parseRkey(atUri) 232 + const publishDate = new Date(post.frontmatter.publishDate).toISOString() 233 + const trimmedContent = post.content.trim() 234 + const titleMatch = trimmedContent.match(/^# (.+)$/m) 235 + const title = titleMatch ? titleMatch[1] : post.frontmatter.title 53 236 54 - const [, , , rkey] = uriMatch; 55 - const publishDate = new Date(post.frontmatter.publishDate).toISOString(); 56 - const trimmedContent = post.content.trim(); 57 - const titleMatch = trimmedContent.match(/^# (.+)$/m); 58 - const title = titleMatch ? titleMatch[1] : post.frontmatter.title; 237 + const { content, images } = await processNoteContent(agent, post, options) 59 238 60 239 const record: Record<string, unknown> = { 61 240 $type: LEXICON, 62 241 title, 63 - content: trimmedContent.slice(0, MAX_CONTENT), 242 + content: content.slice(0, MAX_CONTENT), 64 243 createdAt: publishDate, 65 244 publishedAt: publishDate, 66 - }; 245 + } 246 + 247 + if (images.length > 0) { 248 + record.images = images 249 + } 67 250 68 - const response = await agent.com.atproto.repo.putRecord({ 251 + await agent.com.atproto.repo.putRecord({ 69 252 repo: agent.did!, 70 253 collection: LEXICON, 71 254 rkey: rkey!, 72 255 record, 73 - validate: false 74 - }); 256 + validate: false, 257 + }) 75 258 }
+9 -3
packages/cli/src/commands/publish.ts
··· 25 25 } from "../lib/markdown"; 26 26 import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; 27 27 import { exitOnCancel } from "../lib/prompts"; 28 - import { createNote, updateNote } from "./publish-lite" 28 + import { createNote, updateNote, type NoteOptions } from "./publish-lite" 29 29 30 30 export const publishCommand = command({ 31 31 name: "publish", ··· 265 265 let errorCount = 0; 266 266 let bskyPostCount = 0; 267 267 268 + const context: NoteOptions = { 269 + contentDir, 270 + imagesDir, 271 + allPosts: posts, 272 + }; 273 + 268 274 for (const { post, action } of postsToPublish) { 269 275 s.start(`Publishing: ${post.frontmatter.title}`); 270 276 ··· 300 306 301 307 if (action === "create") { 302 308 atUri = await createDocument(agent, post, config, coverImage); 303 - await createNote(agent, post, atUri) 309 + await createNote(agent, post, atUri, context) 304 310 s.stop(`Created: ${atUri}`); 305 311 306 312 // Update frontmatter with atUri ··· 317 323 } else { 318 324 atUri = post.frontmatter.atUri!; 319 325 await updateDocument(agent, post, atUri, config, coverImage); 320 - await updateNote(agent, post, atUri) 326 + await updateNote(agent, post, atUri, context) 321 327 s.stop(`Updated: ${atUri}`); 322 328 323 329 // For updates, rawContent already has atUri