Explorar o código

fix(chapter): 修正章节 frontmatter 保存与标题编辑时的类型不一致

保存为正式章节或编辑标题时改为逐行更新 rawBlock,
避免 yaml.dump 重写导致 chapter_number/title/created 类型漂移,
并在标题变更时同步规范化 frontmatter。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi hai 2 meses
pai
achega
87ca564d92

+ 3 - 27
src/components/layout/preview-panel.tsx

@@ -14,7 +14,7 @@ import { FilePreview } from "@/components/editor/file-preview"
 import { formatChapterWriting } from "@/lib/chapter-formatting"
 import { parseFrontmatter } from "@/lib/frontmatter"
 import { buildChapterEditorHeader } from "@/lib/chapter-editor-header"
-import { isChapterPage, isFinalChapter, parseChapterMeta, updateChapterStatus } from "@/lib/novel/chapter-meta"
+import { isChapterPage, isFinalChapter, parseChapterMeta, syncChapterFrontmatterFromBody, updateChapterStatus, updateChapterTitle } from "@/lib/novel/chapter-meta"
 import { resolveReviewModel } from "@/lib/novel/review-model"
 import { CognitionPanel } from "@/components/novel/cognition-panel"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
@@ -126,37 +126,13 @@ function formatWritingBodyWithIndent(markdown: string): string {
 }
 
 function normalizeChapterWriting(markdown: string): string {
-  return formatWritingBodyWithIndent(syncChapterFrontmatterTitle(markdown))
+  return formatWritingBodyWithIndent(syncChapterFrontmatterFromBody(markdown))
 }
 
 function getDiskSyncNormalize(path: string): (content: string) => string {
   return isChapterPath(path) ? normalizeChapterWriting : (content) => content
 }
 
