this repo has no description

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.

authored by

Julien Calixte and committed by
Julien Calixte
8d109340 608190ee

+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
··· 26 26 } from "../lib/markdown"; 27 27 import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; 28 28 import { exitOnCancel } from "../lib/prompts"; 29 - import { createNote, updateNote } from "./publish-lite" 29 + import { createNote, updateNote, type NoteOptions } from "./publish-lite" 30 30 31 31 export const publishCommand = command({ 32 32 name: "publish", ··· 282 282 let errorCount = 0; 283 283 let bskyPostCount = 0; 284 284 285 + const context: NoteOptions = { 286 + contentDir, 287 + imagesDir, 288 + allPosts: posts, 289 + }; 290 + 285 291 for (const { post, action } of postsToPublish) { 286 292 s.start(`Publishing: ${post.frontmatter.title}`); 287 293 ··· 317 323 318 324 if (action === "create") { 319 325 atUri = await createDocument(agent, post, config, coverImage); 320 - await createNote(agent, post, atUri) 326 + await createNote(agent, post, atUri, context) 321 327 s.stop(`Created: ${atUri}`); 322 328 323 329 // Update frontmatter with atUri ··· 334 340 } else { 335 341 atUri = post.frontmatter.atUri!; 336 342 await updateDocument(agent, post, atUri, config, coverImage); 337 - await updateNote(agent, post, atUri) 343 + await updateNote(agent, post, atUri, context) 338 344 s.stop(`Updated: ${atUri}`); 339 345 340 346 // For updates, rawContent already has atUri