@chengzhao-dev/dsh-quarto-book 0.6.1 → 0.6.3

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 (39) hide show
  1. package/README.en.md +60 -31
  2. package/README.md +53 -24
  3. package/lib/tools/render-check.js +1 -1
  4. package/package.json +5 -2
  5. package/skills/designing-theme/assets/theme/assets/fonts/lxgw-bright-code-common.woff2 +0 -0
  6. package/skills/designing-theme/assets/theme/assets/fonts/lxgw-bright-medium-common.woff2 +0 -0
  7. package/skills/designing-theme/assets/theme/css/mermaid.css +5 -6
  8. package/skills/designing-theme/knowledge/reading-surface.md +1 -1
  9. package/skills/designing-theme/references/theme-system.md +5 -5
  10. package/skills/designing-theme/scripts/checking-layout.py +49 -9
  11. package/skills/writing-quarto/SKILL.md +2 -2
  12. package/skills/writing-quarto/examples/02-structure-shape.md +54 -12
  13. package/skills/writing-quarto/examples/03-block-after.md +14 -0
  14. package/skills/writing-quarto/examples/03-qmd-elements.md +16 -0
  15. package/skills/writing-quarto/examples/04-paragraph-cases.md +14 -0
  16. package/skills/writing-quarto/examples/04-paragraph-opening.md +2 -2
  17. package/skills/writing-quarto/examples/05-sentence-frame.md +4 -4
  18. package/skills/writing-quarto/examples/05-sentence-reference.md +14 -0
  19. package/skills/writing-quarto/examples/06-wording-cases.md +37 -0
  20. package/skills/writing-quarto/examples/{03-layout-list.md → 07-layout-list.md} +14 -0
  21. package/skills/writing-quarto/examples/08-appendix.md +2 -2
  22. package/skills/writing-quarto/quarto/authoring.md +4 -4
  23. package/skills/writing-quarto/quarto/basics.md +17 -17
  24. package/skills/writing-quarto/quarto/mermaid.md +41 -5
  25. package/skills/writing-quarto/quarto/prose.md +25 -2
  26. package/skills/writing-quarto/quarto/terminal.md +1 -1
  27. package/skills/writing-quarto/scripts/testing-content.py +199 -1
  28. package/skills/writing-quarto/scripts/verifying-content.py +281 -0
  29. package/skills/writing-quarto/steps/02-structure.md +9 -26
  30. package/skills/writing-quarto/steps/03-blocks.md +31 -20
  31. package/skills/writing-quarto/steps/04-paragraphs.md +35 -18
  32. package/skills/writing-quarto/steps/05-sentences.md +24 -14
  33. package/skills/writing-quarto/steps/06-wording.md +37 -0
  34. package/skills/writing-quarto/steps/07-layout.md +12 -13
  35. package/skills/writing-quarto/steps/08-reread.md +7 -4
  36. package/skills/writing-quarto/steps/09-check.md +3 -3
  37. package/templates/quarto-book-project/SKILL.md +1 -1
  38. package/templates/quarto-book-project/checks.json +5 -0
  39. package/templates/quarto-book-project/writing/chapter-map.md +3 -0
@@ -52,6 +52,71 @@ title: 入门
52
52
  先准备输入,再运行检查,最后对照输出。
