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