@chengzhao-dev/dsh-quarto-book 0.6.2 → 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 +60 -31
- package/README.md +53 -24
- package/lib/index.js +49 -7
- package/lib/tools/pass-report.js +12 -12
- package/lib/tools/render-check.js +231 -62
- package/lib/workspace.js +1 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +13 -7
- package/skills/designing-theme/assets/theme/assets/fonts/lxgw-bright-code-common.woff2 +0 -0
- package/skills/designing-theme/assets/theme/assets/fonts/lxgw-bright-medium-common.woff2 +0 -0
- package/skills/designing-theme/assets/theme/css/content.css +15 -0
- package/skills/designing-theme/assets/theme/css/mermaid.css +5 -6
- package/skills/designing-theme/knowledge/reading-surface.md +1 -1
- package/skills/designing-theme/references/theme-system.md +5 -5
- package/skills/designing-theme/scripts/checking-layout.py +60 -12
- package/skills/designing-theme/scripts/measuring-pages.mjs +2 -2
- package/skills/writing-quarto/SKILL.md +7 -0
- package/skills/writing-quarto/examples/02-structure-shape.md +26 -12
- package/skills/writing-quarto/examples/03-block-after.md +14 -0
- package/skills/writing-quarto/examples/03-qmd-elements.md +45 -2
- package/skills/writing-quarto/examples/04-paragraph-cases.md +14 -0
- package/skills/writing-quarto/examples/04-paragraph-opening.md +2 -2
- package/skills/writing-quarto/examples/05-sentence-frame.md +4 -4
- package/skills/writing-quarto/examples/06-wording-cases.md +23 -0
- package/skills/writing-quarto/examples/07-layout-list.md +3 -3
- package/skills/writing-quarto/examples/08-appendix.md +1 -1
- package/skills/writing-quarto/quarto/authoring.md +7 -6
- package/skills/writing-quarto/quarto/basics.md +17 -16
- 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 +41 -5
- package/skills/writing-quarto/quarto/prose.md +4 -4
- package/skills/writing-quarto/quarto/refactor.md +1 -1
- package/skills/writing-quarto/quarto/terminal.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 +264 -1
- package/skills/writing-quarto/scripts/verifying-content.py +442 -36
- package/skills/writing-quarto/steps/03-blocks.md +20 -8
- package/skills/writing-quarto/steps/04-paragraphs.md +19 -2
- package/skills/writing-quarto/steps/06-wording.md +53 -0
- package/skills/writing-quarto/steps/07-layout.md +1 -1
- package/skills/writing-quarto/steps/08-reread.md +1 -0
- package/skills/writing-quarto/steps/09-check.md +3 -3
- package/templates/quarto-book-project/SKILL.md +1 -1
- package/templates/quarto-book-project/checks.json +5 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// `quarto_render_check`:先判断渲染输出目录是否安全,再在 120 秒内跑一次
|
|
2
|
-
// `quarto render`,最后检查产出的 HTML
|
|
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 {
|
package/lib/workspace.js
CHANGED
|
@@ -13,6 +13,7 @@ const BOOK_SOURCE_EXTENSIONS = new Set(['.qmd', '.md']);
|
|
|
13
13
|
const NON_CHAPTER_FILE_RE = /^(?:readme|changelog|license|licence|contributing|code[_-]of[_-]conduct|security|support|authors|contributors|agents|claude)(?:[.-][a-z0-9_-]+)*\.(?:md|markdown|qmd)$/i;
|
|
14
14
|
const IGNORED_DIRECTORIES = new Set([
|
|
15
15
|
'.git',
|
|
16
|
+
'.agents',
|
|
16
17
|
'.quarto',
|
|
17
18
|
'.cache',
|
|
18
19
|
'.venv',
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Quarto Book Writing & Theme",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "Write, check, and publish a Chinese Quarto Book: nine-step chapter writing, structure/link/figure checks, a consistent theme, and render acceptance."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/zh.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chengzhao-dev/dsh-quarto-book",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.6.4",
|
|
4
|
+
"description": "把一份 Quarto Book 写成可读、可查、可发布的中文技术书:九步章节写作、结构/链接/图表检查、统一主题与渲染验收。 · Write, check, and publish a Chinese Quarto Book: nine-step chapter writing, structure/link/figure checks, a consistent theme, and render acceptance.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
"README.md",
|
|
20
20
|
"README.en.md",
|
|
21
21
|
"LICENSE",
|
|
22
|
-
"templates"
|
|
22
|
+
"templates",
|
|
23
|
+
"!**/__pycache__",
|
|
24
|
+
"!**/__pycache__/**",
|
|
25
|
+
"!**/*.pyc"
|
|
23
26
|
],
|
|
24
27
|
"engines": {
|
|
25
28
|
"node": ">=22",
|
|
@@ -32,8 +35,8 @@
|
|
|
32
35
|
"catalog": {
|
|
33
36
|
"category": "skill",
|
|
34
37
|
"summary": {
|
|
35
|
-
"zh": "
|
|
36
|
-
"en": "
|
|
38
|
+
"zh": "把一份 Quarto Book 写成可读、可查、可发布的中文技术书:九步章节写作、结构/链接/图表检查、统一主题与渲染验收。",
|
|
39
|
+
"en": "Write, check, and publish a Chinese Quarto Book: a nine-step chapter workflow, structure/link/figure checks, a consistent theme, and render acceptance."
|
|
37
40
|
},
|
|
38
41
|
"capabilities": [
|
|
39
42
|
"skills",
|
|
@@ -44,8 +47,11 @@
|
|
|
44
47
|
"icon": "./icon.svg",
|
|
45
48
|
"scripts": {
|
|
46
49
|
"build": "tsc -p tsconfig.json",
|
|
47
|
-
"test": "npm run build && node --test tests
|
|
48
|
-
"check": "tsc --noEmit -p tsconfig.json && node scripts/checking-skill-links.mjs",
|
|
50
|
+
"test": "npm run build && node --test \"tests/**/*.test.mjs\"",
|
|
51
|
+
"check": "tsc --noEmit -p tsconfig.json && node scripts/checking-skill-links.mjs && node scripts/checking-skill-budget.mjs && node scripts/checking-package.mjs",
|
|
52
|
+
"check:lib": "node scripts/checking-lib.mjs",
|
|
53
|
+
"prepack": "npm run build",
|
|
54
|
+
"prepublishOnly": "npm run check && npm run check:lib",
|
|
49
55
|
"pack": "node -e \"require('fs').mkdirSync('temp',{recursive:true})\" && npm pack --pack-destination temp",
|
|
50
56
|
"pack:check": "npm pack --dry-run"
|
|
51
57
|
},
|
|
Binary file
|
|
Binary file
|
|
@@ -199,6 +199,21 @@ mjx-container[display="true"] {
|
|
|
199
199
|
box-sizing: border-box;
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
/* 原生 HTML/SVG 图:图与图注作为一个整体居中;图注在图下,沿用上面的 figure 图注样式。 */
|
|
203
|
+
#quarto-document-content .book-figure {
|
|
204
|
+
margin: 1.25rem auto;
|
|
205
|
+
text-align: center;
|
|
206
|
+
}
|
|
207
|
+
#quarto-document-content .book-figure > svg {
|
|
208
|
+
display: block;
|
|
209
|
+
margin-inline: auto;
|
|
210
|
+
max-width: 100%;
|
|
211
|
+
height: auto;
|
|
212
|
+
}
|
|
213
|
+
#quarto-document-content .book-figure > figcaption {
|
|
214
|
+
margin-top: 0.5rem;
|
|
215
|
+
}
|
|
216
|
+
|
|
202
217
|
/* jupyter 单元的图必须用 IPython.display.HTML 输出保持 SVG 内联:image/svg+xml 输出会被
|
|
203
218
|
Quarto 外置成 <img>,无法继承站点 data-bs-theme 明暗与文档字体。text/html 输出不被
|
|
204
219
|
Quarto 识别为图像,fig-cap 退化为图后一个裸 <p>,这里按 figcaption 同款图注样式居中。 */
|
|
@@ -105,14 +105,13 @@ body {
|
|
|
105
105
|
color: var(--body-color) !important;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
/* ----------
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
/* ---------- 类图:预算优先,容器只做兜底 ----------
|
|
109
|
+
类图先按 writing-quarto 技能 quarto/mermaid.md 的内容预算删到栏宽以内;
|
|
110
|
+
这里只保证高度自适应并居中,内容超宽时由容器兜底。 */
|
|
111
111
|
#quarto-document-content .class-diagram .cell-output-display figure.figure {
|
|
112
112
|
overflow-x: auto;
|
|
113
113
|
}
|
|
114
|
-
body.program-map #quarto-document-content .cell-output-display figure.figure svg,
|
|
115
114
|
#quarto-document-content .class-diagram .cell-output-display figure.figure svg {
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
height: auto;
|
|
116
|
+
margin-inline: auto;
|
|
118
117
|
}
|
|
@@ -65,7 +65,7 @@ Bright Code 在 16px 下每个 ASCII 字符前进 8px;现在代码正文 15px
|
|
|
65
65
|
|
|
66
66
|
编译默认色板是 Flexoki(亮纸 `#FFFCF0`、暗墨 `#100F0F`),Solarized 是读者运行时可选的第二套。两套都只换语义令牌和语法色,不换字体、栏宽与组件。Flexoki 的 callout 是编辑注:2px 左条、无填充、无圆角,note 取中性灰,其余用官方语义色(tip 绿、warning 橙、important 紫、caution 红)。文本对比度按 WCAG 2.2 的 1.4.3(4.5:1)校验:正文、强调、链接、13px 表头、14px 图注与代码标题在各自底色上低于 4.5:1 时升到更实的一档,不改成纯黑或纯白,也不借用语法蓝。链接本身不靠颜色,常态与正文同色并保留下划线;Solarized 的正文与注释抬到 4.5:1,链接回到官方 rebase(base01 / base1),页面底色、强调色与 callout 条保持官方值。
|
|
67
67
|
|
|
68
|
-
第三套是读者自己的「山光」四时色板(`palettes/hill-light`,取自 `D:\Github\hill-light\hill-light-theme`
|
|
68
|
+
第三套是读者自己的「山光」四时色板(`palettes/hill-light`,取自 `D:\Github\hill-light\hill-light-theme` 的单一色值来源):晓雾、午荫、暮烟三种浅纸与夜月一种暗纸。它不是照搬原站,而是过一遍本书的令牌约定:保留原站的色相与命名,按本书的 WCAG 4.5:1 校验抬了正文与表头,callout 类型色与代码语法色沿用本书的语义映射(原站的 callout 与代码几乎是单色)。四种时辰里只有夜月是暗色,所以它同时要驱动 Quarto 的 light/dark 基座——`includes/palette.html` 在首帧写 `quarto-color-scheme` 哨兵,运行时对齐 `body` 类;打印时夜月回落到晓雾令牌,纸面不出深底。
|
|
69
69
|
|
|
70
70
|
## 抬色记录与保留的低对比项
|
|
71
71
|
|