konpeki 0.3.0 → 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/AGENTS.md +21 -28
- package/AUTHORING.md +68 -229
- package/CONTRIBUTING.md +10 -23
- package/README.md +96 -87
- package/SETUP.md +50 -114
- package/docs/development.md +43 -248
- package/docs/workflow.md +6 -108
- package/html/README.md +140 -0
- package/html/browser.ts +103 -0
- package/html/document.ts +38 -0
- package/html/floor.ts +34 -0
- package/html/index.html +5 -0
- package/html/inspect.ts +313 -0
- package/html/preview.css +62 -0
- package/html/preview.tsx +463 -0
- package/html/review-hints.ts +63 -0
- package/html/server.ts +99 -0
- package/html/source.ts +104 -0
- package/html/starter.ts +6 -0
- package/html/theme-authoring.md +176 -0
- package/html/theme.md +61 -0
- package/index.html +9 -9
- package/package.json +21 -35
- package/plugin.json +1 -1
- package/public/og.png +0 -0
- package/runtime/browser-B-29TH1a.mjs +595 -0
- package/runtime/floor-Cmk7G3pU.mjs +41 -0
- package/runtime/konpeki.mjs +90 -1410
- package/runtime/server-BAqC_5WD.mjs +2 -0
- package/runtime/server-DfRpfcY9.mjs +205 -0
- package/runtime/source-B_Ui9tMp.mjs +141 -0
- package/runtime/source-CZQq9GUO.mjs +2 -0
- package/skills/konpeki/SKILL.md +88 -172
- package/skills/konpeki/assets/blank.html +17 -0
- package/skills/konpeki/floor.md +76 -0
- package/skills/konpeki/references/cover.md +27 -0
- package/skills/konpeki/references/long-document.md +40 -0
- package/skills/konpeki/references/one-pager.md +27 -0
- package/skills/konpeki/references/patterns.md +118 -0
- package/skills/konpeki/references/resume.md +28 -0
- package/skills/konpeki/references/slides.md +28 -0
- package/skills/konpeki/scripts/ensure-runtime.mjs +10 -31
- package/skills/konpeki/scripts/prepare-document.mjs +25 -14
- package/src/components/PageBoard.tsx +156 -0
- package/src/lib/alignment.ts +21 -0
- package/src/lib/page-board.ts +25 -0
- package/src/lib/review-position.ts +19 -0
- package/src/styles/base.css +4 -6
- package/src/styles/feedback.css +97 -61
- package/src/styles/shell.css +95 -323
- package/theme-base.css +90 -0
- package/theme.css +56 -0
- package/vite.config.ts +2 -5
- package/composition/README.md +0 -156
- package/composition/compile.ts +0 -227
- package/composition/document.ts +0 -600
- package/composition/schema.json +0 -3001
- package/composition/schema.ts +0 -437
- package/composition/theme-tokens.ts +0 -16
- package/composition/types.ts +0 -269
- package/composition/validate.ts +0 -226
- package/composition/vector.ts +0 -143
- package/composition/visualizations.ts +0 -319
- package/design/README.md +0 -17
- package/design/palettes/README.md +0 -14
- package/design/palettes/base.ts +0 -14
- package/design/palettes/candidates.ts +0 -19
- package/design/palettes/index.ts +0 -78
- package/design/review/color-theme.md +0 -44
- package/design/review/layout.md +0 -16
- package/design/review/text.md +0 -18
- package/design/review/typography.md +0 -15
- package/design/review/visuals.md +0 -31
- package/design/semantic-patterns.md +0 -43
- package/design/themes/README.md +0 -40
- package/design/themes/index.ts +0 -24
- package/design/visual-languages/technical-product.md +0 -17
- package/design/visual-review.md +0 -88
- package/lib/assets.d.ts +0 -8
- package/lib/charts.ts +0 -18
- package/lib/contrast.ts +0 -16
- package/lib/layouts.ts +0 -50
- package/lib/slide.tsx +0 -42
- package/lib/taste.ts +0 -17
- package/lib/text.tsx +0 -89
- package/lib/typeface.ts +0 -44
- package/scripts/migrate-react-page.ts +0 -120
- package/skills/konpeki/assets/blank.json +0 -23
- package/slides/README.md +0 -153
- package/slides/architecture/PROMPT.md +0 -31
- package/slides/architecture/index.tsx +0 -102
- package/slides/article-brief/PROMPT.md +0 -35
- package/slides/article-brief/index.tsx +0 -71
- package/slides/bar-chart/PROMPT.md +0 -39
- package/slides/bar-chart/index.tsx +0 -97
- package/slides/comparison/PROMPT.md +0 -29
- package/slides/comparison/index.tsx +0 -95
- package/slides/decision-memo/PROMPT.md +0 -34
- package/slides/decision-memo/index.tsx +0 -85
- package/slides/delivery-plan/PROMPT.md +0 -45
- package/slides/delivery-plan/index.tsx +0 -105
- package/slides/experiment/PROMPT.md +0 -44
- package/slides/experiment/index.tsx +0 -127
- package/slides/incident-workflow/PROMPT.md +0 -57
- package/slides/incident-workflow/index.tsx +0 -78
- package/slides/introducing-konpeki/PROMPT.md +0 -40
- package/slides/introducing-konpeki/README.md +0 -76
- package/slides/introducing-konpeki/SOURCE.md +0 -26
- package/slides/introducing-konpeki/author.ts +0 -165
- package/slides/introducing-konpeki/composition.json +0 -3270
- package/slides/line-chart/PROMPT.md +0 -40
- package/slides/line-chart/index.tsx +0 -72
- package/slides/migration/PROMPT.md +0 -38
- package/slides/migration/index.tsx +0 -89
- package/slides/og-images/PROMPT.md +0 -21
- package/slides/og-images/index.tsx +0 -76
- package/slides/product-introduction/PROMPT.md +0 -24
- package/slides/product-introduction/index.tsx +0 -105
- package/slides/research-brief/PROMPT.md +0 -40
- package/slides/research-brief/index.tsx +0 -104
- package/slides/results-explanation/PROMPT.md +0 -32
- package/slides/results-explanation/index.tsx +0 -96
- package/slides/retrospective/PROMPT.md +0 -43
- package/slides/retrospective/index.tsx +0 -105
- package/slides/sankey/PROMPT.md +0 -11
- package/slides/sankey/index.tsx +0 -93
- package/slides/teaching/PROMPT.md +0 -45
- package/slides/teaching/index.tsx +0 -124
- package/slides/vertical-bar-charts/PROMPT.md +0 -13
- package/slides/vertical-bar-charts/index.tsx +0 -97
- package/src/app/App.tsx +0 -820
- package/src/components/BuildOrb.tsx +0 -40
- package/src/components/Canvas.tsx +0 -1185
- package/src/components/DiagramTypeIcon.tsx +0 -78
- package/src/components/InspectorPanel.tsx +0 -773
- package/src/components/LeftPanel.tsx +0 -120
- package/src/components/PageSizePicker.tsx +0 -30
- package/src/components/Presentation.tsx +0 -105
- package/src/components/RevisionNotes.tsx +0 -56
- package/src/components/RightPanel.tsx +0 -201
- package/src/components/VectorOverflowWarning.tsx +0 -46
- package/src/components/WorkspaceChrome.tsx +0 -288
- package/src/components/ui.tsx +0 -53
- package/src/lib/examples/react-page-migration.json +0 -1295
- package/src/lib/examples.ts +0 -42
- package/src/lib/export-png.ts +0 -104
- package/src/lib/file-session.ts +0 -87
- package/src/lib/history.ts +0 -53
- package/src/lib/model.ts +0 -188
- package/src/lib/page-size.ts +0 -24
- package/src/lib/presentation.ts +0 -17
- package/src/lib/review.ts +0 -26
- package/src/lib/storage.ts +0 -71
- package/src/lib/theme.ts +0 -25
- package/src/lib/use-file-session.ts +0 -227
- package/src/main.tsx +0 -29
- package/src/styles/canvas.css +0 -299
- package/src/styles/chrome.css +0 -384
- package/src/styles/component-previews.css +0 -386
- package/src/styles/left-panel.css +0 -166
- package/src/styles/presentation.css +0 -72
- package/src/styles/right-panel.css +0 -1215
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { a as isGoogleFontResource, t as documentServer } from "./server-DfRpfcY9.mjs";
|
|
2
|
+
import { chromium } from "playwright";
|
|
3
|
+
import { PDFDocument } from "pdf-lib";
|
|
4
|
+
//#region html/inspect.ts
|
|
5
|
+
/** Self-contained so the same measurement runs in the review iframe and CLI's
|
|
6
|
+
* Chromium page. Range bounds describe font metrics, not glyph ink or leading. */
|
|
7
|
+
function inspectHTMLPage(page) {
|
|
8
|
+
const doc = page.ownerDocument, win = doc.defaultView;
|
|
9
|
+
const origin = page.getBoundingClientRect();
|
|
10
|
+
const diagnostics = [], seen = /* @__PURE__ */ new Set();
|
|
11
|
+
const bounds = (r) => ({
|
|
12
|
+
x: r.x - origin.x,
|
|
13
|
+
y: r.y - origin.y,
|
|
14
|
+
width: r.width,
|
|
15
|
+
height: r.height
|
|
16
|
+
});
|
|
17
|
+
const outside = (r, box, x = true, y = true) => x && (r.left < box.left - .75 || r.right > box.right + .75) || y && (r.top < box.top - .75 || r.bottom > box.bottom + .75);
|
|
18
|
+
function report(code, element, message, severity = "error") {
|
|
19
|
+
const target = element.closest("[id]")?.id ?? page.id, key = `${code}/${target}`;
|
|
20
|
+
if (!seen.has(key)) {
|
|
21
|
+
seen.add(key);
|
|
22
|
+
diagnostics.push({
|
|
23
|
+
code,
|
|
24
|
+
severity,
|
|
25
|
+
page: page.id,
|
|
26
|
+
target,
|
|
27
|
+
message
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function visible(element) {
|
|
32
|
+
if (element.closest("defs,clipPath,mask,symbol,marker,pattern,linearGradient,radialGradient")) return false;
|
|
33
|
+
const style = win.getComputedStyle(element);
|
|
34
|
+
for (let ancestor = element; ancestor; ancestor = ancestor.parentElement) if (Number(win.getComputedStyle(ancestor).opacity) === 0) return false;
|
|
35
|
+
return style.visibility === "visible" && style.display !== "none" && element.getClientRects().length > 0;
|
|
36
|
+
}
|
|
37
|
+
if (origin.width <= 0 || origin.height <= 0 || origin.width > 8192 || origin.height > 8192) report("page-size", page, "Use explicit positive page dimensions no larger than 8192 CSS px per side.");
|
|
38
|
+
const elements = Array.from(page.querySelectorAll("*"));
|
|
39
|
+
const blocks = elements.filter((el) => el.id && visible(el)).map((el) => ({
|
|
40
|
+
id: el.id,
|
|
41
|
+
tag: el.tagName.toLowerCase(),
|
|
42
|
+
bounds: bounds(el.getBoundingClientRect())
|
|
43
|
+
}));
|
|
44
|
+
for (const element of elements) {
|
|
45
|
+
if (!visible(element)) continue;
|
|
46
|
+
if (outside(element.getBoundingClientRect(), origin)) report("page-overflow", element, "This element extends beyond the page. Recompose it; content does not continue onto another page automatically.");
|
|
47
|
+
if (element.tagName === "IMG" && !element.naturalWidth) report("missing-image", element, "An image did not load. Use a local asset beside the HTML or an embedded data URL.");
|
|
48
|
+
}
|
|
49
|
+
const text = [];
|
|
50
|
+
const lines = [];
|
|
51
|
+
const textElements = /* @__PURE__ */ new Set();
|
|
52
|
+
const minimumSizes = {
|
|
53
|
+
presentation: 24,
|
|
54
|
+
portrait: 24,
|
|
55
|
+
square: 24,
|
|
56
|
+
link: 24,
|
|
57
|
+
article: 24,
|
|
58
|
+
a4: 11,
|
|
59
|
+
explainer: 24,
|
|
60
|
+
gallery: 24
|
|
61
|
+
};
|
|
62
|
+
const preset = page.getAttribute("data-size") ?? "presentation", minimumSize = minimumSizes[preset];
|
|
63
|
+
const walker = doc.createTreeWalker(page, 4);
|
|
64
|
+
let node;
|
|
65
|
+
while (node = walker.nextNode()) {
|
|
66
|
+
const parent = node.parentElement;
|
|
67
|
+
if (!node.textContent?.trim() || !parent || !visible(parent) || parent.closest("style,defs,title,desc")) continue;
|
|
68
|
+
const range = doc.createRange();
|
|
69
|
+
range.selectNodeContents(node);
|
|
70
|
+
const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0 && r.height > 0);
|
|
71
|
+
if (rects.length) text.push({
|
|
72
|
+
target: parent.closest("[id]")?.id ?? page.id,
|
|
73
|
+
content: node.textContent,
|
|
74
|
+
bounds: rects.map(bounds)
|
|
75
|
+
});
|
|
76
|
+
if (rects.length) textElements.add(parent);
|
|
77
|
+
const style = win.getComputedStyle(parent);
|
|
78
|
+
if (rects.length && parseFloat(style.fontSize) < minimumSize) report("small-text", parent, `Text is ${style.fontSize}; use at least ${minimumSize}px for ${preset}. Reduce content or recompose for the viewing size, rather than shrinking it.`, "warning");
|
|
79
|
+
let leading = parseFloat(style.lineHeight);
|
|
80
|
+
for (let ancestor = parent; ancestor; ancestor = ancestor.parentElement) {
|
|
81
|
+
const s = win.getComputedStyle(ancestor);
|
|
82
|
+
if (s.transform !== "none" || s.scale !== "none" || s.rotate !== "none" || Number(s.zoom) !== 1) leading = NaN;
|
|
83
|
+
}
|
|
84
|
+
for (const rect of rects) {
|
|
85
|
+
const line = parent.namespaceURI === "http://www.w3.org/1999/xhtml" && style.writingMode === "horizontal-tb" && leading > 0 ? new DOMRect(rect.x, rect.y + (rect.height - leading) / 2, rect.width, leading) : rect;
|
|
86
|
+
for (const previous of lines) {
|
|
87
|
+
if (previous.parent === parent) continue;
|
|
88
|
+
const other = previous.rect;
|
|
89
|
+
if (Math.min(line.right, other.right) - Math.max(line.left, other.left) > 1 && Math.min(line.bottom, other.bottom) - Math.max(line.top, other.top) > 1) report("text-overlap", parent, `Text overlaps text in #${previous.parent.closest("[id]")?.id ?? page.id}. Separate their line boxes.`);
|
|
90
|
+
}
|
|
91
|
+
lines.push({
|
|
92
|
+
parent,
|
|
93
|
+
rect: line
|
|
94
|
+
});
|
|
95
|
+
if (outside(rect, origin)) report("text-overflow", parent, "Text extends beyond the page. Do not hide it or shrink required copy to fit.");
|
|
96
|
+
for (let ancestor = parent; ancestor; ancestor = ancestor.parentElement) {
|
|
97
|
+
const style = win.getComputedStyle(ancestor);
|
|
98
|
+
if (outside(rect, ancestor.getBoundingClientRect(), style.overflowX !== "visible", style.overflowY !== "visible")) report("clipped-text", parent, "Text crosses a clipping or scrolling boundary. Increase space or revise the layout.");
|
|
99
|
+
if (ancestor === page) break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const xhtml = "http://www.w3.org/1999/xhtml";
|
|
104
|
+
const roleOf = (el) => el.getAttribute("data-type") ?? (el.tagName === "H1" ? "title" : "heading");
|
|
105
|
+
const leading = (style) => parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2;
|
|
106
|
+
const headlines = Array.from(page.querySelectorAll("h1:not([data-type]), h2:not([data-type]), [data-type=\"title\"], [data-type=\"display\"], [data-type=\"heading\"]")).filter((el) => el.namespaceURI === xhtml && visible(el) && (el.textContent?.match(/\p{L}/gu)?.length ?? 0) > (el.textContent?.match(/\d/g)?.length ?? 0));
|
|
107
|
+
function block(element) {
|
|
108
|
+
let current = element;
|
|
109
|
+
while (current !== page && current.parentElement && win.getComputedStyle(current).display.startsWith("inline")) current = current.parentElement;
|
|
110
|
+
return current;
|
|
111
|
+
}
|
|
112
|
+
const textBlocks = [...new Set(Array.from(textElements, block))].filter((el) => el.namespaceURI === xhtml);
|
|
113
|
+
function extent(element) {
|
|
114
|
+
const range = doc.createRange();
|
|
115
|
+
range.selectNodeContents(element);
|
|
116
|
+
const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0 && r.height > 0);
|
|
117
|
+
return rects.length ? {
|
|
118
|
+
left: Math.min(...rects.map((r) => r.left)),
|
|
119
|
+
right: Math.max(...rects.map((r) => r.right))
|
|
120
|
+
} : element.getBoundingClientRect();
|
|
121
|
+
}
|
|
122
|
+
const normalize = (text) => text.replace(/\s+/g, " ").replace(/\d+/g, "#").trim();
|
|
123
|
+
const printPages = page.getAttribute("data-size") === "a4" ? Array.from(doc.querySelectorAll("body > [data-page]")).filter((p) => p !== page && p.getAttribute("data-size") === "a4").map((p) => normalize(p.textContent ?? "")) : [];
|
|
124
|
+
function labels(heading) {
|
|
125
|
+
const box = heading.getBoundingClientRect(), style = win.getComputedStyle(heading), headingInk = extent(heading);
|
|
126
|
+
const before = textBlocks.filter((b) => !b.contains(heading) && !heading.contains(b) && heading.compareDocumentPosition(b) & 2);
|
|
127
|
+
const nearest = before.at(-1)?.getBoundingClientRect();
|
|
128
|
+
if (!nearest) return [];
|
|
129
|
+
return before.filter((label) => {
|
|
130
|
+
const rect = label.getBoundingClientRect(), labelStyle = win.getComputedStyle(label), labelInk = extent(label);
|
|
131
|
+
const text = (label.textContent ?? "").replace(/\s+/g, " ").trim(), gap = box.top - rect.bottom;
|
|
132
|
+
if (rect.top >= nearest.bottom || rect.bottom <= nearest.top) return false;
|
|
133
|
+
if (gap < -1 || labelInk.right <= headingInk.left || labelInk.left >= headingInk.right || text.length > 48 || /[.!?]$/.test(text) || rect.height > leading(labelStyle) * 1.5) return false;
|
|
134
|
+
if (parseFloat(labelStyle.fontSize) >= parseFloat(style.fontSize) * .8 || label.closest("[data-kp-allow~=\"label-above-headline\"]")) return false;
|
|
135
|
+
if (roleOf(heading) === "heading" && gap > leading(style)) return false;
|
|
136
|
+
return !printPages.some((other) => other.includes(normalize(text)));
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
for (const heading of headlines) for (const label of labels(heading)) report("label-above-headline", label, `"${(label.textContent ?? "").replace(/\s+/g, " ").trim()}" labels the headline below it. Delete it, or move the product, date or source into the headline, supporting line, caption or footer.`, "warning");
|
|
140
|
+
for (const heading of headlines) {
|
|
141
|
+
const words = [];
|
|
142
|
+
const walker = doc.createTreeWalker(heading, 4);
|
|
143
|
+
for (let node; node = walker.nextNode();) for (const match of (node.textContent ?? "").matchAll(/\S+/g)) {
|
|
144
|
+
const range = doc.createRange();
|
|
145
|
+
range.setStart(node, match.index);
|
|
146
|
+
range.setEnd(node, match.index + match[0].length);
|
|
147
|
+
const rect = range.getClientRects()[0];
|
|
148
|
+
if (rect) words.push({
|
|
149
|
+
node,
|
|
150
|
+
top: rect.top,
|
|
151
|
+
text: match[0]
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const size = parseFloat(win.getComputedStyle(heading).fontSize), breaks = Array.from(heading.querySelectorAll("br"));
|
|
155
|
+
const segments = /* @__PURE__ */ new Map();
|
|
156
|
+
for (const word of words) {
|
|
157
|
+
const segment = breaks.filter((br) => br.compareDocumentPosition(word.node) & 4).length;
|
|
158
|
+
const lines = segments.get(segment) ?? [], line = lines.at(-1);
|
|
159
|
+
if (line && word.top <= line[0].top + size / 2) line.push(word);
|
|
160
|
+
else lines.push([word]);
|
|
161
|
+
segments.set(segment, lines);
|
|
162
|
+
}
|
|
163
|
+
const stranded = [...segments.values()].find((lines) => lines.length > 1 && lines.at(-1).length === 1 && lines.at(-2).length > 1);
|
|
164
|
+
if (stranded) report("stranded-word", heading, `A headline line ends on one stranded word, "${stranded.at(-1)[0].text}". Reword it or change its measure rather than shrinking it.`, "warning");
|
|
165
|
+
}
|
|
166
|
+
const rgb = (value) => /^rgba?\(/.test(value) ? value.match(/[\d.]+/g).map(Number) : void 0;
|
|
167
|
+
const opaque = (color) => color && (color.length === 3 || color[3] === 1);
|
|
168
|
+
const luminance = (color) => color.slice(0, 3).reduce((sum, channel, i) => {
|
|
169
|
+
const c = channel / 255;
|
|
170
|
+
return sum + (c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4) * [
|
|
171
|
+
.2126,
|
|
172
|
+
.7152,
|
|
173
|
+
.0722
|
|
174
|
+
][i];
|
|
175
|
+
}, 0);
|
|
176
|
+
for (const element of textElements) {
|
|
177
|
+
if (element.namespaceURI !== "http://www.w3.org/1999/xhtml") continue;
|
|
178
|
+
const style = win.getComputedStyle(element), foreground = rgb(style.color);
|
|
179
|
+
if (!opaque(foreground)) continue;
|
|
180
|
+
let background, uncertain = false;
|
|
181
|
+
for (let ancestor = element; ancestor; ancestor = ancestor.parentElement) {
|
|
182
|
+
const s = win.getComputedStyle(ancestor), color = rgb(s.backgroundColor);
|
|
183
|
+
if (Number(s.opacity) !== 1 || s.filter !== "none" || s.backdropFilter !== "none" || s.mixBlendMode !== "normal") uncertain = true;
|
|
184
|
+
if (!background) {
|
|
185
|
+
if (!color || s.backgroundImage !== "none" || color.length === 4 && color[3] > 0 && color[3] < 1) uncertain = true;
|
|
186
|
+
if (opaque(color)) background = color;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (!background && !win.getComputedStyle(doc.documentElement).colorScheme.includes("dark")) background = [
|
|
190
|
+
255,
|
|
191
|
+
255,
|
|
192
|
+
255
|
|
193
|
+
];
|
|
194
|
+
if (uncertain || !background) continue;
|
|
195
|
+
const a = luminance(foreground), b = luminance(background);
|
|
196
|
+
const ratio = (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
|
|
197
|
+
const size = parseFloat(style.fontSize);
|
|
198
|
+
const minimum = size >= 24 || size >= 18.6667 && Number(style.fontWeight) >= 700 ? 3 : 4.5;
|
|
199
|
+
if (ratio < minimum) report("low-contrast", element, `Text contrast is ${ratio.toFixed(2)}:1; use at least ${minimum}:1 on this solid background.`);
|
|
200
|
+
}
|
|
201
|
+
const probe = doc.createElement("span");
|
|
202
|
+
probe.style.display = "none";
|
|
203
|
+
doc.body.append(probe);
|
|
204
|
+
const roles = [
|
|
205
|
+
"fine",
|
|
206
|
+
"caption",
|
|
207
|
+
"body",
|
|
208
|
+
"lead",
|
|
209
|
+
"heading",
|
|
210
|
+
"title",
|
|
211
|
+
"display"
|
|
212
|
+
];
|
|
213
|
+
const faces = Array.from(doc.fonts);
|
|
214
|
+
const familyName = (family) => family.trim().replace(/^(["'])(.*)\1$/, "$2").toLowerCase();
|
|
215
|
+
const primaryFamily = (family) => familyName(family.match(/^\s*("[^"]*"|'[^']*'|[^,]+)/)?.[0] ?? "");
|
|
216
|
+
function hasFace(family, weight, style = "normal") {
|
|
217
|
+
return faces.some((face) => {
|
|
218
|
+
if (familyName(face.family) !== primaryFamily(family)) return false;
|
|
219
|
+
const weights = face.weight.replace("normal", "400").replace("bold", "700").split(/\s+/).map(Number);
|
|
220
|
+
const angles = (value) => value.match(/-?[\d.]+(?=deg)/g)?.map(Number) ?? [14];
|
|
221
|
+
const requested = angles(style)[0], available = angles(face.style);
|
|
222
|
+
return (face.style === style || style.startsWith("oblique") && face.style.startsWith("oblique") && requested >= available[0] && requested <= (available[1] ?? available[0])) && weight >= weights[0] && weight <= (weights[1] ?? weights[0]);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
for (const element of textElements) {
|
|
226
|
+
const s = win.getComputedStyle(element);
|
|
227
|
+
if (faces.some((face) => familyName(face.family) === primaryFamily(s.fontFamily)) && !hasFace(s.fontFamily, Number(s.fontWeight), s.fontStyle)) report("font-face", element, `No declared face for ${s.fontFamily}, weight ${s.fontWeight}, style ${s.fontStyle}. Declare the matching webfont face; substitution or synthesis changes the intended text.`);
|
|
228
|
+
}
|
|
229
|
+
const colorTokens = [
|
|
230
|
+
"bg",
|
|
231
|
+
"fg",
|
|
232
|
+
"muted",
|
|
233
|
+
"line",
|
|
234
|
+
"line-subtle",
|
|
235
|
+
"line-strong",
|
|
236
|
+
"inverse",
|
|
237
|
+
"accent",
|
|
238
|
+
"wash",
|
|
239
|
+
"surface",
|
|
240
|
+
"contrast",
|
|
241
|
+
"on-contrast",
|
|
242
|
+
"on-contrast-muted",
|
|
243
|
+
"emphasis",
|
|
244
|
+
"emphasis-wash",
|
|
245
|
+
"complete",
|
|
246
|
+
"attention",
|
|
247
|
+
"blocked",
|
|
248
|
+
...Array.from({ length: 6 }, (_, i) => `category-${i + 1}`),
|
|
249
|
+
...Array.from({ length: 5 }, (_, i) => `sequence-${i + 1}`)
|
|
250
|
+
];
|
|
251
|
+
function themeColors(style) {
|
|
252
|
+
probe.style.colorScheme = style.colorScheme;
|
|
253
|
+
return Object.fromEntries(colorTokens.map((token) => {
|
|
254
|
+
const value = style.getPropertyValue(`--kp-${token}`);
|
|
255
|
+
probe.style.color = "";
|
|
256
|
+
probe.style.color = value;
|
|
257
|
+
return [token, value && probe.style.color ? win.getComputedStyle(probe).color : void 0];
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
const pageStyle = win.getComputedStyle(page), version = pageStyle.getPropertyValue("--kp-theme").trim();
|
|
261
|
+
const type = Object.fromEntries(roles.map((role) => [role, {
|
|
262
|
+
size: parseFloat(pageStyle.getPropertyValue(`--kp-font-${role}`)),
|
|
263
|
+
lineHeight: parseFloat(pageStyle.getPropertyValue(`--kp-leading-${role}`)),
|
|
264
|
+
weight: Number(pageStyle.getPropertyValue(`--kp-weight-${role}`)),
|
|
265
|
+
tracking: pageStyle.getPropertyValue(`--kp-tracking-${role}`).trim(),
|
|
266
|
+
family: pageStyle.getPropertyValue(role === "display" ? "--kp-font-family-display" : role === "heading" || role === "title" ? "--kp-font-family-heading" : "--kp-font-family").trim()
|
|
267
|
+
}]));
|
|
268
|
+
const theme = version === "1" ? {
|
|
269
|
+
version: 1,
|
|
270
|
+
mode: pageStyle.colorScheme,
|
|
271
|
+
unit: parseFloat(pageStyle.getPropertyValue("--kp-unit")),
|
|
272
|
+
type
|
|
273
|
+
} : void 0;
|
|
274
|
+
if (version && version !== "1") report("theme-version", page, `Unsupported --kp-theme version ${version}; theme checks were skipped.`, "warning");
|
|
275
|
+
if (theme && !page.closest("[data-theme=\"custom\"]")) {
|
|
276
|
+
const invalid = [
|
|
277
|
+
...colorTokens,
|
|
278
|
+
"font-family",
|
|
279
|
+
"font-family-heading",
|
|
280
|
+
"font-family-display",
|
|
281
|
+
"font-family-mono",
|
|
282
|
+
"weight-strong",
|
|
283
|
+
"weight-mono",
|
|
284
|
+
"unit",
|
|
285
|
+
"rule-width",
|
|
286
|
+
"stroke",
|
|
287
|
+
"stroke-heavy",
|
|
288
|
+
"radius",
|
|
289
|
+
"radius-small",
|
|
290
|
+
"page-width",
|
|
291
|
+
"page-height",
|
|
292
|
+
"page-margin",
|
|
293
|
+
...[
|
|
294
|
+
1,
|
|
295
|
+
2,
|
|
296
|
+
3,
|
|
297
|
+
4,
|
|
298
|
+
5,
|
|
299
|
+
6,
|
|
300
|
+
8,
|
|
301
|
+
10,
|
|
302
|
+
12,
|
|
303
|
+
16
|
|
304
|
+
].map((n) => `space-${n}`),
|
|
305
|
+
...roles.flatMap((role) => [
|
|
306
|
+
"font",
|
|
307
|
+
"leading",
|
|
308
|
+
"weight",
|
|
309
|
+
"tracking"
|
|
310
|
+
].map((property) => `${property}-${role}`))
|
|
311
|
+
].filter((token) => !pageStyle.getPropertyValue(`--kp-${token}`).trim()).map((token) => `missing --kp-${token}`);
|
|
312
|
+
const rootStyle = win.getComputedStyle(doc.documentElement);
|
|
313
|
+
const shadowed = [
|
|
314
|
+
...roles.flatMap((role) => [`font-${role}`, `leading-${role}`]),
|
|
315
|
+
"page-width",
|
|
316
|
+
"page-height",
|
|
317
|
+
"page-margin"
|
|
318
|
+
].filter((token) => rootStyle.getPropertyValue(`--kp-${token}`).trim() && rootStyle.getPropertyValue(`--kp-${token}`).trim() !== pageStyle.getPropertyValue(`--kp-${token}`).trim());
|
|
319
|
+
if (shadowed.length) report("theme-root", page, `Root values differ from this page: ${shadowed.map((token) => `--kp-${token}`).join(", ")}. Apply type and page overrides to [data-page] or [data-size], not :root.`, "warning");
|
|
320
|
+
let previous = 0;
|
|
321
|
+
for (const role of roles) {
|
|
322
|
+
const t = type[role];
|
|
323
|
+
if (!["font", "leading"].every((property) => /^\d+(?:\.\d+)?px$/.test(pageStyle.getPropertyValue(`--kp-${property}-${role}`).trim())) || !(t.size > previous && t.lineHeight >= t.size && t.weight >= 1 && t.weight <= 1e3) || !win.CSS.supports("letter-spacing", t.tracking)) invalid.push(`invalid ${role} type treatment`);
|
|
324
|
+
if (/^\d+(?:\.\d+)?px$/.test(pageStyle.getPropertyValue(`--kp-font-${role}`).trim()) && t.size > 0) previous = t.size;
|
|
325
|
+
}
|
|
326
|
+
const missingFaces = [
|
|
327
|
+
...roles.map((role) => ({
|
|
328
|
+
role,
|
|
329
|
+
...type[role]
|
|
330
|
+
})),
|
|
331
|
+
{
|
|
332
|
+
role: "strong",
|
|
333
|
+
family: pageStyle.getPropertyValue("--kp-font-family"),
|
|
334
|
+
weight: Number(pageStyle.getPropertyValue("--kp-weight-strong"))
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
role: "mono",
|
|
338
|
+
family: pageStyle.getPropertyValue("--kp-font-family-mono"),
|
|
339
|
+
weight: Number(pageStyle.getPropertyValue("--kp-weight-mono"))
|
|
340
|
+
}
|
|
341
|
+
].filter((t) => t.family && t.weight > 0 && !hasFace(t.family, t.weight));
|
|
342
|
+
if (missingFaces.length) report("theme-font", page, `Missing declared normal faces: ${missingFaces.map((t) => `${t.role} (${t.family}, ${t.weight})`).join("; ")}. Declare webfonts with matching families and weights.`);
|
|
343
|
+
const colors = themeColors(pageStyle);
|
|
344
|
+
for (const token of colorTokens) if (!colors[token]) invalid.push(`invalid --kp-${token} color`);
|
|
345
|
+
if (invalid.length) report("theme-definition", page, invalid.join("; "));
|
|
346
|
+
const textContrast = [], chartContrast = [];
|
|
347
|
+
const contrast = (foreground, background, minimum, failures) => {
|
|
348
|
+
const a = rgb(colors[foreground] ?? ""), b = rgb(colors[background] ?? "");
|
|
349
|
+
if (!opaque(a) || !opaque(b)) return;
|
|
350
|
+
const x = luminance(a), y = luminance(b), ratio = (Math.max(x, y) + .05) / (Math.min(x, y) + .05);
|
|
351
|
+
if (ratio < minimum) failures.push(`${foreground} on ${background}: ${ratio.toFixed(2)}:1; needs ${minimum}:1`);
|
|
352
|
+
};
|
|
353
|
+
for (const surface of [
|
|
354
|
+
"bg",
|
|
355
|
+
"surface",
|
|
356
|
+
"wash"
|
|
357
|
+
]) for (const ink of ["fg", "muted"]) contrast(ink, surface, 4.5, textContrast);
|
|
358
|
+
for (const ink of ["on-contrast", "on-contrast-muted"]) contrast(ink, "contrast", 4.5, textContrast);
|
|
359
|
+
contrast("accent", "bg", 4.5, textContrast);
|
|
360
|
+
contrast("inverse", "accent", 4.5, textContrast);
|
|
361
|
+
const categories = Array.from({ length: 6 }, (_, i) => `category-${i + 1}`);
|
|
362
|
+
for (const token of categories) contrast(token, "bg", 3, chartContrast);
|
|
363
|
+
if (textContrast.length) report("theme-contrast", page, textContrast.join("; "));
|
|
364
|
+
if (chartContrast.length) report("theme-chart-contrast", page, chartContrast.join("; "), "warning");
|
|
365
|
+
if (new Set(categories.map((token) => colors[token])).size !== categories.length) report("theme-categories", page, "Category colors contain exact duplicates. Use distinct identities or supply non-color encodings.", "warning");
|
|
366
|
+
}
|
|
367
|
+
for (const element of [page, ...elements]) {
|
|
368
|
+
if (!visible(element) || element.closest("[data-theme=\"custom\"]")) continue;
|
|
369
|
+
const style = win.getComputedStyle(element);
|
|
370
|
+
if (style.getPropertyValue("--kp-theme").trim() !== "1") continue;
|
|
371
|
+
if (element.hasAttribute("data-type") && !roles.includes(element.getAttribute("data-type"))) report("theme-role", element, `Unknown data-type="${element.getAttribute("data-type")}". Use ${roles.join(", ")}.`, "warning");
|
|
372
|
+
const deviations = [];
|
|
373
|
+
if (textElements.has(element) && !roles.some((role) => Math.abs(parseFloat(style.getPropertyValue(`--kp-font-${role}`)) - parseFloat(style.fontSize)) < .1 && (element.namespaceURI !== "http://www.w3.org/1999/xhtml" || Math.abs(parseFloat(style.getPropertyValue(`--kp-leading-${role}`)) - parseFloat(style.lineHeight)) < .1))) deviations.push(`type pair ${style.fontSize}/${style.lineHeight}`);
|
|
374
|
+
const colors = Object.values(themeColors(style));
|
|
375
|
+
const values = [style.backgroundColor];
|
|
376
|
+
if (textElements.has(element)) values.push(element.namespaceURI === "http://www.w3.org/2000/svg" ? style.fill : style.color);
|
|
377
|
+
probe.style.fontSize = style.fontSize;
|
|
378
|
+
function themedLength(property, value, tokens) {
|
|
379
|
+
return tokens.some((token) => {
|
|
380
|
+
probe.style[property] = "";
|
|
381
|
+
probe.style[property] = style.getPropertyValue(`--kp-${token}`);
|
|
382
|
+
return probe.style[property] && win.getComputedStyle(probe)[property] === value;
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
probe.style.borderTopStyle = "solid";
|
|
386
|
+
const widths = [
|
|
387
|
+
"rule-width",
|
|
388
|
+
"stroke",
|
|
389
|
+
"stroke-heavy"
|
|
390
|
+
];
|
|
391
|
+
for (const side of [
|
|
392
|
+
"Top",
|
|
393
|
+
"Right",
|
|
394
|
+
"Bottom",
|
|
395
|
+
"Left"
|
|
396
|
+
]) {
|
|
397
|
+
const width = style[`border${side}Width`];
|
|
398
|
+
if (parseFloat(width) > 0) {
|
|
399
|
+
values.push(style[`border${side}Color`]);
|
|
400
|
+
if (!themedLength("borderTopWidth", width, widths)) deviations.push(`border width ${width}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
for (const corner of [
|
|
404
|
+
"TopLeft",
|
|
405
|
+
"TopRight",
|
|
406
|
+
"BottomLeft",
|
|
407
|
+
"BottomRight"
|
|
408
|
+
]) {
|
|
409
|
+
const radius = style[`border${corner}Radius`];
|
|
410
|
+
if (radius !== "0px" && !themedLength("borderTopLeftRadius", radius, ["radius", "radius-small"])) deviations.push(`corner radius ${radius}`);
|
|
411
|
+
}
|
|
412
|
+
if (element.matches("path,rect,circle,ellipse,polygon,polyline,line")) {
|
|
413
|
+
if (!element.matches("line")) values.push(style.fill);
|
|
414
|
+
values.push(style.stroke);
|
|
415
|
+
if (style.stroke !== "none" && parseFloat(style.strokeWidth) > 0 && !themedLength("strokeWidth", style.strokeWidth, widths)) deviations.push(`stroke width ${style.strokeWidth}`);
|
|
416
|
+
}
|
|
417
|
+
for (const value of new Set(values)) if (value !== "none" && value !== "rgba(0, 0, 0, 0)" && !colors.includes(value)) deviations.push(`color ${value}`);
|
|
418
|
+
if (deviations.length) report("theme-value", element, `Outside the active theme: ${[...new Set(deviations)].join(", ")}. Use complete type roles and theme color/shape tokens, or data-theme="custom" for deliberately unthemed content.`, "warning");
|
|
419
|
+
}
|
|
420
|
+
probe.remove();
|
|
421
|
+
for (const link of doc.querySelectorAll("link[rel=\"stylesheet\"]")) if (!link.sheet && !link.disabled) report("missing-stylesheet", page, "A stylesheet did not load. Keep stylesheets beside the HTML or inline them.");
|
|
422
|
+
for (const font of doc.fonts) if (font.status === "error") report("missing-font", page, `Font failed to load: ${font.family}.`);
|
|
423
|
+
return {
|
|
424
|
+
id: page.id,
|
|
425
|
+
width: origin.width,
|
|
426
|
+
height: origin.height,
|
|
427
|
+
blocks,
|
|
428
|
+
text,
|
|
429
|
+
theme,
|
|
430
|
+
diagnostics
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
//#endregion
|
|
434
|
+
//#region html/browser.ts
|
|
435
|
+
async function browserDocument(input, options = {}) {
|
|
436
|
+
if (options.format !== void 0 && !["png", "pdf"].includes(options.format)) throw new Error("HTML exports support PNG and PDF. Whole-page SVG export is not supported.");
|
|
437
|
+
const scale = options.scale ?? 2;
|
|
438
|
+
if (!Number.isFinite(scale) || scale <= 0 || scale > 8) throw new Error("Scale must be greater than 0 and at most 8.");
|
|
439
|
+
const server = await documentServer(input);
|
|
440
|
+
let browser;
|
|
441
|
+
try {
|
|
442
|
+
const snapshot = await server.store.read();
|
|
443
|
+
const selected = options.page;
|
|
444
|
+
if (selected !== void 0 && (!Number.isInteger(selected) || selected < 1 || selected > snapshot.pages.length)) throw new Error(`Page must be an integer from 1 to ${snapshot.pages.length}.`);
|
|
445
|
+
try {
|
|
446
|
+
browser = await chromium.launch({ headless: true });
|
|
447
|
+
} catch {
|
|
448
|
+
throw new Error("Chromium could not start. Run `konpeki browser install` to install the pinned browser; Linux also needs Chromium's system libraries.");
|
|
449
|
+
}
|
|
450
|
+
const context = await browser.newContext({
|
|
451
|
+
viewport: {
|
|
452
|
+
width: 794,
|
|
453
|
+
height: 1123
|
|
454
|
+
},
|
|
455
|
+
deviceScaleFactor: scale,
|
|
456
|
+
serviceWorkers: "block"
|
|
457
|
+
});
|
|
458
|
+
await context.route("**/*", (route) => {
|
|
459
|
+
const request = route.request();
|
|
460
|
+
return request.url().startsWith(`${server.origin}${server.prefix}/`) || isGoogleFontResource(request.url(), request.resourceType()) ? route.continue() : route.abort();
|
|
461
|
+
});
|
|
462
|
+
const tab = await context.newPage(), pages = [], diagnostics = [];
|
|
463
|
+
const cdp = await context.newCDPSession(tab);
|
|
464
|
+
await cdp.send("DOM.enable");
|
|
465
|
+
await cdp.send("CSS.enable");
|
|
466
|
+
const failed = /* @__PURE__ */ new Set();
|
|
467
|
+
tab.on("requestfailed", (request) => failed.add(request.url()));
|
|
468
|
+
tab.on("response", (response) => {
|
|
469
|
+
if (!response.ok()) failed.add(response.url());
|
|
470
|
+
});
|
|
471
|
+
const pdf = options.format === "pdf" ? await PDFDocument.create() : void 0;
|
|
472
|
+
let bytes;
|
|
473
|
+
for (let index = 0; index < snapshot.pages.length; index++) {
|
|
474
|
+
if (selected !== void 0 && selected !== index + 1) continue;
|
|
475
|
+
if (options.format === "png" && selected === void 0 && index > 0) break;
|
|
476
|
+
const id = snapshot.pages[index];
|
|
477
|
+
await tab.goto("about:blank");
|
|
478
|
+
await tab.setViewportSize({
|
|
479
|
+
width: 794,
|
|
480
|
+
height: 1123
|
|
481
|
+
});
|
|
482
|
+
failed.clear();
|
|
483
|
+
if (!(await tab.goto(`${server.origin}${server.prefix}/document/?page=${encodeURIComponent(id)}&revision=${snapshot.revision}`))?.ok()) throw new Error("The HTML changed while rendering. Inspect and retry the latest source.");
|
|
484
|
+
await tab.evaluate(async () => {
|
|
485
|
+
await document.fonts.ready;
|
|
486
|
+
await Promise.all(Array.from(document.images).map((image) => image.decode().catch(() => {})));
|
|
487
|
+
});
|
|
488
|
+
const element = tab.locator("[data-konpeki-current]");
|
|
489
|
+
const initial = await element.boundingBox();
|
|
490
|
+
if (!initial || initial.width <= 0 || initial.height <= 0 || initial.width > 8192 || initial.height > 8192) throw new Error("Invalid page dimensions. Use an explicit width and height up to 8192 CSS px.");
|
|
491
|
+
await tab.setViewportSize({
|
|
492
|
+
width: Math.ceil(initial.width),
|
|
493
|
+
height: Math.ceil(initial.height)
|
|
494
|
+
});
|
|
495
|
+
await tab.evaluate(async () => {
|
|
496
|
+
await document.fonts.ready;
|
|
497
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
498
|
+
});
|
|
499
|
+
await tab.waitForLoadState("networkidle");
|
|
500
|
+
const report = await element.evaluate(inspectHTMLPage);
|
|
501
|
+
const { root } = await cdp.send("DOM.getDocument", {
|
|
502
|
+
depth: -1,
|
|
503
|
+
pierce: true
|
|
504
|
+
});
|
|
505
|
+
const { nodeId } = await cdp.send("DOM.querySelector", {
|
|
506
|
+
nodeId: root.nodeId,
|
|
507
|
+
selector: "[data-konpeki-current]"
|
|
508
|
+
});
|
|
509
|
+
const { node } = await cdp.send("DOM.describeNode", {
|
|
510
|
+
nodeId,
|
|
511
|
+
depth: -1,
|
|
512
|
+
pierce: true
|
|
513
|
+
});
|
|
514
|
+
const fallbacks = /* @__PURE__ */ new Map();
|
|
515
|
+
async function auditFonts(node, target) {
|
|
516
|
+
if (node.nodeType !== 1) return;
|
|
517
|
+
const attrs = node.attributes ?? [], idIndex = attrs.indexOf("id");
|
|
518
|
+
if (idIndex >= 0) target = attrs[idIndex + 1];
|
|
519
|
+
const { computedStyle } = await cdp.send("CSS.getComputedStyleForNode", { nodeId: node.nodeId });
|
|
520
|
+
const style = Object.fromEntries(computedStyle.map((s) => [s.name, s.value]));
|
|
521
|
+
if (style.display === "none" || Number(style.opacity) === 0) return;
|
|
522
|
+
if (style.visibility === "visible" && (node.pseudoType || node.children?.some((child) => child.nodeType === 3 && child.nodeValue.trim()))) {
|
|
523
|
+
const { fonts } = await cdp.send("CSS.getPlatformFontsForNode", { nodeId: node.nodeId });
|
|
524
|
+
for (const font of fonts) if (font.glyphCount > 0 && !font.isCustomFont) {
|
|
525
|
+
if (!fallbacks.has(target)) fallbacks.set(target, /* @__PURE__ */ new Set());
|
|
526
|
+
fallbacks.get(target).add(font.familyName);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
for (const child of [...node.children ?? [], ...node.pseudoElements ?? []]) await auditFonts(child, target);
|
|
530
|
+
}
|
|
531
|
+
await auditFonts(node, id);
|
|
532
|
+
for (const [target, fonts] of fallbacks) report.diagnostics.push({
|
|
533
|
+
code: "font-fallback",
|
|
534
|
+
severity: "error",
|
|
535
|
+
page: id,
|
|
536
|
+
target,
|
|
537
|
+
message: `Text was drawn with installed fonts: ${[...fonts].join(", ")}. Load webfonts covering its families, styles, weights and glyphs from Google Fonts, local files or embedded data instead of relying on this machine.`
|
|
538
|
+
});
|
|
539
|
+
if (Math.abs(report.width - initial.width) > .75 || Math.abs(report.height - initial.height) > .75) throw new Error("Page dimensions depend on the viewport. Give pages a fixed CSS width and height; their content may use responsive layout.");
|
|
540
|
+
for (const resource of failed) report.diagnostics.push({
|
|
541
|
+
code: "missing-resource",
|
|
542
|
+
severity: "error",
|
|
543
|
+
page: id,
|
|
544
|
+
target: id,
|
|
545
|
+
message: `Resource did not load: ${resource.startsWith(`${server.origin}${server.prefix}/document/`) ? resource.slice(`${server.origin}${server.prefix}/document/`.length) : new URL(resource).origin + new URL(resource).pathname}. Use local assets or check access to Google Fonts.`
|
|
546
|
+
});
|
|
547
|
+
diagnostics.push(...report.diagnostics);
|
|
548
|
+
pages.push({
|
|
549
|
+
pageNumber: index + 1,
|
|
550
|
+
...report,
|
|
551
|
+
...options.details ? {} : { text: void 0 }
|
|
552
|
+
});
|
|
553
|
+
if (options.format === "png") {
|
|
554
|
+
if (report.width * report.height * scale * scale > 64e6) throw new Error("PNG exceeds 64 million pixels; reduce --scale.");
|
|
555
|
+
bytes = await element.screenshot({
|
|
556
|
+
type: "png",
|
|
557
|
+
animations: "disabled"
|
|
558
|
+
});
|
|
559
|
+
} else if (pdf) {
|
|
560
|
+
await tab.emulateMedia({ media: "screen" });
|
|
561
|
+
await tab.addStyleTag({ content: `@page { size: ${report.width}px ${report.height}px; margin: 0; } body { print-color-adjust: exact; }` });
|
|
562
|
+
const part = await PDFDocument.load(await tab.pdf({
|
|
563
|
+
preferCSSPageSize: true,
|
|
564
|
+
printBackground: true,
|
|
565
|
+
margin: {
|
|
566
|
+
top: 0,
|
|
567
|
+
left: 0,
|
|
568
|
+
right: 0,
|
|
569
|
+
bottom: 0
|
|
570
|
+
}
|
|
571
|
+
}));
|
|
572
|
+
if (part.getPageCount() !== 1) throw new Error(`Page ${index + 1} printed onto multiple sheets. Fix its overflow before exporting PDF.`);
|
|
573
|
+
for (const page of await pdf.copyPages(part, [0])) pdf.addPage(page);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
if ((await server.store.read()).revision !== snapshot.revision) throw new Error("The source changed during rendering. Retry the latest revision.");
|
|
577
|
+
if (pdf) bytes = await pdf.save();
|
|
578
|
+
return {
|
|
579
|
+
bytes,
|
|
580
|
+
report: {
|
|
581
|
+
schema: "konpeki-html-inspection/v1",
|
|
582
|
+
revision: snapshot.revision,
|
|
583
|
+
units: "css-pixels",
|
|
584
|
+
ok: !diagnostics.some((d) => d.severity === "error"),
|
|
585
|
+
pages,
|
|
586
|
+
diagnostics
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
} finally {
|
|
590
|
+
await browser?.close();
|
|
591
|
+
await server.close();
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
//#endregion
|
|
595
|
+
export { browserDocument };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
//#region html/floor.ts
|
|
3
|
+
var tiers = {
|
|
4
|
+
Bans: "ban",
|
|
5
|
+
Defaults: "default",
|
|
6
|
+
Verify: "verify"
|
|
7
|
+
};
|
|
8
|
+
/** The rules in skills/konpeki/floor.md, the single source for the review checklist. */
|
|
9
|
+
function floorRules(source = readFileSync(new URL("../skills/konpeki/floor.md", import.meta.url), "utf8")) {
|
|
10
|
+
const rules = [];
|
|
11
|
+
let tier;
|
|
12
|
+
for (const line of source.split("\n")) {
|
|
13
|
+
const heading = /^## (\w+)/.exec(line);
|
|
14
|
+
if (heading) {
|
|
15
|
+
tier = tiers[heading[1]];
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const rule = /^- ((?:`[a-z0-9-]+`(?:, )?)+) \((inspect|review)\):(?: (.*))?$/.exec(line);
|
|
19
|
+
if (rule && tier) rules.push({
|
|
20
|
+
ids: rule[1].match(/[a-z0-9-]+/g),
|
|
21
|
+
tier,
|
|
22
|
+
check: rule[2],
|
|
23
|
+
text: rule[3] ?? ""
|
|
24
|
+
});
|
|
25
|
+
else if (/^ {2}\S/.test(line) && rules.length) rules.at(-1).text = `${rules.at(-1).text} ${line.trim()}`.trim();
|
|
26
|
+
}
|
|
27
|
+
return rules;
|
|
28
|
+
}
|
|
29
|
+
/** The first sentence, ignoring full stops inside quoted examples. */
|
|
30
|
+
function firstSentence(text) {
|
|
31
|
+
let quoted = false;
|
|
32
|
+
for (let i = 0; i < text.length; i++) if (text[i] === "\"") quoted = !quoted;
|
|
33
|
+
else if (!quoted && text[i] === "." && (i + 1 === text.length || text[i + 1] === " ")) return text.slice(0, i + 1);
|
|
34
|
+
return text;
|
|
35
|
+
}
|
|
36
|
+
/** The first sentence of each rule that only a person looking at the page can check. */
|
|
37
|
+
function reviewChecklist(rules = floorRules()) {
|
|
38
|
+
return rules.filter((rule) => rule.check === "review").map((rule) => `${rule.ids[0]}: ${firstSentence(rule.text)}`);
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
export { reviewChecklist };
|