A CLI for publishing standard.site documents to ATProto
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 308 lines 7.2 kB view raw
1import type { Agent } from "@atproto/api"; 2import * as fs from "node:fs/promises"; 3import * as path from "node:path"; 4import mimeTypes from "mime-types"; 5import type { BlogPost, BlobObject } from "../lib/types"; 6 7const LEXICON = "space.remanso.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( 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 } 119 120 images.push({ image: blob, alt: alt || undefined }); 121 processedContent = processedContent.replace( 122 fullMatch, 123 `![${alt}](${blob.ref.$link})`, 124 ); 125 } 126 127 return { content: processedContent, images }; 128} 129 130export function resolveInternalLinks( 131 content: string, 132 allPosts: BlogPost[], 133): string { 134 const linkRegex = /(?<![!@])\[([^\]]+)\]\(([^)]+)\)/g; 135 136 return content.replace(linkRegex, (fullMatch, text, url) => { 137 if (!isLocalPath(url)) return fullMatch; 138 139 // Normalize to a slug-like string for comparison 140 const normalized = url 141 .replace(/^(\.\.\/|\.\/)+/, "") 142 .replace(/\/?$/, "") 143 .replace(/\.mdx?$/, "") 144 .replace(/\/index$/, ""); 145 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 }); 154 155 if (!matchedPost) return text; 156 157 const noteUri = matchedPost.frontmatter.atUri!.replace( 158 /\/[^/]+\/([^/]+)$/, 159 `/space.remanso.note/$1`, 160 ); 161 return `[${text}](${noteUri})`; 162 }); 163} 164 165async function processNoteContent( 166 agent: Agent, 167 post: BlogPost, 168 options: NoteOptions, 169): Promise<{ content: string; images: ImageRecord[] }> { 170 let content = post.content.trim(); 171 172 content = resolveInternalLinks(content, options.allPosts); 173 174 const result = await processImages( 175 agent, 176 content, 177 post.filePath, 178 options.contentDir, 179 options.imagesDir, 180 ); 181 182 return result; 183} 184 185function parseRkey(atUri: string): string { 186 const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 187 if (!uriMatch) { 188 throw new Error(`Invalid atUri format: ${atUri}`); 189 } 190 return uriMatch[3]!; 191} 192 193async function buildNoteRecord( 194 agent: Agent, 195 post: BlogPost, 196 options: NoteOptions, 197): Promise<Record<string, unknown>> { 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; 202 203 const { content, images } = await processNoteContent(agent, post, options); 204 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 }; 212 213 if (images.length > 0) { 214 record.images = images; 215 } 216 217 if (post.frontmatter.theme) { 218 record.theme = post.frontmatter.theme; 219 } 220 221 if (post.frontmatter.fontSize) { 222 record.fontSize = post.frontmatter.fontSize; 223 } 224 225 if (post.frontmatter.fontFamily) { 226 record.fontFamily = post.frontmatter.fontFamily; 227 } 228 229 return record; 230} 231 232export 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 }); 239} 240 241export async function createNote( 242 agent: Agent, 243 post: BlogPost, 244 atUri: string, 245 options: NoteOptions, 246): Promise<void> { 247 const rkey = parseRkey(atUri); 248 const record = await buildNoteRecord(agent, post, options); 249 250 await agent.com.atproto.repo.createRecord({ 251 repo: agent.did!, 252 collection: LEXICON, 253 record, 254 rkey, 255 validate: false, 256 }); 257} 258 259export async function updateNote( 260 agent: Agent, 261 post: BlogPost, 262 atUri: string, 263 options: NoteOptions, 264): Promise<void> { 265 const rkey = parseRkey(atUri); 266 const record = await buildNoteRecord(agent, post, options); 267 268 await agent.com.atproto.repo.putRecord({ 269 repo: agent.did!, 270 collection: LEXICON, 271 rkey: rkey!, 272 record, 273 validate: false, 274 }); 275} 276 277export function findPostsWithStaleLinks( 278 allPosts: BlogPost[], 279 newSlugs: string[], 280 excludeFilePaths: Set<string>, 281): BlogPost[] { 282 const linkRegex = /(?<![!@])\[([^\]]+)\]\(([^)]+)\)/g; 283 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; 288 289 const matches = [...post.content.matchAll(linkRegex)]; 290 return matches.some((match) => { 291 const url = match[2]!; 292 if (!isLocalPath(url)) return false; 293 294 const normalized = url 295 .replace(/^(\.\.\/|\.\/)+/, "") 296 .replace(/\/?$/, "") 297 .replace(/\.mdx?$/, "") 298 .replace(/\/index$/, ""); 299 300 return newSlugs.some( 301 (slug) => 302 slug === normalized || 303 slug.endsWith(`/${normalized}`) || 304 normalized.endsWith(`/${slug}`), 305 ); 306 }); 307 }); 308}