Просмотр исходного кода

feat(v7): M1 阶段 A——容错读写库(parser/serializer)

- js-yaml 依赖(MIT、零传递依赖)
- Front matter 解析(parseFrontMatter)+ 容错(不崩溃、保留未知字段)
- YAML 安全解析(parseYAML 包装 js-yaml)
- 防呆序列化(serializeYAML:平铺/块列表/危险值引号)
- Front matter 序列化(serializeFrontMatter 保留未知字段)
- Markdown 表格解析(parseMarkdownTable)
- book.yaml 解析(parseBookConfig + 默认值合并)
- 测试:35 个用例全绿(容错、边界、保留未知字段验证)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lingfengQAQ 2 дней назад
Родитель
Сommit
29cd5bc035

+ 48 - 0
v7/package-lock.json

@@ -0,0 +1,48 @@
+{
+  "name": "webnovel-writer",
+  "version": "0.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "webnovel-writer",
+      "version": "0.0.0",
+      "dependencies": {
+        "js-yaml": "^5.2.0"
+      },
+      "bin": {
+        "webnovel-writer": "bin/webnovel-writer.js"
+      },
+      "engines": {
+        "node": ">=22.13.0"
+      }
+    },
+    "node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://mirrors.cloud.tencent.com/npm/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "license": "Python-2.0"
+    },
+    "node_modules/js-yaml": {
+      "version": "5.2.0",
+      "resolved": "https://mirrors.cloud.tencent.com/npm/js-yaml/-/js-yaml-5.2.0.tgz",
+      "integrity": "sha512-YeLUMlvR4Ou1B119LIaM0r65JvbOBooJDc9yEu0dClb/uSC5P4FrLU8OCCz/HXWvtPoIrR0dRzABTjo1sTN9Bw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/puzrin"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/nodeca"
+        }
+      ],
+      "dependencies": {
+        "argparse": "^2.0.1"
+      },
+      "bin": {
+        "js-yaml": "bin/js-yaml.mjs"
+      }
+    }
+  }
+}

+ 3 - 1
v7/package.json

@@ -12,5 +12,7 @@
   "scripts": {
     "test": "node --test"
   },
-  "dependencies": {}
+  "dependencies": {
+    "js-yaml": "^5.2.0"
+  }
 }

+ 49 - 0
v7/src/storage/parsers/book-config.js

@@ -0,0 +1,49 @@
+import { parseYAML } from './yaml-safe.js'
+
+/**
+ * 解析 book.yaml 配置文件(平铺字段)。
+ * @param {string} yamlString - book.yaml 文件内容
+ * @returns {{ok: boolean, data: object|null, error: string}}
+ */
+export function parseBookConfig(yamlString) {
+  const result = parseYAML(yamlString)
+
+  if (!result.ok) {
+    return {
+      ok: false,
+      data: null,
+      error: `book.yaml 解析失败:${result.error}`,
+    }
+  }
+
+  // 验证必需字段(spec §3)
+  const requiredFields = ['spec_version', '书名', '类型', '每章目标字数', '卷规模']
+  const missingFields = requiredFields.filter((field) => !(field in result.data))
+
+  // 默认值(无论是否缺少必需字段,都合并可选字段的默认值)
+  const defaults = {
+    spec_version: '7.0',
+    书名: '未命名',
+    类型: '玄幻',
+    每章目标字数: 3000,
+    卷规模: 40,
+    文体基线起: 1,
+    文体基线止: 30,
+    伏笔悬了太久章数: 10,
+    悬念悬了太久章数: 10,
+    感情线悬了太久章数: 30,
+    连续弱钩上限: 3,
+    关键章稿数: 3,
+    自动确认细纲: false,
+    连写批次大小: 8,
+  }
+
+  // 合并默认值(只覆盖 undefined 的字段)
+  const mergedData = { ...defaults, ...result.data }
+
+  return {
+    ok: true,
+    data: mergedData,
+    error: '',
+  }
+}

+ 96 - 0
v7/src/storage/parsers/front-matter.js

