@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.
- package/README.en.md +3 -3
- package/README.md +3 -3
- package/lib/index.js +49 -7
- package/lib/tools/pass-report.js +12 -12
- package/lib/tools/render-check.js +230 -61
- package/lib/workspace.js +1 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +9 -6
- package/skills/designing-theme/assets/theme/css/content.css +15 -0
- package/skills/designing-theme/scripts/checking-layout.py +11 -3
- package/skills/designing-theme/scripts/measuring-pages.mjs +2 -2
- package/skills/writing-quarto/SKILL.md +7 -0
- package/skills/writing-quarto/examples/03-qmd-elements.md +29 -2
- package/skills/writing-quarto/quarto/authoring.md +3 -2
- package/skills/writing-quarto/quarto/basics.md +1 -0
- package/skills/writing-quarto/quarto/coords.md +2 -1
- package/skills/writing-quarto/quarto/figures.md +49 -0
- package/skills/writing-quarto/quarto/mermaid.md +1 -1
- package/skills/writing-quarto/quarto/refactor.md +1 -1
- package/skills/writing-quarto/references/book-journey.md +38 -0
- package/skills/writing-quarto/references/token-budget.md +26 -0
- package/skills/writing-quarto/scripts/checking-callouts.py +2 -2
- package/skills/writing-quarto/scripts/checking-dom.py +12 -6
- package/skills/writing-quarto/scripts/checking-links.py +29 -13
- package/skills/writing-quarto/scripts/checking-names.py +3 -1
- package/skills/writing-quarto/scripts/checking-output.py +3 -3
- package/skills/writing-quarto/scripts/checking-prose.py +11 -7
- package/skills/writing-quarto/scripts/testing-content.py +68 -1
- package/skills/writing-quarto/scripts/verifying-content.py +166 -36
- package/skills/writing-quarto/steps/03-blocks.md +4 -3
- 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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
27
|
+
/** 读取 SKILL.md 实际用到的几个键,只做最小的 YAML frontmatter 解析。 */
|
|
28
28
|
function parseFrontmatter(source) {
|
|
29
|
-
const
|
|
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:
|
|
32
|
+
return { data: {}, body: normalized };
|
|
32
33
|
const data = {};
|
|
33
34
|
const stack = [{ indent: -1, target: data }];
|
|
34
|
-
|
|
35
|
-
|
|
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:
|
|
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('"')) ||
|
package/lib/tools/pass-report.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
51
|
+
return parseConfigScalar(match[1]);
|
|
37
52
|
}
|
|
38
53
|
return undefined;
|
|
39
54
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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, {
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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(`${
|
|
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(`${
|
|
229
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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 (!
|
|
287
|
-
checks.push({ id: 'search_index', status: 'skip', detail: '
|
|
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:
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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 (!
|
|
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
|
-
//
|
|
339
|
-
const
|
|
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:
|
|
345
|
-
?
|
|
346
|
-
:
|
|
347
|
-
?
|
|
348
|
-
:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|