spiderlint 0.0.0 → 0.143.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.
Files changed (186) hide show
  1. package/LICENSE +235 -0
  2. package/README.md +244 -0
  3. package/bin/spiderlint.js +11 -0
  4. package/dist/agent.js +46 -0
  5. package/dist/cache/extractors.js +37 -0
  6. package/dist/cache/http.js +99 -0
  7. package/dist/cache/index.js +101 -0
  8. package/dist/cache/purge.js +63 -0
  9. package/dist/cache/status.js +52 -0
  10. package/dist/cli.js +602 -0
  11. package/dist/color.js +10 -0
  12. package/dist/config/environment.js +88 -0
  13. package/dist/config/index.js +104 -0
  14. package/dist/config/policy.js +170 -0
  15. package/dist/config/schema.js +127 -0
  16. package/dist/crawl/body.js +92 -0
  17. package/dist/crawl/browser.js +498 -0
  18. package/dist/crawl/crawl.js +58 -0
  19. package/dist/crawl/diversity.js +153 -0
  20. package/dist/crawl/dns.js +194 -0
  21. package/dist/crawl/fetch.js +48 -0
  22. package/dist/crawl/frontier.js +370 -0
  23. package/dist/crawl/guard.js +69 -0
  24. package/dist/crawl/http.js +171 -0
  25. package/dist/crawl/links.js +89 -0
  26. package/dist/crawl/log.js +27 -0
  27. package/dist/crawl/network.js +88 -0
  28. package/dist/crawl/probe.js +107 -0
  29. package/dist/crawl/profile.js +33 -0
  30. package/dist/crawl/rdap.js +86 -0
  31. package/dist/crawl/resolve.js +132 -0
  32. package/dist/crawl/resources.js +233 -0
  33. package/dist/crawl/robots.js +100 -0
  34. package/dist/crawl/route.js +92 -0
  35. package/dist/crawl/scope.js +32 -0
  36. package/dist/crawl/scratch.js +15 -0
  37. package/dist/crawl/sitemap.js +187 -0
  38. package/dist/crawl/svcb.js +59 -0
  39. package/dist/crawl/tls-hello.js +297 -0
  40. package/dist/crawl/tls-probe.js +71 -0
  41. package/dist/facts/browser.js +138 -0
  42. package/dist/facts/byline.js +17 -0
  43. package/dist/facts/co2.js +27 -0
  44. package/dist/facts/csp.js +71 -0
  45. package/dist/facts/export.js +79 -0
  46. package/dist/facts/flatten.js +33 -0
  47. package/dist/facts/graph.js +73 -0
  48. package/dist/facts/headers.js +330 -0
  49. package/dist/facts/html.js +230 -0
  50. package/dist/facts/labels.js +89 -0
  51. package/dist/facts/language.js +32 -0
  52. package/dist/facts/parity.js +38 -0
  53. package/dist/facts/read-note.js +11 -0
  54. package/dist/facts/resources.js +46 -0
  55. package/dist/facts/robots.js +13 -0
  56. package/dist/facts/sites.js +140 -0
  57. package/dist/facts/transport.js +200 -0
  58. package/dist/facts/types.js +4 -0
  59. package/dist/facts/vendors.js +76 -0
  60. package/dist/fold/index.js +94 -0
  61. package/dist/groups/assign.js +45 -0
  62. package/dist/groups/sample.js +60 -0
  63. package/dist/i18n.js +92 -0
  64. package/dist/index.js +607 -0
  65. package/dist/interrupt.js +37 -0
  66. package/dist/logger.js +95 -0
  67. package/dist/plugins/axe.js +104 -0
  68. package/dist/plugins/carbon-txt.js +96 -0
  69. package/dist/plugins/cookies-registry.js +25 -0
  70. package/dist/plugins/cookies.js +168 -0
  71. package/dist/plugins/css.js +328 -0
  72. package/dist/plugins/dns.js +864 -0
  73. package/dist/plugins/feed-content.js +132 -0
  74. package/dist/plugins/feed-model.js +282 -0
  75. package/dist/plugins/feeds.js +836 -0
  76. package/dist/plugins/html-validate.js +191 -0
  77. package/dist/plugins/htmlhint.js +95 -0
  78. package/dist/plugins/icons.js +302 -0
  79. package/dist/plugins/images.js +269 -0
  80. package/dist/plugins/index.js +286 -0
  81. package/dist/plugins/keyboard.js +194 -0
  82. package/dist/plugins/lighthouse.js +110 -0
  83. package/dist/plugins/link-text.js +73 -0
  84. package/dist/plugins/list.js +22 -0
  85. package/dist/plugins/live.js +254 -0
  86. package/dist/plugins/mail.js +985 -0
  87. package/dist/plugins/manifest.js +97 -0
  88. package/dist/plugins/markup.js +98 -0
  89. package/dist/plugins/messages.js +25 -0
  90. package/dist/plugins/network.js +192 -0
  91. package/dist/plugins/origin.js +488 -0
  92. package/dist/plugins/profile-links.js +102 -0
  93. package/dist/plugins/schema-registry.js +101 -0
  94. package/dist/plugins/sshfp.js +159 -0
  95. package/dist/plugins/stack.js +161 -0
  96. package/dist/plugins/structured-data.js +358 -0
  97. package/dist/plugins/tls-probe.js +298 -0
  98. package/dist/plugins/tls-registry.js +418 -0
  99. package/dist/plugins/tls-scan.js +280 -0
  100. package/dist/plugins/trackers-registry.js +27 -0
  101. package/dist/plugins/trackers.js +56 -0
  102. package/dist/plugins/types.js +7 -0
  103. package/dist/plugins/visit.js +50 -0
  104. package/dist/plugins/well-known-registry.js +111 -0
  105. package/dist/plugins/well-known.js +732 -0
  106. package/dist/progress.js +162 -0
  107. package/dist/report/agent.js +122 -0
  108. package/dist/report/checkstyle.js +22 -0
  109. package/dist/report/csv.js +18 -0
  110. package/dist/report/html.js +266 -0
  111. package/dist/report/human.js +220 -0
  112. package/dist/report/index.js +16 -0
  113. package/dist/report/json.js +8 -0
  114. package/dist/report/printable.js +29 -0
  115. package/dist/report/rating.js +26 -0
  116. package/dist/report/sarif.js +100 -0
  117. package/dist/report/stats.js +46 -0
  118. package/dist/rules/builtin.js +465 -0
  119. package/dist/rules/caching.js +103 -0
  120. package/dist/rules/catalog.js +139 -0
  121. package/dist/rules/clock.js +48 -0
  122. package/dist/rules/declarative.js +234 -0
  123. package/dist/rules/deprecated.js +45 -0
  124. package/dist/rules/disclosure.js +99 -0
  125. package/dist/rules/fix.js +21 -0
  126. package/dist/rules/i18n.js +131 -0
  127. package/dist/rules/insights.js +100 -0
  128. package/dist/rules/length.js +52 -0
  129. package/dist/rules/message.js +48 -0
  130. package/dist/rules/relations.js +88 -0
  131. package/dist/rules/robots.js +75 -0
  132. package/dist/rules/rulesets.js +134 -0
  133. package/dist/rules/run.js +75 -0
  134. package/dist/rules/score.js +61 -0
  135. package/dist/rules/types.js +7 -0
  136. package/dist/rules/url.js +90 -0
  137. package/dist/server/analytics.js +49 -0
  138. package/dist/server/api.js +134 -0
  139. package/dist/server/clients.js +54 -0
  140. package/dist/server/clock.js +33 -0
  141. package/dist/server/compress.js +58 -0
  142. package/dist/server/health.js +14 -0
  143. package/dist/server/jobs.js +82 -0
  144. package/dist/server/main.js +83 -0
  145. package/dist/server/page.js +161 -0
  146. package/dist/server/policy.js +145 -0
  147. package/dist/server/providers.js +60 -0
  148. package/dist/server/queue.js +50 -0
  149. package/dist/server/scan.js +39 -0
  150. package/dist/server/settings.js +197 -0
  151. package/dist/server/web.js +353 -0
  152. package/dist/server/worker.js +129 -0
  153. package/dist/store/disk.js +215 -0
  154. package/dist/store/memory.js +18 -0
  155. package/dist/telemetry-environment.js +9 -0
  156. package/dist/telemetry.js +104 -0
  157. package/locales/es/LC_MESSAGES/messages.po +1864 -0
  158. package/locales/uk/LC_MESSAGES/messages.po +1864 -0
  159. package/package.json +134 -2
  160. package/presets/browser.yaml +26 -0
  161. package/presets/graph.yaml +35 -0
  162. package/presets/i18n.yaml +18 -0
  163. package/presets/insights.yaml +12 -0
  164. package/presets/links.yaml +22 -0
  165. package/presets/parity.yaml +59 -0
  166. package/presets/performance.yaml +271 -0
  167. package/presets/recommended.yaml +50 -0
  168. package/presets/redirects.yaml +23 -0
  169. package/presets/resources.yaml +29 -0
  170. package/presets/robots.yaml +13 -0
  171. package/presets/security-headers.yaml +257 -0
  172. package/presets/seo.yaml +380 -0
  173. package/presets/sitemap.yaml +34 -0
  174. package/presets/suggestions.yaml +97 -0
  175. package/presets/sustainability.yaml +16 -0
  176. package/presets/tls.yaml +72 -0
  177. package/presets/vendor.yaml +14 -0
  178. package/presets/web-comprehensive.yaml +17 -0
  179. package/presets/web-quick.yaml +15 -0
  180. package/src/server/apple-touch-icon.png +0 -0
  181. package/src/server/logo-36.png +0 -0
  182. package/src/server/logo-72.png +0 -0
  183. package/src/server/logo.png +0 -0
  184. package/vendors/dns.yaml +21 -0
  185. package/vendors/paths.yaml +55 -0
  186. package/vendors/stack.yaml +57 -0