@@ -0,0 +1,96 @@
+import { parseYAML } from './yaml-safe.js'
+
+/**
+ * 解析 Markdown 文件的 front matter(--- 包裹的 YAML)与正文。
+ * @param {string} content - 完整文件内容
+ * @returns {{ok: boolean, data: object|null, body: string, error: string, rawYAML: string}}
+ */
+export function parseFrontMatter(content) {
+  if (typeof content !== 'string') {
+    return {
+      ok: false,
+      data: null,
+      body: '',
+      error: '内容必须是字符串',
+      rawYAML: '',
+    }
+  }
+
+  // 查找 front matter 分隔符(开头的 ---)
+  const lines = content.split('\n')
+
+  // 必须以 --- 开头(允许前面有空行)
+  let startIndex = -1
+  for (let i = 0; i < lines.length; i++) {
+    const trimmed = lines[i].trim()
+    if (trimmed === '---') {
+      startIndex = i
+      break
+    }
+    if (trimmed !== '') {
+      // 遇到非空非 --- 行,说明没有 front matter
+      return {
+        ok: false,
+        data: null,
+        body: content,
+        error: '缺少 front matter 分隔符(文件必须以 --- 开头)',
+        rawYAML: '',
+      }
+    }
+  }
+
+  if (startIndex === -1) {
+    return {
+      ok: false,
+      data: null,
+      body: content,
+      error: '缺少 front matter 分隔符',
+      rawYAML: '',
+    }
+  }
+
+  // 查找结束的 ---
+  let endIndex = -1
+  for (let i = startIndex + 1; i < lines.length; i++) {
+    if (lines[i].trim() === '---') {
+      endIndex = i
+      break
+    }
+  }
+
+  if (endIndex === -1) {
+    return {
+      ok: false,
+      data: null,
+      body: content,
+      error: 'front matter 分隔符不配对(缺少结束的 ---)',
+      rawYAML: '',
+    }
+  }
+
+  // 提取 YAML 部分与正文部分
+  const yamlLines = lines.slice(startIndex + 1, endIndex)
+  const rawYAML = yamlLines.join('\n')
+  const bodyLines = lines.slice(endIndex + 1)
+  const body = bodyLines.join('\n').trim()
+
+  // 解析 YAML
+  const yamlResult = parseYAML(rawYAML)
+  if (!yamlResult.ok) {
+    return {
+      ok: false,
+      data: null,
+      body: body,
+      error: `YAML 解析失败:${yamlResult.error}`,
+      rawYAML: rawYAML,
+    }
+  }
+
+  return {
+    ok: true,
+    data: yamlResult.data,
+    body: body,
+    error: '',
+    rawYAML: rawYAML,
+  }
+}

+ 94 - 0
v7/src/storage/parsers/markdown-table.js

@@ -0,0 +1,94 @@
+/**
+ * 解析 Markdown 表格。
+ * @param {string} content - Markdown 表格文本(含表头 | A | B | C |)
+ * @returns {{ok: boolean, headers: string[], rows: object[], error: string}}
+ */
+export function parseMarkdownTable(content) {
+  if (typeof content !== 'string') {
+    return {
+      ok: false,
+      headers: [],
+      rows: [],
+      error: '内容必须是字符串',
+    }
+  }
+
+  const lines = content.split('\n').map((line) => line.trim()).filter((line) => line !== '')
+
+  if (lines.length < 2) {
+    return {
+      ok: false,
+      headers: [],
+      rows: [],
+      error: 'Markdown 表格至少需要两行(表头 + 分隔符)',
+    }
+  }
+
+  // 解析表头(第一行)
+  const headerLine = lines[0]
+  if (!headerLine.startsWith('|') || !headerLine.endsWith('|')) {
+    return {
+      ok: false,
+      headers: [],
+      rows: [],
+      error: '表头行必须以 | 开头和结尾',
+    }
+  }
+
+  const headers = headerLine
+    .slice(1, -1)
+    .split('|')
+    .map((h) => h.trim())
+
+  // 跳过分隔符行(第二行,形如 |---|---|)
+  const separatorLine = lines[1]
+  if (!separatorLine.includes('---')) {
+    return {
+      ok: false,
+      headers: [],
+      rows: [],
+      error: '表格第二行必须是分隔符行(|---|---|)',
+    }
+  }
+
+  // 解析数据行(从第三行开始)
+  const rows = []
+  for (let i = 2; i < lines.length; i++) {
+    const line = lines[i]
+
+    // 跳过空行
+    if (line === '') continue
+
+    // 跳过不是表格行的内容
+    if (!line.startsWith('|') || !line.endsWith('|')) {
+      continue
+    }
+
+    const cells = line
+      .slice(1, -1)
+      .split('|')
+      .map((c) => c.trim())
+
+    // 容错:单元格数量不匹配表头时跳过(或补空)
+    if (cells.length !== headers.length) {
+      // 补齐或截断
+      while (cells.length < headers.length) {
+        cells.push('')
+      }
+      cells.splice(headers.length)
+    }
+
+    const row = {}
+    for (let j = 0; j < headers.length; j++) {
+      row[headers[j]] = cells[j]
+    }
+    rows.push(row)
+  }
+
+  return {
+    ok: true,
+    headers: headers,
+    rows: rows,
+    error: '',
+  }
+}

