A CLI for publishing standard.site documents to ATProto
sequoia.pub
standard
site
lexicon
cli
publishing
1import { describe, expect, it } from "bun:test";
2import { parseFrontmatter, resolvePostPath } from "../src/lib/markdown";
3import type { BlogPost } from "../src/lib/types";
4
5describe("parseFrontmatter", () => {
6 describe("delimiters", () => {
7 it("parses YAML frontmatter (--- delimiter)", () => {
8 const content = `---
9title: Hello World
10---
11Body 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 = `+++
19title = "Hugo Post"
20+++
21Body 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 = `***
29title: Alt Post
30***
31Body 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 = `---
48title: My Post
49description: 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 = `---
59title: "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 = `---
68title: '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 = `---
77excerpt: >
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 = `---
90excerpt: >-
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 = `---
103excerpt: |
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 = `---
116excerpt: |+
117 This is a kept literal
118 multiline string
119
120end: 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 = `---
131draft: true
132---
133`;
134 const { frontmatter } = parseFrontmatter(content);
135 expect(frontmatter.draft).toBe(true);
136 });
137
138 it("parses boolean false", () => {
139 const content = `---
140draft: 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 = `---
149draft: 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 = `---
160tags: [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 = `---
169tags: ["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 = `---
178tags:
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 = `---
190tags:
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 = `+++
201tags = ["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 = `---
212publishDate: 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 = `---
221pubDate: 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 = `---
230date: 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 = `---
239createdAt: 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 = `---
248created_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 = `---
257publishDate: 2024-01-01
258date: 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 = `---
269title: Raw Test
270custom: 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 = `---
280title: Post
281atUri: 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 = `---
297name: 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 = `---
306summary: 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 = `---
317publishedOn: 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 = `---
328heroImage: /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 = `---
339categories: [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 = `---
348unpublished: 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 = `---
359title: 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 = `---
370title: Post
371---
372# Heading
373
374Some 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 = `---
381title: Post
382---
383`;
384 const { body } = parseFrontmatter(content);
385 expect(body).toBe("");
386 });
387 });
388});
389
390describe("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});