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.
- package/README.md +2 -2
- package/dist/agent.js +2 -2
- package/dist/cli.js +3 -3
- package/dist/crawl/browser.js +2 -2
- package/dist/crawl/rdap.js +1 -1
- package/dist/crawl/resolve.js +1 -1
- package/dist/crawl/resources.js +3 -2
- package/dist/facts/resources.js +2 -2
- package/dist/facts/transport.js +5 -5
- package/dist/facts/vendors.js +1 -1
- package/dist/i18n.js +2 -2
- package/dist/plugins/axe.js +3 -3
- package/dist/plugins/cookies.js +4 -3
- package/dist/plugins/css.js +3 -3
- package/dist/plugins/dns.js +10 -9
- package/dist/plugins/feed-model.js +2 -2
- package/dist/plugins/feeds.js +19 -19
- package/dist/plugins/html-validate.js +3 -3
- package/dist/plugins/htmlhint.js +3 -3
- package/dist/plugins/icons.js +12 -11
- package/dist/plugins/images.js +2 -3
- package/dist/plugins/index.js +1 -1
- package/dist/plugins/keyboard.js +2 -3
- package/dist/plugins/lighthouse.js +3 -3
- package/dist/plugins/link-text.js +3 -4
- package/dist/plugins/list.js +2 -3
- package/dist/plugins/live.js +2 -3
- package/dist/plugins/mail.js +13 -12
- package/dist/plugins/manifest.js +2 -3
- package/dist/plugins/markup.js +5 -6
- package/dist/plugins/network.js +4 -4
- package/dist/plugins/origin.js +3 -3
- package/dist/plugins/profile-links.js +3 -3
- package/dist/plugins/sshfp.js +6 -6
- package/dist/plugins/stack.js +2 -2
- package/dist/plugins/structured-data.js +4 -5
- package/dist/plugins/tls-probe.js +3 -3
- package/dist/plugins/trackers.js +2 -3
- package/dist/plugins/well-known.js +3 -3
- package/dist/report/csv.js +2 -2
- package/dist/report/human.js +5 -5
- package/dist/report/index.js +2 -3
- package/dist/rules/builtin.js +1 -1
- package/dist/rules/catalog.js +2 -2
- package/dist/rules/declarative.js +12 -12
- package/dist/rules/i18n.js +1 -1
- package/dist/rules/insights.js +1 -1
- package/dist/rules/robots.js +1 -1
- package/dist/server/web.js +6 -5
- 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.
|
|
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.
|
|
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 =
|
|
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,
|
|
53
|
-
return [Object.assign(flag(`--${name}`, text,
|
|
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", "
|
|
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");
|
package/dist/crawl/browser.js
CHANGED
|
@@ -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
|
|
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");
|
package/dist/crawl/rdap.js
CHANGED
|
@@ -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 ?
|
|
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
|
}
|
package/dist/crawl/resolve.js
CHANGED
|
@@ -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" ?
|
|
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) {
|
package/dist/crawl/resources.js
CHANGED
|
@@ -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
|
-
|
|
54
|
+
function mediaType(raw = "") {
|
|
55
|
+
const text = raw;
|
|
56
|
+
return (String(text ?? "")
|
|
56
57
|
.split(";", 1)[0]
|
|
57
58
|
?.trim()
|
|
58
59
|
.toLowerCase() ?? "");
|
package/dist/facts/resources.js
CHANGED
|
@@ -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
|
|
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);
|
package/dist/facts/transport.js
CHANGED
|
@@ -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
|
|
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
|
|
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((
|
|
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";
|
package/dist/facts/vendors.js
CHANGED
|
@@ -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 ?
|
|
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 =
|
|
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())
|
package/dist/plugins/axe.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|
package/dist/plugins/cookies.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
+
};
|
package/dist/plugins/css.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|
package/dist/plugins/dns.js
CHANGED
|
@@ -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 {
|
|
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
|
|
64
|
-
function services(host, reply
|
|
65
|
-
return records(reply,
|
|
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) },
|
|
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
|
|
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
|
|
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
|
-
|
|
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) ?
|
|
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() ?
|
|
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"] : [])];
|
package/dist/plugins/feeds.js
CHANGED
|
@@ -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 {
|
|
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) ?
|
|
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
|
|
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
|
|
507
|
-
return page && !isOk(page) ?
|
|
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) ? [] :
|
|
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))) ? [] :
|
|
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 ?
|
|
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 ? [] :
|
|
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
|
|
586
|
-
return parsed(linked) ? [] :
|
|
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
|
|
638
|
+
return `${url} does not answer (${file.error})`;
|
|
639
639
|
if (file.status < 200 || file.status > 299)
|
|
640
|
-
return
|
|
640
|
+
return `${url} answers ${file.status}`;
|
|
641
641
|
if (!file.format)
|
|
642
|
-
return
|
|
642
|
+
return `${url} is not an image (${file.type || "no type"})`;
|
|
643
643
|
if (!ARTWORK_TYPES.has(file.format))
|
|
644
|
-
return
|
|
644
|
+
return `${url} is ${file.format}, not JPEG or PNG`;
|
|
645
645
|
if (file.width !== file.height)
|
|
646
|
-
return
|
|
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 ?
|
|
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
|
|
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
|
-
|
|
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
|
+
};
|
package/dist/plugins/htmlhint.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|
package/dist/plugins/icons.js
CHANGED
|
@@ -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 {
|
|
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) =>
|
|
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
|
|
176
|
+
return problem;
|
|
177
177
|
const missing = file?.format === "ico" ? ICO_SIZES.filter((size) => !file.sizes?.includes(size)) : [];
|
|
178
|
-
return missing.length > 0 ?
|
|
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
|
|
190
|
+
return problem;
|
|
191
191
|
if (file?.xml === false)
|
|
192
|
-
return
|
|
193
|
-
return icon.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
|
|
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
|
|
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
|
-
|
|
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
|
+
};
|
package/dist/plugins/images.js
CHANGED
|
@@ -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
|
|
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
|
+
};
|
package/dist/plugins/index.js
CHANGED
|
@@ -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 ?
|
|
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");
|
package/dist/plugins/keyboard.js
CHANGED
|
@@ -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
|
|
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
|
+
};
|