@chengzhao-dev/dsh-quarto-book 0.3.4 → 0.4.0
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/LICENSE +20 -20
- package/README.en.md +35 -4
- package/README.md +35 -4
- package/cordis.patch.yml +2 -2
- package/docs/plugin-flow.md +15 -4
- package/docs/versioning.md +9 -9
- package/lib/index.js +3 -1
- package/lib/tools/pass-report.js +332 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +10 -2
- package/skills/designing-theme/SKILL.md +1 -0
- package/skills/designing-theme/assets/theme/css/base.css +368 -368
- package/skills/designing-theme/assets/theme/includes/footer.html +63 -63
- package/skills/designing-theme/assets/theme/palettes/github/meta.md +38 -38
- package/skills/designing-theme/knowledge/reading-surface.md +68 -68
- package/skills/designing-theme/scripts/measuring-pages.mjs +309 -309
- package/skills/writing-quarto/SKILL.md +44 -43
- package/skills/writing-quarto/scripts/checking-callouts.py +102 -102
- package/skills/writing-quarto/scripts/checking-dom.py +577 -577
- package/skills/writing-quarto/scripts/diagrams/rendering-coords.py +250 -250
- package/skills/writing-quarto/scripts/testing-check-ascii-names.py +71 -71
- package/skills/writing-quarto/scripts/testing-content.py +149 -149
- package/skills/writing-quarto/scripts/verifying-content.py +378 -378
- package/skills/writing-quarto/steps/09-check.md +13 -1
- package/templates/quarto-book-project/writing/chapter-map.md +22 -22
|
@@ -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())
|