@cloudhal/mcp-server 0.0.0-stage → 0.1.1

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.
Files changed (105) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +18 -2
  3. package/dist/src/analytics-reports.d.ts +164 -0
  4. package/dist/src/analytics-reports.js +632 -0
  5. package/dist/src/analytics-tools.d.ts +4 -0
  6. package/dist/src/analytics-tools.js +597 -0
  7. package/dist/src/asset-tools.d.ts +5 -0
  8. package/dist/src/asset-tools.js +685 -0
  9. package/dist/src/body-tools.d.ts +46 -0
  10. package/dist/src/body-tools.js +708 -0
  11. package/dist/src/changes.d.ts +10 -0
  12. package/dist/src/changes.js +112 -0
  13. package/dist/src/chrome-tools.d.ts +8 -0
  14. package/dist/src/chrome-tools.js +207 -0
  15. package/dist/src/comment-tools.d.ts +9 -0
  16. package/dist/src/comment-tools.js +743 -0
  17. package/dist/src/comments.d.ts +184 -0
  18. package/dist/src/comments.js +408 -0
  19. package/dist/src/common.d.ts +258 -0
  20. package/dist/src/common.js +488 -0
  21. package/dist/src/content-shared.d.ts +50 -0
  22. package/dist/src/content-shared.js +102 -0
  23. package/dist/src/design-tools.d.ts +9 -0
  24. package/dist/src/design-tools.js +349 -0
  25. package/dist/src/elevation-tools.d.ts +3 -0
  26. package/dist/src/elevation-tools.js +331 -0
  27. package/dist/src/entry-terms.d.ts +14 -0
  28. package/dist/src/entry-terms.js +51 -0
  29. package/dist/src/entry-tools.d.ts +4 -0
  30. package/dist/src/entry-tools.js +442 -0
  31. package/dist/src/http.d.ts +48 -0
  32. package/dist/src/http.js +101 -0
  33. package/dist/src/index.d.ts +10 -0
  34. package/dist/src/index.js +10 -0
  35. package/dist/src/menu-tools.d.ts +41 -0
  36. package/dist/src/menu-tools.js +1058 -0
  37. package/dist/src/page-tools.d.ts +5 -0
  38. package/dist/src/page-tools.js +311 -0
  39. package/dist/src/preview-doc.d.ts +9 -0
  40. package/dist/src/preview-doc.js +56 -0
  41. package/dist/src/preview-view.d.ts +19 -0
  42. package/dist/src/preview-view.js +71 -0
  43. package/dist/src/publish-tools.d.ts +10 -0
  44. package/dist/src/publish-tools.js +248 -0
  45. package/dist/src/render/cdp.d.ts +29 -0
  46. package/dist/src/render/cdp.js +190 -0
  47. package/dist/src/render/checks/components.d.ts +5 -0
  48. package/dist/src/render/checks/components.js +95 -0
  49. package/dist/src/render/checks/content.d.ts +5 -0
  50. package/dist/src/render/checks/content.js +63 -0
  51. package/dist/src/render/checks/contrast.d.ts +5 -0
  52. package/dist/src/render/checks/contrast.js +72 -0
  53. package/dist/src/render/checks/index.d.ts +1 -0
  54. package/dist/src/render/checks/index.js +29 -0
  55. package/dist/src/render/checks/layout.d.ts +5 -0
  56. package/dist/src/render/checks/layout.js +44 -0
  57. package/dist/src/render/checks/motion.d.ts +5 -0
  58. package/dist/src/render/checks/motion.js +66 -0
  59. package/dist/src/render/checks/navigation.d.ts +5 -0
  60. package/dist/src/render/checks/navigation.js +78 -0
  61. package/dist/src/render/checks/prelude.d.ts +1 -0
  62. package/dist/src/render/checks/prelude.js +64 -0
  63. package/dist/src/render/limiter.d.ts +11 -0
  64. package/dist/src/render/limiter.js +29 -0
  65. package/dist/src/render/page.d.ts +19 -0
  66. package/dist/src/render/page.js +135 -0
  67. package/dist/src/render/types.d.ts +89 -0
  68. package/dist/src/render/types.js +140 -0
  69. package/dist/src/render-tools.d.ts +14 -0
  70. package/dist/src/render-tools.js +314 -0
  71. package/dist/src/render.d.ts +14 -0
  72. package/dist/src/render.js +33 -0
  73. package/dist/src/schedule-tools.d.ts +2 -0
  74. package/dist/src/schedule-tools.js +1596 -0
  75. package/dist/src/schedules.d.ts +292 -0
  76. package/dist/src/schedules.js +890 -0
  77. package/dist/src/schema-transforms.d.ts +14 -0
  78. package/dist/src/schema-transforms.js +50 -0
  79. package/dist/src/search-tools.d.ts +2 -0
  80. package/dist/src/search-tools.js +112 -0
  81. package/dist/src/server-approvals.d.ts +51 -0
  82. package/dist/src/server-approvals.js +273 -0
  83. package/dist/src/server-call.d.ts +28 -0
  84. package/dist/src/server-call.js +259 -0
  85. package/dist/src/server-instructions.d.ts +4 -0
  86. package/dist/src/server-instructions.js +74 -0
  87. package/dist/src/server-resources.d.ts +4 -0
  88. package/dist/src/server-resources.js +83 -0
  89. package/dist/src/server-schema.d.ts +20 -0
  90. package/dist/src/server-schema.js +82 -0
  91. package/dist/src/server.d.ts +80 -0
  92. package/dist/src/server.js +135 -0
  93. package/dist/src/stdio.d.ts +9 -0
  94. package/dist/src/stdio.js +18 -0
  95. package/dist/src/surface-tools.d.ts +4 -0
  96. package/dist/src/surface-tools.js +357 -0
  97. package/dist/src/taxonomy-tools.d.ts +16 -0
  98. package/dist/src/taxonomy-tools.js +1433 -0
  99. package/dist/src/terms.d.ts +47 -0
  100. package/dist/src/terms.js +271 -0
  101. package/dist/src/tools.d.ts +39 -0
  102. package/dist/src/tools.js +511 -0
  103. package/dist/src/translation-tools.d.ts +3 -0
  104. package/dist/src/translation-tools.js +422 -0
  105. package/package.json +86 -3
