@chengzhao-dev/dsh-quarto-book 0.6.12 → 0.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -29,7 +29,7 @@ Both skills are discovered from the package's `skills/` directory and loaded on
29
29
  2. Open a session in the book repository. `_quarto.yml` needs a standalone `type: book` line, and the book needs at least one `.qmd` or `.md` chapter source; only then do both skill names appear.
30
30
  3. Call a skill: `writing-quarto` for prose, `designing-theme` for the theme.
31
31
 
32
- Replace `--profile` with the profile you actually run. The desktop app always runs the `desktop` profile, and that profile is owned by the app, so `dsh --profile desktop` is rejected; install or upgrade with `dsh plugin --profile desktop add <package>`, and use the app's Settings → Plugins → Add plugin only if the app refuses that write. To pin a version, write `npm:@chengzhao-dev/dsh-quarto-book@0.6.12`; to follow a GitHub tag, write `github:chengzhao-dev/dsh-quarto-book#v0.6.12`.
32
+ Replace `--profile` with the profile you actually run. The desktop app always runs the `desktop` profile, and that profile is owned by the app, so `dsh --profile desktop` is rejected; install or upgrade with `dsh plugin --profile desktop add <package>`, and use the app's Settings → Plugins → Add plugin only if the app refuses that write. To pin a version, write `npm:@chengzhao-dev/dsh-quarto-book@0.6.13`; to follow a GitHub tag, write `github:chengzhao-dev/dsh-quarto-book#v0.6.13`.
33
33
 
34
34
  Upgrading from the old unscoped package `dsh-quarto-book` (removed from npm with 0.3.4, so this only affects profiles that installed it earlier): remove it from the same profile first with `dsh plugin --profile <profile> remove dsh-quarto-book`, then add the scoped package. Keeping both installed registers `writing-quarto` and `designing-theme` twice at the same rank 550.
35
35
 
@@ -106,6 +106,14 @@ npm run pack
106
106
 
107
107
  `npm test` compiles and runs the tool tests; `npm run check` type-checks and validates skill paths; `npm run pack` writes the tarball into `temp/`, which is already in `.gitignore`.
108
108
 
109
+ Replay the CI sequence locally with one command before pushing:
110
+
111
+ ```bash
112
+ npm run ci
113
+ ```
114
+
115
+ It runs `npm ci`, `npm test`, `npm run check:lib`, `npm run check`, and `npm run pack:check` — the same steps as `.github/workflows/ci.yml`, plus the publish-time package gate.
116
+
109
117
  Development install:
110
118
 
111
119
  ```bash
package/README.md CHANGED
@@ -29,7 +29,7 @@
29
29
  2. 在书仓库里打开会话。`_quarto.yml` 要有独立一行的 `type: book`,书根至少有一个 `.qmd` 或 `.md` 章节源文件;两个技能名这时才会出现。
30
30
  3. 调用技能:写正文用 `writing-quarto`,改主题用 `designing-theme`。
31
31
 
32
- `--profile` 换成你实际运行的那个 profile。桌面应用固定跑 `desktop` profile,且该 profile 由应用独占管理,`dsh --profile desktop` 直接启动会被拒绝;安装或升级用 `dsh plugin --profile desktop add <包>`,应用拒绝写入时再走「设置 → 插件 → 添加插件」。需要固定版本时写 `npm:@chengzhao-dev/dsh-quarto-book@0.6.12`,跟 GitHub 标签时写 `github:chengzhao-dev/dsh-quarto-book#v0.6.12`。
32
+ `--profile` 换成你实际运行的那个 profile。桌面应用固定跑 `desktop` profile,且该 profile 由应用独占管理,`dsh --profile desktop` 直接启动会被拒绝;安装或升级用 `dsh plugin --profile desktop add <包>`,应用拒绝写入时再走「设置 → 插件 → 添加插件」。需要固定版本时写 `npm:@chengzhao-dev/dsh-quarto-book@0.6.13`,跟 GitHub 标签时写 `github:chengzhao-dev/dsh-quarto-book#v0.6.13`。
33
33
 
34
34
  从旧的无作用域包 `dsh-quarto-book` 升级(该包已随 0.3.4 从 npm 删除,只影响之前装过它的 profile):先在同一个 profile 里 `dsh plugin --profile <profile> remove dsh-quarto-book`,再装新包。两个包同时留着会以相同的 rank 550 各注册一遍 `writing-quarto` 和 `designing-theme`。
35
35
 
@@ -108,6 +108,14 @@ npm run pack
108
108
 
109
109
  `npm test` 会先编译再跑工具测试;`npm run check` 做类型检查与技能路径自检;`npm run pack` 把 tarball 只写进 `temp/`,那个目录已在 `.gitignore` 里。
