@chengzhao-dev/dsh-quarto-book 0.6.3 → 0.6.4

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.
Files changed (32) hide show
  1. package/README.en.md +3 -3
  2. package/README.md +3 -3
  3. package/lib/index.js +49 -7
  4. package/lib/tools/pass-report.js +12 -12
  5. package/lib/tools/render-check.js +230 -61
  6. package/lib/workspace.js +1 -0
  7. package/locale/en.json +1 -1
  8. package/locale/zh.json +1 -1
  9. package/package.json +9 -6
  10. package/skills/designing-theme/assets/theme/css/content.css +15 -0
  11. package/skills/designing-theme/scripts/checking-layout.py +11 -3
  12. package/skills/designing-theme/scripts/measuring-pages.mjs +2 -2
  13. package/skills/writing-quarto/SKILL.md +7 -0
  14. package/skills/writing-quarto/examples/03-qmd-elements.md +29 -2
  15. package/skills/writing-quarto/quarto/authoring.md +3 -2
  16. package/skills/writing-quarto/quarto/basics.md +1 -0
  17. package/skills/writing-quarto/quarto/coords.md +2 -1
  18. package/skills/writing-quarto/quarto/figures.md +49 -0
  19. package/skills/writing-quarto/quarto/mermaid.md +1 -1
  20. package/skills/writing-quarto/quarto/refactor.md +1 -1
  21. package/skills/writing-quarto/references/book-journey.md +38 -0
  22. package/skills/writing-quarto/references/token-budget.md +26 -0
  23. package/skills/writing-quarto/scripts/checking-callouts.py +2 -2
  24. package/skills/writing-quarto/scripts/checking-dom.py +12 -6
  25. package/skills/writing-quarto/scripts/checking-links.py +29 -13
  26. package/skills/writing-quarto/scripts/checking-names.py +3 -1
  27. package/skills/writing-quarto/scripts/checking-output.py +3 -3
  28. package/skills/writing-quarto/scripts/checking-prose.py +11 -7
  29. package/skills/writing-quarto/scripts/testing-content.py +68 -1
  30. package/skills/writing-quarto/scripts/verifying-content.py +166 -36
  31. package/skills/writing-quarto/steps/03-blocks.md +4 -3
  32. package/skills/writing-quarto/steps/06-wording.md +17 -0
