@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.
- package/README.en.md +60 -31
- package/README.md +53 -24
- package/lib/tools/render-check.js +1 -1
- package/package.json +5 -2
- package/skills/designing-theme/assets/theme/assets/fonts/lxgw-bright-code-common.woff2 +0 -0
- package/skills/designing-theme/assets/theme/assets/fonts/lxgw-bright-medium-common.woff2 +0 -0
- package/skills/designing-theme/assets/theme/css/mermaid.css +5 -6
- package/skills/designing-theme/knowledge/reading-surface.md +1 -1
- package/skills/designing-theme/references/theme-system.md +5 -5
- package/skills/designing-theme/scripts/checking-layout.py +49 -9
- package/skills/writing-quarto/SKILL.md +2 -2
- package/skills/writing-quarto/examples/02-structure-shape.md +54 -12
- package/skills/writing-quarto/examples/03-block-after.md +14 -0
- package/skills/writing-quarto/examples/03-qmd-elements.md +16 -0
- package/skills/writing-quarto/examples/04-paragraph-cases.md +14 -0
- package/skills/writing-quarto/examples/04-paragraph-opening.md +2 -2
- package/skills/writing-quarto/examples/05-sentence-frame.md +4 -4
- package/skills/writing-quarto/examples/05-sentence-reference.md +14 -0
- package/skills/writing-quarto/examples/06-wording-cases.md +37 -0
- package/skills/writing-quarto/examples/{03-layout-list.md → 07-layout-list.md} +14 -0
- package/skills/writing-quarto/examples/08-appendix.md +2 -2
- package/skills/writing-quarto/quarto/authoring.md +4 -4
- package/skills/writing-quarto/quarto/basics.md +17 -17
- package/skills/writing-quarto/quarto/mermaid.md +41 -5
- package/skills/writing-quarto/quarto/prose.md +25 -2
- package/skills/writing-quarto/quarto/terminal.md +1 -1
- package/skills/writing-quarto/scripts/testing-content.py +199 -1
- package/skills/writing-quarto/scripts/verifying-content.py +281 -0
- package/skills/writing-quarto/steps/02-structure.md +9 -26
- package/skills/writing-quarto/steps/03-blocks.md +31 -20
- package/skills/writing-quarto/steps/04-paragraphs.md +35 -18
- package/skills/writing-quarto/steps/05-sentences.md +24 -14
- package/skills/writing-quarto/steps/06-wording.md +37 -0
- package/skills/writing-quarto/steps/07-layout.md +12 -13
- package/skills/writing-quarto/steps/08-reread.md +7 -4
- package/skills/writing-quarto/steps/09-check.md +3 -3
- package/templates/quarto-book-project/SKILL.md +1 -1
- package/templates/quarto-book-project/checks.json +5 -0
- 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:
|
|
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)
|