diff --git a/src/index.ts b/src/index.ts index d2b7df7..f174cca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,82 @@ -import YAML from "yaml"; -const FRONTMATTER_REGEX = /^---[\s\S]*?---/gm; +import YAML, { YAMLError } from "yaml"; + +export type EMDYData = Record; + +type Mode = "yaml" | "markdown"; +interface PendingDocument { + content: string; + markdownKey: string; +} + +export class EMDYError extends Error { + name = "EMDYError"; +} + +export class EMDYParseError extends EMDYError { + name = "EMDYParseError"; + original?: Error; + + constructor(message: string, original?: Error) { + super(`EMDY Parse Error: ${message}`); + this.original = original; + } +} + +/** + * Parse a single PendingDocument into EMDYData + * @param document The PendingDocument to parse + * @returns The parsed EMDYData + */ +function parseDocument(document: PendingDocument): EMDYData { + const yamlLines: string[] = []; + const markdownLines: string[] = []; + const contentLines = document.content.trim().split("\n"); + + let mode: Mode = "markdown"; + + for (const line of contentLines) { + // Handle boundary lines + if (line.trim() === "---") { + // Upon encountering a boundary in YAML mode, switch to markdown mode + if (mode === "yaml") { + mode = "markdown"; + } + + // When in markdown mode (default), only switch to YAML mode + // if no markdown has been collected yet. This ensures that + // YAML frontmatter is only parsed if it exists at the start of the document. + else if (mode === "markdown" && markdownLines.length === 0) { + mode = "yaml"; + } + + // Continue the loop to skip the boundary line + continue; + } + + // Handle content lines + if (mode === "yaml") { + yamlLines.push(line); + } else { + markdownLines.push(line); + } + } + + const yamlContent = yamlLines.join("\n").trim(); + const markdownContent = markdownLines.join("\n").trim(); + + try { + return { + ...YAML.parse(yamlContent || "{}"), + [document.markdownKey]: markdownContent, + }; + } catch (error) { + if (error instanceof YAMLError) { + throw new EMDYParseError("Failed to parse YAML frontmatter", error); + } + + throw new EMDYError(`An unexpected error occured: ${error}`); + } +} /** * Parse a markdown document with optional YAML frontmatter into an object @@ -8,23 +85,8 @@ const FRONTMATTER_REGEX = /^---[\s\S]*?---/gm; * @param markdownKey The key to use for the markdown content * @returns The parsed markdown content and frontmatter */ -function parse( - content: string, - markdownKey = "content", -): Record { - const markdown = content.trim().replace(FRONTMATTER_REGEX, "").trim(); - let frontmatter: Record = {}; - - if (FRONTMATTER_REGEX.test(content)) { - const frontmatterString = content.match(FRONTMATTER_REGEX)?.[0] ?? ""; - const cleanFrontmatter = frontmatterString.replaceAll("---", "").trim(); - frontmatter = YAML.parse(cleanFrontmatter); - } - - return { - [markdownKey]: markdown, - ...frontmatter, - }; +export function parse(content: string, markdownKey = "content"): EMDYData { + return parseDocument({ content, markdownKey }); } /** @@ -34,7 +96,7 @@ function parse( * @param markdownKey The key to use for the markdown content * @returns The parsed markdown content and frontmatter */ -function stringify( +export function stringify( data: Record, markdownKey = "content", ): string { @@ -50,11 +112,54 @@ function stringify( const stringifiedFrontmatter = YAML.stringify(frontmatter).trim(); - return `---\n${stringifiedFrontmatter}\n---\n${markdown}`; + return ` +--- +${stringifiedFrontmatter} +--- +${markdown} +`.trim(); +} + +/** + * Parse a multi-document file into an array of EMDYData objects + * @param content The raw markdown content containing multiple documents + * @param markdownKey The key to use for the markdown content in each document + * @returns An array of parsed EMDYData objects + */ +export function parseAll(content: string, markdownKey = "content"): EMDYData[] { + const documents: PendingDocument[] = []; + const lines = content.split("\n"); + + let currentDoc: string[] = []; + + for (const line of lines) { + if (line.trim().startsWith("===") && currentDoc.length > 0) { + documents.push({ + content: currentDoc.join("\n").trim(), + markdownKey, + }); + currentDoc = []; + continue; + } + + currentDoc.push(line); + } + + // Add any remaining content as the last document + if (currentDoc.length > 0) { + documents.push({ + content: currentDoc.join("\n").trim(), + markdownKey, + }); + } + + // Split by document boundaries + return documents.map(parseDocument); } const EMDY = { parse, + parseAll, stringify, } as const; diff --git a/src/test/emdy.test.ts b/src/test/emdy.test.ts index 4291bf8..0abe941 100644 --- a/src/test/emdy.test.ts +++ b/src/test/emdy.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import EMDY from "../index"; +import EMDY, { EMDYParseError } from "../index"; describe(EMDY.parse, () => { test("parses markdown content without frontmatter", () => { @@ -31,6 +31,19 @@ And here is the content content: "And here is the content", }); }); + + test("throws an EMDYParseError for invalid frontmatter", () => { + const content = `--- +key: +anothe +--- + +stuff`; + + expect(() => { + EMDY.parse(content); + }).toThrow(EMDYParseError); + }); }); describe(EMDY.stringify, () => { @@ -65,3 +78,54 @@ describe(EMDY.stringify, () => { ); }); }); + +describe(EMDY.parseAll, () => { + test("parses multiple emdy documents", () => { + const content = `--- +hello: world +goodbye: world +--- + +some markdown +=== +another document +=== +--- +more frontmatter: yep +--- + +and how +`.trim(); + + const parsed = EMDY.parseAll(content); + expect(parsed).toEqual([ + { + hello: "world", + goodbye: "world", + content: "some markdown", + }, + { + content: "another document", + }, + { + "more frontmatter": "yep", + content: "and how", + }, + ]); + }); + + test("parses a single emdy document", () => { + const content = `--- +hello: world +--- +example! +`; + + expect(EMDY.parseAll(content)).toEqual([ + { + hello: "world", + content: "example!", + }, + ]); + }); +});