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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.en.md +3 -3
  2. package/README.md +5 -3
  3. package/lib/index.js +49 -7
  4. package/lib/tools/pass-report.js +12 -12
  5. package/lib/tools/render-check.js +253 -65
  6. package/lib/workspace.js +1 -0
  7. package/locale/en.json +1 -1
  8. package/locale/zh.json +1 -1
  9. package/package.json +9 -6
  10. package/skills/designing-theme/SKILL.md +1 -1
  11. package/skills/designing-theme/assets/theme/css/content.css +31 -8
  12. package/skills/designing-theme/references/theme-spine.md +1 -1
  13. package/skills/designing-theme/scripts/checking-layout.py +11 -3
  14. package/skills/designing-theme/scripts/measuring-pages.mjs +2 -2
  15. package/skills/writing-quarto/SKILL.md +11 -2
  16. package/skills/writing-quarto/examples/03-block-after.md +29 -0
  17. package/skills/writing-quarto/examples/03-excerpt-lead.md +10 -0
  18. package/skills/writing-quarto/examples/03-qmd-elements.md +29 -2
  19. package/skills/writing-quarto/examples/04-paragraph-cases.md +13 -0
  20. package/skills/writing-quarto/examples/07-layout-list.md +34 -0
  21. package/skills/writing-quarto/quarto/authoring.md +5 -4
  22. package/skills/writing-quarto/quarto/basics.md +1 -0
  23. package/skills/writing-quarto/quarto/coords.md +2 -1
  24. package/skills/writing-quarto/quarto/figures.md +49 -0
  25. package/skills/writing-quarto/quarto/mermaid.md +2 -2
  26. package/skills/writing-quarto/quarto/refactor.md +1 -1
  27. package/skills/writing-quarto/quarto/render.md +4 -4
  28. package/skills/writing-quarto/quarto/tables.md +79 -0
  29. package/skills/writing-quarto/references/book-journey.md +38 -0
  30. package/skills/writing-quarto/references/token-budget.md +26 -0
  31. package/skills/writing-quarto/scripts/checking-callouts.py +2 -2
  32. package/skills/writing-quarto/scripts/checking-dom.py +12 -6
  33. package/skills/writing-quarto/scripts/checking-links.py +70 -13
  34. package/skills/writing-quarto/scripts/checking-names.py +3 -1
  35. package/skills/writing-quarto/scripts/checking-output.py +3 -3
  36. package/skills/writing-quarto/scripts/checking-prose.py +11 -7
  37. package/skills/writing-quarto/scripts/testing-content.py +68 -1
  38. package/skills/writing-quarto/scripts/verifying-content.py +187 -35
  39. package/skills/writing-quarto/steps/02-structure.md +1 -1
  40. package/skills/writing-quarto/steps/03-blocks.md +19 -6
  41. package/skills/writing-quarto/steps/04-paragraphs.md +1 -0
  42. package/skills/writing-quarto/steps/06-wording.md +17 -0
  43. package/skills/writing-quarto/steps/07-layout.md +5 -1
  44. package/skills/writing-quarto/steps/08-reread.md +1 -1
@@ -240,7 +240,8 @@ def check_contracts(book_dir, htmls, css_pairs, root=None, require_favicon=True)
240
240
  ))
241
241
 
242
242
  # ---------- C1 复制按钮可见性 ----------
243
- sibling_ok = False
243
+ checked = 0
244
+ bad = 0
244
245
  scaffold_pages = 0
245
246
  for path, tree, _rels in htmls:
246
247
  scaffolds = [n for n in walk(tree) if SCAFFOLD in n.classes]
@@ -249,20 +250,23 @@ def check_contracts(book_dir, htmls, css_pairs, root=None, require_favicon=True)
249
250
  scaffold_pages += 1
250
251
  for sc in scaffolds:
251
252
  code = [c for c in sc.children if c.tag == "div" and SRCODE in c.classes]
253
+ if not code:
254
+ continue
255
+ checked += 1
252
256
  btns = [c for c in sc.children if c.tag == "button" and COPY_BTN in c.classes]
253
- if code and btns and not any(is_descendant(b, code[0]) for b in btns):
254
- sibling_ok = True
257
+ if not btns or any(is_descendant(b, code[0]) for b in btns):
258
+ bad += 1
255
259
  hover_rule = any(
256
260
  ".%s:hover .%s" % (SCAFFOLD, COPY_BTN) in css for _, css in css_pairs
257
261
  )
