@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.
@@ -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())