A CLI for publishing standard.site documents to ATProto

fix: links from new notes

+203 -8
+58 -3
packages/cli/src/commands/publish.ts
··· 26 26 } from "../lib/markdown"; 27 27 import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; 28 28 import { exitOnCancel } from "../lib/prompts"; 29 - import { createNote, updateNote, type NoteOptions } from "../extensions/litenote" 29 + import { createNote, updateNote, findPostsWithStaleLinks, type NoteOptions } from "../extensions/litenote" 30 30 31 31 export const publishCommand = command({ 32 32 name: "publish", ··· 288 288 allPosts: posts, 289 289 }; 290 290 291 + // Pass 1: Create/update document records and collect note queue 292 + const noteQueue: Array<{ 293 + post: BlogPost; 294 + action: "create" | "update"; 295 + atUri: string; 296 + }> = []; 297 + 291 298 for (const { post, action } of postsToPublish) { 292 299 s.start(`Publishing: ${post.frontmatter.title}`); 293 300 ··· 323 330 324 331 if (action === "create") { 325 332 atUri = await createDocument(agent, post, config, coverImage); 326 - await createNote(agent, post, atUri, context) 333 + post.frontmatter.atUri = atUri; 327 334 s.stop(`Created: ${atUri}`); 328 335 329 336 // Update frontmatter with atUri ··· 340 347 } else { 341 348 atUri = post.frontmatter.atUri!; 342 349 await updateDocument(agent, post, atUri, config, coverImage); 343 - await updateNote(agent, post, atUri, context) 344 350 s.stop(`Updated: ${atUri}`); 345 351 346 352 // For updates, rawContent already has atUri ··· 397 403 slug: post.slug, 398 404 bskyPostRef, 399 405 }; 406 + 407 + noteQueue.push({ post, action, atUri }); 400 408 } catch (error) { 401 409 const errorMessage = 402 410 error instanceof Error ? error.message : String(error); 403 411 s.stop(`Error publishing "${path.basename(post.filePath)}"`); 404 412 log.error(` ${errorMessage}`); 405 413 errorCount++; 414 + } 415 + } 416 + 417 + // Pass 2: Create/update litenote notes (atUris are now available for link resolution) 418 + for (const { post, action, atUri } of noteQueue) { 419 + try { 420 + if (action === "create") { 421 + await createNote(agent, post, atUri, context); 422 + } else { 423 + await updateNote(agent, post, atUri, context); 424 + } 425 + } catch (error) { 426 + log.warn( 427 + `Failed to create note for "${post.frontmatter.title}": ${error instanceof Error ? error.message : String(error)}`, 428 + ); 429 + } 430 + } 431 + 432 + // Re-process already-published posts with stale links to newly created posts 433 + const newlyCreatedSlugs = noteQueue 434 + .filter((r) => r.action === "create") 435 + .map((r) => r.post.slug); 436 + 437 + if (newlyCreatedSlugs.length > 0) { 438 + const batchFilePaths = new Set(noteQueue.map((r) => r.post.filePath)); 439 + const stalePosts = findPostsWithStaleLinks( 440 + posts, 441 + newlyCreatedSlugs, 442 + batchFilePaths, 443 + ); 444 + 445 + for (const stalePost of stalePosts) { 446 + try { 447 + s.start(`Updating links in: ${stalePost.frontmatter.title}`); 448 + await updateNote( 449 + agent, 450 + stalePost, 451 + stalePost.frontmatter.atUri!, 452 + context, 453 + ); 454 + s.stop(`Updated links: ${stalePost.frontmatter.title}`); 455 + } catch (error) { 456 + s.stop(`Failed to update links: ${stalePost.frontmatter.title}`); 457 + log.warn( 458 + ` ${error instanceof Error ? error.message : String(error)}`, 459 + ); 460 + } 406 461 } 407 462 } 408 463
+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 + }