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