@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
@@ -4,6 +4,7 @@
4
4
  The checks encode the writing-quarto conventions: one chapter table in
5
5
  `_quarto.yml`, YAML titles instead of a body H1, ##/### heading depth, short
6
6
  paragraphs and sentences, declared code-fence languages, `{mermaid}` fences,
7
+ class-diagram content budgets, directory-tree annotation columns and markers,
7
8
  and no placeholder text left in the prose.
8
9
 
9
10
  Project-specific rules are optional. If the book has
@@ -31,6 +32,7 @@ import argparse
31
32
  import json
32
33
  import re
33
34
  import sys
35
+ import unicodedata
34
36
  from dataclasses import dataclass
35
37
  from pathlib import Path
36
38
 
@@ -46,6 +48,30 @@ CHAPTER_NUMBER_RE = re.compile(r"第\s*\d+\s*章")
46
48
  PLACEHOLDER_RE = re.compile(r"^\s*TODO|待补|待定|TBD", re.IGNORECASE)
47
49
  TRAILING_SPACE_RE = re.compile(r"[ ]+$")
48
50
 
51
+ QA_PROSE_RE = re.compile(r"(?:它|只|都|分别|各自)回答|回答(?:四类|整局|这个|哪些|什么)")
52
+ CODE_META_RE = re.compile(r"对应的代码")
53
+ COINED_RELATION_RE = re.compile(r"(?:组成|依赖|继承|调用)边")
54
+ CARRIER_RE = re.compile("承载")
55
+ NEXT_SECTION_RE = re.compile(r"(?:下一节|上一节)")
56
+ LADDER_RE = re.compile(r"(?:分别交给|各说一层)")
57
+
58
+ TREE_CATEGORY_COMMENTS = frozenset({
59
+ "头文件", "源文件", "源码", "实现", "测试", "配置文件",
60
+ "构建脚本", "构建规则", "动态库", "可执行文件",
61
+ })
62
+ MAX_TREE_LINES = 20
63
+ MAX_TREE_COMMENT_WIDTH = 28
64
+ MAX_TREE_DIR_COMMENT_WIDTH = 16
65
+ MIN_TREE_GAP = 4
66
+
67
+ # 类图的内容预算。数字是版面判断,不是浏览器实测:正文栏约 840 px、字号
68
+ # 15 px 时,6 个类型、14 行成员、单行 40 列仍能在栏内排下;超过就删内容或拆图。
69
+ MAX_CLASS_DIAGRAM_TYPES = 6
70
+ MAX_CLASS_DIAGRAM_MEMBERS = 14
71
+ MAX_CLASS_MEMBERS = 5
72
+ MAX_CLASS_MEMBER_COLUMNS = 40
73
+ CLASS_RE = re.compile(r"^class\s+([A-Za-z_]\w*)\s*(\{)?")
74
+
49
75
  HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
50
76
  HEADING_PERSONAL_RE = re.compile(r"自己|咱们|我们|你|您|跟我|试试|来看")
51
77
  FENCE_RE = re.compile(r"^\s*(```+|~~~+)\s*(\{[^}]*\}|[^\s`]*)?")
@@ -66,7 +92,8 @@ class Issue:
66
92
 
67
93
 
68
94
  def read_text(path: Path) -> str:
69
- return path.read_text(encoding="utf-8", errors="replace")
95
+ # utf-8-sig 兼容带 BOM 的合法 UTF-8 文件;BOM 不进入行号与正文。
96
+ return path.read_text(encoding="utf-8-sig")
70
97
 
71
98
 
72
99
  def strip_frontmatter(text: str) -> tuple[str, str]:
@@ -116,9 +143,9 @@ def parse_chapters(quarto_yml: Path) -> list[str]:
116
143
  if stripped and not raw.startswith((" ", "\t", "-")):
117
144
  in_chapters = False
118
145
  continue
119
- if not stripped.startswith("-"):
146
+ if not stripped:
120
147
  continue
121
- item = stripped[1:].strip()
148
+ item = stripped[1:].strip() if stripped.startswith("-") else stripped
122
149
  key, sep, value = item.partition(":")
123
150
  if sep and re.fullmatch(r"[a-z_]+", key.strip()):
124
151
  # 只接受会落到页面的键:href 与 part 的文件形式。