+ 67 - 0
v7/src/storage/parsers/yaml-safe.js

@@ -0,0 +1,67 @@
+import * as yaml from 'js-yaml'
+
+/**
+ * 安全解析 YAML 字符串(容错,不抛异常)。
+ * @param {string} yamlString - YAML 字符串
+ * @param {object} options - 选项(preserveUnknown 默认 true)
+ * @returns {{ok: boolean, data: object|null, error: string, raw: string}}
+ */
+export function parseYAML(yamlString, options = {}) {
+  const preserveUnknown = options.preserveUnknown !== false
+
+  if (typeof yamlString !== 'string') {
+    return {
+      ok: false,
+      data: null,
+      error: 'YAML 内容必须是字符串',
+      raw: '',
+    }
+  }
+
+  // 空字符串视为空对象(合法)
+  if (yamlString.trim() === '') {
+    return {
+      ok: true,
+      data: {},
+      error: '',
+      raw: yamlString,
+    }
+  }
+
+  try {
+    const data = yaml.load(yamlString, { schema: yaml.DEFAULT_SCHEMA })
+
+    // js-yaml.load 可能返回 null(空文档)、非对象类型
+    if (data === null || data === undefined) {
+      return {
+        ok: true,
+        data: {},
+        error: '',
+        raw: yamlString,
+      }
+    }
+
+    if (typeof data !== 'object' || Array.isArray(data)) {
+      return {
+        ok: false,
+        data: null,
+        error: 'YAML 根节点必须是对象(不能是数组或标量)',
+        raw: yamlString,
+      }
+    }
+
+    return {
+      ok: true,
+      data: data,
+      error: '',
+      raw: yamlString,
+    }
+  } catch (err) {
+    return {
+      ok: false,
+      data: null,
+      error: err.message || 'YAML 解析失败',
+      raw: yamlString,
+    }
+  }
+}

+ 69 - 0
v7/src/storage/serializers/front-matter.js

@@ -0,0 +1,69 @@
+import { serializeYAML } from './yaml-dialect.js'
+
+/**
+ * 组装 front matter + 正文为完整 Markdown 文件。
+ * 保留未知字段:从 originalYAML 提取非 data 中的字段,拼接到输出。
+ * @param {object} data - 已知字段对象
+ * @param {string} body - Markdown 正文
+ * @param {string} originalYAML - 原始 YAML 字符串(可选,用于保留未知字段)
+ * @returns {string} 完整 Markdown 文件内容
+ */
+export function serializeFrontMatter(data, body, originalYAML = '') {
+  let yamlContent = serializeYAML(data)
+
+  // 如果有原始 YAML,尝试保留未知字段
+  if (originalYAML) {
+    const unknownFields = extractUnknownFields(originalYAML, data)
+    if (unknownFields.length > 0) {
+      yamlContent += '\n' + unknownFields.join('\n')
+    }
+  }
+
+  return `---\n${yamlContent}\n---\n${body}`
+}
+
+/**
+ * 从原始 YAML 中提取未知字段(不在 data 中的字段)。
+ * @param {string} originalYAML
+ * @param {object} data - 已知字段
+ * @returns {string[]} 未知字段行数组
+ */
+function extractUnknownFields(originalYAML, data) {
+  const knownKeys = new Set(Object.keys(data))
+  const unknownLines = []
+
+  const lines = originalYAML.split('\n')
+  let i = 0
+  while (i < lines.length) {
+    const line = lines[i]
+    const trimmed = line.trim()
+
+    // 跳过空行和注释
+    if (trimmed === '' || trimmed.startsWith('#')) {
+      i++
+      continue
+    }
+
+    // 解析键(支持 "key:" 和 "key: value" 两种形式)
+    const match = trimmed.match(/^([^:]+):/)
+    if (match) {
+      const key = match[1].trim()
+      if (!knownKeys.has(key)) {
+        // 未知字段,保留整行
+        unknownLines.push(line)
+
+        // 如果是列表字段,保留后续的列表项
+        i++
+        while (i < lines.length && lines[i].trim().startsWith('-')) {
+          unknownLines.push(lines[i])
+          i++
+        }
+        continue
+      }
+    }
+
+    i++
+  }
+
+  return unknownLines
+}

