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
package/dist/progress.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { logUpdateStderr } from "log-update";
|
|
5
|
+
import { relative } from "./crawl/scope.js";
|
|
6
|
+
import { log } from "./logger.js";
|
|
7
|
+
const BAR_WIDTH = 20;
|
|
8
|
+
const REDRAW_MS = 1000;
|
|
9
|
+
const ETA_WINDOW = 50;
|
|
10
|
+
const ETA_MIN_SAMPLES = 3;
|
|
11
|
+
const ETA_Z = 2;
|
|
12
|
+
const COUNT_MS = 250;
|
|
13
|
+
// What a run does, in order: the crawl, then the work on its pages, then the rules.
|
|
14
|
+
export const PHASES = ["crawl", "resources", "probes", "site", "lint"];
|
|
15
|
+
// What each phase is called on the status line.
|
|
16
|
+
const PHASE_TEXT = { crawl: "crawling", resources: "linked resources", probes: "external links", site: "site checks", lint: "rules" };
|
|
17
|
+
// The status line: whether it draws, what it shows, the origin trimmed from its URL, the phase and how far inside it.
|
|
18
|
+
const state = { isOn: false, isShown: false, done: 0, total: 0, page: "", step: "", since: 0, origin: "", last: 0, notified: 0, phase: "crawl", count: undefined, intervals: [] };
|
|
19
|
+
// Called on every change of done or known pages; the server’s scan runner sets it.
|
|
20
|
+
const observer = {};
|
|
21
|
+
// Hands every progress change to `listener`, status line or not.
|
|
22
|
+
export function onProgress(listener) {
|
|
23
|
+
observer.listener = listener;
|
|
24
|
+
}
|
|
25
|
+
// The current counts to the listener, if one is set.
|
|
26
|
+
function notify() {
|
|
27
|
+
const total = Math.max(state.done, state.total);
|
|
28
|
+
const range = eta(state.intervals, total - state.done);
|
|
29
|
+
state.notified = Date.now();
|
|
30
|
+
observer.listener?.({ done: state.done, total, phase: state.phase, ...(state.count && { step: { ...state.count } }), ...(range && { eta: etaSpan(range) }) });
|
|
31
|
+
}
|
|
32
|
+
// Turns the status line on for an interactive run; `--no-progress`, a pipe or JSON logs keep it off.
|
|
33
|
+
export function enableProgress(isOn) {
|
|
34
|
+
state.isOn = isOn;
|
|
35
|
+
}
|
|
36
|
+
// Whether the status line draws, so per-page logs can step down to debug.
|
|
37
|
+
export function isProgressOn() {
|
|
38
|
+
return state.isOn;
|
|
39
|
+
}
|
|
40
|
+
// Moves the run into `phase`, which has `total` things to do when it knows; the status line comes back for it, and the listener hears it at once.
|
|
41
|
+
export function progressPhase(phase, total) {
|
|
42
|
+
log.debug({ phase, total }, "progress phase");
|
|
43
|
+
Object.assign(state, { phase, count: total === undefined ? undefined : { done: 0, total }, page: "", step: "", since: Date.now() });
|
|
44
|
+
if (state.isOn)
|
|
45
|
+
state.isShown = true;
|
|
46
|
+
progressDraw();
|
|
47
|
+
notify();
|
|
48
|
+
}
|
|
49
|
+
// Counts `done` things finished inside the phase; the listener hears it at most four times a second, and the last one always.
|
|
50
|
+
export function progressCount(done) {
|
|
51
|
+
if (!state.count)
|
|
52
|
+
return;
|
|
53
|
+
state.count.done = done;
|
|
54
|
+
progressDraw();
|
|
55
|
+
if (done >= state.count.total || Date.now() - state.notified >= COUNT_MS)
|
|
56
|
+
notify();
|
|
57
|
+
}
|
|
58
|
+
// Clears the status line once the run no longer reports, whichever phase it ended in.
|
|
59
|
+
export function progressEnd() {
|
|
60
|
+
if (!state.isShown)
|
|
61
|
+
return;
|
|
62
|
+
logUpdateStderr.clear();
|
|
63
|
+
logUpdateStderr.done();
|
|
64
|
+
state.isShown = false;
|
|
65
|
+
}
|
|
66
|
+
// Names the page and step in flight, timed from now.
|
|
67
|
+
export function progressStep(page, step) {
|
|
68
|
+
Object.assign(state, { page, step, since: Date.now() });
|
|
69
|
+
}
|
|
70
|
+
// Counts `done` pages finished and clears the step, redrawing at once.
|
|
71
|
+
export function progressDone(done) {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
// One page since the last joins the window; a jump (a resumed store) only moves the baseline.
|
|
74
|
+
const isSampled = state.isShown || observer.listener !== undefined;
|
|
75
|
+
if (isSampled && done - state.done === 1)
|
|
76
|
+
state.intervals = [...state.intervals, (now - state.last) / 1000].slice(-ETA_WINDOW);
|
|
77
|
+
else if (isSampled)
|
|
78
|
+
log.debug({ done, previous: state.done, samples: state.intervals.length }, "progress gap not sampled");
|
|
79
|
+
Object.assign(state, { done, page: "", step: "", since: 0, last: now });
|
|
80
|
+
progressDraw();
|
|
81
|
+
notify();
|
|
82
|
+
}
|
|
83
|
+
// Seconds left as [low, high]: `remaining` gaps at the window’s mean, ± ETA_Z σ of their sum and of the mean itself.
|
|
84
|
+
export function eta(intervals, remaining) {
|
|
85
|
+
const count = intervals.length;
|
|
86
|
+
if (count < ETA_MIN_SAMPLES || remaining <= 0)
|
|
87
|
+
return undefined;
|
|
88
|
+
const mean = intervals.reduce((sum, gap) => sum + gap, 0) / count;
|
|
89
|
+
const variance = intervals.reduce((sum, gap) => sum + (gap - mean) ** 2, 0) / (count - 1);
|
|
90
|
+
const spread = ETA_Z * Math.sqrt(variance * (remaining + (remaining * remaining) / count));
|
|
91
|
+
return [Math.max(0, remaining * mean - spread), remaining * mean + spread];
|
|
92
|
+
}
|
|
93
|
+
// The range as whole numbers in the unit the high end reaches: the low end rounded down, the high end up.
|
|
94
|
+
export function etaSpan(range) {
|
|
95
|
+
const [size, unit] = range[1] < 60 ? [1, "second"] : range[1] < 3600 ? [60, "minute"] : [3600, "hour"];
|
|
96
|
+
return { low: Math.floor(range[0] / size), high: Math.ceil(range[1] / size), unit };
|
|
97
|
+
}
|
|
98
|
+
const SUFFIX = { second: "s", minute: "min", hour: "h" };
|
|
99
|
+
// The range as `ETA 2–4 min`, `ETA under 3 min` while the low end is zero, one value when both ends meet.
|
|
100
|
+
export function etaText(range) {
|
|
101
|
+
if (!range)
|
|
102
|
+
return "";
|
|
103
|
+
const { low, high, unit } = etaSpan(range);
|
|
104
|
+
if (low === high)
|
|
105
|
+
return `ETA ${high} ${SUFFIX[unit]}`;
|
|
106
|
+
return low === 0 ? `ETA under ${high} ${SUFFIX[unit]}` : `ETA ${low}–${high} ${SUFFIX[unit]}`;
|
|
107
|
+
}
|
|
108
|
+
// What the run is at: the page in flight while crawling, else the phase with its count.
|
|
109
|
+
function where() {
|
|
110
|
+
if (state.phase === "crawl")
|
|
111
|
+
return relative(state.page, state.origin);
|
|
112
|
+
return `${PHASE_TEXT[state.phase]}${state.count ? ` ${state.count.done}/${state.count.total}` : ""}`;
|
|
113
|
+
}
|
|
114
|
+
// The line as drawn: a bar, done/known, the ETA, the step, the page, seconds on the step.
|
|
115
|
+
function line() {
|
|
116
|
+
const { done } = state;
|
|
117
|
+
const total = Math.max(done, state.total);
|
|
118
|
+
const filled = total > 0 ? Math.min(BAR_WIDTH, Math.round((done / total) * BAR_WIDTH)) : 0;
|
|
119
|
+
const seconds = state.since > 0 ? `${Math.round((Date.now() - state.since) / 1000)} s` : "";
|
|
120
|
+
return [`▕${"█".repeat(filled)}${"░".repeat(BAR_WIDTH - filled)}▏`, `${done}/${total}`, etaText(eta(state.intervals, total - done)), state.step, where(), seconds].filter(Boolean).join(" ");
|
|
121
|
+
}
|
|
122
|
+
// Draws the line again in place.
|
|
123
|
+
function progressDraw() {
|
|
124
|
+
if (state.isShown)
|
|
125
|
+
logUpdateStderr(line());
|
|
126
|
+
}
|
|
127
|
+
// Writes `text` above the line, or straight to stderr while it is hidden.
|
|
128
|
+
export function progressPrint(text) {
|
|
129
|
+
if (!state.isShown) {
|
|
130
|
+
process.stderr.write(text);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
logUpdateStderr.persist(text);
|
|
134
|
+
progressDraw();
|
|
135
|
+
}
|
|
136
|
+
// Reads the known total, keeping the last one when the queue cannot answer, and redraws.
|
|
137
|
+
async function refresh(known) {
|
|
138
|
+
try {
|
|
139
|
+
state.total = await known();
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
log.debug({ error: String(error), total: state.total }, "progress total unreadable; last kept");
|
|
143
|
+
}
|
|
144
|
+
progressDraw();
|
|
145
|
+
notify();
|
|
146
|
+
}
|
|
147
|
+
// Redraws each second from `known` until the returned stop runs, which clears the line.
|
|
148
|
+
export function trackProgress(known, origin) {
|
|
149
|
+
if (!state.isOn && !observer.listener)
|
|
150
|
+
return () => { };
|
|
151
|
+
Object.assign(state, { isShown: state.isOn, origin, last: Date.now(), intervals: [], phase: "crawl", count: undefined });
|
|
152
|
+
const timer = setInterval(() => void refresh(known), REDRAW_MS);
|
|
153
|
+
timer.unref();
|
|
154
|
+
return () => {
|
|
155
|
+
clearInterval(timer);
|
|
156
|
+
if (state.isShown)
|
|
157
|
+
logUpdateStderr.clear();
|
|
158
|
+
if (state.isShown)
|
|
159
|
+
logUpdateStderr.done();
|
|
160
|
+
state.isShown = false;
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { mkdir } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { writeAtomic } from "../cache/index.js";
|
|
7
|
+
import { relative, singleOrigin } from "../crawl/scope.js";
|
|
8
|
+
import { isRanked } from "../facts/flatten.js";
|
|
9
|
+
import { log } from "../logger.js";
|
|
10
|
+
import { fixFor } from "../rules/fix.js";
|
|
11
|
+
import { cachedReads, inEnglish, valuesAt } from "../rules/message.js";
|
|
12
|
+
import { utc } from "../facts/read-note.js";
|
|
13
|
+
import { importance, scoreOf } from "../rules/score.js";
|
|
14
|
+
import { bundle } from "./human.js";
|
|
15
|
+
import { passing } from "./rating.js";
|
|
16
|
+
import { printable, printableFinding } from "./printable.js";
|
|
17
|
+
// Pages a fix clears: a fold’s occurrences, an aggregate’s URLs, else the one page.
|
|
18
|
+
function clears(finding) {
|
|
19
|
+
return finding.occurrences ?? finding.urls?.length ?? 1;
|
|
20
|
+
}
|
|
21
|
+
// The origin to re-audit for a finding: the audited one, else its URL’s, else the host it names.
|
|
22
|
+
function originOf(finding, shared) {
|
|
23
|
+
return shared || (URL.canParse(finding.url) ? new URL(finding.url).origin : `https://${finding.url}`);
|
|
24
|
+
}
|
|
25
|
+
// Findings grouped by rule, rules by their most important finding (score times the share of pages touched) then the pages they clear, findings within a rule by the same.
|
|
26
|
+
export function ordered(findings, pages) {
|
|
27
|
+
const byRule = new Map();
|
|
28
|
+
for (const finding of findings)
|
|
29
|
+
byRule.set(finding.rule, [...(byRule.get(finding.rule) ?? []), finding]);
|
|
30
|
+
const total = (same) => same.reduce((sum, finding) => sum + clears(finding), 0);
|
|
31
|
+
const top = (same) => Math.max(...same.map((finding) => importance(finding, pages)));
|
|
32
|
+
const rules = byRule
|
|
33
|
+
.values()
|
|
34
|
+
.map((same) => same.toSorted((a, b) => importance(b, pages) - importance(a, pages) || clears(b) - clears(a) || a.url.localeCompare(b.url)))
|
|
35
|
+
.toArray();
|
|
36
|
+
return rules.toSorted((a, b) => top(b) - top(a) || total(b) - total(a) || a[0].rule.localeCompare(b[0].rule));
|
|
37
|
+
}
|
|
38
|
+
// Where a finding sits: a fold’s samples, an aggregate’s URLs, or its pages, each with its values and locations.
|
|
39
|
+
function where(same, origin) {
|
|
40
|
+
const finding = same[0];
|
|
41
|
+
const named = (url, owner) => [relative(url, origin), ...valuesAt(owner, url, inEnglish).map((value) => relative(value, origin))].join(": ");
|
|
42
|
+
const at = (url, locations, owner = finding) => [`- ${named(url, owner)}`, ...(locations ?? []).map((location) => ` - at ${location}`)];
|
|
43
|
+
if (finding.occurrences !== undefined) {
|
|
44
|
+
const pages = finding.sampled === undefined ? `${finding.occurrences} pages` : `${finding.occurrences} of ${finding.sampled} sampled pages`;
|
|
45
|
+
return [`Where: ${pages} ${finding.groups ? `in groups ${finding.groups.join(", ")}` : `of group ${finding.group}`} (${Math.round((finding.coverage ?? 0) * 100)} %), for example:`, ...(finding.samples ?? []).flatMap((url) => at(url, finding.sampleLocations?.[url]))];
|
|
46
|
+
}
|
|
47
|
+
if (finding.urls)
|
|
48
|
+
return [`Where: ${named(finding.url, finding)}, used by or shared with:`, ...finding.urls.flatMap((url) => at(url, undefined)), ...(finding.locations ?? []).map((location) => `- at ${location}`)];
|
|
49
|
+
return same.length > 1 ? [`Where: ${same.length} pages:`, ...same.flatMap((page) => at(page.url, page.locations, page))] : [`Where: ${named(finding.url, finding)}`, ...(finding.locations ?? []).map((location) => `- at ${location}`)];
|
|
50
|
+
}
|
|
51
|
+
// The reads a bundle took from a cache, so the agent knows a fix may already be live.
|
|
52
|
+
function cached(same, origin) {
|
|
53
|
+
const reads = cachedReads(same);
|
|
54
|
+
return reads.length === 0 ? [] : ["From the cache (`--refresh` reads them again):", ...reads.map((read) => `- ${relative(read.key, origin) || read.key}${read.at ? `, read ${utc(read.at)}` : ""}`)];
|
|
55
|
+
}
|
|
56
|
+
// One self-contained task over findings sharing rule and message: rule and severity, message, place, what the rule reads and expects, the fix and how to prove it.
|
|
57
|
+
function block(same, guide, origin) {
|
|
58
|
+
const finding = same[0];
|
|
59
|
+
const target = originOf(finding, origin);
|
|
60
|
+
const reads = guide?.facts.length ? `Reads: ${guide.facts.map((fact) => `\`${fact}\``).join(", ")}${guide.expect ? `, expects \`${JSON.stringify(guide.expect)}\`` : ""}` : "";
|
|
61
|
+
const facts = finding.scope === "page" && guide?.expect ? `, and \`spiderlint show-facts ${finding.samples?.[0] ?? finding.url}\` shows \`${guide.facts[0]}\` meeting it` : "";
|
|
62
|
+
const fix = guide?.fix ? `Fix: ${fixFor(guide.fix, finding)}` : guide?.docs ? `Fix: follow ${guide.docs}` : "";
|
|
63
|
+
return [
|
|
64
|
+
`## ${finding.rule} (${finding.severity} ${scoreOf(finding).toFixed(1)})`,
|
|
65
|
+
"",
|
|
66
|
+
origin ? finding.message.replaceAll(`${origin}/`, "/") : finding.message,
|
|
67
|
+
"",
|
|
68
|
+
...where(same, origin),
|
|
69
|
+
...cached(same, origin),
|
|
70
|
+
...[reads, fix, guide?.fix && guide.docs ? `Docs: ${guide.docs}` : ""].filter(Boolean),
|
|
71
|
+
`Done when: \`spiderlint audit ${target}/ --rules ${finding.rule}\` reports no ${finding.rule} finding${facts}.`,
|
|
72
|
+
].join("\n");
|
|
73
|
+
}
|
|
74
|
+
// The origin every finding shares, from the pages, else from the findings when the report was stored without them.
|
|
75
|
+
function sharedOrigin(report) {
|
|
76
|
+
return singleOrigin(report.pages.length > 0 ? report.pages.map((page) => page.url.href) : report.findings.map((finding) => finding.url));
|
|
77
|
+
}
|
|
78
|
+
// Findings a coding agent acts on: hints only when listed.
|
|
79
|
+
function actionable(report, isHintListed) {
|
|
80
|
+
const findings = report.findings.filter((finding) => isHintListed || finding.severity !== "hint").map((finding) => printableFinding(finding));
|
|
81
|
+
log.debug({ findings: findings.length, hints: report.findings.length - findings.length, isHintListed }, "agent findings chosen");
|
|
82
|
+
return findings;
|
|
83
|
+
}
|
|
84
|
+
// CO2, bytes, requests and timings as one Markdown table, so an agent weighs a fix against the site; nothing without statistics.
|
|
85
|
+
function statistics(stats = {}) {
|
|
86
|
+
const rows = Object.entries(stats)
|
|
87
|
+
.filter(([path]) => isRanked(path))
|
|
88
|
+
.map(([path, stat]) => `| \`${printable(path).replaceAll("|", String.raw `\|`)}\` | ${[stat.count, stat.min, stat.median, stat.p95, stat.max, stat.total ?? "–"].join(" | ")} |`);
|
|
89
|
+
return rows.length === 0 ? [] : [["# Site statistics", "", "| Fact | Pages | Min | Median | p95 | Max | Total |", "| --- | --: | --: | --: | --: | --: | --: |", ...rows].join("\n")];
|
|
90
|
+
}
|
|
91
|
+
// How many checks and rules came out clean, as one line; the list stays in `json`.
|
|
92
|
+
function passed({ checks, checked }) {
|
|
93
|
+
return [`Checks passed: ${checks.passed} of ${checks.total}; ${passing(checked).length} rules failed nowhere.`];
|
|
94
|
+
}
|
|
95
|
+
// One block per finding after folding, no colour, then the fact statistics; the shared origin once on top and URLs under it relative.
|
|
96
|
+
export function formatAgent(report, _paint, _isFull, _lang, isHintListed = false) {
|
|
97
|
+
const origin = sharedOrigin(report);
|
|
98
|
+
const blocks = ordered(actionable(report, isHintListed), report.pages.length).flatMap((rule) => bundle(rule).map((same) => block(same, report.rules?.[same[0].rule], origin)));
|
|
99
|
+
return [`# spiderlint findings${origin ? ` for ${origin}` : ""}`, ...(blocks.length > 0 ? blocks : ["No findings."]), ...passed(report.summary), ...statistics(report.summary.stats)].join("\n\n");
|
|
100
|
+
}
|
|
101
|
+
// One Markdown prompt per rule, by file name, the same findings always giving the same bytes.
|
|
102
|
+
export function agentFiles(report, isHintListed = false) {
|
|
103
|
+
const origin = sharedOrigin(report);
|
|
104
|
+
const files = new Map();
|
|
105
|
+
const rules = ordered(actionable(report, isHintListed), report.pages.length);
|
|
106
|
+
for (const same of rules) {
|
|
107
|
+
const rule = same[0].rule;
|
|
108
|
+
const title = `# spiderlint: ${rule}${origin ? ` on ${origin}` : ""}`;
|
|
109
|
+
files.set(`${rule.replaceAll(/[^a-z0-9.-]+/gi, "-")}.md`, `${[title, ...bundle(same).map((bundled) => block(bundled, report.rules?.[rule], origin))].join("\n\n")}\n`);
|
|
110
|
+
}
|
|
111
|
+
log.debug({ files: files.size }, "agent files rendered");
|
|
112
|
+
return files;
|
|
113
|
+
}
|
|
114
|
+
// Writes each rule’s prompt into `directory`, atomically, so a reader never sees half a file.
|
|
115
|
+
export async function writeAgentFiles(directory, report, isHintListed = false) {
|
|
116
|
+
await mkdir(directory, { recursive: true });
|
|
117
|
+
const files = agentFiles(report, isHintListed);
|
|
118
|
+
const entries = [...files];
|
|
119
|
+
for (const [name, content] of entries)
|
|
120
|
+
await writeAtomic(path.join(directory, name), content);
|
|
121
|
+
log.info({ directory, files: files.size }, `${files.size} agent prompts written to ${directory}`);
|
|
122
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { printable } from "./printable.js";
|
|
5
|
+
const XML_ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
6
|
+
function escapeXml(text) {
|
|
7
|
+
return printable(text).replaceAll(/["&'<>]/g, (char) => XML_ESCAPES[char] ?? char);
|
|
8
|
+
}
|
|
9
|
+
// `line` and `column` from a `line:column selector` location, when the first one has them.
|
|
10
|
+
function position(finding) {
|
|
11
|
+
const match = /^(\d+):(\d+)/.exec(finding.locations?.[0] ?? "");
|
|
12
|
+
return match ? ` line="${match[1]}" column="${match[2]}"` : "";
|
|
13
|
+
}
|
|
14
|
+
function toError(finding) {
|
|
15
|
+
return ` <error${position(finding)} severity="${finding.severity === "hint" ? "info" : finding.severity}" message="${escapeXml(finding.message)}" source="spiderlint.${escapeXml(finding.rule)}"/>`;
|
|
16
|
+
}
|
|
17
|
+
// Checkstyle XML: one `<file>` per URL, in first-seen order, one `<error>` per finding.
|
|
18
|
+
export function formatCheckstyle(report) {
|
|
19
|
+
const byUrl = Map.groupBy(report.findings, (finding) => finding.url);
|
|
20
|
+
const files = [...byUrl].map(([url, findings]) => [` <file name="${escapeXml(url)}">`, ...findings.map((finding) => toError(finding)), " </file>"].join("\n"));
|
|
21
|
+
return ['<?xml version="1.0" encoding="UTF-8"?>', '<checkstyle version="10.12.0">', ...files, "</checkstyle>"].join("\n");
|
|
22
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { scoreOf } from "../rules/score.js";
|
|
5
|
+
const COLUMNS = ["severity", "score", "rule", "scope", "group", "url", "message", "occurrences", "locations"];
|
|
6
|
+
// RFC 4180 field: quoted when it holds a quote, comma or line break, quotes doubled.
|
|
7
|
+
export function field(value) {
|
|
8
|
+
const text = String(value ?? "");
|
|
9
|
+
return /[\n\r",]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
|
10
|
+
}
|
|
11
|
+
function toRow(finding) {
|
|
12
|
+
const cells = [finding.severity, scoreOf(finding).toFixed(1), finding.rule, finding.scope, finding.group, finding.url, finding.message, finding.occurrences, finding.locations?.join("\n")];
|
|
13
|
+
return cells.map((cell) => field(cell)).join(",");
|
|
14
|
+
}
|
|
15
|
+
// RFC 4180 CSV with a header row, one row per finding, CRLF line endings.
|
|
16
|
+
export function formatCsv(report) {
|
|
17
|
+
return [COLUMNS.join(","), ...report.findings.map((finding) => toRow(finding))].join("\r\n");
|
|
18
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { relative, singleOrigin } from "../crawl/scope.js";
|
|
5
|
+
import { shippedRules } from "../rules/catalog.js";
|
|
6
|
+
import { fixFor } from "../rules/fix.js";
|
|
7
|
+
import { cachedReads, sentence, valuesAt } from "../rules/message.js";
|
|
8
|
+
import { bytes, isLabelled, label, withUnit } from "../facts/labels.js";
|
|
9
|
+
import { utc } from "../facts/read-note.js";
|
|
10
|
+
import { environmentLanguage, environmentLocale, translator } from "../i18n.js";
|
|
11
|
+
import { impact, pagesOf, scoreOf } from "../rules/score.js";
|
|
12
|
+
import { ordered } from "./agent.js";
|
|
13
|
+
import { bundle } from "./human.js";
|
|
14
|
+
import { passing } from "./rating.js";
|
|
15
|
+
const ORDER = { error: 0, warning: 1, info: 2, hint: 3 };
|
|
16
|
+
const LIST = 5;
|
|
17
|
+
const ENTITIES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
18
|
+
// The one stylesheet of the report and the server pages, inline so a saved report stands alone.
|
|
19
|
+
export const STYLE = `
|
|
20
|
+
:root { color-scheme: light dark; --fg: light-dark(#26231c, #ece7da); --muted: light-dark(#6a6454, #a99f88); --bg: light-dark(#f7f5ef, #13110d); --surface: light-dark(#fff, #1b1812); --line: light-dark(#e3ded0, #353025); --accent: light-dark(#7a5c21, #dccfa6); --error: light-dark(#b3261e, #ff8a80); --warning: light-dark(#8a5a00, #ffcc66); --info: light-dark(#1d5fa8, #8ab4f8); --good: light-dark(#1e7a34, #7ee2a8); --good-bg: light-dark(#eef7f0, #14231a); --good-line: light-dark(#b9dcc3, #25492f); --mid: light-dark(#b45d00, #ffb870); --gap: 1.5rem; }
|
|
21
|
+
* { box-sizing: border-box; }
|
|
22
|
+
body { margin: 0 auto; max-inline-size: 68rem; padding: 0 var(--gap) 3rem; font: 1rem/1.6 system-ui, sans-serif; color: var(--fg); background: var(--bg); }
|
|
23
|
+
a { color: var(--accent); overflow-wrap: anywhere; text-underline-offset: .2em; }
|
|
24
|
+
h1 { font-size: 1.75rem; line-height: 1.25; margin-block: 0 .5rem; overflow-wrap: anywhere; }
|
|
25
|
+
h2 { font-size: 1.25rem; margin-block: 3rem 1rem; }
|
|
26
|
+
h3 { font-size: .95rem; font-weight: 600; margin-block: 2rem .75rem; }
|
|
27
|
+
p { margin-block: 0 1rem; }
|
|
28
|
+
small, .muted { color: var(--muted); }
|
|
29
|
+
code { font-size: .9em; overflow-wrap: anywhere; }
|
|
30
|
+
main { padding-block: 2.5rem; }
|
|
31
|
+
.site { display: flex; flex-wrap: wrap; gap: 1rem 2rem; align-items: center; justify-content: space-between; padding-block: 1.5rem; border-block-end: 1px solid var(--line); }
|
|
32
|
+
.site nav { display: flex; gap: 1.5rem; }
|
|
33
|
+
.brand { display: inline-flex; gap: .75rem; align-items: center; color: var(--fg); font-size: 1.15rem; font-weight: 700; letter-spacing: .02em; text-decoration: none; }
|
|
34
|
+
.brand img { display: block; }
|
|
35
|
+
.site nav a, .foot a { text-decoration: none; } .site nav a:hover, .foot a:hover { text-decoration: underline; }
|
|
36
|
+
.foot { display: flex; flex-wrap: wrap; gap: .5rem 1.5rem; padding-block: 1.5rem; border-block-start: 1px solid var(--line); font-size: .9rem; color: var(--muted); }
|
|
37
|
+
.foot p { flex: 1 1 100%; margin: 0; }
|
|
38
|
+
.hero { display: flex; gap: 2rem; align-items: center; padding: 2rem; background: var(--surface); border: 1px solid var(--line); }
|
|
39
|
+
.hero p { margin: 0; }
|
|
40
|
+
.grade { display: grid; place-items: center; inline-size: 5.5rem; block-size: 5.5rem; flex: none; font-size: 2.75rem; font-weight: 700; color: var(--bg); background: var(--muted); }
|
|
41
|
+
.grade-S, .grade-A { background: var(--good); } .grade-B { background: var(--warning); } .grade-C, .grade-D { background: var(--mid); } .grade-E, .grade-F { background: var(--error); }
|
|
42
|
+
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 1rem; margin-block: 1rem; }
|
|
43
|
+
.kpi { padding: 1rem 1.25rem; background: var(--surface); border: 1px solid var(--line); }
|
|
44
|
+
.kpi b { display: block; font-size: 1.5rem; line-height: 1.3; font-variant-numeric: tabular-nums; }
|
|
45
|
+
.kpi span { color: var(--muted); font-size: .8rem; letter-spacing: .04em; text-transform: uppercase; }
|
|
46
|
+
.kpi.good { background: var(--good-bg); border-color: var(--good-line); } .kpi.good b { color: var(--good); }
|
|
47
|
+
.pills { display: flex; flex-wrap: wrap; gap: .5rem; margin-block-end: 0; }
|
|
48
|
+
.pill { padding: .25rem .75rem; border: 1px solid var(--line); font-size: .875rem; color: var(--muted); }
|
|
49
|
+
.pill.error, .pill.warning, .pill.info { background: var(--surface); }
|
|
50
|
+
.notice { display: flex; flex-wrap: wrap; gap: .5rem 1.5rem; align-items: center; justify-content: space-between; margin-block: 0 1.5rem; padding: 1rem 1.25rem; background: var(--surface); border: 1px solid var(--line); border-inline-start: .25rem solid var(--accent); }
|
|
51
|
+
.notice p { margin: 0; }
|
|
52
|
+
.button, button { display: inline-block; font: inherit; padding: .75rem 1.5rem; border: 0; color: var(--bg); background: var(--accent); text-decoration: none; cursor: pointer; }
|
|
53
|
+
.button:hover, button:hover { filter: brightness(1.1); }
|
|
54
|
+
.rule-card { margin-block: 1rem; background: var(--surface); border: 1px solid var(--line); border-inline-start: .25rem solid var(--muted); }
|
|
55
|
+
.rule-card.error { border-inline-start-color: var(--error); } .rule-card.warning { border-inline-start-color: var(--warning); } .rule-card.info { border-inline-start-color: var(--info); }
|
|
56
|
+
.rule-card.passed { border-inline-start-color: var(--good); } .rule-card.untested { border-inline-start-color: var(--line); }
|
|
57
|
+
.rule-head { display: flex; align-items: baseline; gap: .75rem; padding: 1rem 1.25rem; list-style: none; }
|
|
58
|
+
.rule-card summary::marker { content: ""; } .rule-card summary::-webkit-details-marker { display: none; }
|
|
59
|
+
.rule-head .badge { flex: none; font-weight: 700; font-size: .8rem; letter-spacing: .04em; text-transform: uppercase; font-variant-numeric: tabular-nums; }
|
|
60
|
+
.rule-head code, .rule-head > a { flex: none; }
|
|
61
|
+
.rule-head .rule-message { overflow-wrap: anywhere; }
|
|
62
|
+
.rule-head .rule-arrow { margin-inline-start: auto; flex: none; color: var(--muted); }
|
|
63
|
+
.rule-findings { padding: 0 1.25rem .5rem; } .rule-findings .data { color: var(--muted); font-variant-numeric: tabular-nums; margin-inline-start: .75em; } .rule-findings p { margin-block: .5rem 0; }
|
|
64
|
+
.rule-foot { display: grid; grid-template-columns: minmax(0, 1fr) 10rem 13rem; gap: .5rem 1.5rem; padding: .75rem 1.25rem; border-block-start: 1px solid var(--line); font-size: .85rem; color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
65
|
+
.rule-foot > :nth-last-child(2) { grid-column: 2; } .rule-foot > :last-child { grid-column: 3; text-align: end; }
|
|
66
|
+
table { inline-size: 100%; border-collapse: collapse; background: var(--surface); }
|
|
67
|
+
th, td { text-align: start; vertical-align: top; padding: .75rem 1rem; border-block-end: 1px solid var(--line); }
|
|
68
|
+
th { color: var(--muted); font-weight: 500; font-size: .85rem; }
|
|
69
|
+
.error { color: var(--error); } .warning { color: var(--warning); } .info { color: var(--info); } .hint { color: var(--muted); }
|
|
70
|
+
summary { cursor: pointer; } summary h2, summary h3 { display: inline; margin: 0; } section > details:has(> summary > h2) { margin-block: 3rem 1rem; }
|
|
71
|
+
ul { margin: .5rem 0 0; padding-inline-start: 1.25rem; }
|
|
72
|
+
.tick { color: var(--good); font-weight: 700; }
|
|
73
|
+
h4 { font-size: .9rem; font-weight: 500; color: var(--muted); margin-block: 1.5rem .5rem; }
|
|
74
|
+
form { display: flex; flex-wrap: wrap; gap: .75rem; margin-block: 2rem; }
|
|
75
|
+
form label.muted { flex: 1 1 100%; }
|
|
76
|
+
fieldset { flex: 1 1 100%; border: 0; padding: 0; margin: 1rem 0 0; display: grid; gap: .5rem; } fieldset label { display: block; padding: .75rem 1rem; background: var(--surface); border: 1px solid var(--line); }
|
|
77
|
+
legend { padding: 0; }
|
|
78
|
+
input[type=radio] { flex: none; inline-size: auto; margin-inline-end: .5rem; }
|
|
79
|
+
input { flex: 1 1 20rem; font: inherit; padding: .75rem 1rem; border: 1px solid var(--line); color: inherit; background: var(--surface); }
|
|
80
|
+
progress { inline-size: 100%; block-size: .75rem; accent-color: var(--accent); }
|
|
81
|
+
.alert { border-inline-start: .25rem solid var(--error); padding: 1rem 1.25rem; background: light-dark(#fdecea, #2c1614); }
|
|
82
|
+
.skip { position: absolute; inset-inline-start: 1rem; inset-block-start: -4rem; padding: .5rem 1rem; background: var(--surface); z-index: 1; } .skip:focus { inset-block-start: 1rem; }
|
|
83
|
+
@media (max-width: 40rem) { .hero { flex-direction: column; align-items: flex-start; padding: 1.5rem; } body { padding-inline: 1rem; } .rule-foot { grid-template-columns: minmax(0, 1fr); } .rule-foot > :nth-last-child(2), .rule-foot > :last-child { grid-column: auto; text-align: start; } }
|
|
84
|
+
`;
|
|
85
|
+
// `value` as HTML text or a quoted attribute.
|
|
86
|
+
export function escape(value) {
|
|
87
|
+
return String(value).replaceAll(/[&<>"']/g, (character) => ENTITIES[character]);
|
|
88
|
+
}
|
|
89
|
+
// A whole page in the translator’s language and direction.
|
|
90
|
+
export function page(t, title, body, head = "") {
|
|
91
|
+
return `<!DOCTYPE html>\n<html lang="${t.lang}" dir="${t.dir}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="light dark"><meta name="theme-color" media="(prefers-color-scheme: light)" content="#f7f5ef"><meta name="theme-color" media="(prefers-color-scheme: dark)" content="#13110d"><title>${escape(title)}</title><style>${STYLE}</style>${head}</head><body>${body}</body></html>\n`;
|
|
92
|
+
}
|
|
93
|
+
// Translated severity names; the rule ID beside them is never translated.
|
|
94
|
+
function severityName(t, severity) {
|
|
95
|
+
return { error: t._("Error"), warning: t._("Warning"), info: t._("Info"), hint: t._("Hint") }[severity];
|
|
96
|
+
}
|
|
97
|
+
// A link to a crawled http(s) URL, shown relative to the shared origin; anything else is plain text.
|
|
98
|
+
function link(url, origin) {
|
|
99
|
+
const text = escape(relative(url, origin) || url);
|
|
100
|
+
return /^https?:\/\//i.test(url) ? `<a href="${escape(url)}" rel="nofollow noopener noreferrer">${text}</a>` : text;
|
|
101
|
+
}
|
|
102
|
+
// A list whose items past the first few fold into a disclosure.
|
|
103
|
+
function items(t, values) {
|
|
104
|
+
if (values.length === 0)
|
|
105
|
+
return "";
|
|
106
|
+
const shown = `<ul>${values
|
|
107
|
+
.slice(0, LIST)
|
|
108
|
+
.map((value) => `<li>${value}</li>`)
|
|
109
|
+
.join("")}</ul>`;
|
|
110
|
+
return values.length > LIST
|
|
111
|
+
? `${shown}<details><summary>${escape(t._("{count} more", { count: t.number(values.length - LIST) }))}</summary><ul>${values
|
|
112
|
+
.slice(LIST)
|
|
113
|
+
.map((value) => `<li>${value}</li>`)
|
|
114
|
+
.join("")}</ul></details>`
|
|
115
|
+
: shown;
|
|
116
|
+
}
|
|
117
|
+
// Locations under what they locate, as code.
|
|
118
|
+
function locations(t, found) {
|
|
119
|
+
return items(t, (found ?? []).map((location) => `<code>${escape(location)}</code>`));
|
|
120
|
+
}
|
|
121
|
+
// What was measured at `url`, after it.
|
|
122
|
+
function measured(t, finding, url) {
|
|
123
|
+
const values = valuesAt(finding, url, t.number);
|
|
124
|
+
return values.length === 0 ? "" : `<span class="data">${escape(values.join(" "))}</span>`;
|
|
125
|
+
}
|
|
126
|
+
// What a bundle names, how many pages it spans and those pages, each with its values and locations.
|
|
127
|
+
function reach(t, same, origin, total) {
|
|
128
|
+
const [first] = same;
|
|
129
|
+
const at = (finding, url, found) => `${link(url, origin)}${measured(t, finding, url)}${locations(t, found)}`;
|
|
130
|
+
if (same.length > 1)
|
|
131
|
+
return { subject: "", count: same.length, isWhole: total > 0 && same.length >= total, pages: same.map((finding) => at(finding, finding.url, finding.locations)) };
|
|
132
|
+
if (first.occurrences !== undefined)
|
|
133
|
+
return { subject: "", count: first.occurrences, isWhole: (first.coverage ?? 0) >= 1, pages: (first.samples ?? []).map((url) => at(first, url, first.sampleLocations?.[url])) };
|
|
134
|
+
if (first.urls)
|
|
135
|
+
return { subject: first.urls.includes(first.url) ? "" : first.url, count: first.urls.length, isWhole: total > 0 && first.urls.length >= total, pages: first.urls.map((url) => at(first, url)) };
|
|
136
|
+
return { subject: first.url, isWhole: first.scope === "site", pages: [] };
|
|
137
|
+
}
|
|
138
|
+
// The observations a bundle took from a cache, each with when it was read; nothing when every one came fresh.
|
|
139
|
+
function cached(t, same, origin) {
|
|
140
|
+
const reads = cachedReads(same);
|
|
141
|
+
if (reads.length === 0)
|
|
142
|
+
return "";
|
|
143
|
+
return `<p class="muted">${escape(t._("From the cache, so a refreshed run reads them again:"))}</p>${items(t, reads.map((read) => `${link(read.key, origin)}${read.at ? `<span class="data">${escape(t._("read {at}", { at: utc(read.at) }))}</span>` : ""}`))}`;
|
|
144
|
+
}
|
|
145
|
+
// The rule’s fix filled for this finding, then its docs link; nothing when the rule has neither.
|
|
146
|
+
function remedy(t, guide, finding) {
|
|
147
|
+
const fix = guide?.fix ? `<p>${escape(t._("Fix: {fix}", { fix: fixFor(guide.fix, finding) }))}</p>` : "";
|
|
148
|
+
const documentation = guide?.docs && /^https?:\/\//i.test(guide.docs) ? `<p><a href="${escape(guide.docs)}" rel="noopener noreferrer">${escape(t._("Documentation"))}</a></p>` : "";
|
|
149
|
+
return fix + documentation;
|
|
150
|
+
}
|
|
151
|
+
// Score times pages, summed over a bundle.
|
|
152
|
+
function bundleImpact(same, total) {
|
|
153
|
+
return same.reduce((sum, finding) => sum + impact(finding, total), 0);
|
|
154
|
+
}
|
|
155
|
+
// One card per bundle: severity, message and subject on its head; expanded, the pages, locations, fix and docs; rule, impact and scope on its foot.
|
|
156
|
+
function entry(t, same, origin, guides, total) {
|
|
157
|
+
const [first] = same;
|
|
158
|
+
const { subject, isWhole, pages } = reach(t, same, origin, total);
|
|
159
|
+
const said = sentence(first, t);
|
|
160
|
+
const message = origin ? said.replaceAll(`${origin}/`, "/") : said;
|
|
161
|
+
const hasLocations = same.some((finding) => finding.locations?.length) || Object.keys(first.sampleLocations ?? {}).length > 0;
|
|
162
|
+
const share = first.occurrences === undefined
|
|
163
|
+
? ""
|
|
164
|
+
: `<p class="muted">${escape(first.sampled === undefined ? t._("Pages: {count} ({share})", { count: t.number(first.occurrences), share: t.number(first.coverage ?? 0, { style: "percent" }) }) : t._("Sampled pages: {count} of {sampled} ({share})", { count: t.number(first.occurrences), sampled: t.number(first.sampled), share: t.number(first.coverage ?? 0, { style: "percent" }) }))}${first.groups ? ` ${escape(t._("Groups: {groups}", { groups: first.groups.join(", ") }))}` : ""}</p>`;
|
|
165
|
+
const own = same.length === 1 && first.occurrences === undefined && !first.urls ? `${measured(t, first, first.url)}${locations(t, first.locations)}` : "";
|
|
166
|
+
const listed = isWhole && !hasLocations ? "" : items(t, pages);
|
|
167
|
+
const pageCount = same.reduce((sum, finding) => sum + pagesOf(finding, total), 0);
|
|
168
|
+
const scope = isWhole || (total > 0 && pageCount >= total) ? t._("Whole site") : t._("Pages: {count}", { count: t.number(pageCount) });
|
|
169
|
+
const foot = `<code>${escape(first.rule)}</code><span>${escape(t._("Impact"))}: ${escape(t.number(bundleImpact(same, total), { maximumFractionDigits: 1 }))}</span><span>${escape(t._("Scope"))}: ${escape(scope)}</span>`;
|
|
170
|
+
return `<article class="rule-card ${first.severity}"><details><summary class="rule-head"><span class="badge ${first.severity}">${escape(severityName(t, first.severity))} ${scoreOf(first).toFixed(1)}</span><span class="rule-message">${escape(message)}${subject ? ` — ${link(subject, origin)}` : ""}</span><span class="rule-arrow" aria-hidden="true">▾</span></summary><div class="rule-findings">${share}${listed}${own}${cached(t, same, origin)}${remedy(t, guides?.[first.rule], first)}</div></details><footer class="rule-foot">${foot}</footer></article>`;
|
|
171
|
+
}
|
|
172
|
+
// One flat card per bundle, the highest impact first, a rule’s bundles in its own order on a tie.
|
|
173
|
+
function rules(t, findings, origin, guides, total) {
|
|
174
|
+
return bundle(ordered(findings, total).flat())
|
|
175
|
+
.toSorted((a, b) => bundleImpact(b, total) - bundleImpact(a, total))
|
|
176
|
+
.map((same) => entry(t, same, origin, guides, total))
|
|
177
|
+
.join("");
|
|
178
|
+
}
|
|
179
|
+
// A fact’s translated label with its path on hover, or the path as code when it has none.
|
|
180
|
+
function factName(t, path) {
|
|
181
|
+
const english = label(path);
|
|
182
|
+
return english ? `<span title="${escape(path)}">${escape(t._(english))}</span>` : `<code>${escape(path)}</code>`;
|
|
183
|
+
}
|
|
184
|
+
// Every labelled numeric fact’s pages, then min, median, p95, max and total in its unit, in a closed disclosure; nothing without statistics.
|
|
185
|
+
function statistics(t, stats = {}) {
|
|
186
|
+
const rows = Object.entries(stats)
|
|
187
|
+
.filter(([path]) => isLabelled(path))
|
|
188
|
+
.map(([path, stat]) => `<tr><td>${factName(t, path)}</td><td>${escape(t.number(stat.count))}</td>${[stat.min, stat.median, stat.p95, stat.max, stat.total].map((value) => `<td>${escape(value === undefined ? "–" : withUnit(path, value, t.number))}</td>`).join("")}</tr>`);
|
|
189
|
+
const head = [t._("Fact"), t._("Pages"), t._("Minimum"), t._("Median"), t._("95th percentile"), t._("Maximum"), t._("Total")].map((label) => `<th>${escape(label)}</th>`).join("");
|
|
190
|
+
return rows.length === 0 ? "" : `<section><details><summary><h2>${escape(t._("Statistics"))}</h2></summary><table><thead><tr>${head}</tr></thead><tbody>${rows.join("")}</tbody></table></details></section>`;
|
|
191
|
+
}
|
|
192
|
+
// The rules that ran and failed nowhere, one green card per rule; nothing without any.
|
|
193
|
+
function passed(t, report) {
|
|
194
|
+
const clean = passing(report.summary.checked);
|
|
195
|
+
const rows = clean.map(([id, rule]) => {
|
|
196
|
+
const fix = report.rules?.[id]?.fix;
|
|
197
|
+
const body = fix ? `<div class="rule-findings"><p class="muted">${escape(fixFor(fix))}</p></div>` : "";
|
|
198
|
+
const foot = `<span>${escape(t._("Impact"))}: ${escape(t.number(0))}</span><span>${escape(t._("Scope"))}: ${escape(t._("Pages: {count}", { count: t.number(rule.pages) }))}</span>`;
|
|
199
|
+
return `<details class="rule-card passed"><summary class="rule-head"><span class="tick" aria-hidden="true">✓</span><code>${escape(id)}</code><span class="rule-arrow" aria-hidden="true">▾</span></summary>${body}<footer class="rule-foot">${foot}</footer></details>`;
|
|
200
|
+
});
|
|
201
|
+
const counts = { rules: t.number(clean.length), pages: t.number(report.summary.pages), checks: t.number(clean.reduce((sum, [, rule]) => sum + rule.checks, 0)) };
|
|
202
|
+
const heading = escape(t._("Passed: {rules} rules, {pages} pages, {checks} checks", counts));
|
|
203
|
+
return rows.length === 0 ? "" : `<section class="passed"><details open><summary><h2>${heading}</h2></summary>${rows.join("")}</details></section>`;
|
|
204
|
+
}
|
|
205
|
+
// The shipped rules that judged nothing, one muted row per rule linked to its docs, with its preset’s description and presets; nothing when every rule ran.
|
|
206
|
+
function untested(t, ids = []) {
|
|
207
|
+
if (ids.length === 0)
|
|
208
|
+
return "";
|
|
209
|
+
const shipped = shippedRules();
|
|
210
|
+
const rows = ids.map((id) => {
|
|
211
|
+
const { docs, presets = [] } = shipped.get(id) ?? {};
|
|
212
|
+
const name = docs && /^https?:\/\//i.test(docs) ? `<a href="${escape(docs)}" rel="noopener noreferrer"><code>${escape(id)}</code></a>` : `<code>${escape(id)}</code>`;
|
|
213
|
+
const named = presets.length > 0 ? ` <small class="muted">· ${escape(t._("Presets: {names}", { names: presets.map((preset) => preset.name).join(", ") }))}</small>` : "";
|
|
214
|
+
return `<article class="rule-card untested"><div class="rule-head">${name}<span class="rule-message muted">${escape(presets[0]?.description ?? "")}${named}</span></div></article>`;
|
|
215
|
+
});
|
|
216
|
+
const heading = escape(t._("Skipped: {count} rules", { count: t.number(ids.length) }));
|
|
217
|
+
return `<section class="untested"><details><summary><h2>${heading}</h2></summary><p class="muted">${escape(t._("Rules this scan did not judge: its checks leave them out, or the site has nothing they apply to."))}</p>${rows.join("")}</details></section>`;
|
|
218
|
+
}
|
|
219
|
+
// The rating, the totals, then Findings (whole site, pages by group, vendors, hints), Passed, Statistics and Skipped; a finding’s sentence is translated when its template is catalogued.
|
|
220
|
+
export function reportBody(report, t, title, origin) {
|
|
221
|
+
const { summary } = report;
|
|
222
|
+
const { rating } = summary;
|
|
223
|
+
const started = new Intl.DateTimeFormat(t.locale, { dateStyle: "medium", timeStyle: "short" }).format(new Date(summary.started));
|
|
224
|
+
const head = `<header class="hero"><p class="grade grade-${rating?.grade ?? "none"}" title="${escape(t._("Rating"))}">${escape(rating?.grade ?? "–")}</p><div><h1>${escape(title)}</h1><p class="muted">${escape(rating ? t._("Rulesets: {names}", { names: rating.rulesets.join(", ") }) : t._("No checks ran"))} · ${escape(started)}</p></div></header>`;
|
|
225
|
+
const kpi = (value, name, tone = "") => `<div class="kpi${tone && ` ${tone}`}"><b>${escape(value)}</b><span>${escape(name)}</span></div>`;
|
|
226
|
+
const measured = [
|
|
227
|
+
kpi(t.number(summary.pages), t._("Pages")),
|
|
228
|
+
kpi(bytes(summary.bytes, t.number), t._("Size")),
|
|
229
|
+
kpi(t.number(summary.durationMs / 1000, { style: "unit", unit: "second" }), t._("Time")),
|
|
230
|
+
kpi(t._("{passed} of {total}", { passed: t.number(summary.checks.passed), total: t.number(summary.checks.total) }), t._("Checks passed"), "good"),
|
|
231
|
+
];
|
|
232
|
+
const counted = Object.keys(ORDER).map((severity) => {
|
|
233
|
+
const count = summary.findings[severity] ?? 0;
|
|
234
|
+
return `<span class="pill${count > 0 ? ` ${severity}` : ""}">${escape(`${severityName(t, severity)}: ${t.number(count)}`)}</span>`;
|
|
235
|
+
});
|
|
236
|
+
const totals = `<div class="kpis">${measured.join("")}</div><p class="pills">${counted.join("")}</p>`;
|
|
237
|
+
const groups = new Map();
|
|
238
|
+
const vendors = new Map();
|
|
239
|
+
const hints = report.findings.filter((finding) => finding.severity === "hint");
|
|
240
|
+
for (const finding of report.findings) {
|
|
241
|
+
if (finding.severity === "hint")
|
|
242
|
+
continue;
|
|
243
|
+
const [sections, key] = finding.vendor ? [vendors, finding.vendor] : [groups, finding.scope === "site" ? "" : finding.group];
|
|
244
|
+
sections.set(key, [...(sections.get(key) ?? []), finding]);
|
|
245
|
+
}
|
|
246
|
+
const cards = (findings) => rules(t, findings, origin, report.rules, summary.pages);
|
|
247
|
+
const site = groups.get("") ?? [];
|
|
248
|
+
groups.delete("");
|
|
249
|
+
const isOnlyDefault = groups.keys().every((group) => group === "default") && Object.keys(summary.groups).length <= 1;
|
|
250
|
+
const byGroup = [...groups].map(([group, findings]) => `${isOnlyDefault ? "" : `<h4>${escape(t._("Group: {name} · Pages: {count}", { name: group, count: t.number(summary.groups[group] ?? 0) }))}</h4>`}${cards(findings)}`);
|
|
251
|
+
const parts = [
|
|
252
|
+
site.length > 0 ? `<h3>${escape(t._("Whole site"))}</h3>${cards(site)}` : "",
|
|
253
|
+
byGroup.length > 0 ? `<h3>${escape(t._("Pages"))}</h3>${byGroup.join("")}` : "",
|
|
254
|
+
...[...vendors].map(([vendor, findings]) => `<h3>${escape(t._("Vendor: {name}", { name: vendor }))}</h3>${cards(findings)}`),
|
|
255
|
+
hints.length > 0 ? `<details><summary><h3>${escape(t._("Hints: {count}", { count: t.number(hints.length) }))}</h3></summary>${cards(hints)}</details>` : "",
|
|
256
|
+
].filter(Boolean);
|
|
257
|
+
const found = `<section><h2>${escape(t._("Findings"))}</h2>${parts.length > 0 ? parts.join("") : `<p>${escape(t._("No findings."))}</p>`}</section>`;
|
|
258
|
+
return `${head}${totals}${found}${passed(t, report)}${statistics(t, summary.stats)}${untested(t, summary.untested)}`;
|
|
259
|
+
}
|
|
260
|
+
// A standalone page in `lang`, the process locale unless named; colour and `isFull` do not apply, since every list folds into a disclosure.
|
|
261
|
+
export function formatHtml(report, _paint, _isFull, lang) {
|
|
262
|
+
const t = lang === undefined ? translator(environmentLanguage(), environmentLocale()) : translator(lang);
|
|
263
|
+
const origin = singleOrigin(report.pages.map((facts) => facts.url.href));
|
|
264
|
+
const title = origin || t._("spiderlint report");
|
|
265
|
+
return page(t, title, `<main>${reportBody(report, t, title, origin)}</main>`);
|
|
266
|
+
}
|