110
110
 
111
+ 推送前用一条命令复现 CI 的检查序列:
112
+
113
+ ```bash
114
+ npm run ci
115
+ ```
116
+
117
+ `npm run ci` 依次执行 `npm ci`、`npm test`、`npm run check:lib`、`npm run check` 与 `npm run pack:check`,覆盖 `.github/workflows/ci.yml` 的全部步骤,并在打包前挡住发布内容的问题。
118
+
111
119
  开发安装:
112
120
 
113
121
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chengzhao-dev/dsh-quarto-book",
3
- "version": "0.6.12",
3
+ "version": "0.6.13",
4
4
  "description": "把一份 Quarto Book 写成可读、可查、可发布的中文技术书:九步章节写作、结构/链接/图表检查、统一主题与渲染验收。 · Write, check, and publish a Chinese Quarto Book: nine-step chapter writing, structure/link/figure checks, a consistent theme, and render acceptance.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -48,6 +48,7 @@
48
48
  "scripts": {
49
49
  "build": "tsc -p tsconfig.json",
50
50
  "test": "npm run build && node scripts/checking-github-workflows.mjs && node --test \"tests/**/*.test.mjs\"",
51
+ "ci": "npm ci && npm test && npm run check:lib && npm run check && npm run pack:check",
51
52
  "check": "tsc --noEmit -p tsconfig.json && node scripts/checking-skill-links.mjs && node scripts/checking-skill-budget.mjs && node scripts/checking-package.mjs && node scripts/checking-github-workflows.mjs",
52
53
  "check:lib": "node scripts/checking-lib.mjs",
53
54
  "check:workflows": "node scripts/checking-github-workflows.mjs",
@@ -570,13 +570,29 @@ html[data-palette="solarized"] .navbar-brand::before {
570
570
  width: 6rem;
571
571
  }
572
572
 
573
+ /* 只剩书标:书标放到 1.375rem 补回丢掉标题后的视觉重量,并撤掉桌面端那条
574
+ 0.2rem 抬升——它原本用来对齐汉字基线,没有标题时反而把图标顶高。 */
575
+ .navbar-brand {
576
+ position: relative;
577
+ }
573
578
  .navbar-brand::before {
574
- width: 1.25rem;
575
- height: 1.25rem;
579
+ width: 1.375rem;
580
+ height: 1.375rem;
581
+ margin-bottom: 0;
576
582
  }
577
583
 
584
+ /* 书名改成视觉隐藏而不是 display:none:这枚链接仍是「回到首页」,
585
+ 屏幕阅读器要能读出书名,否则书标就是一个没有名字的链接。 */
578
586
  .navbar-title {
579
- display: none;
587
+ position: absolute;
588
+ width: 1px;
589
+ height: 1px;
590
+ margin: -1px;
591
+ padding: 0;
592
+ border: 0;
593
+ overflow: hidden;
594
+ clip-path: inset(50%);
595
+ white-space: nowrap;
580
596
  }
581
597
  }
582
598
  /* 主题切换:可点区域 44px,图标仍是 16px */