53
53
  """
54
54
 
55
+ TREE_CHAPTER = """---
56
+ title: 入门
57
+ ---
58
+
59
+ ## 目标
60
+
61
+ 下面是工程目录树,行尾注释说明每个节点在工程里的用途,读者可以对照磁盘检查。
62
+
63
+ ```text
64
+ ├── apps/ #/ 终端程序
65
+ │ └── cli.h # 读入坐标
66
+ └── tests/ #/ 单元测试
67
+ ```
68
+ """
69
+
70
+
71
+ TREE_GAP_CHAPTER = """---
72
+ title: 入门
73
+ ---
74
+
75
+ ## 目标
76
+
77
+ 下面是工程目录树,行尾注释说明每个节点在工程里的用途。
78
+
79
+ ```text
80
+ ├── lib/ #/ 源码
81
+ └── main.c # 入口
82
+ ```
83
+ """
84
+
85
+
86
+ MERMAID_CHAPTER = """---
87
+ title: 入门
88
+ ---
89
+
90
+ ## 目标
91
+
92
+ 下面是一张只画类型和边的类图,读者可以对照箭头理解类型之间的依赖。
93
+
94
+ ::: {.class-diagram}
95
+ ```{mermaid}
96
+ %%| fig-cap: "类型 A 依赖类型 B。实线箭头从使用者指向被使用者。"
97
+ %%{init: {"fontFamily": "LXGW Bright Medium, system-ui, sans-serif", "class": {"hideEmptyMembersBox": true}}}%%
98
+ classDiagram
99
+ direction LR
100
+ class A
101
+ class B
102
+ A --> B
103
+ ```
104
+ :::
105
+ """
106
+
107
+
108
+ def long_tree_chapter(lines: int = 21) -> str:
109
+ rows = []
110
+ for index in range(lines):
111
+ prefix = f"├── file{index}.txt"
112
+ rows.append(prefix + " " * (20 - len(prefix)) + f"# 用途{index}")
113
+ tree = "\n".join(rows)
114
+ return (
115
+ "---\ntitle: 入门\n---\n\n## 目标\n\n"
116
+ "下面是工程目录树,行尾注释说明每个节点在工程里的用途。\n\n"
117
+ f"```text\n{tree}\n```\n"
118
+ )
119
+
55
120
 
56
121
  def run_case(tmp: Path) -> subprocess.CompletedProcess[str]:
57
122
  return subprocess.run(
@@ -97,6 +162,23 @@ def main() -> int:
97
162
  failures.append(f"{name}: 输出缺少「{needle}」\n{output[:600]}")
98
163
 
99
164
  expect("合法样书", VALID_CHAPTER, 0, "OK")
165
+ expect(
166
+ "正文问答式讲解",
167
+ VALID_CHAPTER + "\n`Board` 只回答四类问题:坐标、满盘、三连与赢家。\n",
168
+ 1, "问答式讲解",
169
+ )
170
+ expect(
171
+ "代码字面复述",
172
+ VALID_CHAPTER + "\n对应的代码里,`kBoardRows` 和 `kBoardCols` 都取 3。\n",
173
+ 1, "代码字面复述",
174
+ )
175
+ expect(
176
+ "自造关系词",
177
+ VALID_CHAPTER + "\n`Game` 与 `Board` 之间的组成边表示什么?\n",
178
+ 1, "自造关系词",
179
+ )
180
+ expect("标题口语化", VALID_CHAPTER.replace("## 目标", "## 自己走一局"), 1, "标题带人称")
181
+ expect("标题带行内代码", VALID_CHAPTER.replace("## 目标", "## 运行 `app`"), 1, "标题带行内代码")
100
182
  expect("正文 H1", VALID_CHAPTER.replace("## 目标", "# 目标"), 1, "正文出现 H1")
101
183
  expect("缺 title", VALID_CHAPTER.replace("title: 入门\n", ""), 1, "缺少 YAML frontmatter")
102
184
  expect(
@@ -126,6 +208,122 @@ def main() -> int:
126
208
  0, "OK",
127
209
  )
128
210
 
211
+ expect("目录树合格", TREE_CHAPTER, 0, "OK")
212
+ expect(
213
+ "标记后多空格",
214
+ TREE_CHAPTER.replace("# 读入坐标", "# 读入坐标"),
215
+ 1, "标记后只留一个空格",
216
+ )
217
+ expect(
218
+ "注释文字未对齐",
219
+ TREE_CHAPTER.replace("#/ 终端程序", "#/ 终端程序"),
220
+ 1, "注释文字未对齐",
221
+ )
222
+ expect(
223
+ "树注释错列",
224
+ TREE_CHAPTER.replace("│ └── cli.h #", "│ └── cli.h #"),
225
+ 1, "右移一列",
226
+ )
227
+ expect("节点与注释太近", TREE_GAP_CHAPTER, 1, "至少留四个空格")
228
+ expect(
229
+ "文件 # 未右移",
230
+ TREE_CHAPTER.replace("│ └── cli.h #", "│ └── cli.h #"),
231
+ 1, "右移一列",
232
+ )
233
+ expect("树注释只写类别词", TREE_CHAPTER.replace("# 读入坐标", "# 头文件"), 1, "类别词")
234
+ expect(
235
+ "目录注释带冒号",
236
+ TREE_CHAPTER.replace("#/ 终端程序", "#/ 终端程序:打印棋盘"),
237
+ 1, "目录注释只写这里装什么",
238
+ )
239
+ expect(
240
+ "目录注释过长",
241
+ TREE_CHAPTER.replace("#/ 终端程序", "#/ 终端程序与它的打印读入"),
242
+ 1, "超过 16 列",
243
+ )
244
+ expect("树注释列了三项", TREE_CHAPTER.replace("# 读入坐标", "# 读入、打印与写快照"), 1, "压到 1 到 2 项")
245
+ expect("目录行缺少 #/", TREE_CHAPTER.replace("#/ 终端程序", "# 终端程序"), 1, "目录行的注释以 #/")
246
+ expect("文件行误写 #/", TREE_CHAPTER.replace("# 读入坐标", "#/ 读入坐标"), 1, "文件行的注释以 #")
247
+ expect("树行缺少注释", TREE_CHAPTER.replace("# 读入坐标", ""), 1, "缺少行尾注释")
248
+ expect(
249
+ "树注释过长",
250
+ TREE_CHAPTER.replace("# 读入坐标", "# 读入坐标并打印当前棋盘的全部状态"),
251
+ 1, "超过 28 列",
252
+ )
253
+ expect("树行数过多", long_tree_chapter(), 1, "超过 20 行")
254
+
255
+ expect("类图合格", MERMAID_CHAPTER, 0, "OK")
256
+ expect(
257
+ "类图缺少图注",
258
+ MERMAID_CHAPTER.replace('%%| fig-cap: "类型 A 依赖类型 B。实线箭头从使用者指向被使用者。"\n', ""),
259
+ 1, "fig-cap",
260
+ )
261
+ wrong_order = MERMAID_CHAPTER.replace(
262
+ '%%| fig-cap: "类型 A 依赖类型 B。实线箭头从使用者指向被使用者。"\n%%{init: ',
263
+ "%%{init: ",
264
+ ).replace(
265
+ "classDiagram",
266
+ '%%| fig-cap: "类型 A 依赖类型 B。实线箭头从使用者指向被使用者。"\nclassDiagram',
267
+ 1,
268
+ )
269
+ expect("类图图注写在 init 之后", wrong_order, 1, "init 之前")
270
+ expect(
271
+ "类图缺少页面容器",
272
+ MERMAID_CHAPTER.replace("::: {.class-diagram}\n```{mermaid}", "```{mermaid}").replace(
273
+ "```\n:::", "```"
274
+ ),
275
+ 1, ".class-diagram",
276
+ )
277
+ expect(
278
+ "类图缺少空成员配置",
279
+ MERMAID_CHAPTER.replace(', "class": {"hideEmptyMembersBox": true}', ""),
280
+ 1, "hideEmptyMembersBox",
281
+ )
282
+ expect(
283
+ "类图有成员却用 LR",
284
+ MERMAID_CHAPTER.replace(
285
+ " class B\n",
286
+ " class B {\n int value\n }\n",
287
+ ),
288
+ 1, "有成员却用 LR",
289
+ )
290
+ expect(
291
+ "类图类型过多",
292
+ MERMAID_CHAPTER.replace(
293
+ " class B\n",
294
+ " class B\n class C\n class D\n class E\n class F\n class G\n class H\n",
295
+ ),
296
+ 1, "超过 6 个",
297
+ )
298
+ expect(
299
+ "类图成员行过长",
300
+ MERMAID_CHAPTER.replace(
301
+ " class A\n",
302
+ " class A {\n +bool CompletesLine(CellPosition position, CellType cell_type) const\n }\n",
303
+ ).replace("direction LR", "direction TB"),
304
+ 1, "超过 40 列",
305
+ )
306
+ expect(
307
+ "单个类成员过多",
308
+ MERMAID_CHAPTER.replace(
309
+ " class A\n",
310
+ " class A {\n int a\n int b\n int c\n int d\n int e\n int f\n }\n",
311
+ ).replace("direction LR", "direction TB"),
312
+ 1, "超过 5 行",
313
+ )
314
+ expect(
315
+ "类图成员总数过多",
316
+ MERMAID_CHAPTER.replace(
317
+ " class A\n class B\n",
318
+ " class A {\n int a1\n int a2\n int a3\n }\n"
319
+ " class B {\n int b1\n int b2\n int b3\n }\n"
320
+ " class C {\n int c1\n int c2\n int c3\n }\n"
321
+ " class D {\n int d1\n int d2\n int d3\n }\n"
322
+ " class E {\n int e1\n int e2\n int e3\n }\n",
323
+ ).replace("direction LR", "direction TB"),
324
+ 1, "超过 14 行",
325
+ )
326
+
129
327
  with tempfile.TemporaryDirectory() as raw:
130
328
  root = Path(raw)
131
329
  make_book(root)
@@ -141,7 +339,7 @@ def main() -> int:
141
339
  print(f" FAIL {line}")
142
340
  return 1
143
341
 
144
- print("PASS:9 个用例全部通过")
342
+ print("PASS:36 个用例全部通过")
145
343
  return 0
146
344
 
147
345
 
@@ -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,7 +48,29 @@ 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
+
55
+ TREE_CATEGORY_COMMENTS = frozenset({
56
+ "头文件", "源文件", "源码", "实现", "测试", "配置文件",
57
+ "构建脚本", "构建规则", "动态库", "可执行文件",
58
+ })
59
+ MAX_TREE_LINES = 20
60
+ MAX_TREE_COMMENT_WIDTH = 28
61
+ MAX_TREE_DIR_COMMENT_WIDTH = 16
62
+ MIN_TREE_GAP = 4
63
+
64
+ # 类图的内容预算。数字是版面判断,不是浏览器实测:正文栏约 840 px、字号
65
+ # 15 px 时,6 个类型、14 行成员、单行 40 列仍能在栏内排下;超过就删内容或拆图。
66
+ MAX_CLASS_DIAGRAM_TYPES = 6
67
+ MAX_CLASS_DIAGRAM_MEMBERS = 14
68
+ MAX_CLASS_MEMBERS = 5
69
+ MAX_CLASS_MEMBER_COLUMNS = 40
70
+ CLASS_RE = re.compile(r"^class\s+([A-Za-z_]\w*)\s*(\{)?")
71
+
49
72
  HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
73
+ HEADING_PERSONAL_RE = re.compile(r"自己|咱们|我们|你|您|跟我|试试|来看")
50
74
  FENCE_RE = re.compile(r"^\s*(```+|~~~+)\s*(\{[^}]*\}|[^\s`]*)?")
51
75
  LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
52
76
  CJK_RE = re.compile(r"[\u3400-\u9fff]")
@@ -158,6 +182,10 @@ def check_heading(path: Path, line: int, level: int, text: str, issues: list[Iss
158
182
  issues.append(Issue(path, line, "空标题"))
159
183
  if re.search(r"[。!?;]$", text):
160
184
  issues.append(Issue(path, line, "标题末尾带句读"))
185
+ if HEADING_PERSONAL_RE.search(text):
186
+ issues.append(Issue(path, line, "标题带人称或口语劝诱;改写成对象名或「动词 + 具体宾语」"))
187
+ if "`" in text:
188
+ issues.append(Issue(path, line, "标题带行内代码;文件名留给正文,标题写对象"))
161
189
 
162
190
 
163
191
  def check_prose_block(path: Path, start_line: int, text: str, issues: list[Issue]) -> None:
@@ -183,6 +211,220 @@ def check_prose_block(path: Path, start_line: int, text: str, issues: list[Issue
183
211
  break
184
212
 
185
213
 
214
+ def display_width(text: str) -> int:
215
+ return sum(2 if unicodedata.east_asian_width(char) in {"W", "F"} else 1 for char in text)
216
+
217
+
218
+ def check_mermaid_block(path: Path, block: list[tuple[int, str]]) -> list[Issue]:
219
+ """Check a class diagram's content budget and direction."""
220
+ diagram_at = next((index for index, (_, raw) in enumerate(block) if "classDiagram" in raw), None)
221
+ if diagram_at is None:
222
+ return []
223
+
224
+ issues: list[Issue] = []
225
+ start_line = block[diagram_at][0]
226
+ text = "\n".join(raw for _, raw in block)
227
+ if "hideEmptyMembersBox" not in text:
228
+ issues.append(Issue(
229
+ path, start_line,
230
+ "类图块首缺少 hideEmptyMembersBox 配置;空类会画出空成员栏",
231
+ ))
232
+ caption_at = next(
233
+ (index for index, (_, raw) in enumerate(block) if raw.lstrip().startswith("%%| fig-cap:")),
234
+ None,
235
+ )
236
+ init_at = next(
237
+ (index for index, (_, raw) in enumerate(block) if raw.lstrip().startswith("%%{init")),
238
+ None,
239
+ )
240
+ if caption_at is None:
241
+ issues.append(Issue(
242
+ path, start_line,
243
+ "类图缺少 fig-cap 图注;线型与读图约定应写在图注里",
244
+ ))
245
+ elif init_at is not None and caption_at > init_at:
246
+ issues.append(Issue(
247
+ path, block[caption_at][0],
248
+ "fig-cap 必须写在 init 之前;写在 init 之后会原样渲染成 Mermaid 注释",
249
+ ))
250
+
251
+ classes: dict[str, list[tuple[int, str]]] = {}
252
+ order: list[str] = []
253
+ direction = ""
254
+ direction_line = start_line
255
+ current: str | None = None
256
+ for number, raw in block[diagram_at:]:
257
+ line = raw.strip()
258
+ if not line or line.startswith("%%"):
259
+ continue
260
+ if line.startswith("direction "):
261
+ direction = line.split(None, 1)[1].strip().upper()
262
+ direction_line = number
263
+ continue
264
+ match = CLASS_RE.match(line)
265
+ if match:
266
+ name = match.group(1)
267
+ if name not in classes:
268
+ classes[name] = []
269
+ order.append(name)
270
+ current = name if "{" in line else None
271
+ continue
272
+ if current is None:
273
+ continue
274
+ if line == "}":
275
+ current = None
276
+ continue
277
+ if line.startswith("<<") and line.endswith(">>"):
278
+ continue
279
+ classes[current].append((number, line))
280
+
281
+ total_members = sum(len(lines) for lines in classes.values())
282
+ if len(order) > MAX_CLASS_DIAGRAM_TYPES:
283
+ issues.append(Issue(
284
+ path, start_line,
285
+ f"类图有 {len(order)} 个类型,超过 {MAX_CLASS_DIAGRAM_TYPES} 个;删节点或拆图",
286
+ ))
287
+ if total_members > MAX_CLASS_DIAGRAM_MEMBERS:
288
+ issues.append(Issue(
289
+ path, start_line,
290
+ f"类图成员共 {total_members} 行,超过 {MAX_CLASS_DIAGRAM_MEMBERS} 行;只留本章连边要用的成员",
291
+ ))
292
+ for name in order:
293
+ members = classes[name]
294
+ if len(members) > MAX_CLASS_MEMBERS:
295
+ issues.append(Issue(
296
+ path, members[MAX_CLASS_MEMBERS][0],
297
+ f"类 {name} 有 {len(members)} 行成员,超过 {MAX_CLASS_MEMBERS} 行;删到本章要用的",
298
+ ))
299
+ for number, line in members:
300
+ width = display_width(line)
301
+ if width > MAX_CLASS_MEMBER_COLUMNS:
302
+ issues.append(Issue(
303
+ path, number,
304
+ f"类图成员行 {width} 列,超过 {MAX_CLASS_MEMBER_COLUMNS} 列;参数省略类型名",
305
+ ))
306
+ if direction == "LR" and total_members:
307
+ issues.append(Issue(
308
+ path, direction_line,
309
+ "类图有成员却用 LR;改成 TB,让类型上下排",
310
+ ))
311
+ return issues
312
+
313
+
314
+ def check_mermaid_wrapper(path: Path, lines: list[str], block: list[tuple[int, str]]) -> list[Issue]:
315
+ """Check a class diagram sits in the .class-diagram scroll container."""
316
+ if not any("classDiagram" in raw for _, raw in block):
317
+ return []
318
+ start = block[0][0]
319
+ index = start - 2
320
+ if 0 <= index < len(lines) and lines[index].lstrip().startswith("```"):
321
+ index -= 1
322
+ while index >= 0 and not lines[index].strip():
323
+ index -= 1
324
+ if index < 0 or ".class-diagram" not in lines[index]:
325
+ return [Issue(
326
+ path, start,
327
+ "类图缺少 .class-diagram 包裹;无法应用类图的宽度与居中规则",
328
+ )]
329
+ return []
330
+
331
+
332
+ def check_tree_block(path: Path, block: list[tuple[int, str]]) -> list[Issue]:
333
+ """Check tree glyph lines: size, marker, width, items, and two marker columns."""
334
+ tree_lines = [(number, raw) for number, raw in block if "├──" in raw or "└──" in raw]
335
+ if len(tree_lines) < 2:
336
+ return []
337
+ issues: list[Issue] = []
338
+ if len(tree_lines) > MAX_TREE_LINES:
339
+ issues.append(Issue(
340
+ path, tree_lines[0][0],
341
+ f"树有 {len(tree_lines)} 行,超过 {MAX_TREE_LINES} 行;只留本章会点名的文件",
342
+ ))
343
+ dirs: list[tuple[int, int]] = []
344
+ files: list[tuple[int, int]] = []
345
+ text_columns: list[tuple[int, int]] = []
346
+ for number, raw in tree_lines:
347
+ hash_index = raw.find("#")
348
+ if hash_index < 0:
349
+ issues.append(Issue(path, number, "树行缺少行尾注释"))
350
+ continue
351
+ prefix = raw[:hash_index]
352
+ entry = prefix.rstrip()
353
+ if display_width(prefix) - display_width(entry) < MIN_TREE_GAP:
354
+ issues.append(Issue(path, number, "节点与注释之间至少留四个空格"))
355
+ rest = raw[hash_index:]
356
+ marker = "#/" if rest.startswith("#/") else "#"
357
+ after = rest[len(marker):]
358
+ if not after.startswith(" ") or (len(after) > 1 and after[1] == " "):
359
+ issues.append(Issue(path, number, "标记后只留一个空格"))
360
+ is_directory = entry.endswith("/")
361
+ if is_directory and marker != "#/":
362
+ issues.append(Issue(path, number, "目录行的注释以 #/ 开头"))
363
+ if not is_directory and marker == "#/":
364
+ issues.append(Issue(path, number, "文件行的注释以 # 开头"))
365
+ comment = after.strip()
366
+ if not comment:
367
+ issues.append(Issue(path, number, "树行注释为空"))
368
+ continue
369
+ if is_directory:
370
+ if ":" in comment or ":" in comment:
371
+ issues.append(Issue(
372
+ path, number,
373
+ "目录注释只写这里装什么,不加冒号或子项职责",
374
+ ))
375
+ if "、" in comment:
376
+ issues.append(Issue(
377
+ path, number,
378
+ "目录注释不列清单,用名词短语",
379
+ ))
380
+ if display_width(comment) > MAX_TREE_DIR_COMMENT_WIDTH:
381
+ issues.append(Issue(
382
+ path, number,
383
+ f"目录注释 {display_width(comment)} 列,超过 {MAX_TREE_DIR_COMMENT_WIDTH} 列;只留名词短语",
384
+ ))
385
+ else:
386
+ if display_width(comment) > MAX_TREE_COMMENT_WIDTH:
387
+ issues.append(Issue(
388
+ path, number,
389
+ f"注释 {display_width(comment)} 列,超过 {MAX_TREE_COMMENT_WIDTH} 列;缩成短名词短语",
390
+ ))
391
+ elif comment.rstrip("::") in TREE_CATEGORY_COMMENTS:
392
+ issues.append(Issue(path, number, f"注释只写类别词「{comment}」;写对象与用途"))
393
+ separators = comment.count("、") + len(re.findall(r"(?<!参)与", comment))
394
+ if separators > 1:
395
+ issues.append(Issue(
396
+ path, number, f"注释列了 {separators + 1} 项;压到 1 到 2 项",
397
+ ))
398
+ column = display_width(prefix)
399
+ if is_directory:
400
+ dirs.append((number, column))
401
+ else:
402
+ files.append((number, column))
403
+ text_columns.append((number, column + len(marker) + len(after) - len(after.lstrip(" "))))
404
+ if dirs:
405
+ dir_column = dirs[0][1]
406
+ if any(column != dir_column for _, column in dirs[1:]):
407
+ issues.append(Issue(path, dirs[0][0], "目录 #/ 未对齐"))
408
+ for number, column in files:
409
+ if column != dir_column + 1:
410
+ issues.append(Issue(
411
+ path, number,
412
+ f"文件 # 应比目录 # 右移一列(目录在 {dir_column},文件在 {column})",
413
+ ))
414
+ break
415
+ elif files:
416
+ file_column = files[0][1]
417
+ if any(column != file_column for _, column in files[1:]):
418
+ issues.append(Issue(path, files[0][0], "文件 # 未对齐"))
419
+ if text_columns:
420
+ text_column = text_columns[0][1]
421
+ for number, column in text_columns[1:]:
422
+ if column != text_column:
423
+ issues.append(Issue(path, number, "注释文字未对齐"))
424
+ break
425
+ return issues
426
+
427
+
186
428
  def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> list[Issue]:
