@chengzhao-dev/dsh-quarto-book 0.3.4 → 0.3.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.
@@ -1,577 +1,577 @@
1
- #!/usr/bin/env python3
2
- """校验渲染产物(_book)里的「结构契约」。已修过的 HTML/CSS 联动 bug 因此不会复发。
3
-
4
- 为什么需要它:Quarto 升级会改动代码块的 DOM。例如复制按钮从 div.sourceCode 的
5
- 后代变成兄弟。这类问题在任何单一源文件里都看不出来。只有把「产物结构」和
6
- 「CSS 选择器」放在一起校验,它才现形。本脚本把历史上真实踩过的坑固化成断言。
7
-
8
- 省 token 的机制:脚本自己去读产物里那些几十 KB 的 HTML/CSS。对外只输出
9
- 每条契约一行的结论(PASS/MISS)。正常情况下无需再人肉读 _book。
10
- 只有某条契约报 FAIL 时,才需要开诊断模式。诊断时再看具体文件。
11
-
12
- 契约清单:
13
- C1 复制按钮可见性:产物中 button.code-copy-button 必须与 div.sourceCode
14
- 同处 .code-copy-outer-scaffold 之下且互为兄弟,且 CSS 里必须存在
15
- .code-copy-outer-scaffold:hover .code-copy-button 规则。
16
- (若选择器仍写成 div.sourceCode:hover 的后代形式,hover 永不生效。)
17
- C2 触屏兜底:CSS 必须包含 @media (hover: none) 下的 .code-copy-button 可见规则,
18
- 否则移动端永远无法复制代码。
19
- C3 打印不隐藏代码:@media print 内不得对 .code-copy-outer-scaffold 设 display:none。
20
- (该层现在包裹代码本身,隐藏它 = 打印/PDF 时代码整块消失。)
21
- C4 站点图标:每个 HTML 的 <head> 必须含 rel="icon" 且指向 favicon.svg,
22
- 且该图标文件确实被发布到产物目录。
23
- C5 Mermaid 图表(按产物条件):产物没有 mermaid-js 占位符,说明正文本就不用图表,
24
- 契约 N/A。一旦出现占位符,就必须同时包含 Quarto Mermaid 运行时,且不得退化为
25
- 普通 sourceCode 代码块。浏览器加载脚本后,占位符会被转换成 SVG。
26
- C6 代码主题稳定性:源 CSS 必须使用统一代码字体,不得保留按 Bash/PowerShell
27
- 命令 token 强制改色的旧选择器。普通文本代码块不得带语言 token。
28
- C7 代码块视觉契约:普通文本代码块与语言代码块必须共用代码背景、边框、
29
- 字体和布局令牌,避免两套代码块样式分叉。
30
- C8 代码来源标题:QMD 代码块必须由 .code-with-filename 包裹,且主题必须提供
31
- 标题背景、文字和间距令牌。标题条不能退化为普通代码块。
32
- C9 可折叠答案:产物中的 details.answer-disclosure 存在时,必须默认收起,
33
- 摘要必须等于“查看答案”,且答案区不得混入标题层级;没有答案块时为 N/A。
34
-
35
- 用法:python checking-dom.py [--root .] [--book-dir _book] [--verbose]
36
- 退出码:0 = 全部契约通过,1 = 有契约失败,2 = 产物目录不存在(需先 quarto render)。
37
- """
38
-
39
- import argparse
40
- import re
41
- import sys
42
- from html.parser import HTMLParser
43
- from pathlib import Path
44
-
45
- SCAFFOLD = "code-copy-outer-scaffold"
46
- COPY_BTN = "code-copy-button"
47
- SRCODE = "sourceCode"
48
-
49
- # 无闭合标签的元素:不入栈。入栈会打乱解析栈。
50
- VOID_TAGS = {
51
- "area", "base", "br", "col", "embed", "hr", "img", "input",
52
- "link", "meta", "param", "source", "track", "wbr",
53
- }
54
-
55
-
56
- class Node:
57
- """极简 DOM 节点:只保留契约所需的 tag / class / attrs / 父子关系。"""
58
-
59
- __slots__ = ("tag", "classes", "attrs", "children", "parent")
60
-
61
- def __init__(self, tag, classes, attrs, parent):
62
- self.tag = tag
63
- self.classes = set(classes)
64
- self.attrs = attrs
65
- self.children = []
66
- self.parent = parent
67
-
68
-
69
- class TreeBuilder(HTMLParser):
70
- """把 HTML 解析成只含 class 信息的轻量树。本文件不引入第三方解析库。"""
71
-
72
- def __init__(self):
73
- super().__init__(convert_charrefs=True)
74
- self.root = Node("#root", set(), {}, None)
75
- self.stack = [self.root]
76
- self.link_rels = [] # [(rel, href)] 供 favicon 契约使用
77
-
78
- def handle_starttag(self, tag, attrs):
79
- attrd = dict(attrs)
80
- classes = (attrd.get("class") or "").split()
81
- node = Node(tag, classes, attrd, self.stack[-1])
82
- self.stack[-1].children.append(node)
83
- if tag == "link":
84
- self.link_rels.append(((attrd.get("rel") or "").lower(), attrd.get("href") or ""))
85
- if tag not in VOID_TAGS:
86
- self.stack.append(node)
87
-
88
- def handle_endtag(self, tag):
89
- # 容忍源文档里缺失的闭合标签(Quarto 输出常有)。解析时向上回溯到匹配的开标签。
90
- for i in range(len(self.stack) - 1, 0, -1):
91
- if self.stack[i].tag == tag:
92
- del self.stack[i:]
93
- return
94
-
95
-
96
- def walk(node):
97
- for child in node.children:
98
- yield child
99
- yield from walk(child)
100
-
101
-
102
- def is_descendant(node, ancestor):
103
- parent = node.parent
104
- while parent is not None:
105
- if parent is ancestor:
106
- return True
107
- parent = parent.parent
108
- return False
109
-
110
-
111
- def normalize(css):
112
- """压掉注释与多余空白。换行与缩进因此不再影响选择器匹配。"""
113
- css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
114
- return re.sub(r"\s+", " ", css)
115
-
116
-
117
- def collect_css(book_dir):
118
- texts = []
119
- for path in sorted(book_dir.rglob("*.css")):
120
- try:
121
- raw = path.read_text(encoding="utf-8", errors="ignore")
122
- except OSError:
123
- continue
124
- if SCAFFOLD in raw or COPY_BTN in raw or "@media" in raw:
125
- texts.append((path, normalize(raw)))
126
- return texts
127
-
128
-
129
- def media_blocks(css, keyword):
130
- """提取 @media <keyword> 整块内容(含嵌套)。扫描按花括号配平决定边界。"""
131
- blocks = []
132
- for m in re.finditer(r"@media[^{]*\b" + keyword + r"\b[^{]*\{", css):
133
- depth = 1
134
- i = m.end()
135
- while i < len(css) and depth:
136
- if css[i] == "{":
137
- depth += 1
138
- elif css[i] == "}":
139
- depth -= 1
140
- i += 1
141
- if depth == 0:
142
- blocks.append(css[m.end():i - 1])
143
- return blocks
144
-
145
-
146
- def rules_of(block_css):
147
- """把一段 CSS 拆成 (selector, body) 列表(跳过嵌套的 @规则首部)。"""
148
- out = []
149
- for m in re.finditer(r"([^{}]+)\{([^{}]*)\}", block_css):
150
- out.append((m.group(1).strip(), m.group(2)))
151
- return out
152
-
153
-
154
- def hidden_selectors(block_css, needle):
155
- """返回该 CSS 块中对 needle 设了 display:none 的选择器列表。"""
156
- hits = []
157
- for selector, body in rules_of(block_css):
158
- if needle in selector and re.search(r"display\s*:\s*none", body):
159
- hits.append(selector)
160
- return hits
161
-
162
-
163
- def parse_highlight_styles(quarto_text):
164
- """从 _quarto.yml 解析 highlight-style.light / .dark。"""
165
- block = re.search(
166
- r"highlight-style:\s*\n((?:[ \t]+.+\n)+)",
167
- quarto_text,
168
- )
169
- if not block:
170
- return None, None
171
- body = block.group(1)
172
- light = re.search(r"light:\s*(\S+)", body)
173
- dark = re.search(r"dark:\s*(\S+)", body)
174
- return (
175
- light.group(1) if light else None,
176
- dark.group(1) if dark else None,
177
- )
178
-
179
-
180
- def parse_active_palette(quarto_text):
181
- """从 css 列表解析 palettes/<name>/tokens.css。"""
182
- match = re.search(r"palettes/([A-Za-z0-9_-]+)/tokens\.css", quarto_text)
183
- return match.group(1) if match else None
184
-
185
-
186
- def parse_palette_meta_highlights(meta_text):
187
- """从 palette meta.md 表格读取期望的 highlight 主题名。"""
188
- light = re.search(r"highlight light\s*\|\s*`([^`]+)`", meta_text)
189
- dark = re.search(r"highlight dark\s*\|\s*`([^`]+)`", meta_text)
190
- return (
191
- light.group(1) if light else None,
192
- dark.group(1) if dark else None,
193
- )
194
-
195
-
196
- def check_contracts(book_dir, htmls, css_pairs, root=None, require_favicon=True):
197
- """跑完全部契约。返回 [(id, 名称, ok, 摘要, [细节行])]。"""
198
- results = []
199
- root = Path(root).resolve() if root is not None else book_dir.resolve().parent
200
-
201
- # ---------- C0 活跃 palette 与高亮主题一致 ----------
202
- quarto = root / "_quarto.yml"
203
- try:
204
- quarto_text = quarto.read_text(encoding="utf-8")
205
- except OSError:
206
- quarto_text = ""
207
- light_hl, dark_hl = parse_highlight_styles(quarto_text)
208
- palette = parse_active_palette(quarto_text)
209
- meta_path = (
210
- root
211
- / "theme/palettes"
212
- / (palette or "_missing_")
213
- / "meta.md"
214
- )
215
- try:
216
- meta_text = meta_path.read_text(encoding="utf-8") if meta_path.is_file() else ""
217
- except OSError:
218
- meta_text = ""
219
- expect_light, expect_dark = parse_palette_meta_highlights(meta_text)
220
- no_custom = "custom.theme" not in quarto_text.lower()
221
- c0 = bool(
222
- palette
223
- and light_hl
224
- and dark_hl
225
- and expect_light
226
- and expect_dark
227
- and light_hl == expect_light
228
- and dark_hl == expect_dark
229
- and no_custom
230
- )
231
- results.append((
232
- "C0", "活跃 palette 与 highlight-style 一致", c0,
233
- "palette=%s,light=%s(期望 %s),dark=%s(期望 %s),无 custom.theme=%s" % (
234
- palette, light_hl, expect_light, dark_hl, expect_dark, no_custom),
235
- [
236
- "_quarto.yml 的 palettes/<name>/tokens.css 须与同 pack meta.md 的 highlight 映射一致",
237
- "禁止 custom.theme;换色板时同步改 theme SCSS、palette tokens 与 highlight-style",
238
- ],
239
- ))
240
-
241
- # ---------- C1 复制按钮可见性 ----------
242
- sibling_ok = False
243
- scaffold_pages = 0
244
- for path, tree, _rels in htmls:
245
- scaffolds = [n for n in walk(tree) if SCAFFOLD in n.classes]
246
- if not scaffolds:
247
- continue
248
- scaffold_pages += 1
249
- for sc in scaffolds:
250
- code = [c for c in sc.children if c.tag == "div" and SRCODE in c.classes]
251
- btns = [c for c in sc.children if c.tag == "button" and COPY_BTN in c.classes]
252
- if code and btns and not any(is_descendant(b, code[0]) for b in btns):
253
- sibling_ok = True
254
- hover_rule = any(
255
- ".%s:hover .%s" % (SCAFFOLD, COPY_BTN) in css for _, css in css_pairs
256
- )
257
- stale_rule = any(
258
- "div.%s:hover .%s" % (SRCODE, COPY_BTN) in css for _, css in css_pairs
259
- )
260
- c1 = sibling_ok and hover_rule and not stale_rule
261
- results.append((
262
- "C1", "复制按钮 hover 可见(兄弟 DOM + scaffold 选择器)", c1,
263
- "pages=%d sibling=%s hover-rule=%s%s"
264
- % (scaffold_pages, sibling_ok, hover_rule, " stale-rule!" if stale_rule else ""),
265
- [
266
- "产物中 button.%s 必须与 div.%s 同为 .%s 的直接子元素" % (COPY_BTN, SRCODE, SCAFFOLD),
267
- "CSS 必须含 .%s:hover .%s(不能停留在 div.%s:hover 的后代写法)"
268
- % (SCAFFOLD, COPY_BTN, SRCODE),
269
- ],
270
- ))
271
-
272
- # ---------- C2 触屏兜底 ----------
273
- touch_ok = False
274
- for _, css in css_pairs:
275
- for block in media_blocks(css, "hover: none"):
276
- if COPY_BTN in block and re.search(r"opacity\s*:\s*1", block):
277
- touch_ok = True
278
- results.append((
279
- "C2", "触屏无 hover 时复制按钮仍可见", touch_ok,
280
- "@media (hover: none) 规则=%s" % touch_ok,
281
- ["CSS 需含 @media (hover: none) { .%s { opacity: 1 } }" % COPY_BTN],
282
- ))
283
-
284
- # ---------- C3 打印不隐藏代码 ----------
285
- offenders = []
286
- for path, css in css_pairs:
287
- for block in media_blocks(css, "print"):
288
- for sel in hidden_selectors(block, SCAFFOLD):
289
- offenders.append("%s: %s" % (path.name, sel))
290
- c3 = not offenders
291
- results.append((
292
- "C3", "打印/PDF 时代码块不被隐藏", c3,
293
- "违规规则数=%d" % len(offenders),
294
- ["@media print 内不得对 .%s 设 display:none(它现在包裹代码本身),只可隐藏 .%s"
295
- % (SCAFFOLD, COPY_BTN)] + offenders,
296
- ))
297
-
298
- # ---------- C4 favicon ----------
299
- icon_pages = 0
300
- missing = []
301
- for path, _tree, link_rels in htmls:
302
- rels = [(r, h) for r, h in link_rels if "icon" in r]
303
- if any("favicon.svg" in h for r, h in rels):
304
- icon_pages += 1
305
- else:
306
- missing.append(str(path.relative_to(book_dir)))
307
- icon_file = any(p.name == "favicon.svg" for p in book_dir.rglob("favicon.svg"))
308
- if not require_favicon:
309
- results.append((
310
- "C4", "站点图标已注入并发布", True,
311
- "N/A(book.favicon 未配置)",
312
- ["配置 book.favicon 后,本项才会要求每页 icon 链接与产物图标文件"],
313
- ))
314
- else:
315
- c4 = icon_pages == len(htmls) and icon_file and bool(htmls)
316
- summary = "%d/%d 页含 icon 链接,图标已发布=%s" % (icon_pages, len(htmls), icon_file)
317
- if missing:
318
- summary += ";缺链接: " + ", ".join(missing[:3])
319
- if len(missing) > 3:
320
- summary += " …"
321
- results.append((
322
- "C4", "站点图标已注入并发布", c4, summary,
323
- ["每页 <head> 需 <link rel=\"icon\" … favicon.svg>;源文件见 theme/assets/favicon.svg,"
324
- "由 .agents/skills/agent-workspace/maintaining-python/scripts/maintenance/generating-favicon.py 生成"],
325
- ))
326
-
327
- # ---------- C5 Mermaid ----------
328
- mermaid_blocks = 0
329
- mermaid_runtime_pages = 0
330
- raw_mermaid = []
331
- for path, _tree, _rels in htmls:
332
- try:
333
- html = path.read_text(encoding="utf-8", errors="ignore")
334
- except OSError:
335
- continue
336
- mermaid_blocks += len(re.findall(r"<pre\b[^>]*class=\"[^\"]*\bmermaid-js\b", html))
337
- if "quarto-diagram/mermaid.min.js" in html and "quarto-diagram/mermaid-init.js" in html:
338
- mermaid_runtime_pages += 1
339
- if re.search(
340
- r"<pre\b[^>]*class=\"[^\"]*\bsourceCode\b[^\"]*\"[^>]*>"
341
- r"(?:(?!</pre>).)*?(?:(?:flowchart|graph)\s+(?:TD|TB|LR|RL|BT)"
342
- r"|sequenceDiagram|stateDiagram|classDiagram)",
343
- html, re.S,
344
- ):
345
- raw_mermaid.append(str(path.relative_to(book_dir)))
346
- # 无占位符 = 正文不用图表,契约 N/A。有占位符才要求 Quarto Mermaid 运行时。
347
- c5 = not raw_mermaid and (mermaid_blocks == 0 or mermaid_runtime_pages > 0)
348
- if mermaid_blocks == 0 and not raw_mermaid:
349
- summary = "正文无 Mermaid 图(契约 N/A)"
350
- elif mermaid_blocks == 0:
351
- summary = "未发现 mermaid-js 占位符,疑似围栏写错退化为普通源码块=%d" % len(raw_mermaid)
352
- else:
353
- summary = "Mermaid 占位符=%d,运行时页面=%d,普通源码块=%d" % (
354
- mermaid_blocks, mermaid_runtime_pages, len(raw_mermaid))
355
- if raw_mermaid:
356
- summary += "(" + ", ".join(raw_mermaid[:3]) + (" …" if len(raw_mermaid) > 3 else "") + ")"
357
- results.append((
358
- "C5", "Mermaid 使用 Quarto 图表运行时", c5, summary,
359
- ["Mermaid 围栏必须使用 ```{mermaid}",
360
- "正文含 Mermaid 图时,产物必须同时有 mermaid-js 占位符和 Quarto Mermaid 运行时",
361
- "不得退化为带 sourceCode 类的普通图表代码块"],
362
- ))
363
-
364
- # ---------- C6 GitHub 代码主题与稳定 token ----------
365
- css_text = "\n".join(css for _, css in css_pairs)
366
- source_css = "\n".join(
367
- path.read_text(encoding="utf-8", errors="ignore")
368
- for path in sorted(book_dir.rglob("*.css"))
369
- )
370
- has_font = "var(--mono-font)" in source_css and "pre.sourceCode" in source_css
371
- has_left_align = bool(re.search(r"text-align\s*:\s*left", source_css))
372
- stale_selectors = re.findall(
373
- r"(?:pre|div)\.sourceCode:is\(\.bash,\s*\.powershell\)[^{]*\{[^}]*color",
374
- source_css,
375
- flags=re.S,
376
- )
377
- legacy_output_pages = []
378
- for path, tree, _rels in htmls:
379
- for node in walk(tree):
380
- if node.tag != "pre" or SRCODE in node.classes:
381
- continue
382
- if any(cls in node.classes for cls in ("bash", "powershell", "hljs")):
383
- legacy_output_pages.append(str(path.relative_to(book_dir)))
384
- c6 = has_font and has_left_align and not stale_selectors and not legacy_output_pages
385
- summary = "统一字体=%s,左对齐=%s,旧命令着色选择器=%d,普通文本代码块语言类=%d" % (
386
- has_font, has_left_align, len(stale_selectors), len(legacy_output_pages))
387
- results.append((
388
- "C6", "代码主题与 token 样式稳定", c6, summary,
389
- [
390
- "亮色/暗色应由 _quarto.yml 的 highlight-style(与活跃 palette meta 一致)提供",
391
- "代码 CSS 应统一使用 --mono-font,不按 Bash/PowerShell 命令 token 强制改色",
392
- "普通文本代码块应保持左对齐并保留终端输出的空格",
393
- "普通文本代码块不应带 sourceCode、bash、powershell 或 hljs 类",
394
- ],
395
- ))
396
-
397
- # ---------- C7 普通文本代码块与语言代码块的视觉契约 ----------
398
- theme_root = root / "theme"
399
- source_css_paths = list(theme_root.glob("css/*.css"))
400
- if palette:
401
- pack_tokens = theme_root / "palettes" / palette / "tokens.css"
402
- if pack_tokens.is_file():
403
- source_css_paths.append(pack_tokens)
404
- source_css_text = "\n".join(
405
- path.read_text(encoding="utf-8", errors="ignore")
406
- for path in sorted(source_css_paths)
407
- )
408
- shared_selectors = (
409
- "pre.sourceCode,\npre:not(.sourceCode)" in source_css_text
410
- or "pre.sourceCode, pre:not(.sourceCode)" in source_css_text
411
- )
412
- shared_tokens = all(token in source_css_text for token in (
413
- "--code-bg", "--code-border", "--code-fg", "--mono-font",
414
- "--code-font-size", "--code-line-height", "--code-padding",
415
- ))
416
- c7 = shared_selectors and shared_tokens
417
- results.append((
418
- "C7", "普通文本代码块与语言代码块共享视觉令牌", c7,
419
- "共享选择器=%s,代码令牌=%s" % (shared_selectors, shared_tokens),
420
- [
421
- "theme/css/code.css 应让 pre.sourceCode、pre:not(.sourceCode) 与 div.sourceCode 共用布局",
422
- "背景、边框、字体、字号、行高和内边距应引用共享 tokens 与活跃 palette 的代码令牌",
423
- ],
424
- ))
425
-
426
- # ---------- C8 代码来源标题 ----------
427
- titled_blocks = 0
428
- untitled = []
429
- for path, tree, _rels in htmls:
430
- wrappers = [n for n in walk(tree) if "code-with-filename" in n.classes]
431
- titled_blocks += len(wrappers)
432
- for node in walk(tree):
433
- if node.tag != "pre":
434
- continue
435
- if any(is_descendant(node, wrapper) for wrapper in wrappers):
436
- continue
437
- if node.classes & {"mermaid", "mermaid-js"}:
438
- continue
439
- untitled.append(str(path.relative_to(book_dir)))
440
- title_tokens = all(token in source_css_text for token in (
441
- "--code-title-bg", "--code-title-fg", "--code-title-font-size", "--code-title-padding",
442
- ))
443
- c8 = titled_blocks > 0 and not untitled and title_tokens
444
- summary = "代码标题块=%d,未包裹 pre=%d,标题令牌=%s" % (
445
- titled_blocks, len(untitled), title_tokens)
446
- if untitled:
447
- summary += "(" + ", ".join(sorted(set(untitled))[:3]) + " …)"
448
- results.append((
449
- "C8", "代码块来源标题已渲染并受主题控制", c8, summary,
450
- [
451
- "站点 QMD 代码块必须使用 {.语言 filename=\"标题\"} 属性围栏",
452
- "产物中的 pre 必须位于 .code-with-filename 下",
453
- "tokens.css 必须提供 --code-title-bg/fg/font-size/padding",
454
- ],
455
- ))
456
-
457
- # ---------- C9 可折叠答案 ----------
458
- answer_total = 0
459
- answer_open = []
460
- answer_summary = []
461
- answer_heading = []
462
- for path, _root, _link_rels in htmls:
463
- try:
464
- text = path.read_text(encoding="utf-8", errors="ignore")
465
- except OSError:
466
- continue
467
- pattern = re.compile(
468
- r"<details\b(?P<attrs>[^>]*)>(?P<body>.*?)</details>",
469
- flags=re.S | re.I,
470
- )
471
- for match in pattern.finditer(text):
472
- attrs = match.group("attrs")
473
- if "answer-disclosure" not in attrs:
474
- continue
475
- answer_total += 1
476
- rel = str(path.relative_to(book_dir))
477
- if re.search(r"\bopen\b", attrs):
478
- answer_open.append(rel)
479
- summary_match = re.search(
480
- r"<summary\b[^>]*>(?P<summary>.*?)</summary>",
481
- match.group("body"),
482
- flags=re.S | re.I,
483
- )
484
- visible = re.sub(r"<[^>]+>", "", summary_match.group("summary")).strip() if summary_match else ""
485
- if visible != "查看答案":
486
- answer_summary.append(rel)
487
- if re.search(r"<h[1-6]\b", match.group("body"), flags=re.I):
488
- answer_heading.append(rel)
489
- c9 = (
490
- not answer_open
491
- and not answer_summary
492
- and not answer_heading
493
- )
494
- summary = "答案块=%d,默认展开=%d,摘要异常=%d,内部标题=%d" % (
495
- answer_total,
496
- len(answer_open),
497
- len(answer_summary),
498
- len(answer_heading),
499
- )
500
- results.append((
501
- "C9", "可折叠答案默认收起且摘要与层级正确(无答案时 N/A)", c9, summary,
502
- [
503
- "站点 QMD 使用 {.answer} 组件,产物为 details.answer-disclosure 和固定摘要",
504
- "answer-disclosure 不得带 open 属性",
505
- "折叠答案内部不得出现 h1–h6",
506
- ],
507
- ))
508
-
509
- return results
510
-
511
-
512
- def main():
513
- try:
514
- sys.stdout.reconfigure(encoding="utf-8")
515
- sys.stderr.reconfigure(encoding="utf-8")
516
- except Exception:
517
- pass
518
-
519
- parser = argparse.ArgumentParser(description="校验渲染产物中的结构契约")
520
- parser.add_argument("--root", default=".", help="书根目录(默认当前目录)")
521
- parser.add_argument("--book-dir", default="_book", help="渲染产物目录(默认 _book)")
522
- parser.add_argument("--verbose", action="store_true", help="失败时展开修复指引")
523
- args = parser.parse_args()
524
-
525
- book_dir = Path(args.book_dir)
526
- if not book_dir.is_dir():
527
- print("MISS 产物目录不存在:%s(请先运行 quarto render)" % book_dir)
528
- return 2
529
-
530
- htmls = []
531
- for path in sorted(book_dir.rglob("*.html")):
532
- builder = TreeBuilder()
533
- try:
534
- builder.feed(path.read_text(encoding="utf-8", errors="ignore"))
535
- except OSError:
536
- continue
537
- htmls.append((path, builder.root, builder.link_rels))
538
-
539
- if not htmls:
540
- print("MISS 未找到任何 HTML:%s" % book_dir)
541
- return 1
542
-
543
- css_pairs = collect_css(book_dir)
544
- root = Path(args.root)
545
- quarto = root / "_quarto.yml"
546
- require_favicon = False
547
- try:
548
- for line in quarto.read_text(encoding="utf-8").splitlines():
549
- if line.lstrip().startswith("#"):
550
- continue
551
- if re.match(r"^\s*favicon:\s*\S", line):
552
- require_favicon = True
553
- break
554
- except OSError:
555
- pass
556
- results = check_contracts(book_dir, htmls, css_pairs, root=root, require_favicon=require_favicon)
557
-
558
- # 默认每条契约只占一行。失败项排在前面。这样方便快速定位。
559
- failed = 0
560
- for cid, name, ok, summary, hints in sorted(results, key=lambda r: r[2]):
561
- if not ok:
562
- failed += 1
563
- print("%s %s %s (%s)" % ("OK " if ok else "FAIL", cid, name, summary))
564
- if not ok and args.verbose:
565
- for line in hints:
566
- print(" · %s" % line)
567
-
568
- print()
569
- if failed == 0:
570
- print("DOM contracts: %d/%d PASS" % (len(results), len(results)))
571
- return 0
572
- print("DOM contracts: %d 项失败,需开诊断模式定位(见 --verbose)" % failed)
573
- return 1
574
-
575
-
576
- if __name__ == "__main__":
577
- sys.exit(main())
1
+ #!/usr/bin/env python3
2
+ """校验渲染产物(_book)里的「结构契约」。已修过的 HTML/CSS 联动 bug 因此不会复发。
3
+
4
+ 为什么需要它:Quarto 升级会改动代码块的 DOM。例如复制按钮从 div.sourceCode 的
5
+ 后代变成兄弟。这类问题在任何单一源文件里都看不出来。只有把「产物结构」和
6
+ 「CSS 选择器」放在一起校验,它才现形。本脚本把历史上真实踩过的坑固化成断言。
7
+
8
+ 省 token 的机制:脚本自己去读产物里那些几十 KB 的 HTML/CSS。对外只输出
9
+ 每条契约一行的结论(PASS/MISS)。正常情况下无需再人肉读 _book。
10
+ 只有某条契约报 FAIL 时,才需要开诊断模式。诊断时再看具体文件。
11
+
12
+ 契约清单:
13
+ C1 复制按钮可见性:产物中 button.code-copy-button 必须与 div.sourceCode
14
+ 同处 .code-copy-outer-scaffold 之下且互为兄弟,且 CSS 里必须存在
15
+ .code-copy-outer-scaffold:hover .code-copy-button 规则。
16
+ (若选择器仍写成 div.sourceCode:hover 的后代形式,hover 永不生效。)
17
+ C2 触屏兜底:CSS 必须包含 @media (hover: none) 下的 .code-copy-button 可见规则,
18
+ 否则移动端永远无法复制代码。
19
+ C3 打印不隐藏代码:@media print 内不得对 .code-copy-outer-scaffold 设 display:none。
20
+ (该层现在包裹代码本身,隐藏它 = 打印/PDF 时代码整块消失。)
21
+ C4 站点图标:每个 HTML 的 <head> 必须含 rel="icon" 且指向 favicon.svg,
22
+ 且该图标文件确实被发布到产物目录。
23
+ C5 Mermaid 图表(按产物条件):产物没有 mermaid-js 占位符,说明正文本就不用图表,
24
+ 契约 N/A。一旦出现占位符,就必须同时包含 Quarto Mermaid 运行时,且不得退化为
25
+ 普通 sourceCode 代码块。浏览器加载脚本后,占位符会被转换成 SVG。
26
+ C6 代码主题稳定性:源 CSS 必须使用统一代码字体,不得保留按 Bash/PowerShell
27
+ 命令 token 强制改色的旧选择器。普通文本代码块不得带语言 token。
28
+ C7 代码块视觉契约:普通文本代码块与语言代码块必须共用代码背景、边框、
29
+ 字体和布局令牌,避免两套代码块样式分叉。
30
+ C8 代码来源标题:QMD 代码块必须由 .code-with-filename 包裹,且主题必须提供
31
+ 标题背景、文字和间距令牌。标题条不能退化为普通代码块。
32
+ C9 可折叠答案:产物中的 details.answer-disclosure 存在时,必须默认收起,
33
+ 摘要必须等于“查看答案”,且答案区不得混入标题层级;没有答案块时为 N/A。
34
+
35
+ 用法:python checking-dom.py [--root .] [--book-dir _book] [--verbose]
36
+ 退出码:0 = 全部契约通过,1 = 有契约失败,2 = 产物目录不存在(需先 quarto render)。
37
+ """
38
+
39
+ import argparse
40
+ import re
41
+ import sys
42
+ from html.parser import HTMLParser
43
+ from pathlib import Path
44
+
45
+ SCAFFOLD = "code-copy-outer-scaffold"
46
+ COPY_BTN = "code-copy-button"
47
+ SRCODE = "sourceCode"
48
+
49
+ # 无闭合标签的元素:不入栈。入栈会打乱解析栈。
50
+ VOID_TAGS = {
51
+ "area", "base", "br", "col", "embed", "hr", "img", "input",
52
+ "link", "meta", "param", "source", "track", "wbr",
53
+ }
54
+
55
+
56
+ class Node:
57
+ """极简 DOM 节点:只保留契约所需的 tag / class / attrs / 父子关系。"""
58
+
59
+ __slots__ = ("tag", "classes", "attrs", "children", "parent")
60
+
61
+ def __init__(self, tag, classes, attrs, parent):
62
+ self.tag = tag
63
+ self.classes = set(classes)
64
+ self.attrs = attrs
65
+ self.children = []
66
+ self.parent = parent
67
+
68
+
69
+ class TreeBuilder(HTMLParser):
70
+ """把 HTML 解析成只含 class 信息的轻量树。本文件不引入第三方解析库。"""
71
+
72
+ def __init__(self):
73
+ super().__init__(convert_charrefs=True)
74
+ self.root = Node("#root", set(), {}, None)
75
+ self.stack = [self.root]
76
+ self.link_rels = [] # [(rel, href)] 供 favicon 契约使用
77
+
78
+ def handle_starttag(self, tag, attrs):
79
+ attrd = dict(attrs)
80
+ classes = (attrd.get("class") or "").split()
81
+ node = Node(tag, classes, attrd, self.stack[-1])
82
+ self.stack[-1].children.append(node)
83
+ if tag == "link":
84
+ self.link_rels.append(((attrd.get("rel") or "").lower(), attrd.get("href") or ""))
85
+ if tag not in VOID_TAGS:
86
+ self.stack.append(node)
87
+
88
+ def handle_endtag(self, tag):
89
+ # 容忍源文档里缺失的闭合标签(Quarto 输出常有)。解析时向上回溯到匹配的开标签。
90
+ for i in range(len(self.stack) - 1, 0, -1):
91
+ if self.stack[i].tag == tag:
92
+ del self.stack[i:]
93
+ return
94
+
95
+
96
+ def walk(node):
97
+ for child in node.children:
98
+ yield child
99
+ yield from walk(child)
100
+
101
+
102
+ def is_descendant(node, ancestor):
103
+ parent = node.parent
104
+ while parent is not None:
105
+ if parent is ancestor:
106
+ return True
107
+ parent = parent.parent
108
+ return False
109
+
110
+
111
+ def normalize(css):
112
+ """压掉注释与多余空白。换行与缩进因此不再影响选择器匹配。"""
113
+ css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
114
+ return re.sub(r"\s+", " ", css)
115
+
116
+
117
+ def collect_css(book_dir):
118
+ texts = []
119
+ for path in sorted(book_dir.rglob("*.css")):
120
+ try:
121
+ raw = path.read_text(encoding="utf-8", errors="ignore")
122
+ except OSError:
123
+ continue
124
+ if SCAFFOLD in raw or COPY_BTN in raw or "@media" in raw:
125
+ texts.append((path, normalize(raw)))
126
+ return texts
127
+
128
+
129
+ def media_blocks(css, keyword):
130
+ """提取 @media <keyword> 整块内容(含嵌套)。扫描按花括号配平决定边界。"""
131
+ blocks = []
132
+ for m in re.finditer(r"@media[^{]*\b" + keyword + r"\b[^{]*\{", css):
133
+ depth = 1
134
+ i = m.end()
135
+ while i < len(css) and depth:
136
+ if css[i] == "{":
137
+ depth += 1
138
+ elif css[i] == "}":
139
+ depth -= 1
140
+ i += 1
141
+ if depth == 0:
142
+ blocks.append(css[m.end():i - 1])
143
+ return blocks
144
+
145
+
146
+ def rules_of(block_css):
147
+ """把一段 CSS 拆成 (selector, body) 列表(跳过嵌套的 @规则首部)。"""
148
+ out = []
149
+ for m in re.finditer(r"([^{}]+)\{([^{}]*)\}", block_css):
150
+ out.append((m.group(1).strip(), m.group(2)))
151
+ return out
152
+
153
+
154
+ def hidden_selectors(block_css, needle):
155
+ """返回该 CSS 块中对 needle 设了 display:none 的选择器列表。"""
156
+ hits = []
157
+ for selector, body in rules_of(block_css):
158
+ if needle in selector and re.search(r"display\s*:\s*none", body):
159
+ hits.append(selector)
160
+ return hits
161
+
162
+
163
+ def parse_highlight_styles(quarto_text):
164
+ """从 _quarto.yml 解析 highlight-style.light / .dark。"""
165
+ block = re.search(
166
+ r"highlight-style:\s*\n((?:[ \t]+.+\n)+)",
167
+ quarto_text,
168
+ )
169
+ if not block:
170
+ return None, None
171
+ body = block.group(1)
172
+ light = re.search(r"light:\s*(\S+)", body)
173
+ dark = re.search(r"dark:\s*(\S+)", body)
174
+ return (
175
+ light.group(1) if light else None,
176
+ dark.group(1) if dark else None,
177
+ )
178
+
179
+
180
+ def parse_active_palette(quarto_text):
181
+ """从 css 列表解析 palettes/<name>/tokens.css。"""
182
+ match = re.search(r"palettes/([A-Za-z0-9_-]+)/tokens\.css", quarto_text)
183
+ return match.group(1) if match else None
184
+
185
+
186
+ def parse_palette_meta_highlights(meta_text):
187
+ """从 palette meta.md 表格读取期望的 highlight 主题名。"""
188
+ light = re.search(r"highlight light\s*\|\s*`([^`]+)`", meta_text)
189
+ dark = re.search(r"highlight dark\s*\|\s*`([^`]+)`", meta_text)
190
+ return (
191
+ light.group(1) if light else None,
192
+ dark.group(1) if dark else None,
193
+ )
194
+
195
+
196
+ def check_contracts(book_dir, htmls, css_pairs, root=None, require_favicon=True):
197
+ """跑完全部契约。返回 [(id, 名称, ok, 摘要, [细节行])]。"""
198
+ results = []
199
+ root = Path(root).resolve() if root is not None else book_dir.resolve().parent
200
+
201
+ # ---------- C0 活跃 palette 与高亮主题一致 ----------
202
+ quarto = root / "_quarto.yml"
203
+ try:
204
+ quarto_text = quarto.read_text(encoding="utf-8")
205
+ except OSError:
206
+ quarto_text = ""
207
+ light_hl, dark_hl = parse_highlight_styles(quarto_text)
208
+ palette = parse_active_palette(quarto_text)
209
+ meta_path = (
210
+ root
211
+ / "theme/palettes"
212
+ / (palette or "_missing_")
213
+ / "meta.md"
214
+ )
215
+ try:
216
+ meta_text = meta_path.read_text(encoding="utf-8") if meta_path.is_file() else ""
217
+ except OSError:
218
+ meta_text = ""
219
+ expect_light, expect_dark = parse_palette_meta_highlights(meta_text)
220
+ no_custom = "custom.theme" not in quarto_text.lower()
221
+ c0 = bool(
222
+ palette
223
+ and light_hl
224
+ and dark_hl
225
+ and expect_light
226
+ and expect_dark
227
+ and light_hl == expect_light
228
+ and dark_hl == expect_dark
229
+ and no_custom
230
+ )
231
+ results.append((
232
+ "C0", "活跃 palette 与 highlight-style 一致", c0,
233
+ "palette=%s,light=%s(期望 %s),dark=%s(期望 %s),无 custom.theme=%s" % (
234
+ palette, light_hl, expect_light, dark_hl, expect_dark, no_custom),
235
+ [
236
+ "_quarto.yml 的 palettes/<name>/tokens.css 须与同 pack meta.md 的 highlight 映射一致",
237
+ "禁止 custom.theme;换色板时同步改 theme SCSS、palette tokens 与 highlight-style",
238
+ ],
239
+ ))
240
+
241
+ # ---------- C1 复制按钮可见性 ----------
242
+ sibling_ok = False
243
+ scaffold_pages = 0
244
+ for path, tree, _rels in htmls:
245
+ scaffolds = [n for n in walk(tree) if SCAFFOLD in n.classes]
246
+ if not scaffolds:
247
+ continue
248
+ scaffold_pages += 1
249
+ for sc in scaffolds:
250
+ code = [c for c in sc.children if c.tag == "div" and SRCODE in c.classes]
251
+ btns = [c for c in sc.children if c.tag == "button" and COPY_BTN in c.classes]
252
+ if code and btns and not any(is_descendant(b, code[0]) for b in btns):
253
+ sibling_ok = True
254
+ hover_rule = any(
255
+ ".%s:hover .%s" % (SCAFFOLD, COPY_BTN) in css for _, css in css_pairs
256
+ )
257
+ stale_rule = any(
258
+ "div.%s:hover .%s" % (SRCODE, COPY_BTN) in css for _, css in css_pairs
259
+ )
260
+ c1 = sibling_ok and hover_rule and not stale_rule
261
+ results.append((
262
+ "C1", "复制按钮 hover 可见(兄弟 DOM + scaffold 选择器)", c1,
263
+ "pages=%d sibling=%s hover-rule=%s%s"
264
+ % (scaffold_pages, sibling_ok, hover_rule, " stale-rule!" if stale_rule else ""),
265
+ [
266
+ "产物中 button.%s 必须与 div.%s 同为 .%s 的直接子元素" % (COPY_BTN, SRCODE, SCAFFOLD),
267
+ "CSS 必须含 .%s:hover .%s(不能停留在 div.%s:hover 的后代写法)"
268
+ % (SCAFFOLD, COPY_BTN, SRCODE),
269
+ ],
270
+ ))
271
+
272
+ # ---------- C2 触屏兜底 ----------
273
+ touch_ok = False
274
+ for _, css in css_pairs:
275
+ for block in media_blocks(css, "hover: none"):
276
+ if COPY_BTN in block and re.search(r"opacity\s*:\s*1", block):
277
+ touch_ok = True
278
+ results.append((
279
+ "C2", "触屏无 hover 时复制按钮仍可见", touch_ok,
280
+ "@media (hover: none) 规则=%s" % touch_ok,
281
+ ["CSS 需含 @media (hover: none) { .%s { opacity: 1 } }" % COPY_BTN],
282
+ ))
283
+
284
+ # ---------- C3 打印不隐藏代码 ----------
285
+ offenders = []
286
+ for path, css in css_pairs:
287
+ for block in media_blocks(css, "print"):
288
+ for sel in hidden_selectors(block, SCAFFOLD):
289
+ offenders.append("%s: %s" % (path.name, sel))
290
+ c3 = not offenders
291
+ results.append((
292
+ "C3", "打印/PDF 时代码块不被隐藏", c3,
293
+ "违规规则数=%d" % len(offenders),
294
+ ["@media print 内不得对 .%s 设 display:none(它现在包裹代码本身),只可隐藏 .%s"
295
+ % (SCAFFOLD, COPY_BTN)] + offenders,
296
+ ))
297
+
298
+ # ---------- C4 favicon ----------
299
+ icon_pages = 0
300
+ missing = []
301
+ for path, _tree, link_rels in htmls:
302
+ rels = [(r, h) for r, h in link_rels if "icon" in r]
303
+ if any("favicon.svg" in h for r, h in rels):
304
+ icon_pages += 1
305
+ else:
306
+ missing.append(str(path.relative_to(book_dir)))
307
+ icon_file = any(p.name == "favicon.svg" for p in book_dir.rglob("favicon.svg"))
308
+ if not require_favicon:
309
+ results.append((
310
+ "C4", "站点图标已注入并发布", True,
311
+ "N/A(book.favicon 未配置)",
312
+ ["配置 book.favicon 后,本项才会要求每页 icon 链接与产物图标文件"],
313
+ ))
314
+ else:
315
+ c4 = icon_pages == len(htmls) and icon_file and bool(htmls)
316
+ summary = "%d/%d 页含 icon 链接,图标已发布=%s" % (icon_pages, len(htmls), icon_file)
317
+ if missing:
318
+ summary += ";缺链接: " + ", ".join(missing[:3])
319
+ if len(missing) > 3:
320
+ summary += " …"
321
+ results.append((
322
+ "C4", "站点图标已注入并发布", c4, summary,
323
+ ["每页 <head> 需 <link rel=\"icon\" … favicon.svg>;源文件见 theme/assets/favicon.svg,"
324
+ "由 .agents/skills/agent-workspace/maintaining-python/scripts/maintenance/generating-favicon.py 生成"],
325
+ ))
326
+
327
+ # ---------- C5 Mermaid ----------
328
+ mermaid_blocks = 0
329
+ mermaid_runtime_pages = 0
330
+ raw_mermaid = []
331
+ for path, _tree, _rels in htmls:
332
+ try:
333
+ html = path.read_text(encoding="utf-8", errors="ignore")
334
+ except OSError:
335
+ continue
336
+ mermaid_blocks += len(re.findall(r"<pre\b[^>]*class=\"[^\"]*\bmermaid-js\b", html))
337
+ if "quarto-diagram/mermaid.min.js" in html and "quarto-diagram/mermaid-init.js" in html:
338
+ mermaid_runtime_pages += 1
339
+ if re.search(
340
+ r"<pre\b[^>]*class=\"[^\"]*\bsourceCode\b[^\"]*\"[^>]*>"
341
+ r"(?:(?!</pre>).)*?(?:(?:flowchart|graph)\s+(?:TD|TB|LR|RL|BT)"
342
+ r"|sequenceDiagram|stateDiagram|classDiagram)",
343
+ html, re.S,
344
+ ):
345
+ raw_mermaid.append(str(path.relative_to(book_dir)))
346
+ # 无占位符 = 正文不用图表,契约 N/A。有占位符才要求 Quarto Mermaid 运行时。
347
+ c5 = not raw_mermaid and (mermaid_blocks == 0 or mermaid_runtime_pages > 0)
348
+ if mermaid_blocks == 0 and not raw_mermaid:
349
+ summary = "正文无 Mermaid 图(契约 N/A)"
350
+ elif mermaid_blocks == 0:
351
+ summary = "未发现 mermaid-js 占位符,疑似围栏写错退化为普通源码块=%d" % len(raw_mermaid)
352
+ else:
353
+ summary = "Mermaid 占位符=%d,运行时页面=%d,普通源码块=%d" % (
354
+ mermaid_blocks, mermaid_runtime_pages, len(raw_mermaid))
355
+ if raw_mermaid:
356
+ summary += "(" + ", ".join(raw_mermaid[:3]) + (" …" if len(raw_mermaid) > 3 else "") + ")"
357
+ results.append((
358
+ "C5", "Mermaid 使用 Quarto 图表运行时", c5, summary,
359
+ ["Mermaid 围栏必须使用 ```{mermaid}",
360
+ "正文含 Mermaid 图时,产物必须同时有 mermaid-js 占位符和 Quarto Mermaid 运行时",
361
+ "不得退化为带 sourceCode 类的普通图表代码块"],
362
+ ))
363
+
364
+ # ---------- C6 GitHub 代码主题与稳定 token ----------
365
+ css_text = "\n".join(css for _, css in css_pairs)
366
+ source_css = "\n".join(
367
+ path.read_text(encoding="utf-8", errors="ignore")
368
+ for path in sorted(book_dir.rglob("*.css"))
369
+ )
370
+ has_font = "var(--mono-font)" in source_css and "pre.sourceCode" in source_css
371
+ has_left_align = bool(re.search(r"text-align\s*:\s*left", source_css))
372
+ stale_selectors = re.findall(
373
+ r"(?:pre|div)\.sourceCode:is\(\.bash,\s*\.powershell\)[^{]*\{[^}]*color",
374
+ source_css,
375
+ flags=re.S,
376
+ )
377
+ legacy_output_pages = []
378
+ for path, tree, _rels in htmls:
379
+ for node in walk(tree):
380
+ if node.tag != "pre" or SRCODE in node.classes:
381
+ continue
382
+ if any(cls in node.classes for cls in ("bash", "powershell", "hljs")):
383
+ legacy_output_pages.append(str(path.relative_to(book_dir)))
384
+ c6 = has_font and has_left_align and not stale_selectors and not legacy_output_pages
385
+ summary = "统一字体=%s,左对齐=%s,旧命令着色选择器=%d,普通文本代码块语言类=%d" % (
386
+ has_font, has_left_align, len(stale_selectors), len(legacy_output_pages))
387
+ results.append((
388
+ "C6", "代码主题与 token 样式稳定", c6, summary,
389
+ [
390
+ "亮色/暗色应由 _quarto.yml 的 highlight-style(与活跃 palette meta 一致)提供",
391
+ "代码 CSS 应统一使用 --mono-font,不按 Bash/PowerShell 命令 token 强制改色",
392
+ "普通文本代码块应保持左对齐并保留终端输出的空格",
393
+ "普通文本代码块不应带 sourceCode、bash、powershell 或 hljs 类",
394
+ ],
395
+ ))
396
+
397
+ # ---------- C7 普通文本代码块与语言代码块的视觉契约 ----------
398
+ theme_root = root / "theme"
399
+ source_css_paths = list(theme_root.glob("css/*.css"))
400
+ if palette:
401
+ pack_tokens = theme_root / "palettes" / palette / "tokens.css"
402
+ if pack_tokens.is_file():
403
+ source_css_paths.append(pack_tokens)
404
+ source_css_text = "\n".join(
405
+ path.read_text(encoding="utf-8", errors="ignore")
406
+ for path in sorted(source_css_paths)
407
+ )
408
+ shared_selectors = (
409
+ "pre.sourceCode,\npre:not(.sourceCode)" in source_css_text
410
+ or "pre.sourceCode, pre:not(.sourceCode)" in source_css_text
411
+ )
412
+ shared_tokens = all(token in source_css_text for token in (
413
+ "--code-bg", "--code-border", "--code-fg", "--mono-font",
414
+ "--code-font-size", "--code-line-height", "--code-padding",
415
+ ))
416
+ c7 = shared_selectors and shared_tokens
417
+ results.append((
418
+ "C7", "普通文本代码块与语言代码块共享视觉令牌", c7,
419
+ "共享选择器=%s,代码令牌=%s" % (shared_selectors, shared_tokens),
420
+ [
421
+ "theme/css/code.css 应让 pre.sourceCode、pre:not(.sourceCode) 与 div.sourceCode 共用布局",
422
+ "背景、边框、字体、字号、行高和内边距应引用共享 tokens 与活跃 palette 的代码令牌",
423
+ ],
424
+ ))
425
+
426
+ # ---------- C8 代码来源标题 ----------
427
+ titled_blocks = 0
428
+ untitled = []
429
+ for path, tree, _rels in htmls:
430
+ wrappers = [n for n in walk(tree) if "code-with-filename" in n.classes]
431
+ titled_blocks += len(wrappers)
432
+ for node in walk(tree):
433
+ if node.tag != "pre":
434
+ continue
435
+ if any(is_descendant(node, wrapper) for wrapper in wrappers):
436
+ continue
437
+ if node.classes & {"mermaid", "mermaid-js"}:
438
+ continue
439
+ untitled.append(str(path.relative_to(book_dir)))
440
+ title_tokens = all(token in source_css_text for token in (
441
+ "--code-title-bg", "--code-title-fg", "--code-title-font-size", "--code-title-padding",
442
+ ))
443
+ c8 = titled_blocks > 0 and not untitled and title_tokens
444
+ summary = "代码标题块=%d,未包裹 pre=%d,标题令牌=%s" % (
445
+ titled_blocks, len(untitled), title_tokens)
446
+ if untitled:
447
+ summary += "(" + ", ".join(sorted(set(untitled))[:3]) + " …)"
448
+ results.append((
449
+ "C8", "代码块来源标题已渲染并受主题控制", c8, summary,
450
+ [
451
+ "站点 QMD 代码块必须使用 {.语言 filename=\"标题\"} 属性围栏",
452
+ "产物中的 pre 必须位于 .code-with-filename 下",
453
+ "tokens.css 必须提供 --code-title-bg/fg/font-size/padding",
454
+ ],
455
+ ))
456
+
457
+ # ---------- C9 可折叠答案 ----------
458
+ answer_total = 0
459
+ answer_open = []
460
+ answer_summary = []
461
+ answer_heading = []
462
+ for path, _root, _link_rels in htmls:
463
+ try:
464
+ text = path.read_text(encoding="utf-8", errors="ignore")
465
+ except OSError:
466
+ continue
467
+ pattern = re.compile(
468
+ r"<details\b(?P<attrs>[^>]*)>(?P<body>.*?)</details>",
469
+ flags=re.S | re.I,
470
+ )
471
+ for match in pattern.finditer(text):
472
+ attrs = match.group("attrs")
473
+ if "answer-disclosure" not in attrs:
474
+ continue
475
+ answer_total += 1
476
+ rel = str(path.relative_to(book_dir))
477
+ if re.search(r"\bopen\b", attrs):
478
+ answer_open.append(rel)
479
+ summary_match = re.search(
480
+ r"<summary\b[^>]*>(?P<summary>.*?)</summary>",
481
+ match.group("body"),
482
+ flags=re.S | re.I,
483
+ )
484
+ visible = re.sub(r"<[^>]+>", "", summary_match.group("summary")).strip() if summary_match else ""
485
+ if visible != "查看答案":
486
+ answer_summary.append(rel)
487
+ if re.search(r"<h[1-6]\b", match.group("body"), flags=re.I):
488
+ answer_heading.append(rel)
489
+ c9 = (
490
+ not answer_open
491
+ and not answer_summary
492
+ and not answer_heading
493
+ )
494
+ summary = "答案块=%d,默认展开=%d,摘要异常=%d,内部标题=%d" % (
495
+ answer_total,
496
+ len(answer_open),
497
+ len(answer_summary),
498
+ len(answer_heading),
499
+ )
500
+ results.append((
501
+ "C9", "可折叠答案默认收起且摘要与层级正确(无答案时 N/A)", c9, summary,
502
+ [
503
+ "站点 QMD 使用 {.answer} 组件,产物为 details.answer-disclosure 和固定摘要",
504
+ "answer-disclosure 不得带 open 属性",
505
+ "折叠答案内部不得出现 h1–h6",
506
+ ],
507
+ ))
508
+
509
+ return results
510
+
511
+
512
+ def main():
513
+ try:
514
+ sys.stdout.reconfigure(encoding="utf-8")
515
+ sys.stderr.reconfigure(encoding="utf-8")
516
+ except Exception:
517
+ pass
518
+
519
+ parser = argparse.ArgumentParser(description="校验渲染产物中的结构契约")
520
+ parser.add_argument("--root", default=".", help="书根目录(默认当前目录)")
521
+ parser.add_argument("--book-dir", default="_book", help="渲染产物目录(默认 _book)")
522
+ parser.add_argument("--verbose", action="store_true", help="失败时展开修复指引")
523
+ args = parser.parse_args()
524
+
525
+ book_dir = Path(args.book_dir)
526
+ if not book_dir.is_dir():
527
+ print("MISS 产物目录不存在:%s(请先运行 quarto render)" % book_dir)
528
+ return 2
529
+
530
+ htmls = []
531
+ for path in sorted(book_dir.rglob("*.html")):
532
+ builder = TreeBuilder()
533
+ try:
534
+ builder.feed(path.read_text(encoding="utf-8", errors="ignore"))
535
+ except OSError:
536
+ continue
537
+ htmls.append((path, builder.root, builder.link_rels))
538
+
539
+ if not htmls:
540
+ print("MISS 未找到任何 HTML:%s" % book_dir)
541
+ return 1
542
+
543
+ css_pairs = collect_css(book_dir)
544
+ root = Path(args.root)
545
+ quarto = root / "_quarto.yml"
546
+ require_favicon = False
547
+ try:
548
+ for line in quarto.read_text(encoding="utf-8").splitlines():
549
+ if line.lstrip().startswith("#"):
550
+ continue
551
+ if re.match(r"^\s*favicon:\s*\S", line):
552
+ require_favicon = True
553
+ break
554
+ except OSError:
555
+ pass
556
+ results = check_contracts(book_dir, htmls, css_pairs, root=root, require_favicon=require_favicon)
557
+
558
+ # 默认每条契约只占一行。失败项排在前面。这样方便快速定位。
559
+ failed = 0
560
+ for cid, name, ok, summary, hints in sorted(results, key=lambda r: r[2]):
561
+ if not ok:
562
+ failed += 1
563
+ print("%s %s %s (%s)" % ("OK " if ok else "FAIL", cid, name, summary))
564
+ if not ok and args.verbose:
565
+ for line in hints:
566
+ print(" · %s" % line)
567
+
568
+ print()
569
+ if failed == 0:
570
+ print("DOM contracts: %d/%d PASS" % (len(results), len(results)))
571
+ return 0
572
+ print("DOM contracts: %d 项失败,需开诊断模式定位(见 --verbose)" % failed)
573
+ return 1
574
+
575
+
576
+ if __name__ == "__main__":
577
+ sys.exit(main())