@chengzhao-dev/dsh-quarto-book 0.6.3 → 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.
Files changed (32) hide show
  1. package/README.en.md +3 -3
  2. package/README.md +3 -3
  3. package/lib/index.js +49 -7
  4. package/lib/tools/pass-report.js +12 -12
  5. package/lib/tools/render-check.js +230 -61
  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/assets/theme/css/content.css +15 -0
  11. package/skills/designing-theme/scripts/checking-layout.py +11 -3
  12. package/skills/designing-theme/scripts/measuring-pages.mjs +2 -2
  13. package/skills/writing-quarto/SKILL.md +7 -0
  14. package/skills/writing-quarto/examples/03-qmd-elements.md +29 -2
  15. package/skills/writing-quarto/quarto/authoring.md +3 -2
  16. package/skills/writing-quarto/quarto/basics.md +1 -0
  17. package/skills/writing-quarto/quarto/coords.md +2 -1
  18. package/skills/writing-quarto/quarto/figures.md +49 -0
  19. package/skills/writing-quarto/quarto/mermaid.md +1 -1
  20. package/skills/writing-quarto/quarto/refactor.md +1 -1
  21. package/skills/writing-quarto/references/book-journey.md +38 -0
  22. package/skills/writing-quarto/references/token-budget.md +26 -0
  23. package/skills/writing-quarto/scripts/checking-callouts.py +2 -2
  24. package/skills/writing-quarto/scripts/checking-dom.py +12 -6
  25. package/skills/writing-quarto/scripts/checking-links.py +29 -13
  26. package/skills/writing-quarto/scripts/checking-names.py +3 -1
  27. package/skills/writing-quarto/scripts/checking-output.py +3 -3
  28. package/skills/writing-quarto/scripts/checking-prose.py +11 -7
  29. package/skills/writing-quarto/scripts/testing-content.py +68 -1
  30. package/skills/writing-quarto/scripts/verifying-content.py +166 -36
  31. package/skills/writing-quarto/steps/03-blocks.md +4 -3
  32. package/skills/writing-quarto/steps/06-wording.md +17 -0
@@ -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
 
@@ -51,6 +51,9 @@ TRAILING_SPACE_RE = re.compile(r"[ ]+$")
51
51
  QA_PROSE_RE = re.compile(r"(?:它|只|都|分别|各自)回答|回答(?:四类|整局|这个|哪些|什么)")
52
52
  CODE_META_RE = re.compile(r"对应的代码")
53
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"(?:分别交给|各说一层)")
54
57
 
55
58
  TREE_CATEGORY_COMMENTS = frozenset({
56
59
  "头文件", "源文件", "源码", "实现", "测试", "配置文件",
@@ -89,7 +92,8 @@ class Issue:
89
92
 
90
93
 
91
94
  def read_text(path: Path) -> str:
92
- return path.read_text(encoding="utf-8", errors="replace")
95
+ # utf-8-sig 兼容带 BOM 的合法 UTF-8 文件;BOM 不进入行号与正文。
96
+ return path.read_text(encoding="utf-8-sig")
93
97
 
94
98
 
95
99
  def strip_frontmatter(text: str) -> tuple[str, str]:
@@ -139,9 +143,9 @@ def parse_chapters(quarto_yml: Path) -> list[str]:
139
143
  if stripped and not raw.startswith((" ", "\t", "-")):
140
144
  in_chapters = False
141
145
  continue
142
- if not stripped.startswith("-"):
146
+ if not stripped:
143
147
  continue
144
- item = stripped[1:].strip()
148
+ item = stripped[1:].strip() if stripped.startswith("-") else stripped
145
149
  key, sep, value = item.partition(":")
146
150
  if sep and re.fullmatch(r"[a-z_]+", key.strip()):
147
151
  # 只接受会落到页面的键:href 与 part 的文件形式。
@@ -153,6 +157,17 @@ def parse_chapters(quarto_yml: Path) -> list[str]:
153
157
  return chapters
154
158
 
155
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
+
156
171
  def collect_chapter_files(root: Path, chapters: list[str]) -> list[Path]:
157
172
  found: list[Path] = []
158
173
  for rel in chapters:
@@ -164,15 +179,8 @@ def collect_chapter_files(root: Path, chapters: list[str]) -> list[Path]:
164
179
  found.append(index)
165
180
  if chapters:
166
181
  return found
167
- # No chapter table: audit every tracked .qmd outside generated/vendor dirs.
168
- excluded = {"node_modules", "_book", ".quarto", ".git", "_freeze", "_site"}
169
- for candidate in sorted(root.rglob("*.qmd")):
170
- rel_parts = candidate.relative_to(root).parts
171
- if any(part in excluded or part.startswith(".") for part in rel_parts):
172
- continue
173
- if candidate not in found:
174
- found.append(candidate)
175
- return found
182
+ # No chapter table: audit every .qmd outside generated/vendor dirs.
183
+ return collect_all_qmd(root)
176
184
 
177
185
 
178
186
  def check_heading(path: Path, line: int, level: int, text: str, issues: list[Issue]) -> None:
@@ -425,6 +433,38 @@ def check_tree_block(path: Path, block: list[tuple[int, str]]) -> list[Issue]:
425
433
  return issues
426
434
 
427
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
+
428
468
  def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> list[Issue]:
429
469
  issues: list[Issue] = []
430
470
  text = read_text(path)
@@ -436,6 +476,10 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
436
476
  elif front and not parse_frontmatter_title(front):
437
477
  issues.append(Issue(path, 1, "frontmatter 缺少 title"))
438
478
 
479
+ # 保留 frontmatter 的行数,后续行号才是文件里的真实行号。
480
+ leading = text[: len(text) - len(body)] if body else ""
481
+ if leading:
482
+ body = "\n" * leading.count("\n") + body
439
483
  lines = body.splitlines()
440
484
  in_fence = False
441
485
  fence_lang = ""
@@ -443,8 +487,11 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
443
487
  fence_lines: list[tuple[int, str]] = []
444
488
  fence_blocks: list[list[tuple[int, str]]] = []
445
489
  mermaid_blocks: list[list[tuple[int, str]]] = []
490
+ html_blocks: list[list[tuple[int, str]]] = []
491
+ fence_is_html = False
446
492
  paragraph: list[str] = []
447
493
  paragraph_start = 0
494
+ last_fence_end: int | None = None
448
495
 
449
496
  def flush_paragraph() -> None:
450
497
  nonlocal paragraph
@@ -459,24 +506,35 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
459
506
  if not in_fence:
460
507
  in_fence = True
461
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
+ ))
462
514
  fence_lang = (fence.group(2) or "").strip()
463
515
  fence_lines = []
516
+ fence_is_html = False
464
517
  if not fence_lang:
465
518
  issues.append(Issue(path, number, "代码围栏未声明语言"))
466
519
  elif fence_lang == "mermaid":
467
520
  issues.append(Issue(path, number, "plain mermaid 围栏(改用 ```{mermaid})"))
468
521
  elif fence_lang.startswith("{"):
469
522
  inner = fence_lang.strip("{}").strip()
523
+ fence_is_html = inner == "=html"
470
524
  if inner and not inner.startswith((".", "=")) and "mermaid" not in inner:
471
525
  # ```{python} style attribute fences are valid.
472
526
  pass
473
527
  else:
474
528
  in_fence = False
529
+ last_fence_end = number
475
530
  if fence_lines:
476
531
  fence_blocks.append(fence_lines)
477
532
  if "mermaid" in fence_lang:
478
533
  mermaid_blocks.append(fence_lines)
534
+ if fence_is_html:
535
+ html_blocks.append(fence_lines)
479
536
  fence_lines = []
537
+ fence_is_html = False
480
538
  continue
481
539
 
482
540
  if in_fence:
@@ -504,7 +562,12 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
504
562
  flush_paragraph()
505
563
  continue
506
564
 
507
- if raw.lstrip().startswith((">", "|", "-", "*", "+", "1.")):
565
+ if raw.lstrip().startswith("|"):
566
+ flush_paragraph()
567
+ # 表格行按列组织,不套散文句长阈值;列数与表头一致性由表格检查负责。
568
+ continue
569
+
570
+ if raw.lstrip().startswith((">", "-", "*", "+", "1.")):
508
571
  flush_paragraph()
509
572
  check_prose_block(path, number, re.sub(r"^\s*(?:[>|]|[-*+]|\d+\.)\s*", "", raw), issues)
510
573
  continue
@@ -515,6 +578,9 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
515
578
 
516
579
  flush_paragraph()
517
580
 
581
+ if in_fence:
582
+ issues.append(Issue(path, fence_start, "代码围栏未闭合"))
583
+
518
584
  for block in fence_blocks:
519
585
  issues.extend(check_tree_block(path, block))
520
586
 
@@ -522,21 +588,41 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
522
588
  issues.extend(check_mermaid_block(path, block))
523
589
  issues.extend(check_mermaid_wrapper(path, lines, block))
524
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
+
525
605
  for index, raw in enumerate(lines, start=1):
526
- match = LINK_RE.search(raw)
527
- if not match:
606
+ if index in fenced_lines:
528
607
  continue