187
429
  issues: list[Issue] = []
188
430
  text = read_text(path)
@@ -198,6 +440,9 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
198
440
  in_fence = False
199
441
  fence_lang = ""
200
442
  fence_start = 0
443
+ fence_lines: list[tuple[int, str]] = []
444
+ fence_blocks: list[list[tuple[int, str]]] = []
445
+ mermaid_blocks: list[list[tuple[int, str]]] = []
201
446
  paragraph: list[str] = []
202
447
  paragraph_start = 0
203
448
 
@@ -215,6 +460,7 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
215
460
  in_fence = True
216
461
  fence_start = number
217
462
  fence_lang = (fence.group(2) or "").strip()
463
+ fence_lines = []
218
464
  if not fence_lang:
219
465
  issues.append(Issue(path, number, "代码围栏未声明语言"))
220
466
  elif fence_lang == "mermaid":
@@ -226,11 +472,17 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
226
472
  pass
227
473
  else:
228
474
  in_fence = False
475
+ if fence_lines:
476
+ fence_blocks.append(fence_lines)
477
+ if "mermaid" in fence_lang:
478
+ mermaid_blocks.append(fence_lines)
479
+ fence_lines = []
229
480
  continue
230
481
 
231
482
  if in_fence:
232
483
  if "\t" in raw:
