@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.
Files changed (53) hide show
  1. package/README.en.md +60 -31
  2. package/README.md +53 -24
  3. package/lib/index.js +49 -7
  4. package/lib/tools/pass-report.js +12 -12
  5. package/lib/tools/render-check.js +231 -62
  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 +13 -7
  10. package/skills/designing-theme/assets/theme/assets/fonts/lxgw-bright-code-common.woff2 +0 -0
  11. package/skills/designing-theme/assets/theme/assets/fonts/lxgw-bright-medium-common.woff2 +0 -0
  12. package/skills/designing-theme/assets/theme/css/content.css +15 -0
  13. package/skills/designing-theme/assets/theme/css/mermaid.css +5 -6
  14. package/skills/designing-theme/knowledge/reading-surface.md +1 -1
  15. package/skills/designing-theme/references/theme-system.md +5 -5
  16. package/skills/designing-theme/scripts/checking-layout.py +60 -12
  17. package/skills/designing-theme/scripts/measuring-pages.mjs +2 -2
  18. package/skills/writing-quarto/SKILL.md +7 -0
  19. package/skills/writing-quarto/examples/02-structure-shape.md +26 -12
  20. package/skills/writing-quarto/examples/03-block-after.md +14 -0
  21. package/skills/writing-quarto/examples/03-qmd-elements.md +45 -2
  22. package/skills/writing-quarto/examples/04-paragraph-cases.md +14 -0
  23. package/skills/writing-quarto/examples/04-paragraph-opening.md +2 -2
  24. package/skills/writing-quarto/examples/05-sentence-frame.md +4 -4
  25. package/skills/writing-quarto/examples/06-wording-cases.md +23 -0
  26. package/skills/writing-quarto/examples/07-layout-list.md +3 -3
  27. package/skills/writing-quarto/examples/08-appendix.md +1 -1
  28. package/skills/writing-quarto/quarto/authoring.md +7 -6
  29. package/skills/writing-quarto/quarto/basics.md +17 -16
  30. package/skills/writing-quarto/quarto/coords.md +2 -1
  31. package/skills/writing-quarto/quarto/figures.md +49 -0
  32. package/skills/writing-quarto/quarto/mermaid.md +41 -5
  33. package/skills/writing-quarto/quarto/prose.md +4 -4
  34. package/skills/writing-quarto/quarto/refactor.md +1 -1
  35. package/skills/writing-quarto/quarto/terminal.md +1 -1
  36. package/skills/writing-quarto/references/book-journey.md +38 -0
  37. package/skills/writing-quarto/references/token-budget.md +26 -0
  38. package/skills/writing-quarto/scripts/checking-callouts.py +2 -2
  39. package/skills/writing-quarto/scripts/checking-dom.py +12 -6
  40. package/skills/writing-quarto/scripts/checking-links.py +29 -13
  41. package/skills/writing-quarto/scripts/checking-names.py +3 -1
  42. package/skills/writing-quarto/scripts/checking-output.py +3 -3
  43. package/skills/writing-quarto/scripts/checking-prose.py +11 -7
  44. package/skills/writing-quarto/scripts/testing-content.py +264 -1
  45. package/skills/writing-quarto/scripts/verifying-content.py +442 -36
  46. package/skills/writing-quarto/steps/03-blocks.md +20 -8
  47. package/skills/writing-quarto/steps/04-paragraphs.md +19 -2
  48. package/skills/writing-quarto/steps/06-wording.md +53 -0
  49. package/skills/writing-quarto/steps/07-layout.md +1 -1
  50. package/skills/writing-quarto/steps/08-reread.md +1 -0
  51. package/skills/writing-quarto/steps/09-check.md +3 -3
  52. package/templates/quarto-book-project/SKILL.md +1 -1
  53. 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 stripQuotes(match[1]);
51
+ return parseConfigScalar(match[1]);
37
52
  }
38
53
  return undefined;
39
54
  }
