A CLI for publishing standard.site documents to ATProto

feat: handle missing frontmatter in parseFrontmatter and updateFrontmatterWithAtUri

Add unit tests for markdown.ts and make frontmatter optional: parseFrontmatter
now extracts title from headings when no frontmatter is present,
and updateFrontmatterWithAtUri creates a new frontmatter block when none exists.

+459 -1
+439
packages/cli/src/lib/markdown.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { 3 + getContentHash, 4 + getSlugFromFilename, 5 + getSlugFromOptions, 6 + getTextContent, 7 + parseFrontmatter, 8 + stripMarkdownForText, 9 + updateFrontmatterWithAtUri, 10 + } from "./markdown"; 11 + 12 + describe("parseFrontmatter", () => { 13 + test("parses YAML frontmatter with --- delimiters", () => { 14 + const content = `--- 15 + title: My Post 16 + description: A description 17 + publishDate: 2024-01-15 18 + --- 19 + Hello 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 = `+++ 31 + title = My Post 32 + description = A description 33 + date = 2024-01-15 34 + +++ 35 + Body 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 = `*** 46 + title: Test 47 + *** 48 + Body`; 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 + 58 + Some 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 = `--- 76 + title: "Quoted Title" 77 + description: 'Single Quoted' 78 + --- 79 + Body`; 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 = `--- 88 + title: Post 89 + tags: [javascript, typescript, "web dev"] 90 + --- 91 + Body`; 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 = `--- 103 + title: Post 104 + tags: 105 + - javascript 106 + - typescript 107 + - web dev 108 + --- 109 + Body`; 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 = `--- 121 + title: Draft Post 122 + draft: true 123 + published: false 124 + --- 125 + Body`; 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 = `--- 134 + nombre: Custom Title 135 + descripcion: Custom Desc 136 + fecha: 2024-06-01 137 + imagen: cover.jpg 138 + etiquetas: [a, b] 139 + borrador: true 140 + --- 141 + Body`; 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 = `--- 163 + title: Post 164 + date: 2024-03-20 165 + --- 166 + Body`; 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 = `--- 174 + title: Post 175 + pubDate: 2024-04-10 176 + --- 177 + Body`; 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 = `--- 185 + title: Post 186 + atUri: at://did:plc:abc/site.standard.post/123 187 + --- 188 + Body`; 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 = `--- 198 + title: Post 199 + draft: true 200 + --- 201 + Body`; 202 + 203 + const result = parseFrontmatter(content); 204 + expect(result.frontmatter.draft).toBe(true); 205 + }); 206 + }); 207 + 208 + describe("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 + 226 + describe("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 + 297 + describe("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 + 316 + describe("updateFrontmatterWithAtUri", () => { 317 + test("inserts atUri into YAML frontmatter", () => { 318 + const content = `--- 319 + title: My Post 320 + --- 321 + Body`; 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 = `+++ 330 + title = My Post 331 + +++ 332 + Body`; 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 = `--- 349 + title: My Post 350 + atUri: "at://did:plc:old/post/000" 351 + --- 352 + Body`; 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 = `+++ 361 + title = My Post 362 + atUri = "at://did:plc:old/post/000" 363 + +++ 364 + Body`; 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 + 372 + describe("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 ![alt](url) partially matches as a link first 393 + expect(stripMarkdownForText("text ![alt](image.png) 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 + 415 + describe("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 + });
+20 -1
packages/cli/src/lib/markdown.ts
··· 21 21 const match = content.match(frontmatterRegex); 22 22 23 23 if (!match) { 24 - throw new Error("Could not parse frontmatter"); 24 + const [, titleMatch] = content.trim().match(/^# (.+)$/m) || [] 25 + const title = titleMatch ?? "" 26 + const [publishDate] = new Date().toISOString().split("T") 27 + 28 + return { 29 + frontmatter: { 30 + title, 31 + publishDate: publishDate ?? "" 32 + }, 33 + body: content, 34 + rawFrontmatter: { 35 + title: 36 + publishDate 37 + } 38 + } 25 39 } 26 40 27 41 const delimiter = match[1]; ··· 353 367 354 368 // Format the atUri entry based on frontmatter type 355 369 const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`; 370 + 371 + // No frontmatter: create one with atUri 372 + if (!delimiterMatch) { 373 + return `---\n${atUriEntry}\n---\n\n${rawContent}`; 374 + } 356 375 357 376 // Check if atUri already exists in frontmatter (handle both formats) 358 377 if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) {