From 4fc99b494423b9909baee8f6abd0197597d6ad5b Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sun, 22 Feb 2026 17:40:33 +0000 Subject: [PATCH] refacto: move extension to remanso package --- package.json | 3 ++- packages/cli/src/commands/publish.ts | 90 ------------------------------------------------------------------------------------------ packages/cli/src/extensions/remanso.test.ts | 213 --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- packages/cli/src/extensions/remanso.ts | 308 -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- packages/remanso/src/commands/publish.ts | 2 +- packages/remanso/src/lib/note.test.ts | 213 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/remanso/src/lib/note.ts | 308 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 7 file(s) changed, 524 insertion(s)(+), 613 deletion(s)(-) diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "deploy:docs": "cd docs && bun run deploy", "deploy:cli": "cd packages/cli && bun run deploy", "deploy:remanso": "cd packages/remanso && bun run deploy", - "test:cli": "cd packages/cli && bun test" + "test:cli": "cd packages/cli && bun test", + "test:remanso": "cd packages/remanso && bun test" }, "devDependencies": { "@types/bun": "latest", diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -28,13 +28,6 @@ } from "../lib/markdown"; import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; import { exitOnCancel } from "../lib/prompts"; -import { - createNote, - updateNote, - deleteNote, - findPostsWithStaleLinks, - type NoteOptions, -} from "../extensions/remanso"; import { fileExists } from "../lib/utils"; export const publishCommand = command({ @@ -379,19 +372,6 @@ let errorCount = 0; let bskyPostCount = 0; - const context: NoteOptions = { - contentDir, - imagesDir, - 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) { const trimmedContent = post.content.trim(); const titleMatch = trimmedContent.match(/^# (.+)$/m); @@ -511,60 +491,12 @@ bskyPostRef, }; - noteQueue.push({ post, action, atUri }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); s.stop(`Error publishing "${path.basename(post.filePath)}"`); log.error(` ${errorMessage}`); errorCount++; - } - } - - // Pass 2: Create/update Remanso 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)}`, - ); - } } } @@ -575,17 +507,6 @@ const ag = await getAgent(); s.start(`Deleting: ${filePath}`); await deleteRecord(ag, atUri); - - // Try to delete the corresponding Remanso note - try { - const noteAtUri = atUri.replace( - "site.standard.document", - "space.remanso.note", - ); - await deleteNote(ag, noteAtUri); - } catch { - // Note may not exist, ignore - } delete state.posts[filePath]; s.stop(`Deleted: ${filePath}`); @@ -603,17 +524,6 @@ const ag = await getAgent(); s.start(`Deleting unmatched: ${title}`); await deleteRecord(ag, atUri); - - // Try to delete the corresponding Remanso note - try { - const noteAtUri = atUri.replace( - "site.standard.document", - "space.remanso.note", - ); - await deleteNote(ag, noteAtUri); - } catch { - // Note may not exist, ignore - } s.stop(`Deleted unmatched: ${title}`); unmatchedDeletedCount++; diff --git a/packages/cli/src/extensions/remanso.test.ts b/packages/cli/src/extensions/remanso.test.ts deleted file mode 100644 --- a/packages/cli/src/extensions/remanso.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { resolveInternalLinks, findPostsWithStaleLinks } from "./remanso"; -import type { BlogPost } from "../lib/types"; - -function makePost( - slug: string, - atUri?: string, - options?: { content?: string; draft?: boolean; filePath?: string }, -): BlogPost { - return { - filePath: options?.filePath ?? `content/${slug}.md`, - slug, - frontmatter: { - title: slug, - publishDate: "2024-01-01", - atUri, - draft: options?.draft, - }, - content: options?.content ?? "", - rawContent: "", - rawFrontmatter: {}, - }; -} - -describe("resolveInternalLinks", () => { - test("strips link for unpublished local path", () => { - const posts = [makePost("other-post")]; - const content = "See [my post](./other-post)"; - expect(resolveInternalLinks(content, posts)).toBe("See my post"); - }); - - test("rewrites published link to remanso atUri", () => { - const posts = [ - makePost("other-post", "at://did:plc:abc/site.standard.document/abc123"), - ]; - const content = "See [my post](./other-post)"; - expect(resolveInternalLinks(content, posts)).toBe( - "See [my post](at://did:plc:abc/space.remanso.note/abc123)", - ); - }); - - test("leaves external links unchanged", () => { - const posts = [makePost("other-post")]; - const content = "See [example](https://example.com)"; - expect(resolveInternalLinks(content, posts)).toBe( - "See [example](https://example.com)", - ); - }); - - test("leaves anchor links unchanged", () => { - const posts: BlogPost[] = []; - const content = "See [section](#heading)"; - expect(resolveInternalLinks(content, posts)).toBe( - "See [section](#heading)", - ); - }); - - test("handles .md extension in link path", () => { - const posts = [ - makePost("guide", "at://did:plc:abc/site.standard.document/guide123"), - ]; - const content = "Read the [guide](guide.md)"; - expect(resolveInternalLinks(content, posts)).toBe( - "Read the [guide](at://did:plc:abc/space.remanso.note/guide123)", - ); - }); - - test("handles nested slug matching", () => { - const posts = [ - makePost("blog/my-post", "at://did:plc:abc/site.standard.document/rkey1"), - ]; - const content = "See [post](my-post)"; - expect(resolveInternalLinks(content, posts)).toBe( - "See [post](at://did:plc:abc/space.remanso.note/rkey1)", - ); - }); - - test("does not rewrite image embeds", () => { - const posts = [ - makePost("photo", "at://did:plc:abc/site.standard.document/photo1"), - ]; - const content = "![alt](photo)"; - expect(resolveInternalLinks(content, posts)).toBe("![alt](photo)"); - }); - - test("does not rewrite @mention links", () => { - const posts = [ - makePost("mention", "at://did:plc:abc/site.standard.document/m1"), - ]; - const content = "@[name](mention)"; - expect(resolveInternalLinks(content, posts)).toBe("@[name](mention)"); - }); - - test("handles multiple links in same content", () => { - const posts = [ - makePost("published", "at://did:plc:abc/site.standard.document/pub1"), - makePost("unpublished"), - ]; - const content = - "See [a](published) and [b](unpublished) and [c](https://ext.com)"; - expect(resolveInternalLinks(content, posts)).toBe( - "See [a](at://did:plc:abc/space.remanso.note/pub1) and b and [c](https://ext.com)", - ); - }); - - test("handles index path normalization", () => { - const posts = [ - makePost("docs", "at://did:plc:abc/site.standard.document/docs1"), - ]; - const content = "See [docs](./docs/index)"; - expect(resolveInternalLinks(content, posts)).toBe( - "See [docs](at://did:plc:abc/space.remanso.note/docs1)", - ); - }); -}); - -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/remanso.ts b/packages/cli/src/extensions/remanso.ts deleted file mode 100644 --- a/packages/cli/src/extensions/remanso.ts +++ /dev/null @@ -1,308 +0,0 @@ -import type { Agent } from "@atproto/api"; -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import mimeTypes from "mime-types"; -import type { BlogPost, BlobObject } from "../lib/types"; - -const LEXICON = "space.remanso.note"; -const MAX_CONTENT = 10000; - -interface ImageRecord { - image: BlobObject; - alt?: string; -} - -export interface NoteOptions { - contentDir: string; - imagesDir?: string; - allPosts: BlogPost[]; -} - -async function fileExists(filePath: string): Promise { - try { - await fs.access(filePath); - return true; - } catch { - return false; - } -} - -export function isLocalPath(url: string): boolean { - return ( - !url.startsWith("http://") && - !url.startsWith("https://") && - !url.startsWith("#") && - !url.startsWith("mailto:") - ); -} - -function getImageCandidates( - src: string, - postFilePath: string, - contentDir: string, - imagesDir?: string, -): string[] { - const candidates = [ - path.resolve(path.dirname(postFilePath), src), - path.resolve(contentDir, src), - ]; - if (imagesDir) { - candidates.push(path.resolve(imagesDir, src)); - const baseName = path.basename(imagesDir); - const idx = src.indexOf(baseName); - if (idx !== -1) { - const after = src.substring(idx + baseName.length).replace(/^[/\\]/, ""); - candidates.push(path.resolve(imagesDir, after)); - } - } - return candidates; -} - -async function uploadBlob( - agent: Agent, - candidates: string[], -): Promise { - for (const filePath of candidates) { - if (!(await fileExists(filePath))) continue; - - try { - const imageBuffer = await fs.readFile(filePath); - if (imageBuffer.byteLength === 0) continue; - const mimeType = mimeTypes.lookup(filePath) || "application/octet-stream"; - const response = await agent.com.atproto.repo.uploadBlob( - new Uint8Array(imageBuffer), - { encoding: mimeType }, - ); - return { - $type: "blob", - ref: { $link: response.data.blob.ref.toString() }, - mimeType, - size: imageBuffer.byteLength, - }; - } catch {} - } - return undefined; -} - -async function processImages( - agent: Agent, - content: string, - postFilePath: string, - contentDir: string, - imagesDir?: string, -): Promise<{ content: string; images: ImageRecord[] }> { - const images: ImageRecord[] = []; - const uploadCache = new Map(); - let processedContent = content; - - const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; - const matches = [...content.matchAll(imageRegex)]; - - for (const match of matches) { - const fullMatch = match[0]; - const alt = match[1] ?? ""; - const src = match[2]!; - if (!isLocalPath(src)) continue; - - let blob = uploadCache.get(src); - if (!blob) { - const candidates = getImageCandidates( - src, - postFilePath, - contentDir, - imagesDir, - ); - blob = await uploadBlob(agent, candidates); - if (!blob) continue; - uploadCache.set(src, blob); - } - - images.push({ image: blob, alt: alt || undefined }); - processedContent = processedContent.replace( - fullMatch, - `![${alt}](${blob.ref.$link})`, - ); - } - - return { content: processedContent, images }; -} - -export function resolveInternalLinks( - content: string, - allPosts: BlogPost[], -): string { - const linkRegex = /(? { - if (!isLocalPath(url)) return fullMatch; - - // Normalize to a slug-like string for comparison - const normalized = url - .replace(/^(\.\.\/|\.\/)+/, "") - .replace(/\/?$/, "") - .replace(/\.mdx?$/, "") - .replace(/\/index$/, ""); - - const matchedPost = allPosts.find((p) => { - if (!p.frontmatter.atUri) return false; - return ( - p.slug === normalized || - p.slug.endsWith(`/${normalized}`) || - normalized.endsWith(`/${p.slug}`) - ); - }); - - if (!matchedPost) return text; - - const noteUri = matchedPost.frontmatter.atUri!.replace( - /\/[^/]+\/([^/]+)$/, - `/space.remanso.note/$1`, - ); - return `[${text}](${noteUri})`; - }); -} - -async function processNoteContent( - agent: Agent, - post: BlogPost, - options: NoteOptions, -): Promise<{ content: string; images: ImageRecord[] }> { - let content = post.content.trim(); - - content = resolveInternalLinks(content, options.allPosts); - - const result = await processImages( - agent, - content, - post.filePath, - options.contentDir, - options.imagesDir, - ); - - return result; -} - -function parseRkey(atUri: string): string { - const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); - if (!uriMatch) { - throw new Error(`Invalid atUri format: ${atUri}`); - } - return uriMatch[3]!; -} - -async function buildNoteRecord( - agent: Agent, - post: BlogPost, - options: NoteOptions, -): Promise> { - const publishDate = new Date(post.frontmatter.publishDate).toISOString(); - const trimmedContent = post.content.trim(); - const titleMatch = trimmedContent.match(/^# (.+)$/m); - const title = titleMatch ? titleMatch[1] : post.frontmatter.title; - - const { content, images } = await processNoteContent(agent, post, options); - - const record: Record = { - $type: LEXICON, - title, - content: content.slice(0, MAX_CONTENT), - createdAt: publishDate, - publishedAt: publishDate, - }; - - if (images.length > 0) { - record.images = images; - } - - if (post.frontmatter.theme) { - record.theme = post.frontmatter.theme; - } - - if (post.frontmatter.fontSize) { - record.fontSize = post.frontmatter.fontSize; - } - - if (post.frontmatter.fontFamily) { - record.fontFamily = post.frontmatter.fontFamily; - } - - return record; -} - -export async function deleteNote(agent: Agent, atUri: string): Promise { - const rkey = parseRkey(atUri); - await agent.com.atproto.repo.deleteRecord({ - repo: agent.did!, - collection: LEXICON, - rkey, - }); -} - -export async function createNote( - agent: Agent, - post: BlogPost, - atUri: string, - options: NoteOptions, -): Promise { - const rkey = parseRkey(atUri); - const record = await buildNoteRecord(agent, post, options); - - await agent.com.atproto.repo.createRecord({ - repo: agent.did!, - collection: LEXICON, - record, - rkey, - validate: false, - }); -} - -export async function updateNote( - agent: Agent, - post: BlogPost, - atUri: string, - options: NoteOptions, -): Promise { - const rkey = parseRkey(atUri); - const record = await buildNoteRecord(agent, post, options); - - await agent.com.atproto.repo.putRecord({ - repo: agent.did!, - collection: LEXICON, - rkey: rkey!, - record, - 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}`), - ); - }); - }); -} diff --git a/packages/remanso/src/commands/publish.ts b/packages/remanso/src/commands/publish.ts --- a/packages/remanso/src/commands/publish.ts +++ b/packages/remanso/src/commands/publish.ts @@ -35,7 +35,7 @@ deleteNote, findPostsWithStaleLinks, type NoteOptions, -} from "../../../cli/src/extensions/remanso"; +} from "../lib/note"; async function fileExists(filePath: string): Promise { try { diff --git a/packages/remanso/src/lib/note.test.ts b/packages/remanso/src/lib/note.test.ts new file mode 100644 --- /dev/null +++ b/packages/remanso/src/lib/note.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, test } from "bun:test"; +import { resolveInternalLinks, findPostsWithStaleLinks } from "./note"; +import type { BlogPost } from "../../../cli/src/lib/types"; + +function makePost( + slug: string, + atUri?: string, + options?: { content?: string; draft?: boolean; filePath?: string }, +): BlogPost { + return { + filePath: options?.filePath ?? `content/${slug}.md`, + slug, + frontmatter: { + title: slug, + publishDate: "2024-01-01", + atUri, + draft: options?.draft, + }, + content: options?.content ?? "", + rawContent: "", + rawFrontmatter: {}, + }; +} + +describe("resolveInternalLinks", () => { + test("strips link for unpublished local path", () => { + const posts = [makePost("other-post")]; + const content = "See [my post](./other-post)"; + expect(resolveInternalLinks(content, posts)).toBe("See my post"); + }); + + test("rewrites published link to remanso atUri", () => { + const posts = [ + makePost("other-post", "at://did:plc:abc/site.standard.document/abc123"), + ]; + const content = "See [my post](./other-post)"; + expect(resolveInternalLinks(content, posts)).toBe( + "See [my post](at://did:plc:abc/space.remanso.note/abc123)", + ); + }); + + test("leaves external links unchanged", () => { + const posts = [makePost("other-post")]; + const content = "See [example](https://example.com)"; + expect(resolveInternalLinks(content, posts)).toBe( + "See [example](https://example.com)", + ); + }); + + test("leaves anchor links unchanged", () => { + const posts: BlogPost[] = []; + const content = "See [section](#heading)"; + expect(resolveInternalLinks(content, posts)).toBe( + "See [section](#heading)", + ); + }); + + test("handles .md extension in link path", () => { + const posts = [ + makePost("guide", "at://did:plc:abc/site.standard.document/guide123"), + ]; + const content = "Read the [guide](guide.md)"; + expect(resolveInternalLinks(content, posts)).toBe( + "Read the [guide](at://did:plc:abc/space.remanso.note/guide123)", + ); + }); + + test("handles nested slug matching", () => { + const posts = [ + makePost("blog/my-post", "at://did:plc:abc/site.standard.document/rkey1"), + ]; + const content = "See [post](my-post)"; + expect(resolveInternalLinks(content, posts)).toBe( + "See [post](at://did:plc:abc/space.remanso.note/rkey1)", + ); + }); + + test("does not rewrite image embeds", () => { + const posts = [ + makePost("photo", "at://did:plc:abc/site.standard.document/photo1"), + ]; + const content = "![alt](photo)"; + expect(resolveInternalLinks(content, posts)).toBe("![alt](photo)"); + }); + + test("does not rewrite @mention links", () => { + const posts = [ + makePost("mention", "at://did:plc:abc/site.standard.document/m1"), + ]; + const content = "@[name](mention)"; + expect(resolveInternalLinks(content, posts)).toBe("@[name](mention)"); + }); + + test("handles multiple links in same content", () => { + const posts = [ + makePost("published", "at://did:plc:abc/site.standard.document/pub1"), + makePost("unpublished"), + ]; + const content = + "See [a](published) and [b](unpublished) and [c](https://ext.com)"; + expect(resolveInternalLinks(content, posts)).toBe( + "See [a](at://did:plc:abc/space.remanso.note/pub1) and b and [c](https://ext.com)", + ); + }); + + test("handles index path normalization", () => { + const posts = [ + makePost("docs", "at://did:plc:abc/site.standard.document/docs1"), + ]; + const content = "See [docs](./docs/index)"; + expect(resolveInternalLinks(content, posts)).toBe( + "See [docs](at://did:plc:abc/space.remanso.note/docs1)", + ); + }); +}); + +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/remanso/src/lib/note.ts b/packages/remanso/src/lib/note.ts new file mode 100644 --- /dev/null +++ b/packages/remanso/src/lib/note.ts @@ -0,0 +1,308 @@ +import type { Agent } from "@atproto/api"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import mimeTypes from "mime-types"; +import type { BlogPost, BlobObject } from "../../../cli/src/lib/types"; + +const LEXICON = "space.remanso.note"; +const MAX_CONTENT = 10000; + +interface ImageRecord { + image: BlobObject; + alt?: string; +} + +export interface NoteOptions { + contentDir: string; + imagesDir?: string; + allPosts: BlogPost[]; +} + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +export function isLocalPath(url: string): boolean { + return ( + !url.startsWith("http://") && + !url.startsWith("https://") && + !url.startsWith("#") && + !url.startsWith("mailto:") + ); +} + +function getImageCandidates( + src: string, + postFilePath: string, + contentDir: string, + imagesDir?: string, +): string[] { + const candidates = [ + path.resolve(path.dirname(postFilePath), src), + path.resolve(contentDir, src), + ]; + if (imagesDir) { + candidates.push(path.resolve(imagesDir, src)); + const baseName = path.basename(imagesDir); + const idx = src.indexOf(baseName); + if (idx !== -1) { + const after = src.substring(idx + baseName.length).replace(/^[/\\]/, ""); + candidates.push(path.resolve(imagesDir, after)); + } + } + return candidates; +} + +async function uploadBlob( + agent: Agent, + candidates: string[], +): Promise { + for (const filePath of candidates) { + if (!(await fileExists(filePath))) continue; + + try { + const imageBuffer = await fs.readFile(filePath); + if (imageBuffer.byteLength === 0) continue; + const mimeType = mimeTypes.lookup(filePath) || "application/octet-stream"; + const response = await agent.com.atproto.repo.uploadBlob( + new Uint8Array(imageBuffer), + { encoding: mimeType }, + ); + return { + $type: "blob", + ref: { $link: response.data.blob.ref.toString() }, + mimeType, + size: imageBuffer.byteLength, + }; + } catch {} + } + return undefined; +} + +async function processImages( + agent: Agent, + content: string, + postFilePath: string, + contentDir: string, + imagesDir?: string, +): Promise<{ content: string; images: ImageRecord[] }> { + const images: ImageRecord[] = []; + const uploadCache = new Map(); + let processedContent = content; + + const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; + const matches = [...content.matchAll(imageRegex)]; + + for (const match of matches) { + const fullMatch = match[0]; + const alt = match[1] ?? ""; + const src = match[2]!; + if (!isLocalPath(src)) continue; + + let blob = uploadCache.get(src); + if (!blob) { + const candidates = getImageCandidates( + src, + postFilePath, + contentDir, + imagesDir, + ); + blob = await uploadBlob(agent, candidates); + if (!blob) continue; + uploadCache.set(src, blob); + } + + images.push({ image: blob, alt: alt || undefined }); + processedContent = processedContent.replace( + fullMatch, + `![${alt}](${blob.ref.$link})`, + ); + } + + return { content: processedContent, images }; +} + +export function resolveInternalLinks( + content: string, + allPosts: BlogPost[], +): string { + const linkRegex = /(? { + if (!isLocalPath(url)) return fullMatch; + + // Normalize to a slug-like string for comparison + const normalized = url + .replace(/^(\.\.\/|\.\/)+/, "") + .replace(/\/?$/, "") + .replace(/\.mdx?$/, "") + .replace(/\/index$/, ""); + + const matchedPost = allPosts.find((p) => { + if (!p.frontmatter.atUri) return false; + return ( + p.slug === normalized || + p.slug.endsWith(`/${normalized}`) || + normalized.endsWith(`/${p.slug}`) + ); + }); + + if (!matchedPost) return text; + + const noteUri = matchedPost.frontmatter.atUri!.replace( + /\/[^/]+\/([^/]+)$/, + `/space.remanso.note/$1`, + ); + return `[${text}](${noteUri})`; + }); +} + +async function processNoteContent( + agent: Agent, + post: BlogPost, + options: NoteOptions, +): Promise<{ content: string; images: ImageRecord[] }> { + let content = post.content.trim(); + + content = resolveInternalLinks(content, options.allPosts); + + const result = await processImages( + agent, + content, + post.filePath, + options.contentDir, + options.imagesDir, + ); + + return result; +} + +function parseRkey(atUri: string): string { + const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); + if (!uriMatch) { + throw new Error(`Invalid atUri format: ${atUri}`); + } + return uriMatch[3]!; +} + +async function buildNoteRecord( + agent: Agent, + post: BlogPost, + options: NoteOptions, +): Promise> { + const publishDate = new Date(post.frontmatter.publishDate).toISOString(); + const trimmedContent = post.content.trim(); + const titleMatch = trimmedContent.match(/^# (.+)$/m); + const title = titleMatch ? titleMatch[1] : post.frontmatter.title; + + const { content, images } = await processNoteContent(agent, post, options); + + const record: Record = { + $type: LEXICON, + title, + content: content.slice(0, MAX_CONTENT), + createdAt: publishDate, + publishedAt: publishDate, + }; + + if (images.length > 0) { + record.images = images; + } + + if (post.frontmatter.theme) { + record.theme = post.frontmatter.theme; + } + + if (post.frontmatter.fontSize) { + record.fontSize = post.frontmatter.fontSize; + } + + if (post.frontmatter.fontFamily) { + record.fontFamily = post.frontmatter.fontFamily; + } + + return record; +} + +export async function deleteNote(agent: Agent, atUri: string): Promise { + const rkey = parseRkey(atUri); + await agent.com.atproto.repo.deleteRecord({ + repo: agent.did!, + collection: LEXICON, + rkey, + }); +} + +export async function createNote( + agent: Agent, + post: BlogPost, + atUri: string, + options: NoteOptions, +): Promise { + const rkey = parseRkey(atUri); + const record = await buildNoteRecord(agent, post, options); + + await agent.com.atproto.repo.createRecord({ + repo: agent.did!, + collection: LEXICON, + record, + rkey, + validate: false, + }); +} + +export async function updateNote( + agent: Agent, + post: BlogPost, + atUri: string, + options: NoteOptions, +): Promise { + const rkey = parseRkey(atUri); + const record = await buildNoteRecord(agent, post, options); + + await agent.com.atproto.repo.putRecord({ + repo: agent.did!, + collection: LEXICON, + rkey: rkey!, + record, + 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}`), + ); + }); + }); +} -- tangled.sh