@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.
Files changed (44) hide show
  1. package/README.en.md +3 -3
  2. package/README.md +5 -3
  3. package/lib/index.js +49 -7
  4. package/lib/tools/pass-report.js +12 -12
  5. package/lib/tools/render-check.js +253 -65
  6. package/lib/workspace.js +1 -0
  7. package/locale/en.json +1 -1
  8. package/locale/zh.json +1 -1
  9. package/package.json +9 -6
  10. package/skills/designing-theme/SKILL.md +1 -1
  11. package/skills/designing-theme/assets/theme/css/content.css +31 -8
  12. package/skills/designing-theme/references/theme-spine.md +1 -1
  13. package/skills/designing-theme/scripts/checking-layout.py +11 -3
  14. package/skills/designing-theme/scripts/measuring-pages.mjs +2 -2
  15. package/skills/writing-quarto/SKILL.md +11 -2
  16. package/skills/writing-quarto/examples/03-block-after.md +29 -0
  17. package/skills/writing-quarto/examples/03-excerpt-lead.md +10 -0
  18. package/skills/writing-quarto/examples/03-qmd-elements.md +29 -2
  19. package/skills/writing-quarto/examples/04-paragraph-cases.md +13 -0
  20. package/skills/writing-quarto/examples/07-layout-list.md +34 -0
  21. package/skills/writing-quarto/quarto/authoring.md +5 -4
  22. package/skills/writing-quarto/quarto/basics.md +1 -0
  23. package/skills/writing-quarto/quarto/coords.md +2 -1
  24. package/skills/writing-quarto/quarto/figures.md +49 -0
  25. package/skills/writing-quarto/quarto/mermaid.md +2 -2
  26. package/skills/writing-quarto/quarto/refactor.md +1 -1
  27. package/skills/writing-quarto/quarto/render.md +4 -4
  28. package/skills/writing-quarto/quarto/tables.md +79 -0
  29. package/skills/writing-quarto/references/book-journey.md +38 -0
  30. package/skills/writing-quarto/references/token-budget.md +26 -0
  31. package/skills/writing-quarto/scripts/checking-callouts.py +2 -2
  32. package/skills/writing-quarto/scripts/checking-dom.py +12 -6
  33. package/skills/writing-quarto/scripts/checking-links.py +70 -13
  34. package/skills/writing-quarto/scripts/checking-names.py +3 -1
  35. package/skills/writing-quarto/scripts/checking-output.py +3 -3
  36. package/skills/writing-quarto/scripts/checking-prose.py +11 -7
  37. package/skills/writing-quarto/scripts/testing-content.py +68 -1
  38. package/skills/writing-quarto/scripts/verifying-content.py +187 -35
  39. package/skills/writing-quarto/steps/02-structure.md +1 -1
  40. package/skills/writing-quarto/steps/03-blocks.md +19 -6
  41. package/skills/writing-quarto/steps/04-paragraphs.md +1 -0
  42. package/skills/writing-quarto/steps/06-wording.md +17 -0
  43. package/skills/writing-quarto/steps/07-layout.md +5 -1
  44. 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 stripQuotes(match[1]);
51
+ return parseConfigScalar(match[1]);
37
52
  }
38
53
  return undefined;
39
54
  }
