spiderlint 0.143.0 → 0.143.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +2 -2
  2. package/dist/agent.js +2 -2
  3. package/dist/cli.js +3 -3
  4. package/dist/crawl/browser.js +2 -2
  5. package/dist/crawl/rdap.js +1 -1
  6. package/dist/crawl/resolve.js +1 -1
  7. package/dist/crawl/resources.js +3 -2
  8. package/dist/facts/resources.js +2 -2
  9. package/dist/facts/transport.js +5 -5
  10. package/dist/facts/vendors.js +1 -1
  11. package/dist/i18n.js +2 -2
  12. package/dist/plugins/axe.js +3 -3
  13. package/dist/plugins/cookies.js +4 -3
  14. package/dist/plugins/css.js +3 -3
  15. package/dist/plugins/dns.js +10 -9
  16. package/dist/plugins/feed-model.js +2 -2
  17. package/dist/plugins/feeds.js +19 -19
  18. package/dist/plugins/html-validate.js +3 -3
  19. package/dist/plugins/htmlhint.js +3 -3
  20. package/dist/plugins/icons.js +12 -11
  21. package/dist/plugins/images.js +2 -3
  22. package/dist/plugins/index.js +1 -1
  23. package/dist/plugins/keyboard.js +2 -3
  24. package/dist/plugins/lighthouse.js +3 -3
  25. package/dist/plugins/link-text.js +3 -4
  26. package/dist/plugins/list.js +2 -3
  27. package/dist/plugins/live.js +2 -3
  28. package/dist/plugins/mail.js +13 -12
  29. package/dist/plugins/manifest.js +2 -3
  30. package/dist/plugins/markup.js +5 -6
  31. package/dist/plugins/network.js +4 -4
  32. package/dist/plugins/origin.js +3 -3
  33. package/dist/plugins/profile-links.js +3 -3
  34. package/dist/plugins/sshfp.js +6 -6
  35. package/dist/plugins/stack.js +2 -2
  36. package/dist/plugins/structured-data.js +4 -5
  37. package/dist/plugins/tls-probe.js +3 -3
  38. package/dist/plugins/trackers.js +2 -3
  39. package/dist/plugins/well-known.js +3 -3
  40. package/dist/report/csv.js +2 -2
  41. package/dist/report/human.js +5 -5
  42. package/dist/report/index.js +2 -3
  43. package/dist/rules/builtin.js +1 -1
  44. package/dist/rules/catalog.js +2 -2
  45. package/dist/rules/declarative.js +12 -12
  46. package/dist/rules/i18n.js +1 -1
  47. package/dist/rules/insights.js +1 -1
  48. package/dist/rules/robots.js +1 -1
  49. package/dist/server/web.js +6 -5
  50. package/package.json +2 -2
package/README.md CHANGED
@@ -57,7 +57,7 @@ It also inherits the features of B19 / Ubuntu — see [Features](docs/FEATURES.m
57
57
 
58
58
  ## What this provides
59
59
 
60
- - **CI action** `damian-buho/spiderlint@0.143.0`
60
+ - **CI action** `damian-buho/spiderlint@0.143.1`
61
61
  - **Container image** `ghcr.io/damian-buho/spiderlint:latest`
62
62
  - **Container image** `damianbuho/spiderlint:latest`
63
63
  - **npm package** `spiderlint` — command `spiderlint`
@@ -114,7 +114,7 @@ Needs Node.js >=26.
114
114
  Run it as a step in a GitHub Actions workflow:
115
115
 
116
116
  ```yaml
117
- - uses: damian-buho/spiderlint@0.143.0
117
+ - uses: damian-buho/spiderlint@0.143.1
118
118
  ```
119
119
 
120
120
  The action takes these inputs:
package/dist/agent.js CHANGED
@@ -10,8 +10,8 @@ const CHROME_MAJOR = "147";
10
10
  // The instance a web scan runs on, from its request Host header; empty on the CLI.
11
11
  const SCAN = { via: "" };
12
12
  // The instance `setVia` keeps, without port, path, casing or brackets; garbage becomes empty.
13
- export function normalizeVia(raw) {
14
- const host = (raw ?? "").trim().toLowerCase().split("/", 1)[0] ?? "";
13
+ export function normalizeVia(raw = "") {
14
+ const host = raw.trim().toLowerCase().split("/", 1)[0] ?? "";
15
15
  const bare = host.startsWith("[") ? (host.slice(1).split("]", 1)[0] ?? "") : (host.split(":", 1)[0] ?? "");
16
16
  return /^[a-z0-9:]([a-z0-9.:-]*[a-z0-9])?\.?$/.test(bare) ? bare.replace(/\.$/, "") : "";
17
17
  }
package/dist/cli.js CHANGED
@@ -49,8 +49,8 @@ function severity(name, text) {
49
49
  });
50
50
  }
51
51
  // A --name and --no-name pair, shown as one --[no-]name row.
