// lib/frontmatter.mjs — 技能 frontmatter 解析的唯一实现 // // validate.mjs(结构校验)与 bin/convert.mjs(规则转换)共用本模块。 // 此前两边各写一套行式解析:convert 支持块标量、validate 不支持, // 导致 104/121 个技能的 description 在 validate 侧被解析成字面量 ">", //「description 非空」校验形同虚设(2026-09-13 审查记录,tests/frontmatter.test.mjs 回归)。 // // 支持的 YAML 子集(技能库实际用到的部分): // - 简单 scalar:key: value // - 块标量:key: >(折叠为空格)/ key: |(保留换行),吸收后续缩进行 // - 内联 flow list / 内联 JSON:key: [a, b] / key: {...}(按字符串返回,由调用方解析) // 不支持锚点、嵌套 map、多行 flow 等其余 YAML 特性——新增 frontmatter 写法前先扩展本模块并补测试, // 否则校验与转换会再次出现解析分歧。 // 顶格且非 `key:` 的行一律报错、不静默丢弃:块标量正文里误写顶层列表项(`- xxx`)会让 // description 被静默截断,且标准 YAML 解析器会判定整块 frontmatter 非法、丢弃整个技能 // (tests/frontmatter.test.mjs 与 tests/fixtures/bad-frontmatter 回归)。 // 换行归一:Windows 上 core.autocrlf=true 的检出会把提交时的 LF 变成 CRLF, // 而本模块的分界与行正则都按 LF 写。DSH 侧解析器显式容错 CR(`replace(/\r$/, '')`), // 本模块同样先归一,避免同一份技能在 LF / CRLF 检出一个通过、一个报 missing frontmatter。 // 归一只影响换行,不改动内容——CRLF 与 LF 检出的校验结果因此一致。 function toLf(text) { return text.replace(/\r\n?/g, '\n'); } // 拆文档:frontmatter 原文 + 正文(正文前多余空行一并去掉) export function splitFrontmatter(md) { const m = toLf(md).match(/^---\n([\s\S]*?)\n---\n*([\s\S]*)$/); if (!m) throw new Error('missing frontmatter'); return { raw: m[1], body: m[2] }; } // 行式解析 frontmatter 原文 → 键值对象(值均为字符串;块标量已折叠/展开) export function parseFrontmatterFields(raw) { const fm = {}; const lines = toLf(raw).split('\n'); for (let i = 0; i < lines.length; i++) { const kv = lines[i].match(/^([A-Za-z0-9_-]+):\s*(.*)$/); if (!kv) { // 顶格行既不是 `key:` 也不是块标量的缩进续行——解析器看不见它,内容会被静默丢弃。 // 实际踩过的坑:块标量正文里误写顶层列表项(`- xxx`),description 在此截断, // 而严格 YAML 解析器(外部消费方如 DSH)会因整块 frontmatter 报错而丢掉整个技能。 if (lines[i].trim() !== '' && !/^\s/.test(lines[i])) { throw new Error(`unparsable frontmatter line ${i + 1} (not a 'key:' line nor an indented continuation): ${lines[i]}`); } continue; } const key = kv[1]; const value = kv[2]; if (/^[>|]/.test(value)) { // YAML 块标量:吸收后续缩进行(`>` 折叠为空格,`|` 保留换行) const folded = []; while (lines[i + 1] !== undefined && /^\s+/.test(lines[i + 1])) { folded.push(lines[++i].replace(/^\s+/, '')); } fm[key] = folded.join(value[0] === '>' ? ' ' : '\n').trim(); } else { fm[key] = value; } } return fm; } // 便捷入口:一次拿到 { fm, body } export function parseFrontmatterDocument(md) { const { raw, body } = splitFrontmatter(md); return { fm: parseFrontmatterFields(raw), body }; }