A CLI for publishing standard.site documents to ATProto

feat: link to at links

+141 -5
+131
packages/cli/src/extensions/litenote.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { resolveInternalLinks } from "./litenote"; 3 + import type { BlogPost } from "../lib/types"; 4 + 5 + function makePost(slug: string, atUri?: string): BlogPost { 6 + return { 7 + filePath: `content/${slug}.md`, 8 + slug, 9 + frontmatter: { 10 + title: slug, 11 + publishDate: "2024-01-01", 12 + atUri, 13 + }, 14 + content: "", 15 + rawContent: "", 16 + rawFrontmatter: {}, 17 + }; 18 + } 19 + 20 + describe("resolveInternalLinks", () => { 21 + test("strips link for unpublished local path", () => { 22 + const posts = [makePost("other-post")]; 23 + const content = "See [my post](./other-post)"; 24 + expect(resolveInternalLinks(content, posts)).toBe("See my post"); 25 + }); 26 + 27 + test("rewrites published link to litenote atUri", () => { 28 + const posts = [ 29 + makePost( 30 + "other-post", 31 + "at://did:plc:abc/site.standard.document/abc123", 32 + ), 33 + ]; 34 + const content = "See [my post](./other-post)"; 35 + expect(resolveInternalLinks(content, posts)).toBe( 36 + "See [my post](at://did:plc:abc/space.litenote.note/abc123)", 37 + ); 38 + }); 39 + 40 + test("leaves external links unchanged", () => { 41 + const posts = [makePost("other-post")]; 42 + const content = "See [example](https://example.com)"; 43 + expect(resolveInternalLinks(content, posts)).toBe( 44 + "See [example](https://example.com)", 45 + ); 46 + }); 47 + 48 + test("leaves anchor links unchanged", () => { 49 + const posts: BlogPost[] = []; 50 + const content = "See [section](#heading)"; 51 + expect(resolveInternalLinks(content, posts)).toBe( 52 + "See [section](#heading)", 53 + ); 54 + }); 55 + 56 + test("handles .md extension in link path", () => { 57 + const posts = [ 58 + makePost( 59 + "guide", 60 + "at://did:plc:abc/site.standard.document/guide123", 61 + ), 62 + ]; 63 + const content = "Read the [guide](guide.md)"; 64 + expect(resolveInternalLinks(content, posts)).toBe( 65 + "Read the [guide](at://did:plc:abc/space.litenote.note/guide123)", 66 + ); 67 + }); 68 + 69 + test("handles nested slug matching", () => { 70 + const posts = [ 71 + makePost( 72 + "blog/my-post", 73 + "at://did:plc:abc/site.standard.document/rkey1", 74 + ), 75 + ]; 76 + const content = "See [post](my-post)"; 77 + expect(resolveInternalLinks(content, posts)).toBe( 78 + "See [post](at://did:plc:abc/space.litenote.note/rkey1)", 79 + ); 80 + }); 81 + 82 + test("does not rewrite image embeds", () => { 83 + const posts = [ 84 + makePost( 85 + "photo", 86 + "at://did:plc:abc/site.standard.document/photo1", 87 + ), 88 + ]; 89 + const content = "![alt](photo)"; 90 + expect(resolveInternalLinks(content, posts)).toBe("![alt](photo)"); 91 + }); 92 + 93 + test("does not rewrite @mention links", () => { 94 + const posts = [ 95 + makePost( 96 + "mention", 97 + "at://did:plc:abc/site.standard.document/m1", 98 + ), 99 + ]; 100 + const content = "@[name](mention)"; 101 + expect(resolveInternalLinks(content, posts)).toBe("@[name](mention)"); 102 + }); 103 + 104 + test("handles multiple links in same content", () => { 105 + const posts = [ 106 + makePost( 107 + "published", 108 + "at://did:plc:abc/site.standard.document/pub1", 109 + ), 110 + makePost("unpublished"), 111 + ]; 112 + const content = 113 + "See [a](published) and [b](unpublished) and [c](https://ext.com)"; 114 + expect(resolveInternalLinks(content, posts)).toBe( 115 + "See [a](at://did:plc:abc/space.litenote.note/pub1) and b and [c](https://ext.com)", 116 + ); 117 + }); 118 + 119 + test("handles index path normalization", () => { 120 + const posts = [ 121 + makePost( 122 + "docs", 123 + "at://did:plc:abc/site.standard.document/docs1", 124 + ), 125 + ]; 126 + const content = "See [docs](./docs/index)"; 127 + expect(resolveInternalLinks(content, posts)).toBe( 128 + "See [docs](at://did:plc:abc/space.litenote.note/docs1)", 129 + ); 130 + }); 131 + });
+10 -5
packages/cli/src/extensions/litenote.ts
··· 122 122 return { content: processedContent, images } 123 123 } 124 124 125 - function removeUnpublishedLinks( 125 + export function resolveInternalLinks( 126 126 content: string, 127 127 allPosts: BlogPost[], 128 128 ): string { ··· 138 138 .replace(/\.mdx?$/, "") 139 139 .replace(/\/index$/, "") 140 140 141 - const isPublished = allPosts.some((p) => { 141 + const matchedPost = allPosts.find((p) => { 142 142 if (!p.frontmatter.atUri) return false 143 143 return ( 144 144 p.slug === normalized || ··· 147 147 ) 148 148 }) 149 149 150 - if (!isPublished) return text 151 - return fullMatch 150 + if (!matchedPost) return text 151 + 152 + const noteUri = matchedPost.frontmatter.atUri!.replace( 153 + /\/[^/]+\/([^/]+)$/, 154 + `/space.litenote.note/$1`, 155 + ) 156 + return `[${text}](${noteUri})` 152 157 }) 153 158 } 154 159 ··· 159 164 ): Promise<{ content: string; images: ImageRecord[] }> { 160 165 let content = post.content.trim() 161 166 162 - content = removeUnpublishedLinks(content, options.allPosts) 167 + content = resolveInternalLinks(content, options.allPosts) 163 168 164 169 const result = await processImages( 165 170 agent, content, post.filePath, options.contentDir, options.imagesDir,