529
- target = match.group(1).strip()
530
- if target.startswith(("http://", "https://", "mailto:", "#", "data:")):
531
- continue
532
- clean = target.split("#", 1)[0].split("?", 1)[0]
533
- if not clean or "<" in clean:
534
- continue
535
- if not (path.parent / clean).exists() and not (root / clean).exists():
536
- issues.append(Issue(path, index, f"本地链接不可达:{target}"))
537
-
538
- if text.count("```") % 2 != 0:
539
- issues.append(Issue(path, len(lines), "代码围栏数量为奇数,存在未闭合围栏"))
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
+ ))
540
626
 
541
627
  for term in GENERIC_FORBIDDEN_TERMS:
542
628
  for index, raw in enumerate(lines, start=1):
@@ -571,6 +657,24 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
571
657
  path, index,
572
658
  f"自造关系词「{match.group(0)}」;用组成、调用、依赖、继承等通行词",
573
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
+ ))
574
678
 
575
679
  required = project.get("chapter_titles", {}).get(rel)
576
680
  if required:
@@ -605,6 +709,7 @@ def load_project(root: Path) -> tuple[bool, dict]:
605
709
  def main() -> int:
606
710
  parser = argparse.ArgumentParser(description="检查 Quarto Book 章节源码")
607
711
  parser.add_argument("--root", default=".", help="书根目录(默认当前目录)")
712
+ parser.add_argument("--paths", nargs="+", help="只检查这些 qmd 路径(相对 --root)")
608
713
  parser.add_argument("--strict", action="store_true", help="把未列入目录的文件也视为错误")
609
714
  parser.add_argument("--quiet", action="store_true", help="只输出汇总")
610
715
  args = parser.parse_args()
@@ -621,27 +726,52 @@ def main() -> int:
621
726
  return 2
622
727
 
623
728
  quarto_yml = root / "_quarto.yml"
624
- chapters = parse_chapters(quarto_yml)
729
+ try:
730
+ chapters = parse_chapters(quarto_yml)
731
+ except (OSError, UnicodeDecodeError) as exc:
732
+ print(f"失败 读取目录表失败: {exc}")
733
+ return 1
625
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
626
747
  if not files:
627
748
  print(f"失败 {root} 下没有 .qmd 章节(确认 --root 指向书根目录)")
628
749
  return 1
629
750
 
630
- has_project, project = load_project(root)
751
+ try:
752
+ has_project, project = load_project(root)
753
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
754
+ print(f"失败 读取项目规则失败: {exc}")
755
+ return 1
631
756
 
632
757
  missing = [rel for rel in chapters if not (root / rel).is_file()]
633
- issues: list[Issue] = [
758
+ issues.extend([
634
759
  Issue(quarto_yml, 1, f"目录中的章节不存在:{rel}") for rel in missing
635
- ]
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)
636
767
 
637
768
  for path in files:
638
- issues.extend(check_file(path, root, project, chapters))
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}"))
639
773
 
640
- if args.strict:
641
- for issue in list(issues):
642
- if "未列入 _quarto.yml" in issue.message:
643
- issue.message = issue.message.replace("(目录会漏章)", "")
644
- else:
774
+ if not args.strict:
645
775
  issues = [issue for issue in issues if "未列入 _quarto.yml" not in issue.message]
646
776
 
647
777
  if not args.quiet:
