@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.
- package/README.en.md +60 -31
- package/README.md +53 -24
- package/lib/index.js +49 -7
- package/lib/tools/pass-report.js +12 -12
- package/lib/tools/render-check.js +231 -62
- package/lib/workspace.js +1 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +13 -7
- 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/content.css +15 -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 +60 -12
- package/skills/designing-theme/scripts/measuring-pages.mjs +2 -2
- package/skills/writing-quarto/SKILL.md +7 -0
- package/skills/writing-quarto/examples/02-structure-shape.md +26 -12
- package/skills/writing-quarto/examples/03-block-after.md +14 -0
- package/skills/writing-quarto/examples/03-qmd-elements.md +45 -2
- 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/06-wording-cases.md +23 -0
- package/skills/writing-quarto/examples/07-layout-list.md +3 -3
- package/skills/writing-quarto/examples/08-appendix.md +1 -1
- package/skills/writing-quarto/quarto/authoring.md +7 -6
- package/skills/writing-quarto/quarto/basics.md +17 -16
- package/skills/writing-quarto/quarto/coords.md +2 -1
- package/skills/writing-quarto/quarto/figures.md +49 -0
- package/skills/writing-quarto/quarto/mermaid.md +41 -5
- package/skills/writing-quarto/quarto/prose.md +4 -4
- package/skills/writing-quarto/quarto/refactor.md +1 -1
- package/skills/writing-quarto/quarto/terminal.md +1 -1
- package/skills/writing-quarto/references/book-journey.md +38 -0
- package/skills/writing-quarto/references/token-budget.md +26 -0
- package/skills/writing-quarto/scripts/checking-callouts.py +2 -2
- package/skills/writing-quarto/scripts/checking-dom.py +12 -6
- package/skills/writing-quarto/scripts/checking-links.py +29 -13
- package/skills/writing-quarto/scripts/checking-names.py +3 -1
- package/skills/writing-quarto/scripts/checking-output.py +3 -3
- package/skills/writing-quarto/scripts/checking-prose.py +11 -7
- package/skills/writing-quarto/scripts/testing-content.py +264 -1
- package/skills/writing-quarto/scripts/verifying-content.py +442 -36
- package/skills/writing-quarto/steps/03-blocks.md +20 -8
- package/skills/writing-quarto/steps/04-paragraphs.md +19 -2
- package/skills/writing-quarto/steps/06-wording.md +53 -0
- package/skills/writing-quarto/steps/07-layout.md +1 -1
- package/skills/writing-quarto/steps/08-reread.md +1 -0
- 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
|
@@ -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
|
-
|
|
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
|
|
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
|
|
145
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|