@@ -689,6 +705,11 @@ nav.quarto-secondary-nav .container-fluid.d-flex {
689
705
  nav.quarto-secondary-nav .quarto-search-button {
690
706
  display: none;
691
707
  }
708
+ /* 首页与部首页在次级导航里只有一项(书名或部名),紧挨顶栏品牌又说一遍;
709
+ 它也说不出“书内位置”,故只留侧栏开关,让次级导航只承担章节轨迹。 */
710
+ nav.quarto-secondary-nav .quarto-page-breadcrumbs ol.breadcrumb:has(> li:only-child) {
711
+ display: none;
712
+ }
692
713
  /* 宽屏只隐藏次级导航里的面包屑(仅留汉堡);标题块面包屑桌面端显示、与标题同栏左对齐,
693
714
  窄屏由 Quarto 生成的 d-none 自动隐藏(改显次级导航实例) */
694
715
  @media (min-width: 992px) {
@@ -86,7 +86,7 @@
86
86
  - **任务列表**:`input[type=checkbox]` 用 `accent-color: var(--action-color)`,不用系统蓝。
87
87
  - **图片**:正文图与坐标图圆角一律 `--radius-sm`(3px),不用 6px 大圆角;图注选择器同时覆盖 `figure figcaption`、`.quarto-float-caption`、`.figure-caption`(`lang: zh` 后 Quarto 生成「图 3.1: …」,`lang` 必须写在 `_quarto.yml`,否则图/表题回落英文 Figure/Table)。
88
88
  - **图片灯箱**:`format.html.lightbox` 打开后 GLightbox 默认 0.7 黑遮罩,页面图注会从图后透出;主题把遮罩加深到 0.86、关闭/翻页按钮做成 32% 黑的圆钮白描边。透明底 SVG 必须垫白卡(`img[src$=".svg"]`),否则深色遮罩上会整幅消失;位图不加白边。
89
- - **顶栏品牌标**:`palettes/<name>/brand-mark.svg` 作 `mask`、以 `--navbar-fg` 上色;外形是同一枚「打开的书」,16px 仍可辨。标签页用同目录 `favicon-light.svg` / `favicon-dark.svg`,`book.favicon` 指 Flexoki light,其余五份随 `resources` 发布,`palette.html` 按色板与明暗换 href。图标高 1.25rem、与标题间距 0.5rem,`margin-bottom: 0.2rem` 对齐汉字基线;窄屏先压搜索再省标题。
89
+ - **顶栏品牌标**:`palettes/<name>/brand-mark.svg` 作 `mask`、以 `--navbar-fg` 上色;外形是同一枚「打开的书」,16px 仍可辨。标签页用同目录 `favicon-light.svg` / `favicon-dark.svg`,`book.favicon` 指 Flexoki light,其余五份随 `resources` 发布,`palette.html` 按色板与明暗换 href。图标高 1.25rem、与标题间距 0.5rem,`margin-bottom: 0.2rem` 对齐汉字基线;窄屏先压搜索再省标题:`≤420px` 只留书标,图标长到 1.375rem 并撤掉那条抬升,书名改成视觉隐藏(`clip-path: inset(50%)`)而不是 `display:none`——这枚链接仍是「回到首页」,读屏必须能读出书名。次级导航的面包屑在首页与部首页只有一项(书名或部名),紧挨品牌重复一遍,用 `:has(> li:only-child)` 整条收起,只留侧栏开关;两项以上的章节轨迹才显示。
90
90
  - **顶栏搜索**:Quarto 的 Algolia detached button 自带 `svg.aa-SubmitIcon`,主题不再给 `#quarto-search` 画伪元素放大镜;按钮 32×32、图标 18px、用 `--navbar-muted` 上色,hover 用 `--navbar-hover-bg`。浮层容器、表单和输入框都用纸面/正文令牌,输入框需用与 Quarto 同等长的选择器(`.aa-DetachedContainer .aa-DetachedFormContainer .aa-Form .aa-InputWrapper .aa-Input`)压过编译期暗色值;占位文字用 `--text-secondary`,取消按钮 `white-space: nowrap`,结果选中项用 `--surface-raised`。`≤575.98px` 时浮层收成贴顶通栏,左右无圆角,避免 Quarto 的桌面 `top:100px` 把搜索条压在正文中部。
91
91
  - **色板切换**:`.palette-picker` 是 select-only combobox——按钮 `role="combobox"` + `aria-haspopup="listbox"`,面板 `.palette-menu-list` 为 `role="listbox"`,选项 `role="option"`;焦点留在按钮上,用 `aria-activedescendant` 指当前项。闭合态是 2rem 高胶囊:色点 + 英文名(Flexoki / Solarized / Hill Light)+ 方向箭头;展开后三行「色点 + 名称」,当前项右侧打勾。键位照 APG:↑/↓/Home/End 移动、首字母跳转、Enter 或 Space 提交、Esc 无提交关闭、Tab 关闭并走开。名称用英文,与 `book-palette` 的取值和色板目录同名,不另起中文译名。选择仍写 `book-palette`,首屏恢复逻辑不变,样式在 `css/nav.css`。不用原生 `<select>`(暗色下会保持系统浅色下拉)。
92
92
  - **明暗圆钮**:Quarto 自带的是 16px「开关」位图(`bi-toggle-off/on` 的 data-URI `background-image`,填充色写死在 SVG 里),紧挨色板胶囊时两枚圆角疙瘩容易看混。主题把 `::before` 改成只做 `mask`、颜色吃 `currentColor` 的对比圆盘(描边 1.2、半径 6.2,实心半圆让到描边内缘),`.alternate`(夜间)旋转 180°,实心半圆换边;可点区域 44px、图标 16px。
@@ -518,6 +518,37 @@ def browser_layout(book_dir, verbose):
518
518
  f"{label}: 次级导航底边 "
519
519
  f"{header_rule['secondaryBorderBottomWidth']}px,会和顶栏底边叠成双线"
520
520
  )
521
+ # 品牌区:窄屏(≤420)只留书标,链接不能因此变成无名链接——
522
+ # 书名必须留在可访问树里(视觉隐藏即可),图标补回一点尺寸。
523
+ brand = data.get("navBrand") or {}
524
+ if brand:
525
+ if not brand.get("label"):
526
+ problems.append(
527
+ f"{label}: 顶栏品牌链接没有可读名称,屏幕阅读器读不出书名"
528
+ )
529
+ if brand.get("titleDisplay") == "none":
530
+ problems.append(
531
+ f"{label}: 书名被 display:none 摘出可访问树,窄屏书标成了无名链接"
532
+ )
533
+ expected_icon = 22 if width <= 420 else 20
534
+ if abs(brand.get("iconWidth", 0) - expected_icon) > 0.6:
535
+ problems.append(
536
+ f"{label}: 书标 {brand.get('iconWidth')}px,"
537
+ f"应为 {expected_icon}px"
538
+ )
539
+ # 次级导航只有一项面包屑(首页/部首页的书名)时应整条收起:
540
+ # 它紧挨顶栏品牌重复一遍,也说不出「书内位置」。
541
+ crumb = data.get("secondaryBreadcrumb") or {}
542
+ if (
543
+ crumb
544
+ and crumb.get("navDisplay") != "none"
545
+ and crumb.get("listDisplay") != "none"
546
+ and crumb.get("items", 0) < 2
547
+ ):
548
+ problems.append(
549
+ f"{label}: 次级导航只有 {crumb.get('items', 0)} 项面包屑"
550
+ "(与顶栏书名重复),应整条隐藏"
551
+ )
521
552
  h2_after = data.get("h2Rules") or []
522
553
  if h2_after:
523
554
  first = h2_after[0]
@@ -151,7 +151,9 @@ async function collect(page, pageId, scheme) {
151
151
  }
152
152
  for (const element of scope.querySelectorAll("*")) {
153
153
  if (
154
- element.closest(".screen-reader-only, .sr-only, [aria-hidden='true']")
154
+ // `.visually-hidden` 是 Bootstrap 的屏幕阅读器工具类(clip-path 藏在 1px 盒子里),
155
+ // 与下面 `svg` 同理:它本来就要超出自身 1px 盒宽,不能当版式溢出上报。
156
+ element.closest(".screen-reader-only, .sr-only, .visually-hidden, [aria-hidden='true']")
155
157
  // 内联 SVG(棋盘快照等)没有 CSS 盒模型,<text> 的 clientWidth 恒为 0、
156
158
  // scrollWidth 按字形量出,天然「溢出」,不能当版式问题。
157
159
  || element.closest("svg")
@@ -232,6 +234,35 @@ async function collect(page, pageId, scheme) {
232
234
  };
233
235
  })
234
236
  : null;
237
+ // 品牌区:窄屏收起书名只留书标时,链接仍要有可读名称(屏幕阅读器读不到书名
238
+ // 就等于失去「回到首页」的语义),所以书名只能视觉隐藏、不能 display:none。
239
+ // 书标随书名一起收掉后需要补一点视觉重量,图标尺寸由断点决定。
240
+ const brand = document.querySelector(".navbar-brand");
241
+ const brandTitle = brand ? brand.querySelector(".navbar-title") : null;
242
+ const navBrand = brand
243
+ ? {
244
+ label: (
245
+ brand.getAttribute("aria-label")
246
+ || (brandTitle ? brandTitle.textContent : "")
247
+ || ""
248
+ ).trim(),
249
+ titleDisplay: brandTitle ? style(brandTitle).display : null,
250
+ width: Math.round(brand.getBoundingClientRect().width),
251
+ iconWidth: px(getComputedStyle(brand, "::before").width),
252
+ }
253
+ : null;
254
+ // 次级导航的面包屑在首页与部首页只有一项(书名或部名),紧挨顶栏品牌重复一遍,
255
+ // 也说不出「书内位置」,应整条收起;两项及以上(章节页)才显示。
256
+ const crumbList = document.querySelector(
257
+ "nav.quarto-secondary-nav .quarto-page-breadcrumbs ol.breadcrumb"
258
+ );
259
+ const secondaryBreadcrumb = crumbList
260
+ ? {
261
+ items: crumbList.querySelectorAll(":scope > li").length,
262
+ listDisplay: style(crumbList).display,
263
+ navDisplay: style(crumbList.closest(".quarto-page-breadcrumbs")).display,
264
+ }
265
+ : null;
235
266
  const rootStyle = style(root);
236
267
  return {
237
268
  pageId,
@@ -259,6 +290,8 @@ async function collect(page, pageId, scheme) {
259
290
  copyButtonOpacities,
260
291
  sidebarOverlay,
261
292
  sidebarSections,
293
+ navBrand,
294
+ secondaryBreadcrumb,
262
295
  headerRule,
263
296
  h2Rules,
264
297
  contentWidth: px(rootStyle.width),
@@ -33,7 +33,7 @@ metadata:
33
33
 
34
34
  ## 两种走法
35
35
 
36
- 增量任务只走与改动对应的步骤,只改判为不合格的地方,其余保持原样;九步同走只用于新章、新阶段或用户明确要求全章重构。全章重构仍是这九步,但第 1–2 步先定删留、不动句子,第 3–4 步只拆块和段,第 5–7 步才逐句改。
36
+ 增量任务只走对应步骤,只改判为不合格的地方;全章重构仍走九步,但第 1–2 步先定删留、不动句子,第 3–4 步只拆块和段,第 5–7 步才逐句改。
37
37
 
38
38
  两条走法共守同一组不变量,写在这里,不重复进各步:不新增原文没有的事实、数字、命令、性能结论、引文和出处;不把「可能」「通常」「在本书里」这类留有余地的说法改成确定说法;保留否定、条件、范围、时间和归属;围栏里的代码、命令、路径、URL 与 YAML 不改。增量润色只删除和换词,不扩写、不补论证;全章重构可删可换序,但不能发明事实,也不能把限定说成确定。
39
39
 
@@ -45,7 +45,7 @@ metadata:
45
45
 
46
46
  1. 定任务:写出读者做完能完成的那件事,再列这一章要覆盖的任务。规则在 `steps/01-task.md`。
47
47
  2. 定结构:排页面骨架、二级标题和篇幅预算,只定删留、标题与章题对齐。规则在 `steps/02-structure.md`。
48
- 3. 排列内容块:导语、围栏、收束与 Callout 的次序,一个围栏只放正在讲的那一块。规则在 `steps/03-blocks.md`,表格写法见 `quarto/tables.md`。
48
+ 3. 排列内容块:导语、围栏、收束与 Callout 的次序,一个围栏只放正在讲的那一块。规则在 `steps/03-blocks.md`,表格写法见 `quarto/tables.md`;围栏给声明还是给定义,按 `references/fence-semantics.md` 判。
49
49
  图注写在图下、图前只写引出关系;三类图的统一写法见 `quarto/figures.md`。
50
50
  4. 定段落:一段一个关键,下一句接住上一句的新信息;段间衔接落在话题切换处。规则在 `steps/04-paragraphs.md`。
51
51
  5. 定句子:主语选执行者,对象先行,一句一动作。规则在 `steps/05-sentences.md`。
@@ -0,0 +1,68 @@
1
+ # 围栏与正文的配对
2
+
3
+ 本文件只回答一个问题:代码围栏里给读者看的是声明还是定义,正文就只讲哪种话。
4
+
5
+ ## 两种素材,两种话
6
+
7
+ | 素材 | 围栏里是什么 | 正文可以讲什么 |
8
+ | --- | --- | --- |
9
+ | 声明围栏 | `.h` 里的签名、成员、类定义 | 契约:参数、返回值、异常、边界、副作用、与其它接口的分工 |
10
+ | 定义围栏 | `.cpp`(或头文件里的函数体)里的实现 | 机制:用了哪个函数、什么表达式、是否复制、扫描顺序、为什么这样写 |
11
+
12
+ 判据只有一句:**删掉定义文件,这句话还成立吗?** 成立是契约,不成立是实现。
13
+
14
+ ## 三种合法配对
15
+
16
+ | 配对 | 围栏 | 正文 | 用在 |
17
+ | --- | --- | --- | --- |
18
+ | 契约型 | 声明 | 契约文字 | 定义平凡:单行 `return`、一跳转发、两行赋值 |
19
+ | 机制型 | 声明 + 定义 | 实现文字 | 定义非平凡,需要讲分支、算法或取舍 |
20
+ | 纯定义型 | 定义 | 实现文字 | 定义就在头文件(`constexpr`、模板),或声明已在本章前文出现过 |
21
+
22
+ 补充一条**组合转发例外**:同一小节里几个一跳转发的定义可以合并成一个短围栏,正文讲它们如何组合;合并后仍遵守「正文只讲围栏里看得到的代码」。
23
+
24
+ ## 不允许的写法
25
+
26
+ 1. 声明围栏 + 实现文字(本节讲机制,读者手上却只有接口)。
27
+ 2. 声明围栏 + 「完整实现见源文件」这类指路句:读者在书里看不到,等于没给。
28
+ 3. 正文出现围栏里不存在的函数名或表达式(正文讲 `GetWinner`,围栏只有 `IsWin` 的声明)。
29
+ 4. 声明与定义之间隔了整段机制解释,读者翻到围栏时讲解已经结束。
30
+ 5. 定义不平凡却只写契约,再在「常见错误」或 Callout 里补一段机制。
31
+
32
+ ## 平凡定义的判定
33
+
34
+ - 平凡:单次比较、单个 `return`、两行赋值、纯委托(`return Other();`)、只做一次边界检查后返回。
35
+ - 不平凡:含循环或分支组合、更新状态、与别的类型协作、需要解释「为什么不用另一种写法」。
36
+
37
+ 平凡判例:`IsInRange` 的 `return row >= 0 && row < kRows && col >= 0 && col < kCols;`、`IsWin` 的 `return GetWinner().has_value();`。
38
+
39
+ ## 正反例(棋盘章的真实片段)
40
+
41
+ 改前——声明围栏,正文讲定义:
42
+
43
+ ```markdown
44
+ ```{.cpp filename="board.h"}
45
+ /// @return 是否有任意一枚棋子连成一条线。
46
+ [[nodiscard]] bool IsWin() const;
47
+ ```
48
+
49
+ `IsWin` 复用 `GetWinner` 的三连判定,不再单独扫描棋盘。
50
+ ```
51
+
52
+ 改后——组合围栏,正文对着代码讲组合:
53
+
54
+ ```markdown
55
+ ```{.cpp filename="board.cpp"}
56
+ bool Board::IsGameOver() const { return IsWin() || IsDraw(); }
57
+ bool Board::IsWin() const { return GetWinner().has_value(); }
58
+ bool Board::IsDraw() const { return IsFull() && !IsWin(); }
59
+ ```
60
+
61
+ `IsGameOver` 把两个判断用 `||` 连起来;`IsWin` 与 `IsDraw` 各自只回答一个问题,真正的扫描都在别处。
62
+ ```
63
+
64
+ ## 与其它规则的关系
65
+
66
+ - **代码块门槛**(`steps/03-blocks.md`):平凡实现不出围栏;本文件给的是「已经决定要讲」时该怎么配围栏。
67
+ - **正文与注释分工**:契约与边界写进 `///`,正文不转述;实现文字只补围栏里没有的上下文(为什么这样组合、和谁配合)。
68
+ - **检查器**:`scripts/verifying-content.py` 按 `###` 小节扫描,只有 `.h` 围栏而正文出现实现词时报 `decl-without-def`;「常见错误」「自测问题」「本章回顾」与表格行豁免。
@@ -30,6 +30,10 @@ EXCLUDE_DIRS = {
30
30
  "dist", "_freeze", "temp", "__pycache__", ".venv", "venv",
31
31
  }
32
32
  EXCLUDE_DIR_SUFFIXES = ("_files",)
33
+ # 构建目录的变体:build-debug / build-release / _build 都是输出目录,
34
+ # 里面是 CMake 自己生成的文件名(CompilerIdCXX.cpp、cmake_install.cmake),
35
+ # 不该参与命名检查,否则每次构建后都报一堆假阳性。
36
+ EXCLUDE_DIR_PREFIXES = ("build-", "_build")
33
37
 
34
38
  TOOL_FIXED_NAMES = {
35
39
  "CMakeLists.txt", "_quarto.yml", "_quarto.yaml", "SKILL.md", "AGENTS.md",
@@ -49,6 +53,13 @@ def first_non_ascii(name: str) -> str | None:
49
53
  def name_form_error(name: str) -> str | None:
50
54
  if name in TOOL_FIXED_NAMES:
51
55
  return None
56
+ # CMake 模块名按社区习惯写 CamelCase(CompileSettings.cmake),由
57
+ # include() / find_package() 按名查找,改名会直接断掉构建,故与
58
+ # CMakeLists.txt 同类放行;只看大小写规则,不放过下划线或空格。
59
+ if name.endswith(".cmake"):
60
+ if "_" in name or " " in name:
61
+ return "含下划线" if "_" in name else "含空格"
62
+ return None
52
63
  if any(a.islower() and b.isupper() for a, b in zip(name, name[1:])):
53
64
  return "含驼峰"
54
65
  if "_" in name:
@@ -86,6 +97,7 @@ def main() -> int:
86
97
 
87
98
  print(f"扫描:{repo_root}")
88
99
  print(f"排除目录:{', '.join(sorted(EXCLUDE_DIRS))}")
100
+ print(f"排除目录前缀:{', '.join(EXCLUDE_DIR_PREFIXES)}")
89
101
  print()
90
102
 
91
103
  bad: list[tuple[str, str]] = []
@@ -93,7 +105,9 @@ def main() -> int:
93
105
  for dirpath, dirnames, filenames in os.walk(repo_root):
94
106
  dirnames[:] = [
95
107
  d for d in dirnames
96
- if d not in EXCLUDE_DIRS and not d.endswith(EXCLUDE_DIR_SUFFIXES)
108
+ if d not in EXCLUDE_DIRS
109
+ and not d.endswith(EXCLUDE_DIR_SUFFIXES)
110
+ and not d.startswith(EXCLUDE_DIR_PREFIXES)
97
111
  ]
98
112
  for name in dirnames + filenames:
99
113
  count += 1
@@ -68,6 +68,14 @@ FUNCTION_NAME_RE = re.compile(
68
68
  TERMINAL_RE = re.compile(r"终端|打印|显示|输入|读入|命令行")
69
69
  TEST_RE = re.compile(r"测试|断言|用例|gtest|CTest")
70
70
 
71
+ # 围栏与正文的配对:声明围栏只配契约文字。正文出现实现词而小节里没有定义围栏时
72
+ # 报 decl-without-def,规则与正反例见 references/fence-semantics.md。
73
+ FENCE_FILENAME_RE = re.compile(r'filename="([^"]+)"')
74
+ IMPL_MARKER_RE = re.compile(r"复用|委托给|扫描|遍历|对每个|短路|表达式|状态位|缓存|不再单独|递归")
75
+ FENCE_SKIP_SECTION_RE = re.compile(r"常见错误|自测|回顾|练习|小结")
76
+ DECL_SUFFIXES = (".h", ".hpp", ".hh", ".hxx")
77
+ DEF_SUFFIXES = (".cpp", ".cc", ".cxx", ".inl")
78
+
71
79
  TREE_CATEGORY_COMMENTS = frozenset({
72
80
  "头文件", "源文件", "源码", "实现", "测试", "配置文件",
73
81
  "构建脚本", "构建规则", "动态库", "可执行文件",
@@ -175,12 +183,24 @@ def parse_chapters(quarto_yml: Path) -> list[str]:
175
183
  return chapters
176
184
 
177
185
 
186
+ EXCLUDED_DIRS = {
187
+ "node_modules", "_book", ".quarto", ".git", "_freeze", "_site",
188
+ "temp", "build", "dist", "_build",
189
+ }
190
+ EXCLUDED_DIR_PREFIXES = ("build-",)
191
+
192
+
193
+ def is_generated_dir(part: str) -> bool:
194
+ # 生成物与缓存目录整棵跳过:临时试验稿、构建产物里的 .qmd 不属于书稿源,
195
+ # 整仓扫描时报出它们的问题会让作者去改根本没有交付的文件。
196
+ return part in EXCLUDED_DIRS or part.startswith(".") or part.startswith(EXCLUDED_DIR_PREFIXES)
197
+
198
+
178
199
  def collect_all_qmd(root: Path) -> list[Path]:
179
- excluded = {"node_modules", "_book", ".quarto", ".git", "_freeze", "_site"}
180
200
  found: list[Path] = []
181
201
  for candidate in sorted(root.rglob("*.qmd")):
182
202
  rel_parts = candidate.relative_to(root).parts
183
- if any(part in excluded or part.startswith(".") for part in rel_parts):
203
+ if any(is_generated_dir(part) for part in rel_parts):
184
204
  continue
185
205
  found.append(candidate)
186
206
  return found
@@ -546,6 +566,72 @@ def check_lead(
546
566
  ))
547
567
 
548
568
 
569
+ def check_fence_semantics(path: Path, lines: list[str]) -> list[Issue]:
570
+ """声明围栏只能配契约文字:小节内没有定义围栏却讲实现时报错。"""
571
+ issues: list[Issue] = []
572
+ section_title: str | None = None
573
+ section_line = 0
574
+ fences: list[str] = []
575
+ prose: list[str] = []
576
+ in_fence = False
577
+ in_section = False
578
+
579
+ def flush() -> None:
580
+ nonlocal fences, prose
581
+ if section_title is None:
582
+ return
583
+ if not FENCE_SKIP_SECTION_RE.search(section_title):
584
+ has_decl = any(name.endswith(DECL_SUFFIXES) for name in fences if name)
585
+ has_def = any(name.endswith(DEF_SUFFIXES) for name in fences if name)
586
+ if has_decl and not has_def and prose:
587
+ text = " ".join(prose)
588
+ marker = IMPL_MARKER_RE.search(text)
589
+ if marker:
590
+ issues.append(Issue(
591
+ path, section_line,
592
+ f"小节「{section_title}」只有声明围栏,正文却讲实现({marker.group(0)});"
593
+ "按 references/fence-semantics.md 改成契约文字,或补定义围栏",
594
+ ))
595
+ fences = []
596
+ prose = []
597
+
598
+ section_title = None
599
+ fences = []
600
+ prose = []
601
+ in_fence = False
602
+ in_section = False
603
+ for number, raw in enumerate(lines, start=1):
604
+ if FENCE_RE.match(raw):
605
+ if not in_fence:
606
+ match = FENCE_FILENAME_RE.search(raw)
607
+ fences.append(match.group(1) if match else "")
608
+ in_fence = not in_fence
609
+ continue
610
+ if in_fence:
611
+ continue
612
+ heading = HEADING_RE.match(raw)
613
+ if heading:
614
+ level = len(heading.group(1))
615
+ if level in (2, 3):
616
+ flush()
617
+ section_title = heading.group(2)
618
+ section_line = number
619
+ in_section = True
620
+ elif level == 1:
621
+ flush()
622
+ section_title = None
623
+ in_section = False
624
+ continue
625
+ if not in_section or not raw.strip():
626
+ continue
627
+ stripped = raw.strip()
628
+ if stripped.startswith(("|", ">", ":::", "-", "*", "+")) or re.match(r"^\d+\.\s", stripped):
629
+ continue
630
+ prose.append(stripped)
631
+ flush()
632
+ return issues
633
+
634
+
549
635
  def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> list[Issue]:
550
636
  issues: list[Issue] = []
551
637
  text = read_text(path)
@@ -672,6 +758,8 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
672
758
  if in_fence:
673
759
  issues.append(Issue(path, fence_start, "代码围栏未闭合"))
674
760
 
761
+ issues.extend(check_fence_semantics(path, lines))
762
+
675
763
  for block in fence_blocks:
676
764
  issues.extend(check_tree_block(path, block))
677
765
 
@@ -74,6 +74,10 @@ int main() {
74
74
  - 仅为证明「源码有此函数」而贴出的实现。
75
75
  - 打断小节中心的硬插块。
76
76
 
77
+ ### 声明围栏与定义围栏
78
+
79
+ 围栏里是声明,正文就只能讲契约;正文要讲实现(复用哪个函数、怎么组合、扫不扫描),围栏里必须给出对应的定义。三种合法配对、平凡定义的判定与正反例见 `references/fence-semantics.md`;`verifying-content.py` 对「只有 `.h` 围栏却讲实现」的小节报 `decl-without-def`。
80
+
77
81
  ### 类与多函数摘录的垂直空白
78
82
 
79
83
  摘录 `class` 或多函数片段时**保留**源码中函数之间的空行,禁止为省行数坍缩成函数注释贴着下一声明;省略部分成员时,被保留的相邻函数之间仍空 1 行;同一 access 段内最后一组数据成员与函数之间空 1 行,数据成员组内不空行。与「逐字摘录」冲突时以源码空白为准:教学缩略可删成员,不可删「函数间距」惯例。
@@ -21,6 +21,7 @@
21
21
  | 定段落 | 一段内两个独立中心 | 按「拆核四步」拆段或改列表 | `steps/04-paragraphs.md`「拆核四步(多核混段的拆段方法)」、`examples/04-paragraph-cases.md`「自然段多中心未拆」 |
22
22
  | 定结构 | 章引言顿号串 ≥3 个交付物 | 下沉为列表或正文 `##` | `steps/02-structure.md`「六段块序列」、`examples/02-structure-shape.md`「目录式堆砌」 |
23
23
  | 排列内容块 | 过简实现围栏(单次比较或直接 `return`) | 删围栏,改入口列表 + 失败策略列表 | `steps/03-blocks.md` |
24
+ | 排列内容块 | 声明围栏 + 实现文字(正文讲复用、委托、扫描,围栏只有 `.h`) | 按 `references/fence-semantics.md` 三选一:改契约文字、补组合或定义围栏、删实现句 | `steps/03-blocks.md`「声明围栏与定义围栏」、`references/fence-semantics.md` |
24
25
  | 排列内容块 | 块间闭环缺失(标题下无引导、段→列表无 lead-in、代码→图只有空行、表前无关系句、Callout 复述块内) | 按块链检查清单补桥接 | `steps/03-blocks.md`「块链检查清单」、`steps/03-blocks.md` |
25
26
  | 排列内容块 | 节末旁注裸句(`##` 最后一块是一句可跳过的补充,前面是列表、表格或围栏) | 改写为独立可读的 Callout 收尾,正文删同句;超过两句改小节 | `steps/03-blocks.md`「节末旁注与 Callout 收尾」、`examples/03-qmd-elements.md`「节末旁注改为 Callout 收尾」 |
26
27
  | 定段落 | 段内同义堆叠(两句换说法讲同一机制) | 删辅句或合并;公式递进与简洁边界豁免 | `steps/04-paragraphs.md`「段内同义堆叠禁令」 |