diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index 552f70e..cedd58d 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -26,7 +26,7 @@ import { } from "../lib/markdown"; import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; import { exitOnCancel } from "../lib/prompts"; -import { createNote, updateNote, type NoteOptions } from "../extensions/litenote" +import { createNote, updateNote, findPostsWithStaleLinks, type NoteOptions } from "../extensions/litenote" export const publishCommand = command({ name: "publish", @@ -288,6 +288,13 @@ export const publishCommand = command({ allPosts: posts, }; + // Pass 1: Create/update document records and collect note queue + const noteQueue: Array<{ + post: BlogPost; + action: "create" | "update"; + atUri: string; + }> = []; + for (const { post, action } of postsToPublish) { s.start(`Publishing: ${post.frontmatter.title}`); @@ -323,7 +330,7 @@ export const publishCommand = command({ if (action === "create") { atUri = await createDocument(agent, post, config, coverImage); - await createNote(agent, post, atUri, context) + post.frontmatter.atUri = atUri; s.stop(`Created: ${atUri}`); // Update frontmatter with atUri @@ -340,7 +347,6 @@ export const publishCommand = command({ } else { atUri = post.frontmatter.atUri!; await updateDocument(agent, post, atUri, config, coverImage); - await updateNote(agent, post, atUri, context) s.stop(`Updated: ${atUri}`); // For updates, rawContent already has atUri @@ -397,6 +403,8 @@ export const publishCommand = command({ slug: post.slug, bskyPostRef, }; + + noteQueue.push({ post, action, atUri }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -406,6 +414,53 @@ export const publishCommand = command({ } } + // Pass 2: Create/update litenote notes (atUris are now available for link resolution) + for (const { post, action, atUri } of noteQueue) { + try { + if (action === "create") { + await createNote(agent, post, atUri, context); + } else { + await updateNote(agent, post, atUri, context); + } + } catch (error) { + log.warn( + `Failed to create note for "${post.frontmatter.title}": ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + // Re-process already-published posts with stale links to newly created posts + const newlyCreatedSlugs = noteQueue + .filter((r) => r.action === "create") + .map((r) => r.post.slug); + + if (newlyCreatedSlugs.length > 0) { + const batchFilePaths = new Set(noteQueue.map((r) => r.post.filePath)); + const stalePosts = findPostsWithStaleLinks( + posts, + newlyCreatedSlugs, + batchFilePaths, + ); + + for (const stalePost of stalePosts) { + try { + s.start(`Updating links in: ${stalePost.frontmatter.title}`); + await updateNote( + agent, + stalePost, + stalePost.frontmatter.atUri!, + context, + ); + s.stop(`Updated links: ${stalePost.frontmatter.title}`); + } catch (error) { + s.stop(`Failed to update links: ${stalePost.frontmatter.title}`); + log.warn( + ` ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } + // Save state await saveState(configDir, state); diff --git a/packages/cli/src/extensions/litenote.test.ts b/packages/cli/src/extensions/litenote.test.ts index 5e14552..1ed84c9 100644 --- a/packages/cli/src/extensions/litenote.test.ts +++ b/packages/cli/src/extensions/litenote.test.ts @@ -1,17 +1,22 @@ import { describe, expect, test } from "bun:test"; -import { resolveInternalLinks } from "./litenote"; +import { resolveInternalLinks, findPostsWithStaleLinks } from "./litenote"; import type { BlogPost } from "../lib/types"; -function makePost(slug: string, atUri?: string): BlogPost { +function makePost( + slug: string, + atUri?: string, + options?: { content?: string; draft?: boolean; filePath?: string }, +): BlogPost { return { - filePath: `content/${slug}.md`, + filePath: options?.filePath ?? `content/${slug}.md`, slug, frontmatter: { title: slug, publishDate: "2024-01-01", atUri, + draft: options?.draft, }, - content: "", + content: options?.content ?? "", rawContent: "", rawFrontmatter: {}, }; @@ -129,3 +134,105 @@ describe("resolveInternalLinks", () => { ); }); }); + +describe("findPostsWithStaleLinks", () => { + test("finds published post containing link to a newly created slug", () => { + const posts = [ + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { + content: "Check out [post B](./post-b)", + }), + ]; + const result = findPostsWithStaleLinks(posts, ["post-b"], new Set()); + expect(result).toHaveLength(1); + expect(result[0]!.slug).toBe("post-a"); + }); + + test("excludes posts in the exclude set (current batch)", () => { + const posts = [ + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { + content: "Check out [post B](./post-b)", + }), + ]; + const result = findPostsWithStaleLinks( + posts, + ["post-b"], + new Set(["content/post-a.md"]), + ); + expect(result).toHaveLength(0); + }); + + test("excludes unpublished posts (no atUri)", () => { + const posts = [ + makePost("post-a", undefined, { + content: "Check out [post B](./post-b)", + }), + ]; + const result = findPostsWithStaleLinks(posts, ["post-b"], new Set()); + expect(result).toHaveLength(0); + }); + + test("excludes drafts", () => { + const posts = [ + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { + content: "Check out [post B](./post-b)", + draft: true, + }), + ]; + const result = findPostsWithStaleLinks(posts, ["post-b"], new Set()); + expect(result).toHaveLength(0); + }); + + test("ignores external links", () => { + const posts = [ + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { + content: "Check out [post B](https://example.com/post-b)", + }), + ]; + const result = findPostsWithStaleLinks(posts, ["post-b"], new Set()); + expect(result).toHaveLength(0); + }); + + test("ignores image embeds", () => { + const posts = [ + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { + content: "![post B](./post-b)", + }), + ]; + const result = findPostsWithStaleLinks(posts, ["post-b"], new Set()); + expect(result).toHaveLength(0); + }); + + test("ignores @mention links", () => { + const posts = [ + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { + content: "@[post B](./post-b)", + }), + ]; + const result = findPostsWithStaleLinks(posts, ["post-b"], new Set()); + expect(result).toHaveLength(0); + }); + + test("handles nested slug matching", () => { + const posts = [ + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { + content: "Check out [post](my-post)", + }), + ]; + const result = findPostsWithStaleLinks( + posts, + ["blog/my-post"], + new Set(), + ); + expect(result).toHaveLength(1); + }); + + test("does not match posts without matching links", () => { + const posts = [ + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { + content: "Check out [post C](./post-c)", + }), + ]; + const result = findPostsWithStaleLinks(posts, ["post-b"], new Set()); + expect(result).toHaveLength(0); + }); +}); diff --git a/packages/cli/src/extensions/litenote.ts b/packages/cli/src/extensions/litenote.ts index 42b9cd6..0e39447 100644 --- a/packages/cli/src/extensions/litenote.ts +++ b/packages/cli/src/extensions/litenote.ts @@ -27,7 +27,7 @@ async function fileExists(filePath: string): Promise { } } -function isLocalPath(url: string): boolean { +export function isLocalPath(url: string): boolean { return ( !url.startsWith("http://") && !url.startsWith("https://") && @@ -250,3 +250,36 @@ export async function updateNote( validate: false, }) } + +export function findPostsWithStaleLinks( + allPosts: BlogPost[], + newSlugs: string[], + excludeFilePaths: Set, +): BlogPost[] { + const linkRegex = /(? { + if (excludeFilePaths.has(post.filePath)) return false + if (!post.frontmatter.atUri) return false + if (post.frontmatter.draft) return false + + const matches = [...post.content.matchAll(linkRegex)] + return matches.some((match) => { + const url = match[2]! + if (!isLocalPath(url)) return false + + const normalized = url + .replace(/^\.?\/?/, "") + .replace(/\/?$/, "") + .replace(/\.mdx?$/, "") + .replace(/\/index$/, "") + + return newSlugs.some( + (slug) => + slug === normalized || + slug.endsWith(`/${normalized}`) || + normalized.endsWith(`/${slug}`), + ) + }) + }) +}