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.
- package/LICENSE +235 -0
- package/README.md +244 -0
- package/bin/spiderlint.js +11 -0
- package/dist/agent.js +46 -0
- package/dist/cache/extractors.js +37 -0
- package/dist/cache/http.js +99 -0
- package/dist/cache/index.js +101 -0
- package/dist/cache/purge.js +63 -0
- package/dist/cache/status.js +52 -0
- package/dist/cli.js +602 -0
- package/dist/color.js +10 -0
- package/dist/config/environment.js +88 -0
- package/dist/config/index.js +104 -0
- package/dist/config/policy.js +170 -0
- package/dist/config/schema.js +127 -0
- package/dist/crawl/body.js +92 -0
- package/dist/crawl/browser.js +498 -0
- package/dist/crawl/crawl.js +58 -0
- package/dist/crawl/diversity.js +153 -0
- package/dist/crawl/dns.js +194 -0
- package/dist/crawl/fetch.js +48 -0
- package/dist/crawl/frontier.js +370 -0
- package/dist/crawl/guard.js +69 -0
- package/dist/crawl/http.js +171 -0
- package/dist/crawl/links.js +89 -0
- package/dist/crawl/log.js +27 -0
- package/dist/crawl/network.js +88 -0
- package/dist/crawl/probe.js +107 -0
- package/dist/crawl/profile.js +33 -0
- package/dist/crawl/rdap.js +86 -0
- package/dist/crawl/resolve.js +132 -0
- package/dist/crawl/resources.js +233 -0
- package/dist/crawl/robots.js +100 -0
- package/dist/crawl/route.js +92 -0
- package/dist/crawl/scope.js +32 -0
- package/dist/crawl/scratch.js +15 -0
- package/dist/crawl/sitemap.js +187 -0
- package/dist/crawl/svcb.js +59 -0
- package/dist/crawl/tls-hello.js +297 -0
- package/dist/crawl/tls-probe.js +71 -0
- package/dist/facts/browser.js +138 -0
- package/dist/facts/byline.js +17 -0
- package/dist/facts/co2.js +27 -0
- package/dist/facts/csp.js +71 -0
- package/dist/facts/export.js +79 -0
- package/dist/facts/flatten.js +33 -0
- package/dist/facts/graph.js +73 -0
- package/dist/facts/headers.js +330 -0
- package/dist/facts/html.js +230 -0
- package/dist/facts/labels.js +89 -0
- package/dist/facts/language.js +32 -0
- package/dist/facts/parity.js +38 -0
- package/dist/facts/read-note.js +11 -0
- package/dist/facts/resources.js +46 -0
- package/dist/facts/robots.js +13 -0
- package/dist/facts/sites.js +140 -0
- package/dist/facts/transport.js +200 -0
- package/dist/facts/types.js +4 -0
- package/dist/facts/vendors.js +76 -0
- package/dist/fold/index.js +94 -0
- package/dist/groups/assign.js +45 -0
- package/dist/groups/sample.js +60 -0
- package/dist/i18n.js +92 -0
- package/dist/index.js +607 -0
- package/dist/interrupt.js +37 -0
- package/dist/logger.js +95 -0
- package/dist/plugins/axe.js +104 -0
- package/dist/plugins/carbon-txt.js +96 -0
- package/dist/plugins/cookies-registry.js +25 -0
- package/dist/plugins/cookies.js +168 -0
- package/dist/plugins/css.js +328 -0
- package/dist/plugins/dns.js +864 -0
- package/dist/plugins/feed-content.js +132 -0
- package/dist/plugins/feed-model.js +282 -0
- package/dist/plugins/feeds.js +836 -0
- package/dist/plugins/html-validate.js +191 -0
- package/dist/plugins/htmlhint.js +95 -0
- package/dist/plugins/icons.js +302 -0
- package/dist/plugins/images.js +269 -0
- package/dist/plugins/index.js +286 -0
- package/dist/plugins/keyboard.js +194 -0
- package/dist/plugins/lighthouse.js +110 -0
- package/dist/plugins/link-text.js +73 -0
- package/dist/plugins/list.js +22 -0
- package/dist/plugins/live.js +254 -0
- package/dist/plugins/mail.js +985 -0
- package/dist/plugins/manifest.js +97 -0
- package/dist/plugins/markup.js +98 -0
- package/dist/plugins/messages.js +25 -0
- package/dist/plugins/network.js +192 -0
- package/dist/plugins/origin.js +488 -0
- package/dist/plugins/profile-links.js +102 -0
- package/dist/plugins/schema-registry.js +101 -0
- package/dist/plugins/sshfp.js +159 -0
- package/dist/plugins/stack.js +161 -0
- package/dist/plugins/structured-data.js +358 -0
- package/dist/plugins/tls-probe.js +298 -0
- package/dist/plugins/tls-registry.js +418 -0
- package/dist/plugins/tls-scan.js +280 -0
- package/dist/plugins/trackers-registry.js +27 -0
- package/dist/plugins/trackers.js +56 -0
- package/dist/plugins/types.js +7 -0
- package/dist/plugins/visit.js +50 -0
- package/dist/plugins/well-known-registry.js +111 -0
- package/dist/plugins/well-known.js +732 -0
- package/dist/progress.js +162 -0
- package/dist/report/agent.js +122 -0
- package/dist/report/checkstyle.js +22 -0
- package/dist/report/csv.js +18 -0
- package/dist/report/html.js +266 -0
- package/dist/report/human.js +220 -0
- package/dist/report/index.js +16 -0
- package/dist/report/json.js +8 -0
- package/dist/report/printable.js +29 -0
- package/dist/report/rating.js +26 -0
- package/dist/report/sarif.js +100 -0
- package/dist/report/stats.js +46 -0
- package/dist/rules/builtin.js +465 -0
- package/dist/rules/caching.js +103 -0
- package/dist/rules/catalog.js +139 -0
- package/dist/rules/clock.js +48 -0
- package/dist/rules/declarative.js +234 -0
- package/dist/rules/deprecated.js +45 -0
- package/dist/rules/disclosure.js +99 -0
- package/dist/rules/fix.js +21 -0
- package/dist/rules/i18n.js +131 -0
- package/dist/rules/insights.js +100 -0
- package/dist/rules/length.js +52 -0
- package/dist/rules/message.js +48 -0
- package/dist/rules/relations.js +88 -0
- package/dist/rules/robots.js +75 -0
- package/dist/rules/rulesets.js +134 -0
- package/dist/rules/run.js +75 -0
- package/dist/rules/score.js +61 -0
- package/dist/rules/types.js +7 -0
- package/dist/rules/url.js +90 -0
- package/dist/server/analytics.js +49 -0
- package/dist/server/api.js +134 -0
- package/dist/server/clients.js +54 -0
- package/dist/server/clock.js +33 -0
- package/dist/server/compress.js +58 -0
- package/dist/server/health.js +14 -0
- package/dist/server/jobs.js +82 -0
- package/dist/server/main.js +83 -0
- package/dist/server/page.js +161 -0
- package/dist/server/policy.js +145 -0
- package/dist/server/providers.js +60 -0
- package/dist/server/queue.js +50 -0
- package/dist/server/scan.js +39 -0
- package/dist/server/settings.js +197 -0
- package/dist/server/web.js +353 -0
- package/dist/server/worker.js +129 -0
- package/dist/store/disk.js +215 -0
- package/dist/store/memory.js +18 -0
- package/dist/telemetry-environment.js +9 -0
- package/dist/telemetry.js +104 -0
- package/locales/es/LC_MESSAGES/messages.po +1864 -0
- package/locales/uk/LC_MESSAGES/messages.po +1864 -0
- package/package.json +134 -2
- package/presets/browser.yaml +26 -0
- package/presets/graph.yaml +35 -0
- package/presets/i18n.yaml +18 -0
- package/presets/insights.yaml +12 -0
- package/presets/links.yaml +22 -0
- package/presets/parity.yaml +59 -0
- package/presets/performance.yaml +271 -0
- package/presets/recommended.yaml +50 -0
- package/presets/redirects.yaml +23 -0
- package/presets/resources.yaml +29 -0
- package/presets/robots.yaml +13 -0
- package/presets/security-headers.yaml +257 -0
- package/presets/seo.yaml +380 -0
- package/presets/sitemap.yaml +34 -0
- package/presets/suggestions.yaml +97 -0
- package/presets/sustainability.yaml +16 -0
- package/presets/tls.yaml +72 -0
- package/presets/vendor.yaml +14 -0
- package/presets/web-comprehensive.yaml +17 -0
- package/presets/web-quick.yaml +15 -0
- package/src/server/apple-touch-icon.png +0 -0
- package/src/server/logo-36.png +0 -0
- package/src/server/logo-72.png +0 -0
- package/src/server/logo.png +0 -0
- package/vendors/dns.yaml +21 -0
- package/vendors/paths.yaml +55 -0
- package/vendors/stack.yaml +57 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { HtmlValidate, StaticConfigLoader } from "html-validate";
|
|
5
|
+
import { a11y, document as wholeDocument, recommended, standard } from "html-validate/presets";
|
|
6
|
+
import { log } from "../logger.js";
|
|
7
|
+
import { messageRule } from "./messages.js";
|
|
8
|
+
import { definePlugin } from "./types.js";
|
|
9
|
+
const UPSTREAM = { recommended, document: wholeDocument, standard, a11y };
|
|
10
|
+
const ID = "htmlvalidate";
|
|
11
|
+
const PREFIX = "html-validate/";
|
|
12
|
+
const BASE = ["recommended", "document"];
|
|
13
|
+
// Judges html-validate’s own configuration, never a page.
|
|
14
|
+
const IGNORED = new Set(["deprecated-rule"]);
|
|
15
|
+
const SOURCE = 120;
|
|
16
|
+
// The tag in `body` around `offset`, cut at SOURCE characters.
|
|
17
|
+
function sourceAt(body, offset) {
|
|
18
|
+
const start = body.lastIndexOf("<", offset);
|
|
19
|
+
const end = body.indexOf(">", offset);
|
|
20
|
+
if (start === -1 || end === -1 || offset - start > SOURCE)
|
|
21
|
+
return undefined;
|
|
22
|
+
const tag = body.slice(start, end + 1);
|
|
23
|
+
return tag.length > SOURCE ? `${tag.slice(0, SOURCE)}…` : tag;
|
|
24
|
+
}
|
|
25
|
+
// Same-origin scripts need no SRI, as `resources/sri` already judges; the HTML standard bars only empty and whitespace ids.
|
|
26
|
+
const RULES = { "require-sri": ["error", { target: "crossorigin" }], "valid-id": ["error", { relaxed: true }] };
|
|
27
|
+
const presetNames = (names) => names.map((name) => `html-validate:${name}`);
|
|
28
|
+
// A rendered DOM is Chromium’s serialisation, so the serialisation-style rules are off for it.
|
|
29
|
+
const validators = {
|
|
30
|
+
http: new HtmlValidate(new StaticConfigLoader({ extends: presetNames(BASE), rules: RULES })),
|
|
31
|
+
browser: new HtmlValidate(new StaticConfigLoader({ extends: presetNames([...BASE, "browser"]), rules: RULES })),
|
|
32
|
+
};
|
|
33
|
+
// html-validate’s severity as spiderlint’s: 2 or error, 1 or warn, else off.
|
|
34
|
+
function severityOf(entry) {
|
|
35
|
+
const level = Array.isArray(entry) ? entry[0] : entry;
|
|
36
|
+
if (level === "error" || level === 2)
|
|
37
|
+
return "error";
|
|
38
|
+
return level === "warn" || level === 1 ? "warning" : "off";
|
|
39
|
+
}
|
|
40
|
+
// The enabled rules of html-validate presets, later presets winning, as spiderlint severities.
|
|
41
|
+
function severities(presets) {
|
|
42
|
+
const entries = presets.flatMap((name) => Object.entries(UPSTREAM[name].rules ?? {}));
|
|
43
|
+
const merged = Object.fromEntries(entries.map(([id, entry]) => [id, severityOf(entry)]));
|
|
44
|
+
return Object.fromEntries(Object.entries(merged).filter(([id, severity]) => severity !== "off" && !IGNORED.has(id)));
|
|
45
|
+
}
|
|
46
|
+
// The first line of each html-validate rule description; a rule with none without an element context is exempt.
|
|
47
|
+
export const exempt = new Set();
|
|
48
|
+
const DESCRIPTIONS = new Map();
|
|
49
|
+
const htmlValidateIds = Object.keys(severities(BASE));
|
|
50
|
+
for (const id of htmlValidateIds) {
|
|
51
|
+
try {
|
|
52
|
+
const documentation = validators.http.getContextualDocumentationSync({ ruleId: id, context: undefined });
|
|
53
|
+
const summary = documentation?.description
|
|
54
|
+
?.split("\n")
|
|
55
|
+
.map((line) => line.trim())
|
|
56
|
+
.find(Boolean);
|
|
57
|
+
if (summary)
|
|
58
|
+
DESCRIPTIONS.set(id, summary);
|
|
59
|
+
else {
|
|
60
|
+
log.debug({ id }, "html-validate rule has no description");
|
|
61
|
+
exempt.add(`${PREFIX}${id}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
log.debug({ id }, "html-validate rule has no context-free description");
|
|
66
|
+
exempt.add(`${PREFIX}${id}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// The page rule of html-validate rule `id`; `fix` is derived from the upstream description when present.
|
|
70
|
+
function rule(id) {
|
|
71
|
+
return messageRule(ID, PREFIX, id, `https://html-validate.org/rules/${id}.html`, DESCRIPTIONS.get(id));
|
|
72
|
+
}
|
|
73
|
+
// How much each rule matters, inside the error band: broken parsing first,
|
|
74
|
+
// then accessibility, then attributes a browser drops, then serialization style.
|
|
75
|
+
const SCORES = {
|
|
76
|
+
"no-missing-references": 9.2,
|
|
77
|
+
"missing-doctype": 9.1,
|
|
78
|
+
"close-order": 9,
|
|
79
|
+
"element-permitted-parent": 8.9,
|
|
80
|
+
"element-required-ancestor": 8.8,
|
|
81
|
+
"element-permitted-content": 8.7,
|
|
82
|
+
"no-implicit-close": 8.6,
|
|
83
|
+
"element-permitted-order": 8.5,
|
|
84
|
+
"element-permitted-occurrences": 8.4,
|
|
85
|
+
"element-required-content": 8.3,
|
|
86
|
+
"element-required-attributes": 8.2,
|
|
87
|
+
"void-content": 8.1,
|
|
88
|
+
"no-dup-id": 7.9,
|
|
89
|
+
"aria-hidden-body": 7.8,
|
|
90
|
+
"hidden-focusable": 7.7,
|
|
91
|
+
"no-autoplay": 7.6,
|
|
92
|
+
"area-alt": 7.5,
|
|
93
|
+
"multiple-labeled-controls": 7.4,
|
|
94
|
+
"input-missing-label": 7.3,
|
|
95
|
+
"empty-heading": 7.2,
|
|
96
|
+
"valid-autocomplete": 7.2,
|
|
97
|
+
"no-abstract-role": 7.1,
|
|
98
|
+
"text-content": 7.1,
|
|
99
|
+
"heading-level": 7.1,
|
|
100
|
+
"meta-refresh": 7.1,
|
|
101
|
+
"unique-landmark": 7,
|
|
102
|
+
"prefer-native-element": 7,
|
|
103
|
+
"empty-title": 7,
|
|
104
|
+
"long-title": 7,
|
|
105
|
+
"form-dup-name": 7,
|
|
106
|
+
"map-dup-name": 7,
|
|
107
|
+
"map-id-name": 7,
|
|
108
|
+
"wcag/h30": 6.9,
|
|
109
|
+
"wcag/h37": 6.9,
|
|
110
|
+
"aria-label-misuse": 6.9,
|
|
111
|
+
"no-implicit-button-type": 6.9,
|
|
112
|
+
"no-implicit-input-type": 6.9,
|
|
113
|
+
"no-redundant-aria-label": 6.9,
|
|
114
|
+
"no-redundant-for": 6.9,
|
|
115
|
+
"no-redundant-role": 6.9,
|
|
116
|
+
"wcag/h32": 6.8,
|
|
117
|
+
"wcag/h36": 6.8,
|
|
118
|
+
"wcag/h63": 6.8,
|
|
119
|
+
"wcag/h67": 6.8,
|
|
120
|
+
"wcag/h71": 6.8,
|
|
121
|
+
"autocomplete-password": 6.8,
|
|
122
|
+
"input-attributes": 6.8,
|
|
123
|
+
"valid-for": 6.8,
|
|
124
|
+
"valid-id": 6.8,
|
|
125
|
+
"attribute-boolean-style": 6.7,
|
|
126
|
+
"attribute-empty-style": 6.7,
|
|
127
|
+
"attribute-misuse": 6.7,
|
|
128
|
+
"attribute-allowed-values": 6.7,
|
|
129
|
+
"no-dup-attr": 6.7,
|
|
130
|
+
"no-multiple-main": 6.7,
|
|
131
|
+
"require-sri": 6.7,
|
|
132
|
+
"script-element": 6.7,
|
|
133
|
+
"element-name": 6.7,
|
|
134
|
+
"close-attr": 6.7,
|
|
135
|
+
"doctype-html": 6.6,
|
|
136
|
+
"doctype-style": 6.6,
|
|
137
|
+
deprecated: 6.6,
|
|
138
|
+
"no-deprecated-attr": 6.6,
|
|
139
|
+
"script-type": 6.6,
|
|
140
|
+
"tel-non-breaking": 6.6,
|
|
141
|
+
"unrecognized-char-ref": 6.6,
|
|
142
|
+
"void-style": 6.6,
|
|
143
|
+
"no-conditional-comment": 6.6,
|
|
144
|
+
"no-inline-style": 6.6,
|
|
145
|
+
"no-self-closing": 6.6,
|
|
146
|
+
"no-raw-characters": 6.6,
|
|
147
|
+
"attr-case": 6.6,
|
|
148
|
+
"attr-delimiter": 6.6,
|
|
149
|
+
"attr-quotes": 6.6,
|
|
150
|
+
"attr-spacing": 6.6,
|
|
151
|
+
"element-case": 6.6,
|
|
152
|
+
"no-trailing-whitespace": 6.6,
|
|
153
|
+
"no-utf8-bom": 6.6,
|
|
154
|
+
"no-unused-disable": 6.6,
|
|
155
|
+
"prefer-button": 6.6,
|
|
156
|
+
"prefer-tbody": 6.6,
|
|
157
|
+
"no-dup-class": 6.6,
|
|
158
|
+
};
|
|
159
|
+
// A spiderlint preset from html-validate presets.
|
|
160
|
+
function preset(description, presets) {
|
|
161
|
+
return { description, rules: Object.fromEntries(Object.entries(severities(presets)).map(([id, severity]) => [`${PREFIX}${id}`, SCORES[id] === undefined ? severity : { severity: severity, score: SCORES[id] }])) };
|
|
162
|
+
}
|
|
163
|
+
// Validates an HTML page’s body; a truncated body would fail on elements the cap cut off, so it is skipped.
|
|
164
|
+
async function extract(page, body) {
|
|
165
|
+
if (!page.html || page.http.size.truncated) {
|
|
166
|
+
log.debug({ url: page.url.href, isHtml: page.html !== undefined, truncated: page.http.size.truncated === true }, "html-validate skipped");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const mode = page.browser ? "browser" : "http";
|
|
170
|
+
const report = await validators[mode].validateString(body, page.url.href);
|
|
171
|
+
const messages = report.results
|
|
172
|
+
.flatMap((result) => result.messages)
|
|
173
|
+
.map(({ ruleId, message, severity, line, column, offset, size, selector, context }) => {
|
|
174
|
+
const source = sourceAt(body, offset);
|
|
175
|
+
return { rule: ruleId, message, severity, line, column, offset, size, ...(selector && { selector }), ...(source && { source }), ...(context !== undefined && { context: context }) };
|
|
176
|
+
});
|
|
177
|
+
log.debug({ url: page.url.href, mode, messages: messages.length }, "html validated");
|
|
178
|
+
return { messages };
|
|
179
|
+
}
|
|
180
|
+
const rules = Object.fromEntries(Object.keys(severities(BASE)).map((id) => [`${PREFIX}${id}`, rule(id)]));
|
|
181
|
+
export default definePlugin({
|
|
182
|
+
name: "html-validate",
|
|
183
|
+
extractors: [{ id: ID, extract }],
|
|
184
|
+
rules,
|
|
185
|
+
presets: {
|
|
186
|
+
"html-validate": preset("Markup validity, accessibility and document structure, checked by html-validate", BASE),
|
|
187
|
+
"html-validate:standard": preset("Markup that breaks the HTML standard, with no style rules", ["standard"]),
|
|
188
|
+
"html-validate:a11y": preset("Accessibility defects html-validate can see in the markup", ["a11y"]),
|
|
189
|
+
"html-validate:document": preset("Defects only a whole document has: doctype, labels, heading levels, SRI", ["document"]),
|
|
190
|
+
},
|
|
191
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { HTMLHint } from "htmlhint/dist/core/core.js";
|
|
5
|
+
import { log } from "../logger.js";
|
|
6
|
+
import { messageRule } from "./messages.js";
|
|
7
|
+
import { definePlugin } from "./types.js";
|
|
8
|
+
const ID = "htmlhint";
|
|
9
|
+
const PREFIX = "htmlhint/";
|
|
10
|
+
const SOURCE = 120;
|
|
11
|
+
// htmlhint’s default ruleset, every rule of which reports an error.
|
|
12
|
+
const DEFAULT = Object.fromEntries(Object.keys(HTMLHint.defaultRuleset).map((id) => [id, "error"]));
|
|
13
|
+
// Rules htmlhint ships off that judge a defect rather than a house style, at the level each reports.
|
|
14
|
+
const EXTRA = {
|
|
15
|
+
"alt-require": "warning",
|
|
16
|
+
"attr-no-unnecessary-whitespace": "error",
|
|
17
|
+
"attr-unsafe-chars": "warning",
|
|
18
|
+
"attr-value-no-duplication": "error",
|
|
19
|
+
"attr-whitespace": "error",
|
|
20
|
+
"button-type-require": "warning",
|
|
21
|
+
"doctype-html5": "warning",
|
|
22
|
+
"frame-title-require": "warning",
|
|
23
|
+
"h1-require": "warning",
|
|
24
|
+
"html-lang-require": "warning",
|
|
25
|
+
"id-class-ad-disabled": "warning",
|
|
26
|
+
"input-requires-label": "warning",
|
|
27
|
+
"main-require": "warning",
|
|
28
|
+
"meta-charset-require": "error",
|
|
29
|
+
"meta-description-require": "error",
|
|
30
|
+
"meta-viewport-require": "error",
|
|
31
|
+
"tag-no-obsolete": "error",
|
|
32
|
+
"tagname-specialchars": "error",
|
|
33
|
+
};
|
|
34
|
+
const RULESET = Object.fromEntries(Object.keys({ ...DEFAULT, ...EXTRA }).map((id) => [id, true]));
|
|
35
|
+
// How much each rule matters, inside its own level’s band; absent keeps the band’s base.
|
|
36
|
+
const SCORES = {
|
|
37
|
+
"tag-pair": 9,
|
|
38
|
+
"id-unique": 8.4,
|
|
39
|
+
"src-not-empty": 8.2,
|
|
40
|
+
"attr-no-duplication": 7.8,
|
|
41
|
+
"title-require": 7.6,
|
|
42
|
+
"spec-char-escape": 7.4,
|
|
43
|
+
"doctype-first": 7,
|
|
44
|
+
"tagname-lowercase": 6.8,
|
|
45
|
+
"attr-lowercase": 6.8,
|
|
46
|
+
"attr-value-double-quotes": 6.6,
|
|
47
|
+
"meta-charset-require": 7.8,
|
|
48
|
+
"meta-viewport-require": 7.6,
|
|
49
|
+
"meta-description-require": 7.2,
|
|
50
|
+
"tag-no-obsolete": 7,
|
|
51
|
+
"tagname-specialchars": 6.8,
|
|
52
|
+
"attr-value-no-duplication": 6.8,
|
|
53
|
+
"attr-whitespace": 6.6,
|
|
54
|
+
"attr-no-unnecessary-whitespace": 6.6,
|
|
55
|
+
"alt-require": 6.2,
|
|
56
|
+
"input-requires-label": 6,
|
|
57
|
+
"h1-require": 5.8,
|
|
58
|
+
"html-lang-require": 5.6,
|
|
59
|
+
"main-require": 5.4,
|
|
60
|
+
"frame-title-require": 4.6,
|
|
61
|
+
"button-type-require": 4.8,
|
|
62
|
+
"attr-unsafe-chars": 4.4,
|
|
63
|
+
"doctype-html5": 4.2,
|
|
64
|
+
"id-class-ad-disabled": 3.6,
|
|
65
|
+
};
|
|
66
|
+
// htmlhint’s rule descriptions, keyed by ID; `fix` is derived from them where present.
|
|
67
|
+
const DESCRIPTIONS = new Map();
|
|
68
|
+
for (const [id, rule] of Object.entries(HTMLHint.rules))
|
|
69
|
+
if (rule?.description)
|
|
70
|
+
DESCRIPTIONS.set(id, rule.description);
|
|
71
|
+
// Lints an HTML page’s body; a truncated body would fail on tags the cap cut off, so it is skipped.
|
|
72
|
+
async function extract(page, body) {
|
|
73
|
+
if (!page.html || page.http.size.truncated) {
|
|
74
|
+
log.debug({ url: page.url.href, isHtml: page.html !== undefined, truncated: page.http.size.truncated === true }, "htmlhint skipped");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const hints = HTMLHint.verify(body, RULESET);
|
|
78
|
+
const messages = hints.map(({ rule, type, message, line, col, raw }) => ({ rule: rule.id, type, message, line, column: col, ...(raw && { source: raw.length > SOURCE ? `${raw.slice(0, SOURCE)}…` : raw }) }));
|
|
79
|
+
log.debug({ url: page.url.href, rules: Object.keys(RULESET).length, messages: messages.length }, "html hinted");
|
|
80
|
+
return { messages };
|
|
81
|
+
}
|
|
82
|
+
// A spiderlint preset from rule levels, each rule scored by how much it matters.
|
|
83
|
+
function preset(description, levels) {
|
|
84
|
+
return { description, rules: Object.fromEntries(Object.entries(levels).map(([id, severity]) => [`${PREFIX}${id}`, SCORES[id] === undefined ? severity : { severity, score: SCORES[id] }])) };
|
|
85
|
+
}
|
|
86
|
+
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({
|
|
88
|
+
name: "htmlhint",
|
|
89
|
+
extractors: [{ id: ID, extract }],
|
|
90
|
+
rules,
|
|
91
|
+
presets: {
|
|
92
|
+
htmlhint: preset("Markup defects htmlhint reports by default: unpaired tags, duplicate IDs and attributes, a missing doctype or title", DEFAULT),
|
|
93
|
+
"htmlhint:extra": preset("Defects htmlhint ships off: missing alt, labels, lang, meta tags, obsolete tags, unsafe attribute characters", EXTRA),
|
|
94
|
+
},
|
|
95
|
+
});
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { load } from "cheerio";
|
|
5
|
+
import sharp from "sharp";
|
|
6
|
+
import { optimize } from "svgo";
|
|
7
|
+
import { reason } from "../crawl/fetch.js";
|
|
8
|
+
import { RobotsDisallowed } from "../crawl/probe.js";
|
|
9
|
+
import { log } from "../logger.js";
|
|
10
|
+
import { mediaType } from "./origin.js";
|
|
11
|
+
import { definePlugin } from "./types.js";
|
|
12
|
+
const ID = "icons";
|
|
13
|
+
const APPLE = "180x180";
|
|
14
|
+
const ICO_SIZES = ["16x16", "32x32"];
|
|
15
|
+
const SVG = "image/svg+xml";
|
|
16
|
+
const MAX_PIXELS = 50_000_000;
|
|
17
|
+
// A fill or stroke paint, as an attribute or a style declaration.
|
|
18
|
+
const PAINT = /\b(?:fill|stroke)\s*[:=]\s*["']?\s*([^"';\s>]+)/gi;
|
|
19
|
+
// Paints that add no colour of their own.
|
|
20
|
+
const UNPAINTED = new Set(["none", "transparent", "currentcolor", "inherit"]);
|
|
21
|
+
// Media types an icon may declare, by the format they name.
|
|
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
|
+
// The lower-cased tokens of a `rel`, `sizes` or `purpose` value.
|
|
24
|
+
const tokens = (value) => (value ?? "").toLowerCase().split(/\s+/).filter(Boolean);
|
|
25
|
+
// The sizes an ICO directory lists, 0 standing for 256.
|
|
26
|
+
function icoSizes(bytes) {
|
|
27
|
+
const count = bytes.byteLength >= 6 ? bytes.readUInt16LE(4) : 0;
|
|
28
|
+
const sizes = [];
|
|
29
|
+
for (let entry = 0; entry < count && 6 + (entry + 1) * 16 <= bytes.byteLength; entry += 1) {
|
|
30
|
+
const offset = 6 + entry * 16;
|
|
31
|
+
sizes.push(`${bytes[offset] || 256}x${bytes[offset + 1] || 256}`);
|
|
32
|
+
}
|
|
33
|
+
return sizes;
|
|
34
|
+
}
|
|
35
|
+
// The format, pixel sizes and opacity of an icon body; an unrecognised body has no format.
|
|
36
|
+
async function measure(url, bytes) {
|
|
37
|
+
if (bytes.subarray(0, 4).equals(Buffer.from([0, 0, 1, 0])))
|
|
38
|
+
return { format: "ico", sizes: icoSizes(bytes) };
|
|
39
|
+
const text = bytes.subarray(0, 1024).toString("utf8");
|
|
40
|
+
if (/<svg[\s>]/i.test(text)) {
|
|
41
|
+
const svg = bytes.toString("utf8");
|
|
42
|
+
const colours = new Set(svg
|
|
43
|
+
.matchAll(PAINT)
|
|
44
|
+
.map((match) => String(match[1]).toLowerCase())
|
|
45
|
+
.filter((paint) => !UNPAINTED.has(paint))).size;
|
|
46
|
+
try {
|
|
47
|
+
optimize(svg);
|
|
48
|
+
return { format: "svg", ...(colours > 0 && { colours }) };
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
log.debug({ url, error: reason(error) }, "SVG icon does not parse");
|
|
52
|
+
return { format: "svg", xml: false };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const image = sharp(bytes, { limitInputPixels: MAX_PIXELS });
|
|
57
|
+
const metadata = await image.metadata();
|
|
58
|
+
const stats = metadata.format === "png" ? await image.stats() : undefined;
|
|
59
|
+
const opaque = stats?.isOpaque;
|
|
60
|
+
return { format: metadata.format, sizes: [`${metadata.width}x${metadata.height}`], ...(opaque !== undefined && { opaque }) };
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
log.debug({ url, error: reason(error) }, "icon is no image");
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Every icon URL fetched once per origin, on its own host through the delegated probe.
|
|
68
|
+
function fetcher(origin, context) {
|
|
69
|
+
const files = new Map();
|
|
70
|
+
const fetchOne = async (url) => {
|
|
71
|
+
try {
|
|
72
|
+
const answer = await (new URL(url).origin === origin ? context.fetch : context.delegated)(url, { redirect: "follow", binary: true });
|
|
73
|
+
const isOk = answer.status >= 200 && answer.status <= 299;
|
|
74
|
+
const measured = isOk && answer.bytes ? await measure(url, answer.bytes) : {};
|
|
75
|
+
log.debug({ url, status: answer.status, ...measured }, "icon fetched");
|
|
76
|
+
return { status: answer.status, type: mediaType(answer), ...measured };
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
log.debug({ url, error: reason(error) }, "icon unreachable");
|
|
80
|
+
return { status: 0, error: error instanceof RobotsDisallowed ? "robots.txt disallows it" : reason(error) };
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
return (url) => {
|
|
84
|
+
if (!files.has(url))
|
|
85
|
+
files.set(url, fetchOne(url));
|
|
86
|
+
return files.get(url);
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// The icons the origin’s pages link, one entry per source, URL, sizes and type.
|
|
90
|
+
function linkedIcons(pages) {
|
|
91
|
+
const found = new Map();
|
|
92
|
+
for (const page of pages) {
|
|
93
|
+
const links = page.html?.head.links ?? [];
|
|
94
|
+
for (const link of links) {
|
|
95
|
+
const relation = tokens(link.rel);
|
|
96
|
+
const source = relation.includes("mask-icon") ? "mask-icon" : relation.some((token) => token.startsWith("apple-touch-icon")) ? "apple-touch-icon" : relation.includes("icon") ? "icon" : undefined;
|
|
97
|
+
if (!source || !link.href)
|
|
98
|
+
continue;
|
|
99
|
+
const icon = { url: link.href, source, ...(link.sizes && { sizes: link.sizes }), ...(link.type && { type: link.type.toLowerCase() }), ...(link.color && { color: link.color }) };
|
|
100
|
+
found.set(JSON.stringify(icon), icon);
|
|
101
|
+
}
|
|
102
|
+
const metas = page.html?.metas ?? [];
|
|
103
|
+
for (const meta of metas)
|
|
104
|
+
if (meta.name.toLowerCase() === "msapplication-tileimage" && URL.canParse(meta.content, page.url.href))
|
|
105
|
+
found.set(`tile ${meta.content}`, { url: new URL(meta.content, page.url.href).href, source: "ms-tile" });
|
|
106
|
+
}
|
|
107
|
+
return found.values().toArray();
|
|
108
|
+
}
|
|
109
|
+
// The icons each linked manifest lists; a manifest that does not answer or parse lists none, `manifest/parse` reports it.
|
|
110
|
+
async function manifestIcons(pages, origin, context) {
|
|
111
|
+
const urls = new Set(pages.flatMap((page) => (page.html?.head.links ?? []).filter((link) => tokens(link.rel).includes("manifest") && link.href).map((link) => link.href)));
|
|
112
|
+
const icons = [];
|
|
113
|
+
for (const url of urls) {
|
|
114
|
+
try {
|
|
115
|
+
const answer = await (new URL(url).origin === origin ? context.fetch : context.delegated)(url, { redirect: "follow" });
|
|
116
|
+
const listed = JSON.parse(answer.body).icons;
|
|
117
|
+
const entries = Array.isArray(listed) ? listed : [];
|
|
118
|
+
for (const icon of entries) {
|
|
119
|
+
if (typeof icon.src !== "string" || !URL.canParse(icon.src, answer.url))
|
|
120
|
+
continue;
|
|
121
|
+
icons.push({ url: new URL(icon.src, answer.url).href, source: "manifest", ...(typeof icon.sizes === "string" && { sizes: icon.sizes }), ...(typeof icon.type === "string" && { type: icon.type.toLowerCase() }) });
|
|
122
|
+
}
|
|
123
|
+
log.debug({ url, icons: icons.length }, "manifest icons read");
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
log.debug({ url, error: reason(error) }, "manifest icons unreadable");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return icons;
|
|
130
|
+
}
|
|
131
|
+
// browserconfig.xml’s tile images, and what is wrong with the file; one nobody names and that answers 404 is absent.
|
|
132
|
+
async function browserConfig(pages, origin, context) {
|
|
133
|
+
const named = pages.flatMap((page) => (page.html?.metas ?? []).filter((meta) => meta.name.toLowerCase() === "msapplication-config" && meta.content.toLowerCase() !== "none" && URL.canParse(meta.content, page.url.href)).map((meta) => new URL(meta.content, page.url.href).href));
|
|
134
|
+
const url = named[0] ?? `${origin}/browserconfig.xml`;
|
|
135
|
+
let answer;
|
|
136
|
+
try {
|
|
137
|
+
answer = await (new URL(url).origin === origin ? context.fetch : context.delegated)(url, { redirect: "follow" });
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
log.debug({ url, error: reason(error) }, "browserconfig.xml unreachable");
|
|
141
|
+
answer = { status: 0, body: "", url };
|
|
142
|
+
}
|
|
143
|
+
log.debug({ url, status: answer.status, isNamed: named.length > 0 }, "browserconfig.xml fetched");
|
|
144
|
+
if (answer.status < 200 || answer.status > 299)
|
|
145
|
+
return { tiles: [], problems: named.length > 0 ? [`${url} answers ${answer.status || "nothing"}`] : [] };
|
|
146
|
+
const $ = load(answer.body, { xml: true });
|
|
147
|
+
if ($("browserconfig msapplication").length === 0)
|
|
148
|
+
return { tiles: [], problems: [`${url} does not parse as a browserconfig document`] };
|
|
149
|
+
const tiles = $("browserconfig msapplication tile *[src]")
|
|
150
|
+
.map((_, element) => String($(element).attr("src")))
|
|
151
|
+
.get()
|
|
152
|
+
.filter((named) => URL.canParse(named, answer.url))
|
|
153
|
+
.map((named) => new URL(named, answer.url).href);
|
|
154
|
+
return { tiles, problems: [] };
|
|
155
|
+
}
|
|
156
|
+
const isOk = (file) => file !== undefined && file.status >= 200 && file.status <= 299;
|
|
157
|
+
// What is wrong with fetching `url`, when something is: no answer, not 2xx, or no image in the body.
|
|
158
|
+
function unusable(url, file) {
|
|
159
|
+
if (!file || file.error)
|
|
160
|
+
return `${url} does not answer${file?.error ? ` (${file.error})` : ""}`;
|
|
161
|
+
if (!isOk(file))
|
|
162
|
+
return `${url} answers ${file.status}`;
|
|
163
|
+
return file.format ? undefined : `${url} is not an image (${file.type || "no type"})`;
|
|
164
|
+
}
|
|
165
|
+
// Favicon: a linked icon or /favicon.ico, each a real image, an ICO carrying 16 and 32 px.
|
|
166
|
+
function faviconProblems(declared, files, root) {
|
|
167
|
+
const linked = declared.filter((icon) => icon.source === "icon");
|
|
168
|
+
const rootFile = files[root];
|
|
169
|
+
if (linked.length === 0 && !isOk(rootFile))
|
|
170
|
+
return [`no <link rel=icon> and ${root} answers ${rootFile?.status || "nothing"}`];
|
|
171
|
+
const judged = [...new Set([...linked.map((icon) => icon.url), ...(isOk(rootFile) ? [root] : [])])];
|
|
172
|
+
return judged.flatMap((url) => {
|
|
173
|
+
const file = files[url];
|
|
174
|
+
const problem = unusable(url, file);
|
|
175
|
+
if (problem)
|
|
176
|
+
return [problem];
|
|
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 ")}`] : [];
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
// SVG favicon: one linked with its media type, parsing as XML.
|
|
182
|
+
function svgProblems(declared, files) {
|
|
183
|
+
const svgs = declared.filter((icon) => icon.source === "icon" && (icon.type === SVG || files[icon.url]?.format === "svg" || new URL(icon.url).pathname.endsWith(".svg")));
|
|
184
|
+
if (svgs.length === 0)
|
|
185
|
+
return ["no <link rel=icon> names an SVG, which scales and can follow dark mode"];
|
|
186
|
+
return svgs.flatMap((icon) => {
|
|
187
|
+
const file = files[icon.url];
|
|
188
|
+
const problem = unusable(icon.url, file);
|
|
189
|
+
if (problem)
|
|
190
|
+
return [problem];
|
|
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}"`];
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
// Apple touch icon: linked, or at /apple-touch-icon.png for pages linking none; an opaque PNG, one of them 180×180.
|
|
197
|
+
function appleProblems(declared, files, root, unlinked) {
|
|
198
|
+
const linked = declared.filter((icon) => icon.source === "apple-touch-icon");
|
|
199
|
+
const problems = unlinked > 0 && !isOk(files[root]) ? [`${unlinked} pages link no apple-touch-icon and ${root} answers ${files[root]?.status || "nothing"}`] : [];
|
|
200
|
+
const judged = [...linked, ...(unlinked > 0 && isOk(files[root]) ? [{ url: root, source: "apple-touch-icon" }] : [])];
|
|
201
|
+
for (const icon of judged) {
|
|
202
|
+
const file = files[icon.url];
|
|
203
|
+
const problem = unusable(icon.url, file);
|
|
204
|
+
if (problem)
|
|
205
|
+
problems.push(problem);
|
|
206
|
+
else if (file?.format !== "png")
|
|
207
|
+
problems.push(`${icon.url} is ${file?.format}, not PNG`);
|
|
208
|
+
else if (file.opaque === false)
|
|
209
|
+
problems.push(`${icon.url} has transparent pixels, which iOS draws black`);
|
|
210
|
+
}
|
|
211
|
+
const sizes = judged.map((icon) => ("sizes" in icon && icon.sizes ? tokens(icon.sizes)[0] : files[icon.url]?.sizes?.[0]));
|
|
212
|
+
if (judged.length > 0 && !sizes.includes(APPLE))
|
|
213
|
+
problems.push(`no apple-touch-icon is ${APPLE} (got ${sizes.map((size) => size ?? "unknown").join(", ")})`);
|
|
214
|
+
return problems;
|
|
215
|
+
}
|
|
216
|
+
// Safari pinned tab: a single-colour SVG with a `color`, judged only when linked.
|
|
217
|
+
function maskProblems(declared, files) {
|
|
218
|
+
return declared
|
|
219
|
+
.filter((icon) => icon.source === "mask-icon")
|
|
220
|
+
.flatMap((icon) => {
|
|
221
|
+
const problem = unusable(icon.url, files[icon.url]);
|
|
222
|
+
if (problem)
|
|
223
|
+
return [problem];
|
|
224
|
+
const colours = files[icon.url]?.colours ?? 0;
|
|
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
|
+
});
|
|
227
|
+
}
|
|
228
|
+
// Windows tiles: each `msapplication-TileImage` and browserconfig.xml tile answers with an image.
|
|
229
|
+
function tileProblems(declared, files, config) {
|
|
230
|
+
const urls = [...new Set([...declared.filter((icon) => icon.source === "ms-tile").map((icon) => icon.url), ...config.tiles])];
|
|
231
|
+
return [...config.problems, ...urls.flatMap((url) => unusable(url, files[url]) ?? [])];
|
|
232
|
+
}
|
|
233
|
+
// Declared sizes and type against the measured file; an SVG is any size.
|
|
234
|
+
function sizeProblems(declared, files) {
|
|
235
|
+
const problems = new Set();
|
|
236
|
+
for (const icon of declared) {
|
|
237
|
+
const file = files[icon.url];
|
|
238
|
+
if (!file?.format)
|
|
239
|
+
continue;
|
|
240
|
+
const claimed = tokens(icon.sizes).filter((size) => size !== "any");
|
|
241
|
+
const wrong = file.format === "svg" ? [] : claimed.filter((size) => !file.sizes?.includes(size));
|
|
242
|
+
if (wrong.length > 0)
|
|
243
|
+
problems.add(`${icon.url} declares ${claimed.join(" ")} but is ${file.sizes?.join(" ") ?? "unmeasured"}`);
|
|
244
|
+
const format = icon.type && TYPES[icon.type];
|
|
245
|
+
if (icon.type && format !== file.format)
|
|
246
|
+
problems.add(`${icon.url} declares ${icon.type} but is ${file.format}`);
|
|
247
|
+
}
|
|
248
|
+
return [...problems];
|
|
249
|
+
}
|
|
250
|
+
// Every icon the origin’s pages, manifest and browserconfig.xml name, fetched once, measured and judged.
|
|
251
|
+
const icons = {
|
|
252
|
+
id: ID,
|
|
253
|
+
per: "origin",
|
|
254
|
+
crawled: true,
|
|
255
|
+
version: "2",
|
|
256
|
+
async extract(origin, context) {
|
|
257
|
+
const pages = context.pages.filter((page) => page.html !== undefined);
|
|
258
|
+
if (pages.length === 0)
|
|
259
|
+
return;
|
|
260
|
+
const get = fetcher(origin, context);
|
|
261
|
+
const declared = [...linkedIcons(pages), ...(await manifestIcons(pages, origin, context))];
|
|
262
|
+
const config = await browserConfig(pages, origin, context);
|
|
263
|
+
const favicon = `${origin}/favicon.ico`;
|
|
264
|
+
const apple = `${origin}/apple-touch-icon.png`;
|
|
265
|
+
const unlinked = pages.filter((page) => (page.html?.head.links ?? []).every((link) => tokens(link.rel).every((token) => !token.startsWith("apple-touch-icon")))).length;
|
|
266
|
+
const urls = [...new Set([...declared.map((icon) => icon.url), ...config.tiles, favicon, apple])];
|
|
267
|
+
const files = Object.fromEntries(await Promise.all(urls.map(async (url) => [url, await get(url)])));
|
|
268
|
+
log.debug({ origin, declared: declared.length, files: urls.length, unlinked }, "icons measured");
|
|
269
|
+
return {
|
|
270
|
+
declared,
|
|
271
|
+
files,
|
|
272
|
+
favicon: faviconProblems(declared, files, favicon),
|
|
273
|
+
svg: svgProblems(declared, files),
|
|
274
|
+
"apple-touch": appleProblems(declared, files, apple, unlinked),
|
|
275
|
+
"mask-icon": maskProblems(declared, files),
|
|
276
|
+
"ms-tile": tileProblems(declared, files, config),
|
|
277
|
+
"declared-size": sizeProblems(declared, files),
|
|
278
|
+
};
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
// A rule over one of the per-origin problem lists.
|
|
282
|
+
function problemRule(check, severity, score, message, documentation, fix) {
|
|
283
|
+
// eslint-disable-next-line unicorn/no-incorrect-template-string-interpolation -- {got} is the rule message placeholder
|
|
284
|
+
return { fact: `site.origins.*.${ID}.${check}`, expect: { maxItems: 0 }, message: `${message}: {got}`, severity, score, docs: documentation, fix };
|
|
285
|
+
}
|
|
286
|
+
export default definePlugin({
|
|
287
|
+
name: ID,
|
|
288
|
+
sites: [icons],
|
|
289
|
+
presets: {
|
|
290
|
+
icons: {
|
|
291
|
+
description: "Favicons and platform icons fetched and measured: the favicon, SVG, Apple touch, Safari pinned tab and Windows tile icons, and every declared size",
|
|
292
|
+
rules: {
|
|
293
|
+
"icons/favicon": problemRule("favicon", "warning", 4.6, "the favicon is missing or broken", "https://developers.google.com/search/docs/appearance/favicon-in-search", "Link an icon with <link rel=icon>, or serve /favicon.ico as an ICO holding 16 and 32 px images."),
|
|
294
|
+
"icons/svg": problemRule("svg", "hint", 0.6, "no usable SVG favicon", "https://developer.mozilla.org/docs/Web/HTML/Reference/Attributes/rel#icon", `Link a well-formed SVG with <link rel=icon type="${SVG}">.`),
|
|
295
|
+
"icons/apple-touch": problemRule("apple-touch", "warning", 3.8, "the Apple touch icon is missing or unfit", "https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/ConfiguringWebApplications/ConfiguringWebApplications.html", "Serve an opaque 180×180 PNG at /apple-touch-icon.png, or link one with <link rel=apple-touch-icon>."),
|
|
296
|
+
"icons/mask-icon": problemRule("mask-icon", "hint", 0.4, "the Safari pinned tab icon is unusable; since Safari 12 an SVG rel=icon serves instead", "https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/pinnedTabs/pinnedTabs.html", "Link a single-colour SVG with a color attribute, or drop rel=mask-icon."),
|
|
297
|
+
"icons/ms-tile": problemRule("ms-tile", "info", 1.4, "a Windows tile image or browserconfig.xml is broken", "https://learn.microsoft.com/previous-versions/windows/internet-explorer/ie-developer/platform-apis/dn320426(v=vs.85)", "Serve every image msapplication-TileImage and browserconfig.xml name, or remove them."),
|
|
298
|
+
"icons/declared-size": problemRule("declared-size", "warning", 4.2, "icons are not the size or type they declare", "https://developer.mozilla.org/docs/Web/HTML/Reference/Attributes/sizes", "Make each icon’s sizes and type match the file, or correct the declaration."),
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
});
|