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,328 @@
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 { createRequire } from "node:module";
6
+ import { Parser } from "htmlparser2";
7
+ import { log } from "../logger.js";
8
+ import { pageRule, resourceRule } from "../rules/builtin.js";
9
+ import { said } from "../rules/message.js";
10
+ import { definePlugin } from "./types.js";
11
+ const ID = "css";
12
+ const require = createRequire(import.meta.url);
13
+ // Messages one style sheet or page keeps.
14
+ const MESSAGES_MAX = 200;
15
+ // Characters of the offending source a message quotes.
16
+ const SOURCE_MAX = 80;
17
+ // The rule a declaration list is wrapped in, so it parses as a style sheet; a type selector no caniuse feature matches.
18
+ const WRAPPER = "a{";
19
+ const SETTINGS = { type: "object", additionalProperties: false, properties: { targets: { type: "string", minLength: 1 } } };
20
+ // The parsers once loading; absent until a run’s rules read `css`.
21
+ const loaded = {};
22
+ const load = () => (loaded.libraries ??= Promise.all([import("css-tree"), import("csstree-validator"), import("lightningcss"), import("postcss"), import("doiuse/lib/Detector.js")]));
23
+ // Vendor-prefixed names and values, which a browser without the vendor ignores by design.
24
+ const VENDOR = /(?:^|[\s(,:])-(?:webkit|moz|ms|o|khtml|apple|epub|xv|wap)-/i;
25
+ // Hacks aimed at old Internet Explorer: `\9`, filters and expressions, `*prop` and `_prop`.
26
+ const HACK = /\\9|progid:|expression\(|^[*_]/i;
27
+ // `@page` and its margin boxes, whose descriptors and at-rules css-tree’s grammar lacks.
28
+ const PAGED = new Set(["page", ...["top", "bottom"].flatMap((edge) => [`${edge}-left-corner`, `${edge}-left`, `${edge}-center`, `${edge}-right`, `${edge}-right-corner`]), ...["left", "right"].flatMap((edge) => [`${edge}-top`, `${edge}-middle`, `${edge}-bottom`])]);
29
+ // `env()`, which css-tree matches inside math functions only, as it skips `var()` everywhere.
30
+ const SUBSTITUTED = /(?<![\w-])env\(/i;
31
+ // Legacy aliases judged by the property they alias, whose grammar css-tree keeps current.
32
+ const ALIASES = { "word-wrap": "overflow-wrap" };
33
+ // lightningcss warnings css-tree already reports, with vendor awareness: unknown at-rules and unclosed input.
34
+ const DEFERRED = new Set(["AtRuleInvalid", "EndOfInput"]);
35
+ // The declaration or selector text around `offset`, between the nearest `;`, `{` or `}` on each side.
36
+ function around(css, offset) {
37
+ const start = Math.max(...[";", "{", "}"].map((mark) => css.lastIndexOf(mark, offset - 1))) + 1;
38
+ const ends = [";", "{", "}"].map((mark) => css.indexOf(mark, offset)).filter((index) => index >= 0);
39
+ const text = css
40
+ .slice(start, ends.length > 0 ? Math.min(...ends) : css.length)
41
+ .trim()
42
+ .replaceAll(/\s+/g, " ");
43
+ return text.length > SOURCE_MAX ? `${text.slice(0, SOURCE_MAX)}…` : text;
44
+ }
45
+ // The offset of a 1-based line and column in `css`.
46
+ function offsetOf(css, line, column) {
47
+ let offset = 0;
48
+ for (let at = 1; at < line; at++)
49
+ offset = css.indexOf("\n", offset) + 1;
50
+ return offset + column - 1;
51
+ }
52
+ // A position in a CSS text moved to the document holding it.
53
+ function shift(origin, line, column) {
54
+ return { line: origin.line + line - 1, column: line === 1 ? origin.column + column - 1 : column };
55
+ }
56
+ // Whether a grammar error is a vendor extension, a known hack, or a gap in css-tree’s grammar rather than a defect.
57
+ function isExempt(error, source) {
58
+ const name = error.property ?? error.descriptor ?? "";
59
+ return name.startsWith("-") || HACK.test(name) || error.atrule?.startsWith("-") === true || PAGED.has(error.atrule?.toLowerCase() ?? "") || VENDOR.test(source) || HACK.test(source) || (error.name === "SyntaxMatchError" && SUBSTITUTED.test(source));
60
+ }
61
+ // What a browser drops for a css-tree error: an unknown name is a property, a value outside its grammar a value, the rest a rule or block.
62
+ function kindOf(error) {
63
+ if (error.name === "SyntaxMatchError")
64
+ return "value";
65
+ return /^Unknown (?:property|at-rule descriptor) /.test(error.message) ? "property" : "parse";
66
+ }
67
+ // Whether a node is `@supports` or sits inside it, which guards it from browsers lacking it.
68
+ function isGuarded(node) {
69
+ for (let parent = node; parent; parent = parent.parent)
70
+ if (parent.type === "atrule" && parent.name.toLowerCase() === "supports")
71
+ return true;
72
+ return false;
73
+ }
74
+ // Every caniuse feature doiuse can detect.
75
+ const FEATURE_IDS = () => Object.keys(require("caniuse-lite").features);
76
+ // Grammar, parse and feature facts of one CSS text: a style sheet, or a declaration list when `isDeclarations`.
77
+ async function lint(css, origin, isDeclarations, element) {
78
+ const [csstree, { validate }, { transform }, { default: postcss }, { default: Detector }] = await load();
79
+ const text = isDeclarations ? `${WRAPPER}${css}}` : css;
80
+ const start = isDeclarations ? { line: origin.line, column: origin.column - WRAPPER.length } : origin;
81
+ const messages = [];
82
+ const add = (kind, message, line, column, offset) => {
83
+ messages.push({ kind, message: message.split("\n", 1)[0], ...shift(start, line, column), source: around(text, offset), ...(element && { in: element }) });
84
+ };
85
+ const ast = csstree.parse(text, { positions: true, parseAtrulePrelude: false, parseRulePrelude: false, parseValue: false, parseCustomProperty: false, onParseError: (error) => add("parse", error.message, error.line, error.column, error.offset) });
86
+ csstree.walk(ast, { visit: "Declaration", enter: (node) => void (node.property = ALIASES[node.property.toLowerCase()] ?? node.property) });
87
+ for (const error of validate(ast)) {
88
+ const source = around(text, error.offset);
89
+ const isNoise = isExempt(error, source);
90
+ log.debug({ element, line: error.line, column: error.column, error: error.message, source, isNoise }, "css grammar error");
91
+ if (!isNoise)
92
+ add(kindOf(error), error.message, error.line, error.column, error.offset);
93
+ }
94
+ try {
95
+ const { warnings } = transform({ filename: "style.css", code: Buffer.from(text), errorRecovery: true });
96
+ for (const warning of warnings) {
97
+ const offset = offsetOf(text, warning.loc.line, warning.loc.column);
98
+ const source = around(text, offset);
99
+ const isNoise = DEFERRED.has(warning.type) || VENDOR.test(source) || HACK.test(source);
100
+ log.debug({ element, type: warning.type, line: warning.loc.line, column: warning.loc.column, source, isNoise }, "css parse warning");
101
+ if (!isNoise)
102
+ add("parse", warning.message, warning.loc.line, warning.loc.column, offset);
103
+ }
104
+ }
105
+ catch (error) {
106
+ const loc = error.loc;
107
+ log.debug({ element, loc, error: error instanceof Error ? error.message : String(error) }, "css did not parse");
108
+ if (loc)
109
+ add("parse", error instanceof Error ? error.message : String(error), loc.line, loc.column, offsetOf(text, loc.line, loc.column));
110
+ }
111
+ const unique = new Map(messages.map((message) => [`${message.line}:${message.column}`, message])).values().toArray();
112
+ if (unique.length > MESSAGES_MAX)
113
+ log.debug({ element, messages: unique.length, kept: MESSAGES_MAX }, "css messages cut");
114
+ return { messages: unique.slice(0, MESSAGES_MAX), features: features(postcss, Detector, text, start, element) };
115
+ }
116
+ // Each caniuse feature `text` uses outside `@supports`, first where it appears; none when postcss cannot parse it.
117
+ function features(postcss, Detector, text, start, element) {
118
+ const used = new Map();
119
+ try {
120
+ new Detector(FEATURE_IDS()).process(postcss.parse(text), ({ feature, usage, ignore }) => {
121
+ const at = usage.source?.start;
122
+ if (!at || ignore.includes(feature) || isGuarded(usage))
123
+ return;
124
+ const known = used.get(feature);
125
+ if (known)
126
+ known.count++;
127
+ else
128
+ used.set(feature, { feature, ...shift(start, at.line, at.column), count: 1, ...(element && { in: element }) });
129
+ });
130
+ }
131
+ catch (error) {
132
+ log.debug({ element, error: error instanceof Error ? error.message : String(error) }, "css features not detected");
133
+ }
134
+ return used.values().toArray();
135
+ }
136
+ // A style sheet’s facts.
137
+ async function extractSheet(url, _contentType, body) {
138
+ const facts = await lint(new TextDecoder().decode(body), { line: 1, column: 1 }, false);
139
+ log.debug({ url, messages: facts.messages.length, features: facts.features.length }, "style sheet linted");
140
+ return facts;
141
+ }
142
+ // Each CSS `<style>` block and `style` attribute of an HTML document, with where its text starts.
143
+ function inlineCss(body) {
144
+ const lines = [0, ...body.matchAll(/\n/g).map((match) => match.index + 1)];
145
+ const at = (offset) => {
146
+ const line = lines.findLastIndex((start) => start <= offset);
147
+ return { line: line + 1, column: offset - lines[line] + 1 };
148
+ };
149
+ const found = [];
150
+ let styleStart;
151
+ const parser = new Parser({
152
+ onopentag(name, attributes) {
153
+ const tag = body.slice(parser.startIndex, parser.endIndex + 1);
154
+ const attribute = /\sstyle\s*=\s*["']?/i.exec(tag);
155
+ if (attribute && attributes.style !== undefined)
156
+ found.push({ css: attributes.style, origin: at(parser.startIndex + attribute.index + attribute[0].length), isDeclarations: true, element: `<${name} style>` });
157
+ if (name === "style" && (attributes.type ?? "text/css").toLowerCase() === "text/css")
158
+ styleStart = parser.endIndex + 1;
159
+ },
160
+ onclosetag(name) {
161
+ if (name !== "style" || styleStart === undefined)
162
+ return;
163
+ found.push({ css: body.slice(styleStart, parser.startIndex), origin: at(styleStart), isDeclarations: false, element: "<style>" });
164
+ styleStart = undefined;
165
+ },
166
+ });
167
+ parser.end(body);
168
+ return found;
169
+ }
170
+ // Inline blocks already linted, by a hash of their text and where it starts; cleared past `BLOCKS_MAX`.
171
+ const blocks = new Map();
172
+ const BLOCKS_MAX = 4096;
173
+ // Lints a block once per run, however many pages repeat it at the same spot.
174
+ function lintBlock(block) {
175
+ const hash = createHash("sha256")
176
+ .update(JSON.stringify([block.css, block.origin, block.isDeclarations, block.element]))
177
+ .digest("hex");
178
+ const known = blocks.get(hash);
179
+ log.debug({ element: block.element, line: block.origin.line, hash: hash.slice(0, 12), isReused: known !== undefined }, "inline css block");
180
+ if (known)
181
+ return known;
182
+ if (blocks.size >= BLOCKS_MAX)
183
+ blocks.clear();
184
+ const linted = lint(block.css, block.origin, block.isDeclarations, block.element);
185
+ blocks.set(hash, linted);
186
+ return linted;
187
+ }
188
+ // An HTML page’s inline CSS facts; none for a page without any, or cut at `max-body-size`.
189
+ async function extractInline(page, body) {
190
+ if (!page.html || page.http.size.truncated)
191
+ return undefined;
192
+ const found = inlineCss(body);
193
+ log.debug({ url: page.url.href, blocks: found.length }, "inline css found");
194
+ if (found.length === 0)
195
+ return undefined;
196
+ const linted = await Promise.all(found.map((block) => lintBlock(block)));
197
+ return {
198
+ messages: linted.flatMap((facts) => facts.messages).slice(0, MESSAGES_MAX),
199
+ features: Map.groupBy(linted.flatMap((facts) => facts.features), (used) => used.feature)
200
+ .values()
201
+ .map((uses) => ({ ...uses[0], count: uses.reduce((sum, used) => sum + used.count, 0) }))
202
+ .toArray(),
203
+ };
204
+ }
205
+ // What each kind of message costs the page, its docs and its fix.
206
+ const KINDS = {
207
+ parse: {
208
+ name: "parse-error",
209
+ says: { sheet: "the style sheet has syntax errors, so browsers drop the rule or block each sits in; used by these pages", inline: "inline CSS has syntax errors, so browsers drop the rule or block each sits in" },
210
+ docs: "https://www.w3.org/TR/css-syntax-3/#error-handling",
211
+ fix: 'Close every block and string, and write selectors browsers accept, as in a::before { content: "" }.',
212
+ },
213
+ property: {
214
+ name: "unknown-property",
215
+ says: { sheet: "the style sheet names properties browsers do not know, so they drop those declarations; used by these pages", inline: "inline CSS names properties browsers do not know, so they drop those declarations" },
216
+ docs: "https://developer.mozilla.org/docs/Web/CSS/Reference",
217
+ fix: "Correct the property name, as in color: red for colr: red, or remove the declaration.",
218
+ },
219
+ value: {
220
+ name: "invalid-value",
221
+ says: { sheet: "the style sheet carries values outside a property’s grammar, so browsers drop those declarations; used by these pages", inline: "inline CSS carries values outside a property’s grammar, so browsers drop those declarations" },
222
+ docs: "https://developer.mozilla.org/docs/Web/CSS/CSS_values_and_units/Value_definition_syntax",
223
+ fix: "Give the property a value its grammar accepts, as in margin: calc(-1 * var(--gap)) for margin: -var(--gap).",
224
+ },
225
+ };
226
+ const cssOf = (resource) => resource[ID];
227
+ const isLinted = (_page, resource) => cssOf(resource) !== undefined;
228
+ // One location line: where, in which element, what, and the source it quotes.
229
+ const located = (message) => [`${message.line}:${message.column}`, message.in, message.message, message.source && `— ${message.source}`].filter(Boolean).join(" ");
230
+ // A style sheet rule, keyed by its URL, and its inline twin per page, over the messages of one kind.
231
+ function kindRules(kind) {
232
+ const { name, says, docs, fix } = KINDS[kind];
233
+ const of = (facts) => facts?.messages.filter((message) => message.kind === kind) ?? [];
234
+ const sheet = resourceRule(`css/${name}`, isLinted, (resource) => {
235
+ const hits = of(cssOf(resource));
236
+ return hits.length === 0 ? undefined : { ...said(says.sheet), data: { [resource.url]: { messages: hits.length } }, locations: hits.map((hit) => located(hit)) };
237
+ }, [`resources.${ID}`], (resource) => of(cssOf(resource)), { docs, fix });
238
+ const inline = pageRule(`css/inline-${name}`, [`${ID}.messages`], (page) => {
239
+ const facts = page[ID];
240
+ const hits = of(facts);
241
+ return facts && (hits.length === 0 ? [] : [{ ...said(says.inline), data: { [page.url.href]: { messages: hits.length } }, value: hits, locations: hits.map((hit) => located(hit)) }]);
242
+ }, { docs, fix });
243
+ return { [`css/${name}`]: sheet, [`css/inline-${name}`]: inline };
244
+ }
245
+ // Targets per query, a bad one remembered as none, so each warns once.
246
+ const targetsByQuery = new Map();
247
+ // The declared browser targets: the `targets` setting, else the working directory’s browserslist config; none warns once.
248
+ function targetsOf(settings) {
249
+ const browserslist = require("browserslist");
250
+ const query = settings.targets ?? browserslist.loadConfig({ path: process.cwd() })?.join(", ") ?? "";
251
+ if (!targetsByQuery.has(query)) {
252
+ try {
253
+ targetsByQuery.set(query, query === "" ? undefined : { query, browsers: browserslist(query) });
254
+ }
255
+ catch (error) {
256
+ targetsByQuery.set(query, undefined);
257
+ log.warn({ query, error: error instanceof Error ? error.message : String(error) }, `css/unsupported skipped, the browser targets "${query}" do not parse:`);
258
+ }
259
+ if (query === "")
260
+ log.info({ cwd: process.cwd() }, "css/unsupported skipped: no browser targets, set org.spiderlint.css.targets or add a .browserslistrc");
261
+ }
262
+ return targetsByQuery.get(query);
263
+ }
264
+ // caniuse data per feature, unpacked once.
265
+ const unpacked = new Map();
266
+ // Each browser among `browsers` caniuse marks as lacking `feature`, named with its lowest and highest such version; partial support counts as support.
267
+ function lacking(feature, browsers) {
268
+ const caniuse = require("caniuse-lite");
269
+ if (!unpacked.has(feature))
270
+ unpacked.set(feature, caniuse.features[feature] && caniuse.feature(caniuse.features[feature]));
271
+ const stats = unpacked.get(feature)?.stats ?? {};
272
+ const lacks = browsers.map((browser) => browser.split(" ")).filter(([name, version]) => /^[np]\b/.test(stats[name]?.[version] ?? "y"));
273
+ return Map.groupBy(lacks, ([name]) => name)
274
+ .entries()
275
+ .map(([name, entries]) => {
276
+ const versions = entries.map(([, version]) => version).toSorted((a, b) => a.localeCompare(b, "en", { numeric: true }));
277
+ const [first, last] = [versions[0], versions.at(-1)];
278
+ return `${caniuse.agents[name]?.browser ?? name} ${first}${first === last ? "" : `–${last}`}`;
279
+ })
280
+ .toArray();
281
+ }
282
+ // The features of `facts` the targets lack, one location each.
283
+ function unsupportedIn(facts, targets) {
284
+ const lacks = facts.features.map((used) => ({ used, browsers: lacking(used.feature, targets.browsers) })).filter(({ browsers }) => browsers.length > 0);
285
+ log.debug({ query: targets.query, features: facts.features.length, lacked: lacks.length }, "css features judged");
286
+ const locations = lacks.map(({ used, browsers }) => [`${used.line}:${used.column}`, used.in, unpacked.get(used.feature)?.title ?? used.feature, `— ${browsers.join(", ")}`].filter(Boolean).join(" "));
287
+ return lacks.length === 0 ? undefined : { features: lacks.length, locations };
288
+ }
289
+ const UNSUPPORTED = { docs: "https://developer.mozilla.org/docs/Web/CSS/@supports", fix: "Give the feature a fallback declaration before it, or guard it with @supports (display: grid) { … }." };
290
+ // A rule that runs only once browser targets are declared.
291
+ function targeted(rule, settings) {
292
+ return { meta: rule.meta, check: (...parameters) => (targetsOf(settings) ? rule.check(...parameters) : undefined) };
293
+ }
294
+ // Style sheets using features the declared browser targets lack, keyed by URL.
295
+ const unsupported = (severity, settings) => targeted(resourceRule("css/unsupported", isLinted, (resource) => {
296
+ const targets = targetsOf(settings);
297
+ const judged = unsupportedIn(cssOf(resource), targets);
298
+ return judged && { ...said("the style sheet uses features the browser targets “{query}” lack; used by these pages", { query: targets.query }), data: { [resource.url]: { features: judged.features } }, locations: judged.locations };
299
+ }, [`resources.${ID}`], (resource) => cssOf(resource)?.features, UNSUPPORTED)(severity), settings);
300
+ // Inline CSS using features the declared browser targets lack, per page.
301
+ const inlineUnsupported = (severity, settings) => targeted(pageRule("css/inline-unsupported", [`${ID}.features`], (page) => {
302
+ const facts = page[ID];
303
+ const targets = targetsOf(settings);
304
+ const judged = facts && unsupportedIn(facts, targets);
305
+ return facts && (judged ? [{ ...said("inline CSS uses features the browser targets “{query}” lack", { query: targets.query }), data: { [page.url.href]: { features: judged.features } }, value: facts.features.length, locations: judged.locations }] : []);
306
+ }, UNSUPPORTED)(severity), settings);
307
+ export default definePlugin({
308
+ name: "css",
309
+ settings: SETTINGS,
310
+ extractors: [{ id: ID, extract: extractInline }],
311
+ resources: [{ id: ID, types: ["text/css"], extract: extractSheet }],
312
+ rules: { ...kindRules("parse"), ...kindRules("property"), ...kindRules("value"), "css/unsupported": unsupported, "css/inline-unsupported": inlineUnsupported },
313
+ presets: {
314
+ css: {
315
+ description: "Style sheets and inline CSS as browsers parse them: syntax errors, unknown properties, values outside the grammar, and features the declared browser targets lack",
316
+ rules: {
317
+ "css/parse-error": { severity: "warning", score: 5.6 },
318
+ "css/unknown-property": { severity: "warning", score: 4.6 },
319
+ "css/invalid-value": { severity: "warning", score: 4.4 },
320
+ "css/unsupported": { severity: "info", score: 2.2 },
321
+ "css/inline-parse-error": { severity: "warning", score: 5.2 },
322
+ "css/inline-unknown-property": { severity: "warning", score: 4.2 },
323
+ "css/inline-invalid-value": { severity: "warning", score: 4 },
324
+ "css/inline-unsupported": { severity: "info", score: 1.8 },
325
+ },
326
+ },
327
+ },
328
+ });