forked from
stevedylan.dev/sequoia
A CLI for publishing standard.site documents to ATProto
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  partially matches as a link first
393 expect(stripMarkdownForText("text  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});