@@ -100,11 +100,12 @@ int main() {
100
100
 
101
101
  ### 代码与图的桥接
102
102
 
103
- 代码块引出 `{mermaid}` 图或 `{python}` 坐标图时按固定序列组织:代码块 → 桥接句 → 图(含 `fig-cap`)→ 图后新结论。固定序列的完整写法见 `writing-quarto` 技能 `quarto/authoring.md`「代码与图的桥接」,两条硬边界:
103
+ 代码块引出 `{mermaid}` 图或 `{python}` 坐标图时按固定序列组织:代码块 → 桥接句 → 图(含 `fig-cap`)→ 图后新结论。固定序列的完整写法见 `writing-quarto` 技能 `quarto/authoring.md`「代码与图的桥接」,边界如下:
104
104
 
105
105
  - 代码块与图之间必须有桥接句,说明图要核对代码已讲清的哪条关系;中间只有空行视为堆叠(`verifying-content.py` 的 DOC-E11 只要求相邻围栏留空行,空行不等于桥接正文)。
106
106
  - 图后先给「从图带走的结论」或图外新信息,再进入下一块;小节以图收尾时,结论可并入桥接段,不在图后孤悬一句。图注已写明的信息不复述。坐标图计入盒子密度,口径见 `quarto/coords.md` 与本文件。
107
- - 一图一事、同主题相邻、图不承载正文未写的新信息:一张图只核对一条已讲清的关系;代码与图主题不同时收束本对象再拆 `###`,禁止「代码 A → 短句拐弯 → 图讲 B → 再贴代码 C」的同节混排(三条边界的写法见 `quarto/authoring.md`「代码与图的桥接」)。
107
+ - 图注与图前句分工:图注写图面内容与读法,落在图下;图前只写引出关系的一句,不写“下图标出……”这类图面说明。三类图(Mermaid、坐标图、原生 HTML/SVG)的写法见 `quarto/figures.md`。
108
+ - 一图一事、同主题相邻、图不承载正文未写的新信息:一张图只核对一条已讲清的关系;代码与图主题不同时收束本对象再拆 `###`,禁止「代码 A → 短句拐弯 → 图讲 B → 再贴代码 C」的同节混排(上述边界的写法见 `quarto/authoring.md`「代码与图的桥接」)。
108
109
  - 图前段只讲本页可见的对象,且单一主题:描述版式的句子其对象必须同页可见,引用前章画面时复述读者需要的关键事实(如行列取值 0 到 2),不指称前章画面的版式(「画面上方是列号」式会让读者翻回前章);调用方行为等第二主题移出图前段(示例章定义坐标范围节两段并一段的改稿对照)。
109
110
 
110
111
  图不能替代结论句:桥接句与图带走的结论缺任何一处,读者只有图没有判断依据。正反对照见 `examples/03-qmd-elements.md`「代码与图桥接对照」。
@@ -225,7 +226,7 @@ int main() {
225
226
 
226
227
  ### 情况:图是 Mermaid 或坐标轴图
227
228
 
228
- 要画流程图、类图或时序图才打开 `quarto/mermaid.md`(类图先按内容预算删节点和成员),坐标轴网格图打开 `quarto/coords.md`。不排图不打开。
229
+ 要画流程图、类图或时序图才打开 `quarto/mermaid.md`(类图先按内容预算删节点和成员),坐标轴网格图打开 `quarto/coords.md`,图注与原生 HTML/SVG 图打开 `quarto/figures.md`。不排图不打开。
229
230
 
230
231
  ### 情况:Callout
231
232
 
@@ -31,6 +31,22 @@
31
31
 
32
32
  「只读」「只写」是读写性质的固定术语,保留;`const` 成员函数首次称「只读」时,写明它不改动对象。安全、协议或验收条件确实需要排他时,写成可核对的条件(「`PlaceCell` 在目标格子非空时返回 `false`」),不靠「只」承担论证。
33
33
 
34
+ ### 表示、保存、记录,不用「承载」
35
+
36
+ 读者正文不把类型或字段写成「承载」某个值。「承载」是抽象能力或文档职责的说法,套到 `struct`、`enum`、字段上会让读者分不清它表示什么、运行时保存什么。
37
+
38
+ 按对象选动词:
39
+
40
+ - 类型表达概念:`CellPosition` **表示**一对行列,`CellType` **表示**格子里的取值。
41
+ - 对象保存数据:`Board` 的 `cells_` **保存**九个 `CellType`。
42
+ - 文件或协议留下记录:快照 **记录**落子次数、当前玩家和结果。
43
+ - 名称与值对应:`kX` **对应** X 标记。
44
+
45
+ - 不佳:坐标由 `CellPosition` 承载,取值由 `CellType` 承载。
46
+ - 改写:`CellPosition` 表示一对行列,`CellType` 表示格子里的取值。
47
+ - 不佳:这份配置承载三个字段。
48
+ - 改写:这份配置保存三个字段。
49
+
34
50
  ### 读者正文禁无信息提问与自造关系词
35
51
 
36
52
  读者正文不向读者发问。没有任务指向的提问(「总览图为什么不列成员?」「这条边表示什么?」)只是把作者自检提纲搬进正文,删掉后读者仍能拿到同样信息;需要读者判断的内容改写为陈述句,或放进自测。
@@ -100,6 +116,7 @@
100
116
  - 动词选用与主动语态见「目标语气」;「由 X 完成」在这一步改成主动句,改完露出第二个核就退回定段落。
101
117
  - 「同一概念只使用一个称呼」的判定见「七个质量维度」;英文标识保持大小写。
102
118
  - 「游戏」指作品或玩法整体,「对局」指具体一场:定义「井字棋是…游戏」用「游戏」,规则、落子与结果语境用「对局」;同一页不把两者当同义词换用(对照见 `examples/06-wording-cases.md`「游戏与对局」)。
119
+ - 项目名在同一页首次写全(如「井字棋棋盘」),后文改用对象名(「棋盘」「动态库」「对局」);不逐段重复项目名,也不在同一页反复解释已经说过的事。
103
120
  - 无信息修饰的删除判定见「七个质量维度」;禁用词以 `scripts/verifying-content.py` 词表为准。
104
121
  - 压缩动词写全:`重建`、`重编`、`重跑` 这类两字词,读者可能读成「重新创建」;写「重新生成」「重新编译」「重新运行」,同一页后续也不回退到压缩词。
105
122
  - 连续出现三个「的」时的改写判定见「七个质量维度」第 5 条。