emdy/src/index.ts

166 lines
4.3 KiB
TypeScript

import YAML, { YAMLError } from "yaml";
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
* 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<string, unknown>,
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;