233
484
  issues.append(Issue(path, number, "代码含制表符(改用空格)"))
485
+ fence_lines.append((number, raw))
234
486
  continue
235
487
 
236
488
  heading = HEADING_RE.match(raw)
@@ -263,6 +515,13 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
263
515
 
264
516
  flush_paragraph()
265
517
 
518
+ for block in fence_blocks:
519
+ issues.extend(check_tree_block(path, block))
520
+
521
+ for block in mermaid_blocks:
522
+ issues.extend(check_mermaid_block(path, block))
523
+ issues.extend(check_mermaid_wrapper(path, lines, block))
524
+
266
525
  for index, raw in enumerate(lines, start=1):
267
526
  match = LINK_RE.search(raw)
268
527
  if not match:
@@ -291,6 +550,28 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
291
550
  issues.append(Issue(path, index, f"项目禁用词「{term}」"))
292
551
  break
293
552
 
553
+ for index, raw in enumerate(lines, start=1):
554
+ if raw.lstrip().startswith(("```", " ")):
555
+ continue
556
+ match = QA_PROSE_RE.search(raw)
557
+ if match:
558
+ issues.append(Issue(
559
+ path, index,
560
+ f"问答式讲解「{match.group(0)}」;改成返回值、判断结果或直接陈述",
561
+ ))
562
+ match = CODE_META_RE.search(raw)
563
+ if match:
564
+ issues.append(Issue(
565
+ path, index,
566
+ f"代码字面复述「{match.group(0)}」;改成围栏没给出的行为或约束",
567
+ ))
568
+ match = COINED_RELATION_RE.search(raw)
569
+ if match:
570
+ issues.append(Issue(
571
+ path, index,
572
+ f"自造关系词「{match.group(0)}」;用组成、调用、依赖、继承等通行词",
573
+ ))
574
+
294
575
  required = project.get("chapter_titles", {}).get(rel)
295
576
  if required:
296
577
  title = parse_frontmatter_title(front)