@chengzhao-dev/dsh-quarto-book 0.6.3 → 0.6.5
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 +5 -3
- package/lib/index.js +49 -7
- package/lib/tools/pass-report.js +12 -12
- package/lib/tools/render-check.js +253 -65
- 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/SKILL.md +1 -1
- package/skills/designing-theme/assets/theme/css/content.css +31 -8
- package/skills/designing-theme/references/theme-spine.md +1 -1
- 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 +11 -2
- package/skills/writing-quarto/examples/03-block-after.md +29 -0
- package/skills/writing-quarto/examples/03-excerpt-lead.md +10 -0
- package/skills/writing-quarto/examples/03-qmd-elements.md +29 -2
- package/skills/writing-quarto/examples/04-paragraph-cases.md +13 -0
- package/skills/writing-quarto/examples/07-layout-list.md +34 -0
- package/skills/writing-quarto/quarto/authoring.md +5 -4
- 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 +2 -2
- package/skills/writing-quarto/quarto/refactor.md +1 -1
- package/skills/writing-quarto/quarto/render.md +4 -4
- package/skills/writing-quarto/quarto/tables.md +79 -0
- 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 +70 -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 +187 -35
- package/skills/writing-quarto/steps/02-structure.md +1 -1
- package/skills/writing-quarto/steps/03-blocks.md +19 -6
- package/skills/writing-quarto/steps/04-paragraphs.md +1 -0
- package/skills/writing-quarto/steps/06-wording.md +17 -0
- package/skills/writing-quarto/steps/07-layout.md +5 -1
- package/skills/writing-quarto/steps/08-reread.md +1 -1
|
@@ -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,88 @@ 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
|
+
const killer = spawnFn('taskkill', ['/pid', String(pid), '/T', '/F'], {
|
|
126
|
+
cwd,
|
|
127
|
+
windowsHide: true,
|
|
128
|
+
shell: false,
|
|
129
|
+
});
|
|
130
|
+
// taskkill 启动失败会走异步 error;不监听会成为未处理错误。
|
|
131
|
+
killer.on('error', () => {
|
|
132
|
+
// 下面仍会尝试 child.kill,这里只保证异步错误有出口。
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// taskkill 不可用时退回 child.kill。
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
else if (pid && process.platform !== 'win32') {
|
|
140
|
+
// runProcess 用 detached 启动,负 pid 表示整个进程组。
|
|
141
|
+
try {
|
|
142
|
+
process.kill(-pid, 'SIGKILL');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// 进程组可能已退出,退回 child.kill。
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
child.kill();
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// 进程可能已经退出。
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function runProcess(command, args, cwd, timeoutMs, spawnFn, signal) {
|
|
79
157
|
return new Promise((resolve) => {
|
|
158
|
+
if (signal?.aborted) {
|
|
159
|
+
resolve({ ok: false, missing: false, code: null, output: '已取消。', duration_ms: 0 });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
80
162
|
const started = Date.now();
|
|
81
163
|
let settled = false;
|
|
82
164
|
let output = '';
|
|
83
165
|
let child;
|
|
166
|
+
const onAbort = () => {
|
|
167
|
+
killProcessTree(child, cwd, spawnFn);
|
|
168
|
+
finish({ ok: false, missing: false, code: null, output: '已取消。', duration_ms: Date.now() - started });
|
|
169
|
+
};
|
|
84
170
|
const finish = (result) => {
|
|
85
171
|
if (settled)
|
|
86
172
|
return;
|
|
87
173
|
settled = true;
|
|
88
174
|
clearTimeout(timer);
|
|
175
|
+
signal?.removeEventListener('abort', onAbort);
|
|
89
176
|
resolve(result);
|
|
90
177
|
};
|
|
91
178
|
const append = (chunk) => {
|
|
@@ -95,19 +182,21 @@ function runProcess(command, args, cwd, timeoutMs, spawnFn) {
|
|
|
95
182
|
};
|
|
96
183
|
let timer;
|
|
97
184
|
try {
|
|
98
|
-
child = spawnFn(command, args, {
|
|
185
|
+
child = spawnFn(command, args, {
|
|
186
|
+
cwd,
|
|
187
|
+
windowsHide: true,
|
|
188
|
+
shell: false,
|
|
189
|
+
signal,
|
|
190
|
+
detached: process.platform !== 'win32',
|
|
191
|
+
});
|
|
99
192
|
}
|
|
100
193
|
catch (error) {
|
|
101
194
|
resolve({ ok: false, missing: true, code: null, output: error instanceof Error ? error.message : String(error), duration_ms: Date.now() - started });
|
|
102
195
|
return;
|
|
103
196
|
}
|
|
197
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
104
198
|
timer = setTimeout(() => {
|
|
105
|
-
|
|
106
|
-
child.kill();
|
|
107
|
-
}
|
|
108
|
-
catch {
|
|
109
|
-
// 进程可能已经退出。
|
|
110
|
-
}
|
|
199
|
+
killProcessTree(child, cwd, spawnFn);
|
|
111
200
|
finish({
|
|
112
201
|
ok: false,
|
|
113
202
|
missing: false,
|
|
@@ -127,16 +216,31 @@ function runProcess(command, args, cwd, timeoutMs, spawnFn) {
|
|
|
127
216
|
});
|
|
128
217
|
});
|
|
129
218
|
}
|
|
130
|
-
async function runPython(script, args, root, explicitPython, spawnFn) {
|
|
219
|
+
async function runPython(script, args, root, explicitPython, spawnFn, signal) {
|
|
131
220
|
const candidates = [explicitPython, process.env.PYTHON, 'python3', 'python'].filter((item) => typeof item === 'string' && item.trim() !== '');
|
|
132
221
|
let last = { ok: false, missing: true, code: null, output: `找不到 Python 解释器:${script}`, duration_ms: 0 };
|
|
222
|
+
let executedFailure;
|
|
133
223
|
for (const candidate of [...new Set(candidates)]) {
|
|
134
|
-
const result = await runProcess(candidate, [script, ...args], root, PYTHON_TIMEOUT_MS, spawnFn);
|
|
135
|
-
if (
|
|
224
|
+
const result = await runProcess(candidate, [script, ...args], root, PYTHON_TIMEOUT_MS, spawnFn, signal);
|
|
225
|
+
if (result.ok)
|
|
136
226
|
return result;
|
|
137
227
|
last = result;
|
|
228
|
+
// 坏解释器(例如 Windows Store 占位符)会启动成功但退出非 0;
|
|
229
|
+
// 先记住它的输出,再继续试下一个候选,避免只调用一个坏解释器就判书失败。
|
|
230
|
+
if (!result.missing && result.code !== null)
|
|
231
|
+
executedFailure = result;
|
|
138
232
|
}
|
|
139
|
-
return last;
|
|
233
|
+
return executedFailure ?? last;
|
|
234
|
+
}
|
|
235
|
+
function pythonEnvironmentFailure(result) {
|
|
236
|
+
if (result.missing || result.code === null)
|
|
237
|
+
return true;
|
|
238
|
+
const text = result.output.toLowerCase();
|
|
239
|
+
return (result.code === 9009 ||
|
|
240
|
+
text.includes('enoent') ||
|
|
241
|
+
text.includes('不是内部或外部命令') ||
|
|
242
|
+
text.includes('command not found') ||
|
|
243
|
+
text.includes('no such file'));
|
|
140
244
|
}
|
|
141
245
|
function truncate(value, max = 180) {
|
|
142
246
|
const collapsed = value.replace(/\s+/g, ' ').trim();
|
|
@@ -175,6 +279,9 @@ function relativeTo(root, file) {
|
|
|
175
279
|
}
|
|
176
280
|
async function readUtf8(file) {
|
|
177
281
|
try {
|
|
282
|
+
const info = await stat(file);
|
|
283
|
+
if (info.size > MAX_HTML_BYTES)
|
|
284
|
+
return undefined;
|
|
178
285
|
const bytes = await readFile(file);
|
|
179
286
|
if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf)
|
|
180
287
|
return undefined;
|
|
@@ -209,29 +316,85 @@ export async function inspectHtml(input) {
|
|
|
209
316
|
const errors = [];
|
|
210
317
|
const checks = [];
|
|
211
318
|
const index = path.join(input.outputDir, 'index.html');
|
|
212
|
-
const
|
|
319
|
+
const indexPath = relativeTo(input.root, index);
|
|
320
|
+
let indexText;
|
|
321
|
+
let indexDetail;
|
|
322
|
+
try {
|
|
323
|
+
const indexStat = await stat(index);
|
|
324
|
+
if (indexStat.size > MAX_HTML_BYTES) {
|
|
325
|
+
indexDetail = `${indexPath} 超过 ${Math.round(MAX_HTML_BYTES / 1024 / 1024)} MiB,未读`;
|
|
326
|
+
errors.push(`${indexPath} 太大,未读`);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
indexText = await readUtf8(index);
|
|
330
|
+
indexDetail =
|
|
331
|
+
indexText === undefined ? `缺少可读的 ${indexPath}` : `${indexPath} 已生成`;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
indexDetail = `缺少可读的 ${indexPath}`;
|
|
336
|
+
}
|
|
213
337
|
checks.push({
|
|
214
338
|
id: 'index_html',
|
|
215
339
|
status: indexText === undefined ? 'fail' : 'pass',
|
|
216
|
-
detail:
|
|
217
|
-
? `缺少可读的 ${relativeTo(input.root, index)}`
|
|
218
|
-
: `${relativeTo(input.root, index)} 已生成`,
|
|
340
|
+
detail: indexDetail,
|
|
219
341
|
});
|
|
220
342
|
const broken = [];
|
|
221
|
-
|
|
343
|
+
const fileUris = [];
|
|
344
|
+
let scaffoldFound = false;
|
|
345
|
+
let scannedBytes = 0;
|
|
346
|
+
let sizeLimited = false;
|
|
347
|
+
const scanFiles = htmlFiles.slice(0, MAX_HTML_FILES);
|
|
348
|
+
for (const file of scanFiles) {
|
|
349
|
+
const relative = relativeTo(input.outputDir, file);
|
|
350
|
+
let size = 0;
|
|
351
|
+
try {
|
|
352
|
+
size = (await stat(file)).size;
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
broken.push(`${relative} 无法读取`);
|
|
356
|
+
errors.push(`${relative} 无法读取`);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (size > MAX_HTML_BYTES) {
|
|
360
|
+
broken.push(`${relative} 超过 ${Math.round(MAX_HTML_BYTES / 1024 / 1024)} MiB,未读`);
|
|
361
|
+
errors.push(`${relative} 太大,未读`);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (scannedBytes + size > MAX_HTML_TOTAL_BYTES) {
|
|
365
|
+
sizeLimited = true;
|
|
366
|
+
errors.push(`HTML 总量超过 ${Math.round(MAX_HTML_TOTAL_BYTES / 1024 / 1024)} MiB,检查提前结束`);
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
scannedBytes += size;
|
|
222
370
|
const text = await readUtf8(file);
|
|
223
371
|
if (text === undefined) {
|
|
224
|
-
broken.push(`${
|
|
372
|
+
broken.push(`${relative} 不是 UTF-8 或带 BOM`);
|
|
373
|
+
errors.push(`${relative} 读取失败或不是 UTF-8`);
|
|
225
374
|
continue;
|
|
226
375
|
}
|
|
227
376
|
if (text.includes('\ufffd')) {
|
|
228
|
-
broken.push(`${
|
|
229
|
-
continue;
|
|
377
|
+
broken.push(`${relative} 含 U+FFFD 替换符`);
|
|
230
378
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
379
|
+
else {
|
|
380
|
+
const marker = MOJIBAKE_MARKERS.find((item) => text.includes(item));
|
|
381
|
+
if (marker)
|
|
382
|
+
broken.push(`${relative} 命中乱码特征「${marker}」`);
|
|
383
|
+
}
|
|
384
|
+
if (/(?:href|src)\s*=\s*["'][^"']*file:\/\/\//i.test(text))
|
|
385
|
+
fileUris.push(relative);
|
|
386
|
+
if (!scaffoldFound && containsScaffold(text))
|
|
387
|
+
scaffoldFound = true;
|
|
234
388
|
}
|
|
389
|
+
checks.push({
|
|
390
|
+
id: 'html_files',
|
|
391
|
+
status: htmlFiles.length > MAX_HTML_FILES || sizeLimited ? 'fail' : 'pass',
|
|
392
|
+
detail: htmlFiles.length > MAX_HTML_FILES
|
|
393
|
+
? `HTML 文件 ${htmlFiles.length} 个,超过上限 ${MAX_HTML_FILES}`
|
|
394
|
+
: sizeLimited
|
|
395
|
+
? `HTML 总量超过 ${Math.round(MAX_HTML_TOTAL_BYTES / 1024 / 1024)} MiB`
|
|
396
|
+
: `${htmlFiles.length} 个 HTML 在检查上限内`,
|
|
397
|
+
});
|
|
235
398
|
checks.push({
|
|
236
399
|
id: 'utf8',
|
|
237
400
|
status: htmlFiles.length === 0 ? 'skip' : broken.length === 0 ? 'pass' : 'fail',
|
|
@@ -251,12 +414,6 @@ export async function inspectHtml(input) {
|
|
|
251
414
|
? '首页声明 charset=utf-8'
|
|
252
415
|
: '首页缺少 charset=utf-8 声明',
|
|
253
416
|
});
|
|
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
417
|
checks.push({
|
|
261
418
|
id: 'no_file_uri',
|
|
262
419
|
status: htmlFiles.length === 0 ? 'skip' : fileUris.length === 0 ? 'pass' : 'fail',
|
|
@@ -283,8 +440,8 @@ export async function inspectHtml(input) {
|
|
|
283
440
|
: 'sitemap.xml 没有 <loc> 条目',
|
|
284
441
|
});
|
|
285
442
|
}
|
|
286
|
-
if (!
|
|
287
|
-
checks.push({ id: 'search_index', status: 'skip', detail: '
|
|
443
|
+
if (!searchEnabled(input.yaml)) {
|
|
444
|
+
checks.push({ id: 'search_index', status: 'skip', detail: '书配置显式关闭搜索,不要求 search.json' });
|
|
288
445
|
}
|
|
289
446
|
else {
|
|
290
447
|
const searchIndex = path.join(input.outputDir, 'search.json');
|
|
@@ -312,41 +469,56 @@ export async function inspectHtml(input) {
|
|
|
312
469
|
});
|
|
313
470
|
}
|
|
314
471
|
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);
|
|
472
|
+
const callouts = await runPython(calloutsScript, ['--root', input.root, '--book-dir', input.outputDir], input.root, input.python, spawnFn, input.signal);
|
|
316
473
|
checks.push({
|
|
317
474
|
id: 'callouts',
|
|
318
|
-
status: callouts.ok ? 'pass' : 'fail',
|
|
319
|
-
detail:
|
|
475
|
+
status: pythonEnvironmentFailure(callouts) ? 'skip' : callouts.ok ? 'pass' : 'fail',
|
|
476
|
+
detail: pythonEnvironmentFailure(callouts)
|
|
477
|
+
? `未运行 checking-callouts.py:${lastMeaningfulLine(callouts.output)}`
|
|
478
|
+
: callouts.ok
|
|
479
|
+
? lastMeaningfulLine(callouts.output)
|
|
480
|
+
: `checking-callouts.py:${lastMeaningfulLine(callouts.output)}`,
|
|
320
481
|
});
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
482
|
+
if (pythonEnvironmentFailure(callouts)) {
|
|
483
|
+
errors.push(`checking-callouts.py 未运行:${lastMeaningfulLine(callouts.output)}`);
|
|
484
|
+
}
|
|
485
|
+
if (!scaffoldFound) {
|
|
486
|
+
for (const name of await listCssFiles(input.outputDir)) {
|
|
487
|
+
const text = await readUtf8(name);
|
|
488
|
+
if (text && containsScaffold(text)) {
|
|
489
|
+
scaffoldFound = true;
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
331
493
|
}
|
|
332
|
-
if (!
|
|
494
|
+
if (!scaffoldFound) {
|
|
333
495
|
checks.push({ id: 'dom', status: 'skip', detail: '产物没有主题脚手架类,跳过 DOM 契约' });
|
|
334
496
|
}
|
|
335
497
|
else {
|
|
336
498
|
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
|
|
499
|
+
const dom = await runPython(domScript, ['--root', input.root, '--book-dir', input.outputDir], input.root, input.python, spawnFn, input.signal);
|
|
500
|
+
// C1/C2 依赖主题 CSS,C6/C7/C8 依赖主题资产;没有 theme/ 的书只跑通用契约。
|
|
501
|
+
const hasTheme = await isDirectory(path.join(input.root, 'theme'));
|
|
502
|
+
const universal = ['C3', 'C5', 'C9'];
|
|
503
|
+
const themeOwned = ['C1', 'C2', 'C6', 'C7', 'C8'];
|
|
504
|
+
const selected = parseDomLines(dom.output).filter((item) => universal.includes(item.id) || (hasTheme && themeOwned.includes(item.id)));
|
|
340
505
|
const failed = selected.filter((item) => item.status === 'fail');
|
|
506
|
+
const domUnavailable = pythonEnvironmentFailure(dom);
|
|
341
507
|
checks.push({
|
|
342
508
|
id: 'dom',
|
|
343
|
-
status: selected.length === 0 ? 'fail' : failed.length === 0 ? 'pass' : 'fail',
|
|
344
|
-
detail:
|
|
345
|
-
?
|
|
346
|
-
:
|
|
347
|
-
?
|
|
348
|
-
:
|
|
509
|
+
status: domUnavailable ? 'skip' : selected.length === 0 ? 'fail' : failed.length === 0 ? 'pass' : 'fail',
|
|
510
|
+
detail: domUnavailable
|
|
511
|
+
? `未运行 checking-dom.py:${lastMeaningfulLine(dom.output)}`
|
|
512
|
+
: selected.length === 0
|
|
513
|
+
? `checking-dom.py 没有返回可解析的契约行:${lastMeaningfulLine(dom.output)}`
|
|
514
|
+
: failed.length === 0
|
|
515
|
+
? hasTheme
|
|
516
|
+
? `${selected.length} 项 DOM 契约通过`
|
|
517
|
+
: `${selected.length} 项通用 DOM 契约通过(无 theme/,跳过主题契约)`
|
|
518
|
+
: `${failed.map((item) => `${item.id} ${item.detail}`).join(';')}`,
|
|
349
519
|
});
|
|
520
|
+
if (domUnavailable)
|
|
521
|
+
errors.push(`checking-dom.py 未运行:${lastMeaningfulLine(dom.output)}`);
|
|
350
522
|
}
|
|
351
523
|
const usable = checks.every((check) => check.status !== 'fail');
|
|
352
524
|
return {
|
|
@@ -359,6 +531,14 @@ export async function inspectHtml(input) {
|
|
|
359
531
|
errors,
|
|
360
532
|
};
|
|
361
533
|
}
|
|
534
|
+
async function isDirectory(dir) {
|
|
535
|
+
try {
|
|
536
|
+
return (await stat(dir)).isDirectory();
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
362
542
|
async function listCssFiles(dir) {
|
|
363
543
|
const files = [];
|
|
364
544
|
async function walk(current) {
|
|
@@ -384,7 +564,8 @@ function renderErrorText(result) {
|
|
|
384
564
|
const tail = result.output.trim();
|
|
385
565
|
if (!tail)
|
|
386
566
|
return `quarto render 退出码 ${result.exit_code ?? '未知'},没有输出。`;
|
|
387
|
-
|
|
567
|
+
// 只保留输出末尾;truncate 从头截断,不能直接拿整段输出调用。
|
|
568
|
+
return truncate(tail.slice(-500), 500);
|
|
388
569
|
}
|
|
389
570
|
export async function runRenderCheck(input) {
|
|
390
571
|
const root = path.resolve(input.root);
|
|
@@ -416,7 +597,7 @@ export async function runRenderCheck(input) {
|
|
|
416
597
|
}
|
|
417
598
|
const qmdPath = path.resolve(root, input.qmdPath);
|
|
418
599
|
try {
|
|
419
|
-
await
|
|
600
|
+
await stat(qmdPath);
|
|
420
601
|
}
|
|
421
602
|
catch {
|
|
422
603
|
warnings.push(`检查入口 ${input.qmdPath} 不存在,仍按书根执行渲染检查。`);
|
|
@@ -426,10 +607,14 @@ export async function runRenderCheck(input) {
|
|
|
426
607
|
warnings.push(output.warning ?? '渲染输出目录不安全。');
|
|
427
608
|
return result;
|
|
428
609
|
}
|
|
610
|
+
if (await outputDirEscapesRoot(root, output.resolved)) {
|
|
611
|
+
warnings.push(`project.output-dir \`${output.value}\` 经符号链接解析后落在书根之外;渲染会写到书外。改回书根内的普通目录后再查。`);
|
|
612
|
+
return result;
|
|
613
|
+
}
|
|
429
614
|
result.safe_to_render = true;
|
|
430
615
|
if (input.dryRun)
|
|
431
616
|
return result;
|
|
432
|
-
const renderResult = await runProcess('quarto', ['render'], root, input.timeoutMs ?? QUARTO_TIMEOUT_MS, input.spawn ?? nodeSpawn);
|
|
617
|
+
const renderResult = await runProcess('quarto', ['render'], root, input.timeoutMs ?? QUARTO_TIMEOUT_MS, input.spawn ?? nodeSpawn, input.signal);
|
|
433
618
|
result.render_result = {
|
|
434
619
|
ok: renderResult.ok,
|
|
435
620
|
exit_code: renderResult.code,
|
|
@@ -454,8 +639,10 @@ export async function runRenderCheck(input) {
|
|
|
454
639
|
yaml,
|
|
455
640
|
spawn: input.spawn,
|
|
456
641
|
python: input.python,
|
|
642
|
+
signal: input.signal,
|
|
457
643
|
});
|
|
458
644
|
result.html_report = inspected.report;
|
|
645
|
+
errors.push(...inspected.errors);
|
|
459
646
|
return result;
|
|
460
647
|
}
|
|
461
648
|
function reportLine(check) {
|
|
@@ -469,7 +656,8 @@ function cardLines(value) {
|
|
|
469
656
|
lines.push(...value.errors);
|
|
470
657
|
if (value.html_report) {
|
|
471
658
|
lines.push(...value.html_report.checks.map(reportLine));
|
|
472
|
-
|
|
659
|
+
const indexCheck = value.html_report.checks.find((check) => check.id === 'index_html');
|
|
660
|
+
lines.push(`首页:${indexCheck?.status === 'pass' ? '已生成' : '未生成'}`);
|
|
473
661
|
}
|
|
474
662
|
return lines;
|
|
475
663
|
}
|
|
@@ -481,7 +669,7 @@ function renderSummary(value) {
|
|
|
481
669
|
return `可渲染:输出目录安全,dry_run 未执行 quarto render。建议输出目录 ${value.suggested_output_dir}。`;
|
|
482
670
|
}
|
|
483
671
|
if (!value.render_result.ok) {
|
|
484
|
-
return
|
|
672
|
+
return value.errors[0] ?? renderErrorText(value.render_result);
|
|
485
673
|
}
|
|
486
674
|
if (!value.html_report) {
|
|
487
675
|
return '渲染完成,但没有生成 HTML 报告。';
|
|
@@ -590,7 +778,7 @@ export function registerRenderCheckTool(tools) {
|
|
|
590
778
|
if (!root) {
|
|
591
779
|
return refusedResult('当前会话不在 Quarto Book 项目内:需要 git checkout、_quarto.yml 的 type: book,以及至少一个 .qmd 或 .md 源文件。');
|
|
592
780
|
}
|
|
593
|
-
return runRenderCheck({ root, qmdPath: path.resolve(cwd, args.qmd_path), dryRun: args.dry_run });
|
|
781
|
+
return runRenderCheck({ root, qmdPath: path.resolve(cwd, args.qmd_path), dryRun: args.dry_run, signal: exec?.signal });
|
|
594
782
|
},
|
|
595
783
|
presentCall(args) {
|
|
596
784
|
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.5",
|
|
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": {
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"catalog": {
|
|
36
36
|
"category": "skill",
|
|
37
37
|
"summary": {
|
|
38
|
-
"zh": "
|
|
39
|
-
"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."
|
|
40
40
|
},
|
|
41
41
|
"capabilities": [
|
|
42
42
|
"skills",
|
|
@@ -47,8 +47,11 @@
|
|
|
47
47
|
"icon": "./icon.svg",
|
|
48
48
|
"scripts": {
|
|
49
49
|
"build": "tsc -p tsconfig.json",
|
|
50
|
-
"test": "npm run build && node --test tests
|
|
51
|
-
"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",
|
|
52
55
|
"pack": "node -e \"require('fs').mkdirSync('temp',{recursive:true})\" && npm pack --pack-destination temp",
|
|
53
56
|
"pack:check": "npm pack --dry-run"
|
|
54
57
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* dsh-quarto-book Quarto 主题 · 正文内容(表格 / 引用 / 正文组件与排版;自 common.css 拆分) */
|
|
2
2
|
|
|
3
|
-
/* ---------- 表格(booktabs
|
|
3
|
+
/* ---------- 表格(booktabs:顶/底细线、表头加粗带浅底,无行线与竖线) ---------- */
|
|
4
4
|
.table {
|
|
5
5
|
--bs-table-hover-bg: transparent;
|
|
6
6
|
/* 表体与正文同档:表格字号一旦小于正文,正文里嵌表格就会出现两次字级跳变,
|
|
@@ -12,13 +12,19 @@
|
|
|
12
12
|
max-width: 100%;
|
|
13
13
|
overflow-x: auto;
|
|
14
14
|
-webkit-overflow-scrolling: touch;
|
|
15
|
+
/* 列宽随内容分配:不写固定像素,长单元格换行,整表窄屏横向滚动。 */
|
|
16
|
+
table-layout: auto;
|
|
17
|
+
margin-block: 1.35rem 1.5rem;
|
|
15
18
|
border: none;
|
|
16
19
|
border-radius: 0;
|
|
17
20
|
font-variant-numeric: tabular-nums;
|
|
18
21
|
}
|
|
19
22
|
.table > :not(caption) > * > * {
|
|
20
|
-
padding: 0.55rem 0.
|
|
21
|
-
|
|
23
|
+
padding: 0.55rem 0.95rem;
|
|
24
|
+
/* 多行单元格从顶部起排,同一行里短格子不被拉到垂直居中。 */
|
|
25
|
+
vertical-align: top;
|
|
26
|
+
/* 长 API 名与长句允许在格内折行,避免把整张表撑出内容列。 */
|
|
27
|
+
overflow-wrap: break-word;
|
|
22
28
|
border-bottom: 0;
|
|
23
29
|
}
|
|
24
30
|
/* 首尾列贴边,让表格与正文左缘对齐 */
|
|
@@ -29,13 +35,15 @@
|
|
|
29
35
|
padding-inline-end: 0;
|
|
30
36
|
}
|
|
31
37
|
.table thead th {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
/* 表头用加粗与一道极浅底色标出层级;不套 .compact-table,列宽仍随内容。 */
|
|
39
|
+
background-color: color-mix(in srgb, var(--table-border) 24%, transparent);
|
|
40
|
+
font-weight: 700;
|
|
41
|
+
/* 表头比表体小 1px(17px → 15px),层级靠字重、字号与底色,不靠更粗的线。 */
|
|
35
42
|
font-size: 0.9375rem;
|
|
43
|
+
line-height: 1.6;
|
|
36
44
|
text-align: left;
|
|
37
|
-
padding-top: 0;
|
|
38
|
-
padding-bottom: 0.
|
|
45
|
+
padding-top: 0.5rem;
|
|
46
|
+
padding-bottom: 0.5rem;
|
|
39
47
|
border-top: 1px solid var(--table-border);
|
|
40
48
|
border-bottom: 1px solid var(--table-border);
|
|
41
49
|
}
|
|
@@ -199,6 +207,21 @@ mjx-container[display="true"] {
|
|
|
199
207
|
box-sizing: border-box;
|
|
200
208
|
}
|
|
201
209
|
|
|
210
|
+
/* 原生 HTML/SVG 图:图与图注作为一个整体居中;图注在图下,沿用上面的 figure 图注样式。 */
|
|
211
|
+
#quarto-document-content .book-figure {
|
|
212
|
+
margin: 1.25rem auto;
|
|
213
|
+
text-align: center;
|
|
214
|
+
}
|
|
215
|
+
#quarto-document-content .book-figure > svg {
|
|
216
|
+
display: block;
|
|
217
|
+
margin-inline: auto;
|
|
218
|
+
max-width: 100%;
|
|
219
|
+
height: auto;
|
|
220
|
+
}
|
|
221
|
+
#quarto-document-content .book-figure > figcaption {
|
|
222
|
+
margin-top: 0.5rem;
|
|
223
|
+
}
|
|
224
|
+
|
|
202
225
|
/* jupyter 单元的图必须用 IPython.display.HTML 输出保持 SVG 内联:image/svg+xml 输出会被
|
|
203
226
|
Quarto 外置成 <img>,无法继承站点 data-bs-theme 明暗与文档字体。text/html 输出不被
|
|
204
227
|
Quarto 识别为图像,fig-cap 退化为图后一个裸 <p>,这里按 figcaption 同款图注样式居中。 */
|
|
@@ -21,4 +21,4 @@
|
|
|
21
21
|
2. 新规则必须先标明属于哪一步:能放进已有 `##` 就放进去;只有尺度真的变了才允许新开一步。禁止再写一条不标明步骤的禁令。
|
|
22
22
|
3. 字体、栏宽、颜色决定的理由写进 `knowledge/reading-surface.md`,不在 reference 里重复论证。
|
|
23
23
|
4. 同步更新本文件、`SKILL.md`、`theme-system.md`、知识文件与检查脚本。
|
|
24
|
-
5. 改完运行 `scripts/checking-layout.py
|
|
24
|
+
5. 改完运行 `scripts/checking-layout.py`,再用 `quarto_render_check` 做整本渲染;两者都通过才算完成。
|