From 999fd68063dd7ee2ba5e67ac254899e17d83f761 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sat, 14 Feb 2026 11:16:59 +0000 Subject: [PATCH] rebranding to Remanso --- CLAUDE.md | 4 ++-- packages/cli/src/commands/publish.ts | 4 ++-- packages/cli/src/extensions/litenote.test.ts | 238 ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- packages/cli/src/extensions/litenote.ts | 297 --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- packages/cli/src/extensions/remanso.test.ts | 238 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/cli/src/extensions/remanso.ts | 297 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 6 file(s) changed, 539 insertion(s)(+), 539 deletion(s)(-) diff --git a/CLAUDE.md b/CLAUDE.md --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## Project Overview -Sequoia is a CLI tool for publishing Markdown documents with frontmatter to the AT Protocol (Bluesky's decentralized social network). It converts blog posts into ATProto records (`site.standard.document`, `space.litenote.note`) and publishes them to a user's PDS. +Sequoia is a CLI tool for publishing Markdown documents with frontmatter to the AT Protocol (Bluesky's decentralized social network). It converts blog posts into ATProto records (`site.standard.document`, `space.remanso.note`) and publishes them to a user's PDS. Website: @@ -63,7 +63,7 @@ **Extensions** (`src/extensions/`): -- `litenote.ts` — Creates `space.litenote.note` records with embedded images +- `remanso.ts` — Creates `space.remanso.note` records with embedded images ## Key Patterns 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 @@ -26,7 +26,7 @@ } from "../lib/markdown"; import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; import { exitOnCancel } from "../lib/prompts"; -import { createNote, updateNote, findPostsWithStaleLinks, type NoteOptions } from "../extensions/litenote" +import { createNote, updateNote, findPostsWithStaleLinks, type NoteOptions } from "../extensions/remanso" export const publishCommand = command({ name: "publish", @@ -419,7 +419,7 @@ } } - // Pass 2: Create/update litenote notes (atUris are now available for link resolution) + // Pass 2: Create/update Remanso notes (atUris are now available for link resolution) for (const { post, action, atUri } of noteQueue) { try { if (action === "create") { diff --git a/packages/cli/src/extensions/litenote.test.ts b/packages/cli/src/extensions/litenote.test.ts deleted file mode 100644 --- a/packages/cli/src/extensions/litenote.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { resolveInternalLinks, findPostsWithStaleLinks } from "./litenote"; -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 litenote 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.litenote.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.litenote.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.litenote.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.litenote.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.litenote.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/litenote.ts b/packages/cli/src/extensions/litenote.ts deleted file mode 100644 --- a/packages/cli/src/extensions/litenote.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { Agent } from "@atproto/api" -import * as fs from "node:fs/promises" -import * as path from "node:path" -import mimeTypes from "mime-types" -import { BlogPost, BlobObject } from "../lib/types" - -const LEXICON = "space.litenote.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.litenote.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]! -} - -export async function createNote( - agent: Agent, - post: BlogPost, - atUri: string, - options: NoteOptions, -): Promise { - const rkey = parseRkey(atUri) - 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 - } - - 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 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 - } - - 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/cli/src/extensions/remanso.test.ts b/packages/cli/src/extensions/remanso.test.ts new file mode 100644 --- /dev/null +++ b/packages/cli/src/extensions/remanso.test.ts @@ -0,0 +1,238 @@ +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 new file mode 100644 --- /dev/null +++ b/packages/cli/src/extensions/remanso.ts @@ -0,0 +1,297 @@ +import { Agent } from "@atproto/api" +import * as fs from "node:fs/promises" +import * as path from "node:path" +import mimeTypes from "mime-types" +import { 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]! +} + +export async function createNote( + agent: Agent, + post: BlogPost, + atUri: string, + options: NoteOptions, +): Promise { + const rkey = parseRkey(atUri) + 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 + } + + 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 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 + } + + 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