258
262
  stale_rule = any(
259
263
  "div.%s:hover .%s" % (SRCODE, COPY_BTN) in css for _, css in css_pairs
260
264
  )
261
- c1 = sibling_ok and hover_rule and not stale_rule
265
+ c1 = checked > 0 and bad == 0 and hover_rule and not stale_rule
262
266
  results.append((
263
267
  "C1", "复制按钮 hover 可见(兄弟 DOM + scaffold 选择器)", c1,
264
- "pages=%d sibling=%s hover-rule=%s%s"
265
- % (scaffold_pages, sibling_ok, hover_rule, " stale-rule!" if stale_rule else ""),
268
+ "pages=%d checked=%d bad=%d hover-rule=%s%s"
269
+ % (scaffold_pages, checked, bad, hover_rule, " stale-rule!" if stale_rule else ""),
266
270
  [
267
271
  "产物中 button.%s 必须与 div.%s 同为 .%s 的直接子元素" % (COPY_BTN, SRCODE, SCAFFOLD),
268
272
  "CSS 必须含 .%s:hover .%s(不能停留在 div.%s:hover 的后代写法)"
@@ -535,6 +539,8 @@ def main():
535
539
  args = parser.parse_args()
536
540
 
537
541
  book_dir = Path(args.book_dir)
542
+ if not book_dir.is_absolute():
543
+ book_dir = Path(args.root) / book_dir
538
544
  if not book_dir.is_dir():
539
545
  print("MISS 产物目录不存在:%s(请先运行 quarto render)" % book_dir)
540
546
  return 2
@@ -20,8 +20,13 @@ import sys
20
20
  from pathlib import Path
21
21
 
22
22
  LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
23
+ REF_USE_RE = re.compile(r"(!?)\[([^\]]*)\]\[([^\]]*)\]")
24
+ REF_DEF_RE = re.compile(r"^ {0,3}\[([^\]]+)\]:\s*(?:<([^>\s]+)>|(\S+))")
25
+ INLINE_CODE_RE = re.compile(r"`+[^`]*`+")
23
26
  HTML_LINK_RE = re.compile(r"<(?:a|img)\b[^>]*\b(?:href|src)=\"([^\"]+)\"", re.IGNORECASE)
27
+ FENCE_RE = re.compile(r"^\s*(```+|~~~+)")
24
28
  SKIP_PREFIXES = ("http://", "https://", "mailto:", "tel:", "data:", "#", "//")
29
+ SKIP_DIRS = {".git", ".cache", ".quarto", "_book", "_freeze", "_site", "build", "node_modules", "temp"}
25
30
 
26
31
 
27
32
  def collect_sources(root: Path, paths: list[str]) -> list[Path]:
@@ -41,17 +46,57 @@ def collect_sources(root: Path, paths: list[str]) -> list[Path]:
41
46
  return sorted(
42
47
  path for path in found
43
48
  if not any(
44
- part in {".git", ".cache", ".quarto", "_book", "_freeze", "_site", "build", "node_modules", "temp"}
45
- for part in path.parts
49
+ part in SKIP_DIRS
50
+ for part in path.relative_to(root).parts
46
51
  )
47
52
  )
48
53
 
49
54
 
50
- def link_targets(text: str):
51
- for match in LINK_RE.finditer(text):
52
- yield match.group(1)
53
- for match in HTML_LINK_RE.finditer(text):
54
- yield match.group(1)
55
+ def normalize_label(label: str) -> str:
56
+ return re.sub(r"\s+", " ", label.strip()).casefold()
57
+
58
+
59
+ def collect_definitions(text: str) -> dict[str, tuple[int, str]]:
60
+ """收集引用式链接定义 `[label]: target`;围栏内的定义不算。"""
61
+ definitions: dict[str, tuple[int, str]] = {}
62
+ in_fence = False
63
+ for number, line in enumerate(text.splitlines(), start=1):
64
+ if FENCE_RE.match(line):
65
+ in_fence = not in_fence
66
+ continue
67
+ if in_fence:
68
+ continue
69
+ match = REF_DEF_RE.match(line)
70
+ if not match:
71
+ continue
72
+ label = normalize_label(match.group(1))
73
+ target = match.group(2) or match.group(3) or ""
74
+ if label and label not in definitions:
75
+ definitions[label] = (number, target)
76
+ return definitions
77
+
78
+
79
+ def iter_links(text: str, definitions: dict[str, tuple[int, str]]):
80
+ in_fence = False
81
+ for number, line in enumerate(text.splitlines(), start=1):
82
+ if FENCE_RE.match(line):
83
+ in_fence = not in_fence
84
+ continue
85
+ if in_fence or REF_DEF_RE.match(line):
86
+ continue
87
+ # 行内代码里的方括号不是链接;先抹掉,避免把示例当引用。
88
+ visible = INLINE_CODE_RE.sub(lambda match: " " * len(match.group(0)), line)
89
+ for match in LINK_RE.finditer(visible):
90
+ yield number, match.group(1), None
91
+ for match in REF_USE_RE.finditer(visible):
92
+ label = match.group(3) or match.group(2)
93
+ key = normalize_label(label)
94
+ if not key:
95
+ continue
96
+ definition = definitions.get(key)
97
+ yield number, definition[1] if definition else None, label
98
+ for match in HTML_LINK_RE.finditer(visible):
99
+ yield number, match.group(1), None
55
100
 
56
101
 
57
102
  def main() -> int:
@@ -72,13 +117,25 @@ def main() -> int:
72
117
  return 2
73
118
 
74
119
  sources = collect_sources(root, args.paths)
75
- broken: list[tuple[str, str, str]] = []
120
+ if not sources:
121
+ print("失败 没有可检查的 Markdown/Quarto 源文件", file=sys.stderr)
122
+ return 2
123
+ broken: list[tuple[str, int, str, str]] = []
76
124
  checked = 0
77
125
 
78
126
  for source in sources:
79
- text = source.read_text(encoding="utf-8", errors="replace")
127
+ try:
128
+ text = source.read_text(encoding="utf-8")
129
+ except UnicodeDecodeError as exc:
130
+ print(f"失败 {source} 不是合法 UTF-8:{exc}", file=sys.stderr)
131
+ return 1
80
132
  rel = source.relative_to(root).as_posix()
81
- for raw in link_targets(text):
133
+ definitions = collect_definitions(text)
134
+ for number, raw, label in iter_links(text, definitions):
135
+ if raw is None:
136
+ checked += 1
137
+ broken.append((rel, number, f"[{label}]", "引用标签未定义"))
138
+ continue
82
139
  target = raw.strip()
83
140
  if not target or target.startswith(SKIP_PREFIXES):
84
141
  continue
@@ -91,7 +148,7 @@ def main() -> int:
91
148
  candidates = [source.parent / clean, root / clean]
92
149
  if any(candidate.exists() for candidate in candidates):
93
150
  continue
94
- broken.append((rel, target, "目标不存在"))
151
+ broken.append((rel, number, target, "目标不存在"))
95
152
 
96
153
  print(f"扫描 {len(sources)} 个文件,检查 {checked} 条本地链接。")
97
154
  if not broken:
@@ -99,8 +156,8 @@ def main() -> int:
99
156
  return 0
100
157
 
101
158
  print(f"发现 {len(broken)} 条断链:")
102
- for rel, target, reason in broken[:80]:
103
- print(f" {rel} -> {target}({reason})")
159
+ for rel, number, target, reason in broken[:80]:
160
+ print(f" {rel}:{number} -> {target}({reason})")
104
161
  return 1
105
162
 
106
163
 
@@ -33,7 +33,7 @@ EXCLUDE_DIR_SUFFIXES = ("_files",)
33
33
 
34
34
  TOOL_FIXED_NAMES = {
35
35
  "CMakeLists.txt", "_quarto.yml", "_quarto.yaml", "SKILL.md", "AGENTS.md",
36
- "README.md", "README.en.md", "LICENSE", "CONTRIBUTING.md",
36
+ "README.md", "README.en.md", "LICENSE", "OFL.txt", "CONTRIBUTING.md",
37
37
  ".editorconfig", ".gitignore", ".gitattributes", "openai.yaml",
38
38
  "package.json", "package-lock.json", "pnpm-lock.yaml", "tsconfig.json",
39
39
  }
@@ -56,6 +56,8 @@ def name_form_error(name: str) -> str | None:
56
56
  if " " in name:
57
57
  return "含空格"
58
58
  if "-" not in name:
59
+ if not all(ch.islower() or ch.isdigit() or ch == "." for ch in name):
60
+ return "含非小写字符"
59
61
  return None
60
62
  parts = name.split("-")
61
63
  if any(part == "" for part in parts):
@@ -7,7 +7,7 @@
7
7
  不在本检查范围内。
8
8
 
9
9
  用法:python checking-output.py [--book-dir _book] [--root .] [--require-sitemap]
10
- 退出码:0 = 通过;1 = 存在阻塞问题。
10
+ 退出码:0 = 通过;1 = 存在阻塞问题;2 = 渲染产物目录或首页缺失。
11
11
  """
12
12
 
13
13
  import argparse
@@ -37,12 +37,12 @@ def main() -> int:
37
37
  index = book / "index.html"
38
38
  if not index.is_file():
39
39
  print(f"缺少产物首页 {book.as_posix()}/index.html:确认在仓库根目录渲染")
40
- return 1
40
+ return 2
41
41
 
42
42
  html_files = sorted(book.rglob("*.html"))
43
43
  if not html_files:
44
44
  print(f"{book.as_posix()} 下没有任何 HTML 文件")
45
- return 1
45
+ return 2
46
46
 
47
47
  for path in html_files:
48
48
  rel = path.relative_to(book).as_posix()
@@ -42,13 +42,13 @@ class Issue:
42
42
  message: str
43
43
 
44
44
 
45
- def strip_frontmatter(text: str) -> str:
45
+ def split_frontmatter(text: str) -> tuple[int, str]:
46
46
  lines = text.splitlines()
47
47
  if lines and lines[0].strip() == "---":
48
48
  for index in range(1, len(lines)):
49
49
  if lines[index].strip() == "---":
50
- return "\n".join(lines[index + 1 :])
51
- return text
50
+ return index + 1, "\n".join(lines[index + 1 :])
51
+ return 0, text
52
52
 
53
53
 
54
54
  def mask_links_and_tags(line: str) -> str:
@@ -98,11 +98,11 @@ def check_line(path: str, number: int, line: str) -> list[Issue]:
98
98
 
99
99
 
100
100
  def scan_file(root: Path, path: Path) -> list[Issue]:
101
- text = path.read_text(encoding="utf-8", errors="replace")
102
- text = strip_frontmatter(text)
101
+ text = path.read_text(encoding="utf-8")
102
+ offset, body = split_frontmatter(text)
103
103
  issues: list[Issue] = []
104
104
  in_fence = False
105
- for number, line in enumerate(text.splitlines(), start=1):
105
+ for number, line in enumerate(body.splitlines(), start=offset + 1):
106
106
  if FENCE.match(line):
107
107
  in_fence = not in_fence
108
108
  continue
@@ -135,7 +135,11 @@ def main() -> int:
135
135
 
136
136
  issues: list[Issue] = []
137
137
  for path in collect_files(root):
138
- issues.extend(scan_file(root, path))
138
+ try:
139
+ issues.extend(scan_file(root, path))
140
+ except UnicodeDecodeError as exc:
141
+ print(f"失败 {path} 不是合法 UTF-8:{exc}", file=sys.stderr)
142
+ return 1
139
143
 
140
144
  if args.json:
141
145
  print(
@@ -68,6 +68,36 @@ title: 入门
68
68
  """
69
69
 
70
70
 
71
+ HTML_FIGURE_BAD = """---
72
+ title: 入门
73
+ ---
74
+
75
+ ## 目标
76
+
77
+ 这一章说明一个可以重复验证的目标。
78
+
79
+ ```{=html}
80
+ <svg class="coords-grid" role="img" aria-label="棋盘坐标。"><rect width="10" height="10"/></svg>
81
+ ```
82
+ """
83
+
84
+ HTML_FIGURE_GOOD = """---
85
+ title: 入门
86
+ ---
87
+
88
+ ## 目标
89
+
90
+ 这一章说明一个可以重复验证的目标。
91
+
92
+ ```{=html}
93
+ <figure class="book-figure">
94
+ <svg class="coords-grid" role="img" aria-label="棋盘坐标。"><rect width="10" height="10"/></svg>
95
+ <figcaption>棋盘坐标图:3 行 3 列。</figcaption>
96
+ </figure>
97
+ ```
98
+ """
99
+
100
+
71
101
  TREE_GAP_CHAPTER = """---
72
102
  title: 入门
73
103
  ---
@@ -139,8 +169,11 @@ def main() -> int:
139
169
  pass
140
170
 
141
171
  failures: list[str] = []
172
+ passed_cases = 0
142
173
 
143
174
  def expect(name: str, chapter: str, code: int, needle: str, project: bool = False) -> None:
175
+ nonlocal passed_cases
176
+ passed_cases += 1
144
177
  with tempfile.TemporaryDirectory() as raw:
145
178
  root = Path(raw)
146
179
  make_book(root, chapter)
@@ -177,6 +210,31 @@ def main() -> int:
177
210
  VALID_CHAPTER + "\n`Game` 与 `Board` 之间的组成边表示什么?\n",
178
211
  1, "自造关系词",
179
212
  )
213
+ expect(
214
+ "类型承载",
215
+ VALID_CHAPTER + "\n坐标由 `CellPosition` 承载,取值由 `CellType` 承载。\n",
216
+ 1, "承载",
217
+ )
218
+ expect(
219
+ "小节导航",
220
+ VALID_CHAPTER + "\n下一节说明落子顺序。\n",
221
+ 1, "小节导航",
222
+ )
223
+ expect(
224
+ "拉链句",
225
+ VALID_CHAPTER + "\n两个类型分别交给坐标与取值。\n",
226
+ 1, "拉链句",
227
+ )
228
+ duplicate_link = (
229
+ "https://github.com/chengzhao-dev/cpp-board-games/blob/main/"
230
+ "games/tictactoe/tictactoe/include/board.h"
231
+ )
232
+ expect(
233
+ "同章重复文件链接",
234
+ VALID_CHAPTER
235
+ + f"\n[board.h]({duplicate_link})\n\n[board.h]({duplicate_link})\n",
236
+ 1, "同章重复文件链接",
237
+ )
180
238
  expect("标题口语化", VALID_CHAPTER.replace("## 目标", "## 自己走一局"), 1, "标题带人称")
181
239
  expect("标题带行内代码", VALID_CHAPTER.replace("## 目标", "## 运行 `app`"), 1, "标题带行内代码")
182
240
  expect("正文 H1", VALID_CHAPTER.replace("## 目标", "# 目标"), 1, "正文出现 H1")
@@ -196,6 +254,12 @@ def main() -> int:
196
254
  "---\ntitle: 入门\n---\n\n## 目标\n\n```\nprint(1)\n```\n",
197
255
  1, "未声明语言",
198
256
  )
257
+ expect(
258
+ "相邻围栏缺少空行",
259
+ "---\ntitle: 入门\n---\n\n## 目标\n\n"
260
+ "```text\na\n```\n```text\nb\n```\n",
261
+ 1, "DOC-E11",
262
+ )
199
263
  expect(
200
264
  "项目禁用词生效",
201
265
  "---\ntitle: 入门\n---\n\n## 目标\n\n棋盘格子按行列编号,程序读取当前状态。\n",
@@ -209,6 +273,8 @@ def main() -> int:
209
273
  )
210
274
 
211
275
  expect("目录树合格", TREE_CHAPTER, 0, "OK")
276
+ expect("原生 SVG 缺图注", HTML_FIGURE_BAD, 1, "figcaption")
277
+ expect("原生 SVG 图注合格", HTML_FIGURE_GOOD, 0, "OK")
212
278
  expect(
213
279
  "标记后多空格",
214
280
  TREE_CHAPTER.replace("# 读入坐标", "# 读入坐标"),
@@ -325,6 +391,7 @@ def main() -> int:
325
391
  )
326
392
 
327
393
  with tempfile.TemporaryDirectory() as raw:
394
+ passed_cases += 1
328
395
  root = Path(raw)
329
396
  make_book(root)
330
397
  (root / "content" / "part" / "01-intro.qmd").unlink()
@@ -339,7 +406,7 @@ def main() -> int:
339
406
  print(f" FAIL {line}")
340
407
  return 1
341
408
 
342
- print("PASS:36 个用例全部通过")
409
+ print(f"PASS:{passed_cases} 个用例全部通过")
343
410
  return 0
344
411
 
345
412