A CLI for publishing standard.site documents to ATProto

fix: links from new notes

+203 -8
+58 -3
packages/cli/src/commands/publish.ts
··· 25 25 } from "../lib/markdown"; 26 26 import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; 27 27 import { exitOnCancel } from "../lib/prompts"; 28 - import { createNote, updateNote, type NoteOptions } from "../extensions/litenote" 28 + import { createNote, updateNote, findPostsWithStaleLinks, type NoteOptions } from "../extensions/litenote" 29 29 30 30 export const publishCommand = command({ 31 31 name: "publish", ··· 271 271 allPosts: posts, 272 272 }; 273 273 274 + // Pass 1: Create/update document records and collect note queue 275 + const noteQueue: Array<{ 276 + post: BlogPost; 277 + action: "create" | "update"; 278 + atUri: string; 279 + }> = []; 280 + 274 281 for (const { post, action } of postsToPublish) { 275 282 s.start(`Publishing: ${post.frontmatter.title}`); 276 283 ··· 306 313 307 314 if (action === "create") { 308 315 atUri = await createDocument(agent, post, config, coverImage); 309 - await createNote(agent, post, atUri, context) 316 + post.frontmatter.atUri = atUri; 310 317 s.stop(`Created: ${atUri}`); 311 318 312 319 // Update frontmatter with atUri ··· 323 330 } else { 324 331 atUri = post.frontmatter.atUri!; 325 332 await updateDocument(agent, post, atUri, config, coverImage); 326 - await updateNote(agent, post, atUri, context) 327 333 s.stop(`Updated: ${atUri}`); 328 334 329 335 // For updates, rawContent already has atUri ··· 381 387 slug: post.slug, 382 388 bskyPostRef, 383 389 }; 390 + 391 + noteQueue.push({ post, action, atUri }); 384 392 } catch (error) { 385 393 const errorMessage = 386 394 error instanceof Error ? error.message : String(error); 387 395 s.stop(`Error publishing "${path.basename(post.filePath)}"`); 388 396 log.error(` ${errorMessage}`); 389 397 errorCount++; 398 + } 399 + } 400 + 401 + // Pass 2: Create/update litenote notes (atUris are now available for link resolution) 402 + for (const { post, action, atUri } of noteQueue) { 403 + try { 404 + if (action === "create") { 405 + await createNote(agent, post, atUri, context); 406 + } else { 407 + await updateNote(agent, post, atUri, context); 408 + } 409 + } catch (error) { 410 + log.warn( 411 + `Failed to create note for "${post.frontmatter.title}": ${error instanceof Error ? error.message : String(error)}`, 412 + ); 413 + } 414 + } 415 + 416 + // Re-process already-published posts with stale links to newly created posts 417 + const newlyCreatedSlugs = noteQueue 418 + .filter((r) => r.action === "create") 419 + .map((r) => r.post.slug); 420 + 421 + if (newlyCreatedSlugs.length > 0) { 422 + const batchFilePaths = new Set(noteQueue.map((r) => r.post.filePath)); 423 + const stalePosts = findPostsWithStaleLinks( 424 + posts, 425 + newlyCreatedSlugs, 426 + batchFilePaths, 427 + ); 428 + 429 + for (const stalePost of stalePosts) { 430 + try { 431 + s.start(`Updating links in: ${stalePost.frontmatter.title}`); 432 + await updateNote( 433 + agent, 434 + stalePost, 435 + stalePost.frontmatter.atUri!, 436 + context, 437 + ); 438 + s.stop(`Updated links: ${stalePost.frontmatter.title}`); 439 + } catch (error) { 440 + s.stop(`Failed to update links: ${stalePost.frontmatter.title}`); 441 + log.warn( 442 + ` ${error instanceof Error ? error.message : String(error)}`, 443 + ); 444 + } 390 445 } 391 446 } 392 447
+111 -4
packages/cli/src/extensions/litenote.test.ts
··· 1 1 import { describe, expect, test } from "bun:test"; 2 - import { resolveInternalLinks } from "./litenote"; 2 + import { resolveInternalLinks, findPostsWithStaleLinks } from "./litenote"; 3 3 import type { BlogPost } from "../lib/types"; 4 4 5 - function makePost(slug: string, atUri?: string): BlogPost { 5 + function makePost( 6 + slug: string, 7 + atUri?: string, 8 + options?: { content?: string; draft?: boolean; filePath?: string }, 9 + ): BlogPost { 6 10 return { 7 - filePath: `content/${slug}.md`, 11 + filePath: options?.filePath ?? `content/${slug}.md`, 8 12 slug, 9 13 frontmatter: { 10 14 title: slug, 11 15 publishDate: "2024-01-01", 12 16 atUri, 17 + draft: options?.draft, 13 18 }, 14 - content: "", 19 + content: options?.content ?? "", 15 20 rawContent: "", 16 21 rawFrontmatter: {}, 17 22 }; ··· 129 134 ); 130 135 }); 131 136 }); 137 + 138 + describe("findPostsWithStaleLinks", () => { 139 + test("finds published post containing link to a newly created slug", () => { 140 + const posts = [ 141 + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { 142 + content: "Check out [post B](./post-b)", 143 + }), 144 + ]; 145 + const result = findPostsWithStaleLinks(posts, ["post-b"], new Set()); 146 + expect(result).toHaveLength(1); 147 + expect(result[0]!.slug).toBe("post-a"); 148 + }); 149 + 150 + test("excludes posts in the exclude set (current batch)", () => { 151 + const posts = [ 152 + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { 153 + content: "Check out [post B](./post-b)", 154 + }), 155 + ]; 156 + const result = findPostsWithStaleLinks( 157 + posts, 158 + ["post-b"], 159 + new Set(["content/post-a.md"]), 160 + ); 161 + expect(result).toHaveLength(0); 162 + }); 163 + 164 + test("excludes unpublished posts (no atUri)", () => { 165 + const posts = [ 166 + makePost("post-a", undefined, { 167 + content: "Check out [post B](./post-b)", 168 + }), 169 + ]; 170 + const result = findPostsWithStaleLinks(posts, ["post-b"], new Set()); 171 + expect(result).toHaveLength(0); 172 + }); 173 + 174 + test("excludes drafts", () => { 175 + const posts = [ 176 + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { 177 + content: "Check out [post B](./post-b)", 178 + draft: true, 179 + }), 180 + ]; 181 + const result = findPostsWithStaleLinks(posts, ["post-b"], new Set()); 182 + expect(result).toHaveLength(0); 183 + }); 184 + 185 + test("ignores external links", () => { 186 + const posts = [ 187 + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { 188 + content: "Check out [post B](https://example.com/post-b)", 189 + }), 190 + ]; 191 + const result = findPostsWithStaleLinks(posts, ["post-b"], new Set()); 192 + expect(result).toHaveLength(0); 193 + }); 194 + 195 + test("ignores image embeds", () => { 196 + const posts = [ 197 + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { 198 + content: "![post B](./post-b)", 199 + }), 200 + ]; 201 + const result = findPostsWithStaleLinks(posts, ["post-b"], new Set()); 202 + expect(result).toHaveLength(0); 203 + }); 204 + 205 + test("ignores @mention links", () => { 206 + const posts = [ 207 + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { 208 + content: "@[post B](./post-b)", 209 + }), 210 + ]; 211 + const result = findPostsWithStaleLinks(posts, ["post-b"], new Set()); 212 + expect(result).toHaveLength(0); 213 + }); 214 + 215 + test("handles nested slug matching", () => { 216 + const posts = [ 217 + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { 218 + content: "Check out [post](my-post)", 219 + }), 220 + ]; 221 + const result = findPostsWithStaleLinks( 222 + posts, 223 + ["blog/my-post"], 224 + new Set(), 225 + ); 226 + expect(result).toHaveLength(1); 227 + }); 228 + 229 + test("does not match posts without matching links", () => { 230 + const posts = [ 231 + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { 232 + content: "Check out [post C](./post-c)", 233 + }), 234 + ]; 235 + const result = findPostsWithStaleLinks(posts, ["post-b"], new Set()); 236 + expect(result).toHaveLength(0); 237 + }); 238 + });
+34 -1
packages/cli/src/extensions/litenote.ts
··· 27 27 } 28 28 } 29 29 30 - function isLocalPath(url: string): boolean { 30 + export function isLocalPath(url: string): boolean { 31 31 return ( 32 32 !url.startsWith("http://") && 33 33 !url.startsWith("https://") && ··· 250 250 validate: false, 251 251 }) 252 252 } 253 + 254 + export 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 + }