| 1 | import { describe, expect, it } from "bun:test"; |
| 2 | import { parseFrontmatter } from "../src/lib/markdown"; |
| 3 | |
| 4 | describe("parseFrontmatter", () => { |
| 5 | describe("delimiters", () => { |
| 6 | it("parses YAML frontmatter (--- delimiter)", () => { |
| 7 | const content = `--- |
| 8 | title: Hello World |
| 9 | --- |
| 10 | Body content here.`; |
| 11 | const { frontmatter, body } = parseFrontmatter(content); |
| 12 | expect(frontmatter.title).toBe("Hello World"); |
| 13 | expect(body).toBe("Body content here."); |
| 14 | }); |
| 15 | |
| 16 | it("parses TOML frontmatter (+++ delimiter)", () => { |
| 17 | const content = `+++ |
| 18 | title = "Hugo Post" |
| 19 | +++ |
| 20 | Body content here.`; |
| 21 | const { frontmatter, body } = parseFrontmatter(content); |
| 22 | expect(frontmatter.title).toBe("Hugo Post"); |
| 23 | expect(body).toBe("Body content here."); |
| 24 | }); |
| 25 | |
| 26 | it("parses alternative frontmatter (*** delimiter)", () => { |
| 27 | const content = `*** |
| 28 | title: Alt Post |
| 29 | *** |
| 30 | Body content here.`; |
| 31 | const { frontmatter, body } = parseFrontmatter(content); |
| 32 | expect(frontmatter.title).toBe("Alt Post"); |
| 33 | expect(body).toBe("Body content here."); |
| 34 | }); |
| 35 | |
| 36 | it("throws when no frontmatter is present", () => { |
| 37 | const content = "Just plain content with no frontmatter."; |
| 38 | expect(() => parseFrontmatter(content)).toThrow( |
| 39 | "Could not parse frontmatter", |
| 40 | ); |
| 41 | }); |
| 42 | }); |
| 43 | |
| 44 | describe("scalar values", () => { |
| 45 | it("parses a string value", () => { |
| 46 | const content = `--- |
| 47 | title: My Post |
| 48 | description: A short description |
| 49 | --- |
| 50 | `; |
| 51 | const { frontmatter } = parseFrontmatter(content); |
| 52 | expect(frontmatter.title).toBe("My Post"); |
| 53 | expect(frontmatter.description).toBe("A short description"); |
| 54 | }); |
| 55 | |
| 56 | it("strips double quotes from values", () => { |
| 57 | const content = `--- |
| 58 | title: "Quoted Title" |
| 59 | --- |
| 60 | `; |
| 61 | const { frontmatter } = parseFrontmatter(content); |
| 62 | expect(frontmatter.title).toBe("Quoted Title"); |
| 63 | }); |
| 64 | |
| 65 | it("strips single quotes from values", () => { |
| 66 | const content = `--- |
| 67 | title: 'Single Quoted' |
| 68 | --- |
| 69 | `; |
| 70 | const { frontmatter } = parseFrontmatter(content); |
| 71 | expect(frontmatter.title).toBe("Single Quoted"); |
| 72 | }); |
| 73 | |
| 74 | it("parses YAML folded multiline string", () => { |
| 75 | const content = `--- |
| 76 | excerpt: > |
| 77 | This is a folded |
| 78 | multiline string |
| 79 | --- |
| 80 | `; |
| 81 | const { rawFrontmatter } = parseFrontmatter(content); |
| 82 | expect(rawFrontmatter.excerpt).toBe( |
| 83 | "This is a folded multiline string\n", |
| 84 | ); |
| 85 | }); |
| 86 | |
| 87 | it("parses YAML stripped folded multiline string", () => { |
| 88 | const content = `--- |
| 89 | excerpt: >- |
| 90 | This is a stripped folded |
| 91 | multiline string |
| 92 | --- |
| 93 | `; |
| 94 | const { rawFrontmatter } = parseFrontmatter(content); |
| 95 | expect(rawFrontmatter.excerpt).toBe( |
| 96 | "This is a stripped folded multiline string", |
| 97 | ); |
| 98 | }); |
| 99 | |
| 100 | it("parses YAML literal multiline string", () => { |
| 101 | const content = `--- |
| 102 | excerpt: | |
| 103 | This is a literal |
| 104 | multiline string |
| 105 | --- |
| 106 | `; |
| 107 | const { rawFrontmatter } = parseFrontmatter(content); |
| 108 | expect(rawFrontmatter.excerpt).toBe( |
| 109 | "This is a literal\nmultiline string\n", |
| 110 | ); |
| 111 | }); |
| 112 | |
| 113 | it("parses YAML kept literal multiline string", () => { |
| 114 | const content = `--- |
| 115 | excerpt: |+ |
| 116 | This is a kept literal |
| 117 | multiline string |
| 118 | |
| 119 | end: true |
| 120 | --- |
| 121 | `; |
| 122 | const { rawFrontmatter } = parseFrontmatter(content); |
| 123 | expect(rawFrontmatter.excerpt).toBe( |
| 124 | "This is a kept literal\nmultiline string\n\n", |
| 125 | ); |
| 126 | }); |
| 127 | |
| 128 | it("parses boolean true", () => { |
| 129 | const content = `--- |
| 130 | draft: true |
| 131 | --- |
| 132 | `; |
| 133 | const { frontmatter } = parseFrontmatter(content); |
| 134 | expect(frontmatter.draft).toBe(true); |
| 135 | }); |
| 136 | |
| 137 | it("parses boolean false", () => { |
| 138 | const content = `--- |
| 139 | draft: false |
| 140 | --- |
| 141 | `; |
| 142 | const { frontmatter } = parseFrontmatter(content); |
| 143 | expect(frontmatter.draft).toBe(false); |
| 144 | }); |
| 145 | |
| 146 | it('parses string "true" in draft field as boolean true', () => { |
| 147 | const content = `--- |
| 148 | draft: true |
| 149 | --- |
| 150 | `; |
| 151 | const { rawFrontmatter } = parseFrontmatter(content); |
| 152 | expect(rawFrontmatter.draft).toBe(true); |
| 153 | }); |
| 154 | }); |
| 155 | |
| 156 | describe("arrays", () => { |
| 157 | it("parses inline YAML arrays", () => { |
| 158 | const content = `--- |
| 159 | tags: [typescript, bun, testing] |
| 160 | --- |
| 161 | `; |
| 162 | const { frontmatter } = parseFrontmatter(content); |
| 163 | expect(frontmatter.tags).toEqual(["typescript", "bun", "testing"]); |
| 164 | }); |
| 165 | |
| 166 | it("parses inline YAML arrays with quoted items", () => { |
| 167 | const content = `--- |
| 168 | tags: ["typescript", "bun", "testing"] |
| 169 | --- |
| 170 | `; |
| 171 | const { frontmatter } = parseFrontmatter(content); |
| 172 | expect(frontmatter.tags).toEqual(["typescript", "bun", "testing"]); |
| 173 | }); |
| 174 | |
| 175 | it("parses YAML block arrays", () => { |
| 176 | const content = `--- |
| 177 | tags: |
| 178 | - typescript |
| 179 | - bun |
| 180 | - testing |
| 181 | --- |
| 182 | `; |
| 183 | const { frontmatter } = parseFrontmatter(content); |
| 184 | expect(frontmatter.tags).toEqual(["typescript", "bun", "testing"]); |
| 185 | }); |
| 186 | |
| 187 | it("parses YAML block arrays with quoted items", () => { |
| 188 | const content = `--- |
| 189 | tags: |
| 190 | - "typescript" |
| 191 | - 'bun' |
| 192 | --- |
| 193 | `; |
| 194 | const { frontmatter } = parseFrontmatter(content); |
| 195 | expect(frontmatter.tags).toEqual(["typescript", "bun"]); |
| 196 | }); |
| 197 | |
| 198 | it("parses inline TOML arrays", () => { |
| 199 | const content = `+++ |
| 200 | tags = ["typescript", "bun"] |
| 201 | +++ |
| 202 | `; |
| 203 | const { frontmatter } = parseFrontmatter(content); |
| 204 | expect(frontmatter.tags).toEqual(["typescript", "bun"]); |
| 205 | }); |
| 206 | }); |
| 207 | |
| 208 | describe("publish date fallbacks", () => { |
| 209 | it("uses publishDate field directly", () => { |
| 210 | const content = `--- |
| 211 | publishDate: 2024-01-15 |
| 212 | --- |
| 213 | `; |
| 214 | const { frontmatter } = parseFrontmatter(content); |
| 215 | expect(frontmatter.publishDate).toBe("2024-01-15"); |
| 216 | }); |
| 217 | |
| 218 | it("falls back to pubDate", () => { |
| 219 | const content = `--- |
| 220 | pubDate: 2024-02-01 |
| 221 | --- |
| 222 | `; |
| 223 | const { frontmatter } = parseFrontmatter(content); |
| 224 | expect(frontmatter.publishDate).toBe("2024-02-01"); |
| 225 | }); |
| 226 | |
| 227 | it("falls back to date", () => { |
| 228 | const content = `--- |
| 229 | date: 2024-03-10 |
| 230 | --- |
| 231 | `; |
| 232 | const { frontmatter } = parseFrontmatter(content); |
| 233 | expect(frontmatter.publishDate).toBe("2024-03-10"); |
| 234 | }); |
| 235 | |
| 236 | it("falls back to createdAt", () => { |
| 237 | const content = `--- |
| 238 | createdAt: 2024-04-20 |
| 239 | --- |
| 240 | `; |
| 241 | const { frontmatter } = parseFrontmatter(content); |
| 242 | expect(frontmatter.publishDate).toBe("2024-04-20"); |
| 243 | }); |
| 244 | |
| 245 | it("falls back to created_at", () => { |
| 246 | const content = `--- |
| 247 | created_at: 2024-05-30 |
| 248 | --- |
| 249 | `; |
| 250 | const { frontmatter } = parseFrontmatter(content); |
| 251 | expect(frontmatter.publishDate).toBe("2024-05-30"); |
| 252 | }); |
| 253 | |
| 254 | it("prefers publishDate over other fallbacks", () => { |
| 255 | const content = `--- |
| 256 | publishDate: 2024-01-01 |
| 257 | date: 2023-01-01 |
| 258 | --- |
| 259 | `; |
| 260 | const { frontmatter } = parseFrontmatter(content); |
| 261 | expect(frontmatter.publishDate).toBe("2024-01-01"); |
| 262 | }); |
| 263 | }); |
| 264 | |
| 265 | describe("rawFrontmatter", () => { |
| 266 | it("returns all raw fields", () => { |
| 267 | const content = `--- |
| 268 | title: Raw Test |
| 269 | custom: value |
| 270 | --- |
| 271 | `; |
| 272 | const { rawFrontmatter } = parseFrontmatter(content); |
| 273 | expect(rawFrontmatter.title).toBe("Raw Test"); |
| 274 | expect(rawFrontmatter.custom).toBe("value"); |
| 275 | }); |
| 276 | |
| 277 | it("preserves atUri in both frontmatter and rawFrontmatter", () => { |
| 278 | const content = `--- |
| 279 | title: Post |
| 280 | atUri: at://did:plc:abc123/app.bsky.feed.post/xyz |
| 281 | --- |
| 282 | `; |
| 283 | const { frontmatter, rawFrontmatter } = parseFrontmatter(content); |
| 284 | expect(frontmatter.atUri).toBe( |
| 285 | "at://did:plc:abc123/app.bsky.feed.post/xyz", |
| 286 | ); |
| 287 | expect(rawFrontmatter.atUri).toBe( |
| 288 | "at://did:plc:abc123/app.bsky.feed.post/xyz", |
| 289 | ); |
| 290 | }); |
| 291 | }); |
| 292 | |
| 293 | describe("FrontmatterMapping", () => { |
| 294 | it("maps a custom title field", () => { |
| 295 | const content = `--- |
| 296 | name: My Mapped Title |
| 297 | --- |
| 298 | `; |
| 299 | const { frontmatter } = parseFrontmatter(content, { title: "name" }); |
| 300 | expect(frontmatter.title).toBe("My Mapped Title"); |
| 301 | }); |
| 302 | |
| 303 | it("maps a custom description field", () => { |
| 304 | const content = `--- |
| 305 | summary: Custom description |
| 306 | --- |
| 307 | `; |
| 308 | const { frontmatter } = parseFrontmatter(content, { |
| 309 | description: "summary", |
| 310 | }); |
| 311 | expect(frontmatter.description).toBe("Custom description"); |
| 312 | }); |
| 313 | |
| 314 | it("maps a custom publishDate field", () => { |
| 315 | const content = `--- |
| 316 | publishedOn: 2024-06-15 |
| 317 | --- |
| 318 | `; |
| 319 | const { frontmatter } = parseFrontmatter(content, { |
| 320 | publishDate: "publishedOn", |
| 321 | }); |
| 322 | expect(frontmatter.publishDate).toBe("2024-06-15"); |
| 323 | }); |
| 324 | |
| 325 | it("maps a custom coverImage field", () => { |
| 326 | const content = `--- |
| 327 | heroImage: /images/cover.jpg |
| 328 | --- |
| 329 | `; |
| 330 | const { frontmatter } = parseFrontmatter(content, { |
| 331 | coverImage: "heroImage", |
| 332 | }); |
| 333 | expect(frontmatter.ogImage).toBe("/images/cover.jpg"); |
| 334 | }); |
| 335 | |
| 336 | it("maps a custom tags field", () => { |
| 337 | const content = `--- |
| 338 | categories: [news, updates] |
| 339 | --- |
| 340 | `; |
| 341 | const { frontmatter } = parseFrontmatter(content, { tags: "categories" }); |
| 342 | expect(frontmatter.tags).toEqual(["news", "updates"]); |
| 343 | }); |
| 344 | |
| 345 | it("maps a custom draft field", () => { |
| 346 | const content = `--- |
| 347 | unpublished: true |
| 348 | --- |
| 349 | `; |
| 350 | const { frontmatter } = parseFrontmatter(content, { |
| 351 | draft: "unpublished", |
| 352 | }); |
| 353 | expect(frontmatter.draft).toBe(true); |
| 354 | }); |
| 355 | |
| 356 | it("falls back to standard field name when mapped field is absent", () => { |
| 357 | const content = `--- |
| 358 | title: Standard Title |
| 359 | --- |
| 360 | `; |
| 361 | const { frontmatter } = parseFrontmatter(content, { title: "heading" }); |
| 362 | expect(frontmatter.title).toBe("Standard Title"); |
| 363 | }); |
| 364 | }); |
| 365 | |
| 366 | describe("body", () => { |
| 367 | it("returns the body content after the closing delimiter", () => { |
| 368 | const content = `--- |
| 369 | title: Post |
| 370 | --- |
| 371 | # Heading |
| 372 | |
| 373 | Some paragraph text.`; |
| 374 | const { body } = parseFrontmatter(content); |
| 375 | expect(body).toBe("# Heading\n\nSome paragraph text."); |
| 376 | }); |
| 377 | |
| 378 | it("returns an empty body when there is no content after frontmatter", () => { |
| 379 | const content = `--- |
| 380 | title: Post |
| 381 | --- |
| 382 | `; |
| 383 | const { body } = parseFrontmatter(content); |
| 384 | expect(body).toBe(""); |
| 385 | }); |
| 386 | }); |
| 387 | }); |