forked from
stevedylan.dev/sequoia
A CLI for publishing standard.site documents to ATProto
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 ``,
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}