@@ -0,0 +1,254 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { AxeBuilder } from "@axe-core/playwright";
5
+ import { reason } from "../crawl/fetch.js";
6
+ import { log } from "../logger.js";
7
+ import { pageRule } from "../rules/builtin.js";
8
+ import { said } from "../rules/message.js";
9
+ import { definePlugin } from "./types.js";
10
+ import { DESCRIBE, visit, withPage } from "./visit.js";
11
+ const ID = "live";
12
+ // Click listeners resolved per page, at most.
13
+ const MAX_LISTENERS = 200;
14
+ // Font size in CSS pixels under which iOS Safari zooms into a focused field.
15
+ const MIN_FONT = 16;
16
+ // Elements listed per forced-colours fact, at most.
17
+ const MAX_FORCED = 50;
18
+ // What the page does under reduced motion, form fields below MIN_FONT, and the workers and WebMCP tools it registered.
19
+ const READ = `(async () => {
20
+ ${DESCRIBE}
21
+ const motion = document.getAnimations().filter((animation) => animation.playState === "running").flatMap((animation) => {
22
+ const end = animation.effect?.getComputedTiming().endTime ?? 0;
23
+ if (end !== Infinity && end <= 5000) return [];
24
+ const target = animation.effect?.target;
25
+ return [{ ...(target ? describe(target) : { target: "document", html: "" }), name: animation.animationName ?? animation.transitionProperty ?? (animation.id || "script"), ...(end !== Infinity && { seconds: Math.round(end / 1000) }) }];
26
+ });
27
+ const videos = [...document.querySelectorAll("video")].filter((video) => video.autoplay && !video.paused).map((video) => describe(video));
28
+ const SKIPPED = ["hidden", "checkbox", "radio", "submit", "button", "reset", "image", "file", "range", "color"];
29
+ const inputs = [...document.querySelectorAll("input, select, textarea")].filter((field) => !SKIPPED.includes(field.type) && field.checkVisibility()).flatMap((field) => {
30
+ const size = Number.parseFloat(getComputedStyle(field).fontSize);
31
+ return size < ${MIN_FONT} ? [{ ...describe(field), size }] : [];
32
+ });
33
+ const registrations = (await navigator.serviceWorker?.getRegistrations()) ?? [];
34
+ const serviceWorkers = registrations.map((registration) => ({ scope: registration.scope, ...((registration.active ?? registration.waiting ?? registration.installing) && { script: (registration.active ?? registration.waiting ?? registration.installing).scriptURL }) }));
35
+ const media = (sheet) => { try { return [sheet.media?.mediaText ?? "", ...[...sheet.cssRules].flatMap(function texts(rule) { return [rule.conditionText ?? "", ...(rule.cssRules ? [...rule.cssRules].flatMap(texts) : [])]; })]; } catch { return [sheet.media?.mediaText ?? ""]; } };
36
+ const conditions = [...document.styleSheets].flatMap(media).map((text) => text.replaceAll(" ", ""));
37
+ const isDark = [document.querySelector('meta[name="color-scheme"]')?.content ?? "", getComputedStyle(document.documentElement).colorScheme].some((value) => value.includes("dark")) || conditions.some((text) => text.includes("prefers-color-scheme:dark"));
38
+ const isContrast = conditions.some((text) => /prefers-contrast(?::more|[)])/.test(text));
39
+ const tools = "modelContext" in navigator ? ((await navigator.modelContextTesting?.listTools?.()) ?? []).map((tool) => tool.name) : undefined;
40
+ return { motion, videos, inputs, "service-workers": serviceWorkers, ...(tools && { webmcp: { tools } }), isDark, isContrast };
41
+ })()`;
42
+ // Under forced colours: shown controls with no visible text, media or surviving paint, and opt-out roots holding visible text.
43
+ const FORCED = `(() => {
44
+ ${DESCRIBE}
45
+ const shown = (node) => node.checkVisibility({ visibilityProperty: true }) && node.getBoundingClientRect().width > 0;
46
+ const hasText = (root) => {
47
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
48
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
49
+ if (!node.data.trim()) continue;
50
+ const range = document.createRange();
51
+ range.selectNodeContents(node);
52
+ const box = range.getBoundingClientRect();
53
+ if (box.width > 1 && box.height > 1) return true;
54
+ }
55
+ return false;
56
+ };
57
+ const paints = (node, pseudo) => {
58
+ const style = getComputedStyle(node, pseudo);
59
+ if (pseudo && !["none", "normal", '""'].includes(style.content)) return true;
60
+ return (style.backgroundImage !== "none" || style.maskImage !== "none") && (style.maskImage === "none" || style.forcedColorAdjust === "none");
61
+ };
62
+ const drawn = (control) => hasText(control) || [...control.querySelectorAll("img, svg, picture, canvas, video, object")].some(shown) || [control, ...control.querySelectorAll("*")].slice(0, 20).some((node) => paints(node, null) || paints(node, "::before") || paints(node, "::after"));
63
+ const icons = [...document.querySelectorAll('a[href], button, [role="button"]')].filter((control) => shown(control) && !drawn(control)).slice(0, ${MAX_FORCED}).map((control) => describe(control));
64
+ const optOut = [...document.querySelectorAll("body *")].filter((node) => getComputedStyle(node).forcedColorAdjust === "none" && getComputedStyle(node.parentElement).forcedColorAdjust !== "none" && shown(node) && hasText(node)).slice(0, ${MAX_FORCED}).map((node) => { const style = getComputedStyle(node); return { ...describe(node), colors: style.color + " on " + style.backgroundColor }; });
65
+ return { icons, "opt-out": optOut };
66
+ })()`;
67
+ // Describes an element CDP found taking clicks, when it is a `<div>` or `<span>` with no role outside a native control.
68
+ const CLICKABLE = `function () {
69
+ ${DESCRIBE}
70
+ return ["div", "span"].includes(this.localName) && !this.hasAttribute("role") && !this.closest("a[href], button, label, summary") ? describe(this) : null;
71
+ }`;
72
+ // Elements with their own click listener, through Chromium’s DevTools protocol; undefined in a browser without it.
73
+ async function clickables(page, url) {
74
+ let cdp;
75
+ try {
76
+ cdp = await page.context().newCDPSession(page);
77
+ }
78
+ catch (error) {
79
+ log.debug({ url, error: reason(error) }, "no DevTools protocol; click listeners unread");
80
+ return;
81
+ }
82
+ try {
83
+ const { result } = await cdp.send("Runtime.evaluate", { expression: "document.documentElement" });
84
+ const { listeners } = await cdp.send("DOMDebugger.getEventListeners", { objectId: result.objectId, depth: -1, pierce: true });
85
+ const nodes = [...new Set(listeners.filter((listener) => listener.type === "click" && listener.backendNodeId !== undefined).map((listener) => listener.backendNodeId))];
86
+ const found = [];
87
+ for (const backendNodeId of nodes.slice(0, MAX_LISTENERS)) {
88
+ const { object } = await cdp.send("DOM.resolveNode", { backendNodeId });
89
+ const { result: described } = await cdp.send("Runtime.callFunctionOn", { objectId: object.objectId, functionDeclaration: CLICKABLE, returnByValue: true });
90
+ if (described.value)
91
+ found.push(described.value);
92
+ }
93
+ log.debug({ url, listeners: nodes.length, read: Math.min(nodes.length, MAX_LISTENERS), found: found.length }, "click listeners read");
94
+ return found;
95
+ }
96
+ finally {
97
+ await cdp.detach();
98
+ }
99
+ }
100
+ // Text axe’s `rule` finds below its contrast ratio in the page as it renders now.
101
+ async function faint(page, rule = "color-contrast") {
102
+ const results = await new AxeBuilder({ page }).withRules([rule]).analyze();
103
+ return results.violations
104
+ .flatMap((violation) => violation.nodes)
105
+ .map((node) => {
106
+ const data = node.any[0]?.data;
107
+ return { target: node.target.flat().join(" >>> "), html: /^<[^>]*>/.exec(node.html)?.[0] ?? node.html, ...(data?.contrastRatio !== undefined && { contrast: `${data.fgColor} on ${data.bgColor} ${data.contrastRatio}:1, needs ${data.expectedContrastRatio}` }) };
108
+ });
109
+ }
110
+ // Text axe finds below 7:1 in a fresh copy of the page loaded as a visitor asking for more contrast.
111
+ async function enhanced(live, url) {
112
+ return withPage(live, async (fresh) => {
113
+ await fresh.emulateMedia({ contrast: "more" });
114
+ await visit(fresh, url);
115
+ const found = await faint(fresh, "color-contrast-enhanced");
116
+ log.debug({ url, faint: found.length }, "increased contrast read");
117
+ return found;
118
+ });
119
+ }
120
+ // Reads a fresh copy of the page loaded as a visitor asking for reduced motion and a dark scheme, then forcing colours, leaving the crawler’s own page as rendered.
121
+ async function extract(page, _body, live) {
122
+ if (!live || !page.html)
123
+ return;
124
+ const read = await withPage(live, async (fresh) => {
125
+ await fresh.emulateMedia({ reducedMotion: "reduce", colorScheme: "dark" });
126
+ await visit(fresh, page.url.href);
127
+ const { isDark, isContrast, ...facts } = (await fresh.evaluate(READ));
128
+ const dark = isDark ? await faint(fresh) : undefined;
129
+ const found = await clickables(fresh, page.url.href);
130
+ await fresh.emulateMedia({ forcedColors: "active" });
131
+ const forced = (await fresh.evaluate(FORCED));
132
+ log.debug({ url: page.url.href, motion: facts.motion.length, videos: facts.videos.length, inputs: facts.inputs.length, serviceWorkers: facts["service-workers"].length, webmcp: facts.webmcp?.tools.length, isDark, faint: dark?.length, icons: forced.icons.length, optOut: forced["opt-out"].length, isContrast }, "live page read");
133
+ return { ...facts, ...(found && { clickables: found }), ...(dark && { dark }), forced, isContrast };
134
+ });
135
+ const { isContrast, ...facts } = read;
136
+ return { ...facts, contrast: { claimed: isContrast, ...(isContrast && { faint: await enhanced(live, page.url.href) }) } };
137
+ }
138
+ const liveOf = (page) => page[ID];
139
+ const located = (element) => `${element.target} ${element.html}`;
140
+ const reducedMotion = pageRule("live/reduced-motion", [`${ID}.motion`, `${ID}.videos`], (page) => {
141
+ const facts = liveOf(page);
142
+ if (!facts)
143
+ return;
144
+ const moving = [...facts.motion.map((entry) => `${located(entry)} ${entry.name} ${entry.seconds === undefined ? "endless" : `${entry.seconds} s`}`), ...facts.videos.map((video) => `${located(video)} autoplays`)];
145
+ return moving.length === 0 ? [] : [{ ...said("animations and videos keep moving although the visitor asks for reduced motion"), data: { [page.url.href]: { animations: facts.motion.length, videos: facts.videos.length } }, value: moving, locations: moving }];
146
+ }, { docs: "https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html", fix: "Stop endless and long animations and autoplay inside `@media (prefers-reduced-motion: reduce)`." });
147
+ const clickListener = pageRule("live/click-listener", [`${ID}.clickables`], (page) => {
148
+ const found = liveOf(page)?.clickables;
149
+ if (!found)
150
+ return;
151
+ return found.length === 0 ? [] : [{ ...said("a <div> or <span> takes clicks with no role, so neither keyboards nor screen readers find it"), data: { [page.url.href]: { elements: found.length } }, value: found.map((element) => element.target), locations: found.map((element) => located(element)) }];
152
+ }, { docs: "https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/", fix: 'Use `<button>` or `<a href>`; a custom control needs a role, `tabindex="0"` and a key handler too.' });
153
+ const inputFontSize = pageRule("live/input-font-size", [`${ID}.inputs`], (page) => {
154
+ const small = liveOf(page)?.inputs;
155
+ if (!small)
156
+ return;
157
+ return small.length === 0
158
+ ? []
159
+ : [{ ...said("a form field is set under {limit} px, so iOS Safari zooms in on focus", { limit: MIN_FONT }), data: { [page.url.href]: { fields: small.length } }, value: small.map((field) => `${field.target} ${field.size} px`), locations: small.map((field) => `${located(field)} ${field.size} px`) }];
160
+ }, { docs: "https://developer.mozilla.org/docs/Web/HTML/Viewport_meta_tag", fix: "Give inputs, selects and text areas `font-size: 16px` or more; measured at the crawler’s desktop viewport, so a phone-only media query goes unseen." });
161
+ const darkContrast = pageRule("live/dark-contrast", [`${ID}.dark`], (page) => {
162
+ const faintText = liveOf(page)?.dark;
163
+ if (!faintText)
164
+ return;
165
+ return faintText.length === 0
166
+ ? []
167
+ : [
168
+ {
169
+ ...said("an element is too faint to read in the dark scheme the page claims to support"),
170
+ data: { [page.url.href]: { elements: faintText.length } },
171
+ value: faintText.map((element) => element.target),
172
+ locations: faintText.map((element) => `${located(element)}${element.contrast ? ` ${element.contrast}` : ""}`),
173
+ },
174
+ ];
175
+ }, { docs: "https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html", fix: "Give every `prefers-color-scheme: dark` colour a matching background, or drop `dark` from `color-scheme` until the dark styles exist." });
176
+ const forcedIcons = pageRule("live/forced-icons", [`${ID}.forced.icons`], (page) => {
177
+ const blank = liveOf(page)?.forced?.icons;
178
+ if (!blank)
179
+ return;
180
+ return blank.length === 0
181
+ ? []
182
+ : [
183
+ {
184
+ ...said("a control shows nothing under forced colours: no visible text, and its icon is a gradient or a mask the system colours paint over"),
185
+ data: { [page.url.href]: { controls: blank.length } },
186
+ value: blank.map((element) => element.target),
187
+ locations: blank.map((element) => located(element)),
188
+ },
189
+ ];
190
+ }, { docs: "https://developer.mozilla.org/docs/Web/CSS/@media/forced-colors", fix: "Draw icons with inline SVG in `currentColor` or an `<img>`; a masked icon needs `forced-color-adjust: none` and `background-color: ButtonText` under `@media (forced-colors: active)`." });
191
+ const forcedOptOut = pageRule("live/forced-opt-out", [`${ID}.forced.opt-out`], (page) => {
192
+ const kept = liveOf(page)?.forced?.["opt-out"];
193
+ if (!kept)
194
+ return;
195
+ return kept.length === 0 ? [] : [{ ...said("an element keeps its own text colours under forced colours"), data: { [page.url.href]: { elements: kept.length } }, value: kept.map((element) => element.target), locations: kept.map((element) => `${located(element)} ${element.colors}`) }];
196
+ }, { docs: "https://developer.mozilla.org/docs/Web/CSS/forced-color-adjust", fix: "Keep `forced-color-adjust: none` to small graphics such as logos and swatches, never on text." });
197
+ const contrastMore = pageRule("live/contrast-more", [`${ID}.contrast.claimed`], (page) => {
198
+ const contrast = liveOf(page)?.contrast;
199
+ if (!contrast)
200
+ return;
201
+ return contrast.claimed ? [] : [{ ...said("no style answers `prefers-contrast: more`, so a visitor asking for more contrast sees the default colours"), value: false }];
202
+ }, { docs: "https://developer.mozilla.org/docs/Web/CSS/@media/prefers-contrast", fix: "Darken muted text and borders inside `@media (prefers-contrast: more)`, aiming at 7:1." });
203
+ const contrastEnhanced = pageRule("live/contrast-enhanced", [`${ID}.contrast.faint`], (page) => {
204
+ const faintText = liveOf(page)?.contrast?.faint;
205
+ if (!faintText)
206
+ return;
207
+ return faintText.length === 0
208
+ ? []
209
+ : [
210
+ {
211
+ ...said("an element stays below 7:1 when the visitor asks for the more contrast the page answers"),
212
+ data: { [page.url.href]: { elements: faintText.length } },
213
+ value: faintText.map((element) => element.target),
214
+ locations: faintText.map((element) => `${located(element)}${element.contrast ? ` ${element.contrast}` : ""}`),
215
+ },
216
+ ];
217
+ }, { docs: "https://www.w3.org/WAI/WCAG22/Understanding/contrast-enhanced.html", fix: "Raise every colour pair inside `@media (prefers-contrast: more)` to 7:1, or 4.5:1 for large text." });
218
+ const webmcp = pageRule("live/webmcp", [`${ID}.webmcp`], (page) => {
219
+ const tools = liveOf(page)?.webmcp?.tools;
220
+ if (!tools)
221
+ return;
222
+ return tools.length === 0 ? [{ ...said("the browser supports WebMCP but the page registers no tools, so an agent in it can only click and read"), data: { [page.url.href]: { tools: 0 } }, value: tools }] : [];
223
+ }, { docs: "https://webmcp.org/", fix: "Register the page’s actions as WebMCP tools with `navigator.modelContext`, so an in-browser agent calls them instead of driving the interface." });
224
+ export default definePlugin({
225
+ name: "live",
226
+ extractors: [{ id: ID, mode: "browser", cost: "expensive", inputs: ["resources"], extract }],
227
+ rules: {
228
+ "live/reduced-motion": reducedMotion,
229
+ "live/click-listener": clickListener,
230
+ "live/input-font-size": inputFontSize,
231
+ "live/dark-contrast": darkContrast,
232
+ "live/forced-icons": forcedIcons,
233
+ "live/forced-opt-out": forcedOptOut,
234
+ "live/contrast-more": contrastMore,
235
+ "live/contrast-enhanced": contrastEnhanced,
236
+ "live/webmcp": webmcp,
237
+ },
238
+ presets: {
239
+ live: {
240
+ description: "The rendered page on sampled pages: motion under reduced-motion, contrast in a claimed dark scheme and under increased contrast, icons and opt-outs under forced colours, click handlers on plain elements, form fields small enough to zoom",
241
+ rules: {
242
+ "live/reduced-motion": { severity: "warning", score: 6.2 },
243
+ "live/dark-contrast": { severity: "warning", score: 5.6 },
244
+ "live/contrast-enhanced": { severity: "warning", score: 5 },
245
+ "live/forced-icons": { severity: "warning", score: 5.4 },
246
+ "live/click-listener": { severity: "warning", score: 5.2 },
247
+ "live/input-font-size": { severity: "info", score: 2.6 },
248
+ "live/contrast-more": { severity: "info", score: 1.6 },
249
+ "live/forced-opt-out": { severity: "info", score: 2.4 },
250
+ "live/webmcp": { severity: "hint", score: 0.8 },
251
+ },
252
+ },
253
+ },
254
+ });