Markdown Frontmatter Parser

Split Markdown frontmatter from the body and parse flat YAML scalars, with BOM handling and clear error messages.

TypescriptConfig Management

Code

snippet
export interface FrontmatterResult<T extends Record<string, unknown>> {
  attributes: T;
  body: string;
  rawFrontmatter: string;
}

function parseScalar(raw: string): unknown {
  if (raw === "" || raw === "~" || raw === "null") return null;
  if (raw === "true" || raw === "false") return raw === "true";
  if (/^-?\d+(?:\.\d+)?$/.test(raw)) return Number(raw);
  if (raw.startsWith('"') && raw.endsWith('"')) {
    return raw.slice(1, -1).replace(/\\n/g, "\n").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
  }
  if (raw.startsWith("'") && raw.endsWith("'")) {
    return raw.slice(1, -1).replace(/''/g, "'");
  }
  return raw;
}

export function parseFrontmatter<T extends Record<string, unknown> = Record<string, unknown>>(
  markdown: string,
): FrontmatterResult<T> {
  if (typeof markdown !== "string") throw new TypeError("markdown must be a string");
  const normalized = markdown.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
  if (!normalized.startsWith("---\n")) {
    throw new SyntaxError("frontmatter must start with a --- line");
  }
  const closing = normalized.indexOf("\n---", 4);
  if (closing === -1) throw new SyntaxError("missing closing --- delimiter");
  const rawFrontmatter = normalized.slice(4, closing);
  const body = normalized.slice(closing + 4).replace(/^\n/, "");
  const attributes: Record<string, unknown> = {};
  for (const line of rawFrontmatter.split("\n")) {
    if (line.trim() === "" || line.trimStart().startsWith("#")) continue;
    const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
    if (!match) throw new SyntaxError("unsupported frontmatter line: " + line);
    attributes[match[1]!] = parseScalar(match[2]!.trim());
  }
  return { attributes: attributes as T, body, rawFrontmatter };
}