-function updateChapterHeading(markdown: string, nextTitle: string): string {
-  const { rawBlock, body } = parseFrontmatter(markdown)
-  const normalizedTitle = nextTitle.trim()
-  const bodyWithoutHeading = body.replace(/^#\s+.+$(\r?\n)?/m, "").replace(/^\n+/, "")
-  const nextBody = normalizedTitle
-    ? `# ${normalizedTitle}${bodyWithoutHeading ? `\n\n${bodyWithoutHeading}` : "\n"}`
-    : bodyWithoutHeading
-  return rawBlock + nextBody
-}
-
-function syncChapterFrontmatterTitle(markdown: string): string {
-  const { rawBlock, body, frontmatter } = parseFrontmatter(markdown)
-  if (!rawBlock || !frontmatter) return markdown
-  const heading = body.match(/^#\s+(.+)$/m)?.[1]?.trim()
-  if (!heading) return markdown
-  const fmTitle = typeof (frontmatter as Record<string, unknown>).title === "string"
-    ? String((frontmatter as Record<string, unknown>).title).trim()
-    : ""
-  if (!fmTitle || fmTitle === heading) return markdown
-  const escaped = heading.replace(/"/g, '\\"')
-  const nextRaw = rawBlock.replace(/^title:\s*.*$/m, `title: "${escaped}"`)
-  return nextRaw + body
-}
-
 function getChapterTitleFromPath(path: string): string {
   const fileName = normalizePath(path).split("/").pop() ?? ""
   return fileName.replace(/\.md$/i, "").trim()
@@ -742,7 +718,7 @@ export function PreviewPanel() {
     setChapterTitleEditing(false)
     if (nextTitle === chapterDisplayTitle) return
     try {
-      await syncChapterToCanonicalPath(selectedFile, updateChapterHeading(fileContent, nextTitle), { renameToCanonical: true })
+      await syncChapterToCanonicalPath(selectedFile, updateChapterTitle(fileContent, nextTitle), { renameToCanonical: true })
     } catch (error) {
       console.error("章节标题同步失败:", error)
     }

+ 151 - 0
src/lib/novel/chapter-meta.spec.ts

@@ -0,0 +1,151 @@
+import { describe, expect, it } from "vitest"
+import { syncChapterFrontmatterFromBody, updateChapterStatus, updateChapterTitle } from "./chapter-meta"
+
+describe("updateChapterStatus", () => {
+  it("updates only chapter_status while preserving other frontmatter formatting", () => {
+    const input = [
+      "---",
+      "type: chapter",
+      "chapter_number: 49",
+      "chapter_status: draft",
+      'title: "第49章-第三枚筹码"',
+      "created: 2026-07-04",
+      "---",
+      "",
+      "# 第49章-第三枚筹码",
+      "",
+      "正文",
+    ].join("\n")
+
+    const output = updateChapterStatus(input, "final")
+
+    expect(output).toBe([
+      "---",
+      "type: chapter",
+      "chapter_number: 49",
+      "chapter_status: final",
+      'title: "第49章-第三枚筹码"',
+      "created: 2026-07-04",
+      "---",
+      "",
+      "# 第49章-第三枚筹码",
+      "",
+      "正文",
+    ].join("\n"))
+  })
+
+  it("replaces quoted chapter_status values", () => {
+    const input = [
+      "---",
+      "type: chapter",
+      "chapter_status: 'draft'",
+      "---",
+      "",
+      "正文",
+    ].join("\n")
+
+    expect(updateChapterStatus(input, "final")).toContain("chapter_status: final")
+    expect(updateChapterStatus(input, "final")).not.toContain("'draft'")
+  })
+
+  it("returns content unchanged when frontmatter is missing", () => {
+    const input = "# 无 frontmatter\n\n正文"
+    expect(updateChapterStatus(input, "final")).toBe(input)
+  })
+
+  it("repairs yaml.dump corruption to canonical scalar types", () => {
+    const input = [
+      "---",
+      "type: chapter",
+      "chapter_number: '49'",
+      "chapter_status: draft",
+      "title: 第49章-第三枚筹码",
+      "created: '2026-07-04'",
+      "---",
+      "",
+      "正文",
+    ].join("\n")
+
+    const output = updateChapterStatus(input, "final")
+
+    expect(output).toBe([
+      "---",
+      "type: chapter",
+      "chapter_number: 49",
+      "chapter_status: final",
+      'title: "第49章-第三枚筹码"',
+      "created: 2026-07-04",
+      "---",
+      "",
+      "正文",
+    ].join("\n"))
+  })
+})
+
+describe("updateChapterTitle", () => {
+  it("updates body heading and frontmatter title with canonical formatting", () => {
+    const input = [
+      "---",
+      "type: chapter",
+      "chapter_number: '49'",
+      "chapter_status: draft",
+      "title: 旧标题",
+      "created: '2026-07-04'",
+      "---",
+      "",
+      "# 旧标题",
+      "",
+      "正文",
+    ].join("\n")
+
+    const output = updateChapterTitle(input, "第49章-第三枚筹码")
+
+    expect(output).toBe([
+      "---",
+      "type: chapter",
+      "chapter_number: 49",
+      "chapter_status: draft",
+      'title: "第49章-第三枚筹码"',
+      "created: 2026-07-04",
+      "---",
+      "",
+      "# 第49章-第三枚筹码",
+      "",
+      "正文",
+    ].join("\n"))
+  })
+})
+
+describe("syncChapterFrontmatterFromBody", () => {
+  it("normalizes frontmatter from body heading without changing body", () => {
+    const input = [
+      "---",
+      "type: chapter",
+      "chapter_number: '49'",
+      "chapter_status: draft",
+      "title: 第49章-第三枚筹码",
+      "created: '2026-07-04'",
+      "---",
+      "",
+      "# 第49章-第三枚筹码",
+      "",
+      "正文",
+    ].join("\n")
+
+    const output = syncChapterFrontmatterFromBody(input)
+
+    expect(output).toBe([
+      "---",
+      "type: chapter",
+      "chapter_number: 49",
+      "chapter_status: draft",
+      'title: "第49章-第三枚筹码"',
+      "created: 2026-07-04",
+      "---",
+      "",
+      "# 第49章-第三枚筹码",
+      "",
+      "正文",
+    ].join("\n"))
+  })
+})

+ 87 - 13
src/lib/novel/chapter-meta.ts

@@ -1,5 +1,4 @@
-import yaml from "js-yaml"
-import { parseFrontmatter } from "@/lib/frontmatter"
+import { parseFrontmatter, type FrontmatterValue } from "@/lib/frontmatter"
 
 export type ChapterStatus = "outline" | "draft" | "revised" | "final" | "archived"
 export type OutlineType = "chapter-outline" | "volume-outline" | "story-outline"
@@ -52,18 +51,93 @@ export function isFinalChapter(frontmatter: Record<string, unknown>): boolean {
   return normalizeChapterStatus(frontmatter.chapter_status) === "final"
 }
 
-export function updateChapterStatus(content: string, status: ChapterStatus): string {
-  const { frontmatter, body } = parseFrontmatter(content)
-  const nextFrontmatter: Record<string, unknown> = {
-    ...(frontmatter ?? {}),
-    chapter_status: status,
+const CHAPTER_STATUS_LINE_RE = /^chapter_status:\s*["']?[\w-]+["']?\s*$/m
+
+function yamlEscape(value: string): string {
+  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
+}
+
+function replaceFrontmatterLine(rawBlock: string, key: string, newLine: string): string {
+  const re = new RegExp(`^${key}:\\s*.*$`, "m")
+  return re.test(rawBlock) ? rawBlock.replace(re, newLine) : rawBlock
+}
+
+/** 将章节 frontmatter 关键字段写回 canonical YAML 标量类型。 */
+export function normalizeChapterFrontmatterBlock(
+  rawBlock: string,
+  frontmatter: Record<string, FrontmatterValue> | null,
+): string {
+  if (!frontmatter) return rawBlock
+
+  let next = rawBlock
+
+  const chapterNumber = parseChapterNumber(frontmatter.chapter_number)
+  if (chapterNumber !== null) {
+    next = replaceFrontmatterLine(next, "chapter_number", `chapter_number: ${chapterNumber}`)
+  }
+
+  const title = typeof frontmatter.title === "string" ? frontmatter.title : null
+  if (title !== null && title !== "") {
+    next = replaceFrontmatterLine(next, "title", `title: "${yamlEscape(title)}"`)
   }
 
-  const yamlPayload = yaml.dump(nextFrontmatter, {
-    lineWidth: -1,
-    noRefs: true,
-    sortKeys: false,
-  }).trimEnd()
+  const created = typeof frontmatter.created === "string" ? frontmatter.created.trim() : null
+  if (created) {
+    next = replaceFrontmatterLine(next, "created", `created: ${created}`)
+  }
 
-  return `---\n${yamlPayload}\n---\n\n${body.replace(/^\s*/, "")}`
+  return next
+}
+
+export function updateChapterStatus(content: string, status: ChapterStatus): string {
+  const { frontmatter, body, rawBlock } = parseFrontmatter(content)
+  if (!rawBlock) return content
+
+  let nextRaw = CHAPTER_STATUS_LINE_RE.test(rawBlock)
+    ? rawBlock.replace(CHAPTER_STATUS_LINE_RE, `chapter_status: ${status}`)
+    : insertChapterStatusLine(rawBlock, status)
+
+  nextRaw = normalizeChapterFrontmatterBlock(nextRaw, frontmatter)
+
+  return nextRaw + body
+}
+
+export function updateChapterTitle(content: string, nextTitle: string): string {
+  const { frontmatter, body, rawBlock } = parseFrontmatter(content)
+  const normalizedTitle = nextTitle.trim()
+  const bodyWithoutHeading = body.replace(/^#\s+.+$(\r?\n)?/m, "").replace(/^\n+/, "")
+  const nextBody = normalizedTitle
+    ? `# ${normalizedTitle}${bodyWithoutHeading ? `\n\n${bodyWithoutHeading}` : "\n"}`
+    : bodyWithoutHeading
+
+  if (!rawBlock || !frontmatter) return rawBlock + nextBody
+
+  const nextRaw = normalizeChapterFrontmatterBlock(rawBlock, {
+    ...frontmatter,
+    title: normalizedTitle,
+  })
+  return nextRaw + nextBody
+}
+
+/** 以正文标题为准同步 frontmatter,并修正关键字段的 YAML 标量类型。 */
+export function syncChapterFrontmatterFromBody(content: string): string {
+  const { frontmatter, body, rawBlock } = parseFrontmatter(content)
+  if (!rawBlock || !frontmatter) return content
+
+  const heading = body.match(/^#\s+(.+)$/m)?.[1]?.trim()
+  if (!heading) return content
+
+  const nextRaw = normalizeChapterFrontmatterBlock(rawBlock, {
+    ...frontmatter,
+    title: heading,
+  })
+  if (nextRaw === rawBlock) return content
+  return nextRaw + body
+}
+
+function insertChapterStatusLine(rawBlock: string, status: ChapterStatus): string {
+  if (/^type:\s*chapter\s*$/m.test(rawBlock)) {
+    return rawBlock.replace(/^type:\s*chapter\s*$/m, `type: chapter\nchapter_status: ${status}`)
+  }
+  return rawBlock.replace(/(\r?\n)---\s*(?:\r?\n|$)/, `$1chapter_status: ${status}$1---$1`)
 }