@@ -0,0 +1,5 @@
1
+ /**
2
+ * In-page checks, part of `RENDER_CHECK_SCRIPT`: contrast: text against what is painted beneath it, band by band.
3
+ * Plain source text, run inside the page; it shares the script's locals (see ./index.ts).
4
+ */
5
+ export declare const CONTRAST_CHECKS = " // contrast: text against what is painted beneath it (elementsFromPoint), band by band.\n const canvas = document.createElement(\"canvas\");\n canvas.width = canvas.height = 1;\n const g = canvas.getContext(\"2d\", { willReadFrequently: true });\n const rgba = (css) => {\n g.clearRect(0, 0, 1, 1);\n g.fillStyle = \"rgba(0,0,0,0)\";\n g.fillStyle = css;\n g.fillRect(0, 0, 1, 1);\n const d = g.getImageData(0, 0, 1, 1).data;\n return [d[0], d[1], d[2], d[3] / 255];\n };\n const over = (top, under) => [0, 1, 2].map((i) => top[i] * top[3] + under[i] * (1 - top[3]));\n const lum = (c) => {\n const [r, gg, b] = c.map((v) => {\n v /= 255;\n return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);\n });\n return 0.2126 * r + 0.7152 * gg + 0.0722 * b;\n };\n const ratio = (a, b) => {\n const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p);\n return (x + 0.05) / (y + 0.05);\n };\n const texts = [];\n const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);\n const seen = new Set();\n while (walker.nextNode() && texts.length < 800) {\n const n = walker.currentNode;\n const el = n.parentElement;\n if (!el || seen.has(el) || !n.nodeValue.trim()) continue;\n if (el.closest(\"script,style,noscript,template,svg\")) continue;\n seen.add(el);\n if (visible(el)) texts.push(el);\n }\n const pending = new Set(texts);\n const page = Math.max(document.documentElement.scrollHeight, H);\n for (let y = 0; y < page && pending.size; y += H) {\n window.scrollTo(0, y);\n for (const el of [...pending]) {\n const r = el.getBoundingClientRect();\n const cx = r.left + Math.min(r.width / 2, 12), cy = r.top + Math.min(r.height / 2, 10);\n if (cy < 0 || cy >= H || cx < 0 || cx >= W) continue;\n const stack = document.elementsFromPoint(cx, cy);\n const at = stack.indexOf(el);\n if (at < 0) continue;\n pending.delete(el);\n const layers = [];\n let known = true;\n for (const e of stack.slice(at)) {\n const s = getComputedStyle(e);\n if (/^(IMG|VIDEO|CANVAS|IFRAME|PICTURE)$/.test(e.tagName) || e.tagName.toLowerCase() === \"svg\" || s.backgroundImage !== \"none\") { known = false; break; }\n const c = rgba(s.backgroundColor);\n if (c[3] > 0) { layers.push(c); if (c[3] >= 1) break; }\n }\n if (!known) continue;\n let bg = [255, 255, 255];\n for (const c of layers.reverse()) bg = over(c, bg);\n const s = getComputedStyle(el);\n const fg = over(rgba(s.color), bg);\n const size = parseFloat(s.fontSize), weight = Number(s.fontWeight) || 400;\n const need = size >= 24 || (size >= 18.66 && weight >= 700) ? 3 : 4.5;\n const got = ratio(fg, bg);\n if (got < need) add(\"contrast\", el, got.toFixed(1) + \":1, needs \" + need + \":1\");\n }\n }\n window.scrollTo(0, 0);\n";
@@ -0,0 +1,72 @@
1
+ /**
2
+ * In-page checks, part of `RENDER_CHECK_SCRIPT`: contrast: text against what is painted beneath it, band by band.
3
+ * Plain source text, run inside the page; it shares the script's locals (see ./index.ts).
4
+ */
5
+ export const CONTRAST_CHECKS = ` // contrast: text against what is painted beneath it (elementsFromPoint), band by band.
6
+ const canvas = document.createElement("canvas");
7
+ canvas.width = canvas.height = 1;
8
+ const g = canvas.getContext("2d", { willReadFrequently: true });
9
+ const rgba = (css) => {
10
+ g.clearRect(0, 0, 1, 1);
11
+ g.fillStyle = "rgba(0,0,0,0)";
12
+ g.fillStyle = css;
13
+ g.fillRect(0, 0, 1, 1);
14
+ const d = g.getImageData(0, 0, 1, 1).data;
15
+ return [d[0], d[1], d[2], d[3] / 255];
16
+ };
17
+ const over = (top, under) => [0, 1, 2].map((i) => top[i] * top[3] + under[i] * (1 - top[3]));
18
+ const lum = (c) => {
19
+ const [r, gg, b] = c.map((v) => {
20
+ v /= 255;
21
+ return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
22
+ });
23
+ return 0.2126 * r + 0.7152 * gg + 0.0722 * b;
24
+ };
25
+ const ratio = (a, b) => {
26
+ const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p);
27
+ return (x + 0.05) / (y + 0.05);
28
+ };
29
+ const texts = [];
30
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
31
+ const seen = new Set();
32
+ while (walker.nextNode() && texts.length < 800) {
33
+ const n = walker.currentNode;
34
+ const el = n.parentElement;
35
+ if (!el || seen.has(el) || !n.nodeValue.trim()) continue;
36
+ if (el.closest("script,style,noscript,template,svg")) continue;
37
+ seen.add(el);
38
+ if (visible(el)) texts.push(el);
39
+ }
40
+ const pending = new Set(texts);
41
+ const page = Math.max(document.documentElement.scrollHeight, H);
42
+ for (let y = 0; y < page && pending.size; y += H) {
43
+ window.scrollTo(0, y);
44
+ for (const el of [...pending]) {
45
+ const r = el.getBoundingClientRect();
46
+ const cx = r.left + Math.min(r.width / 2, 12), cy = r.top + Math.min(r.height / 2, 10);
47
+ if (cy < 0 || cy >= H || cx < 0 || cx >= W) continue;
48
+ const stack = document.elementsFromPoint(cx, cy);
49
+ const at = stack.indexOf(el);
50
+ if (at < 0) continue;
51
+ pending.delete(el);
52
+ const layers = [];
53
+ let known = true;
54
+ for (const e of stack.slice(at)) {
55
+ const s = getComputedStyle(e);
56
+ if (/^(IMG|VIDEO|CANVAS|IFRAME|PICTURE)$/.test(e.tagName) || e.tagName.toLowerCase() === "svg" || s.backgroundImage !== "none") { known = false; break; }
57
+ const c = rgba(s.backgroundColor);
58
+ if (c[3] > 0) { layers.push(c); if (c[3] >= 1) break; }
59
+ }
60
+ if (!known) continue;
61
+ let bg = [255, 255, 255];
62
+ for (const c of layers.reverse()) bg = over(c, bg);
63
+ const s = getComputedStyle(el);
64
+ const fg = over(rgba(s.color), bg);
65
+ const size = parseFloat(s.fontSize), weight = Number(s.fontWeight) || 400;
66
+ const need = size >= 24 || (size >= 18.66 && weight >= 700) ? 3 : 4.5;
67
+ const got = ratio(fg, bg);
68
+ if (got < need) add("contrast", el, got.toFixed(1) + ":1, needs " + need + ":1");
69
+ }
70
+ }
71
+ window.scrollTo(0, 0);
72
+ `;
@@ -0,0 +1 @@
1
+ export declare const RENDER_CHECK_SCRIPT: string;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The checks, run inside the page (kept as source text: Workers bundlers rename functions, and
3
+ * this must stay self-contained). Returns `PageCheck[]` in `RENDER_CHECK_IDS` order.
4
+ *
5
+ * One async function in the page, assembled from ordered parts that share its locals: the
6
+ * prelude defines the helpers (`add`, `describe`, `visible`, `imgs`, `W`, `H`); the motion part
7
+ * scrolls the page once and defines `frame`, which the navigation and component parts use; the
8
+ * component part ends back at the top. Keep the order.
9
+ */
10
+ import { COMPONENT_CHECKS } from "./components.js";
11
+ import { CONTENT_CHECKS } from "./content.js";
12
+ import { CONTRAST_CHECKS } from "./contrast.js";
13
+ import { LAYOUT_CHECKS } from "./layout.js";
14
+ import { MOTION_CHECKS } from "./motion.js";
15
+ import { NAVIGATION_CHECKS } from "./navigation.js";
16
+ import { PRELUDE } from "./prelude.js";
17
+
18
+ export const RENDER_CHECK_SCRIPT = [
19
+ "(async () => {",
20
+ PRELUDE,
21
+ LAYOUT_CHECKS,
22
+ CONTRAST_CHECKS,
23
+ CONTENT_CHECKS,
24
+ MOTION_CHECKS,
25
+ NAVIGATION_CHECKS,
26
+ COMPONENT_CHECKS,
27
+ " return ids.map((id) => out[id]);",
28
+ "})()",
29
+ ].join("\n");
@@ -0,0 +1,5 @@
1
+ /**
2
+ * In-page checks, part of `RENDER_CHECK_SCRIPT`: overflow-x, img-alt / img-broken, heading-order.
3
+ * Plain source text, run inside the page; it shares the script's locals (see ./index.ts).
4
+ */
5
+ export declare const LAYOUT_CHECKS = " // overflow-x: the outermost elements that stick out past the screen's right edge.\n const sw = document.documentElement.scrollWidth;\n if (sw > W + 1) {\n const clipped = (el) => {\n for (let e = el.parentElement; e && e !== document.body; e = e.parentElement) {\n const o = getComputedStyle(e).overflowX;\n if (o !== \"visible\") return true;\n }\n return false;\n };\n for (const el of document.body.querySelectorAll(\"*\")) {\n const r = el.getBoundingClientRect();\n if (r.width === 0 || r.right <= W + 1) continue;\n const p = el.parentElement && el.parentElement.getBoundingClientRect();\n if (p && p.right > W + 1) continue;\n if (clipped(el)) continue;\n add(\"overflow-x\", el, Math.round(r.right - W) + \" px past the edge\");\n }\n if (out[\"overflow-x\"].count === 0) add(\"overflow-x\", document.body, \"the page is \" + (sw - W) + \" px wider than the screen\");\n }\n\n // img-alt / img-broken\n for (const img of imgs) {\n if (!img.hasAttribute(\"alt\") && img.getAttribute(\"role\") !== \"presentation\" && img.getAttribute(\"aria-hidden\") !== \"true\")\n add(\"img-alt\", img, \"no alt attribute\");\n if (img.getAttribute(\"src\") && img.complete && img.naturalWidth === 0) add(\"img-broken\", img, \"did not load\");\n }\n\n // heading-order\n const hs = [...document.querySelectorAll(\"h1,h2,h3,h4,h5,h6\")].filter(visible);\n const h1 = hs.filter((h) => h.tagName === \"H1\");\n if (h1.length === 0) add(\"heading-order\", document.body, \"no level-1 heading\");\n for (const h of h1.slice(1)) add(\"heading-order\", h, \"a second level-1 heading\");\n let prev = 0;\n for (const h of hs) {\n const level = Number(h.tagName[1]);\n if (prev && level > prev + 1) add(\"heading-order\", h, \"h\" + prev + \" \u2192 h\" + level + \" skips a level\");\n prev = level;\n }\n";
@@ -0,0 +1,44 @@
1
+ /**
2
+ * In-page checks, part of `RENDER_CHECK_SCRIPT`: overflow-x, img-alt / img-broken, heading-order.
3
+ * Plain source text, run inside the page; it shares the script's locals (see ./index.ts).
4
+ */
5
+ export const LAYOUT_CHECKS = ` // overflow-x: the outermost elements that stick out past the screen's right edge.
6
+ const sw = document.documentElement.scrollWidth;
7
+ if (sw > W + 1) {
8
+ const clipped = (el) => {
9
+ for (let e = el.parentElement; e && e !== document.body; e = e.parentElement) {
10
+ const o = getComputedStyle(e).overflowX;
11
+ if (o !== "visible") return true;
12
+ }
13
+ return false;
14
+ };
15
+ for (const el of document.body.querySelectorAll("*")) {
16
+ const r = el.getBoundingClientRect();
17
+ if (r.width === 0 || r.right <= W + 1) continue;
18
+ const p = el.parentElement && el.parentElement.getBoundingClientRect();
19
+ if (p && p.right > W + 1) continue;
20
+ if (clipped(el)) continue;
21
+ add("overflow-x", el, Math.round(r.right - W) + " px past the edge");
22
+ }
23
+ if (out["overflow-x"].count === 0) add("overflow-x", document.body, "the page is " + (sw - W) + " px wider than the screen");
24
+ }
25
+
26
+ // img-alt / img-broken
27
+ for (const img of imgs) {
28
+ if (!img.hasAttribute("alt") && img.getAttribute("role") !== "presentation" && img.getAttribute("aria-hidden") !== "true")
29
+ add("img-alt", img, "no alt attribute");
30
+ if (img.getAttribute("src") && img.complete && img.naturalWidth === 0) add("img-broken", img, "did not load");
31
+ }
32
+
33
+ // heading-order
34
+ const hs = [...document.querySelectorAll("h1,h2,h3,h4,h5,h6")].filter(visible);
35
+ const h1 = hs.filter((h) => h.tagName === "H1");
36
+ if (h1.length === 0) add("heading-order", document.body, "no level-1 heading");
37
+ for (const h of h1.slice(1)) add("heading-order", h, "a second level-1 heading");
38
+ let prev = 0;
39
+ for (const h of hs) {
40
+ const level = Number(h.tagName[1]);
41
+ if (prev && level > prev + 1) add("heading-order", h, "h" + prev + " → h" + level + " skips a level");
42
+ prev = level;
43
+ }
44
+ `;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * In-page checks, part of `RENDER_CHECK_SCRIPT`: The reader's scroll pass, then motion-cls, reduced-motion, motion-budget, flash; defines `frame`.
3
+ * Plain source text, run inside the page; it shares the script's locals (see ./index.ts).
4
+ */
5
+ export declare const MOTION_CHECKS = " // RFC-0024 \u00A78: scroll through the page once, as a reader would, so reveals play, then measure.\n let shift = 0;\n let po;\n try {\n po = new PerformanceObserver((list) => {\n for (const e of list.getEntries()) if (!e.hadRecentInput) shift += e.value;\n });\n po.observe({ type: \"layout-shift\", buffered: true });\n } catch {}\n const pageH = document.documentElement.scrollHeight;\n for (let y = 0; y < pageH; y += Math.round(H * 0.8)) {\n window.scrollTo(0, y);\n await new Promise((r) => setTimeout(r, 90));\n }\n window.scrollTo(0, pageH);\n await new Promise((r) => setTimeout(r, 700));\n if (po) {\n for (const e of po.takeRecords()) if (!e.hadRecentInput) shift += e.value;\n po.disconnect();\n }\n // motion-cls: more layout shift than Core Web Vitals' \"good\" threshold.\n if (shift > 0.1) add(\"motion-cls\", document.body, \"layout shift \" + shift.toFixed(2) + \" while loading and scrolling (good is 0.1 or less)\");\n // reduced-motion: with the reader asking for less, nothing stays hidden and nothing travels.\n if (matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n for (const el of document.querySelectorAll(\"[data-ch-reveal]\"))\n if (el.classList.contains(\"ch-pre\") || Number(getComputedStyle(el).opacity) === 0)\n add(\"reduced-motion\", el, \"stays hidden for readers who ask for reduced motion\");\n const moving = /transform|translate|scale|rotate|^top$|^left$|^right$|^bottom$|offset/;\n for (const a of document.getAnimations ? document.getAnimations() : []) {\n if (a.playState !== \"running\" || !a.effect || !a.effect.getKeyframes) continue;\n const props = a.effect.getKeyframes().flatMap((k) => Object.keys(k));\n if (!props.some((p) => moving.test(p))) continue;\n const el = a.effect.target;\n if (el) add(\"reduced-motion\", el, \"moves for readers who ask for reduced motion\");\n }\n }\n // motion-budget: a page carries a few scroll effects and one scroll story, not a show.\n const scrolls = document.querySelectorAll(\"[data-ch-scroll]\");\n if (scrolls.length > 6) add(\"motion-budget\", scrolls[6], scrolls.length + \" scroll effects on the page; keep to 6 or fewer, the strongest ones\");\n const stories = document.querySelectorAll(\".ch-scrolly\");\n if (stories.length > 1) add(\"motion-budget\", stories[1], stories.length + \" scroll stories on the page; keep to one\");\n const splits = document.querySelectorAll('[data-ch-reveal^=\"split-\"]');\n if (splits.length > 2) add(\"motion-budget\", splits[2], splits.length + \" split headings or texts; keep to 2, for the lines that matter most\");\n const ambients = document.querySelectorAll(\"[data-ch-ambient]\");\n if (ambients.length > 2) add(\"motion-budget\", ambients[2], ambients.length + \" ambient backgrounds on the page; keep to 2, best one on the hero\");\n const loops = document.querySelectorAll(\"[data-ch-loop], .ch-marquee, [data-ch-rotate]\");\n if (loops.length > 2) add(\"motion-budget\", loops[2], loops.length + \" things that loop on the page; keep to 2\");\n // flash: a running animation that repeats faster than three times a second and changes how\n // bright something is (opacity, color, background, filter, visibility).\n const bright = /opacity|color|background|filter|visibility|fill/i;\n for (const a of document.getAnimations ? document.getAnimations() : []) {\n if (a.playState !== \"running\" || !a.effect || !a.effect.getComputedTiming) continue;\n const t = a.effect.getComputedTiming();\n const each = Number(t.duration) / Math.max(0.0001, Math.abs(a.playbackRate || 1));\n if (!(t.iterations > 1) || !(each > 0) || each >= 1000 / 3) continue;\n const props = a.effect.getKeyframes ? a.effect.getKeyframes().flatMap((k) => Object.keys(k)) : [];\n if (props.some((p) => bright.test(p)) && a.effect.target) add(\"flash\", a.effect.target, \"repeats \" + Math.round(1000 / each) + \" times a second\");\n }\n window.scrollTo(0, 0);\n const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));\n await frame();\n";
@@ -0,0 +1,66 @@
1
+ /**
2
+ * In-page checks, part of `RENDER_CHECK_SCRIPT`: The reader's scroll pass, then motion-cls, reduced-motion, motion-budget, flash; defines `frame`.
3
+ * Plain source text, run inside the page; it shares the script's locals (see ./index.ts).
4
+ */
5
+ export const MOTION_CHECKS = ` // RFC-0024 §8: scroll through the page once, as a reader would, so reveals play, then measure.
6
+ let shift = 0;
7
+ let po;
8
+ try {
9
+ po = new PerformanceObserver((list) => {
10
+ for (const e of list.getEntries()) if (!e.hadRecentInput) shift += e.value;
11
+ });
12
+ po.observe({ type: "layout-shift", buffered: true });
13
+ } catch {}
14
+ const pageH = document.documentElement.scrollHeight;
15
+ for (let y = 0; y < pageH; y += Math.round(H * 0.8)) {
16
+ window.scrollTo(0, y);
17
+ await new Promise((r) => setTimeout(r, 90));
18
+ }
19
+ window.scrollTo(0, pageH);
20
+ await new Promise((r) => setTimeout(r, 700));
21
+ if (po) {
22
+ for (const e of po.takeRecords()) if (!e.hadRecentInput) shift += e.value;
23
+ po.disconnect();
24
+ }
25
+ // motion-cls: more layout shift than Core Web Vitals' "good" threshold.
26
+ if (shift > 0.1) add("motion-cls", document.body, "layout shift " + shift.toFixed(2) + " while loading and scrolling (good is 0.1 or less)");
27
+ // reduced-motion: with the reader asking for less, nothing stays hidden and nothing travels.
28
+ if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
29
+ for (const el of document.querySelectorAll("[data-ch-reveal]"))
30
+ if (el.classList.contains("ch-pre") || Number(getComputedStyle(el).opacity) === 0)
31
+ add("reduced-motion", el, "stays hidden for readers who ask for reduced motion");
32
+ const moving = /transform|translate|scale|rotate|^top$|^left$|^right$|^bottom$|offset/;
33
+ for (const a of document.getAnimations ? document.getAnimations() : []) {
34
+ if (a.playState !== "running" || !a.effect || !a.effect.getKeyframes) continue;
35
+ const props = a.effect.getKeyframes().flatMap((k) => Object.keys(k));
36
+ if (!props.some((p) => moving.test(p))) continue;
37
+ const el = a.effect.target;
38
+ if (el) add("reduced-motion", el, "moves for readers who ask for reduced motion");
39
+ }
40
+ }
41
+ // motion-budget: a page carries a few scroll effects and one scroll story, not a show.
42
+ const scrolls = document.querySelectorAll("[data-ch-scroll]");
43
+ if (scrolls.length > 6) add("motion-budget", scrolls[6], scrolls.length + " scroll effects on the page; keep to 6 or fewer, the strongest ones");
44
+ const stories = document.querySelectorAll(".ch-scrolly");
45
+ if (stories.length > 1) add("motion-budget", stories[1], stories.length + " scroll stories on the page; keep to one");
46
+ const splits = document.querySelectorAll('[data-ch-reveal^="split-"]');
47
+ if (splits.length > 2) add("motion-budget", splits[2], splits.length + " split headings or texts; keep to 2, for the lines that matter most");
48
+ const ambients = document.querySelectorAll("[data-ch-ambient]");
49
+ if (ambients.length > 2) add("motion-budget", ambients[2], ambients.length + " ambient backgrounds on the page; keep to 2, best one on the hero");
50
+ const loops = document.querySelectorAll("[data-ch-loop], .ch-marquee, [data-ch-rotate]");
51
+ if (loops.length > 2) add("motion-budget", loops[2], loops.length + " things that loop on the page; keep to 2");
52
+ // flash: a running animation that repeats faster than three times a second and changes how
53
+ // bright something is (opacity, color, background, filter, visibility).
54
+ const bright = /opacity|color|background|filter|visibility|fill/i;
55
+ for (const a of document.getAnimations ? document.getAnimations() : []) {
56
+ if (a.playState !== "running" || !a.effect || !a.effect.getComputedTiming) continue;
57
+ const t = a.effect.getComputedTiming();
58
+ const each = Number(t.duration) / Math.max(0.0001, Math.abs(a.playbackRate || 1));
59
+ if (!(t.iterations > 1) || !(each > 0) || each >= 1000 / 3) continue;
60
+ const props = a.effect.getKeyframes ? a.effect.getKeyframes().flatMap((k) => Object.keys(k)) : [];
61
+ if (props.some((p) => bright.test(p)) && a.effect.target) add("flash", a.effect.target, "repeats " + Math.round(1000 / each) + " times a second");
62
+ }
63
+ window.scrollTo(0, 0);
64
+ const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
65
+ await frame();
66
+ `;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * In-page checks, part of `RENDER_CHECK_SCRIPT`: skip-link, landmarks, header-contrast, focus-obscured.
3
+ * Plain source text, run inside the page; it shares the script's locals (see ./index.ts).
4
+ */
5
+ export declare const NAVIGATION_CHECKS = " // skip-link: the first focusable element is a link to the main content (or inside it).\n const shown = (e) => e.getClientRects().length > 0 && getComputedStyle(e).visibility !== \"hidden\";\n const focusables = [...document.querySelectorAll('a[href],button,input,select,textarea,summary,[tabindex]:not([tabindex=\"-1\"])')]\n .filter((e) => !e.disabled && !e.closest(\"[inert],dialog:not([open]),[popover]:not(:popover-open)\") && shown(e));\n const firstFocus = focusables[0];\n if (firstFocus) {\n const href = firstFocus.tagName === \"A\" ? firstFocus.getAttribute(\"href\") || \"\" : \"\";\n const target = href.startsWith(\"#\") && href.length > 1 ? document.getElementById(decodeURIComponent(href.slice(1))) : null;\n if (!target) add(\"skip-link\", firstFocus, \"the first thing Tab reaches is not a link to the main content\");\n else if (!target.matches(\"main,[role=main]\") && !target.closest(\"main,[role=main]\")) add(\"skip-link\", firstFocus, \"it points at #\" + target.id + \", outside the main content\");\n }\n\n // landmarks: one banner header, one main, one footer (outside articles and sections), named menus.\n const top = (sel) => [...document.querySelectorAll(sel)].filter((e) => shown(e) && !(e.parentElement && e.parentElement.closest(\"article,aside,main,nav,section,dialog\")));\n const banners = top(\"header\"), mains = [...document.querySelectorAll(\"main,[role=main]\")].filter(shown), infos = top(\"footer\");\n if (banners.length !== 1) add(\"landmarks\", banners[1] || document.body, banners.length + \" page headers (one is right)\");\n if (mains.length !== 1) add(\"landmarks\", mains[1] || document.body, mains.length + \" main regions (one is right)\");\n if (infos.length !== 1) add(\"landmarks\", infos[1] || document.body, infos.length + \" page footers (one is right)\");\n const navs = [...document.querySelectorAll(\"nav\")].filter(shown);\n if (navs.length > 1) for (const n of navs) if (!n.getAttribute(\"aria-label\") && !n.getAttribute(\"aria-labelledby\")) add(\"landmarks\", n, \"a menu without a name (aria-label), among \" + navs.length);\n\n // header-contrast: a transparent header's text against what is under it, at the top of the page.\n const clear = document.querySelector(\"header[data-transparent]\");\n if (clear && !clear.hasAttribute(\"data-ch-scrolled\")) {\n const done = new Set();\n const tw = document.createTreeWalker(clear, NodeFilter.SHOW_TEXT);\n while (tw.nextNode()) {\n const el = tw.currentNode.parentElement;\n if (!el || done.has(el) || !tw.currentNode.nodeValue.trim() || !visible(el)) continue;\n done.add(el);\n if (el.closest(\"[popover],dialog,.ch-menu__panel\")) continue;\n const r = el.getBoundingClientRect();\n if (r.width <= 1 || r.height <= 1) continue;\n const cx = r.left + Math.min(r.width / 2, 12), cy = r.top + Math.min(r.height / 2, 10);\n if (cy < 0 || cy >= H || cx < 0 || cx >= W) continue;\n // Text on a fill of its own inside the header (a button, a field) is the contrast check's.\n let own = false;\n for (let e = el; e && e !== clear; e = e.parentElement) if (rgba(getComputedStyle(e).backgroundColor)[3] > 0.5) { own = true; break; }\n if (own) continue;\n let bg = null;\n for (const e of document.elementsFromPoint(cx, cy)) {\n if (clear.contains(e)) continue;\n if (/^(IMG|VIDEO|CANVAS|IFRAME|PICTURE)$/.test(e.tagName)) break;\n const c = rgba(getComputedStyle(e).backgroundColor);\n if (c[3] > 0) { bg = over(c, [255, 255, 255]); break; }\n }\n if (!bg) bg = [255, 255, 255];\n const st = getComputedStyle(el);\n const got = ratio(over(rgba(st.color), bg), bg);\n const big = parseFloat(st.fontSize) >= 24 || (parseFloat(st.fontSize) >= 18.66 && Number(st.fontWeight) >= 700);\n if (got < (big ? 3 : 4.5)) add(\"header-contrast\", el, got.toFixed(1) + \":1 over the first band, needs \" + (big ? 3 : 4.5) + \":1\");\n }\n }\n\n // focus-obscured: tabbing back up the page, a sticky or fixed header never covers the focused element.\n const stuck = [...document.querySelectorAll(\"header,[data-ch-bar]\")].filter((e) => shown(e) && /sticky|fixed/.test(getComputedStyle(e).position));\n if (stuck.length) {\n const targets = [...document.querySelectorAll(\"main a[href],main button,main input,main summary,body > footer a[href]\")]\n .filter((e) => shown(e) && visible(e)).slice(-40).reverse();\n window.scrollTo(0, document.documentElement.scrollHeight);\n await frame();\n for (const el of targets) {\n el.focus();\n await frame();\n const r = el.getBoundingClientRect();\n const cx = Math.min(Math.max(r.left + r.width / 2, 0), W - 1), cy = Math.min(Math.max(r.top + r.height / 2, 0), H - 1);\n const at = document.elementFromPoint(cx, cy);\n const cover = at && !el.contains(at) && stuck.find((s) => s.contains(at));\n if (cover) add(\"focus-obscured\", el, \"under the \" + (cover.tagName === \"HEADER\" ? \"sticky header\" : \"bar\") + \" when it has focus\");\n }\n if (document.activeElement && document.activeElement.blur) document.activeElement.blur();\n window.scrollTo(0, 0);\n }\n";
@@ -0,0 +1,78 @@
1
+ /**
2
+ * In-page checks, part of `RENDER_CHECK_SCRIPT`: skip-link, landmarks, header-contrast, focus-obscured.
3
+ * Plain source text, run inside the page; it shares the script's locals (see ./index.ts).
4
+ */
5
+ export const NAVIGATION_CHECKS = ` // skip-link: the first focusable element is a link to the main content (or inside it).
6
+ const shown = (e) => e.getClientRects().length > 0 && getComputedStyle(e).visibility !== "hidden";
7
+ const focusables = [...document.querySelectorAll('a[href],button,input,select,textarea,summary,[tabindex]:not([tabindex="-1"])')]
8
+ .filter((e) => !e.disabled && !e.closest("[inert],dialog:not([open]),[popover]:not(:popover-open)") && shown(e));
9
+ const firstFocus = focusables[0];
10
+ if (firstFocus) {
11
+ const href = firstFocus.tagName === "A" ? firstFocus.getAttribute("href") || "" : "";
12
+ const target = href.startsWith("#") && href.length > 1 ? document.getElementById(decodeURIComponent(href.slice(1))) : null;
13
+ if (!target) add("skip-link", firstFocus, "the first thing Tab reaches is not a link to the main content");
14
+ else if (!target.matches("main,[role=main]") && !target.closest("main,[role=main]")) add("skip-link", firstFocus, "it points at #" + target.id + ", outside the main content");
15
+ }
16
+
17
+ // landmarks: one banner header, one main, one footer (outside articles and sections), named menus.
18
+ const top = (sel) => [...document.querySelectorAll(sel)].filter((e) => shown(e) && !(e.parentElement && e.parentElement.closest("article,aside,main,nav,section,dialog")));
19
+ const banners = top("header"), mains = [...document.querySelectorAll("main,[role=main]")].filter(shown), infos = top("footer");
20
+ if (banners.length !== 1) add("landmarks", banners[1] || document.body, banners.length + " page headers (one is right)");
21
+ if (mains.length !== 1) add("landmarks", mains[1] || document.body, mains.length + " main regions (one is right)");
22
+ if (infos.length !== 1) add("landmarks", infos[1] || document.body, infos.length + " page footers (one is right)");
23
+ const navs = [...document.querySelectorAll("nav")].filter(shown);
24
+ if (navs.length > 1) for (const n of navs) if (!n.getAttribute("aria-label") && !n.getAttribute("aria-labelledby")) add("landmarks", n, "a menu without a name (aria-label), among " + navs.length);
25
+
26
+ // header-contrast: a transparent header's text against what is under it, at the top of the page.
27
+ const clear = document.querySelector("header[data-transparent]");
28
+ if (clear && !clear.hasAttribute("data-ch-scrolled")) {
29
+ const done = new Set();
30
+ const tw = document.createTreeWalker(clear, NodeFilter.SHOW_TEXT);
31
+ while (tw.nextNode()) {
32
+ const el = tw.currentNode.parentElement;
33
+ if (!el || done.has(el) || !tw.currentNode.nodeValue.trim() || !visible(el)) continue;
34
+ done.add(el);
35
+ if (el.closest("[popover],dialog,.ch-menu__panel")) continue;
36
+ const r = el.getBoundingClientRect();
37
+ if (r.width <= 1 || r.height <= 1) continue;
38
+ const cx = r.left + Math.min(r.width / 2, 12), cy = r.top + Math.min(r.height / 2, 10);
39
+ if (cy < 0 || cy >= H || cx < 0 || cx >= W) continue;
40
+ // Text on a fill of its own inside the header (a button, a field) is the contrast check's.
41
+ let own = false;
42
+ for (let e = el; e && e !== clear; e = e.parentElement) if (rgba(getComputedStyle(e).backgroundColor)[3] > 0.5) { own = true; break; }
43
+ if (own) continue;
44
+ let bg = null;
45
+ for (const e of document.elementsFromPoint(cx, cy)) {
46
+ if (clear.contains(e)) continue;
47
+ if (/^(IMG|VIDEO|CANVAS|IFRAME|PICTURE)$/.test(e.tagName)) break;
48
+ const c = rgba(getComputedStyle(e).backgroundColor);
49
+ if (c[3] > 0) { bg = over(c, [255, 255, 255]); break; }
50
+ }
51
+ if (!bg) bg = [255, 255, 255];
52
+ const st = getComputedStyle(el);
53
+ const got = ratio(over(rgba(st.color), bg), bg);
54
+ const big = parseFloat(st.fontSize) >= 24 || (parseFloat(st.fontSize) >= 18.66 && Number(st.fontWeight) >= 700);
55
+ if (got < (big ? 3 : 4.5)) add("header-contrast", el, got.toFixed(1) + ":1 over the first band, needs " + (big ? 3 : 4.5) + ":1");
56
+ }
57
+ }
58
+
59
+ // focus-obscured: tabbing back up the page, a sticky or fixed header never covers the focused element.
60
+ const stuck = [...document.querySelectorAll("header,[data-ch-bar]")].filter((e) => shown(e) && /sticky|fixed/.test(getComputedStyle(e).position));
61
+ if (stuck.length) {
62
+ const targets = [...document.querySelectorAll("main a[href],main button,main input,main summary,body > footer a[href]")]
63
+ .filter((e) => shown(e) && visible(e)).slice(-40).reverse();
64
+ window.scrollTo(0, document.documentElement.scrollHeight);
65
+ await frame();
66
+ for (const el of targets) {
67
+ el.focus();
68
+ await frame();
69
+ const r = el.getBoundingClientRect();
70
+ const cx = Math.min(Math.max(r.left + r.width / 2, 0), W - 1), cy = Math.min(Math.max(r.top + r.height / 2, 0), H - 1);
71
+ const at = document.elementFromPoint(cx, cy);
72
+ const cover = at && !el.contains(at) && stuck.find((s) => s.contains(at));
73
+ if (cover) add("focus-obscured", el, "under the " + (cover.tagName === "HEADER" ? "sticky header" : "bar") + " when it has focus");
74
+ }
75
+ if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
76
+ window.scrollTo(0, 0);
77
+ }
78
+ `;
@@ -0,0 +1 @@
1
+ export declare const PRELUDE: string;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * In-page checks, part of `RENDER_CHECK_SCRIPT`: Shared helpers (W, H, ids, out, describe, add, visible), lazy images, fonts, finished entrances.
3
+ * Plain source text, run inside the page; it shares the script's locals (see ./index.ts).
4
+ */
5
+ import { RENDER_CHECK_IDS } from "../types.js";
6
+
7
+ export const PRELUDE = ` const W = document.documentElement.clientWidth || window.innerWidth, H = window.innerHeight, MAX = 5;
8
+ const ids = ${JSON.stringify(RENDER_CHECK_IDS)};
9
+ const out = Object.fromEntries(ids.map((id) => [id, { id, count: 0, examples: [] }]));
10
+ const clip = (s, n) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
11
+ const describe = (el) => {
12
+ if (el === document.body || el === document.documentElement) return "the page";
13
+ const parts = [];
14
+ const sec = el.closest && el.closest("[data-section-id]");
15
+ const comp = el.closest && el.closest("[data-component-id]");
16
+ if (sec) parts.push("section " + sec.getAttribute("data-section-id"));
17
+ else {
18
+ const mark = el.closest && el.closest("header,footer,nav,aside");
19
+ if (mark) parts.push("theme " + mark.tagName.toLowerCase() + " (not a section)");
20
+ }
21
+ if (comp) parts.push("component " + comp.getAttribute("data-component-id"));
22
+ let tag = el.tagName.toLowerCase();
23
+ if (el.id) tag += "#" + el.id;
24
+ else if (typeof el.className === "string" && el.className.trim())
25
+ tag += "." + el.className.trim().split(/\\s+/).slice(0, 2).join(".");
26
+ parts.push(tag);
27
+ const text = ((el.innerText || "").trim() || el.getAttribute("alt") || el.getAttribute("src") || "").replace(/\\s+/g, " ");
28
+ if (text) parts.push(JSON.stringify(clip(text, 48)));
29
+ return parts.join(" ");
30
+ };
31
+ const add = (id, el, note) => {
32
+ const c = out[id];
33
+ c.count++;
34
+ if (c.examples.length < MAX) c.examples.push(describe(el) + (note ? " — " + note : ""));
35
+ };
36
+ const visible = (el) => {
37
+ const r = el.getBoundingClientRect();
38
+ if (r.width === 0 && r.height === 0) return false;
39
+ const s = getComputedStyle(el);
40
+ // RFC-0024: content waiting for its reveal (.ch-pre) is content, whatever its opacity now.
41
+ const waiting = el.closest && el.closest(".ch-pre");
42
+ return s.visibility !== "hidden" && s.display !== "none" && (Number(s.opacity) > 0 || !!waiting);
43
+ };
44
+
45
+ // Lazy images load first, so broken ones show and the screenshots are complete.
46
+ const imgs = [...document.images];
47
+ // (which ones the page asked to be lazy is kept for carousel-lcp)
48
+ const wasLazy = new Set(imgs.filter((i) => i.loading === "lazy"));
49
+ for (const img of imgs) if (img.loading === "lazy") img.loading = "eager";
50
+ await Promise.race([
51
+ Promise.all(imgs.map((i) => i.complete ? null : new Promise((r) => {
52
+ i.addEventListener("load", r, { once: true });
53
+ i.addEventListener("error", r, { once: true });
54
+ }))),
55
+ new Promise((r) => setTimeout(r, 4000)),
56
+ ]);
57
+ if (document.fonts) await Promise.race([document.fonts.ready, new Promise((r) => setTimeout(r, 2000))]);
58
+ // RFC-0024: entrances that play on load finish first, so checks see the page's final state.
59
+ if (document.getAnimations)
60
+ await Promise.race([
61
+ Promise.all(document.getAnimations().map((a) => a.finished.catch(() => null))),
62
+ new Promise((r) => setTimeout(r, 2500)),
63
+ ]);
64
+ `;
@@ -0,0 +1,11 @@
1
+ /** Render budgets: per key per minute and per site per day, in memory. */
2
+ /** Per key per minute and per site per day, in memory (each Worker isolate counts on its own). */
3
+ export declare class RenderLimiter {
4
+ #private;
5
+ constructor(limits?: {
6
+ perKeyPerMinute?: number;
7
+ perSitePerDay?: number;
8
+ });
9
+ /** Counts one render, or says why not. */
10
+ take(key: string, site: string, now: Date): string | undefined;
11
+ }
@@ -0,0 +1,29 @@
1
+ /** Render budgets: per key per minute and per site per day, in memory. */
2
+
3
+ /** Per key per minute and per site per day, in memory (each Worker isolate counts on its own). */
4
+ export class RenderLimiter {
5
+ #perKeyPerMinute ;
6
+ #perSitePerDay ;
7
+ #keys = new Map ();
8
+ #sites = new Map ();
9
+ constructor(limits = {}) {
10
+ this.#perKeyPerMinute = limits.perKeyPerMinute ?? 10;
11
+ this.#perSitePerDay = limits.perSitePerDay ?? 200;
12
+ }
13
+ /** Counts one render, or says why not. */
14
+ take(key , site , now ) {
15
+ const t = now.getTime();
16
+ const recent = (this.#keys.get(key) ?? []).filter((x) => t - x < 60_000);
17
+ if (recent.length >= this.#perKeyPerMinute)
18
+ return `at most ${this.#perKeyPerMinute} renders a minute per key; try again in a minute`;
19
+ const day = now.toISOString().slice(0, 10);
20
+ const used = this.#sites.get(site);
21
+ const count = used?.day === day ? used.count : 0;
22
+ if (count >= this.#perSitePerDay)
23
+ return `at most ${this.#perSitePerDay} renders a day per site; use preview_page until tomorrow (UTC)`;
24
+ recent.push(t);
25
+ this.#keys.set(key, recent);
26
+ this.#sites.set(site, { day, count: count + 1 });
27
+ return undefined;
28
+ }
29
+ }
@@ -0,0 +1,19 @@
1
+ import { type PageRenderer, type RenderCheck, type RenderCheckId, type RenderOptions, type RenderResult } from "./types.js";
2
+ export declare const sleep: (ms: number) => Promise<unknown>;
3
+ /** Renders `url` at each width: checks first (they load lazy images), then the screenshots. */
4
+ export declare function renderPage(renderer: PageRenderer, url: string, options: RenderOptions): Promise<RenderResult>;
5
+ /**
6
+ * The content's height. Not the document's scrollHeight: a phone layout that overflows sideways
7
+ * widens the layout viewport and stretches it.
8
+ */
9
+ export declare const PAGE_HEIGHT = "(() => {\n const b = document.body;\n if (!b) return document.documentElement.scrollHeight;\n const s = getComputedStyle(b);\n return b.scrollHeight + parseFloat(s.marginTop) + parseFloat(s.marginBottom);\n})()";
10
+ /** What the in-page script returns for one width. */
11
+ export interface PageCheck {
12
+ id: RenderCheckId;
13
+ count: number;
14
+ examples: string[];
15
+ }
16
+ export declare function mergeChecks(perWidth: readonly {
17
+ width: number;
18
+ checks: PageCheck[];
19
+ }[]): RenderCheck[];
@@ -0,0 +1,135 @@
1
+ /** `renderPage`: checks at each width (one page per width), then the screenshots. */
2
+ import { RENDER_CHECK_SCRIPT } from "./checks/index.js";
3
+ import {
4
+
5
+ RENDER_CHECK_IDS,
6
+
7
+
8
+ RenderError,
9
+
10
+
11
+
12
+
13
+ VIEWPORT_HEIGHT,
14
+ } from "./types.js";
15
+
16
+ export const sleep = (ms ) => new Promise((r) => setTimeout(r, ms));
17
+
18
+ /** Renders `url` at each width: checks first (they load lazy images), then the screenshots. */
19
+ export async function renderPage(
20
+ renderer ,
21
+ url ,
22
+ options ,
23
+ ) {
24
+ const timeoutMs = options.timeoutMs ?? 20_000;
25
+ let session ;
26
+ let timer ;
27
+ let done = false;
28
+ // Where a slow render was when it timed out, for the error.
29
+ let stage = "starting a browser";
30
+ const work = (async () => {
31
+ const opened = await renderer.session();
32
+ if (done) {
33
+ await opened.close().catch(() => {});
34
+ throw new RenderError("timed out");
35
+ }
36
+ session = opened;
37
+ const shots = [];
38
+ const perWidth = [];
39
+ for (const width of options.widths) {
40
+ if (done) throw new RenderError("timed out");
41
+ const vh = VIEWPORT_HEIGHT[width];
42
+ stage = `opening the page at ${width} px`;
43
+ const tab = await session.open(url, {
44
+ width,
45
+ height: vh,
46
+ scheme: options.scheme ?? "light",
47
+ reducedMotion: (options.motion ?? "static") !== "full",
48
+ });
49
+ try {
50
+ stage = `checking the page at ${width} px`;
51
+ const checks = await tab.evaluate (RENDER_CHECK_SCRIPT);
52
+ perWidth.push({ width, checks: Array.isArray(checks) ? checks : [] });
53
+ const height = Math.max(vh, Math.ceil((await tab.evaluate (PAGE_HEIGHT)) ?? vh));
54
+ const max = Math.max(1, options.maxImages ?? 4);
55
+ const frames = options.frames ? Math.min(6, Math.max(1, Math.round(options.frames))) : 0;
56
+ const count = frames || (options.fullPage ? Math.min(max, Math.ceil(height / vh)) : 1);
57
+ const images = [];
58
+ stage = `taking screenshots at ${width} px`;
59
+ for (let i = 0; i < count; i++) {
60
+ // Frames spread over the whole page; otherwise one viewport after another, the last
61
+ // ending at the page's end so nothing is cut in half there.
62
+ const y = frames
63
+ ? Math.round((Math.max(0, height - vh) * i) / Math.max(1, count - 1))
64
+ : i === count - 1 && count * vh >= height
65
+ ? Math.max(0, height - vh)
66
+ : i * vh;
67
+ const at = await tab.evaluate (`(window.scrollTo(0, ${y}), window.scrollY)`);
68
+ // A phone layout that overflows sideways may not scroll at all: stop rather than repeat.
69
+ if (i > 0 && Math.abs((at ?? 0) - y) > 2) break;
70
+ await sleep(frames ? 450 : 150);
71
+ images.push(await tab.screenshot(options.quality ?? 60));
72
+ }
73
+ const seen = frames ? height : images.length >= count ? count * vh : images.length * vh;
74
+ shots.push({ width, height, truncated: seen < height, images });
75
+ } finally {
76
+ await tab.close().catch(() => {});
77
+ }
78
+ }
79
+ return { shots, checks: mergeChecks(perWidth) };
80
+ })();
81
+ const timeout = new Promise ((_, reject) => {
82
+ timer = setTimeout(
83
+ () =>
84
+ reject(new RenderError(`the page did not render within ${timeoutMs / 1000} s (${stage})`)),
85
+ timeoutMs,
86
+ );
87
+ });
88
+ try {
89
+ return await Promise.race([work, timeout]);
90
+ } finally {
91
+ done = true;
92
+ clearTimeout(timer);
93
+ work.catch(() => {});
94
+ await (session )?.close().catch(() => {});
95
+ }
96
+ }
97
+
98
+ /**
99
+ * The content's height. Not the document's scrollHeight: a phone layout that overflows sideways
100
+ * widens the layout viewport and stretches it.
101
+ */
102
+ export const PAGE_HEIGHT = `(() => {
103
+ const b = document.body;
104
+ if (!b) return document.documentElement.scrollHeight;
105
+ const s = getComputedStyle(b);
106
+ return b.scrollHeight + parseFloat(s.marginTop) + parseFloat(s.marginBottom);
107
+ })()`;
108
+
109
+ /** What the in-page script returns for one width. */
110
+
111
+
112
+
113
+
114
+
115
+
116
+ export function mergeChecks(
117
+ perWidth ,
118
+ ) {
119
+ const multi = perWidth.length > 1;
120
+ return RENDER_CHECK_IDS.map((id) => {
121
+ const merged = { id, ok: true, count: 0, examples: [], widths: [] };
122
+ for (const { width, checks } of perWidth) {
123
+ const c = checks.find((x) => x.id === id);
124
+ if (!c || c.count === 0) continue;
125
+ merged.ok = false;
126
+ merged.count += c.count;
127
+ merged.widths.push(width);
128
+ for (const e of c.examples) {
129
+ if (merged.examples.length >= 6) break;
130
+ merged.examples.push(multi ? `@${width}: ${e}` : e);
131
+ }
132
+ }
133
+ return merged;
134
+ });
135
+ }