52
- function toggle(name, text, fallback, environment) {
53
- return [Object.assign(flag(`--${name}`, text, fallback, environment), { flags: `--[no-]${name}` }), new Option(`--no-${name}`).hideHelp()];
52
+ function toggle(name, text, environment) {
53
+ return [Object.assign(flag(`--${name}`, text, "auto", environment), { flags: `--[no-]${name}` }), new Option(`--no-${name}`).hideHelp()];
54
54
  }
55
55
  // Options under one help heading.
56
56
  function section(title, options) {
@@ -323,7 +323,7 @@ function program(act) {
323
323
  root.usage("<command> [domain…] [options]").description(DOMAIN);
324
324
  root.addOption(flag("--config <path>", "settings file", "projectfile.yaml", "SPIDERLINT_CONFIG"));
325
325
  root.addOption(repeatable("--site <names>", "only these org.spiderlint.sites, repeatable", "all"));
326
- for (const option of [...toggle("color", "force or disable color", "auto", "NO_COLOR, FORCE_COLOR"), ...toggle("progress", "status line on an interactive stderr", "auto")])
326
+ for (const option of [...toggle("color", "force or disable color", "NO_COLOR, FORCE_COLOR"), ...toggle("progress", "status line on an interactive stderr")])
327
327
  root.addOption(option);
328
328
  root.addOption(flag("--log-level <level>", "trace, debug, info, warn, error or silent", "warn", "SPIDERLINT_LOG_LEVEL"));
329
329
  root.version(VERSION, "-V, --version", "show the version");
@@ -101,8 +101,8 @@ async function watchProtocols(page, seen) {
101
101
  }
102
102
  }
103
103
  // `type/subtype` and charset of a Content-Type header; an unparsable one is its raw essence.
104
- function contentTypeOf(header) {
105
- const raw = [header ?? ""].flat()[0] ?? "";
104
+ function contentTypeOf(header = "") {
105
+ const raw = [header].flat()[0] ?? "";
106
106
  try {
107
107
  const mime = new MIMEType(raw);
108
108
  const charset = mime.params.get("charset");
@@ -80,7 +80,7 @@ export async function registration(domain, get) {
80
80
  const body = JSON.parse(answer.body);
81
81
  const expires = body.events?.find((event) => event.eventAction === "expiration")?.eventDate;
82
82
  const isDated = expires !== undefined && !Number.isNaN(Date.parse(expires));
83
- const nameservers = [...new Set((body.nameservers ?? []).flatMap((server) => (server.ldhName ? [nsName(server.ldhName)] : [])))].toSorted((a, b) => a.localeCompare(b));
83
+ const nameservers = [...new Set((body.nameservers ?? []).flatMap((server) => (server.ldhName ? nsName(server.ldhName) : [])))].toSorted((a, b) => a.localeCompare(b));
84
84
  log.debug({ domain, url, status: body.status, expires, nameservers: nameservers.length }, "rdap answered");
85
85
  return { server: base, ...(Array.isArray(body.status) && { status: body.status.map((entry) => String(entry).toLowerCase()) }), ...(isDated && { expires: new Date(expires).toISOString() }), nameservers };
86
86
  }
@@ -32,7 +32,7 @@ function notFound(hostname, why) {
32
32
  async function addressesOf(client, hostname, family) {
33
33
  const types = family === 4 ? ["A"] : family === 6 ? ["AAAA"] : ["A", "AAAA"];
34
34
  const replies = await Promise.all(types.map((type) => client.query(hostname, type)));
35
- return replies.flatMap((reply) => reply.answers.flatMap((record) => (record.type === "A" || record.type === "AAAA" ? [{ address: record.data, family: record.type === "A" ? 4 : 6 }] : [])));
35
+ return replies.flatMap((reply) => reply.answers.flatMap((record) => (record.type === "A" || record.type === "AAAA" ? { address: record.data, family: record.type === "A" ? 4 : 6 } : [])));
36
36
  }
37
37
  // `dns.lookup` overloads (`(host, cb)`, `(host, family, cb)`) normalised to the three-argument form `lookup` takes.
38
38
  function overloaded(lookup) {
@@ -51,8 +51,9 @@ async function drain(response, max, isKept = false) {
51
51
  return { bytes, ...(isKept && { body: Buffer.concat(chunks) }) };
52
52
  }
53
53
  // The media type of a content-type header, lower-cased.
54
- function mediaType(raw) {
55
- return (String(raw ?? "")
54
+ function mediaType(raw = "") {
55
+ const text = raw;
56
+ return (String(text ?? "")
56
57
  .split(";", 1)[0]
57
58
  ?.trim()
58
59
  .toLowerCase() ?? "");
@@ -11,8 +11,8 @@ const SOURCES = [
11
11
  ['link[rel~="manifest"][href]', "manifest", "href"],
12
12
  ];
13
13
  // Every candidate URL of a `srcset`, descriptors dropped.
14
- function srcset(raw) {
15
- return (raw ?? "")
14
+ function srcset(raw = "") {
15
+ return raw
16
16
  .split(",")
17
17
  .map((entry) => entry.trim().split(/\s+/, 1)[0] ?? "")
18
18
  .filter((entry) => entry.length > 0);
@@ -114,8 +114,8 @@ export function redactHeaders(headers) {
114
114
  return out;
115
115
  }
116
116
  // `DNS:a, IP Address:b` to `[a, b]`.
117
- function subjectAltNames(raw) {
118
- return (raw ?? "")
117
+ function subjectAltNames(raw = "") {
118
+ return raw
119
119
  .split(",")
120
120
  .map((entry) => entry.trim().replace(/^[^:]+:/, ""))
121
121
  .filter((entry) => entry.length > 0);
@@ -189,10 +189,10 @@ export function servedVersions(pages) {
189
189
  for (const page of pages) {
190
190
  if (page.http.version === undefined || page.http.version === "3.0" || !quic.has(page.url.origin))
191
191
  continue;
192
- const { hostname, port } = new URL(page.url.href);
193
- const own = new Set([`:${port || "443"}`, `${hostname}:${port || "443"}`]);
192
+ const url = new URL(page.url.href);
193
+ const own = new Set([`:${url.port || "443"}`, `${url.hostname}:${url.port || "443"}`]);
194
194
  const offers = page.http.parsed?.["alt-svc"]?.value;
195
- const isAdvertised = Array.isArray(offers) && offers.some((offer) => offer.protocol === "h3" && own.has(offer.authority ?? ""));
195
+ const isAdvertised = Array.isArray(offers) && offers.some(({ protocol, authority = "" }) => protocol === "h3" && own.has(authority));
196
196
  log.debug({ url: page.url.href, observed: page.http.version, isAdvertised }, "http version of a page carried over tcp");
197
197
  if (isAdvertised)
198
198
  page.http.version = "3.0";
@@ -42,7 +42,7 @@ export function vendorFacts(pages, site, isOn) {
42
42
  const anchors = page.html ? [...page.html.links.internal, ...page.html.links.external] : [];
43
43
  const linked = anchors.flatMap((href) => {
44
44
  const entry = vendorPath(href, "page");
45
- return entry ? [{ href, entry }] : [];
45
+ return entry ? { href, entry } : [];
46
46
  });
47
47
  const loads = [...(page.resources ?? []).map((resource) => resource.url), ...(page.html?.scripts ?? []).flatMap((script) => script.src ?? [])];
48
48
  const loaded = loads.flatMap((href) => vendorPath(href, "resource") ?? []);
package/dist/i18n.js CHANGED
@@ -29,8 +29,8 @@ function catalog(lang) {
29
29
  return entries;
30
30
  }
31
31
  // The language tags of an `Accept-Language` header or POSIX locale (`uk_UA.UTF-8`) with a positive q-value, best first.
32
- function ranked(accept) {
33
- const parts = (accept ?? "").split(",").map((part) => {
32
+ function ranked(accept = "") {
33
+ const parts = accept.split(",").map((part) => {
34
34
  const [tag = "", ...parameters] = part.trim().split(";");
35
35
  const q = Number(parameters
36
36
  .map((parameter) => parameter.trim())
@@ -5,7 +5,6 @@ import { AxeBuilder } from "@axe-core/playwright";
5
5
  import axeCore from "axe-core";
6
6
  import { log } from "../logger.js";
7
7
  import { said } from "../rules/message.js";
8
- import { definePlugin } from "./types.js";
9
8
  const ID = "axe";
10
9
  const PREFIX = "axe/";
11
10
  const WCAG = new Set(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]);
@@ -92,7 +91,8 @@ async function extract(page, _body, live) {
92
91
  log.debug({ url: page.url.href, violations: facts.violations.length, incomplete: facts.incomplete.length, passes: results.passes.length }, "axe analysed");
93
92
  return facts;
94
93
  }
95
- export default definePlugin({
94
+ // eslint-disable-next-line unicorn/no-top-level-side-effects -- preset() assembles plain rule data, no IO
95
+ export default {
96
96
  name: "axe",
97
97
  extractors: [{ id: ID, mode: "browser", cost: "expensive", inputs: ["resources"], extract }],
98
98
  rules: Object.fromEntries(RULES.map((entry) => [`${PREFIX}${entry.ruleId}`, rule(entry.ruleId, entry.helpUrl.replace(/\?.*$/, ""), entry.help)])),
@@ -101,4 +101,4 @@ export default definePlugin({
101
101
  "axe:wcag": preset("WCAG 2.2 A and AA success criteria axe-core can test", isWcag),
102
102
  "axe:best-practice": preset("axe-core best practices beyond WCAG", (tags) => tags.includes("best-practice")),
103
103
  },
104
- });
104
+ };
@@ -8,7 +8,7 @@ import { log } from "../logger.js";
8
8
  import { resourceRule } from "../rules/builtin.js";
9
9
  import { said } from "../rules/message.js";
10
10
  import { TRACKING_COOKIES } from "./cookies-registry.js";
11
- import { definePlugin } from "./types.js";
11
+ import {} from "./types.js";
12
12
  import { visit } from "./visit.js";
13
13
  // Every hand-kept tracking cookie name as one anchored pattern, `*` as any suffix.
14
14
  const TRACKING = `^(?:${Object.values(TRACKING_COOKIES)
@@ -148,7 +148,8 @@ const STORAGE_BEFORE_CONSENT = {
148
148
  docs: "https://eur-lex.europa.eu/eli/dir/2002/58/art_5/oj",
149
149
  };
150
150
  // One set of cookie expectations over the page’s Set-Cookie, each resource’s, and what scripts write through `document.cookie`.
151
- export default definePlugin({
151
+ // eslint-disable-next-line unicorn/no-top-level-side-effects -- Object.fromEntries() assembles plain rule data, no IO
152
+ export default {
152
153
  name: "cookies",
153
154
  extractors: [consent],
154
155
  rules: RESOURCES,
@@ -165,4 +166,4 @@ export default definePlugin({
165
166
  rules: { "cookies/before-consent": BEFORE_CONSENT, "cookies/storage-before-consent": STORAGE_BEFORE_CONSENT },
166
167
  },
167
168
  },
168
- });
169
+ };
@@ -7,7 +7,6 @@ import { Parser } from "htmlparser2";
7
7
  import { log } from "../logger.js";
8
8
  import { pageRule, resourceRule } from "../rules/builtin.js";
9
9
  import { said } from "../rules/message.js";
10
- import { definePlugin } from "./types.js";
11
10
  const ID = "css";
12
11
  const require = createRequire(import.meta.url);
13
12
  // Messages one style sheet or page keeps.
@@ -304,7 +303,8 @@ const inlineUnsupported = (severity, settings) => targeted(pageRule("css/inline-
304
303
  const judged = facts && unsupportedIn(facts, targets);
305
304
  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
305
  }, UNSUPPORTED)(severity), settings);
307
- export default definePlugin({
306
+ // eslint-disable-next-line unicorn/no-top-level-side-effects -- kindRules() assembles plain rule data, no IO
307
+ export default {
308
308
  name: "css",
309
309
  settings: SETTINGS,
310
310
  extractors: [{ id: ID, extract: extractInline }],
@@ -325,4 +325,4 @@ export default definePlugin({
325
325
  },
326
326
  },
327
327
  },
328
- });
328
+ };
@@ -10,7 +10,7 @@ import { nsName, registration } from "../crawl/rdap.js";
10
10
  import { parseSvcb } from "../crawl/svcb.js";
11
11
  import { dnsProvider } from "../facts/vendors.js";
12
12
  import { log } from "../logger.js";
13
- import { definePlugin } from "./types.js";
13
+ import {} from "./types.js";
14
14
  // Suffixes of special-use names, never queried.
15
15
  const SPECIAL_USE = ["localhost", "test", "invalid", "example", "local", "onion", "i2p", "alt", "internal", "home.arpa"];
16
16
  // RFC 8624 §3.1: DNSKEY algorithms that MUST NOT sign, and DS digests that MUST NOT be used.
@@ -60,14 +60,14 @@ export function records(reply, type) {
60
60
  export function texts(reply) {
61
61
  return records(reply, "TXT").map(({ data }) => (Array.isArray(data) ? data : [data]).map((chunk) => chunk.toString("utf8")).join(""));
62
62
  }
63
- // Parsed SVCB-shaped records of `type` (`UNKNOWN_64` SVCB, `UNKNOWN_65` HTTPS); an unparsable one warns and is dropped.
64
- function services(host, reply, type) {
65
- return records(reply, type).flatMap(({ data }) => {
63
+ // Parsed HTTPS-shaped records (`UNKNOWN_65`); an unparsable one warns and is dropped.
64
+ function services(host, reply) {
65
+ return records(reply, "UNKNOWN_65").flatMap(({ data }) => {
66
66
  try {
67
67
  return [parseSvcb(data)];
68
68
  }
69
69
  catch (error) {
70
- log.warn({ host, type, error: reason(error) }, `${type} record of ${host} unparsable:`);
70
+ log.warn({ host, type: "UNKNOWN_65", error: reason(error) }, `UNKNOWN_65 record of ${host} unparsable:`);
71
71
  return [];
72
72
  }
73
73
  });
@@ -158,7 +158,7 @@ const addresses = {
158
158
  const [a, aaaa, https, sale, agents, apex] = await Promise.all([context.dns.query(host, "A"), context.dns.query(host, "AAAA"), context.dns.query(host, "UNKNOWN_65"), context.dns.query(`_for-sale.${zone}`, "TXT"), context.dns.query(`_index._agents.${zone}`, "UNKNOWN_65"), context.dns.query(zone, "CNAME")]);
159
159
  const v4 = records(a, "A").map(({ data, ttl }) => ({ address: data, ttl }));
160
160
  const v6 = records(aaaa, "AAAA").map(({ data, ttl }) => ({ address: data, ttl }));
161
- const hinted = services(host, https, "UNKNOWN_65").map((record) => {
161
+ const hinted = services(host, https).map((record) => {
162
162
  const isHinted = record.ipv4hint !== undefined || record.ipv6hint !== undefined;
163
163
  return isHinted && (record.target === "." || isSameName(record.target, host))
164
164
  ? {
@@ -169,7 +169,7 @@ const addresses = {
169
169
  : record;
170
170
  });
171
171
  const forSale = texts(sale);
172
- const agentServices = services(host, agents, "UNKNOWN_65");
172
+ const agentServices = services(host, agents);
173
173
  const authorised = await caa(host, zone, context.dns);
174
174
  const allowed = authorised && issuer(host, authorised, context.pages);
175
175
  const broken = dangling(host, a);
@@ -851,7 +851,8 @@ const RECOMMENDED = [...CORE, "domain/expiry", "domain/expiring", "domain/lock",
851
851
  // The `RULES` entries named by `ids`.
852
852
  const pick = (ids) => Object.fromEntries(ids.map((id) => [id, RULES[id]]));
853
853
  // Records, CAA, DNSSEC and name servers of every crawled host, asked of the configured resolver.
854
- export default definePlugin({
854
+ // eslint-disable-next-line unicorn/no-top-level-side-effects -- pick() assembles plain rule data, no IO
855
+ export default {
855
856
  name: "dns",
856
857
  settings: { type: "object", additionalProperties: false, properties: { compare: { type: "array", items: { type: "string", minLength: 1 }, default: [] }, rdap: { type: "boolean", default: true } } },
857
858
  sites: [addresses, dnssec, nameservers, resolvers, rdap],
@@ -861,4 +862,4 @@ export default definePlugin({
861
862
  "dns:core": { description: "HTTPS record, CAA and its iodef contact, DNSSEC state and dangling CNAMEs, a handful of queries per host", rules: pick(CORE) },
862
863
  "dns:recommended": { description: "dns:core, the registration’s expiry and transfer lock, and name servers open to recursion", rules: pick(RECOMMENDED) },
863
864
  },
864
- });
865
+ };
@@ -82,7 +82,7 @@ const childOf = (node, local, uri = "") => childrenOf(node, local, uri)[0];
82
82
  const textOf = (node) => node?.text.trim() || undefined;
83
83
  const typeOf = (node) => (node?.attributes.type === "html" || node?.attributes.type === "xhtml" ? node.attributes.type : "text");
84
84
  const dated = (node, field) => (textOf(node) ? { field, raw: textOf(node) } : undefined);
85
- const emailsOf = (node, names) => names.flatMap((name) => childrenOf(node, name).flatMap((child) => (textOf(child) ? [{ field: name, raw: textOf(child) }] : [])));
85
+ const emailsOf = (node, names) => names.flatMap((name) => childrenOf(node, name).flatMap((child) => (textOf(child) ? { field: name, raw: textOf(child) } : [])));
86
86
  // Un-namespaced children `known` does not name, once each.
87
87
  const unknownIn = (node, known) => [...new Set((node?.children ?? []).filter((child) => child.uri === "" && !known.has(child.local)).map((child) => child.local))];
88
88
  // Atom links of `node` by relation, the default relation `alternate`, resolved against the element’s base.
@@ -105,7 +105,7 @@ function rssItem(node, position, isRdf) {
105
105
  const ns = isRdf ? NS.rss1 : "";
106
106
  const guid = childOf(node, "guid");
107
107
  const id = textOf(guid) ?? node.attributes["rdf:about"];
108
- const contents = [childOf(node, "description", ns), childOf(node, "encoded", NS.content)].flatMap((child) => (child && child.text.trim() ? [{ field: child.uri === NS.content ? "content:encoded" : "description", type: "html", value: child.text, ...(child.base && { base: child.base }) }] : []));
108
+ const contents = [childOf(node, "description", ns), childOf(node, "encoded", NS.content)].flatMap((child) => (child && child.text.trim() ? { field: child.uri === NS.content ? "content:encoded" : "description", type: "html", value: child.text, ...(child.base && { base: child.base }) } : []));
109
109
  const title = childOf(node, "title", ns);
110
110
  const link = textOf(childOf(node, "link", ns));
111
111
  const missing = [...(!title && !childOf(node, "description", ns) ? ["title or description"] : []), ...(isRdf && !link ? ["link"] : [])];
@@ -10,7 +10,7 @@ import { header, linkTargets, pageRule, resolve } from "../rules/builtin.js";
10
10
  import { said } from "../rules/message.js";
11
11
  import { judgeContent, judgeTitle, wordsOf } from "./feed-content.js";
12
12
  import { NS, readJson, readXml } from "./feed-model.js";
13
- import { definePlugin } from "./types.js";
13
+ import {} from "./types.js";
14
14
  const ID = "feed";
15
15
  const FEED_TYPES = new Set(["application/rss+xml", "application/atom+xml", "application/rdf+xml", "application/feed+json"]);
16
16
  const WRONG_TYPES = new Set(["text/html", "text/plain", "application/octet-stream", ""]);
@@ -243,7 +243,7 @@ async function extract(page, body) {
243
243
  return [];
244
244
  const url = new URL(enclosure.url, page.url.href);
245
245
  url.hash = "";
246
- return /^https?:$/.test(url.protocol) ? [{ url: url.href, ...(enclosure.length !== undefined && { length: enclosure.length }), ...(enclosure.type !== undefined && { type: enclosure.type }), position: item.position }] : [];
246
+ return /^https?:$/.test(url.protocol) ? { url: url.href, ...(enclosure.length !== undefined && { length: enclosure.length }), ...(enclosure.type !== undefined && { type: enclosure.type }), position: item.position } : [];
247
247
  }));
248
248
  const image = model.itunes.image ? resolve(model.itunes.image, page.url.href) : undefined;
249
249
  const itunesImage = image && URL.canParse(image) && /^https?:$/.test(new URL(image).protocol) ? new URL(image).href : undefined;
@@ -335,7 +335,7 @@ const enclosure = pageRule("feeds/enclosure", [`${ID}.enclosures`, "resources"],
335
335
  if (!answer)
336
336
  return [];
337
337
  if (answer.status < 200 || answer.status > 299)
338
- return [`item ${position} ${url} answers ${answer.status}`];
338
+ return `item ${position} ${url} answers ${answer.status}`;
339
339
  const faults = [];
340
340
  const length = firstHeader(answer.headers["content-length"]);
341
341
  if (length !== undefined && declaredLength !== undefined && length !== declaredLength)
@@ -503,8 +503,8 @@ function target(entry, index, site) {
503
503
  const itemStatus = siteRule("feeds/item-status", [`${ID}.entries`, "http.status", "site.redirects"], "links items to pages that fail or redirect", (feed, _page, index, site) => (feed.entries ?? []).flatMap((entry) => {
504
504
  const { page, landing } = target(entry, index, site);
505
505
  if (landing)
506
- return [`item ${entry.position} ${entry.link} redirects to ${landing}`];
507
- return page && !isOk(page) ? [`item ${entry.position} ${entry.link} answers ${page.http.status}`] : [];
506
+ return `item ${entry.position} ${entry.link} redirects to ${landing}`;
507
+ return page && !isOk(page) ? `item ${entry.position} ${entry.link} answers ${page.http.status}` : [];
508
508
  }), { docs: DOCS.rss, fix: "Point each item link at the final URL of a page that answers 200." });
509
509
  const itemCanonical = siteRule("feeds/item-canonical", [`${ID}.entries`, "html.canonical"], "links items away from their canonical URL", (feed, _page, index, site) => (feed.entries ?? []).flatMap((entry) => {
510
510
  const { page, landing } = target(entry, index, site);
@@ -512,7 +512,7 @@ const itemCanonical = siteRule("feeds/item-canonical", [`${ID}.entries`, "html.c
512
512
  return [];
513
513
  const canonical = resolve(page.html.canonical, page.url.href);
514
514
  const here = [page.url.href, page.url.twin, entry.link].flatMap((href) => (href ? [resolve(href, href)] : []));
515
- return here.includes(canonical) ? [] : [`item ${entry.position} links ${entry.link}, its canonical is ${canonical}`];
515
+ return here.includes(canonical) ? [] : `item ${entry.position} links ${entry.link}, its canonical is ${canonical}`;
516
516
  }), { docs: DOCS.rss, fix: "Link each item to its page’s canonical URL." });
517
517
  const itemTitle = siteRule("feeds/item-title", [`${ID}.entries`, "html.title", "html.property"], "titles items unlike the pages they link", (feed, _page, index, site) => (feed.entries ?? []).flatMap((entry) => {
518
518
  const { page, landing } = target(entry, index, site);
@@ -521,21 +521,21 @@ const itemTitle = siteRule("feeds/item-title", [`${ID}.entries`, "html.title", "
521
521
  const titles = [page.html.title, page.html.property["og:title"]].flatMap((title) => (title ? [normal(title)] : []));
522
522
  // A `Post: ` style label a mixed feed puts before the title is not part of the page’s title.
523
523
  const wanted = [normal(entry.title), normal(entry.title.replace(/^[^:]{1,30}:\s+/, ""))];
524
- return titles.length === 0 || titles.some((title) => wanted.some((text) => title.includes(text))) ? [] : [`item ${entry.position} “${entry.title}” against “${page.html.title ?? page.html.property["og:title"]}”`];
524
+ return titles.length === 0 || titles.some((title) => wanted.some((text) => title.includes(text))) ? [] : `item ${entry.position} “${entry.title}” against “${page.html.title ?? page.html.property["og:title"]}”`;
525
525
  }), { docs: DOCS.rss, fix: "Use the page’s own title as the item title." });
526
526
  const itemDate = siteRule("feeds/item-date", [`${ID}.entries`, "html.published"], "dates items unlike the pages they link", (feed, _page, index, site) => (feed.entries ?? []).flatMap((entry) => {
527
527
  const { page, landing } = target(entry, index, site);
528
528
  const published = landing ? undefined : page?.html?.published;
529
529
  if (!published || !entry.published || Number.isNaN(Date.parse(published)))
530
530
  return [];
531
- return Math.abs(Date.parse(published) - Date.parse(entry.published)) > DAY_MS ? [`item ${entry.position} ${entry.published}, the page says ${published}`] : [];
531
+ return Math.abs(Date.parse(published) - Date.parse(entry.published)) > DAY_MS ? `item ${entry.position} ${entry.published}, the page says ${published}` : [];
532
532
  }), { docs: DOCS.rss, fix: "Take the item date from the same field the page’s `article:published_time` or `datePublished` comes from." });
533
533
  // A feed carrying summaries where readers expect full posts: every linked page far longer than its item.
534
534
  const summaryOnly = siteRule("feeds/summary-only", [`${ID}.entries`, "html.text"], "carries summaries while the linked pages say far more", (feed, _page, index, site) => {
535
535
  const judged = (feed.entries ?? []).flatMap((entry) => {
536
536
  const { page, landing } = target(entry, index, site);
537
537
  const pageWords = page?.html?.text;
538
- return landing || !page || !pageWords || entry.words === undefined || !isOk(page) || pageWords < 150 ? [] : [{ entry, words: entry.words, pageWords }];
538
+ return landing || !page || !pageWords || entry.words === undefined || !isOk(page) || pageWords < 150 ? [] : { entry, words: entry.words, pageWords };
539
539
  });
540
540
  const short = judged.filter(({ words, pageWords }) => words < pageWords * 0.3);
541
541
  return short.length === judged.length && judged.length > 0 ? short.map(({ entry, words, pageWords }) => `item ${entry.position}: ${words} words against ${pageWords} on the page`) : [];
@@ -582,8 +582,8 @@ const archive = siteRule("feeds/archive", [`${ID}.archives`, `${ID}.problems.arc
582
582
  if (!linked)
583
583
  return [];
584
584
  if (!isOk(linked))
585
- return [`${relation} ${href} answers ${linked.http.status}`];
586
- return parsed(linked) ? [] : [`${relation} ${href} is not a feed`];
585
+ return `${relation} ${href} answers ${linked.http.status}`;
586
+ return parsed(linked) ? [] : `${relation} ${href} is not a feed`;
587
587
  }),
588
588
  ], { docs: DOCS.archive, fix: "Point `prev-archive`, `next-archive` and `current` at feed documents that answer 200, and drop them from a complete feed." });
589
589
  const ARTWORK_MIN = 1400;
@@ -635,17 +635,17 @@ const feedImages = {
635
635
  const problems = declared.flatMap((url) => {
636
636
  const file = files[url];
637
637
  if (file.error)
638
- return [`${url} does not answer (${file.error})`];
638
+ return `${url} does not answer (${file.error})`;
639
639
  if (file.status < 200 || file.status > 299)
640
- return [`${url} answers ${file.status}`];
640
+ return `${url} answers ${file.status}`;
641
641
  if (!file.format)
642
- return [`${url} is not an image (${file.type || "no type"})`];
642
+ return `${url} is not an image (${file.type || "no type"})`;
643
643
  if (!ARTWORK_TYPES.has(file.format))
644
- return [`${url} is ${file.format}, not JPEG or PNG`];
644
+ return `${url} is ${file.format}, not JPEG or PNG`;
645
645
  if (file.width !== file.height)
646
- return [`${url} is ${file.width}x${file.height}, not square`];
646
+ return `${url} is ${file.width}x${file.height}, not square`;
647
647
  const width = file.width ?? 0;
648
- return width < ARTWORK_MIN || width > ARTWORK_MAX ? [`${url} is ${file.width} px wide, Apple wants ${ARTWORK_MIN}–${ARTWORK_MAX}`] : [];
648
+ return width < ARTWORK_MIN || width > ARTWORK_MAX ? `${url} is ${file.width} px wide, Apple wants ${ARTWORK_MIN}–${ARTWORK_MAX}` : [];
649
649
  });
650
650
  log.debug({ origin, declared: declared.length, problems: problems.length }, "podcast artwork measured");
651
651
  return { declared, files, "itunes-image": problems };
@@ -752,7 +752,7 @@ const RULES = {
752
752
  "feeds/enclosure": enclosure,
753
753
  "feeds/podcast-locked": podcastLocked,
754
754
  };
755
- export default definePlugin({
755
+ export default {
756
756
  name: "feeds",
757
757
  settings: {
758
758
  type: "object",
@@ -833,4 +833,4 @@ export default definePlugin({
833
833
  },
834
834
  },
835
835
  },
836
- });
836
+ };
@@ -5,7 +5,6 @@ import { HtmlValidate, StaticConfigLoader } from "html-validate";
5
5
  import { a11y, document as wholeDocument, recommended, standard } from "html-validate/presets";
6
6
  import { log } from "../logger.js";
7
7
  import { messageRule } from "./messages.js";
8
- import { definePlugin } from "./types.js";
9
8
  const UPSTREAM = { recommended, document: wholeDocument, standard, a11y };
10
9
  const ID = "htmlvalidate";
11
10
  const PREFIX = "html-validate/";
@@ -178,7 +177,8 @@ async function extract(page, body) {
178
177
  return { messages };
179
178
  }
180
179
  const rules = Object.fromEntries(Object.keys(severities(BASE)).map((id) => [`${PREFIX}${id}`, rule(id)]));
181
- export default definePlugin({
180
+ // eslint-disable-next-line unicorn/no-top-level-side-effects -- preset() assembles plain rule data, no IO
181
+ export default {
182
182
  name: "html-validate",
183
183
  extractors: [{ id: ID, extract }],
184
184
  rules,
@@ -188,4 +188,4 @@ export default definePlugin({
188
188
  "html-validate:a11y": preset("Accessibility defects html-validate can see in the markup", ["a11y"]),
189
189
  "html-validate:document": preset("Defects only a whole document has: doctype, labels, heading levels, SRI", ["document"]),
190
190
  },
191
- });
191
+ };
@@ -4,7 +4,6 @@
4
4
  import { HTMLHint } from "htmlhint/dist/core/core.js";
5
5
  import { log } from "../logger.js";
6
6
  import { messageRule } from "./messages.js";
7
- import { definePlugin } from "./types.js";
8
7
  const ID = "htmlhint";
9
8
  const PREFIX = "htmlhint/";
10
9
  const SOURCE = 120;
@@ -84,7 +83,8 @@ function preset(description, levels) {
84
83
  return { description, rules: Object.fromEntries(Object.entries(levels).map(([id, severity]) => [`${PREFIX}${id}`, SCORES[id] === undefined ? severity : { severity, score: SCORES[id] }])) };
85
84
  }
86
85
  const rules = Object.fromEntries(Object.keys(RULESET).map((id) => [`${PREFIX}${id}`, messageRule(ID, PREFIX, id, `https://htmlhint.com/rules/${id}`, DESCRIPTIONS.get(id))]));
87
- export default definePlugin({
86
+ // eslint-disable-next-line unicorn/no-top-level-side-effects -- preset() assembles plain rule data, no IO
87
+ export default {
88
88
  name: "htmlhint",
89
89
  extractors: [{ id: ID, extract }],
90
90
  rules,
@@ -92,4 +92,4 @@ export default definePlugin({
92
92
  htmlhint: preset("Markup defects htmlhint reports by default: unpaired tags, duplicate IDs and attributes, a missing doctype or title", DEFAULT),
93
93
  "htmlhint:extra": preset("Defects htmlhint ships off: missing alt, labels, lang, meta tags, obsolete tags, unsafe attribute characters", EXTRA),
94
94
  },
95
- });
95
+ };
@@ -8,7 +8,7 @@ import { reason } from "../crawl/fetch.js";
8
8
  import { RobotsDisallowed } from "../crawl/probe.js";
9
9
  import { log } from "../logger.js";
10
10
  import { mediaType } from "./origin.js";
11
- import { definePlugin } from "./types.js";
11
+ import {} from "./types.js";
12
12
  const ID = "icons";
13
13
  const APPLE = "180x180";
14
14
  const ICO_SIZES = ["16x16", "32x32"];
@@ -21,7 +21,7 @@ const UNPAINTED = new Set(["none", "transparent", "currentcolor", "inherit"]);
21
21
  // Media types an icon may declare, by the format they name.
22
22
  const TYPES = { "image/png": "png", "image/x-icon": "ico", "image/vnd.microsoft.icon": "ico", [SVG]: "svg", "image/jpeg": "jpeg", "image/gif": "gif", "image/webp": "webp", "image/avif": "avif" };
23
23
  // The lower-cased tokens of a `rel`, `sizes` or `purpose` value.
24
- const tokens = (value) => (value ?? "").toLowerCase().split(/\s+/).filter(Boolean);
24
+ const tokens = (value = "") => value.toLowerCase().split(/\s+/).filter(Boolean);
25
25
  // The sizes an ICO directory lists, 0 standing for 256.
26
26
  function icoSizes(bytes) {
27
27
  const count = bytes.byteLength >= 6 ? bytes.readUInt16LE(4) : 0;
@@ -173,9 +173,9 @@ function faviconProblems(declared, files, root) {
173
173
  const file = files[url];
174
174
  const problem = unusable(url, file);
175
175
  if (problem)
176
- return [problem];
176
+ return problem;
177
177
  const missing = file?.format === "ico" ? ICO_SIZES.filter((size) => !file.sizes?.includes(size)) : [];
178
- return missing.length > 0 ? [`${url} holds ${file?.sizes?.join(", ") || "no image"}, not ${ICO_SIZES.join(" and ")}`] : [];
178
+ return missing.length > 0 ? `${url} holds ${file?.sizes?.join(", ") || "no image"}, not ${ICO_SIZES.join(" and ")}` : [];
179
179
  });
180
180
  }
181
181
  // SVG favicon: one linked with its media type, parsing as XML.
@@ -187,10 +187,10 @@ function svgProblems(declared, files) {
187
187
  const file = files[icon.url];
188
188
  const problem = unusable(icon.url, file);
189
189
  if (problem)
190
- return [problem];
190
+ return problem;
191
191
  if (file?.xml === false)
192
- return [`${icon.url} does not parse as XML`];
193
- return icon.type === SVG ? [] : [`${icon.url} is linked without type="${SVG}"`];
192
+ return `${icon.url} does not parse as XML`;
193
+ return icon.type === SVG ? [] : `${icon.url} is linked without type="${SVG}"`;
194
194
  });
195
195
  }
196
196
  // Apple touch icon: linked, or at /apple-touch-icon.png for pages linking none; an opaque PNG, one of them 180×180.
@@ -210,7 +210,7 @@ function appleProblems(declared, files, root, unlinked) {
210
210
  }
211
211
  const sizes = judged.map((icon) => ("sizes" in icon && icon.sizes ? tokens(icon.sizes)[0] : files[icon.url]?.sizes?.[0]));
212
212
  if (judged.length > 0 && !sizes.includes(APPLE))
213
- problems.push(`no apple-touch-icon is ${APPLE} (got ${sizes.map((size) => size ?? "unknown").join(", ")})`);
213
+ problems.push(`no apple-touch-icon is ${APPLE} (got ${sizes.map((size = "unknown") => size).join(", ")})`);
214
214
  return problems;
215
215
  }
216
216
  // Safari pinned tab: a single-colour SVG with a `color`, judged only when linked.
@@ -220,7 +220,7 @@ function maskProblems(declared, files) {
220
220
  .flatMap((icon) => {
221
221
  const problem = unusable(icon.url, files[icon.url]);
222
222
  if (problem)
223
- return [problem];
223
+ return problem;
224
224
  const colours = files[icon.url]?.colours ?? 0;
225
225
  return [...(files[icon.url]?.format === "svg" ? [] : [`${icon.url} is not an SVG`]), ...(colours > 1 ? [`${icon.url} paints ${colours} colours, not one`] : []), ...(icon.color ? [] : [`${icon.url} is linked without a color`])];
226
226
  });
@@ -283,7 +283,8 @@ function problemRule(check, severity, score, message, documentation, fix) {
283
283
  // eslint-disable-next-line unicorn/no-incorrect-template-string-interpolation -- {got} is the rule message placeholder
284
284
  return { fact: `site.origins.*.${ID}.${check}`, expect: { maxItems: 0 }, message: `${message}: {got}`, severity, score, docs: documentation, fix };
285
285
  }
286
- export default definePlugin({
286
+ // eslint-disable-next-line unicorn/no-top-level-side-effects -- problemRule() assembles plain rule data, no IO
287
+ export default {
287
288
  name: ID,
288
289
  sites: [icons],
289
290
  presets: {
@@ -299,4 +300,4 @@ export default definePlugin({
299
300
  },
300
301
  },
301
302
  },
302
- });
303
+ };
@@ -8,7 +8,6 @@ import { optimize } from "svgo";
8
8
  import { log } from "../logger.js";
9
9
  import { resourceRule } from "../rules/builtin.js";
10
10
  import { said } from "../rules/message.js";
11
- import { definePlugin } from "./types.js";
12
11
  const ID = "images";
13
12
  const TEXT = "text";
14
13
  const FONTS = "fonts";
@@ -230,7 +229,7 @@ const oversized = (severity, settings) => {
230
229
  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
230
  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
231
  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({
232
+ export default {
234
233
  name: "images",
235
234
  settings: SETTINGS,
236
235
  extractors: [{ id: LAYOUT, mode: "browser", cost: "expensive", inputs: ["resources"], extract: async (_page, _body, live) => live && (await live.evaluate(LAYOUT_SCRIPT)) }],
@@ -266,4 +265,4 @@ export default definePlugin({
266
265
  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
266
  },
268
267
  },
269
- });
268
+ };
@@ -212,7 +212,7 @@ export function extractorsFor(rules) {
212
212
  export function siteExtractorsFor(rules) {
213
213
  const read = new Set(rules.flatMap((rule) => rule.meta.facts.flatMap((fact) => {
214
214
  const subject = subjectPath(fact);
215
- return subject ? [`${subject.kind}\t${subject.id}`] : [];
215
+ return subject ? `${subject.kind}\t${subject.id}` : [];
216
216
  })));
217
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
218
  log.debug({ extractors: active.map((extractor) => extractor.id), read: read.size }, "site extractors chosen");
@@ -4,7 +4,6 @@
4
4
  import { log } from "../logger.js";
5
5
  import { pageRule } from "../rules/builtin.js";
6
6
  import { said } from "../rules/message.js";
7
- import { definePlugin } from "./types.js";
8
7
  import { DESCRIBE, visit, withPage } from "./visit.js";
9
8
  const ID = "keyboard";
10
9
  // Tab presses allowed per candidate, beyond the count, and in all.
@@ -175,7 +174,7 @@ const skipLink = pageRule("keyboard/skip-link", [`${ID}.first`], (page) => {
175
174
  return;
176
175
  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
176
  }, { 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({
177
+ export default {
179
178
  name: "keyboard",
180
179
  extractors: [{ id: ID, mode: "browser", cost: "expensive", inputs: ["resources"], extract }],
181
180
  rules: { "keyboard/tab-walk": tabWalk, "keyboard/focus-visible": focusVisible, "keyboard/focus-obscured": focusObscured, "keyboard/forced-focus": forcedFocus, "keyboard/skip-link": skipLink },
@@ -191,4 +190,4 @@ export default definePlugin({
191
190
  },
192
191
  },
193
192
  },
194
- });
193
+ };