40
- function hasTruthySwitch(yaml, key) {
41
- const value = configValue(yaml, key);
42
- return value === 'true';
55
+ /** Quarto 的搜索默认开启;只有显式关掉(false/no/off)才跳过索引检查。 */
56
+ function searchEnabled(yaml) {
57
+ const value = configValue(yaml, 'search');
58
+ return value === undefined || !/^(?:false|no|off)$/i.test(value);
43
59
  }
44
60
  /**
45
61
  * `project.output-dir` 只能落在书根内部,且不能就是书根自身。
@@ -75,17 +91,88 @@ export function evaluateOutputDir(root, yaml) {
75
91
  }
76
92
  return { safe: true, value, resolved };
77
93
  }
78
- function runProcess(command, args, cwd, timeoutMs, spawnFn) {
94
+ async function nearestExistingRealPath(target) {
95
+ let current = path.resolve(target);
96
+ while (true) {
97
+ try {
98
+ return await realpath(current);
99
+ }
100
+ catch (error) {
101
+ const code = error.code;
102
+ if (code !== 'ENOENT' && code !== 'ENOTDIR')
103
+ return undefined;
104
+ const parent = path.dirname(current);
105
+ if (parent === current)
106
+ return undefined;
107
+ current = parent;
108
+ }
109
+ }
110
+ }
111
+ /** 输出目录或其最近的现存父目录经符号链接解析后仍须落在书根内。 */
112
+ async function outputDirEscapesRoot(root, resolved) {
113
+ const realRoot = await nearestExistingRealPath(root);
114
+ const realTarget = await nearestExistingRealPath(resolved);
115
+ if (!realRoot || !realTarget)
116
+ return true;
117
+ const relative = path.relative(realRoot, realTarget);
118
+ return relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
119
+ }
120
+ function killProcessTree(child, cwd, spawnFn) {
121
+ const pid = child.pid;
122
+ if (pid && process.platform === 'win32') {
123
+ // Windows 上只杀直接子进程会留下 Quarto 派生的 pandoc/python。
124
+ try {
125
+ 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, { cwd, windowsHide: true, shell: false });
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
- try {
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 (!result.missing)
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 indexText = await readUtf8(index);
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: indexText === undefined
217
- ? `缺少可读的 ${relativeTo(input.root, index)}`
218
- : `${relativeTo(input.root, index)} 已生成`,
340
+ detail: indexDetail,
219
341
  });
220
342
  const broken = [];
221
- for (const file of htmlFiles) {
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(`${relativeTo(input.outputDir, file)} 不是 UTF-8 或带 BOM`);
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(`${relativeTo(input.outputDir, file)} 含 U+FFFD 替换符`);
229
- continue;
377
+ broken.push(`${relative} 含 U+FFFD 替换符`);
230
378
  }
231
- const marker = MOJIBAKE_MARKERS.find((item) => text.includes(item));
232
- if (marker)
233
- broken.push(`${relativeTo(input.outputDir, file)} 命中乱码特征「${marker}」`);
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 (!hasTruthySwitch(input.yaml, 'search')) {
287
- checks.push({ id: 'search_index', status: 'skip', detail: '书配置未打开搜索,不要求 search.json' });
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: callouts.ok ? lastMeaningfulLine(callouts.output) : `checking-callouts.py:${lastMeaningfulLine(callouts.output)}`,
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
- const allText = [];
322
- for (const file of htmlFiles) {
323
- const text = await readUtf8(file);
324
- if (text)
325
- allText.push(text);
326
- }
327
- for (const name of await listCssFiles(input.outputDir)) {
328
- const text = await readUtf8(name);
329
- if (text)
330
- allText.push(text);
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 (!allText.some(containsScaffold)) {
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
- // C9 只在产物里出现 details.answer-disclosure 时判 fail,没有答案时记 N/A。
339
- const selected = parseDomLines(dom.output).filter((item) => ['C1', 'C2', 'C3', 'C5', 'C6', 'C7', 'C8', 'C9'].includes(item.id));
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: selected.length === 0
345
- ? `checking-dom.py 没有返回可解析的契约行:${lastMeaningfulLine(dom.output)}`
346
- : failed.length === 0
347
- ? `${selected.length} 项 DOM 契约通过`
348
- : `${failed.map((item) => `${item.id} ${item.detail}`).join(';')}`,
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
- return truncate(tail, 500);
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 readFile(qmdPath);
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
- lines.push(`首页:${value.html_report.output_files.find((file) => file === 'index.html') ?? '未生成'}`);
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 `渲染失败:${value.errors[0] ?? renderErrorText(value.render_result)}`;
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": "Polish Chinese technical chapters, check book structure and mixed Chinese-English prose, and maintain a consistent, readable theme."
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Quarto 图书写作与主题",
4
- "description": "润色中文技术章节,检查 Book 结构与中英混排,并维护一套统一、可读的阅读主题。"
4
+ "description": "把一份 Quarto Book 写成可读、可查、可发布的中文技术书:九步章节写作、结构/链接/图表检查、统一主题与渲染验收。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chengzhao-dev/dsh-quarto-book",
3
- "version": "0.6.3",
4
- "description": "润色中文技术章节,检查 Book 结构与中英混排,并维护一套统一、可读的阅读主题。 · Polish Chinese technical chapters, check book structure and mixed Chinese-English prose, and maintain a consistent, readable theme.",
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": "润色中文技术章节,检查 Book 结构与中英混排,并维护一套统一、可读的阅读主题。",
39
- "en": "Polish Chinese technical chapters, check book structure and mixed Chinese-English prose, and maintain a consistent, readable theme."
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/*.test.mjs",
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
  ---
2
2
  name: designing-theme
3
- description: 维护 Quarto Book 阅读主题:颜色、字体、间距、导航与 Callout 样式;不用于章节正文
3
+ description: 维护 Quarto Book 阅读主题:颜色、字体、字号、行距、表格与 Callout 样式、导航;不用于章节正文
4
4
  metadata:
5
5
  short-description: 维护 Quarto 图书主题
6
6
  ---
@@ -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.9rem;
21
- vertical-align: baseline;
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
- background-color: transparent;
33
- font-weight: 600;
34
- /* 表头比表体小 1px(17px → 15px),层级靠字重与字号,不靠比顶线更粗的线。 */
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.45rem;
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` 与整本 `quarto render`;两者都通过才算完成。
24
+ 5. 改完运行 `scripts/checking-layout.py`,再用 `quarto_render_check` 做整本渲染;两者都通过才算完成。