@@ -130,6 +157,17 @@ def parse_chapters(quarto_yml: Path) -> list[str]:
130
157
  return chapters
131
158
 
132
159
 
160
+ def collect_all_qmd(root: Path) -> list[Path]:
161
+ excluded = {"node_modules", "_book", ".quarto", ".git", "_freeze", "_site"}
162
+ found: list[Path] = []
163
+ for candidate in sorted(root.rglob("*.qmd")):
164
+ rel_parts = candidate.relative_to(root).parts
165
+ if any(part in excluded or part.startswith(".") for part in rel_parts):
166
+ continue
167
+ found.append(candidate)
168
+ return found
169
+
170
+
133
171
  def collect_chapter_files(root: Path, chapters: list[str]) -> list[Path]:
134
172
  found: list[Path] = []
135
173
  for rel in chapters:
@@ -141,15 +179,8 @@ def collect_chapter_files(root: Path, chapters: list[str]) -> list[Path]:
141
179
  found.append(index)
142
180
  if chapters:
143
181
  return found
144
- # No chapter table: audit every tracked .qmd outside generated/vendor dirs.
145
- excluded = {"node_modules", "_book", ".quarto", ".git", "_freeze", "_site"}
146
- for candidate in sorted(root.rglob("*.qmd")):
147
- rel_parts = candidate.relative_to(root).parts
148
- if any(part in excluded or part.startswith(".") for part in rel_parts):
149
- continue
150
- if candidate not in found:
151
- found.append(candidate)
152
- return found
182
+ # No chapter table: audit every .qmd outside generated/vendor dirs.
183
+ return collect_all_qmd(root)
153
184
 
154
185
 
155
186
  def check_heading(path: Path, line: int, level: int, text: str, issues: list[Issue]) -> None:
