@chengzhao-dev/dsh-quarto-book 0.6.11 → 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 +9 -1
- package/README.md +9 -1
- package/package.json +2 -1
- package/skills/designing-theme/assets/theme/css/nav.css +24 -3
- package/skills/designing-theme/references/theme-system.md +1 -1
- package/skills/designing-theme/scripts/checking-layout.py +31 -0
- package/skills/designing-theme/scripts/measuring-pages.mjs +34 -1
- package/skills/writing-quarto/SKILL.md +2 -2
- package/skills/writing-quarto/quarto/mermaid.md +6 -0
- package/skills/writing-quarto/references/book-journey.md +1 -0
- package/skills/writing-quarto/references/fence-semantics.md +68 -0
- package/skills/writing-quarto/scripts/checking-names.py +15 -1
- package/skills/writing-quarto/scripts/verifying-content.py +173 -2
- package/skills/writing-quarto/steps/02-structure.md +1 -1
- package/skills/writing-quarto/steps/03-blocks.md +4 -0
- package/skills/writing-quarto/steps/08-reread.md +1 -0
- package/skills/writing-quarto/steps/09-check.md +2 -0
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.
|
|
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.
|
|
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.
|
|
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.
|
|
575
|
-
height: 1.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`。
|
|
@@ -10,6 +10,12 @@ Quarto 只执行 ` ```{mermaid} ` 属性围栏;plain 的 ` ```mermaid ` 围栏
|
|
|
10
10
|
|
|
11
11
|
渲染核对方法:调用 `quarto_render_check`,在返回的 `html_report` 对应 HTML 中应出现 mermaid 图表容器(`mermaid-js` 或 `quarto-diagram` 类名),不应出现 `sourceCode mermaid` 高亮代码块。
|
|
12
12
|
|
|
13
|
+
## 无障碍名称
|
|
14
|
+
|
|
15
|
+
每个 `{mermaid}` 围栏都写角色与名称:`{.mermaid role="img" aria-label="调用链:Server 接收请求,交给 Controller 处理。"}`;`fig-cap` 给视觉读者,`aria-label` 给读屏软件,两处各写一句短名称。
|
|
16
|
+
|
|
17
|
+
名称说清谁和谁、按什么顺序、结果落到哪,不写“流程图”这类对象名。缺名称时 axe 报 `svg-img-alt`;渲染核对在 HTML 里搜 `<pre class="mermaid" role="img"`。
|
|
18
|
+
|
|
13
19
|
## 块首固定 init 指令
|
|
14
20
|
|
|
15
21
|
每个 `{mermaid}` 块的第一行写:
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
- **对象章**只写本章对象:数据成员、构造后的状态、公开接口与实现选择。后章才展开的类型名、个数标题、块后归属段不写;「留在某类型」「由某类得出」「不放进本章」这类归属句整句删除。
|
|
25
25
|
- 对象章内的互斥结果写成无序列表,每项「情形:结果」,列表后停止;标题点名这一节的实体,不数条目(`八条线` 拆成 `线坐标表`、`成线判断`)。
|
|
26
26
|
- 总览章与对象章的分界见项目章节表;本书 `01-rules.qmd`、`02-mapping-layout.qmd`、`viewing-program.qmd` 与两份 index 是总览,`03` 起各章是对象章。
|
|
27
|
+
- 整书遍同时统一陈述范围:部分页使用项目名与「这一部分」,不把单个样例抬成全书;只有书首页概括全书。页首导语不复述目录与章节卡片,卡片入口只写章节标题与一句交付结果。
|
|
27
28
|
|
|
28
29
|
## 以井字棋书为例
|
|
29
30
|
|
|
@@ -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
|
|
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
|
|
@@ -54,6 +54,8 @@ CODE_META_RE = re.compile(r"对应的代码")
|
|
|
54
54
|
COINED_RELATION_RE = re.compile(r"(?:组成|依赖|继承|调用)边")
|
|
55
55
|
CARRIER_RE = re.compile("承载")
|
|
56
56
|
NEXT_SECTION_RE = re.compile(r"(?:下一节|上一节)")
|
|
57
|
+
CALLOUT_OPEN_RE = re.compile(r"^\s*:::\s*\{?\s*\.callout-")
|
|
58
|
+
OPENING_CLICHE_RE = re.compile(r"^(?:本章|本节|这一章|这一节)(?:将|会|要)|^下面(?:介绍|讲)")
|
|
57
59
|
LADDER_RE = re.compile(r"(?:分别交给|各说一层)")
|
|
58
60
|
SKILL_TONE_RE = re.compile(r"这里不定义|测试也不必|不必为[^。;\n]{0,20}再记|不写双层循环")
|
|
59
61
|
INLINE_NAME_RE = re.compile(r"`([A-Za-z_][A-Za-z0-9_]*)`")
|
|
@@ -66,6 +68,14 @@ FUNCTION_NAME_RE = re.compile(
|
|
|
66
68
|
TERMINAL_RE = re.compile(r"终端|打印|显示|输入|读入|命令行")
|
|
67
69
|
TEST_RE = re.compile(r"测试|断言|用例|gtest|CTest")
|
|
68
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
|
+
|
|
69
79
|
TREE_CATEGORY_COMMENTS = frozenset({
|
|
70
80
|
"头文件", "源文件", "源码", "实现", "测试", "配置文件",
|
|
71
81
|
"构建脚本", "构建规则", "动态库", "可执行文件",
|
|
@@ -95,6 +105,10 @@ MAX_HEADING_LEVEL = 3
|
|
|
95
105
|
MAX_PARAGRAPH_CHARS = 320
|
|
96
106
|
MAX_SENTENCE_CHARS = 95
|
|
97
107
|
|
|
108
|
+
# 导语长度由项目在 checks.json 的 lead_chars 里声明,例如 [60, 140];没有声明
|
|
109
|
+
# 就不检查,避免把一个项目的版面预算带进另一本书。
|
|
110
|
+
LEAD_SKIP_RE = re.compile(r"^\s*(?:::|\||```|~~~|[-*+]\s|\d+\.\s|>|#)")
|
|
111
|
+
|
|
98
112
|
|
|
99
113
|
@dataclass
|
|
100
114
|
class Issue:
|
|
@@ -169,12 +183,24 @@ def parse_chapters(quarto_yml: Path) -> list[str]:
|
|
|
169
183
|
return chapters
|
|
170
184
|
|
|
171
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
|
+
|
|
172
199
|
def collect_all_qmd(root: Path) -> list[Path]:
|
|
173
|
-
excluded = {"node_modules", "_book", ".quarto", ".git", "_freeze", "_site"}
|
|
174
200
|
found: list[Path] = []
|
|
175
201
|
for candidate in sorted(root.rglob("*.qmd")):
|
|
176
202
|
rel_parts = candidate.relative_to(root).parts
|
|
177
|
-
if any(part
|
|
203
|
+
if any(is_generated_dir(part) for part in rel_parts):
|
|
178
204
|
continue
|
|
179
205
|
found.append(candidate)
|
|
180
206
|
return found
|
|
@@ -495,6 +521,117 @@ def check_html_figure_block(path: Path, block: list[tuple[int, str]]) -> list[Is
|
|
|
495
521
|
return issues
|
|
496
522
|
|
|
497
523
|
|
|
524
|
+
def visible_chars(text: str) -> int:
|
|
525
|
+
label = LINK_LABEL_RE.sub(r"\1", text)
|
|
526
|
+
return len(re.sub(r"\s+", "", label))
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def check_lead(
|
|
530
|
+
path: Path, front: str, lines: list[str], issues: list[Issue], limits: list[int]
|
|
531
|
+
) -> None:
|
|
532
|
+
"""检查 title 与第一个标题之间的首段导语长度。
|
|
533
|
+
|
|
534
|
+
着陆页(body-classes 里有 index-page 或 homepage)按卡片组织,不受限。
|
|
535
|
+
"""
|
|
536
|
+
if "body-classes:" in front and ("index-page" in front or "homepage" in front):
|
|
537
|
+
return
|
|
538
|
+
first = len(lines)
|
|
539
|
+
for index, raw in enumerate(lines):
|
|
540
|
+
if HEADING_RE.match(raw):
|
|
541
|
+
first = index
|
|
542
|
+
break
|
|
543
|
+
paragraph: list[str] = []
|
|
544
|
+
start = 1
|
|
545
|
+
for index, raw in enumerate(lines[:first], start=1):
|
|
546
|
+
if not raw.strip() or LEAD_SKIP_RE.match(raw):
|
|
547
|
+
if paragraph:
|
|
548
|
+
break
|
|
549
|
+
continue
|
|
550
|
+
if not paragraph:
|
|
551
|
+
start = index
|
|
552
|
+
paragraph.append(raw.strip())
|
|
553
|
+
if not paragraph:
|
|
554
|
+
return
|
|
555
|
+
length = visible_chars(" ".join(paragraph))
|
|
556
|
+
low, high = limits[0], limits[1]
|
|
557
|
+
if length < low:
|
|
558
|
+
issues.append(Issue(
|
|
559
|
+
path, start,
|
|
560
|
+
f"导语 {length} 字符,短于 {low};补一句本章要解决什么",
|
|
561
|
+
))
|
|
562
|
+
elif length > high:
|
|
563
|
+
issues.append(Issue(
|
|
564
|
+
path, start,
|
|
565
|
+
f"导语 {length} 字符,超过 {high};先删下一块已经摆出来的内容",
|
|
566
|
+
))
|
|
567
|
+
|
|
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
|
+
|
|
498
635
|
def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> list[Issue]:
|
|
499
636
|
issues: list[Issue] = []
|
|
500
637
|
text = read_text(path)
|
|
@@ -511,6 +648,9 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
|
|
|
511
648
|
if leading:
|
|
512
649
|
body = "\n" * leading.count("\n") + body
|
|
513
650
|
lines = body.splitlines()
|
|
651
|
+
limits = project.get("lead_chars")
|
|
652
|
+
if isinstance(limits, list) and len(limits) == 2 and all(isinstance(v, int) for v in limits):
|
|
653
|
+
check_lead(path, front, lines, issues, limits)
|
|
514
654
|
in_fence = False
|
|
515
655
|
fence_lang = ""
|
|
516
656
|
fence_start = 0
|
|
@@ -561,6 +701,11 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
|
|
|
561
701
|
fence_blocks.append(fence_lines)
|
|
562
702
|
if "mermaid" in fence_lang:
|
|
563
703
|
mermaid_blocks.append(fence_lines)
|
|
704
|
+
if "aria-label=" not in fence_lang or "role=" not in fence_lang:
|
|
705
|
+
issues.append(Issue(
|
|
706
|
+
path, number,
|
|
707
|
+
'mermaid 图缺少无障碍名称:围栏写 role="img" 与 aria-label="…"',
|
|
708
|
+
))
|
|
564
709
|
if fence_is_html:
|
|
565
710
|
html_blocks.append(fence_lines)
|
|
566
711
|
fence_lines = []
|
|
@@ -613,6 +758,8 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
|
|
|
613
758
|
if in_fence:
|
|
614
759
|
issues.append(Issue(path, fence_start, "代码围栏未闭合"))
|
|
615
760
|
|
|
761
|
+
issues.extend(check_fence_semantics(path, lines))
|
|
762
|
+
|
|
616
763
|
for block in fence_blocks:
|
|
617
764
|
issues.extend(check_tree_block(path, block))
|
|
618
765
|
|
|
@@ -637,6 +784,30 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
|
|
|
637
784
|
(n for n, raw in enumerate(lines, start=1) if HEADING_RE.match(raw)),
|
|
638
785
|
len(lines) + 1,
|
|
639
786
|
)
|
|
787
|
+
# 章首(第一个标题之前)只放自然段与列表:Callout 会把导语压成旁注,开篇套话则先讲
|
|
788
|
+
# 章节安排、不讲读者要做的事。着陆页是目录页,不受这条限制。
|
|
789
|
+
if path.name != "index.qmd":
|
|
790
|
+
opening_line = 0
|
|
791
|
+
opening_text = ""
|
|
792
|
+
for index, raw in enumerate(lines, start=1):
|
|
793
|
+
if index >= first_heading:
|
|
794
|
+
break
|
|
795
|
+
if index in fenced_lines:
|
|
796
|
+
continue
|
|
797
|
+
if CALLOUT_OPEN_RE.match(raw):
|
|
798
|
+
issues.append(Issue(
|
|
799
|
+
path, index,
|
|
800
|
+
"章首出现 Callout;第一个标题之前的导语只用自然段与列表(steps/02-structure.md)",
|
|
801
|
+
))
|
|
802
|
+
continue
|
|
803
|
+
if not opening_text and raw.strip() and not raw.lstrip().startswith(("|", ">", "-", "*", "+")):
|
|
804
|
+
opening_line = index
|
|
805
|
+
opening_text = raw.strip()
|
|
806
|
+
if opening_text and OPENING_CLICHE_RE.match(opening_text):
|
|
807
|
+
issues.append(Issue(
|
|
808
|
+
path, opening_line,
|
|
809
|
+
"引言以「本章将…/下面介绍…」开场;第一句写读者要做成的事(steps/02-structure.md)",
|
|
810
|
+
))
|
|
640
811
|
intro_blob_targets: set[str] = set()
|
|
641
812
|
for index, raw in enumerate(lines, start=1):
|
|
642
813
|
if index >= first_heading or index in fenced_lines:
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
**章际衔接(定结构检查项)**:第 N 章的引言、任务节与自测题只用第 1..N-1 章已建立的名字与概念;本章新术语在首次真正使用处就近落术语卡或行内括号;术语卡的升级判据见 `writing-quarto` 技能 `quarto/authoring.md`「Callout 升级判据」,写法见 `steps/03-blocks.md`。某概念接不上前章已建立的名字时,把它下沉回首次使用它的章建立,不在本章临时补课。
|
|
19
19
|
|
|
20
|
-
- **无标题引言**:第一个 `##` 之前,写成**连贯读者文**回答读者三问——从哪来(承接上一章已成立的结果)、这一章做什么(本章要解决的问题与读者将获得的能力,正面陈述;范围能并则并,不写否定式后章预告)、代码在哪(可选:若本章有主源文件,用**同段末句**自然语言点名并挂全章 blob 链接,不另起「代码写在…里」独立段)。第一句写出谁在什么情况下做本章这件事,类章同时写出类名;类型怎么实现放到后面的句子。不另开背景节。**形态硬限(用户决定)**:首选**恰好 1 个自然段**(通常 2–4 句);必要时允许 **1 个自然段 + 1 个列表**(列表前引导句写在同一段末,或段落后紧跟列表;列表不算第二散文段);**禁止**出现两个散文自然段并立,更禁止 ≥3 段、单句孤儿段、结尾导航段。列表仅用于并列范围/对象,任务细节下沉正文 `##`,不用顿号串交付物冒充散文。**摘录免责句永不写进读者正文**:「本章摘录源码服务于讲解」「片段不保证与源文件逐字一致」「不等于文件内顺序」类作者旁白一律删除(旧「示例位置句开口子」已废止),同源保证交给 `verifying-content.py`;摘录免责只保留在 技能的权威文件与检查脚本里。不放 Callout,不写后续章节内容,不用「后面的 X」式前向指称引入后续章节才建立的结构件(如「实现放在后面的编号目录里」)。引言容纳多个独立概念时**并段或改列表**,禁止拆成两个散文段;拆分判据见 `steps/04-paragraphs.md
|
|
20
|
+
- **无标题引言**:第一个 `##` 之前,写成**连贯读者文**回答读者三问——从哪来(承接上一章已成立的结果)、这一章做什么(本章要解决的问题与读者将获得的能力,正面陈述;范围能并则并,不写否定式后章预告)、代码在哪(可选:若本章有主源文件,用**同段末句**自然语言点名并挂全章 blob 链接,不另起「代码写在…里」独立段)。第一句写出谁在什么情况下做本章这件事,类章同时写出类名;类型怎么实现放到后面的句子。不另开背景节。**形态硬限(用户决定)**:首选**恰好 1 个自然段**(通常 2–4 句);必要时允许 **1 个自然段 + 1 个列表**(列表前引导句写在同一段末,或段落后紧跟列表;列表不算第二散文段);**禁止**出现两个散文自然段并立,更禁止 ≥3 段、单句孤儿段、结尾导航段。列表仅用于并列范围/对象,任务细节下沉正文 `##`,不用顿号串交付物冒充散文。**摘录免责句永不写进读者正文**:「本章摘录源码服务于讲解」「片段不保证与源文件逐字一致」「不等于文件内顺序」类作者旁白一律删除(旧「示例位置句开口子」已废止),同源保证交给 `verifying-content.py`;摘录免责只保留在 技能的权威文件与检查脚本里。不放 Callout,不写后续章节内容,不用「后面的 X」式前向指称引入后续章节才建立的结构件(如「实现放在后面的编号目录里」)。引言容纳多个独立概念时**并段或改列表**,禁止拆成两个散文段;拆分判据见 `steps/04-paragraphs.md`「拆分判据」。陈述范围跟页面一样大:部分页只说这一部分的对局与代码,只有书首页可以写「这本书」;第一句写读者要做成的事,不用「本章将…」「下面介绍…」开场。内容量硬限:导语 60–140 个可见字符(链接只算标签文字,空白不计;超过先删下一块已经摆出来的内容,不足补本章要解决什么);项目可在 `.agents/skills/quarto-book-project/checks.json` 用 `"lead_chars": [60, 140]` 声明,`verifying-content.py` 按此检查,着陆页不受限;token 预算见本文件「分层上限」;范围边界能并入承接段时不成列表。承接中引用其他章节一律用章节标题链接,不用「第 N 章」;**链接预算**:引言只挂承接上一章的那 1 个章节链接和本章主源文件的 blob 链接,其余章节链接下沉到正文真正用到它的句子,每处只挂当下需要的那个目标,同一段不堆多个章节链接(Google 技术写作指南把每个链接视为一次读者可能离开当前页的机会,链接越多,认知负担越重);本章回顾的下一章预告不写链接,直述预告一句(页面导航已有前进按钮),两者均见 `steps/06-wording.md`。任务概览默认并入承接句,正文小节就是任务的展开;预览列表仅当任务没有对应正文小节时使用(示例章四项工作列表已按此删除)。原 front matter `description` 的定位信息并入引言承接句,信息只保留引言一处、不同义两存;章节 qmd 不写 `description`(字段出处见 basics.md)。着陆页 `index` 按 landing 规则组织,不硬套本契约。
|
|
21
21
|
- **任务序列**:每节一个可验证结果。判定标准是任务结束时存在一个读者能自己复述的检查动作;只有「读完了解释」的内容并入相邻任务。概念 → 最小示例 → 操作 → 验证 → 边界,先让读者看到行为再解释规则;单次操作按「做什么 → 执行什么 → 看到什么 → 简短说明」推进。各章的任务节以该章标题里的对象为准,不把每一章都写成「目录、设计、构建、测试」四节。设计范围以这一章能在 `src/` 里核对的源文件收束。内容量硬限:任务级 `##` 通常不超过 4 个;`##` 与 `###` 开头的承接句数见本文件「分层上限」,写完直接进入代码、命令或列表;超出即为凑字数信号,禁令见 `steps/04-paragraphs.md`「凑字数禁令」。有 `###` 子节时,二级标题(`##`)的引言须承接上文;恰好 2 个子节时一句点一个,3 个或 4 个时不再逐个点名,点名交给三级标题的句式见本文件「标题分层」,点名用读者已认识的词,不点后文才定义的类型名;一句之内禁止把两个子节对象配成「由 X 定位,并保存 Y」「分别交给」式的两组对应(对照见 `examples/05-sentence-frame.md`「拉链句」)。交付清单或纯边界说明不单独设 `##`,并入引言或相邻工程总览节;同一头文件中连续的值类型优先按一个可验证结果归并到同一任务节。任务节内的讲述顺序按「类型 → 简单函数 → 更难的函数」排列,最难的组装放在最后,并满足先定义后调用:读者要跟着读懂的行内代码标识符,其行为必须已经出现过——上一章、上文,或同一小节里紧挨着的声明和一句行为——上一章已建立的名字视为已知;未定义名字的调用链、节首概览与编号步骤整段删除。`##` 导语只用白话,不点后文才定义的函数名。章内顺序的依据见 `SKILL.md`「九步」的定结构一条,本文件不复述。
|
|
22
22
|
- **常见错误**:服务失败后回来的读者。每个症状一个 `###`,标题后第一句写触发条件和可观察现象。误用型条目一段成文写原因与正确做法;原因与做法各需一句完整交代时(如原因牵涉私有段的设计动机),用症状句导语加「出错的原因/正确的做法」引导词列表,列表可作为节导语的合法收尾,导语预算只约束症状句;排查型故障展开定位 → 修复 → 验证三步。形态分野与细则是 `writing-quarto` 技能 `quarto/terminal.md`「常见错误条目形态与三步诊断流」。入门章节只保留最可能遇到的 2-3 项,不是完整 FAQ。
|
|
23
23
|
- **自测问题**:服务刚跑通主线、需要确认理解的读者。每章 0-7 道题,题目问本章已经写明的一件可观察结果(会怎样、是哪一个、从几开始),没有这种结果就不设题;只有本章写了原因、而且情景已经说全时,才用「为什么」。题面用本章已经用过的词说全,不用本章未定义的概念,不问「为什么不做成另一种」;题干是合上书的一问,只看本题题干、不看答案与上一题;题干代词的判法见 `steps/05-sentences.md`「代词不能代替未写出的对象」,允许「什么」——它在要一个名字或一句文字,不是指上文没出现的东西;题面里每个细节都要改变答案,答案不受细节影响时不单点某一种情形。题面只描述一个自洽局面,每个事实都要能同时成立,禁止把互相矛盾的局面写成题干(同一句里先说轮到甲方、又说这一步的用户是 O,读者听到的是程序自相矛盾,不是一次非法调用)——「轮到谁」和「这一步的用户」若是同一个对象,只写一次。要考拒绝,用正文已经用过的那条规则问可观察结果(正例:「空状态面板开局,轮到甲方。没轮到的一方来操作时,状态面板会改变吗?」,答案首句「不会。操作的用户不是当前用户,`Place` 返回 `kRejected`,状态面板和轮次都保持原样。」);不为了出题制造正文没走过的矛盾局面。一道题只问一件可观察结果,不把两个观察捆进同一题(如「状态面板是空的吗」与「轮到谁」并列),拆开或删去一个。固定导语「不要展开答案,试着自己把回答完整说出来,写完之后展开对照;说不出来的部分,回到对应小节对照正文。」;答案写在 `::: {.answer}` 围栏 div 内,由书内 `filters/answering-disclosure.lua` 渲染为默认收起的原生 `<details>`;答案只使用本章已讲过的概念。题目同样必须能由本章正文回答:答案依据须是正文已写明的内容,正文没讲的概念不设题;若问题值得问,先在正文补上依据再设题。答案先答所问:首句直接回答题面,一到两句,不补题面之外的对比、延伸或重复题面(对照与归属由正文承载)。问答中的文件、路径与标识符指称统一用行内代码,不挂链接(`verifying-content.py` 对自测节链接记 WARN);所属目录在答案前文已确立时,路径可省去重复的顶层前缀(如先写 `example/`,后文写 `include/state.h`)。
|
|
@@ -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`「段内同义堆叠禁令」 |
|
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
|
|
46
46
|
不手跑 `quarto render`,也不手跑 `checking-output.py`、`checking-callouts.py`、`checking-dom.py`。工具已经按当前书配置调用它们。
|
|
47
47
|
|
|
48
|
+
渲染之外还有一层可访问性核对:书配置的 debug 项里可打开 `axe: true`,Quarto 会在浏览器里加载 axe-core 报告 `heading-order`、`svg-img-alt` 这类问题。报告只作提示,改法是给图补 `aria-label`、补齐跳过的标题层级,不把行为项当作新步骤。
|
|
49
|
+
|
|
48
50
|
### 执行回执
|
|
49
51
|
|
|
50
52
|
检查结论确定后,调用一次 `quarto_pass_report`,把这次执行的结论压成一张卡片:
|