@chengzhao-dev/dsh-quarto-book 0.3.4 → 0.4.0
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/LICENSE +20 -20
- package/README.en.md +35 -4
- package/README.md +35 -4
- package/cordis.patch.yml +2 -2
- package/docs/plugin-flow.md +15 -4
- package/docs/versioning.md +9 -9
- package/lib/index.js +3 -1
- package/lib/tools/pass-report.js +332 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +10 -2
- package/skills/designing-theme/SKILL.md +1 -0
- package/skills/designing-theme/assets/theme/css/base.css +368 -368
- package/skills/designing-theme/assets/theme/includes/footer.html +63 -63
- package/skills/designing-theme/assets/theme/palettes/github/meta.md +38 -38
- package/skills/designing-theme/knowledge/reading-surface.md +68 -68
- package/skills/designing-theme/scripts/measuring-pages.mjs +309 -309
- package/skills/writing-quarto/SKILL.md +44 -43
- package/skills/writing-quarto/scripts/checking-callouts.py +102 -102
- package/skills/writing-quarto/scripts/checking-dom.py +577 -577
- package/skills/writing-quarto/scripts/diagrams/rendering-coords.py +250 -250
- package/skills/writing-quarto/scripts/testing-check-ascii-names.py +71 -71
- package/skills/writing-quarto/scripts/testing-content.py +149 -149
- package/skills/writing-quarto/scripts/verifying-content.py +378 -378
- package/skills/writing-quarto/steps/09-check.md +13 -1
- package/templates/quarto-book-project/writing/chapter-map.md +22 -22
|
@@ -1,378 +1,378 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Audit a Quarto Book's chapter sources.
|
|
3
|
-
|
|
4
|
-
The checks encode the writing-quarto conventions: one chapter table in
|
|
5
|
-
`_quarto.yml`, YAML titles instead of a body H1, ##/### heading depth, short
|
|
6
|
-
paragraphs and sentences, declared code-fence languages, `{mermaid}` fences,
|
|
7
|
-
and no placeholder text left in the prose.
|
|
8
|
-
|
|
9
|
-
Project-specific rules are optional. If the book has
|
|
10
|
-
`.agents/skills/quarto-book-project/SKILL.md`, the script also reads
|
|
11
|
-
`.agents/skills/quarto-book-project/checks.json` when present:
|
|
12
|
-
|
|
13
|
-
{
|
|
14
|
-
"forbidden_terms": ["..."],
|
|
15
|
-
"fragment_prefixes": ["games/", "shared/"],
|
|
16
|
-
"chapter_titles": {"content/part/01-intro.qmd": "入门"}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
Without a project skill, those checks are skipped and only the generic rules
|
|
20
|
-
run. This keeps another book's chapter names and vocabulary out of this book.
|
|
21
|
-
|
|
22
|
-
Usage:
|
|
23
|
-
python verifying-content.py [--root PATH] [--strict] [--quiet]
|
|
24
|
-
|
|
25
|
-
Exit codes: 0 = no blocking problem, 1 = problems found, 2 = bad root.
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
from __future__ import annotations
|
|
29
|
-
|
|
30
|
-
import argparse
|
|
31
|
-
import json
|
|
32
|
-
import re
|
|
33
|
-
import sys
|
|
34
|
-
from dataclasses import dataclass
|
|
35
|
-
from pathlib import Path
|
|
36
|
-
|
|
37
|
-
# Generic prose hygiene. These are writing problems in any technical book; the
|
|
38
|
-
# list stays short on purpose so it does not become one book's style guide.
|
|
39
|
-
GENERIC_FORBIDDEN_TERMS = (
|
|
40
|
-
"第一版", "探针", "自足", "链路", "四步", "契约链",
|
|
41
|
-
"本章规划", "众所周知", "毋庸置疑", "综上所述",
|
|
42
|
-
"务必", "严禁", "千万不要",
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
CHAPTER_NUMBER_RE = re.compile(r"第\s*\d+\s*章")
|
|
46
|
-
PLACEHOLDER_RE = re.compile(r"^\s*TODO|待补|待定|TBD", re.IGNORECASE)
|
|
47
|
-
TRAILING_SPACE_RE = re.compile(r"[ ]+$")
|
|
48
|
-
|
|
49
|
-
HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
|
|
50
|
-
FENCE_RE = re.compile(r"^\s*(```+|~~~+)\s*(\{[^}]*\}|[^\s`]*)?")
|
|
51
|
-
LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
|
|
52
|
-
CJK_RE = re.compile(r"[\u3400-\u9fff]")
|
|
53
|
-
TITLE_RE = re.compile(r"^title:\s*(.+?)\s*$", re.MULTILINE)
|
|
54
|
-
|
|
55
|
-
MAX_HEADING_LEVEL = 3
|
|
56
|
-
MAX_PARAGRAPH_CHARS = 320
|
|
57
|
-
MAX_SENTENCE_CHARS = 95
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
@dataclass
|
|
61
|
-
class Issue:
|
|
62
|
-
path: Path
|
|
63
|
-
line: int
|
|
64
|
-
message: str
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def read_text(path: Path) -> str:
|
|
68
|
-
return path.read_text(encoding="utf-8", errors="replace")
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def strip_frontmatter(text: str) -> tuple[str, str]:
|
|
72
|
-
if not text.startswith("---"):
|
|
73
|
-
return "", text
|
|
74
|
-
lines = text.splitlines(keepends=True)
|
|
75
|
-
for index in range(1, len(lines)):
|
|
76
|
-
if lines[index].strip() == "---":
|
|
77
|
-
return "".join(lines[1:index]), "".join(lines[index + 1:])
|
|
78
|
-
return "", text
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def parse_frontmatter_title(front: str) -> str:
|
|
82
|
-
match = TITLE_RE.search(front)
|
|
83
|
-
if not match:
|
|
84
|
-
return ""
|
|
85
|
-
value = match.group(1).strip()
|
|
86
|
-
if value.startswith(("'", '"')) and value.endswith(("'", '"')):
|
|
87
|
-
value = value[1:-1]
|
|
88
|
-
return value
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def parse_chapters(quarto_yml: Path) -> list[str]:
|
|
92
|
-
"""Return chapter hrefs in declaration order.
|
|
93
|
-
|
|
94
|
-
Supports the two forms Quarto Book uses:
|
|
95
|
-
chapters:
|
|
96
|
-
- index.qmd
|
|
97
|
-
- href: content/part/01-intro.qmd
|
|
98
|
-
It deliberately does not parse part titles as chapters.
|
|
99
|
-
"""
|
|
100
|
-
if not quarto_yml.is_file():
|
|
101
|
-
return []
|
|
102
|
-
chapters: list[str] = []
|
|
103
|
-
in_chapters = False
|
|
104
|
-
for raw in read_text(quarto_yml).splitlines():
|
|
105
|
-
stripped = raw.strip()
|
|
106
|
-
if re.match(r"^chapters:\s*$", stripped):
|
|
107
|
-
in_chapters = True
|
|
108
|
-
continue
|
|
109
|
-
if not in_chapters:
|
|
110
|
-
continue
|
|
111
|
-
if stripped and not raw.startswith((" ", "\t", "-")):
|
|
112
|
-
in_chapters = False
|
|
113
|
-
continue
|
|
114
|
-
if not stripped.startswith("-"):
|
|
115
|
-
continue
|
|
116
|
-
item = stripped[1:].strip()
|
|
117
|
-
href = re.sub(r"^href:\s*", "", item).strip().strip("'\"")
|
|
118
|
-
if href.endswith(".qmd"):
|
|
119
|
-
chapters.append(href)
|
|
120
|
-
return chapters
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def collect_chapter_files(root: Path, chapters: list[str]) -> list[Path]:
|
|
124
|
-
found: list[Path] = []
|
|
125
|
-
for rel in chapters:
|
|
126
|
-
candidate = root / rel
|
|
127
|
-
if candidate.is_file() and candidate not in found:
|
|
128
|
-
found.append(candidate)
|
|
129
|
-
index = root / "index.qmd"
|
|
130
|
-
if index.is_file() and index not in found:
|
|
131
|
-
found.append(index)
|
|
132
|
-
if chapters:
|
|
133
|
-
return found
|
|
134
|
-
# No chapter table: audit every tracked .qmd outside generated/vendor dirs.
|
|
135
|
-
excluded = {"node_modules", "_book", ".quarto", ".git", "_freeze", "_site"}
|
|
136
|
-
for candidate in sorted(root.rglob("*.qmd")):
|
|
137
|
-
rel_parts = candidate.relative_to(root).parts
|
|
138
|
-
if any(part in excluded or part.startswith(".") for part in rel_parts):
|
|
139
|
-
continue
|
|
140
|
-
if candidate not in found:
|
|
141
|
-
found.append(candidate)
|
|
142
|
-
return found
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def check_heading(path: Path, line: int, level: int, text: str, issues: list[Issue]) -> None:
|
|
146
|
-
if level > MAX_HEADING_LEVEL:
|
|
147
|
-
issues.append(Issue(path, line, f"H{level} 超出允许深度 H{MAX_HEADING_LEVEL}"))
|
|
148
|
-
if not text:
|
|
149
|
-
issues.append(Issue(path, line, "空标题"))
|
|
150
|
-
if re.search(r"[。!?;]$", text):
|
|
151
|
-
issues.append(Issue(path, line, "标题末尾带句读"))
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def check_prose_block(path: Path, start_line: int, text: str, issues: list[Issue]) -> None:
|
|
155
|
-
compact = text.strip()
|
|
156
|
-
if not compact:
|
|
157
|
-
return
|
|
158
|
-
if CHAPTER_NUMBER_RE.search(compact):
|
|
159
|
-
issues.append(Issue(path, start_line, "第 N 章式编号指称(用章节标题或链接)"))
|
|
160
|
-
if CJK_RE.search(compact):
|
|
161
|
-
length = len(re.sub(r"\s+", "", compact))
|
|
162
|
-
if length > MAX_PARAGRAPH_CHARS:
|
|
163
|
-
issues.append(Issue(
|
|
164
|
-
path, start_line,
|
|
165
|
-
f"段落 {length} 字符,超过 {MAX_PARAGRAPH_CHARS};拆成两段或删枝蔓",
|
|
166
|
-
))
|
|
167
|
-
for sentence in re.split(r"(?<=[。!?])", compact):
|
|
168
|
-
sentence = sentence.strip()
|
|
169
|
-
if len(sentence) > MAX_SENTENCE_CHARS:
|
|
170
|
-
issues.append(Issue(
|
|
171
|
-
path, start_line,
|
|
172
|
-
f"句子 {len(sentence)} 字符,超过 {MAX_SENTENCE_CHARS};先拆主谓再补条件",
|
|
173
|
-
))
|
|
174
|
-
break
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> list[Issue]:
|
|
178
|
-
issues: list[Issue] = []
|
|
179
|
-
text = read_text(path)
|
|
180
|
-
front, body = strip_frontmatter(text)
|
|
181
|
-
rel = path.relative_to(root).as_posix()
|
|
182
|
-
|
|
183
|
-
if not front and path.name != "index.qmd":
|
|
184
|
-
issues.append(Issue(path, 1, "缺少 YAML frontmatter(章节 title 从这里读取)"))
|
|
185
|
-
elif front and not parse_frontmatter_title(front):
|
|
186
|
-
issues.append(Issue(path, 1, "frontmatter 缺少 title"))
|
|
187
|
-
|
|
188
|
-
lines = body.splitlines()
|
|
189
|
-
in_fence = False
|
|
190
|
-
fence_lang = ""
|
|
191
|
-
fence_start = 0
|
|
192
|
-
paragraph: list[str] = []
|
|
193
|
-
paragraph_start = 0
|
|
194
|
-
|
|
195
|
-
def flush_paragraph() -> None:
|
|
196
|
-
nonlocal paragraph
|
|
197
|
-
if paragraph:
|
|
198
|
-
check_prose_block(path, paragraph_start, " ".join(paragraph), issues)
|
|
199
|
-
paragraph = []
|
|
200
|
-
|
|
201
|
-
for number, raw in enumerate(lines, start=1):
|
|
202
|
-
fence = FENCE_RE.match(raw)
|
|
203
|
-
if fence:
|
|
204
|
-
flush_paragraph()
|
|
205
|
-
if not in_fence:
|
|
206
|
-
in_fence = True
|
|
207
|
-
fence_start = number
|
|
208
|
-
fence_lang = (fence.group(2) or "").strip()
|
|
209
|
-
if not fence_lang:
|
|
210
|
-
issues.append(Issue(path, number, "代码围栏未声明语言"))
|
|
211
|
-
elif fence_lang == "mermaid":
|
|
212
|
-
issues.append(Issue(path, number, "plain mermaid 围栏(改用 ```{mermaid})"))
|
|
213
|
-
elif fence_lang.startswith("{"):
|
|
214
|
-
inner = fence_lang.strip("{}").strip()
|
|
215
|
-
if inner and not inner.startswith((".", "=")) and "mermaid" not in inner:
|
|
216
|
-
# ```{python} style attribute fences are valid.
|
|
217
|
-
pass
|
|
218
|
-
else:
|
|
219
|
-
in_fence = False
|
|
220
|
-
continue
|
|
221
|
-
|
|
222
|
-
if in_fence:
|
|
223
|
-
if "\t" in raw:
|
|
224
|
-
issues.append(Issue(path, number, "代码含制表符(改用空格)"))
|
|
225
|
-
continue
|
|
226
|
-
|
|
227
|
-
heading = HEADING_RE.match(raw)
|
|
228
|
-
if heading:
|
|
229
|
-
flush_paragraph()
|
|
230
|
-
level = len(heading.group(1))
|
|
231
|
-
heading_text = heading.group(2)
|
|
232
|
-
if level == 1:
|
|
233
|
-
issues.append(Issue(path, number, "正文出现 H1;书名由 YAML title 提供"))
|
|
234
|
-
check_heading(path, number, level, heading_text, issues)
|
|
235
|
-
continue
|
|
236
|
-
|
|
237
|
-
if PLACEHOLDER_RE.search(raw):
|
|
238
|
-
issues.append(Issue(path, number, "残留占位内容"))
|
|
239
|
-
if TRAILING_SPACE_RE.search(raw):
|
|
240
|
-
issues.append(Issue(path, number, "行尾空白"))
|
|
241
|
-
|
|
242
|
-
if not raw.strip():
|
|
243
|
-
flush_paragraph()
|
|
244
|
-
continue
|
|
245
|
-
|
|
246
|
-
if raw.lstrip().startswith((">", "|", "-", "*", "+", "1.")):
|
|
247
|
-
flush_paragraph()
|
|
248
|
-
check_prose_block(path, number, re.sub(r"^\s*(?:[>|]|[-*+]|\d+\.)\s*", "", raw), issues)
|
|
249
|
-
continue
|
|
250
|
-
|
|
251
|
-
if not paragraph:
|
|
252
|
-
paragraph_start = number
|
|
253
|
-
paragraph.append(raw.strip())
|
|
254
|
-
|
|
255
|
-
flush_paragraph()
|
|
256
|
-
|
|
257
|
-
for index, raw in enumerate(lines, start=1):
|
|
258
|
-
match = LINK_RE.search(raw)
|
|
259
|
-
if not match:
|
|
260
|
-
continue
|
|
261
|
-
target = match.group(1).strip()
|
|
262
|
-
if target.startswith(("http://", "https://", "mailto:", "#", "data:")):
|
|
263
|
-
continue
|
|
264
|
-
clean = target.split("#", 1)[0].split("?", 1)[0]
|
|
265
|
-
if not clean or "<" in clean:
|
|
266
|
-
continue
|
|
267
|
-
if not (path.parent / clean).exists() and not (root / clean).exists():
|
|
268
|
-
issues.append(Issue(path, index, f"本地链接不可达:{target}"))
|
|
269
|
-
|
|
270
|
-
if text.count("```") % 2 != 0:
|
|
271
|
-
issues.append(Issue(path, len(lines), "代码围栏数量为奇数,存在未闭合围栏"))
|
|
272
|
-
|
|
273
|
-
for term in GENERIC_FORBIDDEN_TERMS:
|
|
274
|
-
for index, raw in enumerate(lines, start=1):
|
|
275
|
-
if term in raw and not raw.lstrip().startswith(("```", " ")):
|
|
276
|
-
issues.append(Issue(path, index, f"禁用词「{term}」"))
|
|
277
|
-
break
|
|
278
|
-
|
|
279
|
-
for term in project.get("forbidden_terms", []):
|
|
280
|
-
for index, raw in enumerate(lines, start=1):
|
|
281
|
-
if term and term in raw:
|
|
282
|
-
issues.append(Issue(path, index, f"项目禁用词「{term}」"))
|
|
283
|
-
break
|
|
284
|
-
|
|
285
|
-
required = project.get("chapter_titles", {}).get(rel)
|
|
286
|
-
if required:
|
|
287
|
-
title = parse_frontmatter_title(front)
|
|
288
|
-
if required not in title:
|
|
289
|
-
issues.append(Issue(path, 1, f"章节 title 应包含「{required}」,实际为「{title}」"))
|
|
290
|
-
|
|
291
|
-
if rel not in chapters and path.name != "index.qmd" and path.parent.name != "":
|
|
292
|
-
issues.append(Issue(path, 1, "文件未列入 _quarto.yml 的 chapters(目录会漏章)"))
|
|
293
|
-
|
|
294
|
-
return issues
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
def load_project(root: Path) -> tuple[bool, dict]:
|
|
298
|
-
skill = root / ".agents" / "skills" / "quarto-book-project" / "SKILL.md"
|
|
299
|
-
if not skill.is_file():
|
|
300
|
-
return False, {}
|
|
301
|
-
config_path = skill.parent / "checks.json"
|
|
302
|
-
if not config_path.is_file():
|
|
303
|
-
return True, {}
|
|
304
|
-
try:
|
|
305
|
-
data = json.loads(read_text(config_path))
|
|
306
|
-
except json.JSONDecodeError as exc:
|
|
307
|
-
print(f"警告:{config_path} 不是合法 JSON({exc}),忽略项目规则")
|
|
308
|
-
return True, {}
|
|
309
|
-
if not isinstance(data, dict):
|
|
310
|
-
print(f"警告:{config_path} 顶层应为对象,忽略项目规则")
|
|
311
|
-
return True, {}
|
|
312
|
-
return True, data
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
def main() -> int:
|
|
316
|
-
parser = argparse.ArgumentParser(description="检查 Quarto Book 章节源码")
|
|
317
|
-
parser.add_argument("--root", default=".", help="书根目录(默认当前目录)")
|
|
318
|
-
parser.add_argument("--strict", action="store_true", help="把未列入目录的文件也视为错误")
|
|
319
|
-
parser.add_argument("--quiet", action="store_true", help="只输出汇总")
|
|
320
|
-
args = parser.parse_args()
|
|
321
|
-
|
|
322
|
-
try:
|
|
323
|
-
sys.stdout.reconfigure(encoding="utf-8")
|
|
324
|
-
sys.stderr.reconfigure(encoding="utf-8")
|
|
325
|
-
except Exception:
|
|
326
|
-
pass
|
|
327
|
-
|
|
328
|
-
root = Path(args.root).resolve()
|
|
329
|
-
if not root.is_dir():
|
|
330
|
-
print(f"失败 根目录不存在: {root}")
|
|
331
|
-
return 2
|
|
332
|
-
|
|
333
|
-
quarto_yml = root / "_quarto.yml"
|
|
334
|
-
chapters = parse_chapters(quarto_yml)
|
|
335
|
-
files = collect_chapter_files(root, chapters)
|
|
336
|
-
if not files:
|
|
337
|
-
print(f"失败 {root} 下没有 .qmd 章节(确认 --root 指向书根目录)")
|
|
338
|
-
return 1
|
|
339
|
-
|
|
340
|
-
has_project, project = load_project(root)
|
|
341
|
-
|
|
342
|
-
missing = [rel for rel in chapters if not (root / rel).is_file()]
|
|
343
|
-
issues: list[Issue] = [
|
|
344
|
-
Issue(quarto_yml, 1, f"目录中的章节不存在:{rel}") for rel in missing
|
|
345
|
-
]
|
|
346
|
-
|
|
347
|
-
for path in files:
|
|
348
|
-
issues.extend(check_file(path, root, project, chapters))
|
|
349
|
-
|
|
350
|
-
if args.strict:
|
|
351
|
-
for issue in list(issues):
|
|
352
|
-
if "未列入 _quarto.yml" in issue.message:
|
|
353
|
-
issue.message = issue.message.replace("(目录会漏章)", "")
|
|
354
|
-
else:
|
|
355
|
-
issues = [issue for issue in issues if "未列入 _quarto.yml" not in issue.message]
|
|
356
|
-
|
|
357
|
-
if not args.quiet:
|
|
358
|
-
print(f"根目录:{root}")
|
|
359
|
-
print(f"章节:{len(files)} 个 .qmd;目录声明:{len(chapters)} 条")
|
|
360
|
-
print(f"项目规则:{'已加载' if has_project else '未发现 .agents/skills/quarto-book-project,跳过'}")
|
|
361
|
-
print()
|
|
362
|
-
|
|
363
|
-
if not issues:
|
|
364
|
-
print(f"OK:{len(files)} 个章节通过通用检查。")
|
|
365
|
-
return 0
|
|
366
|
-
|
|
367
|
-
issues.sort(key=lambda item: (str(item.path), item.line))
|
|
368
|
-
print(f"发现 {len(issues)} 个问题:")
|
|
369
|
-
for issue in issues[:120]:
|
|
370
|
-
rel = issue.path.relative_to(root).as_posix() if issue.path.is_relative_to(root) else str(issue.path)
|
|
371
|
-
print(f" {rel}:{issue.line}: {issue.message}")
|
|
372
|
-
if len(issues) > 120:
|
|
373
|
-
print(f" ……其余 {len(issues) - 120} 条省略")
|
|
374
|
-
return 1
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
if __name__ == "__main__":
|
|
378
|
-
sys.exit(main())
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Audit a Quarto Book's chapter sources.
|
|
3
|
+
|
|
4
|
+
The checks encode the writing-quarto conventions: one chapter table in
|
|
5
|
+
`_quarto.yml`, YAML titles instead of a body H1, ##/### heading depth, short
|
|
6
|
+
paragraphs and sentences, declared code-fence languages, `{mermaid}` fences,
|
|
7
|
+
and no placeholder text left in the prose.
|
|
8
|
+
|
|
9
|
+
Project-specific rules are optional. If the book has
|
|
10
|
+
`.agents/skills/quarto-book-project/SKILL.md`, the script also reads
|
|
11
|
+
`.agents/skills/quarto-book-project/checks.json` when present:
|
|
12
|
+
|
|
13
|
+
{
|
|
14
|
+
"forbidden_terms": ["..."],
|
|
15
|
+
"fragment_prefixes": ["games/", "shared/"],
|
|
16
|
+
"chapter_titles": {"content/part/01-intro.qmd": "入门"}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
Without a project skill, those checks are skipped and only the generic rules
|
|
20
|
+
run. This keeps another book's chapter names and vocabulary out of this book.
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
python verifying-content.py [--root PATH] [--strict] [--quiet]
|
|
24
|
+
|
|
25
|
+
Exit codes: 0 = no blocking problem, 1 = problems found, 2 = bad root.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import json
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
# Generic prose hygiene. These are writing problems in any technical book; the
|
|
38
|
+
# list stays short on purpose so it does not become one book's style guide.
|
|
39
|
+
GENERIC_FORBIDDEN_TERMS = (
|
|
40
|
+
"第一版", "探针", "自足", "链路", "四步", "契约链",
|
|
41
|
+
"本章规划", "众所周知", "毋庸置疑", "综上所述",
|
|
42
|
+
"务必", "严禁", "千万不要",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
CHAPTER_NUMBER_RE = re.compile(r"第\s*\d+\s*章")
|
|
46
|
+
PLACEHOLDER_RE = re.compile(r"^\s*TODO|待补|待定|TBD", re.IGNORECASE)
|
|
47
|
+
TRAILING_SPACE_RE = re.compile(r"[ ]+$")
|
|
48
|
+
|
|
49
|
+
HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
|
|
50
|
+
FENCE_RE = re.compile(r"^\s*(```+|~~~+)\s*(\{[^}]*\}|[^\s`]*)?")
|
|
51
|
+
LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
|
|
52
|
+
CJK_RE = re.compile(r"[\u3400-\u9fff]")
|
|
53
|
+
TITLE_RE = re.compile(r"^title:\s*(.+?)\s*$", re.MULTILINE)
|
|
54
|
+
|
|
55
|
+
MAX_HEADING_LEVEL = 3
|
|
56
|
+
MAX_PARAGRAPH_CHARS = 320
|
|
57
|
+
MAX_SENTENCE_CHARS = 95
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Issue:
|
|
62
|
+
path: Path
|
|
63
|
+
line: int
|
|
64
|
+
message: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def read_text(path: Path) -> str:
|
|
68
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def strip_frontmatter(text: str) -> tuple[str, str]:
|
|
72
|
+
if not text.startswith("---"):
|
|
73
|
+
return "", text
|
|
74
|
+
lines = text.splitlines(keepends=True)
|
|
75
|
+
for index in range(1, len(lines)):
|
|
76
|
+
if lines[index].strip() == "---":
|
|
77
|
+
return "".join(lines[1:index]), "".join(lines[index + 1:])
|
|
78
|
+
return "", text
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_frontmatter_title(front: str) -> str:
|
|
82
|
+
match = TITLE_RE.search(front)
|
|
83
|
+
if not match:
|
|
84
|
+
return ""
|
|
85
|
+
value = match.group(1).strip()
|
|
86
|
+
if value.startswith(("'", '"')) and value.endswith(("'", '"')):
|
|
87
|
+
value = value[1:-1]
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_chapters(quarto_yml: Path) -> list[str]:
|
|
92
|
+
"""Return chapter hrefs in declaration order.
|
|
93
|
+
|
|
94
|
+
Supports the two forms Quarto Book uses:
|
|
95
|
+
chapters:
|
|
96
|
+
- index.qmd
|
|
97
|
+
- href: content/part/01-intro.qmd
|
|
98
|
+
It deliberately does not parse part titles as chapters.
|
|
99
|
+
"""
|
|
100
|
+
if not quarto_yml.is_file():
|
|
101
|
+
return []
|
|
102
|
+
chapters: list[str] = []
|
|
103
|
+
in_chapters = False
|
|
104
|
+
for raw in read_text(quarto_yml).splitlines():
|
|
105
|
+
stripped = raw.strip()
|
|
106
|
+
if re.match(r"^chapters:\s*$", stripped):
|
|
107
|
+
in_chapters = True
|
|
108
|
+
continue
|
|
109
|
+
if not in_chapters:
|
|
110
|
+
continue
|
|
111
|
+
if stripped and not raw.startswith((" ", "\t", "-")):
|
|
112
|
+
in_chapters = False
|
|
113
|
+
continue
|
|
114
|
+
if not stripped.startswith("-"):
|
|
115
|
+
continue
|
|
116
|
+
item = stripped[1:].strip()
|
|
117
|
+
href = re.sub(r"^href:\s*", "", item).strip().strip("'\"")
|
|
118
|
+
if href.endswith(".qmd"):
|
|
119
|
+
chapters.append(href)
|
|
120
|
+
return chapters
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def collect_chapter_files(root: Path, chapters: list[str]) -> list[Path]:
|
|
124
|
+
found: list[Path] = []
|
|
125
|
+
for rel in chapters:
|
|
126
|
+
candidate = root / rel
|
|
127
|
+
if candidate.is_file() and candidate not in found:
|
|
128
|
+
found.append(candidate)
|
|
129
|
+
index = root / "index.qmd"
|
|
130
|
+
if index.is_file() and index not in found:
|
|
131
|
+
found.append(index)
|
|
132
|
+
if chapters:
|
|
133
|
+
return found
|
|
134
|
+
# No chapter table: audit every tracked .qmd outside generated/vendor dirs.
|
|
135
|
+
excluded = {"node_modules", "_book", ".quarto", ".git", "_freeze", "_site"}
|
|
136
|
+
for candidate in sorted(root.rglob("*.qmd")):
|
|
137
|
+
rel_parts = candidate.relative_to(root).parts
|
|
138
|
+
if any(part in excluded or part.startswith(".") for part in rel_parts):
|
|
139
|
+
continue
|
|
140
|
+
if candidate not in found:
|
|
141
|
+
found.append(candidate)
|
|
142
|
+
return found
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def check_heading(path: Path, line: int, level: int, text: str, issues: list[Issue]) -> None:
|
|
146
|
+
if level > MAX_HEADING_LEVEL:
|
|
147
|
+
issues.append(Issue(path, line, f"H{level} 超出允许深度 H{MAX_HEADING_LEVEL}"))
|
|
148
|
+
if not text:
|
|
149
|
+
issues.append(Issue(path, line, "空标题"))
|
|
150
|
+
if re.search(r"[。!?;]$", text):
|
|
151
|
+
issues.append(Issue(path, line, "标题末尾带句读"))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def check_prose_block(path: Path, start_line: int, text: str, issues: list[Issue]) -> None:
|
|
155
|
+
compact = text.strip()
|
|
156
|
+
if not compact:
|
|
157
|
+
return
|
|
158
|
+
if CHAPTER_NUMBER_RE.search(compact):
|
|
159
|
+
issues.append(Issue(path, start_line, "第 N 章式编号指称(用章节标题或链接)"))
|
|
160
|
+
if CJK_RE.search(compact):
|
|
161
|
+
length = len(re.sub(r"\s+", "", compact))
|
|
162
|
+
if length > MAX_PARAGRAPH_CHARS:
|
|
163
|
+
issues.append(Issue(
|
|
164
|
+
path, start_line,
|
|
165
|
+
f"段落 {length} 字符,超过 {MAX_PARAGRAPH_CHARS};拆成两段或删枝蔓",
|
|
166
|
+
))
|
|
167
|
+
for sentence in re.split(r"(?<=[。!?])", compact):
|
|
168
|
+
sentence = sentence.strip()
|
|
169
|
+
if len(sentence) > MAX_SENTENCE_CHARS:
|
|
170
|
+
issues.append(Issue(
|
|
171
|
+
path, start_line,
|
|
172
|
+
f"句子 {len(sentence)} 字符,超过 {MAX_SENTENCE_CHARS};先拆主谓再补条件",
|
|
173
|
+
))
|
|
174
|
+
break
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> list[Issue]:
|
|
178
|
+
issues: list[Issue] = []
|
|
179
|
+
text = read_text(path)
|
|
180
|
+
front, body = strip_frontmatter(text)
|
|
181
|
+
rel = path.relative_to(root).as_posix()
|
|
182
|
+
|
|
183
|
+
if not front and path.name != "index.qmd":
|
|
184
|
+
issues.append(Issue(path, 1, "缺少 YAML frontmatter(章节 title 从这里读取)"))
|
|
185
|
+
elif front and not parse_frontmatter_title(front):
|
|
186
|
+
issues.append(Issue(path, 1, "frontmatter 缺少 title"))
|
|
187
|
+
|
|
188
|
+
lines = body.splitlines()
|
|
189
|
+
in_fence = False
|
|
190
|
+
fence_lang = ""
|
|
191
|
+
fence_start = 0
|
|
192
|
+
paragraph: list[str] = []
|
|
193
|
+
paragraph_start = 0
|
|
194
|
+
|
|
195
|
+
def flush_paragraph() -> None:
|
|
196
|
+
nonlocal paragraph
|
|
197
|
+
if paragraph:
|
|
198
|
+
check_prose_block(path, paragraph_start, " ".join(paragraph), issues)
|
|
199
|
+
paragraph = []
|
|
200
|
+
|
|
201
|
+
for number, raw in enumerate(lines, start=1):
|
|
202
|
+
fence = FENCE_RE.match(raw)
|
|
203
|
+
if fence:
|
|
204
|
+
flush_paragraph()
|
|
205
|
+
if not in_fence:
|
|
206
|
+
in_fence = True
|
|
207
|
+
fence_start = number
|
|
208
|
+
fence_lang = (fence.group(2) or "").strip()
|
|
209
|
+
if not fence_lang:
|
|
210
|
+
issues.append(Issue(path, number, "代码围栏未声明语言"))
|
|
211
|
+
elif fence_lang == "mermaid":
|
|
212
|
+
issues.append(Issue(path, number, "plain mermaid 围栏(改用 ```{mermaid})"))
|
|
213
|
+
elif fence_lang.startswith("{"):
|
|
214
|
+
inner = fence_lang.strip("{}").strip()
|
|
215
|
+
if inner and not inner.startswith((".", "=")) and "mermaid" not in inner:
|
|
216
|
+
# ```{python} style attribute fences are valid.
|
|
217
|
+
pass
|
|
218
|
+
else:
|
|
219
|
+
in_fence = False
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
if in_fence:
|
|
223
|
+
if "\t" in raw:
|
|
224
|
+
issues.append(Issue(path, number, "代码含制表符(改用空格)"))
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
heading = HEADING_RE.match(raw)
|
|
228
|
+
if heading:
|
|
229
|
+
flush_paragraph()
|
|
230
|
+
level = len(heading.group(1))
|
|
231
|
+
heading_text = heading.group(2)
|
|
232
|
+
if level == 1:
|
|
233
|
+
issues.append(Issue(path, number, "正文出现 H1;书名由 YAML title 提供"))
|
|
234
|
+
check_heading(path, number, level, heading_text, issues)
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
if PLACEHOLDER_RE.search(raw):
|
|
238
|
+
issues.append(Issue(path, number, "残留占位内容"))
|
|
239
|
+
if TRAILING_SPACE_RE.search(raw):
|
|
240
|
+
issues.append(Issue(path, number, "行尾空白"))
|
|
241
|
+
|
|
242
|
+
if not raw.strip():
|
|
243
|
+
flush_paragraph()
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
if raw.lstrip().startswith((">", "|", "-", "*", "+", "1.")):
|
|
247
|
+
flush_paragraph()
|
|
248
|
+
check_prose_block(path, number, re.sub(r"^\s*(?:[>|]|[-*+]|\d+\.)\s*", "", raw), issues)
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
if not paragraph:
|
|
252
|
+
paragraph_start = number
|
|
253
|
+
paragraph.append(raw.strip())
|
|
254
|
+
|
|
255
|
+
flush_paragraph()
|
|
256
|
+
|
|
257
|
+
for index, raw in enumerate(lines, start=1):
|
|
258
|
+
match = LINK_RE.search(raw)
|
|
259
|
+
if not match:
|
|
260
|
+
continue
|
|
261
|
+
target = match.group(1).strip()
|
|
262
|
+
if target.startswith(("http://", "https://", "mailto:", "#", "data:")):
|
|
263
|
+
continue
|
|
264
|
+
clean = target.split("#", 1)[0].split("?", 1)[0]
|
|
265
|
+
if not clean or "<" in clean:
|
|
266
|
+
continue
|
|
267
|
+
if not (path.parent / clean).exists() and not (root / clean).exists():
|
|
268
|
+
issues.append(Issue(path, index, f"本地链接不可达:{target}"))
|
|
269
|
+
|
|
270
|
+
if text.count("```") % 2 != 0:
|
|
271
|
+
issues.append(Issue(path, len(lines), "代码围栏数量为奇数,存在未闭合围栏"))
|
|
272
|
+
|
|
273
|
+
for term in GENERIC_FORBIDDEN_TERMS:
|
|
274
|
+
for index, raw in enumerate(lines, start=1):
|
|
275
|
+
if term in raw and not raw.lstrip().startswith(("```", " ")):
|
|
276
|
+
issues.append(Issue(path, index, f"禁用词「{term}」"))
|
|
277
|
+
break
|
|
278
|
+
|
|
279
|
+
for term in project.get("forbidden_terms", []):
|
|
280
|
+
for index, raw in enumerate(lines, start=1):
|
|
281
|
+
if term and term in raw:
|
|
282
|
+
issues.append(Issue(path, index, f"项目禁用词「{term}」"))
|
|
283
|
+
break
|
|
284
|
+
|
|
285
|
+
required = project.get("chapter_titles", {}).get(rel)
|
|
286
|
+
if required:
|
|
287
|
+
title = parse_frontmatter_title(front)
|
|
288
|
+
if required not in title:
|
|
289
|
+
issues.append(Issue(path, 1, f"章节 title 应包含「{required}」,实际为「{title}」"))
|
|
290
|
+
|
|
291
|
+
if rel not in chapters and path.name != "index.qmd" and path.parent.name != "":
|
|
292
|
+
issues.append(Issue(path, 1, "文件未列入 _quarto.yml 的 chapters(目录会漏章)"))
|
|
293
|
+
|
|
294
|
+
return issues
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def load_project(root: Path) -> tuple[bool, dict]:
|
|
298
|
+
skill = root / ".agents" / "skills" / "quarto-book-project" / "SKILL.md"
|
|
299
|
+
if not skill.is_file():
|
|
300
|
+
return False, {}
|
|
301
|
+
config_path = skill.parent / "checks.json"
|
|
302
|
+
if not config_path.is_file():
|
|
303
|
+
return True, {}
|
|
304
|
+
try:
|
|
305
|
+
data = json.loads(read_text(config_path))
|
|
306
|
+
except json.JSONDecodeError as exc:
|
|
307
|
+
print(f"警告:{config_path} 不是合法 JSON({exc}),忽略项目规则")
|
|
308
|
+
return True, {}
|
|
309
|
+
if not isinstance(data, dict):
|
|
310
|
+
print(f"警告:{config_path} 顶层应为对象,忽略项目规则")
|
|
311
|
+
return True, {}
|
|
312
|
+
return True, data
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def main() -> int:
|
|
316
|
+
parser = argparse.ArgumentParser(description="检查 Quarto Book 章节源码")
|
|
317
|
+
parser.add_argument("--root", default=".", help="书根目录(默认当前目录)")
|
|
318
|
+
parser.add_argument("--strict", action="store_true", help="把未列入目录的文件也视为错误")
|
|
319
|
+
parser.add_argument("--quiet", action="store_true", help="只输出汇总")
|
|
320
|
+
args = parser.parse_args()
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
324
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
325
|
+
except Exception:
|
|
326
|
+
pass
|
|
327
|
+
|
|
328
|
+
root = Path(args.root).resolve()
|
|
329
|
+
if not root.is_dir():
|
|
330
|
+
print(f"失败 根目录不存在: {root}")
|
|
331
|
+
return 2
|
|
332
|
+
|
|
333
|
+
quarto_yml = root / "_quarto.yml"
|
|
334
|
+
chapters = parse_chapters(quarto_yml)
|
|
335
|
+
files = collect_chapter_files(root, chapters)
|
|
336
|
+
if not files:
|
|
337
|
+
print(f"失败 {root} 下没有 .qmd 章节(确认 --root 指向书根目录)")
|
|
338
|
+
return 1
|
|
339
|
+
|
|
340
|
+
has_project, project = load_project(root)
|
|
341
|
+
|
|
342
|
+
missing = [rel for rel in chapters if not (root / rel).is_file()]
|
|
343
|
+
issues: list[Issue] = [
|
|
344
|
+
Issue(quarto_yml, 1, f"目录中的章节不存在:{rel}") for rel in missing
|
|
345
|
+
]
|
|
346
|
+
|
|
347
|
+
for path in files:
|
|
348
|
+
issues.extend(check_file(path, root, project, chapters))
|
|
349
|
+
|
|
350
|
+
if args.strict:
|
|
351
|
+
for issue in list(issues):
|
|
352
|
+
if "未列入 _quarto.yml" in issue.message:
|
|
353
|
+
issue.message = issue.message.replace("(目录会漏章)", "")
|
|
354
|
+
else:
|
|
355
|
+
issues = [issue for issue in issues if "未列入 _quarto.yml" not in issue.message]
|
|
356
|
+
|
|
357
|
+
if not args.quiet:
|
|
358
|
+
print(f"根目录:{root}")
|
|
359
|
+
print(f"章节:{len(files)} 个 .qmd;目录声明:{len(chapters)} 条")
|
|
360
|
+
print(f"项目规则:{'已加载' if has_project else '未发现 .agents/skills/quarto-book-project,跳过'}")
|
|
361
|
+
print()
|
|
362
|
+
|
|
363
|
+
if not issues:
|
|
364
|
+
print(f"OK:{len(files)} 个章节通过通用检查。")
|
|
365
|
+
return 0
|
|
366
|
+
|
|
367
|
+
issues.sort(key=lambda item: (str(item.path), item.line))
|
|
368
|
+
print(f"发现 {len(issues)} 个问题:")
|
|
369
|
+
for issue in issues[:120]:
|
|
370
|
+
rel = issue.path.relative_to(root).as_posix() if issue.path.is_relative_to(root) else str(issue.path)
|
|
371
|
+
print(f" {rel}:{issue.line}: {issue.message}")
|
|
372
|
+
if len(issues) > 120:
|
|
373
|
+
print(f" ……其余 {len(issues) - 120} 条省略")
|
|
374
|
+
return 1
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
if __name__ == "__main__":
|
|
378
|
+
sys.exit(main())
|