@@ -188,6 +219,252 @@ def check_prose_block(path: Path, start_line: int, text: str, issues: list[Issue
188
219
  break
189
220
 
190
221
 
222
+ def display_width(text: str) -> int:
223
+ return sum(2 if unicodedata.east_asian_width(char) in {"W", "F"} else 1 for char in text)
224
+
225
+
226
+ def check_mermaid_block(path: Path, block: list[tuple[int, str]]) -> list[Issue]:
227
+ """Check a class diagram's content budget and direction."""
228
+ diagram_at = next((index for index, (_, raw) in enumerate(block) if "classDiagram" in raw), None)
229
+ if diagram_at is None:
230
+ return []
231
+
232
+ issues: list[Issue] = []
233
+ start_line = block[diagram_at][0]
234
+ text = "\n".join(raw for _, raw in block)
235
+ if "hideEmptyMembersBox" not in text:
236
+ issues.append(Issue(
237
+ path, start_line,
238
+ "类图块首缺少 hideEmptyMembersBox 配置;空类会画出空成员栏",
239
+ ))
240
+ caption_at = next(
241
+ (index for index, (_, raw) in enumerate(block) if raw.lstrip().startswith("%%| fig-cap:")),
242
+ None,
243
+ )
244
+ init_at = next(
245
+ (index for index, (_, raw) in enumerate(block) if raw.lstrip().startswith("%%{init")),
246
+ None,
247
+ )
248
+ if caption_at is None:
249
+ issues.append(Issue(
250
+ path, start_line,
251
+ "类图缺少 fig-cap 图注;线型与读图约定应写在图注里",
252
+ ))
253
+ elif init_at is not None and caption_at > init_at:
254
+ issues.append(Issue(
255
+ path, block[caption_at][0],
256
+ "fig-cap 必须写在 init 之前;写在 init 之后会原样渲染成 Mermaid 注释",
257
+ ))
258
+
259
+ classes: dict[str, list[tuple[int, str]]] = {}
260
+ order: list[str] = []
261
+ direction = ""
262
+ direction_line = start_line
263
+ current: str | None = None
264
+ for number, raw in block[diagram_at:]:
265
+ line = raw.strip()
266
+ if not line or line.startswith("%%"):
267
+ continue
268
+ if line.startswith("direction "):
269
+ direction = line.split(None, 1)[1].strip().upper()
270
+ direction_line = number
271
+ continue
272
+ match = CLASS_RE.match(line)
273
+ if match:
274
+ name = match.group(1)
275
+ if name not in classes:
276
+ classes[name] = []
277
+ order.append(name)
278
+ current = name if "{" in line else None
279
+ continue
280
+ if current is None:
281
+ continue
282
+ if line == "}":
283
+ current = None
284
+ continue
285
+ if line.startswith("<<") and line.endswith(">>"):
286
+ continue
287
+ classes[current].append((number, line))
288
+
289
+ total_members = sum(len(lines) for lines in classes.values())
290
+ if len(order) > MAX_CLASS_DIAGRAM_TYPES:
291
+ issues.append(Issue(
292
+ path, start_line,
293
+ f"类图有 {len(order)} 个类型,超过 {MAX_CLASS_DIAGRAM_TYPES} 个;删节点或拆图",
294
+ ))
295
+ if total_members > MAX_CLASS_DIAGRAM_MEMBERS:
296
+ issues.append(Issue(
297
+ path, start_line,
298
+ f"类图成员共 {total_members} 行,超过 {MAX_CLASS_DIAGRAM_MEMBERS} 行;只留本章连边要用的成员",
299
+ ))
300
+ for name in order:
301
+ members = classes[name]
302
+ if len(members) > MAX_CLASS_MEMBERS:
303
+ issues.append(Issue(
304
+ path, members[MAX_CLASS_MEMBERS][0],
305
+ f"类 {name} 有 {len(members)} 行成员,超过 {MAX_CLASS_MEMBERS} 行;删到本章要用的",
306
+ ))
307
+ for number, line in members:
308
+ width = display_width(line)
309
+ if width > MAX_CLASS_MEMBER_COLUMNS:
310
+ issues.append(Issue(
311
+ path, number,
312
+ f"类图成员行 {width} 列,超过 {MAX_CLASS_MEMBER_COLUMNS} 列;参数省略类型名",
313
+ ))
314
+ if direction == "LR" and total_members:
315
+ issues.append(Issue(
316
+ path, direction_line,
317
+ "类图有成员却用 LR;改成 TB,让类型上下排",
318
+ ))
319
+ return issues
320
+
321
+
322
+ def check_mermaid_wrapper(path: Path, lines: list[str], block: list[tuple[int, str]]) -> list[Issue]:
323
+ """Check a class diagram sits in the .class-diagram scroll container."""
324
+ if not any("classDiagram" in raw for _, raw in block):
325
+ return []
326
+ start = block[0][0]
327
+ index = start - 2
328
+ if 0 <= index < len(lines) and lines[index].lstrip().startswith("```"):
329
+ index -= 1
330
+ while index >= 0 and not lines[index].strip():
331
+ index -= 1
332
+ if index < 0 or ".class-diagram" not in lines[index]:
333
+ return [Issue(
334
+ path, start,
335
+ "类图缺少 .class-diagram 包裹;无法应用类图的宽度与居中规则",
336
+ )]
337
+ return []
338
+
339
+
340
+ def check_tree_block(path: Path, block: list[tuple[int, str]]) -> list[Issue]:
341
+ """Check tree glyph lines: size, marker, width, items, and two marker columns."""
342
+ tree_lines = [(number, raw) for number, raw in block if "├──" in raw or "└──" in raw]
343
+ if len(tree_lines) < 2:
344
+ return []
345
+ issues: list[Issue] = []
346
+ if len(tree_lines) > MAX_TREE_LINES:
347
+ issues.append(Issue(
348
+ path, tree_lines[0][0],
349
+ f"树有 {len(tree_lines)} 行,超过 {MAX_TREE_LINES} 行;只留本章会点名的文件",
350
+ ))
351
+ dirs: list[tuple[int, int]] = []
352
+ files: list[tuple[int, int]] = []
353
+ text_columns: list[tuple[int, int]] = []
354
+ for number, raw in tree_lines:
355
+ hash_index = raw.find("#")
356
+ if hash_index < 0:
357
+ issues.append(Issue(path, number, "树行缺少行尾注释"))
358
+ continue
359
+ prefix = raw[:hash_index]
360
+ entry = prefix.rstrip()
361
+ if display_width(prefix) - display_width(entry) < MIN_TREE_GAP:
362
+ issues.append(Issue(path, number, "节点与注释之间至少留四个空格"))
363
+ rest = raw[hash_index:]
364
+ marker = "#/" if rest.startswith("#/") else "#"
365
+ after = rest[len(marker):]
366
+ if not after.startswith(" ") or (len(after) > 1 and after[1] == " "):
367
+ issues.append(Issue(path, number, "标记后只留一个空格"))
368
+ is_directory = entry.endswith("/")
369
+ if is_directory and marker != "#/":
370
+ issues.append(Issue(path, number, "目录行的注释以 #/ 开头"))
371
+ if not is_directory and marker == "#/":
372
+ issues.append(Issue(path, number, "文件行的注释以 # 开头"))
373
+ comment = after.strip()
374
+ if not comment:
375
+ issues.append(Issue(path, number, "树行注释为空"))
376
+ continue
377
+ if is_directory:
378
+ if ":" in comment or ":" in comment:
379
+ issues.append(Issue(
380
+ path, number,
381
+ "目录注释只写这里装什么,不加冒号或子项职责",
382
+ ))
383
+ if "、" in comment:
384
+ issues.append(Issue(
385
+ path, number,
386
+ "目录注释不列清单,用名词短语",
387
+ ))
388
+ if display_width(comment) > MAX_TREE_DIR_COMMENT_WIDTH:
389
+ issues.append(Issue(
390
+ path, number,
391
+ f"目录注释 {display_width(comment)} 列,超过 {MAX_TREE_DIR_COMMENT_WIDTH} 列;只留名词短语",
392
+ ))
393
+ else:
394
+ if display_width(comment) > MAX_TREE_COMMENT_WIDTH:
395
+ issues.append(Issue(
396
+ path, number,
397
+ f"注释 {display_width(comment)} 列,超过 {MAX_TREE_COMMENT_WIDTH} 列;缩成短名词短语",
398
+ ))
399
+ elif comment.rstrip("::") in TREE_CATEGORY_COMMENTS:
400
+ issues.append(Issue(path, number, f"注释只写类别词「{comment}」;写对象与用途"))
401
+ separators = comment.count("、") + len(re.findall(r"(?<!参)与", comment))
402
+ if separators > 1:
403
+ issues.append(Issue(
404
+ path, number, f"注释列了 {separators + 1} 项;压到 1 到 2 项",
405
+ ))
406
+ column = display_width(prefix)
407
+ if is_directory:
408
+ dirs.append((number, column))
409
+ else:
410
+ files.append((number, column))
411
+ text_columns.append((number, column + len(marker) + len(after) - len(after.lstrip(" "))))
412
+ if dirs:
413
+ dir_column = dirs[0][1]
414
+ if any(column != dir_column for _, column in dirs[1:]):
415
+ issues.append(Issue(path, dirs[0][0], "目录 #/ 未对齐"))
416
+ for number, column in files:
417
+ if column != dir_column + 1:
418
+ issues.append(Issue(
419
+ path, number,
420
+ f"文件 # 应比目录 # 右移一列(目录在 {dir_column},文件在 {column})",
421
+ ))
422
+ break
423
+ elif files:
424
+ file_column = files[0][1]
425
+ if any(column != file_column for _, column in files[1:]):
426
+ issues.append(Issue(path, files[0][0], "文件 # 未对齐"))
427
+ if text_columns:
428
+ text_column = text_columns[0][1]
429
+ for number, column in text_columns[1:]:
430
+ if column != text_column:
431
+ issues.append(Issue(path, number, "注释文字未对齐"))
432
+ break
433
+ return issues
434
+
435
+
436
+ def check_html_figure_block(path: Path, block: list[tuple[int, str]]) -> list[Issue]:
437
+ """原生 HTML/SVG 图要用 <figure> 包住,<figcaption> 放在图后。"""
438
+ issues: list[Issue] = []
439
+ svg_line: int | None = None
440
+ figure_line: int | None = None
441
+ caption_line: int | None = None
442
+ for number, raw in block:
443
+ if "<figure" in raw and figure_line is None:
444
+ figure_line = number
445
+ if "<svg" in raw and svg_line is None:
446
+ svg_line = number
447
+ if figure_line is None:
448
+ issues.append(Issue(
449
+ path, number,
450
+ "原生 SVG 图要用 <figure> 包住,并在图后写 <figcaption>",
451
+ ))
452
+ if "<figcaption" in raw and caption_line is None:
453
+ caption_line = number
454
+ if svg_line is not None and figure_line is not None:
455
+ if caption_line is None:
456
+ issues.append(Issue(
457
+ path, svg_line,
458
+ "原生 SVG 图缺 <figcaption>(图注要落在图下)",
459
+ ))
460
+ elif caption_line < svg_line:
461
+ issues.append(Issue(
462
+ path, caption_line,
463
+ "<figcaption> 要放在图后,不能放在图前",
464
+ ))
465
+ return issues
466
+
467
+
191
468
  def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> list[Issue]:
192
469
  issues: list[Issue] = []
193
470
  text = read_text(path)
@@ -199,12 +476,22 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
199
476
  elif front and not parse_frontmatter_title(front):
200
477
  issues.append(Issue(path, 1, "frontmatter 缺少 title"))
201
478
 
479
+ # 保留 frontmatter 的行数,后续行号才是文件里的真实行号。
480
+ leading = text[: len(text) - len(body)] if body else ""
481
+ if leading:
482
+ body = "\n" * leading.count("\n") + body
202
483
  lines = body.splitlines()
203
484
  in_fence = False
204
485
  fence_lang = ""
205
486
  fence_start = 0
487
+ fence_lines: list[tuple[int, str]] = []
488
+ fence_blocks: list[list[tuple[int, str]]] = []
489
+ mermaid_blocks: list[list[tuple[int, str]]] = []
490
+ html_blocks: list[list[tuple[int, str]]] = []
491
+ fence_is_html = False
206
492
  paragraph: list[str] = []
207
493
  paragraph_start = 0
494
+ last_fence_end: int | None = None
208
495
 
209
496
  def flush_paragraph() -> None:
210
497
  nonlocal paragraph
@@ -219,23 +506,41 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
219
506
  if not in_fence:
220
507
  in_fence = True
221
508
  fence_start = number
509
+ if last_fence_end is not None and number == last_fence_end + 1:
510
+ issues.append(Issue(
511
+ path, number,
512
+ "相邻围栏之间至少留一个空行(DOC-E11);空行不等于桥接正文",
513
+ ))
222
514
  fence_lang = (fence.group(2) or "").strip()
515
+ fence_lines = []
516
+ fence_is_html = False
223
517
  if not fence_lang:
224
518
  issues.append(Issue(path, number, "代码围栏未声明语言"))
225
519
  elif fence_lang == "mermaid":
226
520
  issues.append(Issue(path, number, "plain mermaid 围栏(改用 ```{mermaid})"))
227
521
  elif fence_lang.startswith("{"):
228
522
  inner = fence_lang.strip("{}").strip()
523
+ fence_is_html = inner == "=html"
229
524
  if inner and not inner.startswith((".", "=")) and "mermaid" not in inner:
230
525
  # ```{python} style attribute fences are valid.
231
526
  pass
232
527
  else:
233
528
  in_fence = False
529
+ last_fence_end = number
530
+ if fence_lines:
531
+ fence_blocks.append(fence_lines)
532
+ if "mermaid" in fence_lang:
533
+ mermaid_blocks.append(fence_lines)
534
+ if fence_is_html:
535
+ html_blocks.append(fence_lines)
536
+ fence_lines = []
537
+ fence_is_html = False
234
538
  continue
235
539
 
236
540
  if in_fence:
237
541
  if "\t" in raw:
238
542
  issues.append(Issue(path, number, "代码含制表符(改用空格)"))
543
+ fence_lines.append((number, raw))
239
544
  continue
240
545
 
241
546
  heading = HEADING_RE.match(raw)
@@ -257,7 +562,12 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
257
562
  flush_paragraph()
258
563
  continue
259
564
 
260
- if raw.lstrip().startswith((">", "|", "-", "*", "+", "1.")):
565
+ if raw.lstrip().startswith("|"):
566
+ flush_paragraph()
567
+ # 表格行按列组织,不套散文句长阈值;列数与表头一致性由表格检查负责。
568
+ continue
569
+
570
+ if raw.lstrip().startswith((">", "-", "*", "+", "1.")):
261
571
  flush_paragraph()
262
572
  check_prose_block(path, number, re.sub(r"^\s*(?:[>|]|[-*+]|\d+\.)\s*", "", raw), issues)
263
573
  continue
@@ -268,21 +578,51 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
268
578
 
269
579
  flush_paragraph()
270
580
 
581
+ if in_fence:
582
+ issues.append(Issue(path, fence_start, "代码围栏未闭合"))
583
+
584
+ for block in fence_blocks:
585
+ issues.extend(check_tree_block(path, block))
586
+
587
+ for block in mermaid_blocks:
588
+ issues.extend(check_mermaid_block(path, block))
589
+ issues.extend(check_mermaid_wrapper(path, lines, block))
590
+
591
+ for block in html_blocks:
592
+ issues.extend(check_html_figure_block(path, block))
593
+
594
+ fenced_lines: set[int] = set()
595
+ inside_fence = False
596
+ for number, raw in enumerate(lines, start=1):
597
+ if FENCE_RE.match(raw):
598
+ fenced_lines.add(number)
599
+ inside_fence = not inside_fence
600
+ elif inside_fence:
601
+ fenced_lines.add(number)
602
+
603
+ blob_links: dict[str, list[int]] = {}
604
+
271
605
  for index, raw in enumerate(lines, start=1):
272
- match = LINK_RE.search(raw)
273
- if not match:
274
- continue
275
- target = match.group(1).strip()
276
- if target.startswith(("http://", "https://", "mailto:", "#", "data:")):
277
- continue
278
- clean = target.split("#", 1)[0].split("?", 1)[0]
279
- if not clean or "<" in clean:
606
+ if index in fenced_lines:
280
607
  continue
281
- if not (path.parent / clean).exists() and not (root / clean).exists():
282
- issues.append(Issue(path, index, f"本地链接不可达:{target}"))
283
-
284
- if text.count("```") % 2 != 0:
285
- issues.append(Issue(path, len(lines), "代码围栏数量为奇数,存在未闭合围栏"))
608
+ for match in LINK_RE.finditer(raw):
609
+ target = match.group(1).strip()
610
+ if target.startswith(("http://", "https://", "mailto:", "#", "data:", "//")):
611
+ if target.startswith("https://github.com/") and "/blob/" in target:
612
+ blob_links.setdefault(target, []).append(index)
613
+ continue
614
+ clean = target.split("#", 1)[0].split("?", 1)[0]
615
+ if not clean or "<" in clean:
616
+ continue
617
+ if not (path.parent / clean).exists() and not (root / clean).exists():
618
+ issues.append(Issue(path, index, f"本地链接不可达:{target}"))
619
+
620
+ for target, numbers in blob_links.items():
621
+ if len(numbers) > 1:
622
+ issues.append(Issue(
623
+ path, numbers[1],
624
+ f"同章重复文件链接:{target}(首次在第 {numbers[0]} 行);每章每条仓库路径至多一条",
625
+ ))
286
626
 
287
627
  for term in GENERIC_FORBIDDEN_TERMS:
288
628
  for index, raw in enumerate(lines, start=1):
@@ -296,6 +636,46 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
296
636
  issues.append(Issue(path, index, f"项目禁用词「{term}」"))
297
637
  break
298
638
 
639
+ for index, raw in enumerate(lines, start=1):
640
+ if raw.lstrip().startswith(("```", " ")):
641
+ continue
642
+ match = QA_PROSE_RE.search(raw)
643
+ if match:
644
+ issues.append(Issue(
645
+ path, index,
646
+ f"问答式讲解「{match.group(0)}」;改成返回值、判断结果或直接陈述",
647
+ ))
648
+ match = CODE_META_RE.search(raw)
649
+ if match:
650
+ issues.append(Issue(
651
+ path, index,
652
+ f"代码字面复述「{match.group(0)}」;改成围栏没给出的行为或约束",
653
+ ))
654
+ match = COINED_RELATION_RE.search(raw)
655
+ if match:
656
+ issues.append(Issue(
657
+ path, index,
658
+ f"自造关系词「{match.group(0)}」;用组成、调用、依赖、继承等通行词",
659
+ ))
660
+ match = CARRIER_RE.search(raw)
661
+ if match:
662
+ issues.append(Issue(
663
+ path, index,
664
+ "类型或字段不用「承载」描述;改用表示、保存、记录或对应",
665
+ ))
666
+ match = NEXT_SECTION_RE.search(raw)
667
+ if match:
668
+ issues.append(Issue(
669
+ path, index,
670
+ f"小节导航「{match.group(0)}」;删除后用新节首句承接,章末预告保留",
671
+ ))
672
+ match = LADDER_RE.search(raw)
673
+ if match:
674
+ issues.append(Issue(
675
+ path, index,
676
+ f"拉链句「{match.group(0)}」;拆句或改成一项一行",
677
+ ))
678
+
299
679
  required = project.get("chapter_titles", {}).get(rel)
300
680
  if required:
301
681
  title = parse_frontmatter_title(front)
@@ -329,6 +709,7 @@ def load_project(root: Path) -> tuple[bool, dict]:
329
709
  def main() -> int:
330
710
  parser = argparse.ArgumentParser(description="检查 Quarto Book 章节源码")
331
711
  parser.add_argument("--root", default=".", help="书根目录(默认当前目录)")
712
+ parser.add_argument("--paths", nargs="+", help="只检查这些 qmd 路径(相对 --root)")
332
713
  parser.add_argument("--strict", action="store_true", help="把未列入目录的文件也视为错误")
333
714
  parser.add_argument("--quiet", action="store_true", help="只输出汇总")
334
715
  args = parser.parse_args()
@@ -345,27 +726,52 @@ def main() -> int:
345
726
  return 2
346
727
 
347
728
  quarto_yml = root / "_quarto.yml"
348
- chapters = parse_chapters(quarto_yml)
729
+ try:
730
+ chapters = parse_chapters(quarto_yml)
731
+ except (OSError, UnicodeDecodeError) as exc:
732
+ print(f"失败 读取目录表失败: {exc}")
733
+ return 1
349
734
  files = collect_chapter_files(root, chapters)
735
+ issues: list[Issue] = []
736
+ if args.paths:
737
+ requested: list[Path] = []
738
+ for raw in args.paths:
739
+ candidate = (root / raw).resolve()
740
+ if root not in candidate.parents and candidate != root:
741
+ issues.append(Issue(quarto_yml, 1, f"--paths 落在书根之外:{raw}"))
742
+ elif not candidate.is_file():
743
+ issues.append(Issue(candidate, 1, f"--paths 指定的文件不存在:{raw}"))
744
+ else:
745
+ requested.append(candidate)
746
+ files = requested
350
747
  if not files:
351
748
  print(f"失败 {root} 下没有 .qmd 章节(确认 --root 指向书根目录)")
352
749
  return 1
353
750
 
354
- has_project, project = load_project(root)
751
+ try:
752
+ has_project, project = load_project(root)
753
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
754
+ print(f"失败 读取项目规则失败: {exc}")
755
+ return 1
355
756
 
356
757
  missing = [rel for rel in chapters if not (root / rel).is_file()]
357
- issues: list[Issue] = [
758
+ issues.extend([
358
759
  Issue(quarto_yml, 1, f"目录中的章节不存在:{rel}") for rel in missing
359
- ]
760
+ ])
761
+
762
+ if args.strict:
763
+ extras = [item for item in collect_all_qmd(root) if item not in files]
764
+ for item in extras:
765
+ issues.append(Issue(item, 1, "未列入 _quarto.yml(strict 模式)"))
766
+ files.extend(extras)
360
767
 
361
768
  for path in files:
362
- issues.extend(check_file(path, root, project, chapters))
769
+ try:
770
+ issues.extend(check_file(path, root, project, chapters))
771
+ except (OSError, UnicodeDecodeError) as exc:
772
+ issues.append(Issue(path, 1, f"无法按 UTF-8 读取:{exc}"))
363
773
 
364
- if args.strict:
365
- for issue in list(issues):
366
- if "未列入 _quarto.yml" in issue.message:
367
- issue.message = issue.message.replace("(目录会漏章)", "")
368
- else:
774
+ if not args.strict:
369
775
  issues = [issue for issue in issues if "未列入 _quarto.yml" not in issue.message]
370
776
 
371
777
  if not args.quiet: