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