@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,309 +1,309 @@
|
|
|
1
|
-
// 从渲染产物读取真实浏览器几何与计算样式,供 checking-layout.py 做布局回归断言。
|
|
2
|
-
//
|
|
3
|
-
// 用法:node measuring-pages.mjs --book-dir _book --out out.json [--shots-dir DIR] [--all-pages]
|
|
4
|
-
// 退出码:0 = 测量成功;2 = Node/Playwright/Edge 或渲染产物不可用。
|
|
5
|
-
|
|
6
|
-
import { mkdir, readdir, writeFile } from "node:fs/promises";
|
|
7
|
-
import { createRequire } from "node:module";
|
|
8
|
-
import { existsSync } from "node:fs";
|
|
9
|
-
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
10
|
-
import { pathToFileURL } from "node:url";
|
|
11
|
-
|
|
12
|
-
const argv = process.argv.slice(2);
|
|
13
|
-
const flag = (name, fallback) => {
|
|
14
|
-
const index = argv.indexOf(name);
|
|
15
|
-
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
const require = createRequire(import.meta.url);
|
|
19
|
-
const bookDir = resolve(flag("--book-dir", "_book"));
|
|
20
|
-
const outFile = flag("--out", "");
|
|
21
|
-
const shotsDir = flag("--shots-dir", "");
|
|
22
|
-
const allPages = argv.includes("--all-pages");
|
|
23
|
-
const viewports = [1280, 768, 390];
|
|
24
|
-
const colorSchemes = ["light", "dark"];
|
|
25
|
-
|
|
26
|
-
async function collectHtml(directory) {
|
|
27
|
-
const entries = await readdir(directory, { withFileTypes: true });
|
|
28
|
-
const files = [];
|
|
29
|
-
for (const entry of entries) {
|
|
30
|
-
const path = join(directory, entry.name);
|
|
31
|
-
if (entry.isDirectory()) {
|
|
32
|
-
files.push(...await collectHtml(path));
|
|
33
|
-
} else if (entry.name.endsWith(".html") && entry.name !== "404.html") {
|
|
34
|
-
files.push(path);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return files;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function sampleHtml(files, root) {
|
|
41
|
-
const relativeFiles = files
|
|
42
|
-
.map((file) => relative(root, file).split(sep).join("/"))
|
|
43
|
-
.sort();
|
|
44
|
-
const selected = new Set();
|
|
45
|
-
const add = (relativePath) => {
|
|
46
|
-
if (relativePath) {
|
|
47
|
-
selected.add(relativePath);
|
|
48
|
-
}
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
add("index.html");
|
|
52
|
-
|
|
53
|
-
// 按顶层目录分组,取每组首页、第一章与最后一章。首页通常也是 part 落地页。
|
|
54
|
-
const bySection = new Map();
|
|
55
|
-
for (const file of relativeFiles) {
|
|
56
|
-
if (file === "index.html") {
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
const section = file.includes("/") ? file.split("/")[0] : ".";
|
|
60
|
-
const bucket = bySection.get(section) || [];
|
|
61
|
-
bucket.push(file);
|
|
62
|
-
bySection.set(section, bucket);
|
|
63
|
-
}
|
|
64
|
-
for (const [section, bucket] of [...bySection.entries()].sort()) {
|
|
65
|
-
if (section === ".") {
|
|
66
|
-
for (const file of bucket) {
|
|
67
|
-
add(file);
|
|
68
|
-
}
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
add(`${section}/index.html`);
|
|
72
|
-
add(bucket[0]);
|
|
73
|
-
add(bucket.at(-1));
|
|
74
|
-
}
|
|
75
|
-
return files.filter((file) => selected.has(relative(root, file).split(sep).join("/")));
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function loadPlaywright() {
|
|
79
|
-
try {
|
|
80
|
-
return require("playwright");
|
|
81
|
-
} catch (error) {
|
|
82
|
-
for (const directory of (process.env.NODE_PATH || "").split(sep).filter(Boolean)) {
|
|
83
|
-
const candidate = join(directory, "playwright", "index.js");
|
|
84
|
-
if (existsSync(candidate)) {
|
|
85
|
-
return require(candidate);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
throw error;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
async function collect(page, pageId, scheme) {
|
|
93
|
-
return page.evaluate(({ pageId, scheme }) => {
|
|
94
|
-
const root = document.querySelector("#quarto-document-content");
|
|
95
|
-
if (!root) {
|
|
96
|
-
return { error: "missing #quarto-document-content" };
|
|
97
|
-
}
|
|
98
|
-
const px = (value) => Number.parseFloat(value) || 0;
|
|
99
|
-
const style = (element) => getComputedStyle(element);
|
|
100
|
-
const paragraphs = [...root.querySelectorAll(":scope > p, :scope > section > p")];
|
|
101
|
-
const boxSelector = "div.sourceCode, table, .callout, blockquote, .cell";
|
|
102
|
-
const boxes = [...root.querySelectorAll(boxSelector)].filter((element) => {
|
|
103
|
-
for (let parent = element.parentElement; parent && parent !== root; parent = parent.parentElement) {
|
|
104
|
-
if (parent.matches(boxSelector)) {
|
|
105
|
-
return false;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
return true;
|
|
109
|
-
});
|
|
110
|
-
const languageBlocks = [...root.querySelectorAll("div.sourceCode")].map((div) => {
|
|
111
|
-
const pre = div.querySelector("pre");
|
|
112
|
-
const code = div.querySelector("pre > code");
|
|
113
|
-
return {
|
|
114
|
-
outerBorder: px(style(div).borderLeftWidth),
|
|
115
|
-
innerBorder: pre ? px(style(pre).borderLeftWidth) : null,
|
|
116
|
-
whiteSpace: code ? style(code).whiteSpace : null,
|
|
117
|
-
background: style(div).backgroundColor,
|
|
118
|
-
};
|
|
119
|
-
});
|
|
120
|
-
const barePres = [...root.querySelectorAll("pre")]
|
|
121
|
-
.filter((pre) => !pre.closest("div.sourceCode") && !pre.classList.contains("mermaid-js"))
|
|
122
|
-
.map((pre) => {
|
|
123
|
-
const code = pre.querySelector("code");
|
|
124
|
-
return {
|
|
125
|
-
border: px(style(pre).borderTopWidth),
|
|
126
|
-
whiteSpace: code ? style(code).whiteSpace : null,
|
|
127
|
-
background: style(pre).backgroundColor,
|
|
128
|
-
};
|
|
129
|
-
});
|
|
130
|
-
const callouts = [...root.querySelectorAll(".callout")].map((callout) => {
|
|
131
|
-
const body = callout.querySelector(".callout-body");
|
|
132
|
-
const title = callout.querySelector(".callout-title-container");
|
|
133
|
-
const computed = style(callout);
|
|
134
|
-
return {
|
|
135
|
-
bodyFontSize: body ? px(style(body).fontSize) : 0,
|
|
136
|
-
titleFontSize: title ? px(style(title).fontSize) : 0,
|
|
137
|
-
paddingTop: px(computed.paddingTop),
|
|
138
|
-
paddingBottom: px(computed.paddingBottom),
|
|
139
|
-
width: Math.round(callout.getBoundingClientRect().width),
|
|
140
|
-
};
|
|
141
|
-
});
|
|
142
|
-
const tocItems = [...document.querySelectorAll('nav[role="doc-toc"] a.nav-link')]
|
|
143
|
-
.map((anchor) => ({
|
|
144
|
-
text: anchor.textContent.trim(),
|
|
145
|
-
height: Math.round(anchor.getBoundingClientRect().height),
|
|
146
|
-
}));
|
|
147
|
-
const overflow = [];
|
|
148
|
-
for (const scope of [root, document.querySelector("#quarto-sidebar"), document.querySelector(".page-columns")]) {
|
|
149
|
-
if (!scope) {
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
for (const element of scope.querySelectorAll("*")) {
|
|
153
|
-
if (
|
|
154
|
-
element.closest(".screen-reader-only, .sr-only, [aria-hidden='true']")
|
|
155
|
-
|| style(element).display === "none"
|
|
156
|
-
|| style(element).visibility === "hidden"
|
|
157
|
-
) {
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
if (!element.clientWidth || element.scrollWidth <= element.clientWidth + 1) {
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
const overflowX = style(element).overflowX;
|
|
164
|
-
overflow.push({
|
|
165
|
-
tag: element.tagName.toLowerCase(),
|
|
166
|
-
classes: typeof element.className === "string" ? element.className.slice(0, 80) : "",
|
|
167
|
-
scrollWidth: element.scrollWidth,
|
|
168
|
-
clientWidth: element.clientWidth,
|
|
169
|
-
scrolls: overflowX === "auto" || overflowX === "scroll",
|
|
170
|
-
inPre: Boolean(element.closest("pre")),
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
const copyButtonOpacities = [...root.querySelectorAll(".code-copy-button")]
|
|
175
|
-
.map((button) => px(style(button).opacity));
|
|
176
|
-
// ≤991.98px(Quarto 的 lg 断点)左侧栏改为覆盖正文的浮层,必须有不透明底色,
|
|
177
|
-
// 否则下方正文会透上来叠在侧栏文字上。媒体查询按视口宽度生效,收起时也能读到计算值。
|
|
178
|
-
const sidebar = document.querySelector("#quarto-sidebar");
|
|
179
|
-
const sidebarOverlay = sidebar
|
|
180
|
-
? {
|
|
181
|
-
overlayState: window.innerWidth <= 991.98,
|
|
182
|
-
background: style(sidebar).backgroundColor,
|
|
183
|
-
borderRightWidth: px(style(sidebar).borderRightWidth),
|
|
184
|
-
}
|
|
185
|
-
: null;
|
|
186
|
-
// 折叠状态来自构建期写入的 expanded:顶层 part 以 depth=1 渲染,
|
|
187
|
-
// collapse-level 决定哪一层默认展开。断言只看 show 类,不看动画中间态。
|
|
188
|
-
const sidebarSections = sidebar
|
|
189
|
-
? [...sidebar.querySelectorAll("li.sidebar-item-section")].map((item) => {
|
|
190
|
-
const list = item.querySelector(":scope > ul.sidebar-section");
|
|
191
|
-
const own = item.querySelector(":scope > .sidebar-item-container > a.sidebar-item-text");
|
|
192
|
-
return {
|
|
193
|
-
text: (own ? own.textContent : "").trim().slice(0, 40),
|
|
194
|
-
expanded: list ? list.classList.contains("show") : null,
|
|
195
|
-
ownActive: Boolean(own && own.classList.contains("active")),
|
|
196
|
-
containsActive: Boolean(item.querySelector("a.sidebar-item-text.active")),
|
|
197
|
-
};
|
|
198
|
-
})
|
|
199
|
-
: null;
|
|
200
|
-
const rootStyle = style(root);
|
|
201
|
-
return {
|
|
202
|
-
pageId,
|
|
203
|
-
scheme,
|
|
204
|
-
fontFaces: [...document.fonts]
|
|
205
|
-
.filter((face) => [
|
|
206
|
-
"LXGW Bright Medium",
|
|
207
|
-
"LXGW Bright Code",
|
|
208
|
-
].includes(face.family))
|
|
209
|
-
.map((face) => ({
|
|
210
|
-
family: face.family,
|
|
211
|
-
weight: face.weight,
|
|
212
|
-
status: face.status,
|
|
213
|
-
})),
|
|
214
|
-
paragraphWidth: paragraphs.length ? Math.max(...paragraphs.map((item) => item.clientWidth)) : 0,
|
|
215
|
-
paragraphFontSize: paragraphs.length ? px(style(paragraphs[0]).fontSize) : 0,
|
|
216
|
-
paragraphCount: paragraphs.length,
|
|
217
|
-
boxCount: boxes.length,
|
|
218
|
-
textBandCount: [...root.querySelectorAll("p, li")].filter((item) => !item.closest(boxSelector)).length,
|
|
219
|
-
languageBlocks,
|
|
220
|
-
barePres,
|
|
221
|
-
callouts,
|
|
222
|
-
tocItems,
|
|
223
|
-
overflow,
|
|
224
|
-
copyButtonOpacities,
|
|
225
|
-
sidebarOverlay,
|
|
226
|
-
sidebarSections,
|
|
227
|
-
contentWidth: px(rootStyle.width),
|
|
228
|
-
color: rootStyle.color,
|
|
229
|
-
backgroundColor: style(document.body).backgroundColor,
|
|
230
|
-
};
|
|
231
|
-
}, { pageId, scheme });
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
async function main() {
|
|
235
|
-
if (!existsSync(bookDir)) {
|
|
236
|
-
throw new Error(`book directory not found: ${bookDir}`);
|
|
237
|
-
}
|
|
238
|
-
const htmlFiles = (await collectHtml(bookDir)).sort();
|
|
239
|
-
if (!htmlFiles.length) {
|
|
240
|
-
throw new Error(`no rendered HTML under ${bookDir}`);
|
|
241
|
-
}
|
|
242
|
-
const selectedFiles = allPages ? htmlFiles : sampleHtml(htmlFiles, bookDir);
|
|
243
|
-
const { chromium } = loadPlaywright();
|
|
244
|
-
const browser = await chromium.launch({ channel: "msedge", headless: true });
|
|
245
|
-
const result = {
|
|
246
|
-
viewports: {},
|
|
247
|
-
colorSchemes,
|
|
248
|
-
pages: [],
|
|
249
|
-
mode: allPages ? "full" : "sample",
|
|
250
|
-
selectedPages: selectedFiles.map((file) => relative(bookDir, file).split(sep).join("/")),
|
|
251
|
-
};
|
|
252
|
-
try {
|
|
253
|
-
for (const width of viewports) {
|
|
254
|
-
result.viewports[width] = {};
|
|
255
|
-
for (const scheme of colorSchemes) {
|
|
256
|
-
for (const file of selectedFiles) {
|
|
257
|
-
const pageId = relative(bookDir, file).split(sep).join("/");
|
|
258
|
-
const touch = width <= 768;
|
|
259
|
-
const context = await browser.newContext({
|
|
260
|
-
viewport: { width, height: touch ? 844 : 900 },
|
|
261
|
-
hasTouch: touch,
|
|
262
|
-
isMobile: touch,
|
|
263
|
-
colorScheme: scheme,
|
|
264
|
-
});
|
|
265
|
-
const page = await context.newPage();
|
|
266
|
-
const requestedFonts = new Set();
|
|
267
|
-
page.on("request", (request) => {
|
|
268
|
-
if (/\.woff2(?:\?|$)/.test(request.url())) {
|
|
269
|
-
requestedFonts.add(request.url().split("/").pop().split("?")[0]);
|
|
270
|
-
}
|
|
271
|
-
});
|
|
272
|
-
await page.goto(pathToFileURL(file).href, { waitUntil: "networkidle" });
|
|
273
|
-
await page.evaluate(() => document.fonts.ready);
|
|
274
|
-
const data = await collect(page, pageId, scheme);
|
|
275
|
-
data.requestedFonts = [...requestedFonts].sort();
|
|
276
|
-
result.viewports[width][`${scheme}:${pageId}`] = data;
|
|
277
|
-
if (!result.pages.includes(pageId)) {
|
|
278
|
-
result.pages.push(pageId);
|
|
279
|
-
}
|
|
280
|
-
if (shotsDir) {
|
|
281
|
-
const shots = resolve(shotsDir);
|
|
282
|
-
await mkdir(shots, { recursive: true });
|
|
283
|
-
await page.screenshot({
|
|
284
|
-
path: join(shots, `${scheme}-${width}-${pageId.replaceAll("/", "__")}.png`),
|
|
285
|
-
fullPage: false,
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
await page.close();
|
|
289
|
-
await context.close();
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
} finally {
|
|
294
|
-
await browser.close();
|
|
295
|
-
}
|
|
296
|
-
const json = JSON.stringify(result);
|
|
297
|
-
if (outFile) {
|
|
298
|
-
await mkdir(dirname(resolve(outFile)), { recursive: true });
|
|
299
|
-
await writeFile(resolve(outFile), json, "utf8");
|
|
300
|
-
console.log(`OK measure wrote ${resolve(outFile)} (${json.length} bytes)`);
|
|
301
|
-
} else {
|
|
302
|
-
console.log(json);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
main().catch((error) => {
|
|
307
|
-
console.error(`MEASURE-FAILED ${error?.message || error}`);
|
|
308
|
-
process.exit(2);
|
|
309
|
-
});
|
|
1
|
+
// 从渲染产物读取真实浏览器几何与计算样式,供 checking-layout.py 做布局回归断言。
|
|
2
|
+
//
|
|
3
|
+
// 用法:node measuring-pages.mjs --book-dir _book --out out.json [--shots-dir DIR] [--all-pages]
|
|
4
|
+
// 退出码:0 = 测量成功;2 = Node/Playwright/Edge 或渲染产物不可用。
|
|
5
|
+
|
|
6
|
+
import { mkdir, readdir, writeFile } from "node:fs/promises";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
10
|
+
import { pathToFileURL } from "node:url";
|
|
11
|
+
|
|
12
|
+
const argv = process.argv.slice(2);
|
|
13
|
+
const flag = (name, fallback) => {
|
|
14
|
+
const index = argv.indexOf(name);
|
|
15
|
+
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const bookDir = resolve(flag("--book-dir", "_book"));
|
|
20
|
+
const outFile = flag("--out", "");
|
|
21
|
+
const shotsDir = flag("--shots-dir", "");
|
|
22
|
+
const allPages = argv.includes("--all-pages");
|
|
23
|
+
const viewports = [1280, 768, 390];
|
|
24
|
+
const colorSchemes = ["light", "dark"];
|
|
25
|
+
|
|
26
|
+
async function collectHtml(directory) {
|
|
27
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
28
|
+
const files = [];
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
const path = join(directory, entry.name);
|
|
31
|
+
if (entry.isDirectory()) {
|
|
32
|
+
files.push(...await collectHtml(path));
|
|
33
|
+
} else if (entry.name.endsWith(".html") && entry.name !== "404.html") {
|
|
34
|
+
files.push(path);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return files;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sampleHtml(files, root) {
|
|
41
|
+
const relativeFiles = files
|
|
42
|
+
.map((file) => relative(root, file).split(sep).join("/"))
|
|
43
|
+
.sort();
|
|
44
|
+
const selected = new Set();
|
|
45
|
+
const add = (relativePath) => {
|
|
46
|
+
if (relativePath) {
|
|
47
|
+
selected.add(relativePath);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
add("index.html");
|
|
52
|
+
|
|
53
|
+
// 按顶层目录分组,取每组首页、第一章与最后一章。首页通常也是 part 落地页。
|
|
54
|
+
const bySection = new Map();
|
|
55
|
+
for (const file of relativeFiles) {
|
|
56
|
+
if (file === "index.html") {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const section = file.includes("/") ? file.split("/")[0] : ".";
|
|
60
|
+
const bucket = bySection.get(section) || [];
|
|
61
|
+
bucket.push(file);
|
|
62
|
+
bySection.set(section, bucket);
|
|
63
|
+
}
|
|
64
|
+
for (const [section, bucket] of [...bySection.entries()].sort()) {
|
|
65
|
+
if (section === ".") {
|
|
66
|
+
for (const file of bucket) {
|
|
67
|
+
add(file);
|
|
68
|
+
}
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
add(`${section}/index.html`);
|
|
72
|
+
add(bucket[0]);
|
|
73
|
+
add(bucket.at(-1));
|
|
74
|
+
}
|
|
75
|
+
return files.filter((file) => selected.has(relative(root, file).split(sep).join("/")));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function loadPlaywright() {
|
|
79
|
+
try {
|
|
80
|
+
return require("playwright");
|
|
81
|
+
} catch (error) {
|
|
82
|
+
for (const directory of (process.env.NODE_PATH || "").split(sep).filter(Boolean)) {
|
|
83
|
+
const candidate = join(directory, "playwright", "index.js");
|
|
84
|
+
if (existsSync(candidate)) {
|
|
85
|
+
return require(candidate);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function collect(page, pageId, scheme) {
|
|
93
|
+
return page.evaluate(({ pageId, scheme }) => {
|
|
94
|
+
const root = document.querySelector("#quarto-document-content");
|
|
95
|
+
if (!root) {
|
|
96
|
+
return { error: "missing #quarto-document-content" };
|
|
97
|
+
}
|
|
98
|
+
const px = (value) => Number.parseFloat(value) || 0;
|
|
99
|
+
const style = (element) => getComputedStyle(element);
|
|
100
|
+
const paragraphs = [...root.querySelectorAll(":scope > p, :scope > section > p")];
|
|
101
|
+
const boxSelector = "div.sourceCode, table, .callout, blockquote, .cell";
|
|
102
|
+
const boxes = [...root.querySelectorAll(boxSelector)].filter((element) => {
|
|
103
|
+
for (let parent = element.parentElement; parent && parent !== root; parent = parent.parentElement) {
|
|
104
|
+
if (parent.matches(boxSelector)) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
});
|
|
110
|
+
const languageBlocks = [...root.querySelectorAll("div.sourceCode")].map((div) => {
|
|
111
|
+
const pre = div.querySelector("pre");
|
|
112
|
+
const code = div.querySelector("pre > code");
|
|
113
|
+
return {
|
|
114
|
+
outerBorder: px(style(div).borderLeftWidth),
|
|
115
|
+
innerBorder: pre ? px(style(pre).borderLeftWidth) : null,
|
|
116
|
+
whiteSpace: code ? style(code).whiteSpace : null,
|
|
117
|
+
background: style(div).backgroundColor,
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
const barePres = [...root.querySelectorAll("pre")]
|
|
121
|
+
.filter((pre) => !pre.closest("div.sourceCode") && !pre.classList.contains("mermaid-js"))
|
|
122
|
+
.map((pre) => {
|
|
123
|
+
const code = pre.querySelector("code");
|
|
124
|
+
return {
|
|
125
|
+
border: px(style(pre).borderTopWidth),
|
|
126
|
+
whiteSpace: code ? style(code).whiteSpace : null,
|
|
127
|
+
background: style(pre).backgroundColor,
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
const callouts = [...root.querySelectorAll(".callout")].map((callout) => {
|
|
131
|
+
const body = callout.querySelector(".callout-body");
|
|
132
|
+
const title = callout.querySelector(".callout-title-container");
|
|
133
|
+
const computed = style(callout);
|
|
134
|
+
return {
|
|
135
|
+
bodyFontSize: body ? px(style(body).fontSize) : 0,
|
|
136
|
+
titleFontSize: title ? px(style(title).fontSize) : 0,
|
|
137
|
+
paddingTop: px(computed.paddingTop),
|
|
138
|
+
paddingBottom: px(computed.paddingBottom),
|
|
139
|
+
width: Math.round(callout.getBoundingClientRect().width),
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
const tocItems = [...document.querySelectorAll('nav[role="doc-toc"] a.nav-link')]
|
|
143
|
+
.map((anchor) => ({
|
|
144
|
+
text: anchor.textContent.trim(),
|
|
145
|
+
height: Math.round(anchor.getBoundingClientRect().height),
|
|
146
|
+
}));
|
|
147
|
+
const overflow = [];
|
|
148
|
+
for (const scope of [root, document.querySelector("#quarto-sidebar"), document.querySelector(".page-columns")]) {
|
|
149
|
+
if (!scope) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
for (const element of scope.querySelectorAll("*")) {
|
|
153
|
+
if (
|
|
154
|
+
element.closest(".screen-reader-only, .sr-only, [aria-hidden='true']")
|
|
155
|
+
|| style(element).display === "none"
|
|
156
|
+
|| style(element).visibility === "hidden"
|
|
157
|
+
) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (!element.clientWidth || element.scrollWidth <= element.clientWidth + 1) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const overflowX = style(element).overflowX;
|
|
164
|
+
overflow.push({
|
|
165
|
+
tag: element.tagName.toLowerCase(),
|
|
166
|
+
classes: typeof element.className === "string" ? element.className.slice(0, 80) : "",
|
|
167
|
+
scrollWidth: element.scrollWidth,
|
|
168
|
+
clientWidth: element.clientWidth,
|
|
169
|
+
scrolls: overflowX === "auto" || overflowX === "scroll",
|
|
170
|
+
inPre: Boolean(element.closest("pre")),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const copyButtonOpacities = [...root.querySelectorAll(".code-copy-button")]
|
|
175
|
+
.map((button) => px(style(button).opacity));
|
|
176
|
+
// ≤991.98px(Quarto 的 lg 断点)左侧栏改为覆盖正文的浮层,必须有不透明底色,
|
|
177
|
+
// 否则下方正文会透上来叠在侧栏文字上。媒体查询按视口宽度生效,收起时也能读到计算值。
|
|
178
|
+
const sidebar = document.querySelector("#quarto-sidebar");
|
|
179
|
+
const sidebarOverlay = sidebar
|
|
180
|
+
? {
|
|
181
|
+
overlayState: window.innerWidth <= 991.98,
|
|
182
|
+
background: style(sidebar).backgroundColor,
|
|
183
|
+
borderRightWidth: px(style(sidebar).borderRightWidth),
|
|
184
|
+
}
|
|
185
|
+
: null;
|
|
186
|
+
// 折叠状态来自构建期写入的 expanded:顶层 part 以 depth=1 渲染,
|
|
187
|
+
// collapse-level 决定哪一层默认展开。断言只看 show 类,不看动画中间态。
|
|
188
|
+
const sidebarSections = sidebar
|
|
189
|
+
? [...sidebar.querySelectorAll("li.sidebar-item-section")].map((item) => {
|
|
190
|
+
const list = item.querySelector(":scope > ul.sidebar-section");
|
|
191
|
+
const own = item.querySelector(":scope > .sidebar-item-container > a.sidebar-item-text");
|
|
192
|
+
return {
|
|
193
|
+
text: (own ? own.textContent : "").trim().slice(0, 40),
|
|
194
|
+
expanded: list ? list.classList.contains("show") : null,
|
|
195
|
+
ownActive: Boolean(own && own.classList.contains("active")),
|
|
196
|
+
containsActive: Boolean(item.querySelector("a.sidebar-item-text.active")),
|
|
197
|
+
};
|
|
198
|
+
})
|
|
199
|
+
: null;
|
|
200
|
+
const rootStyle = style(root);
|
|
201
|
+
return {
|
|
202
|
+
pageId,
|
|
203
|
+
scheme,
|
|
204
|
+
fontFaces: [...document.fonts]
|
|
205
|
+
.filter((face) => [
|
|
206
|
+
"LXGW Bright Medium",
|
|
207
|
+
"LXGW Bright Code",
|
|
208
|
+
].includes(face.family))
|
|
209
|
+
.map((face) => ({
|
|
210
|
+
family: face.family,
|
|
211
|
+
weight: face.weight,
|
|
212
|
+
status: face.status,
|
|
213
|
+
})),
|
|
214
|
+
paragraphWidth: paragraphs.length ? Math.max(...paragraphs.map((item) => item.clientWidth)) : 0,
|
|
215
|
+
paragraphFontSize: paragraphs.length ? px(style(paragraphs[0]).fontSize) : 0,
|
|
216
|
+
paragraphCount: paragraphs.length,
|
|
217
|
+
boxCount: boxes.length,
|
|
218
|
+
textBandCount: [...root.querySelectorAll("p, li")].filter((item) => !item.closest(boxSelector)).length,
|
|
219
|
+
languageBlocks,
|
|
220
|
+
barePres,
|
|
221
|
+
callouts,
|
|
222
|
+
tocItems,
|
|
223
|
+
overflow,
|
|
224
|
+
copyButtonOpacities,
|
|
225
|
+
sidebarOverlay,
|
|
226
|
+
sidebarSections,
|
|
227
|
+
contentWidth: px(rootStyle.width),
|
|
228
|
+
color: rootStyle.color,
|
|
229
|
+
backgroundColor: style(document.body).backgroundColor,
|
|
230
|
+
};
|
|
231
|
+
}, { pageId, scheme });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function main() {
|
|
235
|
+
if (!existsSync(bookDir)) {
|
|
236
|
+
throw new Error(`book directory not found: ${bookDir}`);
|
|
237
|
+
}
|
|
238
|
+
const htmlFiles = (await collectHtml(bookDir)).sort();
|
|
239
|
+
if (!htmlFiles.length) {
|
|
240
|
+
throw new Error(`no rendered HTML under ${bookDir}`);
|
|
241
|
+
}
|
|
242
|
+
const selectedFiles = allPages ? htmlFiles : sampleHtml(htmlFiles, bookDir);
|
|
243
|
+
const { chromium } = loadPlaywright();
|
|
244
|
+
const browser = await chromium.launch({ channel: "msedge", headless: true });
|
|
245
|
+
const result = {
|
|
246
|
+
viewports: {},
|
|
247
|
+
colorSchemes,
|
|
248
|
+
pages: [],
|
|
249
|
+
mode: allPages ? "full" : "sample",
|
|
250
|
+
selectedPages: selectedFiles.map((file) => relative(bookDir, file).split(sep).join("/")),
|
|
251
|
+
};
|
|
252
|
+
try {
|
|
253
|
+
for (const width of viewports) {
|
|
254
|
+
result.viewports[width] = {};
|
|
255
|
+
for (const scheme of colorSchemes) {
|
|
256
|
+
for (const file of selectedFiles) {
|
|
257
|
+
const pageId = relative(bookDir, file).split(sep).join("/");
|
|
258
|
+
const touch = width <= 768;
|
|
259
|
+
const context = await browser.newContext({
|
|
260
|
+
viewport: { width, height: touch ? 844 : 900 },
|
|
261
|
+
hasTouch: touch,
|
|
262
|
+
isMobile: touch,
|
|
263
|
+
colorScheme: scheme,
|
|
264
|
+
});
|
|
265
|
+
const page = await context.newPage();
|
|
266
|
+
const requestedFonts = new Set();
|
|
267
|
+
page.on("request", (request) => {
|
|
268
|
+
if (/\.woff2(?:\?|$)/.test(request.url())) {
|
|
269
|
+
requestedFonts.add(request.url().split("/").pop().split("?")[0]);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
await page.goto(pathToFileURL(file).href, { waitUntil: "networkidle" });
|
|
273
|
+
await page.evaluate(() => document.fonts.ready);
|
|
274
|
+
const data = await collect(page, pageId, scheme);
|
|
275
|
+
data.requestedFonts = [...requestedFonts].sort();
|
|
276
|
+
result.viewports[width][`${scheme}:${pageId}`] = data;
|
|
277
|
+
if (!result.pages.includes(pageId)) {
|
|
278
|
+
result.pages.push(pageId);
|
|
279
|
+
}
|
|
280
|
+
if (shotsDir) {
|
|
281
|
+
const shots = resolve(shotsDir);
|
|
282
|
+
await mkdir(shots, { recursive: true });
|
|
283
|
+
await page.screenshot({
|
|
284
|
+
path: join(shots, `${scheme}-${width}-${pageId.replaceAll("/", "__")}.png`),
|
|
285
|
+
fullPage: false,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
await page.close();
|
|
289
|
+
await context.close();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
} finally {
|
|
294
|
+
await browser.close();
|
|
295
|
+
}
|
|
296
|
+
const json = JSON.stringify(result);
|
|
297
|
+
if (outFile) {
|
|
298
|
+
await mkdir(dirname(resolve(outFile)), { recursive: true });
|
|
299
|
+
await writeFile(resolve(outFile), json, "utf8");
|
|
300
|
+
console.log(`OK measure wrote ${resolve(outFile)} (${json.length} bytes)`);
|
|
301
|
+
} else {
|
|
302
|
+
console.log(json);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
main().catch((error) => {
|
|
307
|
+
console.error(`MEASURE-FAILED ${error?.message || error}`);
|
|
308
|
+
process.exit(2);
|
|
309
|
+
});
|