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 * with the markdown content and any frontmatter as keys. * @param content The raw markdown content * @param markdownKey The key to use for the markdown content * @returns The parsed markdown content and frontmatter */ export function parse(content: string, markdownKey = "content"): EMDYData { return parseDocument({ content, markdownKey }); } /** * Serialize an object with markdown content and optional frontmatter into a * Markdown document. Uses the `content` key for the markdown content by default. * @param data The object to serialize * @param markdownKey The key to use for the markdown content * @returns The parsed markdown content and frontmatter */ export function stringify( data: Record, markdownKey = "content", ): string { const markdown = String(data[markdownKey] ?? ""); const frontmatter = { ...data }; delete frontmatter[markdownKey]; const remainingKeys = Object.keys(frontmatter).length; if (remainingKeys === 0) { return markdown; } const stringifiedFrontmatter = YAML.stringify(frontmatter).trim(); 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; export default EMDY;