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,269 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { createHash } from "node:crypto";
5
+ import { transform } from "esbuild";
6
+ import sharp, {} from "sharp";
7
+ import { optimize } from "svgo";
8
+ import { log } from "../logger.js";
9
+ import { resourceRule } from "../rules/builtin.js";
10
+ import { said } from "../rules/message.js";
11
+ import { definePlugin } from "./types.js";
12
+ const ID = "images";
13
+ const TEXT = "text";
14
+ const FONTS = "fonts";
15
+ const LAYOUT = "imageLayout";
16
+ const TIMEOUT_SECONDS = 30;
17
+ const MAX_PIXELS = 50_000_000;
18
+ // Digests whose facts stay in memory, oldest dropped first.
19
+ const MEASURED_MAX = 1024;
20
+ const SETTINGS = {
21
+ type: "object",
22
+ additionalProperties: false,
23
+ properties: {
24
+ saving: { type: "object", additionalProperties: false, default: {}, properties: { share: { type: "number", minimum: 0, maximum: 1, default: 0.2 }, bytes: { type: "integer", minimum: 0, default: 10_000 } } },
25
+ weight: { type: "integer", minimum: 0, default: 200_000 },
26
+ oversize: { type: "number", minimum: 1, default: 2 },
27
+ },
28
+ };
29
+ // Encoders per output format, at the quality a CDN image service defaults to; png is palette-quantised as TinyPNG does.
30
+ const ENCODERS = {
31
+ jpeg: (image) => image.jpeg({ quality: 80, mozjpeg: true }),
32
+ png: (image) => image.png({ compressionLevel: 9, palette: true, effort: 7 }),
33
+ webp: (image) => image.webp({ quality: 80, effort: 4 }),
34
+ avif: (image) => image.avif({ quality: 50, effort: 4 }),
35
+ };
36
+ const LEGACY = new Set(["jpeg", "png", "gif"]);
37
+ const SCRIPTS = ["text/javascript", "application/javascript", "application/x-javascript", "application/ecmascript", "text/ecmascript"];
38
+ // Font formats by their first four bytes.
39
+ const FONT_TAGS = { wOF2: "woff2", wOFF: "woff", OTTO: "otf", "\0\u{1}\0\0": "ttf", true: "ttf", ttcf: "ttc" };
40
+ // `font-display` values that hide text while the font loads.
41
+ const INVISIBLE = new Set(["auto", "block"]);
42
+ // sharp reports AVIF as `heif` with AV1 compression.
43
+ function formatOf(metadata) {
44
+ return metadata.format === "heif" && metadata.compression === "av1" ? "avif" : metadata.format;
45
+ }
46
+ // Re-encode targets for `format`: its own encoder, then WebP and AVIF; libvips encodes no animated AVIF.
47
+ function targetsOf(format, isAnimated) {
48
+ const own = Object.hasOwn(ENCODERS, format) && !(isAnimated && format === "avif") ? [["same", format]] : [];
49
+ const modern = LEGACY.has(format) ? [["webp", "webp"], ...(isAnimated ? [] : [["avif", "avif"]])] : [];
50
+ return [...own, ...modern];
51
+ }
52
+ // An SVG’s size, and its size after SVGO when SVGO can parse it.
53
+ function measureSvg(url, body) {
54
+ const text = Buffer.from(body).toString("utf8");
55
+ try {
56
+ return { format: "svg", bytes: body.byteLength, encoded: { same: Buffer.byteLength(optimize(text).data) } };
57
+ }
58
+ catch (error) {
59
+ log.debug({ url, error: error instanceof Error ? error.message : String(error) }, "SVGO cannot parse the SVG");
60
+ return { format: "svg", bytes: body.byteLength };
61
+ }
62
+ }
63
+ // Format, size and the bytes each re-encoding would take, on one libvips thread; SVG goes through SVGO, never rasterised.
64
+ async function measure(url, contentType, body) {
65
+ if (contentType === "image/svg+xml")
66
+ return measureSvg(url, body);
67
+ sharp.concurrency(1);
68
+ sharp.cache(false);
69
+ const input = () => sharp(body, { animated: true, limitInputPixels: MAX_PIXELS }).timeout({ seconds: TIMEOUT_SECONDS });
70
+ const metadata = await input().metadata();
71
+ const format = formatOf(metadata);
72
+ const isAnimated = (metadata.pages ?? 1) > 1;
73
+ const facts = { format, bytes: body.byteLength, width: metadata.width, height: isAnimated ? metadata.pageHeight : metadata.height, ...(isAnimated && { animated: true }) };
74
+ const encoded = {};
75
+ for (const [target, output] of targetsOf(format, isAnimated)) {
76
+ const buffer = await ENCODERS[output](input()).toBuffer();
77
+ encoded[target] = buffer.byteLength;
78
+ }
79
+ log.debug({ url, format, bytes: body.byteLength, isAnimated, encoded }, "image measured");
80
+ return Object.keys(encoded).length > 0 ? { ...facts, encoded } : facts;
81
+ }
82
+ // Facts per content type and body digest, so one image served under several URLs is measured once.
83
+ const measured = new Map();
84
+ // An image’s facts, measured once per digest; a failed measurement is not remembered.
85
+ async function extract(url, contentType, body) {
86
+ const digest = createHash("sha256").update(contentType).update("\0").update(body).digest("hex");
87
+ const known = measured.get(digest);
88
+ log.debug({ url, digest, isKnown: known !== undefined }, "image looked up by digest");
89
+ if (known)
90
+ return structuredClone(await known);
91
+ const facts = measure(url, contentType, body);
92
+ measured.set(digest, facts);
93
+ if (measured.size > MEASURED_MAX)
94
+ measured.delete(measured.keys().next().value);
95
+ try {
96
+ return structuredClone(await facts);
97
+ }
98
+ catch (error) {
99
+ measured.delete(digest);
100
+ throw error;
101
+ }
102
+ }
103
+ // Each `@font-face` family and its `font-display`, read from esbuild’s minified CSS.
104
+ function fontFaces(css) {
105
+ return css
106
+ .matchAll(/@font-face\{([^}]*)\}/g)
107
+ .map(([, block = ""]) => {
108
+ const family = /font-family:\s*("[^"]*"|'[^']*'|[^;]*)/.exec(block)?.[1]?.replaceAll(/^["']|["']$/g, "") ?? "";
109
+ const display = /font-display:\s*([^;]*)/.exec(block)?.[1]?.trim();
110
+ return { family, ...(display && { display }) };
111
+ })
112
+ .toArray();
113
+ }
114
+ // A stylesheet’s or script’s bytes before and after esbuild minifies it.
115
+ async function extractText(url, contentType, body) {
116
+ const loader = contentType === "text/css" ? "css" : "js";
117
+ const { code } = await transform(Buffer.from(body).toString("utf8"), { loader, minify: true, logLevel: "silent" });
118
+ const faces = loader === "css" ? fontFaces(code) : [];
119
+ log.debug({ url, loader, bytes: body.byteLength, minified: Buffer.byteLength(code), fontFaces: faces.length }, "text asset minified");
120
+ return { bytes: body.byteLength, minified: Buffer.byteLength(code), ...(faces.length > 0 && { "font-faces": faces }) };
121
+ }
122
+ // A font’s format by its leading bytes, whatever its content type claims; EOT carries `LP` at byte 34.
123
+ async function extractFont(url, _contentType, body) {
124
+ const tag = Buffer.from(body.subarray(0, 4)).toString("latin1");
125
+ const format = FONT_TAGS[tag] ?? (body[34] === 0x4c && body[35] === 0x50 ? "eot" : "unknown");
126
+ log.debug({ url, tag, format }, "font sniffed");
127
+ return { format, bytes: body.byteLength };
128
+ }
129
+ // Where each rendered `<img>` sits and how many pixels it ships, read in the crawler’s page.
130
+ const LAYOUT_SCRIPT = `(() => ({
131
+ viewport: { width: innerWidth, height: innerHeight, dpr: devicePixelRatio },
132
+ images: [...document.images].map((img) => {
133
+ const box = img.getBoundingClientRect();
134
+ return { src: img.getAttribute("src") ?? img.currentSrc, top: Math.round(box.top + scrollY), width: Math.round(box.width), height: Math.round(box.height), natural: img.naturalWidth, loading: img.loading };
135
+ }),
136
+ }))()`;
137
+ // The share saved when `after` beats `before` by both `saving` bounds.
138
+ function saved(saving, before, after) {
139
+ return after === undefined || before - after < saving.bytes || (before - after) / before < saving.share ? undefined : Math.round((1 - after / before) * 100);
140
+ }
141
+ const imageOf = (resource) => resource[ID];
142
+ const textOf = (resource) => resource[TEXT];
143
+ const fontOf = (resource) => resource[FONTS];
144
+ const isImage = (_page, resource) => imageOf(resource) !== undefined;
145
+ const valueOf = (resource) => imageOf(resource);
146
+ const FACTS = [`resources.${ID}`];
147
+ // A JPEG, PNG or GIF that WebP or AVIF would shrink.
148
+ const modernFormat = (severity, settings) => resourceRule("images/modern-format", isImage, (resource) => {
149
+ const image = imageOf(resource);
150
+ const encoded = image.encoded ?? {};
151
+ const target = ["avif", "webp"].filter((name) => encoded[name] !== undefined).toSorted((a, b) => encoded[a] - encoded[b])[0];
152
+ const share = target && LEGACY.has(image.format) ? saved(settings.saving, image.bytes, encoded[target]) : undefined;
153
+ return share === undefined
154
+ ? undefined
155
+ : { ...said("a {format} image is smaller as {target}; used by these pages", { format: image.format, target: target }), data: { [resource.url]: { size: { fact: "http.size.body", value: image.bytes }, smaller: { fact: "http.size.body", value: encoded[target] } } } };
156
+ }, FACTS, valueOf, { docs: "https://web.dev/articles/choose-the-right-image-format", fix: "Convert the image to WebP or AVIF." })(severity);
157
+ // An image its own format, or SVGO, re-encodes much smaller.
158
+ const recompress = (severity, settings) => resourceRule("images/recompress", isImage, (resource) => {
159
+ const image = imageOf(resource);
160
+ const share = saved(settings.saving, image.bytes, image.encoded?.same);
161
+ return share === undefined ? undefined : { ...said("a {format} image re-encodes smaller; used by these pages", { format: image.format }), data: { [resource.url]: { size: { fact: "http.size.body", value: image.bytes }, smaller: { fact: "http.size.body", value: image.encoded?.same } } } };
162
+ }, FACTS, valueOf, { docs: "https://web.dev/articles/use-imagemin-to-compress-images", fix: "Re-encode the image with a modern tool at a lower quality or smaller dimensions." })(severity);
163
+ // An image heavier than `weight`.
164
+ const weight = (severity, settings) => resourceRule("images/weight", isImage, (resource) => {
165
+ const image = imageOf(resource);
166
+ const limit = settings.weight;
167
+ return image.bytes > limit ? { ...said("a {format} image is above the weight limit; used by these pages", { format: image.format }), data: { [resource.url]: { size: { fact: "http.size.body", value: image.bytes }, limit: { fact: "http.size.body", value: limit } } } } : undefined;
168
+ }, FACTS, valueOf, { docs: "https://developer.mozilla.org/docs/Learn_web_development/Extensions/Performance/Multimedia", fix: "Resize or re-compress the image so the file is below the weight limit." })(severity);
169
+ // A stylesheet or script minification shrinks by both `saving` bounds.
170
+ const minify = (severity, settings) => resourceRule("images/minify", (_page, resource) => textOf(resource) !== undefined, (resource) => {
171
+ const text = textOf(resource);
172
+ const share = saved(settings.saving, text.bytes, text.minified);
173
+ return share === undefined ? undefined : { ...said("a {kind} minifies smaller; used by these pages", { kind: resource.kind }), data: { [resource.url]: { size: { fact: "http.size.body", value: text.bytes }, smaller: { fact: "http.size.body", value: text.minified } } } };
174
+ }, [`resources.${TEXT}`], textOf, { docs: "https://web.dev/articles/reduce-network-payloads-using-text-compression", fix: "Minify the stylesheet or script in the build." })(severity);
175
+ // A font served in any format but WOFF2.
176
+ const fontFormat = resourceRule("images/font-format", (_page, resource) => fontOf(resource) !== undefined, (resource) => {
177
+ const font = fontOf(resource);
178
+ return font.format === "woff2" ? undefined : { ...said("a {format} font is not WOFF2; used by these pages", { format: font.format }), data: { [resource.url]: { size: { fact: "http.size.body", value: font.bytes } } } };
179
+ }, [`resources.${FONTS}`], fontOf, { docs: "https://web.dev/articles/reduce-webfont-size", fix: "Convert the font to WOFF2." });
180
+ // A stylesheet whose `@font-face` rules hide text while their font loads.
181
+ const fontDisplay = resourceRule("images/font-display", (_page, resource) => textOf(resource)?.["font-faces"] !== undefined, (resource) => {
182
+ const invisible = (textOf(resource)?.["font-faces"] ?? []).filter((face) => INVISIBLE.has(face.display ?? "auto")).map((face) => face.family);
183
+ return invisible.length === 0 ? undefined : { ...said("@font-face rules without font-display: swap, fallback or optional; used by these pages"), data: { [resource.url]: { faces: invisible.length, families: [...new Set(invisible)].join(", ") } } };
184
+ }, [`resources.${TEXT}`], (resource) => textOf(resource)?.["font-faces"], { docs: "https://developer.mozilla.org/docs/Web/CSS/@font-face/font-display", fix: "Add font-display: swap (or fallback or optional) to each @font-face." });
185
+ // A page rule over its `<img>` elements: one finding listing each offender as a location.
186
+ function imgRule(id, facts, documentation, sentence, offends, fix) {
187
+ return (severity) => ({
188
+ meta: { id, severity, scope: "page", facts, docs: documentation, ...(fix && { fix }) },
189
+ check(page) {
190
+ if (!page.html)
191
+ return;
192
+ const locations = page.html.images.filter((img) => !img.noscript).flatMap((img) => offends(page, img) ?? []);
193
+ log.debug({ rule: id, url: page.url.href, images: page.html.images.length, offending: locations.length }, "images judged");
194
+ return locations.length === 0 ? [] : [{ rule: id, severity, scope: "page", url: page.url.href, group: page.group, ...sentence, data: { [page.url.href]: { images: locations.length } }, value: locations, locations }];
195
+ },
196
+ });
197
+ }
198
+ // A page rule over its rendered `<img>` boxes, skipped on a page the layout extractor did not sample; hidden images are never judged.
199
+ function layoutRule(id, documentation, sentence, offends, fix) {
200
+ return (severity, settings) => ({
201
+ meta: { id, severity, scope: "page", facts: [LAYOUT], docs: documentation, ...(fix && { fix }) },
202
+ check(page) {
203
+ const layout = page[LAYOUT];
204
+ if (!layout)
205
+ return;
206
+ const locations = layout.images.filter((box) => box.width > 0 && box.height > 0).flatMap((box) => offends(layout, box, settings) ?? []);
207
+ log.debug({ rule: id, url: page.url.href, images: layout.images.length, offending: locations.length }, "rendered images judged");
208
+ return locations.length === 0 ? [] : [{ rule: id, severity, scope: "page", url: page.url.href, group: page.group, ...sentence, data: { [page.url.href]: { images: locations.length } }, value: locations, locations }];
209
+ },
210
+ });
211
+ }
212
+ // The image facts of the resource an `<img src>` loads.
213
+ function loaded(page, source) {
214
+ if (!URL.canParse(source, page.url.href))
215
+ return undefined;
216
+ const url = new URL(source, page.url.href);
217
+ url.hash = "";
218
+ const resource = page.resources?.find((entry) => entry.url === url.href && imageOf(entry));
219
+ return resource && imageOf(resource);
220
+ }
221
+ const dimensions = imgRule("images/dimensions", ["html.images"], "https://web.dev/articles/optimize-cls#images-without-dimensions", said("an <img> without width and height shifts the layout as it loads"), (_page, img) => (img.width === undefined || img.height === undefined ? img.src : undefined), "Add width and height attributes to each <img>.");
222
+ const oversized = (severity, settings) => {
223
+ const { oversize } = settings;
224
+ return imgRule("images/oversized", ["html.images", `resources.${ID}`], "https://web.dev/articles/serve-responsive-images", said("an <img> without srcset ships over {oversize} the width it displays", { oversize: { ratio: oversize } }), (page, img) => {
225
+ const declared = Number(img.width);
226
+ const intrinsic = img.srcset === undefined && /^\d+$/.test(img.width ?? "") ? loaded(page, img.src)?.width : undefined;
227
+ return intrinsic !== undefined && declared > 0 && intrinsic > declared * oversize ? `${img.src} ${intrinsic} px for width=${declared}` : undefined;
228
+ }, "Serve a srcset with a width matching what the page renders.")(severity);
229
+ };
230
+ const lazyBelow = layoutRule("images/lazy-below-fold", "https://web.dev/articles/browser-level-image-lazy-loading", said("an <img> below the fold without loading=lazy loads before the reader scrolls"), (layout, box) => (box.top >= layout.viewport.height && box.loading !== "lazy" ? `${box.src} at ${box.top} px` : undefined), "Add loading=lazy to off-screen images.");
231
+ const lazyAbove = layoutRule("images/lazy-above-fold", "https://web.dev/articles/lcp-lazy-loading", said("an <img> in the first viewport with loading=lazy waits for layout before loading"), (layout, box) => (box.top < layout.viewport.height && box.loading === "lazy" ? `${box.src} at ${box.top} px` : undefined), "Remove loading=lazy from images in the first viewport.");
232
+ const renderedOversize = layoutRule("images/rendered-oversize", "https://web.dev/articles/serve-responsive-images", said("an <img> ships far more pixels than the page renders"), (layout, box, settings) => (box.natural > box.width * layout.viewport.dpr * settings.oversize ? `${box.src} ${box.natural} px shown at ${box.width} px` : undefined), "Serve an image whose natural width matches what the layout renders.");
233
+ export default definePlugin({
234
+ name: "images",
235
+ settings: SETTINGS,
236
+ extractors: [{ id: LAYOUT, mode: "browser", cost: "expensive", inputs: ["resources"], extract: async (_page, _body, live) => live && (await live.evaluate(LAYOUT_SCRIPT)) }],
237
+ resources: [
238
+ { id: ID, types: ["image/jpeg", "image/png", "image/gif", "image/webp", "image/avif", "image/svg+xml"], extract },
239
+ { id: TEXT, types: ["text/css", ...SCRIPTS], extract: extractText },
240
+ { id: FONTS, types: ["font/", "application/font-", "application/x-font-", "application/vnd.ms-fontobject"], extract: extractFont },
241
+ ],
242
+ rules: {
243
+ "images/modern-format": modernFormat,
244
+ "images/recompress": recompress,
245
+ "images/weight": weight,
246
+ "images/dimensions": dimensions,
247
+ "images/oversized": oversized,
248
+ "images/minify": minify,
249
+ "images/font-format": fontFormat,
250
+ "images/font-display": fontDisplay,
251
+ "images/lazy-below-fold": lazyBelow,
252
+ "images/lazy-above-fold": lazyAbove,
253
+ "images/rendered-oversize": renderedOversize,
254
+ },
255
+ presets: {
256
+ images: {
257
+ description: "Image weight and markup: bytes a modern format or a re-encode saves, heavy and oversized images, layout shift",
258
+ rules: { "images/modern-format": { severity: "warning", score: 5.2 }, "images/recompress": { severity: "warning", score: 4.8 }, "images/weight": { severity: "warning", score: 5.6 }, "images/dimensions": { severity: "warning", score: 5.4 }, "images/oversized": { severity: "warning", score: 4.6 } },
259
+ },
260
+ "images:assets": {
261
+ description: "Fonts and text assets: WOFF2, font-display, and bytes minification saves",
262
+ rules: { "images/font-format": { severity: "warning", score: 4.4 }, "images/font-display": { severity: "warning", score: 5 }, "images/minify": { severity: "warning", score: 4.2 } },
263
+ },
264
+ "images:live": {
265
+ description: "Images as the browser lays them out, on sampled pages: lazy loading against the fold, pixels shipped against pixels shown",
266
+ rules: { "images/lazy-below-fold": { severity: "warning", score: 4.8 }, "images/lazy-above-fold": { severity: "warning", score: 5.8 }, "images/rendered-oversize": { severity: "warning", score: 4.4 } },
267
+ },
268
+ },
269
+ });
@@ -0,0 +1,286 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
5
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
6
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
7
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
8
+ });
9
+ }
10
+ return path;
11
+ };
12
+ import path from "node:path";
13
+ import { pathToFileURL } from "node:url";
14
+ import { Ajv2020 } from "ajv/dist/2020.js";
15
+ import { loadedDigest } from "../crawl/browser.js";
16
+ import { VERSION } from "../agent.js";
17
+ import { ExtractorCache } from "../cache/extractors.js";
18
+ import { ConfigError } from "../config/index.js";
19
+ import { CORE_KEYS, describe } from "../config/schema.js";
20
+ import { subjectPath } from "../facts/sites.js";
21
+ import { log } from "../logger.js";
22
+ import { progressStep } from "../progress.js";
23
+ import { inSpan, recordExtractor } from "../telemetry.js";
24
+ import { builtin } from "../rules/builtin.js";
25
+ import report from "../report/index.js";
26
+ import { presetNames } from "../rules/rulesets.js";
27
+ import axe from "./axe.js";
28
+ import cookies from "./cookies.js";
29
+ import css from "./css.js";
30
+ import dns from "./dns.js";
31
+ import feeds from "./feeds.js";
32
+ import htmlValidate from "./html-validate.js";
33
+ import htmlhint from "./htmlhint.js";
34
+ import images from "./images.js";
35
+ import keyboard from "./keyboard.js";
36
+ import lighthouse from "./lighthouse.js";
37
+ import linkText from "./link-text.js";
38
+ import profileLinks from "./profile-links.js";
39
+ import list from "./list.js";
40
+ import live from "./live.js";
41
+ import icons from "./icons.js";
42
+ import manifest from "./manifest.js";
43
+ import markup from "./markup.js";
44
+ import network from "./network.js";
45
+ import origin from "./origin.js";
46
+ import structuredData from "./structured-data.js";
47
+ import tlsProbe from "./tls-probe.js";
48
+ import mail from "./mail.js";
49
+ import sshfp from "./sshfp.js";
50
+ import stack from "./stack.js";
51
+ import trackers from "./trackers.js";
52
+ import wellKnown from "./well-known.js";
53
+ const plugins = [report, htmlValidate, htmlhint, axe, keyboard, profileLinks, live, lighthouse, origin, dns, mail, sshfp, network, stack, tlsProbe, cookies, images, css, wellKnown, feeds, structuredData, manifest, icons, linkText, markup, trackers, list];
54
+ // Milliseconds before a source that has not answered aborts the run.
55
+ const SOURCE_MS = 60_000;
56
+ const loaded = new Set(plugins.map((plugin) => plugin.name));
57
+ const bundled = plugins.flatMap((plugin) => [...(plugin.extractors ?? []), ...(plugin.resources ?? []), ...(plugin.sites ?? [])]);
58
+ for (const extractor of bundled)
59
+ extractor.version ??= VERSION;
60
+ const ajv = new Ajv2020({ strictTypes: false, allErrors: true, useDefaults: true });
61
+ // Each plugin’s validated settings, by plugin name.
62
+ const settings = new Map();
63
+ // `raw` validated against the plugin’s schema, its defaults filled in; a violation names its path.
64
+ function settle(plugin, raw = {}) {
65
+ if (!plugin.settings)
66
+ return undefined;
67
+ const value = structuredClone(raw);
68
+ const validate = ajv.compile(plugin.settings);
69
+ if (!validate(value))
70
+ throw new ConfigError((validate.errors ?? []).map((error) => describe(error, `org.spiderlint/${plugin.name}`)).join("; "));
71
+ return value;
72
+ }
73
+ // Settles every plugin’s key of `raw`; a key no loaded plugin claims is unknown.
74
+ function configure(raw) {
75
+ const unclaimed = Object.keys(raw).filter((key) => plugins.every((plugin) => plugin.name !== key || !plugin.settings));
76
+ if (unclaimed.length > 0)
77
+ throw new ConfigError(unclaimed.map((key) => `org.spiderlint: unknown key "${key}"`).join("; "));
78
+ settings.clear();
79
+ for (const plugin of plugins)
80
+ if (plugin.settings)
81
+ settings.set(plugin.name, settle(plugin, raw[plugin.name]));
82
+ log.debug({ plugins: settings.keys().toArray(), set: Object.keys(raw) }, "plugin settings validated");
83
+ }
84
+ // A TypeScript rule by ID: the core’s, else a plugin’s, handed its plugin’s settings.
85
+ export function ruleMaker(id) {
86
+ const plugin = plugins.find((candidate) => candidate.rules?.[id]);
87
+ const make = plugin?.rules?.[id];
88
+ return builtin[id] ?? (plugin && make && ((severity) => make(severity, settings.has(plugin.name) ? settings.get(plugin.name) : settle(plugin))));
89
+ }
90
+ // A preset a plugin ships.
91
+ export function pluginPreset(name) {
92
+ return plugins.find((plugin) => plugin.presets?.[name])?.presets?.[name];
93
+ }
94
+ export function pluginPresetNames() {
95
+ return plugins.flatMap((plugin) => Object.keys(plugin.presets ?? {}));
96
+ }
97
+ // A formatter by format name, bundled or a plugin’s.
98
+ export function formatter(name) {
99
+ return plugins.find((plugin) => plugin.formatters?.[name])?.formatters?.[name];
100
+ }
101
+ // Every format name, the bundled ones first.
102
+ export function formatNames() {
103
+ return [...plugins.flatMap((plugin) => Object.keys(plugin.formatters ?? {}))];
104
+ }
105
+ function allSources() {
106
+ return plugins.flatMap((plugin) => plugin.sources ?? []);
107
+ }
108
+ function allExtractors() {
109
+ return plugins.flatMap((plugin) => plugin.extractors ?? []);
110
+ }
111
+ function allSiteExtractors() {
112
+ return plugins.flatMap((plugin) => plugin.sites ?? []);
113
+ }
114
+ function allResourceExtractors() {
115
+ return plugins.flatMap((plugin) => plugin.resources ?? []);
116
+ }
117
+ // Whether reading `fact` needs a rendered page: `browser.*`, or the key of a browser-mode extractor.
118
+ export function isBrowserFact(fact) {
119
+ const root = fact.split(".", 1)[0];
120
+ return root === "browser" || allExtractors().some((extractor) => extractor.mode === "browser" && extractor.id === root);
121
+ }
122
+ // Whether reading `fact` needs an expensive extractor, whose facts only the group’s sample carries.
123
+ export function isSampledFact(fact) {
124
+ const root = fact.split(".", 1)[0];
125
+ return allExtractors().some((extractor) => extractor.cost === "expensive" && extractor.id === root);
126
+ }
127
+ // Adds a plugin; a rule, preset or extractor name already taken is a config error.
128
+ function register(plugin) {
129
+ const taken = new Set(allExtractors().map((extractor) => extractor.id));
130
+ const shipped = new Set(presetNames());
131
+ const rules = Object.keys(plugin.rules ?? {}).filter((id) => ruleMaker(id));
132
+ const presets = Object.keys(plugin.presets ?? {}).filter((name) => shipped.has(name));
133
+ const extractors = (plugin.extractors ?? []).map((extractor) => extractor.id).filter((id) => taken.has(id));
134
+ const sites = (plugin.sites ?? []).filter((site) => allSiteExtractors().some((other) => other.id === site.id && other.per === site.per)).map((site) => `site.${site.per}s.*.${site.id}`);
135
+ const resources = (plugin.resources ?? []).filter((resource) => allResourceExtractors().some((other) => other.id === resource.id)).map((resource) => `resources.${resource.id}`);
136
+ const formatters = Object.keys(plugin.formatters ?? {})
137
+ .filter((name) => formatNames().includes(name))
138
+ .map((name) => `format ${name}`);
139
+ const sources = (plugin.sources ?? []).filter((source) => allSources().some((other) => other.id === source.id)).map((source) => `source ${source.id}`);
140
+ const keys = plugin.settings && CORE_KEYS.has(plugin.name) ? [`settings key ${plugin.name}`] : [];
141
+ const clash = [...rules, ...presets, ...extractors, ...sites, ...resources, ...formatters, ...sources, ...keys];
142
+ if (clash.length > 0)
143
+ throw new ConfigError(`plugin ${plugin.name}: ${clash.join(", ")} already defined`);
144
+ plugins.push(plugin);
145
+ log.debug({
146
+ plugin: plugin.name,
147
+ rules: Object.keys(plugin.rules ?? {}).length,
148
+ presets: Object.keys(plugin.presets ?? {}),
149
+ extractors: plugin.extractors?.length ?? 0,
150
+ sites: plugin.sites?.length ?? 0,
151
+ resources: plugin.resources?.length ?? 0,
152
+ formatters: Object.keys(plugin.formatters ?? {}),
153
+ sources: (plugin.sources ?? []).map((source) => source.id),
154
+ }, "plugin registered");
155
+ }
156
+ // Imports each named plugin once: a path from the working directory, else a package installed beside spiderlint; then settles every plugin’s settings from `raw`.
157
+ export async function loadPlugins(names, raw = {}) {
158
+ for (const name of names) {
159
+ if (loaded.has(name)) {
160
+ log.debug({ plugin: name }, "plugin bundled or loaded");
161
+ continue;
162
+ }
163
+ const specifier = /^\.{0,2}\//.test(name) ? pathToFileURL(path.resolve(name)).href : name;
164
+ let module;
165
+ try {
166
+ module = (await import(__rewriteRelativeImportExtension(specifier)));
167
+ }
168
+ catch (error) {
169
+ throw new ConfigError(`plugin ${name}: ${error instanceof Error ? error.message : String(error)}`);
170
+ }
171
+ if (typeof module.default?.name !== "string")
172
+ throw new ConfigError(`plugin ${name}: its default export has no name`);
173
+ if (!loaded.has(module.default.name))
174
+ register(module.default);
175
+ loaded.add(name).add(module.default.name);
176
+ log.debug({ plugin: module.default.name, specifier }, "plugin loaded");
177
+ }
178
+ configure(raw);
179
+ }
180
+ // Seeds with each `sources` entry’s URLs appended; one source that does not follow makes them the whole frontier.
181
+ export async function withSources(config) {
182
+ let { seeds, follow } = config;
183
+ for (const entry of config.sources) {
184
+ const [id = "", ...rest] = entry.split(":");
185
+ const argument = rest.join(":");
186
+ const source = allSources().find((candidate) => candidate.id === id);
187
+ if (!source)
188
+ throw new ConfigError(`source ${id}: unknown (known: ${allSources()
189
+ .map((candidate) => candidate.id)
190
+ .join(", ")})`);
191
+ let urls;
192
+ try {
193
+ urls = await source.urls(argument, AbortSignal.timeout(SOURCE_MS));
194
+ }
195
+ catch (error) {
196
+ throw new ConfigError(`source ${entry}: ${error instanceof Error ? error.message : String(error)}`);
197
+ }
198
+ seeds = [...seeds, ...urls];
199
+ follow &&= source.follow !== false;
200
+ log.debug({ source: id, argument, urls: urls.length, follow }, "source read");
201
+ }
202
+ return { ...config, seeds: [...new Set(seeds)], follow };
203
+ }
204
+ // Extractors whose ID is the first key of a fact some rule reads.
205
+ export function extractorsFor(rules) {
206
+ const roots = new Set(rules.flatMap((rule) => rule.meta.facts.map((fact) => fact.split(".", 1)[0])));
207
+ const active = allExtractors().filter((extractor) => roots.has(extractor.id));
208
+ log.debug({ extractors: active.map((extractor) => extractor.id), roots: roots.size }, "extractors chosen");
209
+ return active;
210
+ }
211
+ // Site extractors whose ID sits under `site.origins.*` or `site.hosts.*` in a fact some rule reads.
212
+ export function siteExtractorsFor(rules) {
213
+ const read = new Set(rules.flatMap((rule) => rule.meta.facts.flatMap((fact) => {
214
+ const subject = subjectPath(fact);
215
+ return subject ? [`${subject.kind}\t${subject.id}`] : [];
216
+ })));
217
+ const active = plugins.flatMap((plugin) => (plugin.sites ?? []).filter((extractor) => read.has(`${extractor.per}s\t${extractor.id}`)).map((extractor) => (settings.has(plugin.name) ? { ...extractor, settings: settings.get(plugin.name) } : extractor)));
218
+ log.debug({ extractors: active.map((extractor) => extractor.id), read: read.size }, "site extractors chosen");
219
+ return active;
220
+ }
221
+ // The rule, else the site extractor, that reads pages past the seeds; none when `rules` judge only the seeds’ origins and hosts.
222
+ export function pageReader(rules) {
223
+ const offsite = rules.find((rule) => rule.meta.linked || rule.meta.facts.some((fact) => !fact.startsWith("site.") || fact.startsWith("site.redirects")));
224
+ return offsite?.meta.id ?? siteExtractorsFor(rules).find((extractor) => extractor.crawled)?.id;
225
+ }
226
+ // IDs of the `per: host` site extractors a `linked` rule reads, which then also run on linked hosts.
227
+ export function linkedSiteExtractors(rules) {
228
+ const ids = new Set(rules
229
+ .filter((rule) => rule.meta.linked)
230
+ .flatMap((rule) => rule.meta.facts.flatMap((fact) => {
231
+ const subject = subjectPath(fact);
232
+ return subject?.kind === "hosts" ? [subject.id] : [];
233
+ })));
234
+ log.debug({ extractors: [...ids] }, "site extractors judging linked hosts");
235
+ return ids;
236
+ }
237
+ // Resource extractors whose ID is the second key of a `resources.<id>` fact some rule reads.
238
+ export function resourceExtractorsFor(rules) {
239
+ const read = new Set(rules.flatMap((rule) => rule.meta.facts.flatMap((fact) => {
240
+ const [root, id] = fact.split(".", 2);
241
+ return root === "resources" && id ? [id] : [];
242
+ })));
243
+ const active = allResourceExtractors().filter((extractor) => read.has(extractor.id));
244
+ log.debug({ extractors: active.map((extractor) => extractor.id), read: read.size }, "resource extractors chosen");
245
+ return active;
246
+ }
247
+ // What keys `extractor` beyond the body: its `inputs` and, rendered, the browser; undefined when one is unknown.
248
+ function inputsOf(extractor, page, live) {
249
+ const browser = live?.context().browser();
250
+ const parts = extractor.mode === "browser" ? [browser ? `${browser.browserType().name()} ${browser.version()}` : undefined] : [];
251
+ const inputs = extractor.inputs ?? [];
252
+ for (const input of inputs)
253
+ parts.push(input === "resources" ? live && loadedDigest(live) : [page.http.headers.link ?? ""].flat().join(", "));
254
+ return parts.includes(undefined) ? undefined : parts.join("\0");
255
+ }
256
+ // Each extractor’s facts under its ID, through `cache`, returning the IDs that added some; one that throws, or needs a `live` page it lacks, adds nothing.
257
+ export async function extract(page, body, active, cache, live, context) {
258
+ const kind = `${page.http["content-type"]}${page.http.size.truncated ? " truncated" : ""}`;
259
+ const added = [];
260
+ for (const extractor of active) {
261
+ if (!live && extractor.mode === "browser") {
262
+ log.debug({ url: page.url.href, extractor: extractor.id }, "extractor needs a rendered page");
263
+ continue;
264
+ }
265
+ try {
266
+ progressStep(page.url.href, extractor.id);
267
+ const running = performance.now();
268
+ const inputs = inputsOf(extractor, page, live);
269
+ if (inputs === undefined)
270
+ log.debug({ url: page.url.href, extractor: extractor.id, inputs: extractor.inputs }, "extractor inputs unknown, run uncached");
271
+ const keyed = inputs === undefined ? { ...extractor, cached: false } : extractor;
272
+ const value = await inSpan(`extract ${extractor.id}`, { "spiderlint.extractor": extractor.id, "url.full": page.url.href }, async () => cache.run(keyed, page.url.href, inputs ? `${kind}\0${inputs}` : kind, body, () => extractor.extract(page, body, live, context)));
273
+ const ms = Math.round(performance.now() - running);
274
+ recordExtractor(extractor.id, ms);
275
+ log.debug({ url: page.url.href, extractor: extractor.id, ms, isEmpty: value === undefined }, "extractor ran");
276
+ if (value === undefined)
277
+ continue;
278
+ page[extractor.id] = value;
279
+ added.push(extractor.id);
280
+ }
281
+ catch (error) {
282
+ log.warn({ url: page.url.href, extractor: extractor.id, error: error instanceof Error ? error.message : String(error) }, `${extractor.id} checks skipped on`);
283
+ }
284
+ }
285
+ return added;
286
+ }