diff --git a/bun.lock b/bun.lock index ae399fb..5c63fdb 100644 --- a/bun.lock +++ b/bun.lock @@ -34,12 +34,15 @@ "@clack/prompts": "^1.0.0", "cmd-ts": "^0.14.3", "glob": "^13.0.0", + "js-yaml": "^4.1.1", "mime-types": "^2.1.35", "minimatch": "^10.1.1", "open": "^11.0.0", + "smol-toml": "^1.6.0", }, "devDependencies": { "@biomejs/biome": "^2.3.13", + "@types/js-yaml": "^4.0.9", "@types/mime-types": "^3.0.1", "@types/node": "^20", }, @@ -581,6 +584,8 @@ "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], @@ -623,6 +628,8 @@ "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], @@ -1003,6 +1010,8 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], @@ -1375,6 +1384,8 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], diff --git a/packages/cli/package.json b/packages/cli/package.json index 0dfacc4..2a9f14b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -17,11 +17,13 @@ "lint": "biome lint --write", "format": "biome format --write", "build": "bun build src/index.ts --target node --outdir dist && mkdir -p dist/components && cp src/components/*.js dist/components/", + "test": "bun test", "dev": "bun run build && bun link", "deploy": "bun run build && bun publish" }, "devDependencies": { "@biomejs/biome": "^2.3.13", + "@types/js-yaml": "^4.0.9", "@types/mime-types": "^3.0.1", "@types/node": "^20" }, @@ -34,8 +36,10 @@ "@clack/prompts": "^1.0.0", "cmd-ts": "^0.14.3", "glob": "^13.0.0", + "js-yaml": "^4.1.1", "mime-types": "^2.1.35", "minimatch": "^10.1.1", - "open": "^11.0.0" + "open": "^11.0.0", + "smol-toml": "^1.6.0" } } diff --git a/packages/cli/src/lib/markdown.ts b/packages/cli/src/lib/markdown.ts index 6c6344c..0a26218 100644 --- a/packages/cli/src/lib/markdown.ts +++ b/packages/cli/src/lib/markdown.ts @@ -1,7 +1,9 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { glob } from "glob"; +import yaml from "js-yaml"; import { minimatch } from "minimatch"; +import * as toml from "smol-toml"; import type { BlogPost, FrontmatterMapping, PostFrontmatter } from "./types"; export function parseFrontmatter( @@ -31,88 +33,14 @@ export function parseFrontmatter( // +++ uses TOML (key = value) // --- and *** use YAML (key: value) const isToml = delimiter === "+++"; - const separator = isToml ? "=" : ":"; - - // Parse frontmatter manually - const raw: Record = {}; - const lines = frontmatterStr.split("\n"); - - let i = 0; - while (i < lines.length) { - const line = lines[i]; - if (line === undefined) { - i++; - continue; - } - const sepIndex = line.indexOf(separator); - if (sepIndex === -1) { - i++; - continue; - } - - const key = line.slice(0, sepIndex).trim(); - let value = line.slice(sepIndex + 1).trim(); - // Handle quoted strings - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - - // Handle inline arrays (simple case for tags) - if (value.startsWith("[") && value.endsWith("]")) { - const arrayContent = value.slice(1, -1); - raw[key] = arrayContent - .split(",") - .map((item) => item.trim().replace(/^["']|["']$/g, "")); - } else if (value === "" && !isToml) { - // Check for YAML-style multiline array (key with no value followed by - items) - const arrayItems: string[] = []; - let j = i + 1; - while (j < lines.length) { - const nextLine = lines[j]; - if (nextLine === undefined) { - j++; - continue; - } - // Check if line is a list item (starts with whitespace and -) - const listMatch = nextLine.match(/^\s+-\s*(.*)$/); - if (listMatch && listMatch[1] !== undefined) { - let itemValue = listMatch[1].trim(); - // Remove quotes if present - if ( - (itemValue.startsWith('"') && itemValue.endsWith('"')) || - (itemValue.startsWith("'") && itemValue.endsWith("'")) - ) { - itemValue = itemValue.slice(1, -1); - } - arrayItems.push(itemValue); - j++; - } else if (nextLine.trim() === "") { - // Skip empty lines within the array - j++; - } else { - // Hit a new key or non-list content - break; - } - } - if (arrayItems.length > 0) { - raw[key] = arrayItems; - i = j; - continue; - } else { - raw[key] = value; - } - } else if (value === "true") { - raw[key] = true; - } else if (value === "false") { - raw[key] = false; - } else { - raw[key] = value; - } - i++; + // Parse frontmatter using the appropriate library + let raw: Record; + if (isToml) { + raw = toml.parse(frontmatterStr) as Record; + } else { + // Use CORE_SCHEMA to keep dates as strings rather than Date objects + raw = (yaml.load(frontmatterStr, { schema: yaml.CORE_SCHEMA }) as Record) ?? {}; } // Apply field mappings to normalize to standard PostFrontmatter fields diff --git a/packages/cli/test/markdown.test.ts b/packages/cli/test/markdown.test.ts new file mode 100644 index 0000000..7bd042c --- /dev/null +++ b/packages/cli/test/markdown.test.ts @@ -0,0 +1,377 @@ +import { describe, expect, it } from "bun:test"; +import { parseFrontmatter } from "../src/lib/markdown"; + +describe("parseFrontmatter", () => { + describe("delimiters", () => { + it("parses YAML frontmatter (--- delimiter)", () => { + const content = `--- +title: Hello World +--- +Body content here.`; + const { frontmatter, body } = parseFrontmatter(content); + expect(frontmatter.title).toBe("Hello World"); + expect(body).toBe("Body content here."); + }); + + it("parses TOML frontmatter (+++ delimiter)", () => { + const content = `+++ +title = "Hugo Post" ++++ +Body content here.`; + const { frontmatter, body } = parseFrontmatter(content); + expect(frontmatter.title).toBe("Hugo Post"); + expect(body).toBe("Body content here."); + }); + + it("parses alternative frontmatter (*** delimiter)", () => { + const content = `*** +title: Alt Post +*** +Body content here.`; + const { frontmatter, body } = parseFrontmatter(content); + expect(frontmatter.title).toBe("Alt Post"); + expect(body).toBe("Body content here."); + }); + + it("throws when no frontmatter is present", () => { + const content = "Just plain content with no frontmatter."; + expect(() => parseFrontmatter(content)).toThrow( + "Could not parse frontmatter", + ); + }); + }); + + describe("scalar values", () => { + it("parses a string value", () => { + const content = `--- +title: My Post +description: A short description +--- +`; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.title).toBe("My Post"); + expect(frontmatter.description).toBe("A short description"); + }); + + it("strips double quotes from values", () => { + const content = `--- +title: "Quoted Title" +--- +`; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.title).toBe("Quoted Title"); + }); + + it("strips single quotes from values", () => { + const content = `--- +title: 'Single Quoted' +--- +`; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.title).toBe("Single Quoted"); + }); + + it("parses YAML folded multiline string", () => { + const content = `--- +excerpt: > + This is a folded + multiline string +--- +`; + const { rawFrontmatter } = parseFrontmatter(content); + expect(rawFrontmatter.excerpt).toBe("This is a folded multiline string\n"); + }); + + it("parses YAML stripped folded multiline string", () => { + const content = `--- +excerpt: >- + This is a stripped folded + multiline string +--- +`; + const { rawFrontmatter } = parseFrontmatter(content); + expect(rawFrontmatter.excerpt).toBe("This is a stripped folded multiline string"); + }); + + it("parses YAML literal multiline string", () => { + const content = `--- +excerpt: | + This is a literal + multiline string +--- +`; + const { rawFrontmatter } = parseFrontmatter(content); + expect(rawFrontmatter.excerpt).toBe("This is a literal\nmultiline string\n"); + }); + + it("parses YAML kept literal multiline string", () => { + const content = `--- +excerpt: |+ + This is a kept literal + multiline string + +end: true +--- +`; + const { rawFrontmatter } = parseFrontmatter(content); + expect(rawFrontmatter.excerpt).toBe("This is a kept literal\nmultiline string\n\n"); + }); + + it("parses boolean true", () => { + const content = `--- +draft: true +--- +`; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.draft).toBe(true); + }); + + it("parses boolean false", () => { + const content = `--- +draft: false +--- +`; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.draft).toBe(false); + }); + + it('parses string "true" in draft field as boolean true', () => { + const content = `--- +draft: true +--- +`; + const { rawFrontmatter } = parseFrontmatter(content); + expect(rawFrontmatter.draft).toBe(true); + }); + }); + + describe("arrays", () => { + it("parses inline YAML arrays", () => { + const content = `--- +tags: [typescript, bun, testing] +--- +`; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.tags).toEqual(["typescript", "bun", "testing"]); + }); + + it("parses inline YAML arrays with quoted items", () => { + const content = `--- +tags: ["typescript", "bun", "testing"] +--- +`; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.tags).toEqual(["typescript", "bun", "testing"]); + }); + + it("parses YAML block arrays", () => { + const content = `--- +tags: + - typescript + - bun + - testing +--- +`; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.tags).toEqual(["typescript", "bun", "testing"]); + }); + + it("parses YAML block arrays with quoted items", () => { + const content = `--- +tags: + - "typescript" + - 'bun' +--- +`; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.tags).toEqual(["typescript", "bun"]); + }); + + it("parses inline TOML arrays", () => { + const content = `+++ +tags = ["typescript", "bun"] ++++ +`; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.tags).toEqual(["typescript", "bun"]); + }); + }); + + describe("publish date fallbacks", () => { + it("uses publishDate field directly", () => { + const content = `--- +publishDate: 2024-01-15 +--- +`; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.publishDate).toBe("2024-01-15"); + }); + + it("falls back to pubDate", () => { + const content = `--- +pubDate: 2024-02-01 +--- +`; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.publishDate).toBe("2024-02-01"); + }); + + it("falls back to date", () => { + const content = `--- +date: 2024-03-10 +--- +`; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.publishDate).toBe("2024-03-10"); + }); + + it("falls back to createdAt", () => { + const content = `--- +createdAt: 2024-04-20 +--- +`; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.publishDate).toBe("2024-04-20"); + }); + + it("falls back to created_at", () => { + const content = `--- +created_at: 2024-05-30 +--- +`; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.publishDate).toBe("2024-05-30"); + }); + + it("prefers publishDate over other fallbacks", () => { + const content = `--- +publishDate: 2024-01-01 +date: 2023-01-01 +--- +`; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.publishDate).toBe("2024-01-01"); + }); + }); + + describe("rawFrontmatter", () => { + it("returns all raw fields", () => { + const content = `--- +title: Raw Test +custom: value +--- +`; + const { rawFrontmatter } = parseFrontmatter(content); + expect(rawFrontmatter.title).toBe("Raw Test"); + expect(rawFrontmatter.custom).toBe("value"); + }); + + it("preserves atUri in both frontmatter and rawFrontmatter", () => { + const content = `--- +title: Post +atUri: at://did:plc:abc123/app.bsky.feed.post/xyz +--- +`; + const { frontmatter, rawFrontmatter } = parseFrontmatter(content); + expect(frontmatter.atUri).toBe( + "at://did:plc:abc123/app.bsky.feed.post/xyz", + ); + expect(rawFrontmatter.atUri).toBe( + "at://did:plc:abc123/app.bsky.feed.post/xyz", + ); + }); + }); + + describe("FrontmatterMapping", () => { + it("maps a custom title field", () => { + const content = `--- +name: My Mapped Title +--- +`; + const { frontmatter } = parseFrontmatter(content, { title: "name" }); + expect(frontmatter.title).toBe("My Mapped Title"); + }); + + it("maps a custom description field", () => { + const content = `--- +summary: Custom description +--- +`; + const { frontmatter } = parseFrontmatter(content, { + description: "summary", + }); + expect(frontmatter.description).toBe("Custom description"); + }); + + it("maps a custom publishDate field", () => { + const content = `--- +publishedOn: 2024-06-15 +--- +`; + const { frontmatter } = parseFrontmatter(content, { + publishDate: "publishedOn", + }); + expect(frontmatter.publishDate).toBe("2024-06-15"); + }); + + it("maps a custom coverImage field", () => { + const content = `--- +heroImage: /images/cover.jpg +--- +`; + const { frontmatter } = parseFrontmatter(content, { + coverImage: "heroImage", + }); + expect(frontmatter.ogImage).toBe("/images/cover.jpg"); + }); + + it("maps a custom tags field", () => { + const content = `--- +categories: [news, updates] +--- +`; + const { frontmatter } = parseFrontmatter(content, { tags: "categories" }); + expect(frontmatter.tags).toEqual(["news", "updates"]); + }); + + it("maps a custom draft field", () => { + const content = `--- +unpublished: true +--- +`; + const { frontmatter } = parseFrontmatter(content, { draft: "unpublished" }); + expect(frontmatter.draft).toBe(true); + }); + + it("falls back to standard field name when mapped field is absent", () => { + const content = `--- +title: Standard Title +--- +`; + const { frontmatter } = parseFrontmatter(content, { title: "heading" }); + expect(frontmatter.title).toBe("Standard Title"); + }); + }); + + describe("body", () => { + it("returns the body content after the closing delimiter", () => { + const content = `--- +title: Post +--- +# Heading + +Some paragraph text.`; + const { body } = parseFrontmatter(content); + expect(body).toBe("# Heading\n\nSome paragraph text."); + }); + + it("returns an empty body when there is no content after frontmatter", () => { + const content = `--- +title: Post +--- +`; + const { body } = parseFrontmatter(content); + expect(body).toBe(""); + }); + }); +});