package/README.en.md CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  **English** | [简体中文](https://github.com/chengzhao-dev/dsh-quarto-book/blob/main/README.md)
7
7
 
8
- Write Chinese technical chapters in a Quarto Book repository: `writing-quarto` owns the chapter skeleton and prose, `designing-theme` owns the theme. Both skills appear only inside `type: book` repositories. After a render, `quarto_render_check` decides whether the HTML is publishable and `quarto_pass_report` prints the closing receipt.
8
+ Take a Quarto Book from chapters to publishable output: `writing-quarto` owns the chapter skeleton, whole-book order and Chinese prose; `designing-theme` owns the theme; after a render, `quarto_render_check` decides whether the HTML is publishable and `quarto_pass_report` prints the closing receipt. Both skills appear only inside `type: book` repositories, and the workflow needs Quarto CLI and Python 3.
9
9
 
10
10
  ## What it provides
11
11
 
@@ -16,7 +16,7 @@ Write Chinese technical chapters in a Quarto Book repository: `writing-quarto` o
16
16
  | `quarto_render_check` | Check that `output-dir` is safe, render once when asked, and inspect HTML in `_book/` for publishing | Editing `.qmd`, fixing config automatically, or re-running a failed render |
17
17
  | `quarto_pass_report` | Compress the run into a fixed-line receipt card: skill, mode, files and steps changed, items held, check verdicts | Saving to disk, quoting chapter prose, or changing the text based on the receipt |
18
18
 
19
- Both skills are discovered from the package's `skills/` directory and loaded on demand. Relative paths inside a skill resolve against that skill's directory: `writing-quarto` uses `steps/`, `examples/`, `quarto/`, `scripts/`; `designing-theme` uses `references/`, `knowledge/`, `assets/`, `scripts/`.
19
+ Both skills are discovered from the package's `skills/` directory and loaded on demand. Relative paths inside a skill resolve against that skill's directory: `writing-quarto` uses `steps/`, `examples/`, `quarto/`, `references/`, `scripts/`; `designing-theme` uses `references/`, `knowledge/`, `assets/`, `scripts/`.
20
20
 
21
21
  ## Quick start
22
22
 
@@ -29,7 +29,7 @@ Both skills are discovered from the package's `skills/` directory and loaded on
29
29
  2. Open a session in the book repository. `_quarto.yml` needs a standalone `type: book` line, and the book needs at least one `.qmd` or `.md` chapter source; only then do both skill names appear.
30
30
  3. Call a skill: `writing-quarto` for prose, `designing-theme` for the theme.
31
31
 
32
- Replace `--profile` with the profile you actually run. The desktop app always runs the `desktop` profile, and that profile is owned by the app, so `dsh --profile desktop` is rejected; install or upgrade with `dsh plugin --profile desktop add <package>`, and use the app's Settings → Plugins → Add plugin only if the app refuses that write. To pin a version, write `npm:@chengzhao-dev/dsh-quarto-book@0.6.3`; to follow a GitHub tag, write `github:chengzhao-dev/dsh-quarto-book#v0.6.3`.
32
+ Replace `--profile` with the profile you actually run. The desktop app always runs the `desktop` profile, and that profile is owned by the app, so `dsh --profile desktop` is rejected; install or upgrade with `dsh plugin --profile desktop add <package>`, and use the app's Settings → Plugins → Add plugin only if the app refuses that write. To pin a version, write `npm:@chengzhao-dev/dsh-quarto-book@0.6.4`; to follow a GitHub tag, write `github:chengzhao-dev/dsh-quarto-book#v0.6.4`.
33
33
 
34
34
  Upgrading from the old unscoped package `dsh-quarto-book` (removed from npm with 0.3.4, so this only affects profiles that installed it earlier): remove it from the same profile first with `dsh plugin --profile <profile> remove dsh-quarto-book`, then add the scoped package. Keeping both installed registers `writing-quarto` and `designing-theme` twice at the same rank 550.
35
35
 
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  [English](https://github.com/chengzhao-dev/dsh-quarto-book/blob/main/README.en.md) | **简体中文**
7
7
 
8
- 在 Quarto Book 仓库里写中文技术章节:`writing-quarto` 管章节骨架与行文,`designing-theme` 管主题样式。两个技能只在 `type: book` 的仓库里出现。渲染后可以用 `quarto_render_check` 判断 HTML 是否可发布,用 `quarto_pass_report` 出一张收尾回执。
8
+ 把一份 Quarto Book 从章节写到可发布:`writing-quarto` 负责章节骨架、整书次序与中文行文,`designing-theme` 负责主题;渲染后由 `quarto_render_check` 判断 HTML 是否可发布,`quarto_pass_report` 给出收尾回执。两个技能只在 `type: book` 的仓库里出现,运行需要 Quarto CLI 与 Python 3。
9
9
 
10
10
  ## 提供什么
11
11
 
@@ -16,7 +16,7 @@
16
16
  | `quarto_render_check` | 先判断 `output-dir` 是否安全,再按需渲染一次,检查 `_book/` 里的 HTML 是否可发布 | 不改 `.qmd`,不自动修配置,不重复渲染 |
17
17
  | `quarto_pass_report` | 收尾时把执行结论压成固定行数的回执卡片:技能、模式、改过的文件与步骤、未改项、检查结论 | 不保存到磁盘,不贴章节原文,不据回执改文章 |
18
18
 
19
- 两个技能从包内 `skills/` 目录发现,按需加载;技能里的相对路径以技能目录为基准:`writing-quarto` 用 `steps/`、`examples/`、`quarto/`、`scripts/`,`designing-theme` 用 `references/`、`knowledge/`、`assets/`、`scripts/`。
19
+ 两个技能从包内 `skills/` 目录发现,按需加载;技能里的相对路径以技能目录为基准:`writing-quarto` 用 `steps/`、`examples/`、`quarto/`、`references/`、`scripts/`,`designing-theme` 用 `references/`、`knowledge/`、`assets/`、`scripts/`。
20
20
 
21
21
  ## 快速开始
22
22
 
@@ -29,7 +29,7 @@
29
29
  2. 在书仓库里打开会话。`_quarto.yml` 要有独立一行的 `type: book`,书根至少有一个 `.qmd` 或 `.md` 章节源文件;两个技能名这时才会出现。
30
30
  3. 调用技能:写正文用 `writing-quarto`,改主题用 `designing-theme`。
31
31
 
32
- `--profile` 换成你实际运行的那个 profile。桌面应用固定跑 `desktop` profile,且该 profile 由应用独占管理,`dsh --profile desktop` 直接启动会被拒绝;安装或升级用 `dsh plugin --profile desktop add <包>`,应用拒绝写入时再走「设置 → 插件 → 添加插件」。需要固定版本时写 `npm:@chengzhao-dev/dsh-quarto-book@0.6.3`,跟 GitHub 标签时写 `github:chengzhao-dev/dsh-quarto-book#v0.6.3`。
32
+ `--profile` 换成你实际运行的那个 profile。桌面应用固定跑 `desktop` profile,且该 profile 由应用独占管理,`dsh --profile desktop` 直接启动会被拒绝;安装或升级用 `dsh plugin --profile desktop add <包>`,应用拒绝写入时再走「设置 → 插件 → 添加插件」。需要固定版本时写 `npm:@chengzhao-dev/dsh-quarto-book@0.6.4`,跟 GitHub 标签时写 `github:chengzhao-dev/dsh-quarto-book#v0.6.4`。
33
33
 
34
34
  从旧的无作用域包 `dsh-quarto-book` 升级(该包已随 0.3.4 从 npm 删除,只影响之前装过它的 profile):先在同一个 profile 里 `dsh plugin --profile <profile> remove dsh-quarto-book`,再装新包。两个包同时留着会以相同的 rank 550 各注册一遍 `writing-quarto` 和 `designing-theme`。
35
35
 
package/lib/index.js CHANGED
@@ -24,15 +24,17 @@ const RANK = 550;
24
24
  const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
25
25
  const PACKAGE_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
26
26
  const SKILLS_ROOT = path.join(PACKAGE_ROOT, 'skills');
27
- /** 读取 SKILL.md 实际用到的几个键,只做最简单的 YAML frontmatter 解析。 */
27
+ /** 读取 SKILL.md 实际用到的几个键,只做最小的 YAML frontmatter 解析。 */
28
28
  function parseFrontmatter(source) {
29
- const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(source);
29
+ const normalized = source.replace(/^\uFEFF/, '');
30
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(normalized);
30
31
  if (!match)
31
- return { data: {}, body: source };
32
+ return { data: {}, body: normalized };
32
33
  const data = {};
33
34
  const stack = [{ indent: -1, target: data }];
34
- for (const rawLine of match[1].split(/\r?\n/)) {
35
- const line = rawLine.replace(/\t/g, ' ');
35
+ const lines = match[1].split(/\r?\n/);
36
+ for (let index = 0; index < lines.length; index += 1) {
37
+ const line = lines[index].replace(/\t/g, ' ');
36
38
  if (!line.trim() || line.trimStart().startsWith('#'))
37
39
  continue;
38
40
  const indent = line.length - line.trimStart().length;
@@ -43,16 +45,56 @@ function parseFrontmatter(source) {
43
45
  stack.pop();
44
46
  const parent = stack[stack.length - 1].target;
45
47
  const key = pair[1];
46
- const value = (pair[2] ?? '').trim();
48
+ const value = stripInlineComment(pair[2] ?? '').trim();
47
49
  if (value === '') {
48
50
  const child = {};
49
51
  parent[key] = child;
50
52
  stack.push({ indent, target: child });
51
53
  continue;
52
54
  }
55
+ if (/^[>|][+-]?\d?$/.test(value)) {
56
+ const block = [];
57
+ let cursor = index + 1;
58
+ for (; cursor < lines.length; cursor += 1) {
59
+ const next = lines[cursor].replace(/\t/g, ' ');
60
+ if (!next.trim()) {
61
+ block.push('');
62
+ continue;
63
+ }
64
+ const nextIndent = next.length - next.trimStart().length;
65
+ if (nextIndent <= indent)
66
+ break;
67
+ block.push(next);
68
+ }
69
+ const nonBlank = block.filter((item) => item.trim());
70
+ const cut = nonBlank.length > 0 ? Math.min(...nonBlank.map((item) => item.length - item.trimStart().length)) : 0;
71
+ const text = block.map((item) => item.slice(cut)).join('\n');
72
+ parent[key] = value[0] === '|' ? text : text.replace(/([^\n])\n(?=[^\n])/g, '$1 ').replace(/\n{2,}/g, '\n');
73
+ index = cursor - 1;
74
+ continue;
75
+ }
53
76
  parent[key] = parseScalar(value);
54
77
  }
55
- return { data, body: source.slice(match[0].length) };
78
+ return { data, body: normalized.slice(match[0].length) };
79
+ }
80
+ /** 去掉 plain scalar 末尾的 ` # 注释`;引号内的 # 保留。 */
81
+ function stripInlineComment(value) {
82
+ let quote;
83
+ for (let index = 0; index < value.length; index += 1) {
84
+ const char = value[index];
85
+ if (quote) {
86
+ if (char === quote && value[index - 1] !== '\\')
87
+ quote = undefined;
88
+ continue;
89
+ }
90
+ if (char === '"' || char === "'") {
91
+ quote = char;
92
+ continue;
93
+ }
94
+ if (char === '#' && (index === 0 || /\s/.test(value[index - 1])))
95
+ return value.slice(0, index);
96
+ }
97
+ return value;
56
98
  }
57
99
  function parseScalar(value) {
58
100
  if ((value.startsWith('"') && value.endsWith('"')) ||
@@ -74,6 +74,7 @@ function normalizeFiles(value) {
74
74
  fail('files 必须是数组');
75
75
  if (value.length > MAX_FILES)
76
76
  fail(`files 最多 ${MAX_FILES} 个路径`);
77
+ const seen = new Set();
77
78
  return value.map((item) => {
78
79
  if (!isRecord(item))
79
80
  fail('files 的每一项必须是 { path, edits }');
@@ -85,7 +86,11 @@ function normalizeFiles(value) {
85
86
  fail('files 的 path 必须是非空字符串');
86
87
  if (!isPlainCount(item.edits))
87
88
  fail('files 的 edits 必须是非负整数');
88
- return { path: filePath.trim(), edits: item.edits };
89
+ const normalizedPath = filePath.trim();
90
+ if (seen.has(normalizedPath))
91
+ fail(`files 的 path 重复:${normalizedPath}`);
92
+ seen.add(normalizedPath);
93
+ return { path: normalizedPath, edits: item.edits };
89
94
  });
90
95
  }
91
96
  function normalizeSteps(value, skill) {
@@ -194,17 +199,12 @@ export function buildPassReport(input) {
194
199
  fail('未执行时必须给出 skip_reason');
195
200
  if ([...reason.trim()].length > MAX_SKIP_REASON)
196
201
  fail(`skip_reason 最多 ${MAX_SKIP_REASON} 个字`);
197
- if (input.files !== undefined && Array.isArray(input.files) && input.files.length > 0) {
198
- fail('未执行时 files 必须为空');
199
- }
200
- if (input.steps !== undefined && Array.isArray(input.steps) && input.steps.length > 0) {
201
- fail('未执行时 steps 必须为空');
202
- }
203
- if (input.held !== undefined && Array.isArray(input.held) && input.held.length > 0) {
204
- fail('未执行时 held 必须为空');
205
- }
206
- if (input.checks !== undefined && Array.isArray(input.checks) && input.checks.length > 0) {
207
- fail('未执行时 checks 必须为空');
202
+ for (const key of ['files', 'steps', 'held', 'checks']) {
203
+ const value = input[key];
204
+ if (value === undefined)
205
+ continue;
206
+ if (!Array.isArray(value) || value.length > 0)
207
+ fail(`未执行时 ${key} 必须为空数组`);
208
208
  }
209
209
  return { receipt: `未执行 ${typedSkill}:${reason.trim()}` };
210
210
  }
@@ -1,7 +1,7 @@
1
1
  // `quarto_render_check`:先判断渲染输出目录是否安全,再在 120 秒内跑一次
2
2
  // `quarto render`,最后检查产出的 HTML 是否可发布。失败只报告,不自动重新运行。
3
3
  import { spawn as nodeSpawn } from 'node:child_process';
4
- import { readFile, readdir } from 'node:fs/promises';
4
+ import { readFile, readdir, realpath, stat } from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { BOOK_PROJECT_RE, findBookRoot, hasBookSource } from '../workspace.js';
@@ -12,6 +12,9 @@ const QUARTO_TIMEOUT_MS = 120_000;
12
12
  const PYTHON_TIMEOUT_MS = 60_000;
13
13
  const MAX_OUTPUT_FILES = 30;
14
14
  const MAX_CAPTURED_OUTPUT = 12_000;
15
+ const MAX_HTML_FILES = 1_000;
16
+ const MAX_HTML_BYTES = 8 * 1024 * 1024;
17
+ const MAX_HTML_TOTAL_BYTES = 64 * 1024 * 1024;
15
18
  // 只收多字乱码序列。单字(如「璇」「浣」「熨」)在正常中文里也会出现,
16
19
  // 拿它们当特征会把合法页面判成乱码。
17
20
  const MOJIBAKE_MARKERS = ['锟斤拷', '烫烫烫', '屯屯屯', 'â€', 'ä', 'é', 'ï¼', '锘匡'];
@@ -26,6 +29,18 @@ export function stripQuotes(value) {
26
29
  }
27
30
  return trimmed;
28
31
  }
32
+ /** 解析 YAML 单行标量:去掉成对引号,再剥掉未引用值末尾的 ` # 注释`。 */
33
+ function parseConfigScalar(raw) {
34
+ const trimmed = raw.trim();
35
+ const quote = trimmed[0];
36
+ if (quote === '"' || quote === "'") {
37
+ const end = trimmed.lastIndexOf(quote);
38
+ if (end > 0)
39
+ return trimmed.slice(1, end);
40
+ }
41
+ const comment = /\s+#/.exec(trimmed);
42
+ return (comment ? trimmed.slice(0, comment.index) : trimmed).trimEnd();
43
+ }
29
44
  function configValue(yaml, key) {
30
45
  const pattern = new RegExp(`^[\\t ]*${key}:[\\t ]*(.*)$`);
31
46
  for (const line of yaml.split(/\r?\n/)) {
@@ -33,13 +48,14 @@ function configValue(yaml, key) {
33
48
  continue;
34
49
  const match = pattern.exec(line);
35
50
  if (match)
36
- return stripQuotes(match[1]);
51
+ return parseConfigScalar(match[1]);
37
52
  }
38
53
  return undefined;
39
54
  }
40
- function hasTruthySwitch(yaml, key) {
41
- const value = configValue(yaml, key);
42
- return value === 'true';
55
+ /** Quarto 的搜索默认开启;只有显式关掉(false/no/off)才跳过索引检查。 */
56
+ function searchEnabled(yaml) {
57
+ const value = configValue(yaml, 'search');
58
+ return value === undefined || !/^(?:false|no|off)$/i.test(value);
43
59
  }
44
60
  /**
45
61
  * `project.output-dir` 只能落在书根内部,且不能就是书根自身。
@@ -75,17 +91,84 @@ export function evaluateOutputDir(root, yaml) {
75
91
  }
76
92
  return { safe: true, value, resolved };
77
93
  }
78
- function runProcess(command, args, cwd, timeoutMs, spawnFn) {
94
+ async function nearestExistingRealPath(target) {
95
+ let current = path.resolve(target);
96
+ while (true) {
97
+ try {
98
+ return await realpath(current);
99
+ }
100
+ catch (error) {
101
+ const code = error.code;
102
+ if (code !== 'ENOENT' && code !== 'ENOTDIR')
103
+ return undefined;
104
+ const parent = path.dirname(current);
105
+ if (parent === current)
106
+ return undefined;
107
+ current = parent;
108
+ }
109
+ }
110
+ }
111
+ /** 输出目录或其最近的现存父目录经符号链接解析后仍须落在书根内。 */
112
+ async function outputDirEscapesRoot(root, resolved) {
113
+ const realRoot = await nearestExistingRealPath(root);
114
+ const realTarget = await nearestExistingRealPath(resolved);
115
+ if (!realRoot || !realTarget)
116
+ return true;
117
+ const relative = path.relative(realRoot, realTarget);
118
+ return relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
119
+ }
120
+ function killProcessTree(child, cwd, spawnFn) {
121
+ const pid = child.pid;
122
+ if (pid && process.platform === 'win32') {
123
+ // Windows 上只杀直接子进程会留下 Quarto 派生的 pandoc/python。
124
+ try {
125
+ spawnFn('taskkill', ['/pid', String(pid), '/T', '/F'], {
126
+ cwd,
127
+ windowsHide: true,
128
+ shell: false,
129
+ });
130
+ }
131
+ catch {
132
+ // taskkill 不可用时退回 child.kill。
133
+ }
134
+ }
135
+ else if (pid && process.platform !== 'win32') {
136
+ // runProcess 用 detached 启动,负 pid 表示整个进程组。
137
+ try {
138
+ process.kill(-pid, 'SIGKILL');
139
+ return;
140
+ }
141
+ catch {
142
+ // 进程组可能已退出,退回 child.kill。
143
+ }
144
+ }
145
+ try {
146
+ child.kill();
147
+ }
148
+ catch {
149
+ // 进程可能已经退出。
150
+ }
151
+ }
152
+ function runProcess(command, args, cwd, timeoutMs, spawnFn, signal) {
79
153
  return new Promise((resolve) => {
154
+ if (signal?.aborted) {
155
+ resolve({ ok: false, missing: false, code: null, output: '已取消。', duration_ms: 0 });
156
+ return;
157
+ }
80
158
  const started = Date.now();
81
159
  let settled = false;
82
160
  let output = '';
83
161
  let child;
162
+ const onAbort = () => {
163
+ killProcessTree(child, cwd, spawnFn);
164
+ finish({ ok: false, missing: false, code: null, output: '已取消。', duration_ms: Date.now() - started });
165
+ };
84
166
  const finish = (result) => {
85
167
  if (settled)
86
168
  return;
87
169
  settled = true;
88
170
  clearTimeout(timer);
171
+ signal?.removeEventListener('abort', onAbort);
89
172
  resolve(result);
90
173
  };
91
174
  const append = (chunk) => {
@@ -95,19 +178,21 @@ function runProcess(command, args, cwd, timeoutMs, spawnFn) {
95
178
  };
96
179
  let timer;
97
180
  try {
98
- child = spawnFn(command, args, { cwd, windowsHide: true, shell: false });
181
+ child = spawnFn(command, args, {
182
+ cwd,
183
+ windowsHide: true,
184
+ shell: false,
185
+ signal,
186
+ detached: process.platform !== 'win32',
187
+ });
99
188
  }
100
189
  catch (error) {
101
190
  resolve({ ok: false, missing: true, code: null, output: error instanceof Error ? error.message : String(error), duration_ms: Date.now() - started });
102
191
  return;
103
192
  }
193
+ signal?.addEventListener('abort', onAbort, { once: true });
104
194
  timer = setTimeout(() => {
105
- try {
106
- child.kill();
107
- }
108
- catch {
109
- // 进程可能已经退出。
110
- }
195
+ killProcessTree(child, cwd, spawnFn);
111
196
  finish({
112
197
  ok: false,
113
198
  missing: false,
@@ -127,16 +212,31 @@ function runProcess(command, args, cwd, timeoutMs, spawnFn) {
127
212
  });
128
213
  });
129
214
  }
130
- async function runPython(script, args, root, explicitPython, spawnFn) {
215
+ async function runPython(script, args, root, explicitPython, spawnFn, signal) {
131
216
  const candidates = [explicitPython, process.env.PYTHON, 'python3', 'python'].filter((item) => typeof item === 'string' && item.trim() !== '');
132
217
  let last = { ok: false, missing: true, code: null, output: `找不到 Python 解释器:${script}`, duration_ms: 0 };
218
+ let executedFailure;
133
219
  for (const candidate of [...new Set(candidates)]) {
134
- const result = await runProcess(candidate, [script, ...args], root, PYTHON_TIMEOUT_MS, spawnFn);
135
- if (!result.missing)
220
+ const result = await runProcess(candidate, [script, ...args], root, PYTHON_TIMEOUT_MS, spawnFn, signal);
221
+ if (result.ok)
136
222
  return result;
137
223
  last = result;
224
+ // 坏解释器(例如 Windows Store 占位符)会启动成功但退出非 0;
225
+ // 先记住它的输出,再继续试下一个候选,避免只调用一个坏解释器就判书失败。
226
+ if (!result.missing && result.code !== null)
227
+ executedFailure = result;
138
228
  }
139
- return last;
229
+ return executedFailure ?? last;
230
+ }
231
+ function pythonEnvironmentFailure(result) {
232
+ if (result.missing || result.code === null)
233
+ return true;
234
+ const text = result.output.toLowerCase();
235
+ return (result.code === 9009 ||
236
+ text.includes('enoent') ||
237
+ text.includes('不是内部或外部命令') ||
238
+ text.includes('command not found') ||
239
+ text.includes('no such file'));
140
240
  }
141
241
  function truncate(value, max = 180) {
142
242
  const collapsed = value.replace(/\s+/g, ' ').trim();
@@ -175,6 +275,9 @@ function relativeTo(root, file) {
175
275
  }
176
276
  async function readUtf8(file) {
177
277
  try {
278
+ const info = await stat(file);
279
+ if (info.size > MAX_HTML_BYTES)
280
+ return undefined;
178
281
  const bytes = await readFile(file);
179
282
  if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf)
180
283
  return undefined;
@@ -218,20 +321,61 @@ export async function inspectHtml(input) {
218
321
  : `${relativeTo(input.root, index)} 已生成`,
219
322
  });
220
323
  const broken = [];
221
- for (const file of htmlFiles) {
324
+ const fileUris = [];
325
+ let scaffoldFound = false;
326
+ let scannedBytes = 0;
327
+ let sizeLimited = false;
328
+ const scanFiles = htmlFiles.slice(0, MAX_HTML_FILES);
329
+ for (const file of scanFiles) {
330
+ const relative = relativeTo(input.outputDir, file);
331
+ let size = 0;
332
+ try {
333
+ size = (await stat(file)).size;
334
+ }
335
+ catch {
336
+ broken.push(`${relative} 无法读取`);
337
+ errors.push(`${relative} 无法读取`);
338
+ continue;
339
+ }
340
+ if (size > MAX_HTML_BYTES) {
341
+ broken.push(`${relative} 超过 ${Math.round(MAX_HTML_BYTES / 1024 / 1024)} MiB,未读`);
342
+ errors.push(`${relative} 太大,未读`);
343
+ continue;
344
+ }
345
+ if (scannedBytes + size > MAX_HTML_TOTAL_BYTES) {
346
+ sizeLimited = true;
347
+ errors.push(`HTML 总量超过 ${Math.round(MAX_HTML_TOTAL_BYTES / 1024 / 1024)} MiB,检查提前结束`);
348
+ break;
349
+ }
350
+ scannedBytes += size;
222
351
  const text = await readUtf8(file);
223
352
  if (text === undefined) {
224
- broken.push(`${relativeTo(input.outputDir, file)} 不是 UTF-8 或带 BOM`);
353
+ broken.push(`${relative} 不是 UTF-8 或带 BOM`);
354
+ errors.push(`${relative} 读取失败或不是 UTF-8`);
225
355
  continue;
226
356
  }
227
357
  if (text.includes('\ufffd')) {
228
- broken.push(`${relativeTo(input.outputDir, file)} 含 U+FFFD 替换符`);
229
- continue;
358
+ broken.push(`${relative} 含 U+FFFD 替换符`);
359
+ }
360
+ else {
361
+ const marker = MOJIBAKE_MARKERS.find((item) => text.includes(item));
362
+ if (marker)
363
+ broken.push(`${relative} 命中乱码特征「${marker}」`);
230
364
  }
231
- const marker = MOJIBAKE_MARKERS.find((item) => text.includes(item));
232
- if (marker)
233
- broken.push(`${relativeTo(input.outputDir, file)} 命中乱码特征「${marker}」`);
365
+ if (/(?:href|src)\s*=\s*["'][^"']*file:\/\/\//i.test(text))
366
+ fileUris.push(relative);
367
+ if (!scaffoldFound && containsScaffold(text))
368
+ scaffoldFound = true;
234
369
  }
370
+ checks.push({
371
+ id: 'html_files',
372
+ status: htmlFiles.length > MAX_HTML_FILES || sizeLimited ? 'fail' : 'pass',
373
+ detail: htmlFiles.length > MAX_HTML_FILES
374
+ ? `HTML 文件 ${htmlFiles.length} 个,超过上限 ${MAX_HTML_FILES}`
375
+ : sizeLimited
376
+ ? `HTML 总量超过 ${Math.round(MAX_HTML_TOTAL_BYTES / 1024 / 1024)} MiB`
377
+ : `${htmlFiles.length} 个 HTML 在检查上限内`,
378
+ });
235
379
  checks.push({
236
380
  id: 'utf8',
237
381
  status: htmlFiles.length === 0 ? 'skip' : broken.length === 0 ? 'pass' : 'fail',
@@ -251,12 +395,6 @@ export async function inspectHtml(input) {
251
395
  ? '首页声明 charset=utf-8'
252
396
  : '首页缺少 charset=utf-8 声明',
253
397
  });
254
- const fileUris = [];
255
- for (const file of htmlFiles) {
256
- const text = await readUtf8(file);
257
- if (text?.includes('file:///'))
258
- fileUris.push(relativeTo(input.outputDir, file));
259
- }
260
398
  checks.push({
261
399
  id: 'no_file_uri',
262
400
  status: htmlFiles.length === 0 ? 'skip' : fileUris.length === 0 ? 'pass' : 'fail',
@@ -283,8 +421,8 @@ export async function inspectHtml(input) {
283
421
  : 'sitemap.xml 没有 <loc> 条目',
284
422
  });
285
423
  }
286
- if (!hasTruthySwitch(input.yaml, 'search')) {
287
- checks.push({ id: 'search_index', status: 'skip', detail: '书配置未打开搜索,不要求 search.json' });
424
+ if (!searchEnabled(input.yaml)) {
425
+ checks.push({ id: 'search_index', status: 'skip', detail: '书配置显式关闭搜索,不要求 search.json' });
288
426
  }
289
427
  else {
290
428
  const searchIndex = path.join(input.outputDir, 'search.json');
@@ -312,41 +450,56 @@ export async function inspectHtml(input) {
312
450
  });
313
451
  }
314
452
  const calloutsScript = path.join(SKILL_DIR, 'scripts', 'checking-callouts.py');
315
- const callouts = await runPython(calloutsScript, ['--root', input.root, '--book-dir', input.outputDir], input.root, input.python, spawnFn);
453
+ const callouts = await runPython(calloutsScript, ['--root', input.root, '--book-dir', input.outputDir], input.root, input.python, spawnFn, input.signal);
316
454
  checks.push({
317
455
  id: 'callouts',
318
- status: callouts.ok ? 'pass' : 'fail',
319
- detail: callouts.ok ? lastMeaningfulLine(callouts.output) : `checking-callouts.py:${lastMeaningfulLine(callouts.output)}`,
456
+ status: pythonEnvironmentFailure(callouts) ? 'skip' : callouts.ok ? 'pass' : 'fail',
457
+ detail: pythonEnvironmentFailure(callouts)
458
+ ? `未运行 checking-callouts.py:${lastMeaningfulLine(callouts.output)}`
459
+ : callouts.ok
460
+ ? lastMeaningfulLine(callouts.output)
461
+ : `checking-callouts.py:${lastMeaningfulLine(callouts.output)}`,
320
462
  });
321
- const allText = [];
322
- for (const file of htmlFiles) {
323
- const text = await readUtf8(file);
324
- if (text)
325
- allText.push(text);
326
- }
327
- for (const name of await listCssFiles(input.outputDir)) {
328
- const text = await readUtf8(name);
329
- if (text)
330
- allText.push(text);
463
+ if (pythonEnvironmentFailure(callouts)) {
464
+ errors.push(`checking-callouts.py 未运行:${lastMeaningfulLine(callouts.output)}`);
465
+ }
466
+ if (!scaffoldFound) {
467
+ for (const name of await listCssFiles(input.outputDir)) {
468
+ const text = await readUtf8(name);
469
+ if (text && containsScaffold(text)) {
470
+ scaffoldFound = true;
471
+ break;
472
+ }
473
+ }
331
474
  }
332
- if (!allText.some(containsScaffold)) {
475
+ if (!scaffoldFound) {
333
476
  checks.push({ id: 'dom', status: 'skip', detail: '产物没有主题脚手架类,跳过 DOM 契约' });
334
477
  }
335
478
  else {
336
479
  const domScript = path.join(SKILL_DIR, 'scripts', 'checking-dom.py');
337
- const dom = await runPython(domScript, ['--root', input.root, '--book-dir', input.outputDir], input.root, input.python, spawnFn);
338
- // C9 只在产物里出现 details.answer-disclosure 时判 fail,没有答案时记 N/A。
339
- const selected = parseDomLines(dom.output).filter((item) => ['C1', 'C2', 'C3', 'C5', 'C6', 'C7', 'C8', 'C9'].includes(item.id));
480
+ const dom = await runPython(domScript, ['--root', input.root, '--book-dir', input.outputDir], input.root, input.python, spawnFn, input.signal);
481
+ // C1/C2 依赖主题 CSS,C6/C7/C8 依赖主题资产;没有 theme/ 的书只跑通用契约。
482
+ const hasTheme = await isDirectory(path.join(input.root, 'theme'));
483
+ const universal = ['C3', 'C5', 'C9'];
484
+ const themeOwned = ['C1', 'C2', 'C6', 'C7', 'C8'];
485
+ const selected = parseDomLines(dom.output).filter((item) => universal.includes(item.id) || (hasTheme && themeOwned.includes(item.id)));
340
486
  const failed = selected.filter((item) => item.status === 'fail');
487
+ const domUnavailable = pythonEnvironmentFailure(dom);
341
488
  checks.push({
342
489
  id: 'dom',
343
- status: selected.length === 0 ? 'fail' : failed.length === 0 ? 'pass' : 'fail',
344
- detail: selected.length === 0
345
- ? `checking-dom.py 没有返回可解析的契约行:${lastMeaningfulLine(dom.output)}`
346
- : failed.length === 0
347
- ? `${selected.length} 项 DOM 契约通过`
348
- : `${failed.map((item) => `${item.id} ${item.detail}`).join(';')}`,
490
+ status: domUnavailable ? 'skip' : selected.length === 0 ? 'fail' : failed.length === 0 ? 'pass' : 'fail',
491
+ detail: domUnavailable
492
+ ? `未运行 checking-dom.py:${lastMeaningfulLine(dom.output)}`
493
+ : selected.length === 0
494
+ ? `checking-dom.py 没有返回可解析的契约行:${lastMeaningfulLine(dom.output)}`
495
+ : failed.length === 0
496
+ ? hasTheme
497
+ ? `${selected.length} 项 DOM 契约通过`
498
+ : `${selected.length} 项通用 DOM 契约通过(无 theme/,跳过主题契约)`
499
+ : `${failed.map((item) => `${item.id} ${item.detail}`).join(';')}`,
349
500
  });
501
+ if (domUnavailable)
502
+ errors.push(`checking-dom.py 未运行:${lastMeaningfulLine(dom.output)}`);
350
503
  }
351
504
  const usable = checks.every((check) => check.status !== 'fail');
352
505
  return {
@@ -359,6 +512,14 @@ export async function inspectHtml(input) {
359
512
  errors,
360
513
  };
361
514
  }
515
+ async function isDirectory(dir) {
516
+ try {
517
+ return (await stat(dir)).isDirectory();
518
+ }
519
+ catch {
520
+ return false;
521
+ }
522
+ }
362
523
  async function listCssFiles(dir) {
363
524
  const files = [];
364
525
  async function walk(current) {
@@ -384,7 +545,8 @@ function renderErrorText(result) {
384
545
  const tail = result.output.trim();
385
546
  if (!tail)
386
547
  return `quarto render 退出码 ${result.exit_code ?? '未知'},没有输出。`;
387
- return truncate(tail, 500);
548
+ // 只保留输出末尾;truncate 从头截断,不能直接拿整段输出调用。
549
+ return truncate(tail.slice(-500), 500);
388
550
  }
389
551
  export async function runRenderCheck(input) {
390
552
  const root = path.resolve(input.root);
@@ -416,7 +578,7 @@ export async function runRenderCheck(input) {
416
578
  }
417
579
  const qmdPath = path.resolve(root, input.qmdPath);
418
580
  try {
419
- await readFile(qmdPath);
581
+ await stat(qmdPath);
420
582
  }
421
583
  catch {
422
584
  warnings.push(`检查入口 ${input.qmdPath} 不存在,仍按书根执行渲染检查。`);
@@ -426,10 +588,14 @@ export async function runRenderCheck(input) {
426
588
  warnings.push(output.warning ?? '渲染输出目录不安全。');
427
589
  return result;
428
590
  }
591
+ if (await outputDirEscapesRoot(root, output.resolved)) {
592
+ warnings.push(`project.output-dir \`${output.value}\` 经符号链接解析后落在书根之外;渲染会写到书外。改回书根内的普通目录后再查。`);
593
+ return result;
594
+ }
429
595
  result.safe_to_render = true;
430
596
  if (input.dryRun)
431
597
  return result;
432
- const renderResult = await runProcess('quarto', ['render'], root, input.timeoutMs ?? QUARTO_TIMEOUT_MS, input.spawn ?? nodeSpawn);
598
+ const renderResult = await runProcess('quarto', ['render'], root, input.timeoutMs ?? QUARTO_TIMEOUT_MS, input.spawn ?? nodeSpawn, input.signal);
433
599
  result.render_result = {
434
600
  ok: renderResult.ok,
435
601
  exit_code: renderResult.code,
@@ -454,8 +620,10 @@ export async function runRenderCheck(input) {
454
620
  yaml,
455
621
  spawn: input.spawn,
456
622
  python: input.python,
623
+ signal: input.signal,
457
624
  });
458
625
  result.html_report = inspected.report;
626
+ errors.push(...inspected.errors);
459
627
  return result;
460
628
  }
461
629
  function reportLine(check) {
@@ -469,7 +637,8 @@ function cardLines(value) {
469
637
  lines.push(...value.errors);
470
638
  if (value.html_report) {
471
639
  lines.push(...value.html_report.checks.map(reportLine));
472
- lines.push(`首页:${value.html_report.output_files.find((file) => file === 'index.html') ?? '未生成'}`);
640
+ const indexCheck = value.html_report.checks.find((check) => check.id === 'index_html');
641
+ lines.push(`首页:${indexCheck?.status === 'pass' ? '已生成' : '未生成'}`);
473
642
  }
474
643
  return lines;
475
644
  }
@@ -481,7 +650,7 @@ function renderSummary(value) {
481
650
  return `可渲染:输出目录安全,dry_run 未执行 quarto render。建议输出目录 ${value.suggested_output_dir}。`;
482
651
  }
483
652
  if (!value.render_result.ok) {
484
- return `渲染失败:${value.errors[0] ?? renderErrorText(value.render_result)}`;
653
+ return value.errors[0] ?? renderErrorText(value.render_result);
485
654
  }
486
655
  if (!value.html_report) {
487
656
  return '渲染完成,但没有生成 HTML 报告。';
@@ -590,7 +759,7 @@ export function registerRenderCheckTool(tools) {
590
759
  if (!root) {
591
760
  return refusedResult('当前会话不在 Quarto Book 项目内:需要 git checkout、_quarto.yml 的 type: book,以及至少一个 .qmd 或 .md 源文件。');
592
761
  }
593
- return runRenderCheck({ root, qmdPath: path.resolve(cwd, args.qmd_path), dryRun: args.dry_run });
762
+ return runRenderCheck({ root, qmdPath: path.resolve(cwd, args.qmd_path), dryRun: args.dry_run, signal: exec?.signal });
594
763
  },
595
764
  presentCall(args) {
596
765
  return {