+ 121 - 0
v7/src/storage/serializers/yaml-dialect.js

@@ -0,0 +1,121 @@
+/**
+ * 将 JS 对象序列化为符合防呆方言的 YAML 字符串。
+ * 规则:
+ * 1. 一律平铺(检测嵌套抛错)
+ * 2. 数组输出块格式(\n- item)
+ * 3. 危险值加引号(数字串/true/false/null/含冒号)
+ * 4. 两空格缩进
+ * @param {object} data - JS 对象(必须平铺)
+ * @returns {string} YAML 字符串
+ */
+export function serializeYAML(data) {
+  if (typeof data !== 'object' || data === null || Array.isArray(data)) {
+    throw new Error('serializeYAML 只接受非空对象(不能是数组或 null)')
+  }
+
+  const lines = []
+
+  for (const [key, value] of Object.entries(data)) {
+    // 检测嵌套映射(违反防呆方言)
+    if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
+      throw new Error(`防呆方言禁止嵌套映射:字段「${key}」的值是对象。所有字段必须平铺到顶层。`)
+    }
+
+    // 数组:块格式(一行一条)
+    if (Array.isArray(value)) {
+      lines.push(`${key}:`)
+      for (const item of value) {
+        const serializedItem = serializeValue(item)
+        lines.push(`  - ${serializedItem}`)
+      }
+      continue
+    }
+
+    // 标量:加引号判断
+    const serializedValue = serializeValue(value)
+    lines.push(`${key}: ${serializedValue}`)
+  }
+
+  return lines.join('\n')
+}
+
+/**
+ * 序列化单个值(判断是否需要引号)。
+ * @param {any} value
+ * @returns {string}
+ */
+function serializeValue(value) {
+  if (value === null || value === undefined) {
+    return 'null'
+  }
+
+  if (typeof value === 'boolean') {
+    return value ? 'true' : 'false'
+  }
+
+  if (typeof value === 'number') {
+    return String(value)
+  }
+
+  if (typeof value !== 'string') {
+    throw new Error(`不支持的值类型:${typeof value}`)
+  }
+
+  // 字符串:判断是否需要引号
+  if (needsQuoting(value)) {
+    // 简单引号转义(双引号内的双引号转义为 \")
+    const escaped = value.replace(/"/g, '\\"')
+    return `"${escaped}"`
+  }
+
+  return value
+}
+
+/**
+ * 判断字符串是否需要引号(防止被 YAML 误判类型)。
+ * @param {string} value
+ * @returns {boolean}
+ */
+function needsQuoting(value) {
+  // 纯数字字符串:123 → "123"
+  if (/^\d+$/.test(value)) {
+    return true
+  }
+
+  // 浮点数:1.23 → "1.23"
+  if (/^\d+\.\d+$/.test(value)) {
+    return true
+  }
+
+  // 布尔字面值:true/false/True/False/TRUE/FALSE → "true"
+  if (/^(true|false|True|False|TRUE|FALSE)$/i.test(value)) {
+    return true
+  }
+
+  // null 字面值:null/Null/NULL → "null"
+  if (/^(null|Null|NULL)$/i.test(value)) {
+    return true
+  }
+
+  // 含冒号(YAML 键值分隔符):A:B → "A:B"
+  if (value.includes(':')) {
+    return true
+  }
+
+  // 以 # 开头(注释):#comment → "#comment"
+  if (value.startsWith('#')) {
+    return true
+  }
+
+  // 以 - 开头(列表项):-item → "-item"
+  if (value.startsWith('-')) {
+    return true
+  }
+
+  // 包含换行符
+  if (value.includes('\n')) {
+    return true
+  }
+
+  return false
+}

+ 58 - 0
v7/test/storage/parsers/book-config.test.js

@@ -0,0 +1,58 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { parseBookConfig } from '../../../src/storage/parsers/book-config.js'
+
+test('正常解析 book.yaml', () => {
+  const yaml = `spec_version: "7.0"
+书名: 测试书
+类型: 玄幻
+每章目标字数: 3000
+卷规模: 40
+文体基线起: 1
+文体基线止: 30
+伏笔悬了太久章数: 10
+悬念悬了太久章数: 10
+感情线悬了太久章数: 30
+连续弱钩上限: 3
+关键章稿数: 3
+自动确认细纲: false
+连写批次大小: 8`
+
+  const result = parseBookConfig(yaml)
+  assert.equal(result.ok, true)
+  assert.equal(result.data.书名, '测试书')
+  assert.equal(result.data.每章目标字数, 3000)
+  assert.equal(result.data.伏笔悬了太久章数, 10)
+})
+
+test('缺字段时使用默认值', () => {
+  const yaml = `spec_version: "7.0"
+书名: 最小配置
+类型: 玄幻
+每章目标字数: 3000
+卷规模: 40`
+
+  const result = parseBookConfig(yaml)
+  assert.equal(result.ok, true)
+  assert.equal(result.data.书名, '最小配置')
+  assert.equal(result.data.文体基线起, 1) // 默认值
+  assert.equal(result.data.伏笔悬了太久章数, 10) // 默认值
+  assert.equal(result.data.连写批次大小, 8) // 默认值
+})
+
+test('边界:YAML 语法错误', () => {
+  const yaml = `书名: "未闭合`
+
+  const result = parseBookConfig(yaml)
+  assert.equal(result.ok, false)
+  assert.ok(result.error.includes('解析失败'))
+})
+
+test('边界:空 YAML', () => {
+  const yaml = ``
+
+  const result = parseBookConfig(yaml)
+  assert.equal(result.ok, true)
+  assert.equal(result.data.书名, '未命名') // 全部默认值
+  assert.equal(result.data.每章目标字数, 3000)
+})

+ 69 - 0
v7/test/storage/parsers/front-matter.test.js

@@ -0,0 +1,69 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { parseFrontMatter } from '../../../src/storage/parsers/front-matter.js'
+
+test('正常路径:含 front matter 的章节文件', () => {
+  const content = `---
+章号: 1
+标题: 测试章节
+---
+这是正文内容。`
+
+  const result = parseFrontMatter(content)
+  assert.equal(result.ok, true)
+  assert.equal(result.data.章号, 1)
+  assert.equal(result.data.标题, '测试章节')
+  assert.equal(result.body, '这是正文内容。')
+  assert.equal(result.error, '')
+})
+
+test('边界:无 front matter', () => {
+  const content = '直接是正文,没有 front matter'
+  const result = parseFrontMatter(content)
+  assert.equal(result.ok, false)
+  assert.ok(result.error.includes('缺少 front matter 分隔符'))
+  assert.equal(result.body, content)
+})
+
+test('边界:单个 --- 不配对', () => {
+  const content = `---
+章号: 1
+标题: 测试`
+
+  const result = parseFrontMatter(content)
+  assert.equal(result.ok, false)
+  assert.ok(result.error.includes('不配对'))
+})
+
+test('边界:YAML 语法错误', () => {
+  const content = `---
+章号: 1
+标题: 测试
+错误缩进
+  子项
+---
+正文`
+
+  const result = parseFrontMatter(content)
+  assert.equal(result.ok, false)
+  assert.ok(result.error.includes('YAML 解析失败'))
+})
+
+test('容错:前面有空行', () => {
+  const content = `
+
+---
+章号: 1
+---
+正文`
+
+  const result = parseFrontMatter(content)
+  assert.equal(result.ok, true)
+  assert.equal(result.data.章号, 1)
+})
+
+test('容错:非字符串输入', () => {
+  const result = parseFrontMatter(null)
+  assert.equal(result.ok, false)
+  assert.ok(result.error.includes('必须是字符串'))
+})

+ 72 - 0
v7/test/storage/parsers/markdown-table.test.js

@@ -0,0 +1,72 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { parseMarkdownTable } from '../../../src/storage/parsers/markdown-table.js'
+
+test('正常解析时间线表格', () => {
+  const content = `| 章 | 书内时间 | 一句话事件 | 在场 |
+|----|----------|------------|------|
+| 1 | 1023春月初一 | 林晚入宗门 | 林晚 |
+| 2 | 1023春月初二 | 初遇神秘老者 | 林晚, 神秘老者 |`
+
+  const result = parseMarkdownTable(content)
+  assert.equal(result.ok, true)
+  assert.deepEqual(result.headers, ['章', '书内时间', '一句话事件', '在场'])
+  assert.equal(result.rows.length, 2)
+  assert.equal(result.rows[0].章, '1')
+  assert.equal(result.rows[0].书内时间, '1023春月初一')
+  assert.equal(result.rows[1].在场, '林晚, 神秘老者')
+})
+
+test('正常解析名册表格', () => {
+  const content = `| 正名 | 别名 | 类型 | 首现章 |
+|------|------|------|---------|
+| 林晚 | 晚晚, 林师妹 | character | 1 |
+| 神秘老者 | 黑衣人 | character | 1 |`
+
+  const result = parseMarkdownTable(content)
+  assert.equal(result.ok, true)
+  assert.equal(result.rows.length, 2)
+  assert.equal(result.rows[0].正名, '林晚')
+  assert.equal(result.rows[0].别名, '晚晚, 林师妹')
+})
+
+test('容错:跳过空行', () => {
+  const content = `| 章 | 事件 |
+|----|------|
+
+| 1 | 事件1 |
+
+| 2 | 事件2 |`
+
+  const result = parseMarkdownTable(content)
+  assert.equal(result.ok, true)
+  assert.equal(result.rows.length, 2)
+})
+
+test('容错:单元格数量不匹配(补空)', () => {
+  const content = `| A | B | C |
+|---|---|---|
+| 1 | 2 |`
+
+  const result = parseMarkdownTable(content)
+  assert.equal(result.ok, true)
+  assert.equal(result.rows.length, 1)
+  assert.equal(result.rows[0].A, '1')
+  assert.equal(result.rows[0].B, '2')
+  assert.equal(result.rows[0].C, '')
+})
+
+test('边界:少于两行', () => {
+  const content = `| A | B |`
+  const result = parseMarkdownTable(content)
+  assert.equal(result.ok, false)
+  assert.ok(result.error.includes('至少需要两行'))
+})
+
+test('边界:表头格式错误', () => {
+  const content = `A | B
+|---|---|`
+  const result = parseMarkdownTable(content)
+  assert.equal(result.ok, false)
+  assert.ok(result.error.includes('必须以 | 开头'))
+})

+ 97 - 0
v7/test/storage/parsers/yaml-safe.test.js

@@ -0,0 +1,97 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { parseYAML } from '../../../src/storage/parsers/yaml-safe.js'
+
+test('正常 YAML 解析', () => {
+  const yaml = `章号: 1
+标题: 测试章节
+列表:
+  - 项1
+  - 项2`
+
+  const result = parseYAML(yaml)
+  assert.equal(result.ok, true)
+  assert.equal(result.data.章号, 1)
+  assert.equal(result.data.标题, '测试章节')
+  assert.deepEqual(result.data.列表, ['项1', '项2'])
+})
+
+test('边界:YAML 语法错误', () => {
+  const yaml = `章号: 1
+标题: "未闭合引号`
+
+  const result = parseYAML(yaml)
+  assert.equal(result.ok, false)
+  assert.ok(result.error.length > 0)
+})
+
+test('边界:空字符串', () => {
+  const result = parseYAML('')
+  assert.equal(result.ok, true)
+  assert.deepEqual(result.data, {})
+})
+
+test('边界:根节点是数组', () => {
+  const yaml = `- 项1
+- 项2`
+
+  const result = parseYAML(yaml)
+  assert.equal(result.ok, false)
+  assert.ok(result.error.includes('必须是对象'))
+})
+
+test('边界:根节点是标量', () => {
+  const yaml = `just a string`
+
+  const result = parseYAML(yaml)
+  assert.equal(result.ok, false)
+  assert.ok(result.error.includes('必须是对象'))
+})
+
+test('容错:null 文档', () => {
+  const yaml = `---
+null`
+
+  const result = parseYAML(yaml)
+  assert.equal(result.ok, true)
+  assert.deepEqual(result.data, {})
+})
+
+test('保留原始 YAML 字符串', () => {
+  const yaml = `章号: 1
+自定义字段: 自定义值`
+
+  const result = parseYAML(yaml)
+  assert.equal(result.ok, true)
+  assert.equal(result.raw, yaml)
+  assert.equal(result.data.自定义字段, '自定义值')
+})
+
+test('容错读取保留未知字段:解析→修改→序列化', async () => {
+  // 导入需要的模块
+  const { parseFrontMatter } = await import('../../../src/storage/parsers/front-matter.js')
+  const { serializeFrontMatter } = await import('../../../src/storage/serializers/front-matter.js')
+
+  const original = `---
+章号: 1
+标题: 测试
+自定义字段: 自定义值
+---
+正文内容`
+
+  // 解析
+  const parsed = parseFrontMatter(original)
+  assert.equal(parsed.ok, true)
+  assert.equal(parsed.data.自定义字段, '自定义值')
+
+  // 修改已知字段
+  parsed.data.标题 = '新标题'
+
+  // 写回(保留未知字段)
+  const serialized = serializeFrontMatter(parsed.data, parsed.body, parsed.rawYAML)
+
+  assert.ok(serialized.includes('自定义字段: 自定义值'), '未知字段应被保留')
+  assert.ok(serialized.includes('标题: 新标题'), '已知字段应被更新')
+  assert.ok(serialized.includes('正文内容'), '正文应被保留')
+})
+

+ 84 - 0
v7/test/storage/serializers/yaml-dialect.test.js

@@ -0,0 +1,84 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { serializeYAML } from '../../../src/storage/serializers/yaml-dialect.js'
+
+test('列表输出块格式', () => {
+  const data = { 伏笔: ['伏笔-001', '伏笔-002'] }
+  const yaml = serializeYAML(data)
+  assert.ok(yaml.includes('伏笔:\n  - 伏笔-001\n  - 伏笔-002'))
+  assert.ok(!yaml.includes('[伏笔-001, 伏笔-002]'))
+})
+
+test('危险值加引号:数字串', () => {
+  const data = { 章号: '123' }
+  const yaml = serializeYAML(data)
+  assert.ok(yaml.includes('章号: "123"'))
+})
+
+test('危险值加引号:布尔字面值', () => {
+  const data = { 开关: 'true', 标志: 'false' }
+  const yaml = serializeYAML(data)
+  assert.ok(yaml.includes('开关: "true"'))
+  assert.ok(yaml.includes('标志: "false"'))
+})
+
+test('危险值加引号:null 字面值', () => {
+  const data = { 值: 'null' }
+  const yaml = serializeYAML(data)
+  assert.ok(yaml.includes('值: "null"'))
+})
+
+test('危险值加引号:含冒号', () => {
+  const data = { 标题: '包含:冒号' }
+  const yaml = serializeYAML(data)
+  assert.ok(yaml.includes('标题: "包含:冒号"'))
+})
+
+test('危险值加引号:以 # 或 - 开头', () => {
+  const data = { 注释: '#comment', 项: '-item' }
+  const yaml = serializeYAML(data)
+  assert.ok(yaml.includes('注释: "#comment"'))
+  assert.ok(yaml.includes('项: "-item"'))
+})
+
+test('正常字符串不加引号', () => {
+  const data = { 标题: '测试章节', 视角: '林晚' }
+  const yaml = serializeYAML(data)
+  assert.ok(yaml.includes('标题: 测试章节'))
+  assert.ok(yaml.includes('视角: 林晚'))
+})
+
+test('数字和布尔值正常输出', () => {
+  const data = { 章号: 1, 字数: 3000, 开关: true }
+  const yaml = serializeYAML(data)
+  assert.ok(yaml.includes('章号: 1'))
+  assert.ok(yaml.includes('字数: 3000'))
+  assert.ok(yaml.includes('开关: true'))
+})
+
+test('嵌套映射抛错', () => {
+  const data = { 外层: { 内层: '值' } }
+  assert.throws(() => {
+    serializeYAML(data)
+  }, /禁止嵌套映射/)
+})
+
+test('空数组', () => {
+  const data = { 列表: [] }
+  const yaml = serializeYAML(data)
+  assert.equal(yaml, '列表:')
+})
+
+test('混合字段', () => {
+  const data = {
+    章号: 1,
+    标题: '测试',
+    伏笔: ['伏笔-001', '伏笔-002'],
+    危险数字: '123',
+  }
+  const yaml = serializeYAML(data)
+  assert.ok(yaml.includes('章号: 1'))
+  assert.ok(yaml.includes('标题: 测试'))
+  assert.ok(yaml.includes('伏笔:\n  - 伏笔-001'))
+  assert.ok(yaml.includes('危险数字: "123"'))
+})