功能说明
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 };
}