← 中文资源

简体中文版本

Markdown Frontmatter 解析器

从 Markdown 中分离 Frontmatter 与正文,解析扁平 YAML 标量,并处理 BOM 与换行差异。

阅读英文原文

功能说明

parseFrontmatter 识别文档开头由三条横线包围的元数据块,把原始 Frontmatter、解析后的扁平属性与正文分开返回。支持字符串、数字、布尔值、null、单双引号与常见转义,适合静态站点构建脚本、博客导入器、内容校验工具和 Markdown 预览。正文保持原样,便于后续交给 Markdown 渲染器。

关键代码

输入先去除 BOM 并统一换行,再要求以三横线加换行开头。结束分隔符通过查找行首的三横线定位,避免把正文中的分隔线误判。属性逐行匹配键名与冒号,跳过空行和注释,标量根据引号、布尔字面量、null 与数字正则分别解析。单引号内两个单引号还原为一个,双引号内还原换行、引号与反斜杠。

使用注意

这是扁平 YAML 子集,不支持嵌套对象、数组、块标量、锚点与多文档,复杂 Frontmatter 应使用完整 YAML 解析器。值中未加引号的冒号或井号可能被误解析,含特殊字符时应加引号。解析器不校验业务字段,类型由调用方传入的泛型声明,运行时仍需 Schema 校验。正文开头的空行只去掉一个,保留其余排版。

验证方式

测试标准 Frontmatter、无正文、空属性、带 BOM、CRLF 与 LF。验证字符串、单引号转义、双引号换行、数字、布尔与 null 的解析结果。测试缺少起始分隔线、缺少结束分隔线、非法属性行与嵌套结构会报错。最后用带正文分隔线的文档确认只识别最前面的元数据块,并检查 rawFrontmatter 与 body 的内容边界。

代码示例

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 };
}