A CLI for publishing standard.site documents to ATProto
at main 285 lines 7.3 kB view raw
1import { Agent } from "@atproto/api" 2import * as fs from "node:fs/promises" 3import * as path from "node:path" 4import mimeTypes from "mime-types" 5import { BlogPost, BlobObject } from "../lib/types" 6 7const LEXICON = "space.litenote.note" 8const MAX_CONTENT = 10000 9 10interface ImageRecord { 11 image: BlobObject 12 alt?: string 13} 14 15export interface NoteOptions { 16 contentDir: string 17 imagesDir?: string 18 allPosts: BlogPost[] 19} 20 21async function fileExists(filePath: string): Promise<boolean> { 22 try { 23 await fs.access(filePath) 24 return true 25 } catch { 26 return false 27 } 28} 29 30export 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 39function getImageCandidates( 40 src: string, 41 postFilePath: string, 42 contentDir: string, 43 imagesDir?: string, 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 59} 60 61async function uploadBlob( 62 agent: Agent, 63 candidates: string[], 64): Promise<BlobObject | undefined> { 65 for (const filePath of candidates) { 66 if (!(await fileExists(filePath))) continue 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 85} 86 87async function processImages( 88 agent: Agent, 89 content: string, 90 postFilePath: string, 91 contentDir: string, 92 imagesDir?: string, 93): Promise<{ content: string; images: ImageRecord[] }> { 94 const images: ImageRecord[] = [] 95 const uploadCache = new Map<string, BlobObject>() 96 let processedContent = content 97 98 const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g 99 const matches = [...content.matchAll(imageRegex)] 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 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 } 114 115 images.push({ image: blob, alt: alt || undefined }) 116 processedContent = processedContent.replace( 117 fullMatch, 118 `![${alt}](${blob.ref.$link})`, 119 ) 120 } 121 122 return { content: processedContent, images } 123} 124 125export function resolveInternalLinks( 126 content: string, 127 allPosts: BlogPost[], 128): string { 129 const linkRegex = /(?<![!@])\[([^\]]+)\]\(([^)]+)\)/g 130 131 return content.replace(linkRegex, (fullMatch, text, url) => { 132 if (!isLocalPath(url)) return fullMatch 133 134 // Normalize to a slug-like string for comparison 135 const normalized = url 136 .replace(/^\.?\/?/, "") 137 .replace(/\/?$/, "") 138 .replace(/\.mdx?$/, "") 139 .replace(/\/index$/, "") 140 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 }) 149 150 if (!matchedPost) return text 151 152 const noteUri = matchedPost.frontmatter.atUri!.replace( 153 /\/[^/]+\/([^/]+)$/, 154 `/space.litenote.note/$1`, 155 ) 156 return `[${text}](${noteUri})` 157 }) 158} 159 160async function processNoteContent( 161 agent: Agent, 162 post: BlogPost, 163 options: NoteOptions, 164): Promise<{ content: string; images: ImageRecord[] }> { 165 let content = post.content.trim() 166 167 content = resolveInternalLinks(content, options.allPosts) 168 169 const result = await processImages( 170 agent, content, post.filePath, options.contentDir, options.imagesDir, 171 ) 172 173 return result 174} 175 176function 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]! 182} 183 184export async function createNote( 185 agent: Agent, 186 post: BlogPost, 187 atUri: string, 188 options: NoteOptions, 189): Promise<void> { 190 const rkey = parseRkey(atUri) 191 const publishDate = new Date(post.frontmatter.publishDate).toISOString() 192 const trimmedContent = post.content.trim() 193 const titleMatch = trimmedContent.match(/^# (.+)$/m) 194 const title = titleMatch ? titleMatch[1] : post.frontmatter.title 195 196 const { content, images } = await processNoteContent(agent, post, options) 197 198 const record: Record<string, unknown> = { 199 $type: LEXICON, 200 title, 201 content: content.slice(0, MAX_CONTENT), 202 createdAt: publishDate, 203 publishedAt: publishDate, 204 } 205 206 if (images.length > 0) { 207 record.images = images 208 } 209 210 await agent.com.atproto.repo.createRecord({ 211 repo: agent.did!, 212 collection: LEXICON, 213 record, 214 rkey, 215 validate: false, 216 }) 217} 218 219export async function updateNote( 220 agent: Agent, 221 post: BlogPost, 222 atUri: string, 223 options: NoteOptions, 224): Promise<void> { 225 const rkey = parseRkey(atUri) 226 const publishDate = new Date(post.frontmatter.publishDate).toISOString() 227 const trimmedContent = post.content.trim() 228 const titleMatch = trimmedContent.match(/^# (.+)$/m) 229 const title = titleMatch ? titleMatch[1] : post.frontmatter.title 230 231 const { content, images } = await processNoteContent(agent, post, options) 232 233 const record: Record<string, unknown> = { 234 $type: LEXICON, 235 title, 236 content: content.slice(0, MAX_CONTENT), 237 createdAt: publishDate, 238 publishedAt: publishDate, 239 } 240 241 if (images.length > 0) { 242 record.images = images 243 } 244 245 await agent.com.atproto.repo.putRecord({ 246 repo: agent.did!, 247 collection: LEXICON, 248 rkey: rkey!, 249 record, 250 validate: false, 251 }) 252} 253 254export function findPostsWithStaleLinks( 255 allPosts: BlogPost[], 256 newSlugs: string[], 257 excludeFilePaths: Set<string>, 258): BlogPost[] { 259 const linkRegex = /(?<![!@])\[([^\]]+)\]\(([^)]+)\)/g 260 261 return allPosts.filter((post) => { 262 if (excludeFilePaths.has(post.filePath)) return false 263 if (!post.frontmatter.atUri) return false 264 if (post.frontmatter.draft) return false 265 266 const matches = [...post.content.matchAll(linkRegex)] 267 return matches.some((match) => { 268 const url = match[2]! 269 if (!isLocalPath(url)) return false 270 271 const normalized = url 272 .replace(/^\.?\/?/, "") 273 .replace(/\/?$/, "") 274 .replace(/\.mdx?$/, "") 275 .replace(/\/index$/, "") 276 277 return newSlugs.some( 278 (slug) => 279 slug === normalized || 280 slug.endsWith(`/${normalized}`) || 281 normalized.endsWith(`/${slug}`), 282 ) 283 }) 284 }) 285}