40
- function hasTruthySwitch(yaml, key) {
41
- const value = configValue(yaml, key);
42
- return value === 'true';
55
+ /** Quarto 的搜索默认开启;只有显式关掉(false/no/off)才跳过索引检查。 */
56
+ function searchEnabled(yaml) {
57
+ const value = configValue(yaml, 'search');
58
+ return value === undefined || !/^(?:false|no|off)$/i.test(value);
43
59
  }
44
60
  /**
45
61
  * `project.output-dir` 只能落在书根内部,且不能就是书根自身。
@@ -75,17 +91,84 @@ export function evaluateOutputDir(root, yaml) {
75
91
  }
76
92
  return { safe: true, value, resolved };
77
93
  }
78
- function runProcess(command, args, cwd, timeoutMs, spawnFn) {
94
+ async function nearestExistingRealPath(target) {
95
+ let current = path.resolve(target);
96
+ while (true) {
97
+ try {
98
+ return await realpath(current);
99
+ }
100
+ catch (error) {
101
+ const code = error.code;
102
+ if (code !== 'ENOENT' && code !== 'ENOTDIR')
103
+ return undefined;
104
+ const parent = path.dirname(current);
105
+ if (parent === current)
106
+ return undefined;
107
+ current = parent;
108
+ }
109
+ }
110
+ }
111
+ /** 输出目录或其最近的现存父目录经符号链接解析后仍须落在书根内。 */
112
+ async function outputDirEscapesRoot(root, resolved) {
113
+ const realRoot = await nearestExistingRealPath(root);
114
+ const realTarget = await nearestExistingRealPath(resolved);
115
+ if (!realRoot || !realTarget)
116
+ return true;
117
+ const relative = path.relative(realRoot, realTarget);
118
+ return relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
119
+ }
120
+ function killProcessTree(child, cwd, spawnFn) {
121
+ const pid = child.pid;
122
+ if (pid && process.platform === 'win32') {
123
+ // Windows 上只杀直接子进程会留下 Quarto 派生的 pandoc/python。
124
+ try {
125
+ spawnFn('taskkill', ['/pid', String(pid), '/T', '/F'], {
126
+ cwd,
127
+ windowsHide: true,
128
+ shell: false,
129
+ });
130
+ }
131
+ catch {
132
+ // taskkill 不可用时退回 child.kill。
133
+ }
134
+ }
135
+ else if (pid && process.platform !== 'win32') {
136
+ // runProcess 用 detached 启动,负 pid 表示整个进程组。
137
+ try {
138
+ process.kill(-pid, 'SIGKILL');
139
+ return;
140
+ }
141
+ catch {
142
+ // 进程组可能已退出,退回 child.kill。
143
+ }
144
+ }
145
+ try {
146
+ child.kill();
147
+ }
148
+ catch {
149
+ // 进程可能已经退出。
150
+ }
151
+ }
152
+ function runProcess(command, args, cwd, timeoutMs, spawnFn, signal) {
79
153
  return new Promise((resolve) => {
154
+ if (signal?.aborted) {
155
+ resolve({ ok: false, missing: false, code: null, output: '已取消。', duration_ms: 0 });
156
+ return;
157
+ }
80
158
  const started = Date.now();
81
159
  let settled = false;
82
160
  let output = '';
83
161
  let child;
162
+ const onAbort = () => {
163
+ killProcessTree(child, cwd, spawnFn);
164
+ finish({ ok: false, missing: false, code: null, output: '已取消。', duration_ms: Date.now() - started });
165
+ };
84
166
  const finish = (result) => {
85
167
  if (settled)
86
168
  return;
87
169
  settled = true;
88
170
  clearTimeout(timer);
171
+ signal?.removeEventListener('abort', onAbort);
89
172
  resolve(result);
90
173
  };
91
174
  const append = (chunk) => {
@@ -95,19 +178,21 @@ function runProcess(command, args, cwd, timeoutMs, spawnFn) {
95
178
  };
96
179
  let timer;
97
180
  try {
98
- child = spawnFn(command, args, { cwd, windowsHide: true, shell: false });
181
+ child = spawnFn(command, args, {
182
+ cwd,
183
+ windowsHide: true,
184
+ shell: false,
185
+ signal,
186
+ detached: process.platform !== 'win32',
187
+ });
99
188
  }
100
189
  catch (error) {
101
190
  resolve({ ok: false, missing: true, code: null, output: error instanceof Error ? error.message : String(error), duration_ms: Date.now() - started });
102
191
  return;
103
192
  }
193
+ signal?.addEventListener('abort', onAbort, { once: true });
104
194
  timer = setTimeout(() => {
105
- try {
106
- child.kill();
107
- }
108
- catch {
109
- // 进程可能已经退出。
110
- }
195
+ killProcessTree(child, cwd, spawnFn);
111
196
  finish({
112
197
  ok: false,
113
198
  missing: false,
@@ -127,16 +212,31 @@ function runProcess(command, args, cwd, timeoutMs, spawnFn) {
127
212
  });
128
213
  });
129
214
  }
130
- async function runPython(script, args, root, explicitPython, spawnFn) {
215
+ async function runPython(script, args, root, explicitPython, spawnFn, signal) {
131
216
  const candidates = [explicitPython, process.env.PYTHON, 'python3', 'python'].filter((item) => typeof item === 'string' && item.trim() !== '');
132
217
  let last = { ok: false, missing: true, code: null, output: `找不到 Python 解释器:${script}`, duration_ms: 0 };
218
+ let executedFailure;
133
219
  for (const candidate of [...new Set(candidates)]) {
134
- const result = await runProcess(candidate, [script, ...args], root, PYTHON_TIMEOUT_MS, spawnFn);
135
- if (!result.missing)
220
+ const result = await runProcess(candidate, [script, ...args], root, PYTHON_TIMEOUT_MS, spawnFn, signal);
221
+ if (result.ok)
136
222
  return result;
137
223
  last = result;
224
+ // 坏解释器(例如 Windows Store 占位符)会启动成功但退出非 0;
225
+ // 先记住它的输出,再继续试下一个候选,避免只调用一个坏解释器就判书失败。
226
+ if (!result.missing && result.code !== null)
227
+ executedFailure = result;
138
228
  }
139
- return last;
229
+ return executedFailure ?? last;
230
+ }
231
+ function pythonEnvironmentFailure(result) {
232
+ if (result.missing || result.code === null)
233
+ return true;
234
+ const text = result.output.toLowerCase();
235
+ return (result.code === 9009 ||
236
+ text.includes('enoent') ||
237
+ text.includes('不是内部或外部命令') ||
238
+ text.includes('command not found') ||
239
+ text.includes('no such file'));
140
240
  }
141
241
  function truncate(value, max = 180) {
142
242
  const collapsed = value.replace(/\s+/g, ' ').trim();
@@ -175,6 +275,9 @@ function relativeTo(root, file) {
175
275
  }
176
276
  async function readUtf8(file) {
177
277
  try {
278
+ const info = await stat(file);
279
+ if (info.size > MAX_HTML_BYTES)
280
+ return undefined;
178
281
  const bytes = await readFile(file);
179
282
  if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf)
180
283
  return undefined;
@@ -218,20 +321,61 @@ export async function inspectHtml(input) {
218
321
  : `${relativeTo(input.root, index)} 已生成`,
219
322
  });
220
323
  const broken = [];
221
- for (const file of htmlFiles) {
324
+ const fileUris = [];
325
+ let scaffoldFound = false;
326
+ let scannedBytes = 0;
327
+ let sizeLimited = false;
328
+ const scanFiles = htmlFiles.slice(0, MAX_HTML_FILES);
329
+ for (const file of scanFiles) {
330
+ const relative = relativeTo(input.outputDir, file);
331
+ let size = 0;
332
+ try {
333
+ size = (await stat(file)).size;
334
+ }
335
+ catch {
336
+ broken.push(`${relative} 无法读取`);
337
+ errors.push(`${relative} 无法读取`);
338
+ continue;
339
+ }
340
+ if (size > MAX_HTML_BYTES) {
341
+ broken.push(`${relative} 超过 ${Math.round(MAX_HTML_BYTES / 1024 / 1024)} MiB,未读`);
342
+ errors.push(`${relative} 太大,未读`);
343
+ continue;
344
+ }
345
+ if (scannedBytes + size > MAX_HTML_TOTAL_BYTES) {
346
+ sizeLimited = true;
347
+ errors.push(`HTML 总量超过 ${Math.round(MAX_HTML_TOTAL_BYTES / 1024 / 1024)} MiB,检查提前结束`);
348
+ break;
349
+ }
350
+ scannedBytes += size;
222
351
  const text = await readUtf8(file);
223
352
  if (text === undefined) {
224
- broken.push(`${relativeTo(input.outputDir, file)} 不是 UTF-8 或带 BOM`);
353
+ broken.push(`${relative} 不是 UTF-8 或带 BOM`);
354
+ errors.push(`${relative} 读取失败或不是 UTF-8`);
225
355
  continue;
226
356
  }
227
357
  if (text.includes('\ufffd')) {
228
- broken.push(`${relativeTo(input.outputDir, file)} 含 U+FFFD 替换符`);
229
- continue;
358
+ broken.push(`${relative} 含 U+FFFD 替换符`);
359
+ }
360
+ else {
361
+ const marker = MOJIBAKE_MARKERS.find((item) => text.includes(item));
362
+ if (marker)
363
+ broken.push(`${relative} 命中乱码特征「${marker}」`);
230
364
  }
231
- const marker = MOJIBAKE_MARKERS.find((item) => text.includes(item));
232
- if (marker)
233
- broken.push(`${relativeTo(input.outputDir, file)} 命中乱码特征「${marker}」`);
365
+ if (/(?:href|src)\s*=\s*["'][^"']*file:\/\/\//i.test(text))
366
+ fileUris.push(relative);
367
+ if (!scaffoldFound && containsScaffold(text))
368
+ scaffoldFound = true;
234
369
  }
370
+ checks.push({
371
+ id: 'html_files',
372
+ status: htmlFiles.length > MAX_HTML_FILES || sizeLimited ? 'fail' : 'pass',
373
+ detail: htmlFiles.length > MAX_HTML_FILES
374
+ ? `HTML 文件 ${htmlFiles.length} 个,超过上限 ${MAX_HTML_FILES}`
375
+ : sizeLimited
376
+ ? `HTML 总量超过 ${Math.round(MAX_HTML_TOTAL_BYTES / 1024 / 1024)} MiB`
377
+ : `${htmlFiles.length} 个 HTML 在检查上限内`,
378
+ });
235
379
  checks.push({
236
380
  id: 'utf8',
237
381
  status: htmlFiles.length === 0 ? 'skip' : broken.length === 0 ? 'pass' : 'fail',
@@ -251,12 +395,6 @@ export async function inspectHtml(input) {
251
395
  ? '首页声明 charset=utf-8'
252
396
  : '首页缺少 charset=utf-8 声明',
253
397
  });
254
- const fileUris = [];
255
- for (const file of htmlFiles) {
256
- const text = await readUtf8(file);
257
- if (text?.includes('file:///'))
258
- fileUris.push(relativeTo(input.outputDir, file));
259
- }
260
398
  checks.push({
261
399
  id: 'no_file_uri',
262
400
  status: htmlFiles.length === 0 ? 'skip' : fileUris.length === 0 ? 'pass' : 'fail',
@@ -283,8 +421,8 @@ export async function inspectHtml(input) {
283
421
  : 'sitemap.xml 没有 <loc> 条目',
284
422
  });
285
423
  }
286
- if (!hasTruthySwitch(input.yaml, 'search')) {
287
- checks.push({ id: 'search_index', status: 'skip', detail: '书配置未打开搜索,不要求 search.json' });
424
+ if (!searchEnabled(input.yaml)) {
425
+ checks.push({ id: 'search_index', status: 'skip', detail: '书配置显式关闭搜索,不要求 search.json' });
288
426
  }
289
427
  else {
290
428
  const searchIndex = path.join(input.outputDir, 'search.json');
@@ -312,41 +450,56 @@ export async function inspectHtml(input) {
312
450
  });
313
451
  }
314
452
  const calloutsScript = path.join(SKILL_DIR, 'scripts', 'checking-callouts.py');
315
- const callouts = await runPython(calloutsScript, ['--root', input.root, '--book-dir', input.outputDir], input.root, input.python, spawnFn);
453
+ const callouts = await runPython(calloutsScript, ['--root', input.root, '--book-dir', input.outputDir], input.root, input.python, spawnFn, input.signal);
316
454
  checks.push({
317
455
  id: 'callouts',
318
- status: callouts.ok ? 'pass' : 'fail',
319
- detail: callouts.ok ? lastMeaningfulLine(callouts.output) : `checking-callouts.py:${lastMeaningfulLine(callouts.output)}`,
456
+ status: pythonEnvironmentFailure(callouts) ? 'skip' : callouts.ok ? 'pass' : 'fail',
457
+ detail: pythonEnvironmentFailure(callouts)
458
+ ? `未运行 checking-callouts.py:${lastMeaningfulLine(callouts.output)}`
459
+ : callouts.ok
460
+ ? lastMeaningfulLine(callouts.output)
461
+ : `checking-callouts.py:${lastMeaningfulLine(callouts.output)}`,
320
462
  });
321
- const allText = [];
322
- for (const file of htmlFiles) {
323
- const text = await readUtf8(file);
324
- if (text)
325
- allText.push(text);
326
- }
327
- for (const name of await listCssFiles(input.outputDir)) {
328
- const text = await readUtf8(name);
329
- if (text)
330
- allText.push(text);
463
+ if (pythonEnvironmentFailure(callouts)) {
464
+ errors.push(`checking-callouts.py 未运行:${lastMeaningfulLine(callouts.output)}`);
465
+ }
466
+ if (!scaffoldFound) {
467
+ for (const name of await listCssFiles(input.outputDir)) {
468
+ const text = await readUtf8(name);
469
+ if (text && containsScaffold(text)) {
470
+ scaffoldFound = true;
471
+ break;
472
+ }
473
+ }
331
474
  }
332
- if (!allText.some(containsScaffold)) {
475
+ if (!scaffoldFound) {
333
476
  checks.push({ id: 'dom', status: 'skip', detail: '产物没有主题脚手架类,跳过 DOM 契约' });
334
477
  }
335
478
  else {
336
479
  const domScript = path.join(SKILL_DIR, 'scripts', 'checking-dom.py');
337
- const dom = await runPython(domScript, ['--root', input.root, '--book-dir', input.outputDir], input.root, input.python, spawnFn);
338
- // C9 只在产物里出现 details.answer-disclosure 时判 fail,没有答案时记 N/A。
339
- const selected = parseDomLines(dom.output).filter((item) => ['C1', 'C2', 'C3', 'C5', 'C6', 'C7', 'C8', 'C9'].includes(item.id));
480
+ const dom = await runPython(domScript, ['--root', input.root, '--book-dir', input.outputDir], input.root, input.python, spawnFn, input.signal);
481
+ // C1/C2 依赖主题 CSS,C6/C7/C8 依赖主题资产;没有 theme/ 的书只跑通用契约。
482
+ const hasTheme = await isDirectory(path.join(input.root, 'theme'));
483
+ const universal = ['C3', 'C5', 'C9'];
484
+ const themeOwned = ['C1', 'C2', 'C6', 'C7', 'C8'];
485
+ const selected = parseDomLines(dom.output).filter((item) => universal.includes(item.id) || (hasTheme && themeOwned.includes(item.id)));
340
486
  const failed = selected.filter((item) => item.status === 'fail');
487
+ const domUnavailable = pythonEnvironmentFailure(dom);
341
488
  checks.push({
342
489
  id: 'dom',
343
- status: selected.length === 0 ? 'fail' : failed.length === 0 ? 'pass' : 'fail',
344
- detail: selected.length === 0
345
- ? `checking-dom.py 没有返回可解析的契约行:${lastMeaningfulLine(dom.output)}`
346
- : failed.length === 0
347
- ? `${selected.length} 项 DOM 契约通过`
348
- : `${failed.map((item) => `${item.id} ${item.detail}`).join(';')}`,
490
+ status: domUnavailable ? 'skip' : selected.length === 0 ? 'fail' : failed.length === 0 ? 'pass' : 'fail',
491
+ detail: domUnavailable
492
+ ? `未运行 checking-dom.py:${lastMeaningfulLine(dom.output)}`
493
+ : selected.length === 0
494
+ ? `checking-dom.py 没有返回可解析的契约行:${lastMeaningfulLine(dom.output)}`
495
+ : failed.length === 0
496
+ ? hasTheme
497
+ ? `${selected.length} 项 DOM 契约通过`
498
+ : `${selected.length} 项通用 DOM 契约通过(无 theme/,跳过主题契约)`
499
+ : `${failed.map((item) => `${item.id} ${item.detail}`).join(';')}`,
349
500
  });
501
+ if (domUnavailable)
502
+ errors.push(`checking-dom.py 未运行:${lastMeaningfulLine(dom.output)}`);
350
503
  }
351
504
  const usable = checks.every((check) => check.status !== 'fail');
352
505
  return {
@@ -359,6 +512,14 @@ export async function inspectHtml(input) {
359
512
  errors,
360
513
  };
361
514
  }
515
+ async function isDirectory(dir) {
516
+ try {
517
+ return (await stat(dir)).isDirectory();
518
+ }
519
+ catch {
520
+ return false;
521
+ }
522
+ }
362
523
  async function listCssFiles(dir) {
363
524
  const files = [];
364
525
  async function walk(current) {
@@ -384,7 +545,8 @@ function renderErrorText(result) {
384
545
  const tail = result.output.trim();
385
546
  if (!tail)
386
547
  return `quarto render 退出码 ${result.exit_code ?? '未知'},没有输出。`;
387
- return truncate(tail, 500);
548
+ // 只保留输出末尾;truncate 从头截断,不能直接拿整段输出调用。
549
+ return truncate(tail.slice(-500), 500);
388
550
  }
389
551
  export async function runRenderCheck(input) {
390
552
  const root = path.resolve(input.root);
@@ -416,7 +578,7 @@ export async function runRenderCheck(input) {
416
578
  }
417
579
  const qmdPath = path.resolve(root, input.qmdPath);
418
580
  try {
419
- await readFile(qmdPath);
581
+ await stat(qmdPath);
420
582
  }
421
583
  catch {
422
584
  warnings.push(`检查入口 ${input.qmdPath} 不存在,仍按书根执行渲染检查。`);
@@ -426,10 +588,14 @@ export async function runRenderCheck(input) {
426
588
  warnings.push(output.warning ?? '渲染输出目录不安全。');
427
589
  return result;
428
590
  }
591
+ if (await outputDirEscapesRoot(root, output.resolved)) {
592
+ warnings.push(`project.output-dir \`${output.value}\` 经符号链接解析后落在书根之外;渲染会写到书外。改回书根内的普通目录后再查。`);
593
+ return result;
594
+ }
429
595
  result.safe_to_render = true;
430
596
  if (input.dryRun)
431
597
  return result;
432
- const renderResult = await runProcess('quarto', ['render'], root, input.timeoutMs ?? QUARTO_TIMEOUT_MS, input.spawn ?? nodeSpawn);
598
+ const renderResult = await runProcess('quarto', ['render'], root, input.timeoutMs ?? QUARTO_TIMEOUT_MS, input.spawn ?? nodeSpawn, input.signal);
433
599
  result.render_result = {
434
600
  ok: renderResult.ok,
435
601
  exit_code: renderResult.code,
@@ -454,8 +620,10 @@ export async function runRenderCheck(input) {
454
620
  yaml,
455
621
  spawn: input.spawn,
456
622
  python: input.python,
623
+ signal: input.signal,
457
624
  });
458
625
  result.html_report = inspected.report;
626
+ errors.push(...inspected.errors);
459
627
  return result;
460
628
  }
461
629
  function reportLine(check) {
@@ -469,7 +637,8 @@ function cardLines(value) {
469
637
  lines.push(...value.errors);
470
638
  if (value.html_report) {
471
639
  lines.push(...value.html_report.checks.map(reportLine));
472
- lines.push(`首页:${value.html_report.output_files.find((file) => file === 'index.html') ?? '未生成'}`);
640
+ const indexCheck = value.html_report.checks.find((check) => check.id === 'index_html');
641
+ lines.push(`首页:${indexCheck?.status === 'pass' ? '已生成' : '未生成'}`);
473
642
  }
474
643
  return lines;
475
644
  }
@@ -481,7 +650,7 @@ function renderSummary(value) {
481
650
  return `可渲染:输出目录安全,dry_run 未执行 quarto render。建议输出目录 ${value.suggested_output_dir}。`;
482
651
  }
483
652
  if (!value.render_result.ok) {
484
- return `渲染失败:${value.errors[0] ?? renderErrorText(value.render_result)}`;
653
+ return value.errors[0] ?? renderErrorText(value.render_result);
485
654
  }
486
655
  if (!value.html_report) {
487
656
  return '渲染完成,但没有生成 HTML 报告。';
@@ -590,7 +759,7 @@ export function registerRenderCheckTool(tools) {
590
759
  if (!root) {
591
760
  return refusedResult('当前会话不在 Quarto Book 项目内:需要 git checkout、_quarto.yml 的 type: book,以及至少一个 .qmd 或 .md 源文件。');
592
761
  }
593
- return runRenderCheck({ root, qmdPath: path.resolve(cwd, args.qmd_path), dryRun: args.dry_run });
762
+ return runRenderCheck({ root, qmdPath: path.resolve(cwd, args.qmd_path), dryRun: args.dry_run, signal: exec?.signal });
594
763
  },
595
764
  presentCall(args) {
596
765
  return {
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.2",
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.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": "润色中文技术章节,检查 Book 结构与中英混排,并维护一套统一、可读的阅读主题。",
36
- "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."
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/*.test.mjs",
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
  },
@@ -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
- 程序全图页与包在 .class-diagram 里的子图,标签按测量宽绘制;超出栏宽时由 figure 横向滚动。 */
110
- body.program-map #quarto-document-content .cell-output-display figure.figure,
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
- min-width: max-content;
117
- max-width: none;
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` 的单一色值来源):晓雾、午荫、暮烟三种浅纸与夜月一种暗纸。它不是照搬原站,而是过一遍本书的令牌契约:保留原站的色相与命名,按本书的 WCAG 4.5:1 校验抬了正文与表头,callout 类型色与代码语法色沿用本书的语义映射(原站的 callout 与代码几乎是单色)。四种时辰里只有夜月是暗色,所以它同时要驱动 Quarto 的 light/dark 基座——`includes/palette.html` 在首帧写 `quarto-color-scheme` 哨兵,运行时对齐 `body` 类;打印时夜月回落到晓雾令牌,纸面不出深底。
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