dsh-maoying 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +169 -0
- package/README.md +17 -2
- package/bin/maoying.mjs +95 -50
- package/docs/creator-panel.md +33 -7
- package/docs/design.md +10 -7
- package/docs/spec.md +7 -4
- package/index.mjs +242 -88
- package/lib/base.css +156 -25
- package/lib/client.js +688 -232
- package/lib/epub.mjs +37 -6
- package/lib/novel-parse.mjs +171 -46
- package/lib/panel-core.mjs +116 -10
- package/lib/version.mjs +122 -36
- package/lib/yaml-loader.mjs +10 -2
- package/package.json +7 -2
- package/presets/maoying/agent.cordis.v2.yml +6 -1
- package/presets/maoying/agent.cordis.yml +5 -2
- package/presets/maoying/restrict.mjs +5 -6
- package/skills/maoying-review-checklist/SKILL.md +1 -1
- package/templates/novel/README.md +3 -3
- package/templates/novel/foreshadows.yml +1 -0
- package/templates/novel/timeline.yml +1 -1
package/lib/epub.mjs
CHANGED
|
@@ -37,12 +37,19 @@ export function crc32(buf) {
|
|
|
37
37
|
return (c ^ -1) >>> 0
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
40
|
+
/**
|
|
41
|
+
* 转 DOS 时间(zip 头用,本地时区;秒精度 2 秒一档)。导出仅为测试可直接
|
|
42
|
+
* 断言 1980 纪元等边界。
|
|
43
|
+
*/
|
|
44
|
+
export function dosDateTime(date) {
|
|
42
45
|
const time =
|
|
43
46
|
((date.getHours() & 0x1f) << 11) | ((date.getMinutes() & 0x3f) << 5) | ((date.getSeconds() >> 1) & 0x1f)
|
|
47
|
+
// 年份下限 clamp 到 1980:DOS 时间纪元从 1980 年起、年份只存 7 位,更早的
|
|
48
|
+
// 系统时钟(或被拨错的机器时间)会按无符号位回绕映射成 2107+ 的「未来」,
|
|
49
|
+
// 统一按 1980 处理保证 zip 头时间不早于纪元。
|
|
50
|
+
const year = Math.max(date.getFullYear(), 1980)
|
|
44
51
|
const day =
|
|
45
|
-
(((
|
|
52
|
+
(((year - 1980) & 0x7f) << 9) | (((date.getMonth() + 1) & 0xf) << 5) | (date.getDate() & 0x1f)
|
|
46
53
|
return { time, day }
|
|
47
54
|
}
|
|
48
55
|
|
|
@@ -111,11 +118,17 @@ export function buildZip(entries) {
|
|
|
111
118
|
}
|
|
112
119
|
|
|
113
120
|
/**
|
|
114
|
-
* XML 文本转义:& 必须先转(否则把刚生成的 & 又转一遍),再 <
|
|
115
|
-
* 章节正文与标题里出现的都过这里;属性值一律用生成的 ASCII
|
|
121
|
+
* XML 文本转义:& 必须先转(否则把刚生成的 & 又转一遍),再 < > 与引号。
|
|
122
|
+
* 章节正文与标题里出现的都过这里;属性值一律用生成的 ASCII 名不走它,引号
|
|
123
|
+
* 转义属防御纵深(将来属性值万一改走用户输入也不破 XML)。
|
|
116
124
|
*/
|
|
117
125
|
export function escapeXml(text) {
|
|
118
|
-
return String(text ?? '')
|
|
126
|
+
return String(text ?? '')
|
|
127
|
+
.replace(/&/g, '&')
|
|
128
|
+
.replace(/</g, '<')
|
|
129
|
+
.replace(/>/g, '>')
|
|
130
|
+
.replace(/"/g, '"')
|
|
131
|
+
.replace(/'/g, ''')
|
|
119
132
|
}
|
|
120
133
|
|
|
121
134
|
/** XML 声明 + DOCTYPE 头:三个 XHTML 文件共用。 */
|
|
@@ -151,6 +164,12 @@ function chapFile(index) {
|
|
|
151
164
|
|
|
152
165
|
/** content.opf:元数据 + manifest + spine。uuid 由调用方给(randomUUID)。 */
|
|
153
166
|
function contentOpf({ title, author, uuid, chapters, modified }) {
|
|
167
|
+
// uuid 只接受 36 位 hex-dash 形状(即 crypto.randomUUID() 的产物):它不经
|
|
168
|
+
// 转义直接内插进 XML,入前形状校验是调用方契约的硬保证——脏数据在这里
|
|
169
|
+
// 炸出中文报错,而不是产出被注入的 OPF。
|
|
170
|
+
if (typeof uuid !== 'string' || !/^[0-9a-f-]{36}$/i.test(uuid)) {
|
|
171
|
+
throw new TypeError(`buildEpub: uuid 必须是 36 位 UUID 字符串(crypto.randomUUID() 产物),实得「${String(uuid)}」`)
|
|
172
|
+
}
|
|
154
173
|
const manifestItems = chapters
|
|
155
174
|
.map((_, i) => ` <item id="c${String(i + 1).padStart(2, '0')}" href="${chapFile(i)}" media-type="application/xhtml+xml"/>`)
|
|
156
175
|
.join('\n')
|
|
@@ -224,6 +243,18 @@ ${paragraphs}
|
|
|
224
243
|
* chapters [{ heading, paragraphs }],顺序即 spine 顺序
|
|
225
244
|
*/
|
|
226
245
|
export function buildEpub({ title, author, uuid, chapters }) {
|
|
246
|
+
// 形状兜底:chapters 必须是数组,且每章 paragraphs 必须是段落数组——上游
|
|
247
|
+
// (章节解析侧)形状漂移时在这里给出带章号的中文报错,而不是抛 undefined
|
|
248
|
+
// 调 .map 的 TypeError 原始栈。
|
|
249
|
+
if (!Array.isArray(chapters)) {
|
|
250
|
+
throw new TypeError(`buildEpub: chapters 必须是章节数组([{ heading, paragraphs }]),实得 ${typeof chapters}`)
|
|
251
|
+
}
|
|
252
|
+
chapters.forEach((chapter, i) => {
|
|
253
|
+
if (!chapter || typeof chapter !== 'object' || !Array.isArray(chapter.paragraphs)) {
|
|
254
|
+
const got = chapter?.paragraphs === null ? 'null' : typeof chapter?.paragraphs
|
|
255
|
+
throw new TypeError(`buildEpub: 第 ${i + 1} 章(${chapter?.heading ?? '无标题'})的 paragraphs 必须是段落数组,实得 ${got}`)
|
|
256
|
+
}
|
|
257
|
+
})
|
|
227
258
|
const modified = `${new Date().toISOString().replace(/\.\d{3}Z$/, '')}Z`
|
|
228
259
|
const entries = [
|
|
229
260
|
// mimetype 必须第一个条目且 stored、内容不带换行——EPUB 规范的身份证
|
package/lib/novel-parse.mjs
CHANGED
|
@@ -24,9 +24,11 @@ export const CHAPTER_STATUSES = [
|
|
|
24
24
|
['accepted', '已定稿'],
|
|
25
25
|
]
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
// CJK 计数范围:基本区 + 扩展 A + 扩展 B(\u{20000}-\u{2A6DF})。扩展 B 在增补
|
|
28
|
+
// 平面,必须带 u 标志按码点匹配——网文生僻字(如「𠮷」)此前会被漏计。
|
|
29
|
+
const CJK_RE = /[\u4e00-\u9fff\u3400-\u4dbf\u{20000}-\u{2a6df}]/gu
|
|
28
30
|
|
|
29
|
-
/** CJK 字符计数(基本区 + 扩展 A
|
|
31
|
+
/** CJK 字符计数(基本区 + 扩展 A/B 区,含增补平面生僻字)。 */
|
|
30
32
|
export function countCjk(text) {
|
|
31
33
|
const matches = String(text ?? '').match(CJK_RE)
|
|
32
34
|
return matches === null ? 0 : matches.length
|
|
@@ -34,7 +36,9 @@ export function countCjk(text) {
|
|
|
34
36
|
|
|
35
37
|
/**
|
|
36
38
|
* 行内注释剥除:`#` 只有出现在行首或空白之后才算注释;单双引号内的 # 原样
|
|
37
|
-
* 保留(双引号内 \"
|
|
39
|
+
* 保留(双引号内 \" 是转义引号——判定看引号前连续反斜杠的奇偶:成对说明
|
|
40
|
+
* 反斜杠自身被转义、引号是收尾引号;只回看一个字符会把 "a\\" 的收尾引号
|
|
41
|
+
* 误判成转义,导致注释剥不掉)。
|
|
38
42
|
*/
|
|
39
43
|
export function stripInlineComment(line) {
|
|
40
44
|
let inSingle = false
|
|
@@ -46,7 +50,11 @@ export function stripInlineComment(line) {
|
|
|
46
50
|
continue
|
|
47
51
|
}
|
|
48
52
|
if (inDouble) {
|
|
49
|
-
if (c === '"'
|
|
53
|
+
if (c === '"') {
|
|
54
|
+
let slashes = 0
|
|
55
|
+
for (let j = i - 1; j >= 0 && line[j] === '\\'; j--) slashes++
|
|
56
|
+
if (slashes % 2 === 0) inDouble = false
|
|
57
|
+
}
|
|
50
58
|
continue
|
|
51
59
|
}
|
|
52
60
|
if (c === "'") inSingle = true
|
|
@@ -96,11 +104,9 @@ export function parseFlatYamlArray(text) {
|
|
|
96
104
|
if (first.startsWith('-')) {
|
|
97
105
|
const arr = []
|
|
98
106
|
let current = null
|
|
99
|
-
let bare = false
|
|
100
107
|
const flush = () => {
|
|
101
108
|
if (current !== null) arr.push(current)
|
|
102
109
|
current = null
|
|
103
|
-
bare = false
|
|
104
110
|
}
|
|
105
111
|
for (const line of lines) {
|
|
106
112
|
const trimmed = line.trim()
|
|
@@ -113,7 +119,6 @@ export function parseFlatYamlArray(text) {
|
|
|
113
119
|
current[m[1].trim()] = parseScalarText(m[2])
|
|
114
120
|
} else {
|
|
115
121
|
arr.push(parseScalarText(rest)) // 裸标量条目
|
|
116
|
-
bare = true
|
|
117
122
|
}
|
|
118
123
|
} else if (trimmed === '-') {
|
|
119
124
|
flush()
|
|
@@ -124,7 +129,7 @@ export function parseFlatYamlArray(text) {
|
|
|
124
129
|
}
|
|
125
130
|
}
|
|
126
131
|
flush()
|
|
127
|
-
return bare || arr.length > 0 ? arr : []
|
|
132
|
+
return arr // 原式 `bare || arr.length > 0 ? arr : []` 恒等于 arr,删简
|
|
128
133
|
}
|
|
129
134
|
// 平键映射
|
|
130
135
|
const map = {}
|
|
@@ -149,27 +154,34 @@ export function parseFlatYamlArray(text) {
|
|
|
149
154
|
* `---` 之后的正文(去首个空行)。只切不解析,解析交给上层。
|
|
150
155
|
*/
|
|
151
156
|
export function splitFrontmatter(text) {
|
|
152
|
-
|
|
157
|
+
// 带 BOM 的文件开头是 \uFEFF,会让下面的开头围栏正则失配 → frontmatter 整体
|
|
158
|
+
// 静默失效(meta 全空、check 全报缺键)。解析入口先剥 BOM:bom 作统一偏移,
|
|
159
|
+
// index 仍按原文计(+1 补回),保住「raw === text.slice(index, index + raw.length)」
|
|
160
|
+
// 的契约——写侧行级手术(panel-core 用 fence.index 拼接替换)不能错位。
|
|
161
|
+
const bom = text.charCodeAt(0) === 0xfeff ? 1 : 0
|
|
162
|
+
const rest = bom === 0 ? text : text.slice(1)
|
|
163
|
+
const open = /^---[ \t]*\r?\n/.exec(rest)
|
|
153
164
|
if (open === null) return { raw: null, index: -1, body: text }
|
|
154
165
|
const rawStart = open[0].length
|
|
155
|
-
const close = /\r?\n---[ \t]*(?:\r?\n|$)/.exec(
|
|
166
|
+
const close = /\r?\n---[ \t]*(?:\r?\n|$)/.exec(rest.slice(rawStart))
|
|
156
167
|
if (close === null) return { raw: null, index: -1, body: text }
|
|
157
168
|
return {
|
|
158
|
-
raw: text.slice(rawStart, rawStart + close.index),
|
|
159
|
-
index: rawStart,
|
|
160
|
-
body: text.slice(rawStart + close.index + close[0].length).replace(/^\r?\n/, ''),
|
|
169
|
+
raw: text.slice(rawStart + bom, rawStart + bom + close.index),
|
|
170
|
+
index: rawStart + bom,
|
|
171
|
+
body: text.slice(rawStart + bom + close.index + close[0].length).replace(/^\r?\n/, ''),
|
|
161
172
|
}
|
|
162
173
|
}
|
|
163
174
|
|
|
164
175
|
/**
|
|
165
176
|
* 解析章节文件:`---` 围栏 frontmatter(简单键值,一行一个)+ body。
|
|
166
|
-
* 无围栏时全部按 body 处理。
|
|
177
|
+
* 无围栏时全部按 body 处理。raw 为围栏内原文(check 侧扫重复键用),无围栏
|
|
178
|
+
* 时为 null。
|
|
167
179
|
*/
|
|
168
180
|
export function parseChapterFile(text) {
|
|
169
181
|
const { raw, body } = splitFrontmatter(text)
|
|
170
|
-
if (raw === null) return { meta: {}, body }
|
|
182
|
+
if (raw === null) return { meta: {}, body, raw: null }
|
|
171
183
|
const meta = parseFlatYamlArray(raw)
|
|
172
|
-
return { meta: meta !== null && typeof meta === 'object' && !Array.isArray(meta) ? meta : {}, body }
|
|
184
|
+
return { meta: meta !== null && typeof meta === 'object' && !Array.isArray(meta) ? meta : {}, body, raw }
|
|
173
185
|
}
|
|
174
186
|
|
|
175
187
|
/** 从文件名取排序键:v01-c001.md → { vol: 1, num: 1 };不合规范的文件返回 null。 */
|
|
@@ -178,19 +190,29 @@ export function chapterSortKey(stem) {
|
|
|
178
190
|
return m === null ? null : { vol: Number(m[1]), num: Number(m[2]) }
|
|
179
191
|
}
|
|
180
192
|
|
|
193
|
+
/**
|
|
194
|
+
* 「列目录下 .md + numeric 感知名称序」在 collectChapters / listOutlines /
|
|
195
|
+
* checkProject 出现三次,收敛到这里。目录不存在给 [](调用方按需先判存在)。
|
|
196
|
+
*/
|
|
197
|
+
function listMdFiles(dir) {
|
|
198
|
+
if (!existsSync(dir)) return []
|
|
199
|
+
return readdirSync(dir)
|
|
200
|
+
.filter((f) => f.endsWith('.md'))
|
|
201
|
+
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
|
202
|
+
}
|
|
203
|
+
|
|
181
204
|
/** 章节五维叙事字段的 frontmatter 键名(值一律中文句子)。 */
|
|
182
205
|
export const FIVE_DIM_KEYS = ['cognitive_frame', 'emotional_anchor', 'world_texture', 'suspense', 'interpersonal_tension']
|
|
183
206
|
|
|
184
|
-
/** 收集章节:读取 chapters/*.md
|
|
207
|
+
/** 收集章节:读取 chapters/*.md,按卷号+章号升序(文件名不合规范的杂散文件沉底)。目录不存在返回 null。 */
|
|
185
208
|
export function collectChapters(root) {
|
|
186
209
|
const chaptersDir = path.join(root, 'chapters')
|
|
187
210
|
if (!existsSync(chaptersDir)) return null
|
|
188
|
-
const files =
|
|
189
|
-
.filter((f) => f.endsWith('.md'))
|
|
190
|
-
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
|
211
|
+
const files = listMdFiles(chaptersDir)
|
|
191
212
|
const chapters = []
|
|
192
213
|
for (const file of files) {
|
|
193
214
|
const stem = file.slice(0, -3)
|
|
215
|
+
const key = chapterSortKey(stem) // 排序键一次算好——volume/num/sortKey 三个字段都要用
|
|
194
216
|
let text = null
|
|
195
217
|
try {
|
|
196
218
|
text = readFileSync(path.join(chaptersDir, file), 'utf8')
|
|
@@ -202,9 +224,9 @@ export function collectChapters(root) {
|
|
|
202
224
|
chapters.push({
|
|
203
225
|
file,
|
|
204
226
|
stem,
|
|
205
|
-
sortKey:
|
|
206
|
-
volume: typeof meta.volume === 'string' && meta.volume !== '' ? meta.volume : (
|
|
207
|
-
num: Number.isInteger(meta.num) ? meta.num : (
|
|
227
|
+
sortKey: key,
|
|
228
|
+
volume: typeof meta.volume === 'string' && meta.volume !== '' ? meta.volume : (key !== null ? canonicalVolume(key.vol) : ''),
|
|
229
|
+
num: Number.isInteger(meta.num) ? meta.num : (key !== null ? key.num : null),
|
|
208
230
|
title: typeof meta.title === 'string' && meta.title !== '' ? meta.title : '(无标题)',
|
|
209
231
|
status,
|
|
210
232
|
summary: typeof meta.summary === 'string' ? meta.summary : '',
|
|
@@ -217,6 +239,10 @@ export function collectChapters(root) {
|
|
|
217
239
|
if (a.sortKey !== null && b.sortKey !== null) {
|
|
218
240
|
return a.sortKey.vol !== b.sortKey.vol ? a.sortKey.vol - b.sortKey.vol : a.sortKey.num - b.sortKey.num
|
|
219
241
|
}
|
|
242
|
+
// 文件名不合规范的杂散 .md 一律沉底(与 listOutlines 同策略)——否则会排
|
|
243
|
+
// 在正常章之前,混进导出并污染 brief 的「前章」链。
|
|
244
|
+
if (a.sortKey !== null) return -1
|
|
245
|
+
if (b.sortKey !== null) return 1
|
|
220
246
|
return a.stem.localeCompare(b.stem, undefined, { numeric: true })
|
|
221
247
|
})
|
|
222
248
|
return chapters
|
|
@@ -227,7 +253,10 @@ export function collectChapters(root) {
|
|
|
227
253
|
/**
|
|
228
254
|
* 流式上下文里的标量落盘:数字直写;字符串在含 YAML 结构字符(流式项分隔、
|
|
229
255
|
* 键值形态、起首指示符、注释引入、首尾空白、控制字符)时按 JSON 双引号落值
|
|
230
|
-
* (JSON 字符串即合法 YAML
|
|
256
|
+
* (JSON 字符串即合法 YAML 双引号标量);裸写会被 YAML 读成数字/布尔的串
|
|
257
|
+
* ('1984'、'true'、'3.14' 这类类型漂移)同样要引起来——落值前用自家
|
|
258
|
+
* parseScalarText 试解析,结果 !== 原串(类型或值变了)即按 JSON 引号落值,
|
|
259
|
+
* 保证读回来自始至终是字符串。
|
|
231
260
|
*/
|
|
232
261
|
export function flowScalar(value) {
|
|
233
262
|
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
|
@@ -241,7 +270,8 @@ export function flowScalar(value) {
|
|
|
241
270
|
/\s[:#]/.test(s) ||
|
|
242
271
|
/^\s|\s$/.test(s) ||
|
|
243
272
|
/[\x00-\x1f]/.test(s)
|
|
244
|
-
|
|
273
|
+
if (unsafe) return JSON.stringify(s)
|
|
274
|
+
return parseScalarText(s) !== s ? JSON.stringify(s) : s
|
|
245
275
|
}
|
|
246
276
|
|
|
247
277
|
/** 章节一行条目(flow mapping,num/title/status/words/summary 一行)。 */
|
|
@@ -305,8 +335,8 @@ export function buildIndexYaml(chapters, volumesRaw) {
|
|
|
305
335
|
|
|
306
336
|
const OUTLINES_DIR = 'outlines'
|
|
307
337
|
|
|
308
|
-
/** 读文本文件;读不到返回 null
|
|
309
|
-
function readTextOrNull(file) {
|
|
338
|
+
/** 读文本文件;读不到返回 null(导出供 bin 等其他文件复用,与 bin 侧本地副本同语义)。 */
|
|
339
|
+
export function readTextOrNull(file) {
|
|
310
340
|
try {
|
|
311
341
|
return readFileSync(file, 'utf8')
|
|
312
342
|
} catch {
|
|
@@ -335,10 +365,8 @@ export function readOutline(novelDir, chapterFile) {
|
|
|
335
365
|
/** 列出全部章纲 [{ file, chapter, words }],按章号升序(不合文件名规范的排最后,按名称序)。 */
|
|
336
366
|
export function listOutlines(novelDir) {
|
|
337
367
|
const dir = path.join(novelDir, OUTLINES_DIR)
|
|
338
|
-
if (!existsSync(dir)) return []
|
|
339
368
|
const outlines = []
|
|
340
|
-
for (const file of
|
|
341
|
-
if (!file.endsWith('.md')) continue
|
|
369
|
+
for (const file of listMdFiles(dir)) {
|
|
342
370
|
const stem = file.slice(0, -3)
|
|
343
371
|
const text = readTextOrNull(path.join(dir, file))
|
|
344
372
|
if (text === null) continue
|
|
@@ -355,6 +383,14 @@ export function listOutlines(novelDir) {
|
|
|
355
383
|
return outlines
|
|
356
384
|
}
|
|
357
385
|
|
|
386
|
+
/**
|
|
387
|
+
* 只数 outlines/ 下的 .md 个数,不读内容。checkProject 的数纲数只需要个数,
|
|
388
|
+
* 走 listOutlines 会把每份章纲全文读进来做 CJK 计数——纯浪费。
|
|
389
|
+
*/
|
|
390
|
+
function countOutlineFiles(novelDir) {
|
|
391
|
+
return listMdFiles(path.join(novelDir, OUTLINES_DIR)).length
|
|
392
|
+
}
|
|
393
|
+
|
|
358
394
|
// ── 写作卡 brief(五期 §9.6)────────────────────────────────────────────────
|
|
359
395
|
//
|
|
360
396
|
// 把一章的写作输入打成一个 md(章纲 + 前章结尾 + 出场角色卡 + 活跃伏笔 +
|
|
@@ -400,18 +436,21 @@ function collectCharacterCards(novelDir) {
|
|
|
400
436
|
if (!file.endsWith('.md')) continue
|
|
401
437
|
const text = readTextOrNull(path.join(dir, file))
|
|
402
438
|
if (text === null) continue
|
|
403
|
-
const { meta, body } = parseChapterFile(text)
|
|
404
|
-
cards.push({ slug: file.slice(0, -3), file: `characters/${file}`, meta, body })
|
|
439
|
+
const { meta, body, raw } = parseChapterFile(text)
|
|
440
|
+
cards.push({ slug: file.slice(0, -3), file: `characters/${file}`, meta, body, raw })
|
|
405
441
|
}
|
|
406
442
|
cards.sort((a, b) => a.slug.localeCompare(b.slug))
|
|
407
443
|
return cards
|
|
408
444
|
}
|
|
409
445
|
|
|
410
|
-
/** 顶层数组 yml 读取:文件缺失、解析失败或非数组一律给 [](条目只留对象)。
|
|
446
|
+
/** 顶层数组 yml 读取:文件缺失、解析失败或非数组一律给 [](条目只留对象)。
|
|
447
|
+
* `-` 空条目后跟裸标量(`-\n- foo`)会被解析成无任何自有键的空对象,一并过滤。 */
|
|
411
448
|
function readYamlArray(novelDir, name) {
|
|
412
449
|
const raw = parseFlatYamlArray(readTextOrNull(path.join(novelDir, name)))
|
|
413
450
|
if (!Array.isArray(raw)) return []
|
|
414
|
-
return raw.filter(
|
|
451
|
+
return raw.filter(
|
|
452
|
+
(item) => item !== null && typeof item === 'object' && !Array.isArray(item) && Object.keys(item).length > 0,
|
|
453
|
+
)
|
|
415
454
|
}
|
|
416
455
|
|
|
417
456
|
/**
|
|
@@ -675,6 +714,32 @@ function canonicalVolume(vol) {
|
|
|
675
714
|
return `v${String(vol).padStart(2, '0')}`
|
|
676
715
|
}
|
|
677
716
|
|
|
717
|
+
/**
|
|
718
|
+
* 扫 frontmatter 原文找重复键:解析器对重复键 last-wins(宽容不炸),但静默
|
|
719
|
+
* 覆盖会藏「改了前一个没生效」的困惑,check 侧给 warn 提醒。判定口径与
|
|
720
|
+
* parseFlatYamlArray 的平键分支对齐:只认零缩进键,键名去首尾引号。
|
|
721
|
+
*/
|
|
722
|
+
function findDuplicateFrontmatterKeys(raw) {
|
|
723
|
+
if (raw === null) return []
|
|
724
|
+
const seen = new Set()
|
|
725
|
+
const dups = []
|
|
726
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
727
|
+
const cleaned = stripInlineComment(line)
|
|
728
|
+
const trimmed = cleaned.trim()
|
|
729
|
+
if (trimmed === '' || trimmed.startsWith('#')) continue
|
|
730
|
+
const indent = cleaned.match(/^[ \t]*/)[0].length
|
|
731
|
+
const m = /^([^:]+):/.exec(trimmed)
|
|
732
|
+
if (m === null || indent !== 0) continue
|
|
733
|
+
const key = m[1].trim().replace(/^['"]|['"]$/g, '')
|
|
734
|
+
if (seen.has(key)) {
|
|
735
|
+
if (!dups.includes(key)) dups.push(key)
|
|
736
|
+
} else {
|
|
737
|
+
seen.add(key)
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return dups
|
|
741
|
+
}
|
|
742
|
+
|
|
678
743
|
/**
|
|
679
744
|
* 账本里的章节引用是否存在:认 stem(v01-c001,含不补零写法)与裸章号。
|
|
680
745
|
* 空值视为「未填」,不算悬空。
|
|
@@ -691,6 +756,21 @@ function chapterRefExists(ref, stems, nums) {
|
|
|
691
756
|
return false
|
|
692
757
|
}
|
|
693
758
|
|
|
759
|
+
/**
|
|
760
|
+
* 章节引用转可比较的序数 {vol, num}:认 v01-c002(含不补零写法)、「卷-章」
|
|
761
|
+
* 与裸章号(裸章号无卷信息,卷号记 0,仅用于与带卷引用的近似比较)。
|
|
762
|
+
* 只做格式解析、不查存在性——expected_resolve 的排期承诺口径用它。
|
|
763
|
+
*/
|
|
764
|
+
function parseChapterRefOrdinal(ref) {
|
|
765
|
+
const raw = String(ref ?? '').trim().replace(/[-—–]/g, '-').replace(/\.md$/i, '')
|
|
766
|
+
let m = /^[vV](\d+)-c(\d+)$/.exec(raw)
|
|
767
|
+
if (m !== null) return { vol: Number(m[1]), num: Number(m[2]) }
|
|
768
|
+
m = /^(\d+)-(\d+)$/.exec(raw)
|
|
769
|
+
if (m !== null) return { vol: Number(m[1]), num: Number(m[2]) }
|
|
770
|
+
if (/^\d+$/.test(raw)) return { vol: 0, num: Number(raw) }
|
|
771
|
+
return null
|
|
772
|
+
}
|
|
773
|
+
|
|
694
774
|
/** 时间线年份还原:数字直用;字符串抓第一段数字;抓不到返回 null。 */
|
|
695
775
|
function parseTimelineYear(year) {
|
|
696
776
|
if (typeof year === 'number' && Number.isFinite(year)) return year
|
|
@@ -710,11 +790,7 @@ export function checkProject(novelDir) {
|
|
|
710
790
|
|
|
711
791
|
// —— chapters/:frontmatter 完整性、枚举、章号卷号一致 ——
|
|
712
792
|
const chaptersDir = path.join(novelDir, 'chapters')
|
|
713
|
-
const chapterFiles =
|
|
714
|
-
? readdirSync(chaptersDir)
|
|
715
|
-
.filter((f) => f.endsWith('.md'))
|
|
716
|
-
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
|
717
|
-
: []
|
|
793
|
+
const chapterFiles = listMdFiles(chaptersDir)
|
|
718
794
|
if (!existsSync(chaptersDir)) push('error', 'chapters/', '找不到 chapters/ 目录——先跑 maoying init')
|
|
719
795
|
|
|
720
796
|
const stems = new Set()
|
|
@@ -729,12 +805,20 @@ export function checkProject(novelDir) {
|
|
|
729
805
|
push('warn', rel, '文件名不合规范(应为 v<卷号2位>-c<章号3位>.md),章纲与账本对不上它')
|
|
730
806
|
}
|
|
731
807
|
let meta = {}
|
|
808
|
+
let fmRaw = null
|
|
732
809
|
try {
|
|
733
|
-
|
|
810
|
+
const parsed = parseChapterFile(readFileSync(path.join(chaptersDir, file), 'utf8'))
|
|
811
|
+
meta = parsed.meta
|
|
812
|
+
fmRaw = parsed.raw
|
|
734
813
|
} catch {
|
|
735
814
|
push('error', rel, '章节文件读取失败')
|
|
736
815
|
continue
|
|
737
816
|
}
|
|
817
|
+
// 重复键:解析保持 last-wins 宽容,但静默覆盖值得提醒(角色卡同理,见 characters 段)
|
|
818
|
+
const dupKeys = findDuplicateFrontmatterKeys(fmRaw)
|
|
819
|
+
if (dupKeys.length > 0) {
|
|
820
|
+
push('warn', rel, `frontmatter 重复键(解析时后者覆盖前者):${dupKeys.join('、')}`)
|
|
821
|
+
}
|
|
738
822
|
for (const k of REQUIRED_CHAPTER_KEYS) {
|
|
739
823
|
if (meta[k] === undefined || meta[k] === '') push('error', rel, `frontmatter 缺少必填键 ${k}`)
|
|
740
824
|
}
|
|
@@ -750,8 +834,14 @@ export function checkProject(novelDir) {
|
|
|
750
834
|
if (typeof meta.volume === 'string' && meta.volume !== '' && meta.volume !== expectedVolume) {
|
|
751
835
|
push('error', rel, `frontmatter volume(${meta.volume})与文件名卷号(${expectedVolume})不一致`)
|
|
752
836
|
}
|
|
753
|
-
|
|
754
|
-
|
|
837
|
+
// num 常被手写成 '3' 这类单引号字符串:比较前按 Number() 归一(与
|
|
838
|
+
// collectChapters 的宽容口径对齐),转不出数字(NaN)时维持严格不等判断。
|
|
839
|
+
if (meta.num !== undefined && meta.num !== '') {
|
|
840
|
+
const parsedNum = Number(meta.num)
|
|
841
|
+
const mismatch = Number.isNaN(parsedNum) ? meta.num !== key.num : parsedNum !== key.num
|
|
842
|
+
if (mismatch) {
|
|
843
|
+
push('error', rel, `frontmatter num(${meta.num})与文件名章号(${key.num})不一致`)
|
|
844
|
+
}
|
|
755
845
|
}
|
|
756
846
|
}
|
|
757
847
|
if (typeof meta.writer === 'string' && meta.writer !== '' && !CHAPTER_WRITERS.includes(meta.writer)) {
|
|
@@ -759,7 +849,7 @@ export function checkProject(novelDir) {
|
|
|
759
849
|
}
|
|
760
850
|
}
|
|
761
851
|
|
|
762
|
-
// —— foreshadows.yml
|
|
852
|
+
// —— foreshadows.yml:状态枚举、id 去重、章节引用、状态-字段自洽 ——
|
|
763
853
|
const foreshadows = readYamlArray(novelDir, 'foreshadows.yml')
|
|
764
854
|
const seenIds = new Map()
|
|
765
855
|
foreshadows.forEach((f, i) => {
|
|
@@ -771,13 +861,44 @@ export function checkProject(novelDir) {
|
|
|
771
861
|
if (seenIds.has(f.id)) push('error', 'foreshadows.yml', `伏笔 id 重复:${f.id}(第 ${seenIds.get(f.id) + 1} 条与第 ${i + 1} 条)`)
|
|
772
862
|
else seenIds.set(f.id, i)
|
|
773
863
|
}
|
|
774
|
-
|
|
864
|
+
// 埋设/回收章是已发生的事实,必须指向已写出的章(空值在此跳过——resolved
|
|
865
|
+
// 缺兑现章属于「漏规则」,在下面单查)。
|
|
866
|
+
for (const k of ['planted_chapter', 'resolved_chapter']) {
|
|
775
867
|
const ref = f[k]
|
|
776
868
|
if ((typeof ref !== 'string' || ref.trim() === '') && typeof ref !== 'number') continue
|
|
777
869
|
if (!chapterRefExists(ref, stems, nums)) {
|
|
778
870
|
push('error', 'foreshadows.yml', `伏笔 ${label} 的 ${k} 引用章节不存在:${ref}`)
|
|
779
871
|
}
|
|
780
872
|
}
|
|
873
|
+
// expected_resolve 是排期承诺(docs/spec.md):目标章天然多数尚未写出,
|
|
874
|
+
// 只查格式合法性(能解析出卷/章),不查存在性——按官方模板排到未来章号
|
|
875
|
+
// 不该报「引用章节不存在」。
|
|
876
|
+
const expected = f.expected_resolve
|
|
877
|
+
if ((typeof expected === 'string' && expected.trim() !== '') || typeof expected === 'number') {
|
|
878
|
+
if (parseChapterRefOrdinal(expected) === null) {
|
|
879
|
+
push('error', 'foreshadows.yml', `伏笔 ${label} 的 expected_resolve 格式不合法:${expected}(应为 v<卷>-c<章>、「卷-章」或裸章号)`)
|
|
880
|
+
} else if (f.status === 'planted' && chapterRefExists(expected, stems, nums)) {
|
|
881
|
+
// 反向提醒:排期目标章已写出却还停在埋设态,多半是忘了登记推进/回收
|
|
882
|
+
push('warn', 'foreshadows.yml', `伏笔 ${label} 的 expected_resolve(${expected})指向的章节已写出,但 status 仍为 planted——确认是否漏登记推进/回收`)
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
// docs/spec.md:resolved 回收时 resolved_chapter 必填
|
|
886
|
+
const resolvedRef = f.resolved_chapter
|
|
887
|
+
const resolvedEmpty =
|
|
888
|
+
resolvedRef === undefined || resolvedRef === null || (typeof resolvedRef === 'string' && resolvedRef.trim() === '')
|
|
889
|
+
if (f.status === 'resolved' && resolvedEmpty) {
|
|
890
|
+
push('error', 'foreshadows.yml', `伏笔 ${label} status 为 resolved 但未填 resolved_chapter(回收必须登记兑现章)`)
|
|
891
|
+
}
|
|
892
|
+
// docs/spec.md:resolved_chapter 不得早于 planted_chapter(两章号均可解析才比)
|
|
893
|
+
const plantedOrd = parseChapterRefOrdinal(f.planted_chapter)
|
|
894
|
+
const resolvedOrd = parseChapterRefOrdinal(resolvedRef)
|
|
895
|
+
if (
|
|
896
|
+
plantedOrd !== null &&
|
|
897
|
+
resolvedOrd !== null &&
|
|
898
|
+
(resolvedOrd.vol < plantedOrd.vol || (resolvedOrd.vol === plantedOrd.vol && resolvedOrd.num < plantedOrd.num))
|
|
899
|
+
) {
|
|
900
|
+
push('error', 'foreshadows.yml', `伏笔 ${label} 的 resolved_chapter(${resolvedRef})早于 planted_chapter(${f.planted_chapter})`)
|
|
901
|
+
}
|
|
781
902
|
})
|
|
782
903
|
|
|
783
904
|
// —— timeline.yml:年份可解析、不倒挂(warn:故事内纪年本就允许花写)——
|
|
@@ -811,11 +932,15 @@ export function checkProject(novelDir) {
|
|
|
811
932
|
}
|
|
812
933
|
})
|
|
813
934
|
|
|
814
|
-
// —— characters/*.md:name
|
|
935
|
+
// —— characters/*.md:name 缺失是硬伤;重复键给 warn ——
|
|
815
936
|
for (const card of cards) {
|
|
816
937
|
if (typeof card.meta.name !== 'string' || card.meta.name.trim() === '') {
|
|
817
938
|
push('error', card.file, '角色卡缺少 name')
|
|
818
939
|
}
|
|
940
|
+
const dupKeys = findDuplicateFrontmatterKeys(card.raw)
|
|
941
|
+
if (dupKeys.length > 0) {
|
|
942
|
+
push('warn', card.file, `frontmatter 重复键(解析时后者覆盖前者):${dupKeys.join('、')}`)
|
|
943
|
+
}
|
|
819
944
|
}
|
|
820
945
|
|
|
821
946
|
// —— project.yml:mode 枚举 ——
|
|
@@ -829,7 +954,7 @@ export function checkProject(novelDir) {
|
|
|
829
954
|
|
|
830
955
|
const summary = {
|
|
831
956
|
chapters: chapterFiles.length,
|
|
832
|
-
outlines:
|
|
957
|
+
outlines: countOutlineFiles(novelDir), // 只列目录计数,不为数数把章纲全文读进来
|
|
833
958
|
foreshadows: foreshadows.length,
|
|
834
959
|
problems: {
|
|
835
960
|
error: problems.filter((p) => p.level === 'error').length,
|