Support multidocument files

This commit is contained in:
Endeavorance 2025-06-06 09:40:19 -04:00
parent c75752b1b7
commit 85b2d5cad7
2 changed files with 191 additions and 22 deletions

View file

@ -1,5 +1,82 @@
import YAML from "yaml"; import YAML, { YAMLError } from "yaml";
const FRONTMATTER_REGEX = /^---[\s\S]*?---/gm;
export type EMDYData = Record<string, unknown>;
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 * 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 * @param markdownKey The key to use for the markdown content
* @returns The parsed markdown content and frontmatter * @returns The parsed markdown content and frontmatter
*/ */
function parse( export function parse(content: string, markdownKey = "content"): EMDYData {
content: string, return parseDocument({ content, markdownKey });
markdownKey = "content",
): Record<string, unknown> {
const markdown = content.trim().replace(FRONTMATTER_REGEX, "").trim();
let frontmatter: Record<string, unknown> = {};
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,
};
} }
/** /**
@ -34,7 +96,7 @@ function parse(
* @param markdownKey The key to use for the markdown content * @param markdownKey The key to use for the markdown content
* @returns The parsed markdown content and frontmatter * @returns The parsed markdown content and frontmatter
*/ */
function stringify( export function stringify(
data: Record<string, unknown>, data: Record<string, unknown>,
markdownKey = "content", markdownKey = "content",
): string { ): string {
@ -50,11 +112,54 @@ function stringify(
const stringifiedFrontmatter = YAML.stringify(frontmatter).trim(); 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 = { const EMDY = {
parse, parse,
parseAll,
stringify, stringify,
} as const; } as const;

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import EMDY from "../index"; import EMDY, { EMDYParseError } from "../index";
describe(EMDY.parse, () => { describe(EMDY.parse, () => {
test("parses markdown content without frontmatter", () => { test("parses markdown content without frontmatter", () => {
@ -31,6 +31,19 @@ And here is the content
content: "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, () => { 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!",
},
]);
});
});