import { describe, expect, it } from "bun:test"; import { getContentHash, parseFrontmatter, resolvePostPath, stripMarkdownForText, updateFrontmatterWithAtUri, } from "../src/lib/markdown"; import type { BlogPost } from "../src/lib/types"; describe("parseFrontmatter", () => { describe("delimiters", () => { it("parses YAML frontmatter (--- delimiter)", () => { const content = `--- title: Hello World --- Body content here.`; const { frontmatter, body } = parseFrontmatter(content); expect(frontmatter.title).toBe("Hello World"); expect(body).toBe("Body content here."); }); it("parses TOML frontmatter (+++ delimiter)", () => { const content = `+++ title = "Hugo Post" +++ Body content here.`; const { frontmatter, body } = parseFrontmatter(content); expect(frontmatter.title).toBe("Hugo Post"); expect(body).toBe("Body content here."); }); it("parses alternative frontmatter (*** delimiter)", () => { const content = `*** title: Alt Post *** Body content here.`; const { frontmatter, body } = parseFrontmatter(content); expect(frontmatter.title).toBe("Alt Post"); expect(body).toBe("Body content here."); }); it("throws when no frontmatter is present", () => { const content = "Just plain content with no frontmatter."; expect(() => parseFrontmatter(content)).toThrow( "Could not parse frontmatter", ); }); }); describe("scalar values", () => { it("parses a string value", () => { const content = `--- title: My Post description: A short description --- `; const { frontmatter } = parseFrontmatter(content); expect(frontmatter.title).toBe("My Post"); expect(frontmatter.description).toBe("A short description"); }); it("strips double quotes from values", () => { const content = `--- title: "Quoted Title" --- `; const { frontmatter } = parseFrontmatter(content); expect(frontmatter.title).toBe("Quoted Title"); }); it("strips single quotes from values", () => { const content = `--- title: 'Single Quoted' --- `; const { frontmatter } = parseFrontmatter(content); expect(frontmatter.title).toBe("Single Quoted"); }); it("parses YAML folded multiline string", () => { const content = `--- excerpt: > This is a folded multiline string --- `; const { rawFrontmatter } = parseFrontmatter(content); expect(rawFrontmatter.excerpt).toBe( "This is a folded multiline string\n", ); }); it("parses YAML stripped folded multiline string", () => { const content = `--- excerpt: >- This is a stripped folded multiline string --- `; const { rawFrontmatter } = parseFrontmatter(content); expect(rawFrontmatter.excerpt).toBe( "This is a stripped folded multiline string", ); }); it("parses YAML literal multiline string", () => { const content = `--- excerpt: | This is a literal multiline string --- `; const { rawFrontmatter } = parseFrontmatter(content); expect(rawFrontmatter.excerpt).toBe( "This is a literal\nmultiline string\n", ); }); it("parses YAML kept literal multiline string", () => { const content = `--- excerpt: |+ This is a kept literal multiline string end: true --- `; const { rawFrontmatter } = parseFrontmatter(content); expect(rawFrontmatter.excerpt).toBe( "This is a kept literal\nmultiline string\n\n", ); }); it("parses boolean true", () => { const content = `--- draft: true --- `; const { frontmatter } = parseFrontmatter(content); expect(frontmatter.draft).toBe(true); }); it("parses boolean false", () => { const content = `--- draft: false --- `; const { frontmatter } = parseFrontmatter(content); expect(frontmatter.draft).toBe(false); }); it('parses string "true" in draft field as boolean true', () => { const content = `--- draft: true --- `; const { rawFrontmatter } = parseFrontmatter(content); expect(rawFrontmatter.draft).toBe(true); }); }); describe("arrays", () => { it("parses inline YAML arrays", () => { const content = `--- tags: [typescript, bun, testing] --- `; const { frontmatter } = parseFrontmatter(content); expect(frontmatter.tags).toEqual(["typescript", "bun", "testing"]); }); it("parses inline YAML arrays with quoted items", () => { const content = `--- tags: ["typescript", "bun", "testing"] --- `; const { frontmatter } = parseFrontmatter(content); expect(frontmatter.tags).toEqual(["typescript", "bun", "testing"]); }); it("parses YAML block arrays", () => { const content = `--- tags: - typescript - bun - testing --- `; const { frontmatter } = parseFrontmatter(content); expect(frontmatter.tags).toEqual(["typescript", "bun", "testing"]); }); it("parses YAML block arrays with quoted items", () => { const content = `--- tags: - "typescript" - 'bun' --- `; const { frontmatter } = parseFrontmatter(content); expect(frontmatter.tags).toEqual(["typescript", "bun"]); }); it("parses inline TOML arrays", () => { const content = `+++ tags = ["typescript", "bun"] +++ `; const { frontmatter } = parseFrontmatter(content); expect(frontmatter.tags).toEqual(["typescript", "bun"]); }); }); describe("publish date fallbacks", () => { it("uses publishDate field directly", () => { const content = `--- publishDate: 2024-01-15 --- `; const { frontmatter } = parseFrontmatter(content); expect(frontmatter.publishDate).toBe("2024-01-15"); }); it("falls back to pubDate", () => { const content = `--- pubDate: 2024-02-01 --- `; const { frontmatter } = parseFrontmatter(content); expect(frontmatter.publishDate).toBe("2024-02-01"); }); it("falls back to date", () => { const content = `--- date: 2024-03-10 --- `; const { frontmatter } = parseFrontmatter(content); expect(frontmatter.publishDate).toBe("2024-03-10"); }); it("falls back to createdAt", () => { const content = `--- createdAt: 2024-04-20 --- `; const { frontmatter } = parseFrontmatter(content); expect(frontmatter.publishDate).toBe("2024-04-20"); }); it("falls back to created_at", () => { const content = `--- created_at: 2024-05-30 --- `; const { frontmatter } = parseFrontmatter(content); expect(frontmatter.publishDate).toBe("2024-05-30"); }); it("prefers publishDate over other fallbacks", () => { const content = `--- publishDate: 2024-01-01 date: 2023-01-01 --- `; const { frontmatter } = parseFrontmatter(content); expect(frontmatter.publishDate).toBe("2024-01-01"); }); }); describe("rawFrontmatter", () => { it("returns all raw fields", () => { const content = `--- title: Raw Test custom: value --- `; const { rawFrontmatter } = parseFrontmatter(content); expect(rawFrontmatter.title).toBe("Raw Test"); expect(rawFrontmatter.custom).toBe("value"); }); it("preserves atUri in both frontmatter and rawFrontmatter", () => { const content = `--- title: Post atUri: at://did:plc:abc123/app.bsky.feed.post/xyz --- `; const { frontmatter, rawFrontmatter } = parseFrontmatter(content); expect(frontmatter.atUri).toBe( "at://did:plc:abc123/app.bsky.feed.post/xyz", ); expect(rawFrontmatter.atUri).toBe( "at://did:plc:abc123/app.bsky.feed.post/xyz", ); }); }); describe("FrontmatterMapping", () => { it("maps a custom title field", () => { const content = `--- name: My Mapped Title --- `; const { frontmatter } = parseFrontmatter(content, { title: "name" }); expect(frontmatter.title).toBe("My Mapped Title"); }); it("maps a custom description field", () => { const content = `--- summary: Custom description --- `; const { frontmatter } = parseFrontmatter(content, { description: "summary", }); expect(frontmatter.description).toBe("Custom description"); }); it("maps a custom publishDate field", () => { const content = `--- publishedOn: 2024-06-15 --- `; const { frontmatter } = parseFrontmatter(content, { publishDate: "publishedOn", }); expect(frontmatter.publishDate).toBe("2024-06-15"); }); it("maps a custom coverImage field", () => { const content = `--- heroImage: /images/cover.jpg --- `; const { frontmatter } = parseFrontmatter(content, { coverImage: "heroImage", }); expect(frontmatter.ogImage).toBe("/images/cover.jpg"); }); it("maps a custom tags field", () => { const content = `--- categories: [news, updates] --- `; const { frontmatter } = parseFrontmatter(content, { tags: "categories" }); expect(frontmatter.tags).toEqual(["news", "updates"]); }); it("maps a custom draft field", () => { const content = `--- unpublished: true --- `; const { frontmatter } = parseFrontmatter(content, { draft: "unpublished", }); expect(frontmatter.draft).toBe(true); }); it("falls back to standard field name when mapped field is absent", () => { const content = `--- title: Standard Title --- `; const { frontmatter } = parseFrontmatter(content, { title: "heading" }); expect(frontmatter.title).toBe("Standard Title"); }); }); describe("body", () => { it("returns the body content after the closing delimiter", () => { const content = `--- title: Post --- # Heading Some paragraph text.`; const { body } = parseFrontmatter(content); expect(body).toBe("# Heading\n\nSome paragraph text."); }); it("returns an empty body when there is no content after frontmatter", () => { const content = `--- title: Post --- `; const { body } = parseFrontmatter(content); expect(body).toBe(""); }); it("preserves TOML tables", () => { const content = `+++ title = "Example" coverImage = "static/covers/example.png" [cover] image = "/covers/example.png" alt = "…" hidden = true +++ Body content here.`; const { frontmatter, body, rawFrontmatter } = parseFrontmatter(content); expect(frontmatter.title).toBe("Example"); expect(body).toBe("Body content here."); const coverTable = rawFrontmatter?.cover as | Record | undefined; expect(coverTable).not.toBe(undefined); const coverImageField = coverTable?.image as string | undefined; expect(coverImageField).toBe("/covers/example.png"); }); }); }); describe("resolvePostPath", () => { const post: BlogPost = { filePath: "/tmp/hello.md", slug: "hello-world", frontmatter: { title: "Hello" } as BlogPost["frontmatter"], content: "", rawContent: "", rawFrontmatter: {}, }; it("defaults to /posts when pathPrefix is undefined", () => { expect(resolvePostPath(post)).toBe("/posts/hello-world"); }); it("uses custom prefix when provided", () => { expect(resolvePostPath(post, "/blog")).toBe("/blog/hello-world"); }); it("omits prefix when pathPrefix is an empty string", () => { expect(resolvePostPath(post, "")).toBe("/hello-world"); }); it("pathTemplate overrides pathPrefix", () => { expect(resolvePostPath(post, "", "/custom/{slug}")).toBe( "/custom/hello-world", ); }); }); describe("stripMarkdownForText", () => { it("removes h1 through h6 headers", async () => { expect( await stripMarkdownForText( "# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6", ), ).toBe("H1\n\nH2\n\nH3\n\nH4\n\nH5\n\nH6"); }); it("removes bold formatting", async () => { expect(await stripMarkdownForText("This is **bold** text.")).toBe( "This is bold text.", ); }); it("removes italic formatting", async () => { expect(await stripMarkdownForText("This is *italic* text.")).toBe( "This is italic text.", ); }); it("removes links but keeps link text", async () => { expect( await stripMarkdownForText("[click here](https://example.com)"), ).toBe("click here"); }); it("removes fenced code blocks", async () => { const input = "before\n```\nconst x = 1;\n```\nafter"; expect(await stripMarkdownForText(input)).toBe("before\n\nafter"); }); it("removes inline code formatting", async () => { expect(await stripMarkdownForText("Run `npm install` to install.")).toBe( "Run npm install to install.", ); }); it("removes images", async () => { expect( await stripMarkdownForText("before ![alt text](image.png) after"), ).toBe("before alt text after"); }); it("normalizes three or more consecutive newlines to two", async () => { expect(await stripMarkdownForText("line 1\n\n\n\nline 2")).toBe( "line 1\n\nline 2", ); }); it("removes single tilde strikethrough", async () => { expect(await stripMarkdownForText("~strikethrough~")).toBe("strikethrough"); }); it("removes double tilde strikethrough", async () => { expect(await stripMarkdownForText("~~strikethrough~~")).toBe( "strikethrough", ); }); it("removes HTML comments but preserves -- outside comments", async () => { expect(await stripMarkdownForText("a -- b e -- f")).toBe( "a -- b e -- f", ); }); it("removes footnote definitions and inline references", async () => { const input = "Text with[^1] a reference.\n\n[^1]: The footnote definition."; expect(await stripMarkdownForText(input)).toBe("Text with a reference."); }); it("trims leading and trailing whitespace", async () => { expect(await stripMarkdownForText("\n\nhello\n\n")).toBe("hello"); }); it("keeps HTML figure captions", async () => { const input = `# Title _Emphasized text._
Figure caption: - Foo - Bar
List: 1. One 2. Two `; expect(await stripMarkdownForText(input)).toBe(`Title Emphasized text. Figure caption: Foo Bar List: One Two`); }); it("keeps nested HTML DIV content", async () => { const input = `# Title _Emphasized text._

Text in a DIV.

Footer`; expect(await stripMarkdownForText(input)).toBe(`Title Emphasized text. Text in a DIV. Footer`); }); }); describe("updateFrontmatterWithAtUri", () => { it("inserts atUri into YAML frontmatter when it does not exist", () => { const content = `--- title: Hello World --- Body content here.`; const updatedContent = updateFrontmatterWithAtUri( content, "at://did:plc:abc123/app.bsky.feed.post/xyz", ); expect(updatedContent).toBe(`--- title: Hello World atUri: at://did:plc:abc123/app.bsky.feed.post/xyz --- Body content here.`); }); it("replaces an existing atUri in YAML frontmatter", () => { const content = `--- title: Hello World atUri: "at://did:plc:old/app.bsky.feed.post/old" --- Body content here.`; const updatedContent = updateFrontmatterWithAtUri( content, "at://did:plc:new/app.bsky.feed.post/new", ); expect(updatedContent).toBe(`--- title: Hello World atUri: at://did:plc:new/app.bsky.feed.post/new --- Body content here.`); }); it("inserts atUri into TOML frontmatter when it does not exist", () => { const content = `+++ title = "Hello World" +++ Body content here.`; const updatedContent = updateFrontmatterWithAtUri( content, "at://did:plc:abc123/app.bsky.feed.post/xyz", ); expect(updatedContent).toBe(`+++ title = "Hello World" atUri = "at://did:plc:abc123/app.bsky.feed.post/xyz" +++ Body content here.`); }); it("replaces an existing atUri in TOML frontmatter", () => { const content = `+++ title = "Hello World" atUri = "at://did:plc:old/app.bsky.feed.post/old" +++ Body content here.`; const updatedContent = updateFrontmatterWithAtUri( content, "at://did:plc:new/app.bsky.feed.post/new", ); expect(updatedContent).toBe(`+++ title = "Hello World" atUri = "at://did:plc:new/app.bsky.feed.post/new" +++ Body content here.`); }); it("inserts atUri into TOML frontmatter when it include another table", () => { const content = `+++ title = "Example" coverImage = "static/covers/example.png" [cover] image = "/covers/example.png" alt = "…" hidden = true +++ Body content here.`; const updatedContent = updateFrontmatterWithAtUri( content, "at://did:plc:new/app.bsky.feed.post/new", ); expect(updatedContent).toBe(`+++ title = "Example" coverImage = "static/covers/example.png" atUri = "at://did:plc:new/app.bsky.feed.post/new" [cover] image = "/covers/example.png" alt = "…" hidden = true +++ Body content here.`); }); }); describe("getContentHash", () => { it("returns the expected SHA-256 hash as lowercase hex", async () => { const post: Partial = { rawContent: "hello world", }; expect(await getContentHash(post)).toBe( "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", ); }); it("returns the same hash for identical content and different hashes for different content", async () => { const firstPost: Partial = { rawContent: "same content", }; const secondPost: Partial = { rawContent: "same content", }; const thirdPost: Partial = { rawContent: "different content", }; const first = await getContentHash(firstPost); const second = await getContentHash(secondPost); const third = await getContentHash(thirdPost); expect(first).toBe(second); expect(first).not.toBe(third); expect(first).toMatch(/^[a-f0-9]{64}$/); }); it("returns a different hash with a coverImagePath", async () => { const textPost: Partial = { rawContent: "hello world", }; const imagePost: Partial = { rawContent: "hello world", coverImagePath: __filename, }; const textHash = await getContentHash(textPost); const imageHash = await getContentHash(imagePost); expect(textHash).not.toBe(imageHash); }); it("returns a different hash with a different coverImagePath", async () => { const firstPost: Partial = { rawContent: "hello world", coverImagePath: __filename, }; const secondPost: Partial = { rawContent: "hello world", coverImagePath: `${__dirname}/config.test.ts`, }; const firstHash = await getContentHash(firstPost); const secondHash = await getContentHash(secondPost); expect(firstHash).not.toBe(secondHash); }); });