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,194 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { log } from "../logger.js";
5
+ import { pageRule } from "../rules/builtin.js";
6
+ import { said } from "../rules/message.js";
7
+ import { definePlugin } from "./types.js";
8
+ import { DESCRIBE, visit, withPage } from "./visit.js";
9
+ const ID = "keyboard";
10
+ // Tab presses allowed per candidate, beyond the count, and in all.
11
+ const PER_CANDIDATE = 2;
12
+ const SLACK = 10;
13
+ const MAX_PRESSES = 300;
14
+ // Presses focus may rest on one element, as inside a frame or a media player, before it counts as stuck.
15
+ const STUCK = 20;
16
+ const CANDIDATES = `globalThis[Symbol.for("spiderlint.keyboard")]`;
17
+ // A page script defining `look(element)`: the focus-relevant styles of the element, its pseudo-elements, parent and first ten children, outline only when drawn.
18
+ const LOOK = `const PROPERTIES = ["outline-style", "outline-width", "outline-color", "box-shadow", "border-color", "border-width", "background-color", "color", "text-decoration-line"];
19
+ const read = (node, pseudo) => { const style = getComputedStyle(node, pseudo); return PROPERTIES.filter((name) => style.outlineStyle !== "none" || !name.startsWith("outline-")).map((name) => style.getPropertyValue(name)).join("|"); };
20
+ const look = (element) => [read(element, null), read(element, "::before"), read(element, "::after"), ...(element.parentElement ? [read(element.parentElement, null)] : []), ...[...element.children].slice(0, 10).map((child) => read(child, null))].join(" / ");`;
21
+ // Lists what a keyboard user must reach and stills transitions, so a focus style reads settled; answers the count.
22
+ const MARK = `(() => {
23
+ const selector = 'a[href], area[href], button, input:not([type="hidden"]), select, textarea, summary, iframe, [contenteditable=""], [contenteditable="true"], audio[controls], video[controls], [tabindex]';
24
+ const candidates = [...document.querySelectorAll(selector)].filter((element) => !(Number(element.getAttribute("tabindex")) < 0) && !element.matches(":disabled") && !element.closest("[inert]") && element.checkVisibility({ visibilityProperty: true }));
25
+ ${CANDIDATES} = candidates;
26
+ const style = document.createElement("style");
27
+ style.textContent = "*, *::before, *::after { transition: none !important; }";
28
+ document.head.append(style);
29
+ return candidates.length;
30
+ })()`;
31
+ // The focused element: where it sits in the candidates, whether focus shows, what covers it, and where a same-page link leads.
32
+ const INSPECT = `(() => {
33
+ ${DESCRIBE}
34
+ let element = document.activeElement;
35
+ while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement;
36
+ if (!element || element === document.body || element === document.documentElement) return null;
37
+ const isAbove = (ancestor, node) => { for (let at = node; at; at = at.parentNode ?? at.host) if (at === ancestor) return true; return false; };
38
+ ${LOOK}
39
+ const focused = look(element);
40
+ let obscuredBy;
41
+ const box = element.getBoundingClientRect();
42
+ const [x, y] = [box.left + box.width / 2, box.top + box.height / 2];
43
+ const hit = x >= 0 && y >= 0 && x < innerWidth && y < innerHeight ? document.elementFromPoint(x, y) : null;
44
+ if (hit && !isAbove(element, hit) && !isAbove(hit, element)) {
45
+ for (let node = hit; node; node = node.parentElement) if (["fixed", "sticky"].includes(getComputedStyle(node).position)) { obscuredBy = describe(node).target; break; }
46
+ }
47
+ element.blur?.();
48
+ const visible = look(element) !== focused;
49
+ element.focus?.({ preventScroll: true });
50
+ const main = document.querySelector("main, [role=main]");
51
+ let skipsTo;
52
+ if (element instanceof HTMLAnchorElement && element.hash && element.href.split("#")[0] === location.href.split("#")[0]) {
53
+ const id = decodeURIComponent(element.hash.slice(1));
54
+ const target = document.getElementById(id) ?? document.getElementsByName(id)[0];
55
+ if (target) skipsTo = { target: describe(target).target, main: Boolean(main && (target === main || target.contains(main) || main.contains(target))) };
56
+ }
57
+ return { index: (${CANDIDATES} ?? []).indexOf(element), ...describe(element), visible, ...(obscuredBy && { "obscured-by": obscuredBy }), inMain: Boolean(main?.contains(element)), ...(skipsTo && { skipsTo }) };
58
+ })()`;
59
+ // Candidates Tab never reached and still shown; a radio group counts as reached through any of its radios.
60
+ function missed(reached) {
61
+ return `(() => {
62
+ ${DESCRIBE}
63
+ const candidates = ${CANDIDATES} ?? [];
64
+ const reached = new Set(${JSON.stringify(reached)});
65
+ const groups = new Set([...reached].map((index) => candidates[index]).filter((element) => element?.type === "radio").map((element) => element.name));
66
+ return candidates.filter((element, index) => !reached.has(index) && element.isConnected && element.checkVisibility({ visibilityProperty: true }) && !(element.type === "radio" && groups.has(element.name))).map((element) => describe(element));
67
+ })()`;
68
+ }
69
+ // Candidate indexes whose focus shows no change once refocused from script, as `INSPECT` compares.
70
+ function unchanged(indexes) {
71
+ return `(() => {
72
+ ${LOOK}
73
+ const candidates = ${CANDIDATES} ?? [];
74
+ return ${JSON.stringify(indexes)}.filter((index) => {
75
+ const element = candidates[index];
76
+ if (!element?.isConnected) return false;
77
+ element.focus({ preventScroll: true });
78
+ const focused = look(element);
79
+ element.blur();
80
+ return look(element) === focused;
81
+ });
82
+ })()`;
83
+ }
84
+ // Presses Tab until focus wraps to its first stop, leaves the page, stops moving, or cycles back to a later stop.
85
+ async function walk(page, url) {
86
+ const count = (await page.evaluate(MARK));
87
+ const limit = Math.min(count * PER_CANDIDATE + SLACK, MAX_PRESSES);
88
+ const stops = [];
89
+ const seen = new Map();
90
+ let [complete, still, trap] = [false, 0, undefined];
91
+ for (let press = 0; press < limit; press += 1) {
92
+ await page.keyboard.press("Tab");
93
+ const stop = (await page.evaluate(INSPECT));
94
+ if (!stop) {
95
+ complete = stops.length > 0;
96
+ if (complete)
97
+ break;
98
+ continue;
99
+ }
100
+ const at = seen.get(stop.index >= 0 ? `#${stop.index}` : stop.target);
101
+ if (at !== undefined && at === stops.length - 1) {
102
+ still += 1;
103
+ if (still < STUCK)
104
+ continue;
105
+ trap = stop.target;
106
+ break;
107
+ }
108
+ still = 0;
109
+ if (at === 0) {
110
+ complete = true;
111
+ break;
112
+ }
113
+ if (at !== undefined) {
114
+ trap = stop.target;
115
+ break;
116
+ }
117
+ seen.set(stop.index >= 0 ? `#${stop.index}` : stop.target, stops.length);
118
+ stops.push(stop);
119
+ }
120
+ const unreached = complete ? (await page.evaluate(missed(stops.map((stop) => stop.index).filter((index) => index >= 0)))) : [];
121
+ const shown = stops.filter((stop) => stop.visible && stop.index >= 0).map((stop) => stop.index);
122
+ await page.emulateMedia({ forcedColors: "active" });
123
+ const lost = new Set((await page.evaluate(unchanged(shown))));
124
+ log.debug({ url, candidates: count, limit, stops: stops.length, complete, trap, unreached: unreached.length, forcedRead: shown.length, forcedLost: lost.size }, "keyboard walked");
125
+ const [head] = stops;
126
+ return {
127
+ stops: stops.map(({ target, html, visible, "obscured-by": obscuredBy, index }) => ({ target, html, visible, ...(obscuredBy && { "obscured-by": obscuredBy }), ...(visible && index >= 0 && { forced: !lost.has(index) }) })),
128
+ complete,
129
+ unreached,
130
+ ...(trap && { trap }),
131
+ ...(head && { first: { target: head.target, "in-main": head.inMain, ...(head.skipsTo && { "skips-to": head.skipsTo }) } }),
132
+ };
133
+ }
134
+ // Walks a fresh copy of the rendered page with Tab, leaving the crawler’s own page to the read-only extractors.
135
+ async function extract(page, _body, live) {
136
+ if (!live || !page.html)
137
+ return;
138
+ return withPage(live, async (fresh) => {
139
+ await visit(fresh, page.url.href);
140
+ return walk(fresh, page.url.href);
141
+ });
142
+ }
143
+ const keyboardOf = (page) => page[ID];
144
+ const located = (element) => `${element.target} ${element.html}`;
145
+ const tabWalk = pageRule("keyboard/tab-walk", [`${ID}.trap`, `${ID}.unreached`], (page) => {
146
+ const facts = keyboardOf(page);
147
+ if (!facts)
148
+ return;
149
+ return [
150
+ ...(facts.trap ? [{ ...said("keyboard focus is trapped: Tab stops moving or cycles back"), data: { [page.url.href]: { trap: facts.trap } }, value: facts.trap, locations: [facts.trap] }] : []),
151
+ ...(facts.unreached.length > 0 ? [{ ...said("an interactive element is never reached by Tab"), data: { [page.url.href]: { elements: facts.unreached.length } }, value: facts.unreached, locations: facts.unreached.map((element) => located(element)) }] : []),
152
+ ];
153
+ }, { docs: "https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html", fix: "Let Tab reach every control: drop negative tabindex from interactive elements, and release focus from any widget that holds it." });
154
+ const focusVisible = pageRule("keyboard/focus-visible", [`${ID}.stops`], (page) => {
155
+ const hidden = keyboardOf(page)?.stops.filter((stop) => !stop.visible);
156
+ if (!hidden)
157
+ return;
158
+ return hidden.length === 0 ? [] : [{ ...said("an element shows no visible change when focused"), data: { [page.url.href]: { elements: hidden.length } }, value: hidden.map((stop) => stop.target), locations: hidden.map((stop) => located(stop)) }];
159
+ }, { docs: "https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html", fix: "Give `:focus-visible` an outline or box-shadow; never remove the outline without a replacement." });
160
+ const focusObscured = pageRule("keyboard/focus-obscured", [`${ID}.stops`], (page) => {
161
+ const covered = keyboardOf(page)?.stops.filter((stop) => stop["obscured-by"]);
162
+ if (!covered)
163
+ return;
164
+ return covered.length === 0 ? [] : [{ ...said("a focused element is hidden under fixed or sticky content"), data: { [page.url.href]: { elements: covered.length } }, value: covered.map((stop) => stop.target), locations: covered.map((stop) => `${located(stop)} under ${stop["obscured-by"]}`) }];
165
+ }, { docs: "https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html", fix: "Set `scroll-padding-top` to the sticky header’s height, or keep banners from covering the content." });
166
+ const forcedFocus = pageRule("keyboard/forced-focus", [`${ID}.stops`], (page) => {
167
+ const lost = keyboardOf(page)?.stops.filter((stop) => stop.forced === false);
168
+ if (!lost)
169
+ return;
170
+ return lost.length === 0 ? [] : [{ ...said("an element loses its focus indicator under forced colours"), data: { [page.url.href]: { elements: lost.length } }, value: lost.map((stop) => stop.target), locations: lost.map((stop) => located(stop)) }];
171
+ }, { docs: "https://developer.mozilla.org/docs/Web/CSS/@media/forced-colors", fix: "Add `outline: 2px solid transparent` beside a `box-shadow` focus ring; forced colours drop the shadow and paint the outline." });
172
+ const skipLink = pageRule("keyboard/skip-link", [`${ID}.first`], (page) => {
173
+ const first = keyboardOf(page)?.first;
174
+ if (!first)
175
+ return;
176
+ return first["in-main"] || first["skips-to"]?.main ? [] : [{ ...said("the first Tab stop is not a link to the main content"), value: first, locations: [first.target] }];
177
+ }, { docs: "https://www.w3.org/WAI/WCAG22/Techniques/general/G1", fix: 'Make the first focusable element `<a href="#main">Skip to content</a>`, pointing at `<main id="main">`.' });
178
+ export default definePlugin({
179
+ name: "keyboard",
180
+ extractors: [{ id: ID, mode: "browser", cost: "expensive", inputs: ["resources"], extract }],
181
+ rules: { "keyboard/tab-walk": tabWalk, "keyboard/focus-visible": focusVisible, "keyboard/focus-obscured": focusObscured, "keyboard/forced-focus": forcedFocus, "keyboard/skip-link": skipLink },
182
+ presets: {
183
+ keyboard: {
184
+ description: "Keyboard use on sampled pages: Tab reaches every control without a trap, focus shows, also under forced colours, and is not covered, a skip link comes first",
185
+ rules: {
186
+ "keyboard/tab-walk": { severity: "warning", score: 6.4 },
187
+ "keyboard/focus-visible": { severity: "warning", score: 6 },
188
+ "keyboard/focus-obscured": { severity: "warning", score: 5.8 },
189
+ "keyboard/forced-focus": { severity: "warning", score: 5.4 },
190
+ "keyboard/skip-link": { severity: "info", score: 2.8 },
191
+ },
192
+ },
193
+ },
194
+ });
@@ -0,0 +1,110 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { debuggingPort } from "../crawl/browser.js";
5
+ import { log } from "../logger.js";
6
+ import { definePlugin } from "./types.js";
7
+ const ID = "lighthouse";
8
+ // The lab metrics kept, by the Lighthouse audit that measures each.
9
+ const VITALS = { lcp: "largest-contentful-paint", cls: "cumulative-layout-shift", tbt: "total-blocking-time", fcp: "first-contentful-paint", si: "speed-index", ttfb: "server-response-time" };
10
+ // The offending value’s placeholder in a rule message.
11
+ const GOT = "{got}";
12
+ // The Lighthouse run in flight: its marks are process-global and Chromium traces once, so runs queue.
13
+ const lane = { tail: Promise.resolve() };
14
+ // Runs `audit` once every earlier call has settled.
15
+ async function queued(audit) {
16
+ const previous = lane.tail;
17
+ const { promise, resolve } = Promise.withResolvers();
18
+ lane.tail = promise;
19
+ await previous;
20
+ try {
21
+ return await audit();
22
+ }
23
+ finally {
24
+ resolve();
25
+ }
26
+ }
27
+ // Runs Lighthouse in a new tab of the crawler’s Chromium, reached through its DevTools port; Lighthouse loads on first use.
28
+ async function extract(page, _body, live) {
29
+ if (!live || !page.html)
30
+ return;
31
+ const port = debuggingPort(live);
32
+ if (port === undefined) {
33
+ log.debug({ url: page.url.href }, "no DevTools port; no Lighthouse facts");
34
+ return;
35
+ }
36
+ const { default: lighthouse } = await import("lighthouse");
37
+ log.debug({ url: page.url.href, port }, "Lighthouse queued");
38
+ const result = await queued(() => lighthouse(page.url.href, { port, output: "json", logLevel: "error" }));
39
+ const lhr = result?.lhr;
40
+ if (!lhr)
41
+ return;
42
+ if (lhr.runtimeError)
43
+ throw new Error(`${lhr.runtimeError.code}: ${lhr.runtimeError.message}`);
44
+ const scores = Object.fromEntries(Object.entries(lhr.categories).flatMap(([id, category]) => (category.score === null ? [] : [[id, category.score]])));
45
+ const vitals = Object.fromEntries(Object.entries(VITALS).flatMap(([key, audit]) => {
46
+ const value = lhr.audits[audit]?.numericValue;
47
+ return value === undefined ? [] : [[key, key === "cls" ? Number(value.toFixed(3)) : Math.round(value)]];
48
+ }));
49
+ log.debug({ url: page.url.href, port, scores, vitals, warnings: lhr.runWarnings }, "Lighthouse audited");
50
+ return { version: lhr.lighthouseVersion, "form-factor": lhr.configSettings.formFactor, scores, vitals };
51
+ }
52
+ // A category score of at least 0.9, Lighthouse’s own green.
53
+ function score(category, label, link, scale) {
54
+ return { fact: `${ID}.scores.${category}`, expect: { type: "number", minimum: 0.9 }, severity: "warning", score: scale, message: `Lighthouse ${label} score is ${GOT}, under 0.9`, docs: link, fix: `Fix the failing audits Lighthouse lists under ${label}, largest savings first.` };
55
+ }
56
+ // A lab metric at most `limit`, the bound Lighthouse marks good.
57
+ function metric(key, limit, unit, link, scale) {
58
+ return {
59
+ fact: `${ID}.vitals.${key}`,
60
+ expect: { type: "number", maximum: limit },
61
+ severity: "warning",
62
+ score: scale,
63
+ message: `${key.toUpperCase()} is ${GOT}${unit} in the lab, over ${limit}${unit}`,
64
+ docs: link,
65
+ fix: `Reduce ${key.toUpperCase()} below ${limit}${unit}; the Lighthouse report names the elements and resources behind it.`,
66
+ };
67
+ }
68
+ export default definePlugin({
69
+ name: "lighthouse",
70
+ extractors: [{ id: ID, mode: "browser", cost: "expensive", cached: false, debugging: true, extract }],
71
+ presets: {
72
+ lighthouse: {
73
+ description: "Lighthouse on sampled pages: performance, accessibility, best-practices and SEO scores, and lab LCP, CLS, TBT and FCP",
74
+ rules: {
75
+ "lighthouse/performance": score("performance", "performance", "https://developer.chrome.com/docs/lighthouse/performance/performance-scoring", [
76
+ [0, 6.4],
77
+ [0.9, 3.3],
78
+ ]),
79
+ "lighthouse/accessibility": score("accessibility", "accessibility", "https://developer.chrome.com/docs/lighthouse/accessibility/scoring", [
80
+ [0, 6.2],
81
+ [0.9, 3.3],
82
+ ]),
83
+ "lighthouse/best-practices": score("best-practices", "best-practices", "https://developer.chrome.com/docs/lighthouse/overview", [
84
+ [0, 5.6],
85
+ [0.9, 3.3],
86
+ ]),
87
+ "lighthouse/seo": score("seo", "SEO", "https://developer.chrome.com/docs/lighthouse/overview", [
88
+ [0, 5.4],
89
+ [0.9, 3.3],
90
+ ]),
91
+ "lighthouse/lcp": metric("lcp", 2500, " ms", "https://web.dev/articles/lcp", [
92
+ [2500, 3.4],
93
+ [6000, 6],
94
+ ]),
95
+ "lighthouse/cls": metric("cls", 0.1, "", "https://web.dev/articles/cls", [
96
+ [0.1, 3.4],
97
+ [0.25, 6],
98
+ ]),
99
+ "lighthouse/tbt": metric("tbt", 200, " ms", "https://web.dev/articles/tbt", [
100
+ [200, 3.4],
101
+ [600, 6],
102
+ ]),
103
+ "lighthouse/fcp": metric("fcp", 1800, " ms", "https://web.dev/articles/fcp", [
104
+ [1800, 3.4],
105
+ [3000, 6],
106
+ ]),
107
+ },
108
+ },
109
+ },
110
+ });
@@ -0,0 +1,73 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { load } from "cheerio";
5
+ import { log } from "../logger.js";
6
+ import { pageRule } from "../rules/builtin.js";
7
+ import { said } from "../rules/message.js";
8
+ import { definePlugin } from "./types.js";
9
+ const ID = "linktext";
10
+ // Link texts that say nothing about their target, per primary language subtag; a language absent here is never judged.
11
+ const GENERIC = {
12
+ en: ["click here", "click", "here", "read more", "more", "learn more", "more info", "more information", "see more", "details", "continue", "continue reading", "link", "this link", "this", "go"],
13
+ es: ["haz clic aquí", "clic aquí", "pulsa aquí", "aquí", "leer más", "más", "saber más", "más información", "ver más", "detalles", "continuar", "seguir leyendo", "enlace", "este enlace"],
14
+ uk: ["натисніть тут", "тут", "читати далі", "читати більше", "детальніше", "докладніше", "більше", "дізнатися більше", "ще", "продовжити", "посилання", "це посилання"],
15
+ };
16
+ // Lower-cased, trimmed of punctuation and arrows, whitespace collapsed.
17
+ function normalise(text, locale) {
18
+ return text
19
+ .toLocaleLowerCase(locale)
20
+ .replaceAll(/\s+/g, " ")
21
+ .trim()
22
+ .replaceAll(/[\p{P}\p{S}\s]+$|^[\p{P}\p{S}\s]+/gu, "");
23
+ }
24
+ // Anchors whose accessible name is a generic phrase of the page’s language; a language with no list adds nothing.
25
+ async function extract(page, body) {
26
+ if (!page.html)
27
+ return;
28
+ const locale = page.html.lang?.split("-", 1)[0]?.toLowerCase();
29
+ const phrases = locale ? GENERIC[locale] : undefined;
30
+ if (!locale || !phrases) {
31
+ log.debug({ url: page.url.href, lang: page.html.lang }, "link text skipped: no phrase list for the language");
32
+ return;
33
+ }
34
+ const known = new Set(phrases);
35
+ const $ = load(body);
36
+ const labelled = (ids) => ids
37
+ .split(/\s+/)
38
+ .map((id) => $(`[id="${id.replaceAll('"', String.raw `\"`)}"]`).text())
39
+ .join(" ");
40
+ const generic = $("a[href]")
41
+ .get()
42
+ .flatMap((element) => {
43
+ const anchor = $(element);
44
+ const alt = anchor
45
+ .find("img[alt]")
46
+ .map((_, img) => $(img).attr("alt"))
47
+ .get()
48
+ .join(" ");
49
+ const name = anchor.attr("aria-labelledby") ? labelled(String(anchor.attr("aria-labelledby"))) : (anchor.attr("aria-label") ?? `${anchor.text()} ${alt}`);
50
+ const text = normalise(name, locale);
51
+ return known.has(text) ? [{ text, href: String(anchor.attr("href")) }] : [];
52
+ });
53
+ log.debug({ url: page.url.href, locale, generic: generic.length }, "link text read");
54
+ return { locale, generic };
55
+ }
56
+ const generic = pageRule("link-text/generic", [`${ID}.generic`], (page) => {
57
+ const facts = page[ID];
58
+ if (!facts)
59
+ return;
60
+ const count = facts.generic.length;
61
+ return count === 0 ? [] : [{ ...said("links say nothing about their targets out of context"), data: { [page.url.href]: { links: count } }, value: facts.generic, locations: facts.generic.map(({ text, href }) => `“${text}” → ${href}`) }];
62
+ }, { docs: "https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context.html", fix: "Name the target in the link text itself, or give the link an `aria-label` that does." });
63
+ export default definePlugin({
64
+ name: "link-text",
65
+ extractors: [{ id: ID, extract }],
66
+ rules: { "link-text/generic": generic },
67
+ presets: {
68
+ "link-text": {
69
+ description: "Links whose text says where they lead, judged in the page’s own language: English, Spanish and Ukrainian",
70
+ rules: { "link-text/generic": { severity: "warning", score: 4.6 } },
71
+ },
72
+ },
73
+ });
@@ -0,0 +1,22 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { readFile } from "node:fs/promises";
5
+ import { definePlugin } from "./types.js";
6
+ // One URL per line of a file, `-` for stdin, skipping blank lines and `#` comments; they are the whole frontier.
7
+ export default definePlugin({
8
+ name: "list",
9
+ sources: [
10
+ {
11
+ id: "list",
12
+ follow: false,
13
+ urls: async (file, signal) => {
14
+ const text = await readFile(file === "-" ? "/dev/stdin" : file, { encoding: "utf8", signal });
15
+ return text
16
+ .split(/\r?\n/)
17
+ .map((line) => line.trim())
18
+ .filter((line) => line.length > 0 && !line.startsWith("#"));
19
+ },
20
+ },
21
+ ],
22
+ });