A CLI for publishing standard.site documents to ATProto
at main 439 lines 12 kB view raw
1import { describe, expect, test } from "bun:test"; 2import { 3 getContentHash, 4 getSlugFromFilename, 5 getSlugFromOptions, 6 getTextContent, 7 parseFrontmatter, 8 stripMarkdownForText, 9 updateFrontmatterWithAtUri, 10} from "./markdown"; 11 12describe("parseFrontmatter", () => { 13 test("parses YAML frontmatter with --- delimiters", () => { 14 const content = `--- 15title: My Post 16description: A description 17publishDate: 2024-01-15 18--- 19Hello world`; 20 21 const result = parseFrontmatter(content); 22 expect(result.frontmatter.title).toBe("My Post"); 23 expect(result.frontmatter.description).toBe("A description"); 24 expect(result.frontmatter.publishDate).toBe("2024-01-15"); 25 expect(result.body).toBe("Hello world"); 26 expect(result.rawFrontmatter.title).toBe("My Post"); 27 }); 28 29 test("parses TOML frontmatter with +++ delimiters", () => { 30 const content = `+++ 31title = My Post 32description = A description 33date = 2024-01-15 34+++ 35Body content`; 36 37 const result = parseFrontmatter(content); 38 expect(result.frontmatter.title).toBe("My Post"); 39 expect(result.frontmatter.description).toBe("A description"); 40 expect(result.frontmatter.publishDate).toBe("2024-01-15"); 41 expect(result.body).toBe("Body content"); 42 }); 43 44 test("parses *** delimited frontmatter", () => { 45 const content = `*** 46title: Test 47*** 48Body`; 49 50 const result = parseFrontmatter(content); 51 expect(result.frontmatter.title).toBe("Test"); 52 expect(result.body).toBe("Body"); 53 }); 54 55 test("handles no frontmatter - extracts title from heading", () => { 56 const content = `# My Heading 57 58Some body text`; 59 60 const result = parseFrontmatter(content); 61 expect(result.frontmatter.title).toBe("My Heading"); 62 expect(result.frontmatter.publishDate).toBeTruthy(); 63 expect(result.body).toBe(content); 64 }); 65 66 test("handles no frontmatter and no heading", () => { 67 const content = "Just plain text"; 68 69 const result = parseFrontmatter(content); 70 expect(result.frontmatter.title).toBe(""); 71 expect(result.body).toBe(content); 72 }); 73 74 test("handles quoted string values", () => { 75 const content = `--- 76title: "Quoted Title" 77description: 'Single Quoted' 78--- 79Body`; 80 81 const result = parseFrontmatter(content); 82 expect(result.rawFrontmatter.title).toBe("Quoted Title"); 83 expect(result.rawFrontmatter.description).toBe("Single Quoted"); 84 }); 85 86 test("parses inline arrays", () => { 87 const content = `--- 88title: Post 89tags: [javascript, typescript, "web dev"] 90--- 91Body`; 92 93 const result = parseFrontmatter(content); 94 expect(result.rawFrontmatter.tags).toEqual([ 95 "javascript", 96 "typescript", 97 "web dev", 98 ]); 99 }); 100 101 test("parses YAML multiline arrays", () => { 102 const content = `--- 103title: Post 104tags: 105 - javascript 106 - typescript 107 - web dev 108--- 109Body`; 110 111 const result = parseFrontmatter(content); 112 expect(result.rawFrontmatter.tags).toEqual([ 113 "javascript", 114 "typescript", 115 "web dev", 116 ]); 117 }); 118 119 test("parses boolean values", () => { 120 const content = `--- 121title: Draft Post 122draft: true 123published: false 124--- 125Body`; 126 127 const result = parseFrontmatter(content); 128 expect(result.rawFrontmatter.draft).toBe(true); 129 expect(result.rawFrontmatter.published).toBe(false); 130 }); 131 132 test("applies frontmatter field mappings", () => { 133 const content = `--- 134nombre: Custom Title 135descripcion: Custom Desc 136fecha: 2024-06-01 137imagen: cover.jpg 138etiquetas: [a, b] 139borrador: true 140--- 141Body`; 142 143 const mapping = { 144 title: "nombre", 145 description: "descripcion", 146 publishDate: "fecha", 147 coverImage: "imagen", 148 tags: "etiquetas", 149 draft: "borrador", 150 }; 151 152 const result = parseFrontmatter(content, mapping); 153 expect(result.frontmatter.title).toBe("Custom Title"); 154 expect(result.frontmatter.description).toBe("Custom Desc"); 155 expect(result.frontmatter.publishDate).toBe("2024-06-01"); 156 expect(result.frontmatter.ogImage).toBe("cover.jpg"); 157 expect(result.frontmatter.tags).toEqual(["a", "b"]); 158 expect(result.frontmatter.draft).toBe(true); 159 }); 160 161 test("falls back to common date field names", () => { 162 const content = `--- 163title: Post 164date: 2024-03-20 165--- 166Body`; 167 168 const result = parseFrontmatter(content); 169 expect(result.frontmatter.publishDate).toBe("2024-03-20"); 170 }); 171 172 test("falls back to pubDate", () => { 173 const content = `--- 174title: Post 175pubDate: 2024-04-10 176--- 177Body`; 178 179 const result = parseFrontmatter(content); 180 expect(result.frontmatter.publishDate).toBe("2024-04-10"); 181 }); 182 183 test("preserves atUri field", () => { 184 const content = `--- 185title: Post 186atUri: at://did:plc:abc/site.standard.post/123 187--- 188Body`; 189 190 const result = parseFrontmatter(content); 191 expect(result.frontmatter.atUri).toBe( 192 "at://did:plc:abc/site.standard.post/123", 193 ); 194 }); 195 196 test("maps draft field correctly", () => { 197 const content = `--- 198title: Post 199draft: true 200--- 201Body`; 202 203 const result = parseFrontmatter(content); 204 expect(result.frontmatter.draft).toBe(true); 205 }); 206}); 207 208describe("getSlugFromFilename", () => { 209 test("removes .md extension", () => { 210 expect(getSlugFromFilename("my-post.md")).toBe("my-post"); 211 }); 212 213 test("removes .mdx extension", () => { 214 expect(getSlugFromFilename("my-post.mdx")).toBe("my-post"); 215 }); 216 217 test("converts to lowercase", () => { 218 expect(getSlugFromFilename("My-Post.md")).toBe("my-post"); 219 }); 220 221 test("replaces spaces with dashes", () => { 222 expect(getSlugFromFilename("my cool post.md")).toBe("my-cool-post"); 223 }); 224}); 225 226describe("getSlugFromOptions", () => { 227 test("uses filepath by default", () => { 228 const slug = getSlugFromOptions("blog/my-post.md", {}); 229 expect(slug).toBe("blog/my-post"); 230 }); 231 232 test("uses slugField from frontmatter when set", () => { 233 const slug = getSlugFromOptions( 234 "blog/my-post.md", 235 { slug: "/custom-slug" }, 236 { slugField: "slug" }, 237 ); 238 expect(slug).toBe("custom-slug"); 239 }); 240 241 test("falls back to filepath when slugField not found in frontmatter", () => { 242 const slug = getSlugFromOptions("blog/my-post.md", {}, { slugField: "slug" }); 243 expect(slug).toBe("blog/my-post"); 244 }); 245 246 test("removes /index suffix when removeIndexFromSlug is true", () => { 247 const slug = getSlugFromOptions( 248 "blog/my-post/index.md", 249 {}, 250 { removeIndexFromSlug: true }, 251 ); 252 expect(slug).toBe("blog/my-post"); 253 }); 254 255 test("removes /_index suffix when removeIndexFromSlug is true", () => { 256 const slug = getSlugFromOptions( 257 "blog/my-post/_index.md", 258 {}, 259 { removeIndexFromSlug: true }, 260 ); 261 expect(slug).toBe("blog/my-post"); 262 }); 263 264 test("strips date prefix when stripDatePrefix is true", () => { 265 const slug = getSlugFromOptions( 266 "2024-01-15-my-post.md", 267 {}, 268 { stripDatePrefix: true }, 269 ); 270 expect(slug).toBe("my-post"); 271 }); 272 273 test("strips date prefix in nested paths", () => { 274 const slug = getSlugFromOptions( 275 "blog/2024-01-15-my-post.md", 276 {}, 277 { stripDatePrefix: true }, 278 ); 279 expect(slug).toBe("blog/my-post"); 280 }); 281 282 test("combines removeIndexFromSlug and stripDatePrefix", () => { 283 const slug = getSlugFromOptions( 284 "blog/2024-01-15-my-post/index.md", 285 {}, 286 { removeIndexFromSlug: true, stripDatePrefix: true }, 287 ); 288 expect(slug).toBe("blog/my-post"); 289 }); 290 291 test("lowercases and replaces spaces", () => { 292 const slug = getSlugFromOptions("Blog/My Post.md", {}); 293 expect(slug).toBe("blog/my-post"); 294 }); 295}); 296 297describe("getContentHash", () => { 298 test("returns a hex string", async () => { 299 const hash = await getContentHash("hello"); 300 expect(hash).toMatch(/^[0-9a-f]+$/); 301 }); 302 303 test("returns consistent results", async () => { 304 const hash1 = await getContentHash("test content"); 305 const hash2 = await getContentHash("test content"); 306 expect(hash1).toBe(hash2); 307 }); 308 309 test("returns different hashes for different content", async () => { 310 const hash1 = await getContentHash("content a"); 311 const hash2 = await getContentHash("content b"); 312 expect(hash1).not.toBe(hash2); 313 }); 314}); 315 316describe("updateFrontmatterWithAtUri", () => { 317 test("inserts atUri into YAML frontmatter", () => { 318 const content = `--- 319title: My Post 320--- 321Body`; 322 323 const result = updateFrontmatterWithAtUri(content, "at://did:plc:abc/post/123"); 324 expect(result).toContain('atUri: "at://did:plc:abc/post/123"'); 325 expect(result).toContain("title: My Post"); 326 }); 327 328 test("inserts atUri into TOML frontmatter", () => { 329 const content = `+++ 330title = My Post 331+++ 332Body`; 333 334 const result = updateFrontmatterWithAtUri(content, "at://did:plc:abc/post/123"); 335 expect(result).toContain('atUri = "at://did:plc:abc/post/123"'); 336 }); 337 338 test("creates frontmatter with atUri when none exists", () => { 339 const content = "# My Post\n\nSome body text"; 340 341 const result = updateFrontmatterWithAtUri(content, "at://did:plc:abc/post/123"); 342 expect(result).toContain('atUri: "at://did:plc:abc/post/123"'); 343 expect(result).toContain("---"); 344 expect(result).toContain("# My Post\n\nSome body text"); 345 }); 346 347 test("replaces existing atUri in YAML", () => { 348 const content = `--- 349title: My Post 350atUri: "at://did:plc:old/post/000" 351--- 352Body`; 353 354 const result = updateFrontmatterWithAtUri(content, "at://did:plc:new/post/999"); 355 expect(result).toContain('atUri: "at://did:plc:new/post/999"'); 356 expect(result).not.toContain("old"); 357 }); 358 359 test("replaces existing atUri in TOML", () => { 360 const content = `+++ 361title = My Post 362atUri = "at://did:plc:old/post/000" 363+++ 364Body`; 365 366 const result = updateFrontmatterWithAtUri(content, "at://did:plc:new/post/999"); 367 expect(result).toContain('atUri = "at://did:plc:new/post/999"'); 368 expect(result).not.toContain("old"); 369 }); 370}); 371 372describe("stripMarkdownForText", () => { 373 test("removes headings", () => { 374 expect(stripMarkdownForText("## Hello")).toBe("Hello"); 375 }); 376 377 test("removes bold", () => { 378 expect(stripMarkdownForText("**bold text**")).toBe("bold text"); 379 }); 380 381 test("removes italic", () => { 382 expect(stripMarkdownForText("*italic text*")).toBe("italic text"); 383 }); 384 385 test("removes links but keeps text", () => { 386 expect(stripMarkdownForText("[click here](https://example.com)")).toBe( 387 "click here", 388 ); 389 }); 390 391 test("removes images", () => { 392 // Note: link regex runs before image regex, so ![alt](url) partially matches as a link first 393 expect(stripMarkdownForText("text ![alt](image.png) more")).toBe( 394 "text !alt more", 395 ); 396 }); 397 398 test("removes code blocks", () => { 399 const input = "Before\n```js\nconst x = 1;\n```\nAfter"; 400 expect(stripMarkdownForText(input)).toContain("Before"); 401 expect(stripMarkdownForText(input)).toContain("After"); 402 expect(stripMarkdownForText(input)).not.toContain("const x"); 403 }); 404 405 test("removes inline code formatting", () => { 406 expect(stripMarkdownForText("use `npm install`")).toBe("use npm install"); 407 }); 408 409 test("normalizes multiple newlines", () => { 410 const input = "Line 1\n\n\n\n\nLine 2"; 411 expect(stripMarkdownForText(input)).toBe("Line 1\n\nLine 2"); 412 }); 413}); 414 415describe("getTextContent", () => { 416 test("uses textContentField from frontmatter when specified", () => { 417 const post = { 418 content: "# Markdown body", 419 rawFrontmatter: { excerpt: "Custom excerpt text" }, 420 }; 421 expect(getTextContent(post, "excerpt")).toBe("Custom excerpt text"); 422 }); 423 424 test("falls back to stripped markdown when textContentField not found", () => { 425 const post = { 426 content: "**Bold text** and [a link](url)", 427 rawFrontmatter: {}, 428 }; 429 expect(getTextContent(post, "missing")).toBe("Bold text and a link"); 430 }); 431 432 test("falls back to stripped markdown when no textContentField specified", () => { 433 const post = { 434 content: "## Heading\n\nParagraph", 435 rawFrontmatter: {}, 436 }; 437 expect(getTextContent(post)).toBe("Heading\n\nParagraph"); 438 }); 439});