vortix-cli 0.1.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 +21 -0
- package/README.md +80 -0
- package/dist/adapters/index.d.ts +5 -0
- package/dist/adapters/types.d.ts +13 -0
- package/dist/checks/accessibility/axe.d.ts +2 -0
- package/dist/checks/accessibility/color-contrast.d.ts +2 -0
- package/dist/checks/accessibility/run-axe.d.ts +6 -0
- package/dist/checks/bugs/anchor-links.d.ts +2 -0
- package/dist/checks/bugs/broken-links.d.ts +2 -0
- package/dist/checks/bugs/console-errors.d.ts +2 -0
- package/dist/checks/bugs/nested-block-elements.d.ts +2 -0
- package/dist/checks/bugs/viewport-meta.d.ts +2 -0
- package/dist/checks/index.d.ts +2 -0
- package/dist/checks/maintainability/dead-css.d.ts +2 -0
- package/dist/checks/maintainability/duplication.d.ts +2 -0
- package/dist/checks/maintainability/license-check.d.ts +2 -0
- package/dist/checks/maintainability/outdated-dependencies.d.ts +2 -0
- package/dist/checks/performance/asset-weight.d.ts +2 -0
- package/dist/checks/performance/core-web-vitals.d.ts +2 -0
- package/dist/checks/performance/image-format.d.ts +2 -0
- package/dist/checks/performance/lazy-loading.d.ts +2 -0
- package/dist/checks/performance/third-party-impact.d.ts +2 -0
- package/dist/checks/privacy/external-fonts.d.ts +2 -0
- package/dist/checks/privacy/fingerprinting.d.ts +2 -0
- package/dist/checks/privacy/match-domains.d.ts +2 -0
- package/dist/checks/privacy/tracker-requests.d.ts +2 -0
- package/dist/checks/security/cookie-security.d.ts +2 -0
- package/dist/checks/security/dependency-vulnerabilities.d.ts +2 -0
- package/dist/checks/security/https-enforced.d.ts +2 -0
- package/dist/checks/security/mixed-content.d.ts +2 -0
- package/dist/checks/security/security-headers.d.ts +2 -0
- package/dist/checks/security/server-info-disclosure.d.ts +2 -0
- package/dist/checks/seo/canonical-url.d.ts +2 -0
- package/dist/checks/seo/meta-tags.d.ts +2 -0
- package/dist/checks/seo/og-images.d.ts +2 -0
- package/dist/checks/seo/robots-sitemap.d.ts +2 -0
- package/dist/checks/seo/structured-data.d.ts +2 -0
- package/dist/chunk-KNVCFVGJ.js +183 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +3112 -0
- package/dist/commands/check.d.ts +5 -0
- package/dist/commands/ci.d.ts +5 -0
- package/dist/commands/config.d.ts +1 -0
- package/dist/commands/detail-ui.d.ts +2 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/progress-ui.d.ts +3 -0
- package/dist/core/config.d.ts +6 -0
- package/dist/core/define-check.d.ts +2 -0
- package/dist/core/detail.d.ts +2 -0
- package/dist/core/finding.d.ts +2 -0
- package/dist/core/load-checks.d.ts +10 -0
- package/dist/core/messages.d.ts +3 -0
- package/dist/core/report.d.ts +15 -0
- package/dist/core/runner.d.ts +64 -0
- package/dist/core/score.d.ts +9 -0
- package/dist/core/types.d.ts +162 -0
- package/dist/dynamic/serve.d.ts +5 -0
- package/dist/dynamic/session.d.ts +8 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +8 -0
- package/dist/utils/box.d.ts +19 -0
- package/dist/utils/budget.d.ts +2 -0
- package/dist/utils/clipboard.d.ts +1 -0
- package/dist/utils/exec.d.ts +18 -0
- package/dist/utils/github-workflow.d.ts +9 -0
- package/dist/utils/glob.d.ts +3 -0
- package/dist/utils/html.d.ts +2 -0
- package/dist/utils/humanize.d.ts +1 -0
- package/dist/utils/package-manager.d.ts +3 -0
- package/dist/utils/pages.d.ts +2 -0
- package/dist/utils/resolve-bin.d.ts +1 -0
- package/package.json +87 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,3112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
CONFIG_DIR,
|
|
4
|
+
defineCheck,
|
|
5
|
+
getConfigPath,
|
|
6
|
+
loadConfig,
|
|
7
|
+
t
|
|
8
|
+
} from "./chunk-KNVCFVGJ.js";
|
|
9
|
+
|
|
10
|
+
// src/commands/check.ts
|
|
11
|
+
import * as p3 from "@clack/prompts";
|
|
12
|
+
|
|
13
|
+
// src/utils/box.ts
|
|
14
|
+
var ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
|
|
15
|
+
function visibleLength(str) {
|
|
16
|
+
return str.replace(ANSI_PATTERN, "").length;
|
|
17
|
+
}
|
|
18
|
+
function padVisible(str, width) {
|
|
19
|
+
return str + " ".repeat(Math.max(0, width - visibleLength(str)));
|
|
20
|
+
}
|
|
21
|
+
function wrapPlainText(text, maxWidth) {
|
|
22
|
+
if (visibleLength(text) <= maxWidth) return [text];
|
|
23
|
+
const words = text.split(" ");
|
|
24
|
+
const lines = [];
|
|
25
|
+
let current = "";
|
|
26
|
+
for (const word of words) {
|
|
27
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
28
|
+
if (current && visibleLength(candidate) > maxWidth) {
|
|
29
|
+
lines.push(current);
|
|
30
|
+
current = word;
|
|
31
|
+
} else {
|
|
32
|
+
current = candidate;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (current) lines.push(current);
|
|
36
|
+
return lines;
|
|
37
|
+
}
|
|
38
|
+
function renderBox(lines, options = {}) {
|
|
39
|
+
const padding = options.padding ?? 1;
|
|
40
|
+
const contentWidth = Math.max(options.minWidth ?? 0, ...lines.map(visibleLength), options.title ? visibleLength(options.title) + 2 : 0);
|
|
41
|
+
const innerWidth = contentWidth + padding * 2;
|
|
42
|
+
const top = options.title ? `\u250C\u2500 ${options.title} ${"\u2500".repeat(Math.max(1, innerWidth - visibleLength(options.title) - 3))}\u2510` : `\u250C${"\u2500".repeat(innerWidth)}\u2510`;
|
|
43
|
+
const bottom = `\u2514${"\u2500".repeat(innerWidth)}\u2518`;
|
|
44
|
+
const body = lines.map((line) => {
|
|
45
|
+
const fill = " ".repeat(Math.max(0, contentWidth - visibleLength(line)));
|
|
46
|
+
return `\u2502${" ".repeat(padding)}${line}${fill}${" ".repeat(padding)}\u2502`;
|
|
47
|
+
});
|
|
48
|
+
return [top, ...body, bottom].join("\n");
|
|
49
|
+
}
|
|
50
|
+
function divider(width = 44) {
|
|
51
|
+
return "\u2500".repeat(width);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/core/report.ts
|
|
55
|
+
import pc from "picocolors";
|
|
56
|
+
|
|
57
|
+
// src/core/score.ts
|
|
58
|
+
var CHECK_SCORE = {
|
|
59
|
+
pass: 100,
|
|
60
|
+
info: 90,
|
|
61
|
+
warn: 60,
|
|
62
|
+
error: 20
|
|
63
|
+
};
|
|
64
|
+
var SEVERITY_RANK = { info: 0, warn: 1, error: 2 };
|
|
65
|
+
var GRADE_RANK = { A: 4, B: 3, C: 2, D: 1, F: 0 };
|
|
66
|
+
function computeScore(checks, findings, skippedCheckIds = /* @__PURE__ */ new Set()) {
|
|
67
|
+
const scoredChecks = checks.filter((check) => !skippedCheckIds.has(check.id));
|
|
68
|
+
if (scoredChecks.length === 0) return { value: 100, grade: "A", cappedByError: false };
|
|
69
|
+
const worstByCheck = /* @__PURE__ */ new Map();
|
|
70
|
+
for (const finding of findings) {
|
|
71
|
+
if (skippedCheckIds.has(finding.checkId)) continue;
|
|
72
|
+
const current = worstByCheck.get(finding.checkId);
|
|
73
|
+
if (!current || SEVERITY_RANK[finding.severity] > SEVERITY_RANK[current]) {
|
|
74
|
+
worstByCheck.set(finding.checkId, finding.severity);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const checksByCategory = /* @__PURE__ */ new Map();
|
|
78
|
+
for (const check of scoredChecks) {
|
|
79
|
+
const list = checksByCategory.get(check.category);
|
|
80
|
+
if (list) list.push(check);
|
|
81
|
+
else checksByCategory.set(check.category, [check]);
|
|
82
|
+
}
|
|
83
|
+
const categoryScores = [...checksByCategory.values()].map((categoryChecks) => {
|
|
84
|
+
const total = categoryChecks.reduce((sum, check) => {
|
|
85
|
+
const worst = worstByCheck.get(check.id);
|
|
86
|
+
return sum + (worst ? CHECK_SCORE[worst] : CHECK_SCORE.pass);
|
|
87
|
+
}, 0);
|
|
88
|
+
return total / categoryChecks.length;
|
|
89
|
+
});
|
|
90
|
+
const value = Math.round(categoryScores.reduce((sum, s) => sum + s, 0) / categoryScores.length);
|
|
91
|
+
const hasErrorFinding = [...worstByCheck.values()].some((severity) => severity === "error");
|
|
92
|
+
const naturalGrade = toGrade(value);
|
|
93
|
+
const grade = hasErrorFinding ? worseOf(naturalGrade, "C") : naturalGrade;
|
|
94
|
+
return { value, grade, cappedByError: hasErrorFinding && GRADE_RANK[naturalGrade] > GRADE_RANK.C };
|
|
95
|
+
}
|
|
96
|
+
function toGrade(value) {
|
|
97
|
+
if (value >= 90) return "A";
|
|
98
|
+
if (value >= 80) return "B";
|
|
99
|
+
if (value >= 70) return "C";
|
|
100
|
+
if (value >= 60) return "D";
|
|
101
|
+
return "F";
|
|
102
|
+
}
|
|
103
|
+
function worseOf(a, b) {
|
|
104
|
+
return GRADE_RANK[a] <= GRADE_RANK[b] ? a : b;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/core/types.ts
|
|
108
|
+
var CATEGORIES = ["performance", "security", "accessibility", "bugs", "seo", "maintainability", "privacy"];
|
|
109
|
+
|
|
110
|
+
// src/core/report.ts
|
|
111
|
+
var SEVERITY_COLOR = {
|
|
112
|
+
error: pc.red,
|
|
113
|
+
warn: pc.yellow,
|
|
114
|
+
info: pc.gray
|
|
115
|
+
};
|
|
116
|
+
var SEVERITY_ICON = { error: "\u2716", warn: "\u26A0", info: "\u2139" };
|
|
117
|
+
var PASS_ICON = pc.green("\u2714");
|
|
118
|
+
var SKIP_ICON = pc.gray("\u2298");
|
|
119
|
+
var MODE_COLOR = {
|
|
120
|
+
static: pc.cyan,
|
|
121
|
+
dynamic: pc.magenta,
|
|
122
|
+
live: pc.blue
|
|
123
|
+
};
|
|
124
|
+
function modeBadge(mode) {
|
|
125
|
+
return MODE_COLOR[mode](`[${mode}]`);
|
|
126
|
+
}
|
|
127
|
+
var GRADE_COLOR = {
|
|
128
|
+
A: pc.green,
|
|
129
|
+
B: pc.green,
|
|
130
|
+
C: pc.yellow,
|
|
131
|
+
D: pc.yellow,
|
|
132
|
+
F: pc.red
|
|
133
|
+
};
|
|
134
|
+
var GRADE_ART = {
|
|
135
|
+
A: [" \u2588\u2588 ", "\u2588 \u2588", "\u2588\u2588\u2588\u2588", "\u2588 \u2588", "\u2588 \u2588"],
|
|
136
|
+
B: ["\u2588\u2588\u2588 ", "\u2588 \u2588", "\u2588\u2588\u2588 ", "\u2588 \u2588", "\u2588\u2588\u2588 "],
|
|
137
|
+
C: ["\u2588\u2588\u2588 ", "\u2588 ", "\u2588 ", "\u2588 ", "\u2588\u2588\u2588 "],
|
|
138
|
+
D: ["\u2588\u2588\u2588 ", "\u2588 \u2588", "\u2588 \u2588", "\u2588 \u2588", "\u2588\u2588\u2588 "],
|
|
139
|
+
F: ["\u2588\u2588\u2588\u2588", "\u2588 ", "\u2588\u2588\u2588 ", "\u2588 ", "\u2588 "]
|
|
140
|
+
};
|
|
141
|
+
var MAX_EXAMPLE_MESSAGE_LENGTH = 70;
|
|
142
|
+
var SCORECARD_WIDTH = 64;
|
|
143
|
+
function printReport(result) {
|
|
144
|
+
const lines = [];
|
|
145
|
+
for (const message of result.configErrors) {
|
|
146
|
+
lines.push(pc.yellow(`\u26A0 ${t("report.configErrorPrefix")}: ${message}`));
|
|
147
|
+
}
|
|
148
|
+
if (result.configErrors.length > 0) lines.push("");
|
|
149
|
+
lines.push(pc.bold(`
|
|
150
|
+
${buildHeader(result)}
|
|
151
|
+
`));
|
|
152
|
+
lines.push(buildScoreCard(result));
|
|
153
|
+
lines.push("");
|
|
154
|
+
const findingsByCheck = groupBy(result.findings, (f) => f.checkId);
|
|
155
|
+
const checksByCategory = groupBy(result.checks, (c) => c.category);
|
|
156
|
+
for (const category of CATEGORIES) {
|
|
157
|
+
const checks = checksByCategory.get(category);
|
|
158
|
+
if (!checks) continue;
|
|
159
|
+
lines.push(pc.bold(category.toUpperCase()));
|
|
160
|
+
for (const check of checks) {
|
|
161
|
+
const skipReason = result.skipped[check.id];
|
|
162
|
+
lines.push(skipReason ? skipLine(check, skipReason) : checkLine(check, findingsByCheck.get(check.id) ?? []));
|
|
163
|
+
}
|
|
164
|
+
lines.push("");
|
|
165
|
+
}
|
|
166
|
+
console.log(lines.join("\n"));
|
|
167
|
+
}
|
|
168
|
+
var GRADE_ART_GAP = " ";
|
|
169
|
+
var CATEGORY_GRID_COLUMNS = 3;
|
|
170
|
+
var CATEGORY_GRID_GAP = " ";
|
|
171
|
+
function buildScoreCard(result, color = true) {
|
|
172
|
+
const skippedIds = new Set(Object.keys(result.skipped));
|
|
173
|
+
const score = computeScore(result.checks, result.findings, skippedIds);
|
|
174
|
+
const counts = countBySeverity(result.findings);
|
|
175
|
+
const gradeColor = GRADE_COLOR[score.grade];
|
|
176
|
+
const infoLines = [
|
|
177
|
+
color ? pc.bold(`${score.value}/100`) : `${score.value}/100`,
|
|
178
|
+
color ? gradeColor(pc.bold(`Grade ${score.grade}`)) : `Grade ${score.grade}`,
|
|
179
|
+
`${pluralize(counts.error, "error", "errors")}, ${pluralize(counts.warn, "warning", "warnings")}, ${pluralize(counts.info, "notice", "notices")}`
|
|
180
|
+
];
|
|
181
|
+
const art = GRADE_ART[score.grade];
|
|
182
|
+
const topPad = Math.floor((art.length - infoLines.length) / 2);
|
|
183
|
+
const lines = [];
|
|
184
|
+
for (let i = 0; i < art.length; i++) {
|
|
185
|
+
const artLine = color ? gradeColor(art[i]) : art[i];
|
|
186
|
+
const info = infoLines[i - topPad] ?? "";
|
|
187
|
+
lines.push(`${artLine}${GRADE_ART_GAP}${info}`);
|
|
188
|
+
}
|
|
189
|
+
lines.push("");
|
|
190
|
+
lines.push(color ? pc.dim(t("report.byCategoryLabel")) : t("report.byCategoryLabel"));
|
|
191
|
+
lines.push(...buildCategoryGrid(result, color));
|
|
192
|
+
const notes = scoreNotes(result, score);
|
|
193
|
+
if (notes.length > 0) {
|
|
194
|
+
lines.push("");
|
|
195
|
+
for (const note2 of notes) {
|
|
196
|
+
for (const wrapped of wrapPlainText(note2, SCORECARD_WIDTH)) lines.push(color ? pc.dim(wrapped) : wrapped);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return renderBox(lines, { title: t("report.overallScoreLabel").toUpperCase(), minWidth: SCORECARD_WIDTH, padding: 2 });
|
|
200
|
+
}
|
|
201
|
+
function buildCategoryGrid(result, color) {
|
|
202
|
+
const findingsByCheck = groupBy(result.findings, (f) => f.checkId);
|
|
203
|
+
const checksByCategory = groupBy(result.checks, (c) => c.category);
|
|
204
|
+
const cells = [];
|
|
205
|
+
for (const category of CATEGORIES) {
|
|
206
|
+
const checks = checksByCategory.get(category);
|
|
207
|
+
if (!checks) continue;
|
|
208
|
+
const active = checks.filter((c) => !result.skipped[c.id]);
|
|
209
|
+
if (active.length === 0) {
|
|
210
|
+
cells.push(color ? pc.gray(`${SKIP_ICON} ${category}`) : `\u2298 ${category}`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
let worst;
|
|
214
|
+
for (const check of active) {
|
|
215
|
+
for (const finding of findingsByCheck.get(check.id) ?? []) {
|
|
216
|
+
if (!worst || SEVERITY_RANK[finding.severity] > SEVERITY_RANK[worst]) worst = finding.severity;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const icon = worst ? SEVERITY_ICON[worst] : "\u2714";
|
|
220
|
+
const text = `${icon} ${category}`;
|
|
221
|
+
cells.push(color ? worst ? SEVERITY_COLOR[worst](text) : pc.green(text) : text);
|
|
222
|
+
}
|
|
223
|
+
const columnWidth = Math.max(...cells.map(visibleLength));
|
|
224
|
+
const rows = [];
|
|
225
|
+
for (let i = 0; i < cells.length; i += CATEGORY_GRID_COLUMNS) {
|
|
226
|
+
const rowCells = cells.slice(i, i + CATEGORY_GRID_COLUMNS);
|
|
227
|
+
rows.push(rowCells.map((cell, j) => j === rowCells.length - 1 ? cell : padVisible(cell, columnWidth)).join(CATEGORY_GRID_GAP));
|
|
228
|
+
}
|
|
229
|
+
return rows;
|
|
230
|
+
}
|
|
231
|
+
var STATUS_TAG = {
|
|
232
|
+
error: "[FAILED]",
|
|
233
|
+
warn: "[WARN] ",
|
|
234
|
+
info: "[INFO] ",
|
|
235
|
+
pass: "[PASS] ",
|
|
236
|
+
skip: "[SKIP] "
|
|
237
|
+
};
|
|
238
|
+
var STATUS_COLOR = {
|
|
239
|
+
error: pc.red,
|
|
240
|
+
warn: pc.yellow,
|
|
241
|
+
info: pc.cyan,
|
|
242
|
+
pass: pc.green,
|
|
243
|
+
skip: pc.gray
|
|
244
|
+
};
|
|
245
|
+
function checkOptionLabel(check, findings, skipReason) {
|
|
246
|
+
const key = skipReason ? "skip" : findings.length === 0 ? "pass" : worstSeverity(countBySeverity(findings));
|
|
247
|
+
const status = skipReason ? "skipped" : findings.length === 0 ? "passed" : severitySummary(countBySeverity(findings));
|
|
248
|
+
return { label: `${STATUS_COLOR[key](STATUS_TAG[key])} ${check.name}`, hint: `${check.category} \xB7 ${check.mode} \xB7 ${status}` };
|
|
249
|
+
}
|
|
250
|
+
function buildFullReport(result) {
|
|
251
|
+
const lines = [];
|
|
252
|
+
for (const message of result.configErrors) {
|
|
253
|
+
lines.push(`\u26A0 ${t("report.configErrorPrefix")}: ${message}`);
|
|
254
|
+
}
|
|
255
|
+
if (result.configErrors.length > 0) lines.push("");
|
|
256
|
+
lines.push(t("report.header", { adapter: result.adapterName, totalPages: result.totalPages, pagesChecked: result.pagesChecked }));
|
|
257
|
+
lines.push("");
|
|
258
|
+
lines.push(buildScoreCard(result, false));
|
|
259
|
+
lines.push("");
|
|
260
|
+
const findingsByCheck = groupBy(result.findings, (f) => f.checkId);
|
|
261
|
+
const checksByCategory = groupBy(result.checks, (c) => c.category);
|
|
262
|
+
for (const category of CATEGORIES) {
|
|
263
|
+
const checks = checksByCategory.get(category);
|
|
264
|
+
if (!checks) continue;
|
|
265
|
+
lines.push(category.toUpperCase());
|
|
266
|
+
for (const check of checks) {
|
|
267
|
+
const skipReason = result.skipped[check.id];
|
|
268
|
+
if (skipReason) {
|
|
269
|
+
lines.push(` \u2298 ${check.name} [${check.mode}] \u2014 skipped: ${skipReason}`);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const findings = findingsByCheck.get(check.id) ?? [];
|
|
273
|
+
if (findings.length === 0) {
|
|
274
|
+
lines.push(` \u2714 ${check.name} [${check.mode}]`);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
lines.push(` ${SEVERITY_ICON[worstSeverity(countBySeverity(findings))]} ${check.name} [${check.mode}]`);
|
|
278
|
+
for (const finding of findings) {
|
|
279
|
+
const location = [finding.file, finding.url].filter(Boolean).join(" ");
|
|
280
|
+
lines.push(` - [${finding.severity}] ${finding.message}${location ? ` (${location})` : ""}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
lines.push("");
|
|
284
|
+
}
|
|
285
|
+
return lines.join("\n");
|
|
286
|
+
}
|
|
287
|
+
function buildHeader(result) {
|
|
288
|
+
const header = t("report.header", { adapter: result.adapterName, totalPages: result.totalPages, pagesChecked: result.pagesChecked });
|
|
289
|
+
const hasLiveChecks = result.checks.some((c) => c.mode === "live");
|
|
290
|
+
if (hasLiveChecks && result.liveUrl) {
|
|
291
|
+
return header + t("report.headerLiveSuffix", { url: result.liveUrl });
|
|
292
|
+
}
|
|
293
|
+
return header;
|
|
294
|
+
}
|
|
295
|
+
function scoreNotes(result, score) {
|
|
296
|
+
const notes = [];
|
|
297
|
+
if (score.cappedByError) notes.push(`(${t("report.scoreCappedNote")})`);
|
|
298
|
+
if (result.pagesChecked < result.totalPages) {
|
|
299
|
+
notes.push(t("report.scorePartialPagesNote", { pagesChecked: result.pagesChecked, totalPages: result.totalPages }));
|
|
300
|
+
}
|
|
301
|
+
const hasLiveChecks = result.checks.some((c) => c.mode === "live");
|
|
302
|
+
if (hasLiveChecks && !result.liveUrl) {
|
|
303
|
+
notes.push(t("report.scoreLiveSkippedNote"));
|
|
304
|
+
}
|
|
305
|
+
return notes;
|
|
306
|
+
}
|
|
307
|
+
function skipLine(check, reason) {
|
|
308
|
+
return ` ${SKIP_ICON} ${check.name} ${modeBadge(check.mode)} ${pc.dim(`\u2014 skipped: ${reason}`)}`;
|
|
309
|
+
}
|
|
310
|
+
function checkLine(check, findings) {
|
|
311
|
+
if (findings.length === 0) {
|
|
312
|
+
return ` ${PASS_ICON} ${check.name} ${modeBadge(check.mode)}`;
|
|
313
|
+
}
|
|
314
|
+
const counts = countBySeverity(findings);
|
|
315
|
+
const worst = worstSeverity(counts);
|
|
316
|
+
const summary = severitySummary(counts);
|
|
317
|
+
const topFinding = findings.find((f) => f.severity === worst) ?? findings[0];
|
|
318
|
+
const more = findings.length > 1 ? pc.dim(t("report.moreFindingsSuffix", { count: findings.length - 1 })) : "";
|
|
319
|
+
return ` ${SEVERITY_COLOR[worst](SEVERITY_ICON[worst])} ${check.name} ${modeBadge(check.mode)} ${pc.dim(`\u2014 ${summary}:`)} ${truncate(topFinding.message, MAX_EXAMPLE_MESSAGE_LENGTH)}${more}`;
|
|
320
|
+
}
|
|
321
|
+
function buildCheckDetail(check, findings, details, skipReason) {
|
|
322
|
+
const counts = countBySeverity(findings);
|
|
323
|
+
const statusKey = skipReason ? "skip" : findings.length === 0 ? "pass" : worstSeverity(counts);
|
|
324
|
+
const statusText = skipReason ? t("report.detailStatusSkipped", { reason: skipReason }) : findings.length === 0 ? t("report.detailStatusPass") : t("report.detailStatusIssues", { summary: severitySummary(counts) });
|
|
325
|
+
const lines = [];
|
|
326
|
+
lines.push(`${STATUS_COLOR[statusKey](STATUS_TAG[statusKey].trim())} ${pc.bold(check.name)}`);
|
|
327
|
+
lines.push(pc.dim(`${check.category} \xB7 ${modeBadge(check.mode)}${check.description ? ` \xB7 ${check.description}` : ""}`));
|
|
328
|
+
lines.push(statusText);
|
|
329
|
+
lines.push(pc.dim(divider()));
|
|
330
|
+
lines.push(pc.bold(t("report.detailFindingsHeader")));
|
|
331
|
+
if (findings.length === 0) {
|
|
332
|
+
lines.push(` ${t("report.detailNoFindings")}`);
|
|
333
|
+
} else {
|
|
334
|
+
for (const finding of findings) {
|
|
335
|
+
const location = [finding.file, finding.url].filter(Boolean).join(" ");
|
|
336
|
+
lines.push(
|
|
337
|
+
` ${SEVERITY_COLOR[finding.severity](SEVERITY_ICON[finding.severity])} [${finding.severity}] ${finding.message}${location ? ` ${pc.dim(`(${location})`)}` : ""}`
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
lines.push(pc.dim(divider()));
|
|
342
|
+
lines.push(pc.bold(t("report.detailValuesHeader")));
|
|
343
|
+
if (details.length === 0) {
|
|
344
|
+
lines.push(` ${t("report.detailNoValues")}`);
|
|
345
|
+
} else {
|
|
346
|
+
for (const line of aggregateDetailLines(details)) lines.push(` ${line}`);
|
|
347
|
+
}
|
|
348
|
+
return lines.join("\n");
|
|
349
|
+
}
|
|
350
|
+
function aggregateDetailLines(details) {
|
|
351
|
+
const byLabel = groupBy(details, (d) => d.label);
|
|
352
|
+
const lines = [];
|
|
353
|
+
for (const [label, group] of byLabel) {
|
|
354
|
+
if (group.length === 1) {
|
|
355
|
+
lines.push(`${label}: ${pc.bold(group[0].value)}`);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const parsed = group.map((entry) => parseLeadingNumber(entry.value));
|
|
359
|
+
if (parsed.every((p6) => p6 !== null)) {
|
|
360
|
+
const nums = parsed.map((p6) => p6?.num);
|
|
361
|
+
const rest = parsed[0]?.rest;
|
|
362
|
+
const med = formatNum(median(nums));
|
|
363
|
+
const avg = formatNum(nums.reduce((a, b) => a + b, 0) / nums.length);
|
|
364
|
+
lines.push(`${label}: median ${pc.bold(med + rest)}, avg ${pc.bold(avg + rest)} ${pc.dim(`(${group.length} pages)`)}`);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
const distinct = [...new Set(group.map((entry) => entry.value))];
|
|
368
|
+
if (distinct.length === 1) {
|
|
369
|
+
lines.push(`${label}: ${pc.bold(distinct[0])} ${pc.dim(`(${group.length} pages)`)}`);
|
|
370
|
+
} else {
|
|
371
|
+
const sample = distinct.slice(0, 3).map((v) => `"${v}"`).join(", ");
|
|
372
|
+
lines.push(
|
|
373
|
+
`${label}: ${distinct.length} different values ${pc.dim(`across ${group.length} pages, e.g. ${sample}${distinct.length > 3 ? ", ..." : ""}`)}`
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return lines;
|
|
378
|
+
}
|
|
379
|
+
function parseLeadingNumber(value) {
|
|
380
|
+
const match = value.match(/^(-?\d+(?:\.\d+)?)(.*)$/);
|
|
381
|
+
if (!match) return null;
|
|
382
|
+
return { num: Number(match[1]), rest: match[2] };
|
|
383
|
+
}
|
|
384
|
+
function median(nums) {
|
|
385
|
+
const sorted = [...nums].sort((a, b) => a - b);
|
|
386
|
+
const mid = Math.floor(sorted.length / 2);
|
|
387
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
388
|
+
}
|
|
389
|
+
function formatNum(n) {
|
|
390
|
+
return Number.isInteger(n) ? String(n) : String(Math.round(n * 100) / 100);
|
|
391
|
+
}
|
|
392
|
+
function worstSeverity(counts) {
|
|
393
|
+
if (counts.error > 0) return "error";
|
|
394
|
+
if (counts.warn > 0) return "warn";
|
|
395
|
+
return "info";
|
|
396
|
+
}
|
|
397
|
+
function severitySummary(counts) {
|
|
398
|
+
return [
|
|
399
|
+
counts.error > 0 && pluralize(counts.error, "error", "errors"),
|
|
400
|
+
counts.warn > 0 && pluralize(counts.warn, "warning", "warnings"),
|
|
401
|
+
counts.info > 0 && pluralize(counts.info, "notice", "notices")
|
|
402
|
+
].filter(Boolean).join(", ");
|
|
403
|
+
}
|
|
404
|
+
function truncate(message, max) {
|
|
405
|
+
return message.length > max ? `${message.slice(0, max - 1)}\u2026` : message;
|
|
406
|
+
}
|
|
407
|
+
function pluralize(count, singular, plural) {
|
|
408
|
+
return `${count} ${count === 1 ? singular : plural}`;
|
|
409
|
+
}
|
|
410
|
+
function groupBy(items, keyFn) {
|
|
411
|
+
const map = /* @__PURE__ */ new Map();
|
|
412
|
+
for (const item of items) {
|
|
413
|
+
const key = keyFn(item);
|
|
414
|
+
const list = map.get(key) ?? [];
|
|
415
|
+
list.push(item);
|
|
416
|
+
map.set(key, list);
|
|
417
|
+
}
|
|
418
|
+
return map;
|
|
419
|
+
}
|
|
420
|
+
function countBySeverity(findings) {
|
|
421
|
+
const counts = { error: 0, warn: 0, info: 0 };
|
|
422
|
+
for (const finding of findings) counts[finding.severity]++;
|
|
423
|
+
return counts;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// src/core/runner.ts
|
|
427
|
+
import { existsSync as existsSync5, readFileSync as readFileSync4, statSync as statSync2 } from "fs";
|
|
428
|
+
import path14 from "path";
|
|
429
|
+
|
|
430
|
+
// src/adapters/index.ts
|
|
431
|
+
import { existsSync, readFileSync, statSync } from "fs";
|
|
432
|
+
import path from "path";
|
|
433
|
+
function hasAny(cwd, files) {
|
|
434
|
+
return files.some((f) => existsSync(path.join(cwd, f)));
|
|
435
|
+
}
|
|
436
|
+
function hasDir(cwd, dirs) {
|
|
437
|
+
return dirs.some((d) => {
|
|
438
|
+
try {
|
|
439
|
+
return statSync(path.join(cwd, d)).isDirectory();
|
|
440
|
+
} catch {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
function readFirst(cwd, files, maxBytes = 8192) {
|
|
446
|
+
for (const f of files) {
|
|
447
|
+
const full = path.join(cwd, f);
|
|
448
|
+
try {
|
|
449
|
+
if (!statSync(full).isFile()) continue;
|
|
450
|
+
const content = readFileSync(full, "utf-8");
|
|
451
|
+
return content.slice(0, maxBytes);
|
|
452
|
+
} catch {
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return "";
|
|
456
|
+
}
|
|
457
|
+
function hasPackageDependency(cwd, names) {
|
|
458
|
+
try {
|
|
459
|
+
const pkg = JSON.parse(readFileSync(path.join(cwd, "package.json"), "utf-8"));
|
|
460
|
+
const all = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
461
|
+
return names.some((n) => n in all);
|
|
462
|
+
} catch {
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
var COMMON_OUTPUT_DIRS = ["dist", "build", "public", "_site", "output", "out", ".output/public", "site", "www"];
|
|
467
|
+
function findExistingOutputDir(cwd) {
|
|
468
|
+
return COMMON_OUTPUT_DIRS.find((dir) => {
|
|
469
|
+
try {
|
|
470
|
+
return statSync(path.join(cwd, dir)).isDirectory();
|
|
471
|
+
} catch {
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
var astro = {
|
|
477
|
+
name: "astro",
|
|
478
|
+
detect: (cwd) => hasAny(cwd, ["astro.config.mjs", "astro.config.ts", "astro.config.js", "astro.config.cjs"]),
|
|
479
|
+
buildCommand: "npx astro build",
|
|
480
|
+
outputDir: "dist",
|
|
481
|
+
resolveServeDir: (outputDir) => {
|
|
482
|
+
const clientDir = path.join(outputDir, "client");
|
|
483
|
+
const serverDir = path.join(outputDir, "server");
|
|
484
|
+
if (existsSync(clientDir) && existsSync(serverDir)) return clientDir;
|
|
485
|
+
return outputDir;
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
var eleventy = {
|
|
489
|
+
name: "eleventy",
|
|
490
|
+
detect: (cwd) => hasAny(cwd, [".eleventy.js", ".eleventy.cjs", "eleventy.config.js", "eleventy.config.mjs", "eleventy.config.cjs"]),
|
|
491
|
+
buildCommand: "npx @11ty/eleventy",
|
|
492
|
+
outputDir: "_site"
|
|
493
|
+
};
|
|
494
|
+
var nextjs = {
|
|
495
|
+
name: "nextjs",
|
|
496
|
+
// Static export only (`output: "export"` in next.config.*) produces servable HTML in `out/`;
|
|
497
|
+
// a default Next.js build is server-rendered and out of scope for this tool.
|
|
498
|
+
detect: (cwd) => {
|
|
499
|
+
if (!hasAny(cwd, ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"])) return false;
|
|
500
|
+
const content = readFirst(cwd, ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"]);
|
|
501
|
+
return /output\s*:\s*["']export["']/.test(content);
|
|
502
|
+
},
|
|
503
|
+
buildCommand: "npx next build",
|
|
504
|
+
outputDir: "out"
|
|
505
|
+
};
|
|
506
|
+
var nuxt = {
|
|
507
|
+
name: "nuxt",
|
|
508
|
+
detect: (cwd) => hasAny(cwd, ["nuxt.config.js", "nuxt.config.ts", "nuxt.config.mjs"]),
|
|
509
|
+
buildCommand: "npx nuxi generate",
|
|
510
|
+
outputDir: ".output/public",
|
|
511
|
+
resolveServeDir: (outputDir) => {
|
|
512
|
+
if (!existsSync(outputDir) && existsSync(path.join(path.dirname(path.dirname(outputDir)), "dist"))) {
|
|
513
|
+
return path.join(path.dirname(path.dirname(outputDir)), "dist");
|
|
514
|
+
}
|
|
515
|
+
return outputDir;
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
var sveltekit = {
|
|
519
|
+
name: "sveltekit",
|
|
520
|
+
// A static build requires @sveltejs/adapter-static. That cannot be verified from the config
|
|
521
|
+
// alone, so a missing outputDir is reported as an explicit error later on.
|
|
522
|
+
detect: (cwd) => hasAny(cwd, ["svelte.config.js", "svelte.config.mjs", "svelte.config.ts"]) && hasPackageDependency(cwd, ["@sveltejs/adapter-static"]),
|
|
523
|
+
buildCommand: "npx vite build",
|
|
524
|
+
outputDir: "build"
|
|
525
|
+
};
|
|
526
|
+
var gatsby = {
|
|
527
|
+
name: "gatsby",
|
|
528
|
+
detect: (cwd) => hasAny(cwd, ["gatsby-config.js", "gatsby-config.ts", "gatsby-config.mjs"]),
|
|
529
|
+
buildCommand: "npx gatsby build",
|
|
530
|
+
outputDir: "public"
|
|
531
|
+
};
|
|
532
|
+
var docusaurus = {
|
|
533
|
+
name: "docusaurus",
|
|
534
|
+
detect: (cwd) => hasAny(cwd, ["docusaurus.config.js", "docusaurus.config.ts"]),
|
|
535
|
+
buildCommand: "npx docusaurus build",
|
|
536
|
+
outputDir: "build"
|
|
537
|
+
};
|
|
538
|
+
var vuepress = {
|
|
539
|
+
name: "vuepress",
|
|
540
|
+
detect: (cwd) => hasAny(cwd, ["docs/.vuepress/config.js", "docs/.vuepress/config.ts", "docs/.vuepress/config.mjs"]) || hasAny(cwd, [".vuepress/config.js", ".vuepress/config.ts", ".vuepress/config.mjs"]),
|
|
541
|
+
buildCommand: "npx vuepress build docs",
|
|
542
|
+
outputDir: "docs/.vuepress/dist",
|
|
543
|
+
resolveServeDir: (outputDir) => {
|
|
544
|
+
if (!existsSync(outputDir)) {
|
|
545
|
+
const root = path.join(path.dirname(path.dirname(path.dirname(outputDir))), ".vuepress", "dist");
|
|
546
|
+
if (existsSync(root)) return root;
|
|
547
|
+
}
|
|
548
|
+
return outputDir;
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
var gridsome = {
|
|
552
|
+
name: "gridsome",
|
|
553
|
+
detect: (cwd) => hasAny(cwd, ["gridsome.config.js"]),
|
|
554
|
+
buildCommand: "npx gridsome build",
|
|
555
|
+
outputDir: "dist"
|
|
556
|
+
};
|
|
557
|
+
var mkdocs = {
|
|
558
|
+
name: "mkdocs",
|
|
559
|
+
detect: (cwd) => hasAny(cwd, ["mkdocs.yml", "mkdocs.yaml"]),
|
|
560
|
+
buildCommand: "mkdocs build",
|
|
561
|
+
outputDir: "site"
|
|
562
|
+
};
|
|
563
|
+
var zola = {
|
|
564
|
+
name: "zola",
|
|
565
|
+
// Zola's config.toml uses `base_url` (snake_case), Hugo's uses `baseURL` (camelCase). That
|
|
566
|
+
// distinction is what separates the two on their shared generic filename.
|
|
567
|
+
detect: (cwd) => /base_url\s*=/.test(readFirst(cwd, ["config.toml"])),
|
|
568
|
+
buildCommand: "zola build",
|
|
569
|
+
outputDir: "public"
|
|
570
|
+
};
|
|
571
|
+
var hugo = {
|
|
572
|
+
name: "hugo",
|
|
573
|
+
detect: (cwd) => {
|
|
574
|
+
if (hasAny(cwd, ["hugo.toml", "hugo.yaml", "hugo.yml"])) return true;
|
|
575
|
+
const content = readFirst(cwd, ["config.toml", "config.yaml", "config.yml"]);
|
|
576
|
+
return /\bbaseURL\s*[:=]/.test(content) || /\blanguageCode\s*[:=]/.test(content);
|
|
577
|
+
},
|
|
578
|
+
buildCommand: "hugo",
|
|
579
|
+
outputDir: "public"
|
|
580
|
+
};
|
|
581
|
+
var hexo = {
|
|
582
|
+
name: "hexo",
|
|
583
|
+
// Hexo defaults to `_config.yml` just like Jekyll. package.json, or the `scaffolds/`
|
|
584
|
+
// convention, is the reliable disambiguator, so hexo must precede jekyll in
|
|
585
|
+
// BUILT_IN_ADAPTERS.
|
|
586
|
+
detect: (cwd) => hasAny(cwd, ["_config.yml"]) && (hasPackageDependency(cwd, ["hexo", "hexo-cli"]) || hasDir(cwd, ["scaffolds"])),
|
|
587
|
+
buildCommand: "npx hexo generate",
|
|
588
|
+
outputDir: "public"
|
|
589
|
+
};
|
|
590
|
+
var jekyll = {
|
|
591
|
+
name: "jekyll",
|
|
592
|
+
detect: (cwd) => hasAny(cwd, ["_config.yml"]),
|
|
593
|
+
buildCommand: "bundle exec jekyll build",
|
|
594
|
+
outputDir: "_site"
|
|
595
|
+
};
|
|
596
|
+
var vite = {
|
|
597
|
+
name: "vite",
|
|
598
|
+
detect: (cwd) => hasAny(cwd, ["vite.config.js", "vite.config.ts", "vite.config.mjs", "vite.config.cjs"]),
|
|
599
|
+
buildCommand: "npx vite build",
|
|
600
|
+
outputDir: "dist"
|
|
601
|
+
};
|
|
602
|
+
var BUILT_IN_ADAPTERS = [
|
|
603
|
+
astro,
|
|
604
|
+
eleventy,
|
|
605
|
+
nextjs,
|
|
606
|
+
nuxt,
|
|
607
|
+
sveltekit,
|
|
608
|
+
gatsby,
|
|
609
|
+
docusaurus,
|
|
610
|
+
vuepress,
|
|
611
|
+
gridsome,
|
|
612
|
+
vite,
|
|
613
|
+
mkdocs,
|
|
614
|
+
zola,
|
|
615
|
+
hugo,
|
|
616
|
+
hexo,
|
|
617
|
+
jekyll
|
|
618
|
+
];
|
|
619
|
+
function detectAdapter(cwd) {
|
|
620
|
+
return BUILT_IN_ADAPTERS.find((a) => a.detect(cwd));
|
|
621
|
+
}
|
|
622
|
+
function resolveAdapter(cwd, target, build) {
|
|
623
|
+
let base;
|
|
624
|
+
if (target.adapter) {
|
|
625
|
+
base = BUILT_IN_ADAPTERS.find((a) => a.name === target.adapter);
|
|
626
|
+
if (!base) {
|
|
627
|
+
throw new Error(t("errors.unknownAdapter", { name: target.adapter, available: BUILT_IN_ADAPTERS.map((a) => a.name).join(", ") }));
|
|
628
|
+
}
|
|
629
|
+
} else {
|
|
630
|
+
base = detectAdapter(cwd);
|
|
631
|
+
}
|
|
632
|
+
let fallbackOutputDir;
|
|
633
|
+
if (!base && !build && !target.outputDir) {
|
|
634
|
+
fallbackOutputDir = findExistingOutputDir(cwd);
|
|
635
|
+
}
|
|
636
|
+
const resolvedOutputDir = target.outputDir ?? base?.outputDir ?? fallbackOutputDir;
|
|
637
|
+
if (!resolvedOutputDir) {
|
|
638
|
+
throw new Error(t("errors.noGeneratorOutputDir"));
|
|
639
|
+
}
|
|
640
|
+
if (!base && build && !target.buildCommand) {
|
|
641
|
+
throw new Error(t("errors.noGeneratorBuildCommand"));
|
|
642
|
+
}
|
|
643
|
+
return {
|
|
644
|
+
name: base?.name ?? (fallbackOutputDir ? "static" : "manual"),
|
|
645
|
+
detect: () => true,
|
|
646
|
+
buildCommand: target.buildCommand ?? base?.buildCommand ?? "",
|
|
647
|
+
outputDir: resolvedOutputDir,
|
|
648
|
+
// Apply the adapter's own output-layout heuristic only while its declared outputDir is in
|
|
649
|
+
// play. An explicit target.outputDir override takes precedence over the heuristic.
|
|
650
|
+
resolveServeDir: target.outputDir ? void 0 : base?.resolveServeDir
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// src/dynamic/serve.ts
|
|
655
|
+
import { existsSync as existsSync2 } from "fs";
|
|
656
|
+
import { readFile } from "fs/promises";
|
|
657
|
+
import http from "http";
|
|
658
|
+
import path2 from "path";
|
|
659
|
+
var MIME_TYPES = {
|
|
660
|
+
".html": "text/html; charset=utf-8",
|
|
661
|
+
".css": "text/css",
|
|
662
|
+
".js": "text/javascript",
|
|
663
|
+
".mjs": "text/javascript",
|
|
664
|
+
".json": "application/json",
|
|
665
|
+
".svg": "image/svg+xml",
|
|
666
|
+
".png": "image/png",
|
|
667
|
+
".jpg": "image/jpeg",
|
|
668
|
+
".jpeg": "image/jpeg",
|
|
669
|
+
".webp": "image/webp",
|
|
670
|
+
".gif": "image/gif",
|
|
671
|
+
".avif": "image/avif",
|
|
672
|
+
".woff": "font/woff",
|
|
673
|
+
".woff2": "font/woff2",
|
|
674
|
+
".ico": "image/x-icon",
|
|
675
|
+
".xml": "application/xml",
|
|
676
|
+
".txt": "text/plain"
|
|
677
|
+
};
|
|
678
|
+
function startStaticServer(root) {
|
|
679
|
+
return new Promise((resolve, reject) => {
|
|
680
|
+
const server = http.createServer(async (req, res) => {
|
|
681
|
+
let urlPath;
|
|
682
|
+
try {
|
|
683
|
+
urlPath = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
|
684
|
+
} catch {
|
|
685
|
+
res.statusCode = 400;
|
|
686
|
+
res.end("Bad request");
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const resolved = path2.resolve(root, `.${urlPath}`);
|
|
690
|
+
if (resolved !== root && !resolved.startsWith(root + path2.sep)) {
|
|
691
|
+
res.statusCode = 403;
|
|
692
|
+
res.end("Forbidden");
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
let filePath = resolved;
|
|
696
|
+
if (urlPath.endsWith("/") || !path2.extname(filePath)) {
|
|
697
|
+
const indexCandidate = path2.join(filePath, "index.html");
|
|
698
|
+
filePath = existsSync2(indexCandidate) ? indexCandidate : `${filePath}.html`;
|
|
699
|
+
}
|
|
700
|
+
try {
|
|
701
|
+
const data = await readFile(filePath);
|
|
702
|
+
res.setHeader("Content-Type", MIME_TYPES[path2.extname(filePath)] ?? "application/octet-stream");
|
|
703
|
+
res.end(data);
|
|
704
|
+
} catch {
|
|
705
|
+
res.statusCode = 404;
|
|
706
|
+
res.end("Not found");
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
server.once("error", reject);
|
|
710
|
+
server.listen(0, "127.0.0.1", () => {
|
|
711
|
+
const { port } = server.address();
|
|
712
|
+
resolve({
|
|
713
|
+
url: `http://127.0.0.1:${port}`,
|
|
714
|
+
close: () => new Promise((closeResolve) => server.close(() => closeResolve()))
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// src/dynamic/session.ts
|
|
721
|
+
function attachCapture(page) {
|
|
722
|
+
const consoleMessages = [];
|
|
723
|
+
const networkRequests = [];
|
|
724
|
+
const onConsole = (msg) => {
|
|
725
|
+
consoleMessages.push({ type: msg.type(), text: msg.text() });
|
|
726
|
+
};
|
|
727
|
+
const onPageError = (error) => {
|
|
728
|
+
consoleMessages.push({ type: "pageerror", text: error.message });
|
|
729
|
+
};
|
|
730
|
+
const onResponse = async (response) => {
|
|
731
|
+
try {
|
|
732
|
+
const headers = await response.allHeaders();
|
|
733
|
+
let bodySize = Number(headers["content-length"] ?? 0);
|
|
734
|
+
if (!bodySize) {
|
|
735
|
+
try {
|
|
736
|
+
bodySize = (await response.body()).length;
|
|
737
|
+
} catch {
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
const request = response.request();
|
|
741
|
+
networkRequests.push({
|
|
742
|
+
url: response.url(),
|
|
743
|
+
status: response.status(),
|
|
744
|
+
contentType: headers["content-type"] ?? null,
|
|
745
|
+
bodySize,
|
|
746
|
+
isMainFrame: request.frame() === page.mainFrame(),
|
|
747
|
+
resourceType: request.resourceType()
|
|
748
|
+
});
|
|
749
|
+
} catch {
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
page.on("console", onConsole);
|
|
753
|
+
page.on("pageerror", onPageError);
|
|
754
|
+
page.on("response", onResponse);
|
|
755
|
+
return {
|
|
756
|
+
consoleMessages,
|
|
757
|
+
networkRequests,
|
|
758
|
+
detach() {
|
|
759
|
+
page.off("console", onConsole);
|
|
760
|
+
page.off("pageerror", onPageError);
|
|
761
|
+
page.off("response", onResponse);
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// src/utils/exec.ts
|
|
767
|
+
import { exec as execCallback } from "child_process";
|
|
768
|
+
var DEFAULT_TIMEOUT_MS = 5 * 6e4;
|
|
769
|
+
function exec(command, options) {
|
|
770
|
+
const { cwd, timeoutMs = DEFAULT_TIMEOUT_MS, allowNonZeroExit = false } = options;
|
|
771
|
+
return new Promise((resolve, reject) => {
|
|
772
|
+
execCallback(command, { cwd, maxBuffer: 1024 * 1024 * 20, timeout: timeoutMs, killSignal: "SIGKILL" }, (error, stdout, stderr) => {
|
|
773
|
+
if (error) {
|
|
774
|
+
if (allowNonZeroExit && (stdout || stderr)) {
|
|
775
|
+
resolve(stdout || stderr || "");
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
const execException = error;
|
|
779
|
+
const timedOut = execException.killed === true && execException.signal === "SIGKILL";
|
|
780
|
+
const reason = timedOut ? `timed out after ${Math.round(timeoutMs / 1e3)}s` : stderr.trim() || stdout.trim() || error.message;
|
|
781
|
+
const execError = new Error(`Command "${command}" failed: ${reason}`);
|
|
782
|
+
execError.stdout = stdout ?? "";
|
|
783
|
+
execError.stderr = stderr ?? "";
|
|
784
|
+
execError.code = execException.code ?? null;
|
|
785
|
+
reject(execError);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
resolve(stdout || stderr || "");
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// src/utils/glob.ts
|
|
794
|
+
import { glob } from "tinyglobby";
|
|
795
|
+
async function globFiles(pattern, options) {
|
|
796
|
+
return glob(pattern, { cwd: options.cwd, absolute: true });
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// src/utils/humanize.ts
|
|
800
|
+
function humanizeCheckId(id) {
|
|
801
|
+
const lastSegment = id.split(".").pop() ?? id;
|
|
802
|
+
return lastSegment.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// src/utils/pages.ts
|
|
806
|
+
import path3 from "path";
|
|
807
|
+
async function collectPages(outputDir) {
|
|
808
|
+
const files = await globFiles("**/*.html", { cwd: outputDir });
|
|
809
|
+
return files.map((file) => {
|
|
810
|
+
const relativeFile = path3.relative(outputDir, file);
|
|
811
|
+
return { file, relativeFile, urlPath: toUrlPath(relativeFile) };
|
|
812
|
+
}).sort((a, b) => a.urlPath.localeCompare(b.urlPath));
|
|
813
|
+
}
|
|
814
|
+
function toUrlPath(relativeFile) {
|
|
815
|
+
const posix = relativeFile.split(path3.sep).join("/");
|
|
816
|
+
if (posix === "index.html") return "/";
|
|
817
|
+
if (posix.endsWith("/index.html")) return `/${posix.slice(0, -"index.html".length)}`;
|
|
818
|
+
return `/${posix}`;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// src/core/runner.ts
|
|
822
|
+
import { chromium } from "playwright";
|
|
823
|
+
|
|
824
|
+
// src/core/detail.ts
|
|
825
|
+
function createDetailFactory(check, push) {
|
|
826
|
+
return (input) => {
|
|
827
|
+
push({
|
|
828
|
+
checkId: check.id,
|
|
829
|
+
category: check.category,
|
|
830
|
+
label: input.label,
|
|
831
|
+
value: input.value,
|
|
832
|
+
url: input.url
|
|
833
|
+
});
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// src/core/finding.ts
|
|
838
|
+
function createFindingFactory(check, configuredSeverity) {
|
|
839
|
+
return (input) => ({
|
|
840
|
+
checkId: check.id,
|
|
841
|
+
category: check.category,
|
|
842
|
+
severity: configuredSeverity ?? input.severity ?? check.severity ?? "warn",
|
|
843
|
+
message: input.message,
|
|
844
|
+
file: input.file,
|
|
845
|
+
url: input.url
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// src/core/load-checks.ts
|
|
850
|
+
import path13 from "path";
|
|
851
|
+
import { pathToFileURL } from "url";
|
|
852
|
+
|
|
853
|
+
// src/checks/accessibility/run-axe.ts
|
|
854
|
+
import { AxeBuilder } from "@axe-core/playwright";
|
|
855
|
+
var IMPACT_SEVERITY = {
|
|
856
|
+
critical: "error",
|
|
857
|
+
serious: "error",
|
|
858
|
+
moderate: "warn",
|
|
859
|
+
minor: "info"
|
|
860
|
+
};
|
|
861
|
+
var cache = /* @__PURE__ */ new WeakMap();
|
|
862
|
+
function runAxe(page) {
|
|
863
|
+
let results = cache.get(page);
|
|
864
|
+
if (!results) {
|
|
865
|
+
results = new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).exclude("iframe").analyze();
|
|
866
|
+
cache.set(page, results);
|
|
867
|
+
}
|
|
868
|
+
return results;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// src/checks/accessibility/axe.ts
|
|
872
|
+
var axe_default = defineCheck({
|
|
873
|
+
id: "accessibility.axe",
|
|
874
|
+
name: "WCAG Accessibility (axe-core)",
|
|
875
|
+
category: "accessibility",
|
|
876
|
+
mode: "dynamic",
|
|
877
|
+
severity: "warn",
|
|
878
|
+
description: "WCAG 2.1 A/AA violations via axe-core.",
|
|
879
|
+
async run(ctx) {
|
|
880
|
+
const results = await runAxe(ctx.page);
|
|
881
|
+
ctx.detail({
|
|
882
|
+
label: "Rules checked / passed / violated",
|
|
883
|
+
value: `${results.passes.length + results.violations.length} / ${results.passes.length} / ${results.violations.length}`,
|
|
884
|
+
url: ctx.pageInfo.urlPath
|
|
885
|
+
});
|
|
886
|
+
return results.violations.map(
|
|
887
|
+
(violation) => ctx.finding({
|
|
888
|
+
message: `${violation.help} (${violation.id}, affects ${violation.nodes.length} element(s))`,
|
|
889
|
+
url: ctx.pageInfo.urlPath,
|
|
890
|
+
severity: IMPACT_SEVERITY[violation.impact ?? "moderate"] ?? "warn"
|
|
891
|
+
})
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
// src/checks/accessibility/color-contrast.ts
|
|
897
|
+
var color_contrast_default = defineCheck({
|
|
898
|
+
id: "accessibility.color-contrast",
|
|
899
|
+
name: "Color Contrast",
|
|
900
|
+
category: "accessibility",
|
|
901
|
+
mode: "dynamic",
|
|
902
|
+
severity: "warn",
|
|
903
|
+
description: "Checks color contrast ratios using axe-core.",
|
|
904
|
+
async run(ctx) {
|
|
905
|
+
const findings = [];
|
|
906
|
+
const results = await runAxe(ctx.page);
|
|
907
|
+
let elementsChecked = 0;
|
|
908
|
+
let elementsFailed = 0;
|
|
909
|
+
for (const violation of results.violations) {
|
|
910
|
+
if (violation.id === "color-contrast") {
|
|
911
|
+
elementsFailed += violation.nodes.length;
|
|
912
|
+
for (const node of violation.nodes) {
|
|
913
|
+
findings.push(
|
|
914
|
+
ctx.finding({
|
|
915
|
+
message: `Color contrast: ${node.html.substring(0, 100)}`,
|
|
916
|
+
url: ctx.pageInfo.urlPath,
|
|
917
|
+
severity: IMPACT_SEVERITY[violation.impact ?? "moderate"] ?? "warn"
|
|
918
|
+
})
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
for (const pass of results.passes) {
|
|
924
|
+
if (pass.id === "color-contrast") elementsChecked += pass.nodes.length;
|
|
925
|
+
}
|
|
926
|
+
elementsChecked += elementsFailed;
|
|
927
|
+
ctx.detail({
|
|
928
|
+
label: "Elements checked / failing contrast",
|
|
929
|
+
value: `${elementsChecked} / ${elementsFailed}`,
|
|
930
|
+
url: ctx.pageInfo.urlPath
|
|
931
|
+
});
|
|
932
|
+
return findings;
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
// src/utils/html.ts
|
|
937
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
938
|
+
import { parse } from "node-html-parser";
|
|
939
|
+
var cache2 = /* @__PURE__ */ new Map();
|
|
940
|
+
function parseHtmlFile(filePath) {
|
|
941
|
+
let root = cache2.get(filePath);
|
|
942
|
+
if (!root) {
|
|
943
|
+
root = parse(readFileSync2(filePath, "utf-8"));
|
|
944
|
+
cache2.set(filePath, root);
|
|
945
|
+
}
|
|
946
|
+
return root;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// src/checks/bugs/anchor-links.ts
|
|
950
|
+
var anchor_links_default = defineCheck({
|
|
951
|
+
id: "bugs.anchor-links",
|
|
952
|
+
name: "Anchor Links",
|
|
953
|
+
category: "bugs",
|
|
954
|
+
mode: "static",
|
|
955
|
+
severity: "warn",
|
|
956
|
+
description: "Validates internal anchor links (#id) point to existing elements.",
|
|
957
|
+
async run(ctx) {
|
|
958
|
+
const findings = [];
|
|
959
|
+
let checked = 0;
|
|
960
|
+
for (const page of ctx.pages) {
|
|
961
|
+
const root = parseHtmlFile(page.file);
|
|
962
|
+
const links = root.querySelectorAll("a[href^='#']");
|
|
963
|
+
for (const link of links) {
|
|
964
|
+
const href = link.getAttribute("href") ?? "";
|
|
965
|
+
const id = href.slice(1);
|
|
966
|
+
if (!id) continue;
|
|
967
|
+
checked++;
|
|
968
|
+
const target = root.querySelector(`[id="${id}"]`);
|
|
969
|
+
if (!target) {
|
|
970
|
+
findings.push(
|
|
971
|
+
ctx.finding({
|
|
972
|
+
message: `Anchor "${href}" points to non-existent element`,
|
|
973
|
+
file: page.relativeFile
|
|
974
|
+
})
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
ctx.detail({ label: "Anchor links checked", value: String(checked) });
|
|
980
|
+
return findings;
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// src/checks/bugs/broken-links.ts
|
|
985
|
+
import { readdirSync } from "fs";
|
|
986
|
+
import path4 from "path";
|
|
987
|
+
function isInternal(href) {
|
|
988
|
+
if (!href || href.startsWith("#") || href.startsWith("//")) return false;
|
|
989
|
+
return !/^[a-z][a-z0-9+.-]*:/i.test(href);
|
|
990
|
+
}
|
|
991
|
+
function existsExactCase(fullPath) {
|
|
992
|
+
const segments = fullPath.split(path4.sep).filter(Boolean);
|
|
993
|
+
let current = path4.parse(fullPath).root || path4.sep;
|
|
994
|
+
for (const segment of segments) {
|
|
995
|
+
let entries;
|
|
996
|
+
try {
|
|
997
|
+
entries = readdirSync(current);
|
|
998
|
+
} catch {
|
|
999
|
+
return false;
|
|
1000
|
+
}
|
|
1001
|
+
if (!entries.includes(segment)) return false;
|
|
1002
|
+
current = path4.join(current, segment);
|
|
1003
|
+
}
|
|
1004
|
+
return true;
|
|
1005
|
+
}
|
|
1006
|
+
function targetExists(target, ctx) {
|
|
1007
|
+
const candidates = [target, path4.join(target, "index.html"), `${target}.html`];
|
|
1008
|
+
return candidates.some((candidate) => ctx.fileExists(candidate) && existsExactCase(candidate));
|
|
1009
|
+
}
|
|
1010
|
+
var REFERENCE_ATTRIBUTES = [
|
|
1011
|
+
{ selector: "a[href]", attr: "href", label: "link" },
|
|
1012
|
+
{ selector: "img[src]", attr: "src", label: "image" },
|
|
1013
|
+
{ selector: "link[href]", attr: "href", label: "stylesheet/link" },
|
|
1014
|
+
{ selector: "script[src]", attr: "src", label: "script" }
|
|
1015
|
+
];
|
|
1016
|
+
var broken_links_default = defineCheck({
|
|
1017
|
+
id: "bugs.broken-links",
|
|
1018
|
+
name: "Broken Links & Assets",
|
|
1019
|
+
category: "bugs",
|
|
1020
|
+
mode: "static",
|
|
1021
|
+
severity: "error",
|
|
1022
|
+
description: "Checks that internal links, images, stylesheets, and scripts in the built output resolve to existing files.",
|
|
1023
|
+
async run(ctx) {
|
|
1024
|
+
const findings = [];
|
|
1025
|
+
let checked = 0;
|
|
1026
|
+
for (const page of ctx.pages) {
|
|
1027
|
+
const root = parseHtmlFile(page.file);
|
|
1028
|
+
for (const { selector, attr, label } of REFERENCE_ATTRIBUTES) {
|
|
1029
|
+
for (const el of root.querySelectorAll(selector)) {
|
|
1030
|
+
const value = el.getAttribute(attr) ?? "";
|
|
1031
|
+
if (!isInternal(value)) continue;
|
|
1032
|
+
const cleanValue = value.split(/[?#]/)[0];
|
|
1033
|
+
if (!cleanValue) continue;
|
|
1034
|
+
checked++;
|
|
1035
|
+
const target = cleanValue.startsWith("/") ? path4.join(ctx.outputDir, cleanValue) : path4.join(path4.dirname(page.file), cleanValue);
|
|
1036
|
+
if (!targetExists(target, ctx)) {
|
|
1037
|
+
findings.push(ctx.finding({ message: `Broken internal ${label} reference "${value}"`, file: page.relativeFile }));
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
ctx.detail({ label: "Internal references checked", value: String(checked) });
|
|
1043
|
+
return findings;
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
// src/checks/bugs/console-errors.ts
|
|
1048
|
+
var console_errors_default = defineCheck({
|
|
1049
|
+
id: "bugs.console-errors",
|
|
1050
|
+
name: "Console Errors",
|
|
1051
|
+
category: "bugs",
|
|
1052
|
+
mode: "dynamic",
|
|
1053
|
+
severity: "error",
|
|
1054
|
+
description: "Reports console errors that occur while the page loads.",
|
|
1055
|
+
async run(ctx) {
|
|
1056
|
+
ctx.detail({ label: "Console messages captured", value: String(ctx.consoleMessages.length), url: ctx.pageInfo.urlPath });
|
|
1057
|
+
return ctx.consoleMessages.filter((msg) => msg.type === "error" || msg.type === "pageerror").map(
|
|
1058
|
+
(msg) => ctx.finding({
|
|
1059
|
+
message: msg.type === "pageerror" ? `Uncaught exception: ${msg.text}` : `Console error: ${msg.text}`,
|
|
1060
|
+
url: ctx.pageInfo.urlPath
|
|
1061
|
+
})
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
// src/checks/bugs/nested-block-elements.ts
|
|
1067
|
+
import { HTMLElement } from "node-html-parser";
|
|
1068
|
+
var BLOCK_ELEMENTS = /* @__PURE__ */ new Set([
|
|
1069
|
+
"address",
|
|
1070
|
+
"article",
|
|
1071
|
+
"aside",
|
|
1072
|
+
"blockquote",
|
|
1073
|
+
"details",
|
|
1074
|
+
"dialog",
|
|
1075
|
+
"dd",
|
|
1076
|
+
"div",
|
|
1077
|
+
"dl",
|
|
1078
|
+
"dt",
|
|
1079
|
+
"fieldset",
|
|
1080
|
+
"figcaption",
|
|
1081
|
+
"figure",
|
|
1082
|
+
"footer",
|
|
1083
|
+
"form",
|
|
1084
|
+
"h1",
|
|
1085
|
+
"h2",
|
|
1086
|
+
"h3",
|
|
1087
|
+
"h4",
|
|
1088
|
+
"h5",
|
|
1089
|
+
"h6",
|
|
1090
|
+
"header",
|
|
1091
|
+
"hgroup",
|
|
1092
|
+
"hr",
|
|
1093
|
+
"li",
|
|
1094
|
+
"main",
|
|
1095
|
+
"nav",
|
|
1096
|
+
"ol",
|
|
1097
|
+
"p",
|
|
1098
|
+
"pre",
|
|
1099
|
+
"section",
|
|
1100
|
+
"table",
|
|
1101
|
+
"ul"
|
|
1102
|
+
]);
|
|
1103
|
+
var INLINE_ELEMENTS = "span, em, strong, b, i, u, small, sub, sup";
|
|
1104
|
+
var INTERACTIVE_ELEMENTS = /* @__PURE__ */ new Set(["a", "button", "input", "select", "textarea", "label", "details", "audio", "video", "iframe"]);
|
|
1105
|
+
var nested_block_elements_default = defineCheck({
|
|
1106
|
+
id: "bugs.nested-block-elements",
|
|
1107
|
+
name: "Nested Block Elements",
|
|
1108
|
+
category: "bugs",
|
|
1109
|
+
mode: "static",
|
|
1110
|
+
severity: "info",
|
|
1111
|
+
description: "Detects invalid nesting: block elements inside genuinely inline elements, and interactive elements nested inside an <a>.",
|
|
1112
|
+
async run(ctx) {
|
|
1113
|
+
const findings = [];
|
|
1114
|
+
let elementsChecked = 0;
|
|
1115
|
+
for (const page of ctx.pages) {
|
|
1116
|
+
const root = parseHtmlFile(page.file);
|
|
1117
|
+
for (const el of root.querySelectorAll(INLINE_ELEMENTS)) {
|
|
1118
|
+
elementsChecked++;
|
|
1119
|
+
for (const child of el.childNodes) {
|
|
1120
|
+
if (child.nodeType === 1 && child instanceof HTMLElement) {
|
|
1121
|
+
const tagName = child.tagName.toLowerCase();
|
|
1122
|
+
if (BLOCK_ELEMENTS.has(tagName)) {
|
|
1123
|
+
findings.push(
|
|
1124
|
+
ctx.finding({
|
|
1125
|
+
message: `Block element "<${child.tagName}>" nested inside inline "<${el.tagName}>"`,
|
|
1126
|
+
file: page.relativeFile,
|
|
1127
|
+
severity: "info"
|
|
1128
|
+
})
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
for (const anchor of root.querySelectorAll("a")) {
|
|
1135
|
+
elementsChecked++;
|
|
1136
|
+
for (const descendant of anchor.querySelectorAll([...INTERACTIVE_ELEMENTS].filter((tag) => tag !== "a").join(", "))) {
|
|
1137
|
+
findings.push(
|
|
1138
|
+
ctx.finding({
|
|
1139
|
+
message: `Interactive element "<${descendant.tagName}>" nested inside "<a>" \u2014 invalid, and most browsers will break the outer link`,
|
|
1140
|
+
file: page.relativeFile,
|
|
1141
|
+
severity: "warn"
|
|
1142
|
+
})
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
for (const _nestedAnchor of anchor.querySelectorAll("a")) {
|
|
1146
|
+
findings.push(
|
|
1147
|
+
ctx.finding({
|
|
1148
|
+
message: `Nested "<a>" inside another "<a>" \u2014 invalid, and most browsers will break the outer link`,
|
|
1149
|
+
file: page.relativeFile,
|
|
1150
|
+
severity: "warn"
|
|
1151
|
+
})
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
ctx.detail({ label: "Elements checked", value: String(elementsChecked) });
|
|
1157
|
+
return findings;
|
|
1158
|
+
}
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
// src/checks/bugs/viewport-meta.ts
|
|
1162
|
+
var viewport_meta_default = defineCheck({
|
|
1163
|
+
id: "bugs.viewport-meta",
|
|
1164
|
+
name: "Viewport Meta Tag",
|
|
1165
|
+
category: "bugs",
|
|
1166
|
+
mode: "static",
|
|
1167
|
+
severity: "warn",
|
|
1168
|
+
description: 'Checks that every page declares <meta name="viewport">, required for mobile-responsive rendering.',
|
|
1169
|
+
async run(ctx) {
|
|
1170
|
+
const findings = [];
|
|
1171
|
+
for (const page of ctx.pages) {
|
|
1172
|
+
const root = parseHtmlFile(page.file);
|
|
1173
|
+
const viewport = root.querySelector('meta[name="viewport"]')?.getAttribute("content");
|
|
1174
|
+
if (!viewport) {
|
|
1175
|
+
findings.push(
|
|
1176
|
+
ctx.finding({
|
|
1177
|
+
message: 'Missing <meta name="viewport"> \u2014 page may not render correctly on mobile devices',
|
|
1178
|
+
file: page.relativeFile
|
|
1179
|
+
})
|
|
1180
|
+
);
|
|
1181
|
+
} else {
|
|
1182
|
+
ctx.detail({ label: "viewport content", value: viewport, url: page.urlPath });
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
return findings;
|
|
1186
|
+
}
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
// src/checks/maintainability/dead-css.ts
|
|
1190
|
+
import path5 from "path";
|
|
1191
|
+
var MAX_FINDINGS = 20;
|
|
1192
|
+
var SOURCE_EXTENSIONS = "html,htm,js,jsx,mjs,cjs,ts,tsx,vue,svelte,astro,mdx,php,twig,liquid,njk,hbs,handlebars,ejs,pug";
|
|
1193
|
+
var IGNORE_MARKERS = ["node_modules", "/.astro/", "/.vercel/", "/.netlify/", "/.cache/", ".min.js"];
|
|
1194
|
+
var CLASS_SELECTOR_PATTERN = /\.([a-zA-Z_-][\w-]*)/g;
|
|
1195
|
+
var WORD_PATTERN = /[a-zA-Z_][\w-]*/g;
|
|
1196
|
+
function stripCssNoise(css) {
|
|
1197
|
+
return css.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/url\([^)]*\)/gi, " ").replace(/(['"])(?:(?!\1)[^\\]|\\.)*\1/g, " ");
|
|
1198
|
+
}
|
|
1199
|
+
var dead_css_default = defineCheck({
|
|
1200
|
+
id: "maintainability.dead-css",
|
|
1201
|
+
name: "Dead CSS",
|
|
1202
|
+
category: "maintainability",
|
|
1203
|
+
mode: "static",
|
|
1204
|
+
severity: "info",
|
|
1205
|
+
description: "Finds CSS classes defined in stylesheets that show up neither in the built pages nor anywhere in the project's own source files (templates, scripts, components) \u2014 a good signal they're safe to delete. Classes assembled purely from dynamic strings at runtime can still slip through undetected.",
|
|
1206
|
+
async run(ctx) {
|
|
1207
|
+
const cssFiles = (await globFiles("**/*.{css,scss,less}", { cwd: ctx.siteRoot })).filter(
|
|
1208
|
+
(file) => !file.includes("node_modules") && !file.endsWith(".min.css")
|
|
1209
|
+
);
|
|
1210
|
+
if (cssFiles.length === 0) {
|
|
1211
|
+
ctx.detail({ label: "CSS files scanned", value: "0" });
|
|
1212
|
+
return [];
|
|
1213
|
+
}
|
|
1214
|
+
const definedClasses = /* @__PURE__ */ new Map();
|
|
1215
|
+
for (const cssFile of cssFiles) {
|
|
1216
|
+
let content;
|
|
1217
|
+
try {
|
|
1218
|
+
content = ctx.readFile(cssFile);
|
|
1219
|
+
} catch {
|
|
1220
|
+
continue;
|
|
1221
|
+
}
|
|
1222
|
+
const cleaned = stripCssNoise(content);
|
|
1223
|
+
for (const match of cleaned.matchAll(CLASS_SELECTOR_PATTERN)) {
|
|
1224
|
+
const nextChar = cleaned[match.index + match[0].length];
|
|
1225
|
+
if (nextChar === "#" || nextChar === "$" || nextChar === "@") continue;
|
|
1226
|
+
if (!definedClasses.has(match[1])) {
|
|
1227
|
+
definedClasses.set(match[1], path5.relative(ctx.siteRoot, cssFile));
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
if (definedClasses.size === 0) {
|
|
1232
|
+
ctx.detail({ label: "CSS classes defined", value: "0" });
|
|
1233
|
+
return [];
|
|
1234
|
+
}
|
|
1235
|
+
const usedClasses = /* @__PURE__ */ new Set();
|
|
1236
|
+
for (const page of ctx.pages) {
|
|
1237
|
+
const root = parseHtmlFile(page.file);
|
|
1238
|
+
for (const el of root.querySelectorAll("[class]")) {
|
|
1239
|
+
for (const cls of el.getAttribute("class")?.split(/\s+/) ?? []) {
|
|
1240
|
+
if (cls) usedClasses.add(cls);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
const unresolved = [...definedClasses.keys()].filter((cls) => !usedClasses.has(cls));
|
|
1245
|
+
if (unresolved.length === 0) {
|
|
1246
|
+
ctx.detail({ label: "CSS classes defined", value: String(definedClasses.size) });
|
|
1247
|
+
return [];
|
|
1248
|
+
}
|
|
1249
|
+
const sourceFiles = (await globFiles(`**/*.{${SOURCE_EXTENSIONS}}`, { cwd: ctx.siteRoot })).filter(
|
|
1250
|
+
(file) => !IGNORE_MARKERS.some((marker) => file.includes(marker))
|
|
1251
|
+
);
|
|
1252
|
+
const sourceTokens = /* @__PURE__ */ new Set();
|
|
1253
|
+
for (const file of sourceFiles) {
|
|
1254
|
+
let content;
|
|
1255
|
+
try {
|
|
1256
|
+
content = ctx.readFile(file);
|
|
1257
|
+
} catch {
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
1260
|
+
for (const match of content.matchAll(WORD_PATTERN)) {
|
|
1261
|
+
sourceTokens.add(match[0]);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
const findings = [];
|
|
1265
|
+
for (const className of unresolved) {
|
|
1266
|
+
if (sourceTokens.has(className)) continue;
|
|
1267
|
+
findings.push(
|
|
1268
|
+
ctx.finding({
|
|
1269
|
+
message: `CSS class ".${className}" in "${definedClasses.get(className)}" not used in HTML or source`,
|
|
1270
|
+
file: definedClasses.get(className),
|
|
1271
|
+
severity: "info"
|
|
1272
|
+
})
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
ctx.detail({ label: "CSS classes defined / unused", value: `${definedClasses.size} / ${unresolved.length}` });
|
|
1276
|
+
return findings.slice(0, MAX_FINDINGS);
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
// src/checks/maintainability/duplication.ts
|
|
1281
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
1282
|
+
import os from "os";
|
|
1283
|
+
import path7 from "path";
|
|
1284
|
+
|
|
1285
|
+
// src/utils/resolve-bin.ts
|
|
1286
|
+
import { createRequire } from "module";
|
|
1287
|
+
import path6 from "path";
|
|
1288
|
+
var require2 = createRequire(import.meta.url);
|
|
1289
|
+
function resolvePackageBin(pkgName, binRelativePath) {
|
|
1290
|
+
const pkgJsonPath = require2.resolve(`${pkgName}/package.json`);
|
|
1291
|
+
return path6.join(path6.dirname(pkgJsonPath), binRelativePath);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// src/checks/maintainability/duplication.ts
|
|
1295
|
+
var MAX_FINDINGS2 = 20;
|
|
1296
|
+
var SOURCE_FORMATS = "javascript,typescript,jsx,tsx,vue,svelte,astro,css,scss,less,markup";
|
|
1297
|
+
var IGNORE_PATTERNS = [
|
|
1298
|
+
// Dependencies, lockfiles and generated or minified assets.
|
|
1299
|
+
"**/node_modules/**",
|
|
1300
|
+
"**/package-lock.json",
|
|
1301
|
+
"**/yarn.lock",
|
|
1302
|
+
"**/pnpm-lock.yaml",
|
|
1303
|
+
"**/*.min.js",
|
|
1304
|
+
"**/*.svg",
|
|
1305
|
+
// Framework and deployment build caches.
|
|
1306
|
+
"**/.astro/**",
|
|
1307
|
+
"**/.vercel/**",
|
|
1308
|
+
"**/.netlify/**",
|
|
1309
|
+
"**/.cache/**",
|
|
1310
|
+
// Type declarations, which are repetitive by nature.
|
|
1311
|
+
"**/*.d.ts",
|
|
1312
|
+
// Tests, fixtures and snapshots, where repetition is intentional.
|
|
1313
|
+
"**/*.test.*",
|
|
1314
|
+
"**/*.spec.*",
|
|
1315
|
+
"**/__tests__/**",
|
|
1316
|
+
"**/__mocks__/**",
|
|
1317
|
+
"**/__snapshots__/**",
|
|
1318
|
+
"**/*.snap",
|
|
1319
|
+
"**/fixtures/**",
|
|
1320
|
+
// Vendored third-party code.
|
|
1321
|
+
"**/vendor/**"
|
|
1322
|
+
];
|
|
1323
|
+
var MIN_LINES = 15;
|
|
1324
|
+
var MIN_TOKENS = 150;
|
|
1325
|
+
var duplication_default = defineCheck({
|
|
1326
|
+
id: "maintainability.duplication",
|
|
1327
|
+
name: "Code Duplication (jscpd)",
|
|
1328
|
+
category: "maintainability",
|
|
1329
|
+
mode: "static",
|
|
1330
|
+
severity: "warn",
|
|
1331
|
+
description: "Finds duplicated code in the source folder via jscpd.",
|
|
1332
|
+
async run(ctx) {
|
|
1333
|
+
const jscpdBin = resolvePackageBin("jscpd", "run-jscpd.js");
|
|
1334
|
+
const reportDir = await mkdtemp(path7.join(os.tmpdir(), "vortix-jscpd-"));
|
|
1335
|
+
try {
|
|
1336
|
+
const outputIsSiteRoot = path7.resolve(ctx.outputDir) === path7.resolve(ctx.siteRoot);
|
|
1337
|
+
const ignore = [...IGNORE_PATTERNS, ...outputIsSiteRoot ? [] : [`**/${path7.basename(ctx.outputDir)}/**`]].join(",");
|
|
1338
|
+
const escapedSiteRoot = ctx.siteRoot.replace(/'/g, "'\\''");
|
|
1339
|
+
const escapedReportDir = reportDir.replace(/'/g, "'\\''");
|
|
1340
|
+
await ctx.exec(
|
|
1341
|
+
`node '${jscpdBin}' '${escapedSiteRoot}' --reporters json --output '${escapedReportDir}' --silent --min-lines ${MIN_LINES} --min-tokens ${MIN_TOKENS} --format "${SOURCE_FORMATS}" --ignore "${ignore}"`
|
|
1342
|
+
);
|
|
1343
|
+
const reportPath = path7.join(reportDir, "jscpd-report.json");
|
|
1344
|
+
if (!ctx.fileExists(reportPath)) {
|
|
1345
|
+
ctx.skip("jscpd did not produce a report \u2014 it may have failed to run");
|
|
1346
|
+
}
|
|
1347
|
+
let report = {};
|
|
1348
|
+
try {
|
|
1349
|
+
report = JSON.parse(ctx.readFile(reportPath));
|
|
1350
|
+
} catch (error) {
|
|
1351
|
+
ctx.skip(`jscpd produced an unreadable report: ${error.message}`);
|
|
1352
|
+
}
|
|
1353
|
+
const duplicates = report.duplicates ?? [];
|
|
1354
|
+
ctx.detail({ label: "Duplicate code blocks found", value: String(duplicates.length) });
|
|
1355
|
+
return duplicates.slice(0, MAX_FINDINGS2).map(
|
|
1356
|
+
(dup) => ctx.finding({
|
|
1357
|
+
message: `Duplicate code (${dup.lines} lines) between "${formatFileRef(dup.firstFile)}" and "${formatFileRef(dup.secondFile)}"`,
|
|
1358
|
+
file: stripFormatSuffix(dup.firstFile?.name)
|
|
1359
|
+
})
|
|
1360
|
+
);
|
|
1361
|
+
} finally {
|
|
1362
|
+
await rm(reportDir, { recursive: true, force: true }).catch(() => {
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
});
|
|
1367
|
+
function stripFormatSuffix(name) {
|
|
1368
|
+
return name?.replace(/:[a-z]+$/, "");
|
|
1369
|
+
}
|
|
1370
|
+
function formatFileRef(ref) {
|
|
1371
|
+
const name = stripFormatSuffix(ref?.name) ?? "?";
|
|
1372
|
+
return ref?.start && ref?.end ? `${name}:${ref.start}-${ref.end}` : name;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// src/checks/maintainability/license-check.ts
|
|
1376
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
1377
|
+
import path8 from "path";
|
|
1378
|
+
var PROBLEMATIC_LICENSES = /* @__PURE__ */ new Set(["GPL-2.0", "GPL-3.0", "AGPL-3.0", "SSPL-1.0", "EUPL-1.1"]);
|
|
1379
|
+
var license_check_default = defineCheck({
|
|
1380
|
+
id: "maintainability.license-check",
|
|
1381
|
+
name: "License Check",
|
|
1382
|
+
category: "maintainability",
|
|
1383
|
+
mode: "static",
|
|
1384
|
+
severity: "info",
|
|
1385
|
+
description: "Checks dependencies for copyleft licenses that may conflict with proprietary code.",
|
|
1386
|
+
async run(ctx) {
|
|
1387
|
+
const findings = [];
|
|
1388
|
+
const pkgPath = path8.join(ctx.siteRoot, "package.json");
|
|
1389
|
+
if (!existsSync3(pkgPath)) {
|
|
1390
|
+
ctx.skip("No package.json found");
|
|
1391
|
+
}
|
|
1392
|
+
const nodeModules = path8.join(ctx.siteRoot, "node_modules");
|
|
1393
|
+
if (!existsSync3(nodeModules)) {
|
|
1394
|
+
ctx.skip("node_modules not found \u2014 install dependencies first");
|
|
1395
|
+
}
|
|
1396
|
+
const lockPath = path8.join(ctx.siteRoot, "package-lock.json");
|
|
1397
|
+
const npmPath = path8.join(ctx.siteRoot, "node_modules", ".package-lock.json");
|
|
1398
|
+
const lockExists = existsSync3(lockPath) || existsSync3(npmPath);
|
|
1399
|
+
if (!lockExists) {
|
|
1400
|
+
ctx.skip("No lockfile found");
|
|
1401
|
+
}
|
|
1402
|
+
const deps = JSON.parse(readFileSync3(pkgPath, "utf-8"));
|
|
1403
|
+
const allDeps = { ...deps.dependencies, ...deps.devDependencies };
|
|
1404
|
+
let checked = 0;
|
|
1405
|
+
for (const [name] of Object.entries(allDeps)) {
|
|
1406
|
+
const licensePath = path8.join(nodeModules, name, "package.json");
|
|
1407
|
+
if (!existsSync3(licensePath)) continue;
|
|
1408
|
+
try {
|
|
1409
|
+
const pkg = JSON.parse(readFileSync3(licensePath, "utf-8"));
|
|
1410
|
+
const license = typeof pkg.license === "string" ? pkg.license : pkg.license?.type;
|
|
1411
|
+
checked++;
|
|
1412
|
+
if (license && PROBLEMATIC_LICENSES.has(license)) {
|
|
1413
|
+
findings.push(
|
|
1414
|
+
ctx.finding({
|
|
1415
|
+
message: `Dependency "${name}" uses license "${license}" \u2014 may have copyleft restrictions`,
|
|
1416
|
+
severity: "warn"
|
|
1417
|
+
})
|
|
1418
|
+
);
|
|
1419
|
+
}
|
|
1420
|
+
} catch {
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
ctx.detail({ label: "Dependencies with a resolvable license / flagged", value: `${checked} / ${findings.length}` });
|
|
1424
|
+
return findings;
|
|
1425
|
+
}
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
// src/checks/maintainability/outdated-dependencies.ts
|
|
1429
|
+
import path9 from "path";
|
|
1430
|
+
var outdated_dependencies_default = defineCheck({
|
|
1431
|
+
id: "maintainability.outdated-dependencies",
|
|
1432
|
+
name: "Outdated Dependencies (npm outdated)",
|
|
1433
|
+
category: "maintainability",
|
|
1434
|
+
mode: "static",
|
|
1435
|
+
severity: "info",
|
|
1436
|
+
description: 'Lists outdated npm dependencies via "npm outdated" (not a security signal \u2014 see security.dependency-vulnerabilities).',
|
|
1437
|
+
async run(ctx) {
|
|
1438
|
+
if (!ctx.fileExists(path9.join(ctx.siteRoot, "package.json"))) ctx.skip("no package.json found");
|
|
1439
|
+
const output = await ctx.exec("npm outdated --json");
|
|
1440
|
+
if (!output.trim()) {
|
|
1441
|
+
ctx.detail({ label: "Outdated dependencies", value: "0" });
|
|
1442
|
+
return [];
|
|
1443
|
+
}
|
|
1444
|
+
let report = {};
|
|
1445
|
+
try {
|
|
1446
|
+
report = JSON.parse(output);
|
|
1447
|
+
} catch (error) {
|
|
1448
|
+
ctx.skip(`npm outdated produced unreadable output: ${error.message}`);
|
|
1449
|
+
}
|
|
1450
|
+
ctx.detail({ label: "Outdated dependencies", value: String(Object.keys(report).length) });
|
|
1451
|
+
return Object.entries(report).map(
|
|
1452
|
+
([name, info]) => ctx.finding({
|
|
1453
|
+
message: `${name}: current ${info.current ?? "not installed"}, latest ${info.latest ?? "unknown"}`
|
|
1454
|
+
})
|
|
1455
|
+
);
|
|
1456
|
+
}
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
// src/utils/budget.ts
|
|
1460
|
+
function severityForOverage(actual, budget) {
|
|
1461
|
+
if (budget <= 0) return "error";
|
|
1462
|
+
return actual >= budget * 1.5 ? "error" : "warn";
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// src/checks/performance/asset-weight.ts
|
|
1466
|
+
var IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/avif"];
|
|
1467
|
+
function assetPath(url) {
|
|
1468
|
+
try {
|
|
1469
|
+
return new URL(url).pathname;
|
|
1470
|
+
} catch {
|
|
1471
|
+
return url;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
function isImageRequest(req) {
|
|
1475
|
+
return req.contentType ? IMAGE_TYPES.includes(req.contentType.split(";")[0].trim()) : false;
|
|
1476
|
+
}
|
|
1477
|
+
var asset_weight_default = defineCheck({
|
|
1478
|
+
id: "performance.asset-weight",
|
|
1479
|
+
name: "Asset Weight & Image Sizes",
|
|
1480
|
+
category: "performance",
|
|
1481
|
+
mode: "dynamic",
|
|
1482
|
+
severity: "warn",
|
|
1483
|
+
description: "Checks total page weight and individual oversized images.",
|
|
1484
|
+
async run(ctx) {
|
|
1485
|
+
const budgets = ctx.config.performance.budgets;
|
|
1486
|
+
const findings = [];
|
|
1487
|
+
const totalKb = ctx.networkRequests.reduce((sum, r) => sum + r.bodySize, 0) / 1024;
|
|
1488
|
+
ctx.detail({
|
|
1489
|
+
label: "Page weight",
|
|
1490
|
+
value: `${Math.round(totalKb)}KB (budget: ${budgets.maxPageWeightKb}KB)`,
|
|
1491
|
+
url: ctx.pageInfo.urlPath
|
|
1492
|
+
});
|
|
1493
|
+
const images = ctx.networkRequests.filter(isImageRequest);
|
|
1494
|
+
if (images.length > 0) {
|
|
1495
|
+
const largestKb = Math.max(...images.map((r) => r.bodySize)) / 1024;
|
|
1496
|
+
ctx.detail({
|
|
1497
|
+
label: "Largest image",
|
|
1498
|
+
value: `${Math.round(largestKb)}KB (budget: ${budgets.maxImageKb}KB)`,
|
|
1499
|
+
url: ctx.pageInfo.urlPath
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
if (totalKb > budgets.maxPageWeightKb) {
|
|
1503
|
+
findings.push(
|
|
1504
|
+
ctx.finding({
|
|
1505
|
+
message: `Page weight ${Math.round(totalKb)}KB exceeds budget of ${budgets.maxPageWeightKb}KB`,
|
|
1506
|
+
url: ctx.pageInfo.urlPath,
|
|
1507
|
+
severity: severityForOverage(totalKb, budgets.maxPageWeightKb)
|
|
1508
|
+
})
|
|
1509
|
+
);
|
|
1510
|
+
}
|
|
1511
|
+
for (const req of images) {
|
|
1512
|
+
const sizeKb = req.bodySize / 1024;
|
|
1513
|
+
if (sizeKb > budgets.maxImageKb) {
|
|
1514
|
+
findings.push(
|
|
1515
|
+
ctx.finding({
|
|
1516
|
+
message: `Image "${assetPath(req.url)}" is ${Math.round(sizeKb)}KB (budget: ${budgets.maxImageKb}KB)`,
|
|
1517
|
+
url: ctx.pageInfo.urlPath,
|
|
1518
|
+
severity: severityForOverage(sizeKb, budgets.maxImageKb)
|
|
1519
|
+
})
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
return findings;
|
|
1524
|
+
}
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
// src/checks/performance/core-web-vitals.ts
|
|
1528
|
+
var core_web_vitals_default = defineCheck({
|
|
1529
|
+
id: "performance.core-web-vitals",
|
|
1530
|
+
name: "Core Web Vitals Budget",
|
|
1531
|
+
category: "performance",
|
|
1532
|
+
mode: "dynamic",
|
|
1533
|
+
severity: "warn",
|
|
1534
|
+
description: "Checks LCP and CLS against configured budgets.",
|
|
1535
|
+
async run(ctx) {
|
|
1536
|
+
const budgets = ctx.config.performance.budgets;
|
|
1537
|
+
const metrics = await ctx.page.evaluate(
|
|
1538
|
+
() => new Promise((resolve) => {
|
|
1539
|
+
let lcp = 0;
|
|
1540
|
+
let lastLcpAt = performance.now();
|
|
1541
|
+
const shifts = [];
|
|
1542
|
+
let lcpObserver;
|
|
1543
|
+
let clsObserver;
|
|
1544
|
+
try {
|
|
1545
|
+
lcpObserver = new PerformanceObserver((list) => {
|
|
1546
|
+
const entries = list.getEntries();
|
|
1547
|
+
const last = entries[entries.length - 1];
|
|
1548
|
+
if (last) {
|
|
1549
|
+
lcp = last.renderTime || last.loadTime || last.startTime;
|
|
1550
|
+
lastLcpAt = performance.now();
|
|
1551
|
+
}
|
|
1552
|
+
});
|
|
1553
|
+
lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
|
|
1554
|
+
clsObserver = new PerformanceObserver((list) => {
|
|
1555
|
+
for (const entry of list.getEntries()) {
|
|
1556
|
+
if (!entry.hadRecentInput) shifts.push({ value: entry.value ?? 0, startTime: entry.startTime });
|
|
1557
|
+
}
|
|
1558
|
+
});
|
|
1559
|
+
clsObserver.observe({ type: "layout-shift", buffered: true });
|
|
1560
|
+
} catch {
|
|
1561
|
+
}
|
|
1562
|
+
function maxSessionWindowCls(entries) {
|
|
1563
|
+
let maxSum = 0;
|
|
1564
|
+
let windowSum = 0;
|
|
1565
|
+
let windowStart = -1;
|
|
1566
|
+
let windowEnd = -1;
|
|
1567
|
+
for (const shift of [...entries].sort((a, b) => a.startTime - b.startTime)) {
|
|
1568
|
+
const startsNewWindow = windowStart === -1 || shift.startTime - windowEnd > 1e3 || shift.startTime - windowStart > 5e3;
|
|
1569
|
+
if (startsNewWindow) {
|
|
1570
|
+
windowStart = shift.startTime;
|
|
1571
|
+
windowSum = 0;
|
|
1572
|
+
}
|
|
1573
|
+
windowEnd = shift.startTime;
|
|
1574
|
+
windowSum += shift.value;
|
|
1575
|
+
maxSum = Math.max(maxSum, windowSum);
|
|
1576
|
+
}
|
|
1577
|
+
return maxSum;
|
|
1578
|
+
}
|
|
1579
|
+
const start = performance.now();
|
|
1580
|
+
const poll = () => {
|
|
1581
|
+
const now = performance.now();
|
|
1582
|
+
if (now - lastLcpAt >= 500 || now - start >= 3e3) {
|
|
1583
|
+
lcpObserver?.disconnect();
|
|
1584
|
+
clsObserver?.disconnect();
|
|
1585
|
+
resolve({ lcp, cls: maxSessionWindowCls(shifts) });
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
setTimeout(poll, 100);
|
|
1589
|
+
};
|
|
1590
|
+
poll();
|
|
1591
|
+
})
|
|
1592
|
+
);
|
|
1593
|
+
const findings = [];
|
|
1594
|
+
ctx.detail({ label: "LCP", value: `${Math.round(metrics.lcp)}ms (budget: ${budgets.lcpMs}ms)`, url: ctx.pageInfo.urlPath });
|
|
1595
|
+
ctx.detail({ label: "CLS", value: `${metrics.cls.toFixed(3)} (budget: ${budgets.cls})`, url: ctx.pageInfo.urlPath });
|
|
1596
|
+
if (metrics.lcp > budgets.lcpMs) {
|
|
1597
|
+
findings.push(
|
|
1598
|
+
ctx.finding({
|
|
1599
|
+
message: `LCP ${Math.round(metrics.lcp)}ms exceeds budget of ${budgets.lcpMs}ms`,
|
|
1600
|
+
url: ctx.pageInfo.urlPath,
|
|
1601
|
+
severity: severityForOverage(metrics.lcp, budgets.lcpMs)
|
|
1602
|
+
})
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
if (metrics.cls > budgets.cls) {
|
|
1606
|
+
findings.push(
|
|
1607
|
+
ctx.finding({
|
|
1608
|
+
message: `CLS ${metrics.cls.toFixed(3)} exceeds budget of ${budgets.cls}`,
|
|
1609
|
+
url: ctx.pageInfo.urlPath,
|
|
1610
|
+
severity: severityForOverage(metrics.cls, budgets.cls)
|
|
1611
|
+
})
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
return findings;
|
|
1615
|
+
}
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
// src/checks/performance/image-format.ts
|
|
1619
|
+
var MODERN_FORMATS = /* @__PURE__ */ new Set(["webp", "avif", "svg"]);
|
|
1620
|
+
var image_format_default = defineCheck({
|
|
1621
|
+
id: "performance.image-format",
|
|
1622
|
+
name: "Image Format",
|
|
1623
|
+
category: "performance",
|
|
1624
|
+
mode: "static",
|
|
1625
|
+
severity: "info",
|
|
1626
|
+
description: "Checks if images use modern formats (WebP/AVIF) instead of legacy formats.",
|
|
1627
|
+
async run(ctx) {
|
|
1628
|
+
const findings = [];
|
|
1629
|
+
let total = 0;
|
|
1630
|
+
let modern = 0;
|
|
1631
|
+
for (const page of ctx.pages) {
|
|
1632
|
+
const root = parseHtmlFile(page.file);
|
|
1633
|
+
const images = root.querySelectorAll("img[src]");
|
|
1634
|
+
for (const img of images) {
|
|
1635
|
+
const src = img.getAttribute("src") ?? "";
|
|
1636
|
+
const ext = src.split(".").pop()?.split("?")[0]?.toLowerCase();
|
|
1637
|
+
if (!ext) continue;
|
|
1638
|
+
total++;
|
|
1639
|
+
if (MODERN_FORMATS.has(ext)) modern++;
|
|
1640
|
+
if (!MODERN_FORMATS.has(ext) && ["jpg", "jpeg", "png", "gif"].includes(ext)) {
|
|
1641
|
+
findings.push(
|
|
1642
|
+
ctx.finding({
|
|
1643
|
+
message: `Image "${src}" uses legacy format "${ext}" \u2014 consider WebP or AVIF`,
|
|
1644
|
+
file: page.relativeFile
|
|
1645
|
+
})
|
|
1646
|
+
);
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
ctx.detail({ label: "Images using a modern format", value: `${modern} / ${total}` });
|
|
1651
|
+
return findings;
|
|
1652
|
+
}
|
|
1653
|
+
});
|
|
1654
|
+
|
|
1655
|
+
// src/checks/performance/lazy-loading.ts
|
|
1656
|
+
var lazy_loading_default = defineCheck({
|
|
1657
|
+
id: "performance.lazy-loading",
|
|
1658
|
+
name: "Lazy Loading",
|
|
1659
|
+
category: "performance",
|
|
1660
|
+
mode: "dynamic",
|
|
1661
|
+
severity: "info",
|
|
1662
|
+
description: 'Checks that images below the fold use loading="lazy", and flags the opposite mistake: an image visible without scrolling marked loading="lazy", which delays it and can hurt LCP. Needs real layout, so this runs in a browser rather than scanning the HTML.',
|
|
1663
|
+
async run(ctx) {
|
|
1664
|
+
const viewportHeight = ctx.page.viewportSize()?.height ?? 720;
|
|
1665
|
+
const images = await ctx.page.$$eval(
|
|
1666
|
+
"img[src]",
|
|
1667
|
+
(elements) => elements.map((el) => {
|
|
1668
|
+
const rect = el.getBoundingClientRect();
|
|
1669
|
+
return {
|
|
1670
|
+
src: el.getAttribute("src") ?? "",
|
|
1671
|
+
loading: el.getAttribute("loading"),
|
|
1672
|
+
top: rect.top,
|
|
1673
|
+
width: rect.width,
|
|
1674
|
+
height: rect.height
|
|
1675
|
+
};
|
|
1676
|
+
})
|
|
1677
|
+
);
|
|
1678
|
+
const eligible = images.filter((img) => img.width > 0 && img.height > 0);
|
|
1679
|
+
const lazyCount = eligible.filter((img) => img.loading === "lazy").length;
|
|
1680
|
+
ctx.detail({ label: "Images checked / marked lazy", value: `${eligible.length} / ${lazyCount}`, url: ctx.pageInfo.urlPath });
|
|
1681
|
+
const findings = [];
|
|
1682
|
+
for (const img of images) {
|
|
1683
|
+
if (img.width === 0 || img.height === 0) continue;
|
|
1684
|
+
const isAboveTheFold = img.top < viewportHeight;
|
|
1685
|
+
if (isAboveTheFold && img.loading === "lazy") {
|
|
1686
|
+
findings.push(
|
|
1687
|
+
ctx.finding({
|
|
1688
|
+
message: `Image "${img.src}" is visible without scrolling but marked loading="lazy" \u2014 delays it behind the loading queue and can hurt LCP`,
|
|
1689
|
+
url: ctx.pageInfo.urlPath,
|
|
1690
|
+
severity: "warn"
|
|
1691
|
+
})
|
|
1692
|
+
);
|
|
1693
|
+
} else if (!isAboveTheFold && img.loading !== "lazy") {
|
|
1694
|
+
findings.push(
|
|
1695
|
+
ctx.finding({
|
|
1696
|
+
message: `Image "${img.src}" is below the fold but missing loading="lazy"`,
|
|
1697
|
+
url: ctx.pageInfo.urlPath
|
|
1698
|
+
})
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
return findings;
|
|
1703
|
+
}
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
// src/checks/performance/third-party-impact.ts
|
|
1707
|
+
var third_party_impact_default = defineCheck({
|
|
1708
|
+
id: "performance.third-party-impact",
|
|
1709
|
+
name: "Third-Party Impact",
|
|
1710
|
+
category: "performance",
|
|
1711
|
+
mode: "dynamic",
|
|
1712
|
+
severity: "warn",
|
|
1713
|
+
description: "Detects render-blocking third-party resources that may impact performance.",
|
|
1714
|
+
async run(ctx) {
|
|
1715
|
+
const findings = [];
|
|
1716
|
+
const firstPartyHost = new URL(ctx.page.url()).hostname;
|
|
1717
|
+
const thirdPartyRequests = ctx.networkRequests.filter((req) => {
|
|
1718
|
+
if (!req.isMainFrame) return false;
|
|
1719
|
+
try {
|
|
1720
|
+
const host = new URL(req.url).hostname;
|
|
1721
|
+
return host !== firstPartyHost && !host.endsWith(`.${firstPartyHost}`);
|
|
1722
|
+
} catch {
|
|
1723
|
+
return false;
|
|
1724
|
+
}
|
|
1725
|
+
});
|
|
1726
|
+
const nonBlockingScriptUrls = new Set(
|
|
1727
|
+
await ctx.page.$$eval("script[src][async], script[src][defer]", (els) => els.map((el) => el.src))
|
|
1728
|
+
);
|
|
1729
|
+
const blockingTypes = ["text/html", "text/css", "application/javascript", "text/javascript"];
|
|
1730
|
+
const blockingRequests = thirdPartyRequests.filter((req) => {
|
|
1731
|
+
const contentType = req.contentType?.split(";")[0]?.trim() ?? "";
|
|
1732
|
+
if (!blockingTypes.includes(contentType)) return false;
|
|
1733
|
+
if (req.resourceType === "script" && nonBlockingScriptUrls.has(req.url)) return false;
|
|
1734
|
+
return true;
|
|
1735
|
+
});
|
|
1736
|
+
for (const req of blockingRequests) {
|
|
1737
|
+
findings.push(
|
|
1738
|
+
ctx.finding({
|
|
1739
|
+
message: `Third-party blocking resource: "${req.url}"`,
|
|
1740
|
+
url: ctx.pageInfo.urlPath
|
|
1741
|
+
})
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1744
|
+
ctx.detail({
|
|
1745
|
+
label: "Third-party requests / render-blocking",
|
|
1746
|
+
value: `${thirdPartyRequests.length} / ${blockingRequests.length}`,
|
|
1747
|
+
url: ctx.pageInfo.urlPath
|
|
1748
|
+
});
|
|
1749
|
+
return findings;
|
|
1750
|
+
}
|
|
1751
|
+
});
|
|
1752
|
+
|
|
1753
|
+
// src/checks/privacy/match-domains.ts
|
|
1754
|
+
function uniqueUrlsMatchingDomains(requests, domains) {
|
|
1755
|
+
const matches = requests.filter((r) => {
|
|
1756
|
+
try {
|
|
1757
|
+
const host = new URL(r.url).hostname;
|
|
1758
|
+
return domains.some((domain) => host === domain || host.endsWith(`.${domain}`));
|
|
1759
|
+
} catch {
|
|
1760
|
+
return false;
|
|
1761
|
+
}
|
|
1762
|
+
});
|
|
1763
|
+
return [...new Set(matches.map((r) => r.url))];
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
// src/checks/privacy/external-fonts.ts
|
|
1767
|
+
var EXTERNAL_FONT_HOSTS = ["fonts.googleapis.com", "fonts.gstatic.com", "use.typekit.net", "fonts.adobe.com"];
|
|
1768
|
+
var external_fonts_default = defineCheck({
|
|
1769
|
+
id: "privacy.external-fonts",
|
|
1770
|
+
name: "External Fonts/APIs",
|
|
1771
|
+
category: "privacy",
|
|
1772
|
+
mode: "dynamic",
|
|
1773
|
+
severity: "info",
|
|
1774
|
+
description: "Detects externally loaded fonts/APIs (a GDPR consideration \u2014 self-hosting is recommended).",
|
|
1775
|
+
async run(ctx) {
|
|
1776
|
+
const uniqueUrls = uniqueUrlsMatchingDomains(ctx.networkRequests, EXTERNAL_FONT_HOSTS);
|
|
1777
|
+
ctx.detail({ label: "Externally loaded fonts/APIs", value: String(uniqueUrls.length), url: ctx.pageInfo.urlPath });
|
|
1778
|
+
return uniqueUrls.map((url) => ctx.finding({ message: `Externally loaded resource "${url}"`, url: ctx.pageInfo.urlPath }));
|
|
1779
|
+
}
|
|
1780
|
+
});
|
|
1781
|
+
|
|
1782
|
+
// src/checks/privacy/fingerprinting.ts
|
|
1783
|
+
var FINGERPRINTING_APIS = [
|
|
1784
|
+
"canvas.toDataURL",
|
|
1785
|
+
"canvas.toBlob",
|
|
1786
|
+
"canvas.getContext('webgl')",
|
|
1787
|
+
"canvas.getContext('experimental-webgl')",
|
|
1788
|
+
"webgl.getParameter",
|
|
1789
|
+
"audioContext.createOscillator",
|
|
1790
|
+
"AudioContext",
|
|
1791
|
+
"OfflineAudioContext"
|
|
1792
|
+
];
|
|
1793
|
+
var fingerprinting_default = defineCheck({
|
|
1794
|
+
id: "privacy.fingerprinting",
|
|
1795
|
+
name: "Fingerprinting",
|
|
1796
|
+
category: "privacy",
|
|
1797
|
+
mode: "dynamic",
|
|
1798
|
+
severity: "warn",
|
|
1799
|
+
description: "Detects browser fingerprinting APIs (Canvas, WebGL, Audio).",
|
|
1800
|
+
async run(ctx) {
|
|
1801
|
+
const findings = [];
|
|
1802
|
+
const scripts = await ctx.page.$$eval("script:not([src])", (els) => els.map((el) => el.textContent ?? "").join("\n"));
|
|
1803
|
+
for (const api of FINGERPRINTING_APIS) {
|
|
1804
|
+
if (scripts.includes(api)) {
|
|
1805
|
+
findings.push(
|
|
1806
|
+
ctx.finding({
|
|
1807
|
+
message: `Potential fingerprinting API detected: "${api}"`,
|
|
1808
|
+
url: ctx.pageInfo.urlPath
|
|
1809
|
+
})
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
ctx.detail({
|
|
1814
|
+
label: "Fingerprinting APIs checked / found",
|
|
1815
|
+
value: `${FINGERPRINTING_APIS.length} / ${findings.length}`,
|
|
1816
|
+
url: ctx.pageInfo.urlPath
|
|
1817
|
+
});
|
|
1818
|
+
return findings;
|
|
1819
|
+
}
|
|
1820
|
+
});
|
|
1821
|
+
|
|
1822
|
+
// src/checks/privacy/tracker-requests.ts
|
|
1823
|
+
var BUILT_IN_TRACKER_DOMAINS = [
|
|
1824
|
+
"google-analytics.com",
|
|
1825
|
+
"googletagmanager.com",
|
|
1826
|
+
"doubleclick.net",
|
|
1827
|
+
"connect.facebook.net",
|
|
1828
|
+
"facebook.com/tr",
|
|
1829
|
+
"hotjar.com",
|
|
1830
|
+
"segment.io",
|
|
1831
|
+
"mixpanel.com",
|
|
1832
|
+
"analytics.tiktok.com"
|
|
1833
|
+
];
|
|
1834
|
+
var CONSENT_HINTS = ["cookieconsent", "cookiebot", "usercentrics", "klaro", "cookie-consent", "cmp"];
|
|
1835
|
+
var tracker_requests_default = defineCheck({
|
|
1836
|
+
id: "privacy.tracker-requests",
|
|
1837
|
+
name: "Tracking Without Consent",
|
|
1838
|
+
category: "privacy",
|
|
1839
|
+
mode: "dynamic",
|
|
1840
|
+
severity: "warn",
|
|
1841
|
+
description: "Heuristic: tracking requests on initial load without a detectable consent mechanism.",
|
|
1842
|
+
async run(ctx) {
|
|
1843
|
+
const trackerDomains = [...BUILT_IN_TRACKER_DOMAINS, ...ctx.config.privacy.trackerDomains];
|
|
1844
|
+
const uniqueUrls = uniqueUrlsMatchingDomains(ctx.networkRequests, trackerDomains);
|
|
1845
|
+
if (uniqueUrls.length === 0) {
|
|
1846
|
+
ctx.detail({ label: "Tracker requests detected", value: "0", url: ctx.pageInfo.urlPath });
|
|
1847
|
+
return [];
|
|
1848
|
+
}
|
|
1849
|
+
const html = (await ctx.page.content()).toLowerCase();
|
|
1850
|
+
const hasConsentHint = CONSENT_HINTS.some((hint) => html.includes(hint));
|
|
1851
|
+
ctx.detail({
|
|
1852
|
+
label: "Tracker requests / consent mechanism detected",
|
|
1853
|
+
value: `${uniqueUrls.length} / ${hasConsentHint ? "yes" : "no"}`,
|
|
1854
|
+
url: ctx.pageInfo.urlPath
|
|
1855
|
+
});
|
|
1856
|
+
if (hasConsentHint) return [];
|
|
1857
|
+
return uniqueUrls.map(
|
|
1858
|
+
(url) => ctx.finding({
|
|
1859
|
+
message: `Tracking request to "${url}" on load without a detectable consent mechanism (heuristic)`,
|
|
1860
|
+
url: ctx.pageInfo.urlPath
|
|
1861
|
+
})
|
|
1862
|
+
);
|
|
1863
|
+
}
|
|
1864
|
+
});
|
|
1865
|
+
|
|
1866
|
+
// src/checks/security/cookie-security.ts
|
|
1867
|
+
function cookieName(setCookieHeader) {
|
|
1868
|
+
return setCookieHeader.split(";")[0]?.split("=")[0]?.trim() || "(unnamed)";
|
|
1869
|
+
}
|
|
1870
|
+
function hasAttribute(setCookieHeader, attribute) {
|
|
1871
|
+
return setCookieHeader.split(";").slice(1).some((part) => part.trim().toLowerCase().startsWith(attribute));
|
|
1872
|
+
}
|
|
1873
|
+
var cookie_security_default = defineCheck({
|
|
1874
|
+
id: "security.cookie-security",
|
|
1875
|
+
name: "Cookie Security",
|
|
1876
|
+
category: "security",
|
|
1877
|
+
mode: "live",
|
|
1878
|
+
severity: "warn",
|
|
1879
|
+
description: "Checks Set-Cookie headers on the live response for missing Secure, HttpOnly, and SameSite attributes.",
|
|
1880
|
+
run(ctx) {
|
|
1881
|
+
ctx.detail({ label: "Cookies set", value: String(ctx.setCookieHeaders.length) });
|
|
1882
|
+
if (ctx.setCookieHeaders.length === 0) return [];
|
|
1883
|
+
const isHttps = ctx.isHttps;
|
|
1884
|
+
const findings = [];
|
|
1885
|
+
for (const setCookie of ctx.setCookieHeaders) {
|
|
1886
|
+
const name = cookieName(setCookie);
|
|
1887
|
+
ctx.detail({
|
|
1888
|
+
label: name,
|
|
1889
|
+
value: `Secure: ${hasAttribute(setCookie, "secure")}, HttpOnly: ${hasAttribute(setCookie, "httponly")}, SameSite: ${hasAttribute(setCookie, "samesite")}`
|
|
1890
|
+
});
|
|
1891
|
+
if (isHttps && !hasAttribute(setCookie, "secure")) {
|
|
1892
|
+
findings.push(
|
|
1893
|
+
ctx.finding({ message: `Cookie "${name}" is missing the Secure attribute \u2014 it can be sent over an unencrypted connection` })
|
|
1894
|
+
);
|
|
1895
|
+
}
|
|
1896
|
+
if (!hasAttribute(setCookie, "httponly")) {
|
|
1897
|
+
findings.push(
|
|
1898
|
+
ctx.finding({
|
|
1899
|
+
message: `Cookie "${name}" is missing the HttpOnly attribute \u2014 it's readable from JavaScript, widening the blast radius of an XSS bug`
|
|
1900
|
+
})
|
|
1901
|
+
);
|
|
1902
|
+
}
|
|
1903
|
+
if (!hasAttribute(setCookie, "samesite")) {
|
|
1904
|
+
findings.push(
|
|
1905
|
+
ctx.finding({
|
|
1906
|
+
message: `Cookie "${name}" is missing the SameSite attribute \u2014 it defaults to a lax policy that still allows some cross-site requests`,
|
|
1907
|
+
severity: "info"
|
|
1908
|
+
})
|
|
1909
|
+
);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
return findings;
|
|
1913
|
+
}
|
|
1914
|
+
});
|
|
1915
|
+
|
|
1916
|
+
// src/checks/security/dependency-vulnerabilities.ts
|
|
1917
|
+
import path10 from "path";
|
|
1918
|
+
var SEVERITY_MAP = {
|
|
1919
|
+
critical: "error",
|
|
1920
|
+
high: "error",
|
|
1921
|
+
moderate: "warn",
|
|
1922
|
+
low: "info",
|
|
1923
|
+
info: "info"
|
|
1924
|
+
};
|
|
1925
|
+
var dependency_vulnerabilities_default = defineCheck({
|
|
1926
|
+
id: "security.dependency-vulnerabilities",
|
|
1927
|
+
name: "Dependency Vulnerabilities (npm audit)",
|
|
1928
|
+
category: "security",
|
|
1929
|
+
mode: "static",
|
|
1930
|
+
severity: "error",
|
|
1931
|
+
description: 'Checks for known vulnerabilities in npm dependencies via "npm audit".',
|
|
1932
|
+
async run(ctx) {
|
|
1933
|
+
if (!ctx.fileExists(path10.join(ctx.siteRoot, "package.json"))) ctx.skip("no package.json found");
|
|
1934
|
+
const hasNpmLock = ctx.fileExists(path10.join(ctx.siteRoot, "package-lock.json"));
|
|
1935
|
+
if (!hasNpmLock) {
|
|
1936
|
+
if (ctx.fileExists(path10.join(ctx.siteRoot, "pnpm-lock.yaml"))) {
|
|
1937
|
+
ctx.skip("project uses a pnpm lockfile \u2014 npm audit can't read it; run `pnpm audit` manually");
|
|
1938
|
+
}
|
|
1939
|
+
if (ctx.fileExists(path10.join(ctx.siteRoot, "yarn.lock"))) {
|
|
1940
|
+
ctx.skip("project uses a yarn lockfile \u2014 npm audit can't read it; run `yarn audit` manually");
|
|
1941
|
+
}
|
|
1942
|
+
ctx.skip("no package-lock.json found \u2014 run `npm install` so dependencies can be audited");
|
|
1943
|
+
}
|
|
1944
|
+
const output = await ctx.exec("npm audit --json");
|
|
1945
|
+
let report = {};
|
|
1946
|
+
try {
|
|
1947
|
+
report = JSON.parse(output);
|
|
1948
|
+
} catch (error) {
|
|
1949
|
+
ctx.skip(`npm audit produced unreadable output: ${error.message}`);
|
|
1950
|
+
}
|
|
1951
|
+
if (report.error) {
|
|
1952
|
+
ctx.skip(`npm audit failed: ${report.error.summary ?? report.error.code ?? "unknown error"}`);
|
|
1953
|
+
}
|
|
1954
|
+
const vulnerabilities = Object.entries(report.vulnerabilities ?? {});
|
|
1955
|
+
ctx.detail({ label: "Vulnerable dependencies found", value: String(vulnerabilities.length) });
|
|
1956
|
+
return vulnerabilities.map(
|
|
1957
|
+
([name, info]) => ctx.finding({
|
|
1958
|
+
message: `${name}: ${info.severity} severity vulnerability${info.via?.[0]?.title ? ` (${info.via[0].title})` : ""}`,
|
|
1959
|
+
severity: SEVERITY_MAP[info.severity] ?? "warn"
|
|
1960
|
+
})
|
|
1961
|
+
);
|
|
1962
|
+
}
|
|
1963
|
+
});
|
|
1964
|
+
|
|
1965
|
+
// src/checks/security/https-enforced.ts
|
|
1966
|
+
var https_enforced_default = defineCheck({
|
|
1967
|
+
id: "security.https-enforced",
|
|
1968
|
+
name: "HTTPS Enforced",
|
|
1969
|
+
category: "security",
|
|
1970
|
+
mode: "live",
|
|
1971
|
+
severity: "error",
|
|
1972
|
+
description: "Checks that the site is served over HTTPS, or at least redirects HTTP requests to HTTPS.",
|
|
1973
|
+
run(ctx) {
|
|
1974
|
+
ctx.detail({ label: "Protocol", value: ctx.parsedUrl.protocol.replace(":", "") });
|
|
1975
|
+
ctx.detail({ label: "Final URL", value: ctx.finalUrl });
|
|
1976
|
+
if (ctx.parsedUrl.protocol === "https:") return [];
|
|
1977
|
+
if (ctx.redirected && ctx.finalUrl.startsWith("https://")) {
|
|
1978
|
+
return [
|
|
1979
|
+
ctx.finding({
|
|
1980
|
+
message: `HTTP requests redirect to HTTPS (ended up at "${ctx.finalUrl}"), but the initial connection is still unencrypted \u2014 link directly to the HTTPS URL and add a Strict-Transport-Security header`,
|
|
1981
|
+
severity: "info"
|
|
1982
|
+
})
|
|
1983
|
+
];
|
|
1984
|
+
}
|
|
1985
|
+
return [
|
|
1986
|
+
ctx.finding({
|
|
1987
|
+
message: "Site is served over plain HTTP and does not redirect to HTTPS \u2014 traffic (including any credentials) can be intercepted or modified in transit",
|
|
1988
|
+
severity: "error"
|
|
1989
|
+
})
|
|
1990
|
+
];
|
|
1991
|
+
}
|
|
1992
|
+
});
|
|
1993
|
+
|
|
1994
|
+
// src/checks/security/mixed-content.ts
|
|
1995
|
+
import { parse as parse2 } from "node-html-parser";
|
|
1996
|
+
var RESOURCE_ATTRS = [
|
|
1997
|
+
["img[src]", "src"],
|
|
1998
|
+
["script[src]", "src"],
|
|
1999
|
+
["iframe[src]", "src"],
|
|
2000
|
+
["source[src]", "src"],
|
|
2001
|
+
["video[src]", "src"],
|
|
2002
|
+
["audio[src]", "src"],
|
|
2003
|
+
["embed[src]", "src"],
|
|
2004
|
+
["object[data]", "data"],
|
|
2005
|
+
['link[rel="stylesheet"][href]', "href"]
|
|
2006
|
+
];
|
|
2007
|
+
var CSS_URL_PATTERN = /url\(\s*['"]?(http:\/\/[^)'"]+)['"]?\s*\)/gi;
|
|
2008
|
+
var mixed_content_default = defineCheck({
|
|
2009
|
+
id: "security.mixed-content",
|
|
2010
|
+
name: "Mixed Content",
|
|
2011
|
+
category: "security",
|
|
2012
|
+
mode: "live",
|
|
2013
|
+
severity: "warn",
|
|
2014
|
+
description: "Scans the fetched HTML of an HTTPS page for hardcoded HTTP resource references (img/script/iframe/stylesheet/etc., and url(http://...) in CSS). Resources injected by client-side JavaScript after load aren't visible here.",
|
|
2015
|
+
run(ctx) {
|
|
2016
|
+
if (!ctx.isHttps) {
|
|
2017
|
+
ctx.skip("Mixed content only applies to HTTPS pages \u2014 this URL is HTTP");
|
|
2018
|
+
}
|
|
2019
|
+
const root = parse2(ctx.html);
|
|
2020
|
+
const findings = [];
|
|
2021
|
+
let checked = 0;
|
|
2022
|
+
for (const [selector, attribute] of RESOURCE_ATTRS) {
|
|
2023
|
+
for (const el of root.querySelectorAll(selector)) {
|
|
2024
|
+
const url = el.getAttribute(attribute);
|
|
2025
|
+
if (!url) continue;
|
|
2026
|
+
checked++;
|
|
2027
|
+
if (/^http:\/\//i.test(url)) {
|
|
2028
|
+
findings.push(ctx.finding({ message: `Mixed content: HTTP resource "${url}" referenced on an HTTPS page` }));
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
for (const styleEl of root.querySelectorAll("style")) {
|
|
2033
|
+
for (const match of styleEl.text.matchAll(CSS_URL_PATTERN)) {
|
|
2034
|
+
checked++;
|
|
2035
|
+
findings.push(ctx.finding({ message: `Mixed content: HTTP resource "${match[1]}" referenced in an inline <style> block` }));
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
for (const el of root.querySelectorAll("[style]")) {
|
|
2039
|
+
for (const match of (el.getAttribute("style") ?? "").matchAll(CSS_URL_PATTERN)) {
|
|
2040
|
+
checked++;
|
|
2041
|
+
findings.push(ctx.finding({ message: `Mixed content: HTTP resource "${match[1]}" referenced in a style attribute` }));
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
ctx.detail({ label: "Resource references checked", value: String(checked) });
|
|
2045
|
+
return findings;
|
|
2046
|
+
}
|
|
2047
|
+
});
|
|
2048
|
+
|
|
2049
|
+
// src/checks/security/security-headers.ts
|
|
2050
|
+
var HEADER_RULES = [
|
|
2051
|
+
{
|
|
2052
|
+
header: "strict-transport-security",
|
|
2053
|
+
severity: "warn",
|
|
2054
|
+
message: "Missing Strict-Transport-Security header \u2014 browsers can't be told to always use HTTPS for this site",
|
|
2055
|
+
httpsOnly: true
|
|
2056
|
+
},
|
|
2057
|
+
{
|
|
2058
|
+
header: "content-security-policy",
|
|
2059
|
+
severity: "warn",
|
|
2060
|
+
message: "Missing Content-Security-Policy header \u2014 no baseline defense against XSS/data-injection attacks"
|
|
2061
|
+
},
|
|
2062
|
+
{
|
|
2063
|
+
header: "x-content-type-options",
|
|
2064
|
+
severity: "warn",
|
|
2065
|
+
message: "Missing X-Content-Type-Options header \u2014 browsers may MIME-sniff responses into an unintended content type"
|
|
2066
|
+
},
|
|
2067
|
+
{
|
|
2068
|
+
header: "referrer-policy",
|
|
2069
|
+
severity: "info",
|
|
2070
|
+
message: "Missing Referrer-Policy header \u2014 full URLs (query strings included) may leak to third parties via the Referer header"
|
|
2071
|
+
},
|
|
2072
|
+
{
|
|
2073
|
+
header: "permissions-policy",
|
|
2074
|
+
severity: "info",
|
|
2075
|
+
message: "Missing Permissions-Policy header \u2014 no explicit restriction on powerful browser features (camera, geolocation, ...)"
|
|
2076
|
+
}
|
|
2077
|
+
];
|
|
2078
|
+
var security_headers_default = defineCheck({
|
|
2079
|
+
id: "security.security-headers",
|
|
2080
|
+
name: "Security Headers",
|
|
2081
|
+
category: "security",
|
|
2082
|
+
mode: "live",
|
|
2083
|
+
severity: "warn",
|
|
2084
|
+
description: "Checks the live response for standard security headers (HSTS, CSP, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, X-Frame-Options/frame-ancestors).",
|
|
2085
|
+
run(ctx) {
|
|
2086
|
+
const findings = [];
|
|
2087
|
+
const isHttps = ctx.isHttps;
|
|
2088
|
+
const csp = ctx.headers["content-security-policy"];
|
|
2089
|
+
for (const rule of HEADER_RULES) {
|
|
2090
|
+
if (rule.httpsOnly && !isHttps) continue;
|
|
2091
|
+
const value = ctx.headers[rule.header];
|
|
2092
|
+
ctx.detail({ label: rule.header, value: value ?? "(missing)" });
|
|
2093
|
+
if (!value) {
|
|
2094
|
+
findings.push(ctx.finding({ message: rule.message, severity: rule.severity }));
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
const hasFrameAncestors = csp?.toLowerCase().includes("frame-ancestors") ?? false;
|
|
2098
|
+
ctx.detail({
|
|
2099
|
+
label: "x-frame-options",
|
|
2100
|
+
value: ctx.headers["x-frame-options"] ?? (hasFrameAncestors ? "(covered by CSP frame-ancestors)" : "(missing)")
|
|
2101
|
+
});
|
|
2102
|
+
if (!ctx.headers["x-frame-options"] && !hasFrameAncestors) {
|
|
2103
|
+
findings.push(
|
|
2104
|
+
ctx.finding({
|
|
2105
|
+
message: "Missing X-Frame-Options header (and no CSP frame-ancestors directive) \u2014 page can be embedded in a clickjacking iframe",
|
|
2106
|
+
severity: "warn"
|
|
2107
|
+
})
|
|
2108
|
+
);
|
|
2109
|
+
}
|
|
2110
|
+
return findings;
|
|
2111
|
+
}
|
|
2112
|
+
});
|
|
2113
|
+
|
|
2114
|
+
// src/checks/security/server-info-disclosure.ts
|
|
2115
|
+
var DISCLOSURE_HEADERS = ["server", "x-powered-by", "x-aspnet-version", "x-aspnetmvc-version"];
|
|
2116
|
+
var VERSION_PATTERN = /\d+\.\d+/;
|
|
2117
|
+
var server_info_disclosure_default = defineCheck({
|
|
2118
|
+
id: "security.server-info-disclosure",
|
|
2119
|
+
name: "Server Info Disclosure",
|
|
2120
|
+
category: "security",
|
|
2121
|
+
mode: "live",
|
|
2122
|
+
severity: "info",
|
|
2123
|
+
description: "Flags response headers (Server, X-Powered-By, ...) that disclose server software or version numbers to attackers.",
|
|
2124
|
+
run(ctx) {
|
|
2125
|
+
const findings = [];
|
|
2126
|
+
for (const header of DISCLOSURE_HEADERS) {
|
|
2127
|
+
const value = ctx.headers[header];
|
|
2128
|
+
ctx.detail({ label: header, value: value ?? "(not sent)" });
|
|
2129
|
+
if (!value) continue;
|
|
2130
|
+
const revealsVersion = VERSION_PATTERN.test(value);
|
|
2131
|
+
findings.push(
|
|
2132
|
+
ctx.finding({
|
|
2133
|
+
message: `"${header}" header discloses ${revealsVersion ? "a specific software version" : "server software"}: "${value}"`,
|
|
2134
|
+
severity: revealsVersion ? "warn" : "info"
|
|
2135
|
+
})
|
|
2136
|
+
);
|
|
2137
|
+
}
|
|
2138
|
+
return findings;
|
|
2139
|
+
}
|
|
2140
|
+
});
|
|
2141
|
+
|
|
2142
|
+
// src/checks/seo/canonical-url.ts
|
|
2143
|
+
var canonical_url_default = defineCheck({
|
|
2144
|
+
id: "seo.canonical-url",
|
|
2145
|
+
name: "Canonical URL",
|
|
2146
|
+
category: "seo",
|
|
2147
|
+
mode: "static",
|
|
2148
|
+
severity: "warn",
|
|
2149
|
+
description: "Checks for missing or duplicate canonical URLs.",
|
|
2150
|
+
async run(ctx) {
|
|
2151
|
+
const findings = [];
|
|
2152
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2153
|
+
for (const page of ctx.pages) {
|
|
2154
|
+
const root = parseHtmlFile(page.file);
|
|
2155
|
+
const canonical = root.querySelector('link[rel="canonical"]')?.getAttribute("href");
|
|
2156
|
+
if (!canonical) {
|
|
2157
|
+
findings.push(
|
|
2158
|
+
ctx.finding({
|
|
2159
|
+
message: "Missing canonical URL",
|
|
2160
|
+
file: page.relativeFile
|
|
2161
|
+
})
|
|
2162
|
+
);
|
|
2163
|
+
continue;
|
|
2164
|
+
}
|
|
2165
|
+
ctx.detail({ label: "Canonical URL", value: canonical, url: page.urlPath });
|
|
2166
|
+
const existing = seen.get(canonical);
|
|
2167
|
+
if (existing && existing !== page.relativeFile) {
|
|
2168
|
+
findings.push(
|
|
2169
|
+
ctx.finding({
|
|
2170
|
+
message: `Duplicate canonical URL "${canonical}" used by "${existing}"`,
|
|
2171
|
+
file: page.relativeFile,
|
|
2172
|
+
severity: "warn"
|
|
2173
|
+
})
|
|
2174
|
+
);
|
|
2175
|
+
} else {
|
|
2176
|
+
seen.set(canonical, page.relativeFile);
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
return findings;
|
|
2180
|
+
}
|
|
2181
|
+
});
|
|
2182
|
+
|
|
2183
|
+
// src/checks/seo/meta-tags.ts
|
|
2184
|
+
var OG_PROPERTIES = ["og:title", "og:description", "og:image"];
|
|
2185
|
+
var TITLE_TRUNCATION_LENGTH = 70;
|
|
2186
|
+
var meta_tags_default = defineCheck({
|
|
2187
|
+
id: "seo.meta-tags",
|
|
2188
|
+
name: "Meta/Title/OG Tags",
|
|
2189
|
+
category: "seo",
|
|
2190
|
+
mode: "static",
|
|
2191
|
+
severity: "warn",
|
|
2192
|
+
description: "Checks title, meta description, and Open Graph tags per page.",
|
|
2193
|
+
async run(ctx) {
|
|
2194
|
+
const findings = [];
|
|
2195
|
+
for (const page of ctx.pages) {
|
|
2196
|
+
const root = parseHtmlFile(page.file);
|
|
2197
|
+
const title = root.querySelector("title")?.text?.trim();
|
|
2198
|
+
if (!title) {
|
|
2199
|
+
findings.push(ctx.finding({ message: "Missing <title>", file: page.relativeFile }));
|
|
2200
|
+
} else {
|
|
2201
|
+
ctx.detail({ label: "Title", value: `"${title}" (${title.length} chars)`, url: page.urlPath });
|
|
2202
|
+
if (title.length > TITLE_TRUNCATION_LENGTH) {
|
|
2203
|
+
findings.push(
|
|
2204
|
+
ctx.finding({
|
|
2205
|
+
message: `Title is ${title.length} characters long \u2014 likely truncated in search results (~${TITLE_TRUNCATION_LENGTH}+ chars)`,
|
|
2206
|
+
file: page.relativeFile
|
|
2207
|
+
})
|
|
2208
|
+
);
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
const description = root.querySelector('meta[name="description"]')?.getAttribute("content")?.trim();
|
|
2212
|
+
if (!description) {
|
|
2213
|
+
findings.push(ctx.finding({ message: 'Missing <meta name="description">', file: page.relativeFile }));
|
|
2214
|
+
} else {
|
|
2215
|
+
ctx.detail({ label: "Meta description", value: `"${description}" (${description.length} chars)`, url: page.urlPath });
|
|
2216
|
+
}
|
|
2217
|
+
const foundOg = OG_PROPERTIES.filter((property) => root.querySelector(`meta[property="${property}"]`)?.getAttribute("content"));
|
|
2218
|
+
ctx.detail({ label: "Open Graph tags found", value: foundOg.length > 0 ? foundOg.join(", ") : "(none)", url: page.urlPath });
|
|
2219
|
+
const missingOg = OG_PROPERTIES.filter((property) => !foundOg.includes(property));
|
|
2220
|
+
if (missingOg.length > 0) {
|
|
2221
|
+
findings.push(
|
|
2222
|
+
ctx.finding({
|
|
2223
|
+
message: `Missing Open Graph tag${missingOg.length > 1 ? "s" : ""}: ${missingOg.join(", ")}`,
|
|
2224
|
+
file: page.relativeFile
|
|
2225
|
+
})
|
|
2226
|
+
);
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
return findings;
|
|
2230
|
+
}
|
|
2231
|
+
});
|
|
2232
|
+
|
|
2233
|
+
// src/checks/seo/og-images.ts
|
|
2234
|
+
import { existsSync as existsSync4 } from "fs";
|
|
2235
|
+
import path11 from "path";
|
|
2236
|
+
var og_images_default = defineCheck({
|
|
2237
|
+
id: "seo.og-images",
|
|
2238
|
+
name: "Open Graph Images",
|
|
2239
|
+
category: "seo",
|
|
2240
|
+
mode: "static",
|
|
2241
|
+
severity: "info",
|
|
2242
|
+
description: "Validates Open Graph images exist and are accessible.",
|
|
2243
|
+
async run(ctx) {
|
|
2244
|
+
const findings = [];
|
|
2245
|
+
for (const page of ctx.pages) {
|
|
2246
|
+
const root = parseHtmlFile(page.file);
|
|
2247
|
+
const ogImage = root.querySelector('meta[property="og:image"]')?.getAttribute("content");
|
|
2248
|
+
if (!ogImage) {
|
|
2249
|
+
findings.push(
|
|
2250
|
+
ctx.finding({
|
|
2251
|
+
message: "Missing og:image tag",
|
|
2252
|
+
file: page.relativeFile,
|
|
2253
|
+
severity: "info"
|
|
2254
|
+
})
|
|
2255
|
+
);
|
|
2256
|
+
continue;
|
|
2257
|
+
}
|
|
2258
|
+
ctx.detail({ label: "og:image", value: ogImage, url: page.urlPath });
|
|
2259
|
+
if (ogImage.startsWith("http")) continue;
|
|
2260
|
+
const imagePath = path11.join(path11.dirname(page.file), ogImage);
|
|
2261
|
+
if (!existsSync4(imagePath)) {
|
|
2262
|
+
findings.push(
|
|
2263
|
+
ctx.finding({
|
|
2264
|
+
message: `og:image "${ogImage}" not found`,
|
|
2265
|
+
file: page.relativeFile
|
|
2266
|
+
})
|
|
2267
|
+
);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
return findings;
|
|
2271
|
+
}
|
|
2272
|
+
});
|
|
2273
|
+
|
|
2274
|
+
// src/checks/seo/robots-sitemap.ts
|
|
2275
|
+
import path12 from "path";
|
|
2276
|
+
function isBlanketDisallow(content) {
|
|
2277
|
+
let currentAgentIsWildcard = false;
|
|
2278
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
2279
|
+
const line = rawLine.trim();
|
|
2280
|
+
if (/^user-agent\s*:/i.test(line)) {
|
|
2281
|
+
currentAgentIsWildcard = line.split(":")[1]?.trim() === "*";
|
|
2282
|
+
continue;
|
|
2283
|
+
}
|
|
2284
|
+
if (currentAgentIsWildcard && /^disallow\s*:\s*\/\s*$/i.test(line)) return true;
|
|
2285
|
+
}
|
|
2286
|
+
return false;
|
|
2287
|
+
}
|
|
2288
|
+
function extractSitemapUrls(content) {
|
|
2289
|
+
return content.split(/\r?\n/).map((line) => line.trim()).filter((line) => /^sitemap\s*:/i.test(line)).map((line) => line.slice(line.indexOf(":") + 1).trim());
|
|
2290
|
+
}
|
|
2291
|
+
var robots_sitemap_default = defineCheck({
|
|
2292
|
+
id: "seo.robots-sitemap",
|
|
2293
|
+
name: "robots.txt & Sitemap",
|
|
2294
|
+
category: "seo",
|
|
2295
|
+
mode: "static",
|
|
2296
|
+
severity: "error",
|
|
2297
|
+
description: "Flags an accidental site-wide crawl block in robots.txt and Sitemap references that don't resolve.",
|
|
2298
|
+
async run(ctx) {
|
|
2299
|
+
const findings = [];
|
|
2300
|
+
const robotsPath = path12.join(ctx.outputDir, "robots.txt");
|
|
2301
|
+
if (!ctx.fileExists(robotsPath)) {
|
|
2302
|
+
ctx.detail({ label: "robots.txt found", value: "no" });
|
|
2303
|
+
return findings;
|
|
2304
|
+
}
|
|
2305
|
+
const content = ctx.readFile(robotsPath);
|
|
2306
|
+
const sitemapUrls = extractSitemapUrls(content);
|
|
2307
|
+
ctx.detail({ label: "Sitemap references in robots.txt", value: sitemapUrls.length > 0 ? sitemapUrls.join(", ") : "(none)" });
|
|
2308
|
+
if (isBlanketDisallow(content)) {
|
|
2309
|
+
findings.push(
|
|
2310
|
+
ctx.finding({
|
|
2311
|
+
message: 'robots.txt blocks all crawlers ("User-agent: *" + "Disallow: /") \u2014 the site is invisible to search engines',
|
|
2312
|
+
file: "robots.txt"
|
|
2313
|
+
})
|
|
2314
|
+
);
|
|
2315
|
+
}
|
|
2316
|
+
for (const sitemapUrl of sitemapUrls) {
|
|
2317
|
+
let sitemapPath;
|
|
2318
|
+
try {
|
|
2319
|
+
sitemapPath = new URL(sitemapUrl).pathname;
|
|
2320
|
+
} catch {
|
|
2321
|
+
sitemapPath = sitemapUrl;
|
|
2322
|
+
}
|
|
2323
|
+
if (!ctx.fileExists(path12.join(ctx.outputDir, sitemapPath))) {
|
|
2324
|
+
findings.push(
|
|
2325
|
+
ctx.finding({
|
|
2326
|
+
message: `robots.txt references a Sitemap that doesn't exist in the build output: "${sitemapUrl}"`,
|
|
2327
|
+
file: "robots.txt",
|
|
2328
|
+
severity: "warn"
|
|
2329
|
+
})
|
|
2330
|
+
);
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
return findings;
|
|
2334
|
+
}
|
|
2335
|
+
});
|
|
2336
|
+
|
|
2337
|
+
// src/checks/seo/structured-data.ts
|
|
2338
|
+
var structured_data_default = defineCheck({
|
|
2339
|
+
id: "seo.structured-data",
|
|
2340
|
+
name: "Structured Data",
|
|
2341
|
+
category: "seo",
|
|
2342
|
+
mode: "static",
|
|
2343
|
+
severity: "info",
|
|
2344
|
+
description: "Validates JSON-LD structured data exists and has basic structure.",
|
|
2345
|
+
async run(ctx) {
|
|
2346
|
+
const findings = [];
|
|
2347
|
+
for (const page of ctx.pages) {
|
|
2348
|
+
const root = parseHtmlFile(page.file);
|
|
2349
|
+
const scripts = root.querySelectorAll('script[type="application/ld+json"]');
|
|
2350
|
+
if (scripts.length === 0) {
|
|
2351
|
+
ctx.detail({ label: "JSON-LD blocks found", value: "0", url: page.urlPath });
|
|
2352
|
+
findings.push(
|
|
2353
|
+
ctx.finding({
|
|
2354
|
+
message: "No JSON-LD structured data found",
|
|
2355
|
+
file: page.relativeFile,
|
|
2356
|
+
severity: "info"
|
|
2357
|
+
})
|
|
2358
|
+
);
|
|
2359
|
+
continue;
|
|
2360
|
+
}
|
|
2361
|
+
const types = [];
|
|
2362
|
+
for (const script of scripts) {
|
|
2363
|
+
try {
|
|
2364
|
+
const data = JSON.parse(script.textContent ?? "");
|
|
2365
|
+
if (data["@type"]) types.push(String(data["@type"]));
|
|
2366
|
+
if (!data["@type"]) {
|
|
2367
|
+
findings.push(
|
|
2368
|
+
ctx.finding({
|
|
2369
|
+
message: "JSON-LD missing @type property",
|
|
2370
|
+
file: page.relativeFile,
|
|
2371
|
+
severity: "warn"
|
|
2372
|
+
})
|
|
2373
|
+
);
|
|
2374
|
+
}
|
|
2375
|
+
} catch {
|
|
2376
|
+
findings.push(
|
|
2377
|
+
ctx.finding({
|
|
2378
|
+
message: "Invalid JSON-LD syntax",
|
|
2379
|
+
file: page.relativeFile,
|
|
2380
|
+
severity: "warn"
|
|
2381
|
+
})
|
|
2382
|
+
);
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
ctx.detail({
|
|
2386
|
+
label: "JSON-LD blocks / @types found",
|
|
2387
|
+
value: `${scripts.length} / ${types.length > 0 ? types.join(", ") : "(none)"}`,
|
|
2388
|
+
url: page.urlPath
|
|
2389
|
+
});
|
|
2390
|
+
}
|
|
2391
|
+
return findings;
|
|
2392
|
+
}
|
|
2393
|
+
});
|
|
2394
|
+
|
|
2395
|
+
// src/checks/index.ts
|
|
2396
|
+
var BUILT_IN_CHECKS = [
|
|
2397
|
+
core_web_vitals_default,
|
|
2398
|
+
asset_weight_default,
|
|
2399
|
+
image_format_default,
|
|
2400
|
+
lazy_loading_default,
|
|
2401
|
+
third_party_impact_default,
|
|
2402
|
+
dependency_vulnerabilities_default,
|
|
2403
|
+
mixed_content_default,
|
|
2404
|
+
security_headers_default,
|
|
2405
|
+
https_enforced_default,
|
|
2406
|
+
server_info_disclosure_default,
|
|
2407
|
+
cookie_security_default,
|
|
2408
|
+
axe_default,
|
|
2409
|
+
color_contrast_default,
|
|
2410
|
+
broken_links_default,
|
|
2411
|
+
console_errors_default,
|
|
2412
|
+
viewport_meta_default,
|
|
2413
|
+
anchor_links_default,
|
|
2414
|
+
nested_block_elements_default,
|
|
2415
|
+
meta_tags_default,
|
|
2416
|
+
robots_sitemap_default,
|
|
2417
|
+
structured_data_default,
|
|
2418
|
+
canonical_url_default,
|
|
2419
|
+
og_images_default,
|
|
2420
|
+
duplication_default,
|
|
2421
|
+
outdated_dependencies_default,
|
|
2422
|
+
license_check_default,
|
|
2423
|
+
dead_css_default,
|
|
2424
|
+
tracker_requests_default,
|
|
2425
|
+
external_fonts_default,
|
|
2426
|
+
fingerprinting_default
|
|
2427
|
+
];
|
|
2428
|
+
|
|
2429
|
+
// src/core/load-checks.ts
|
|
2430
|
+
async function loadChecks(config) {
|
|
2431
|
+
const builtins = BUILT_IN_CHECKS.filter((check) => config.categories[check.category] && !config.disabledChecks.includes(check.id));
|
|
2432
|
+
const custom = [];
|
|
2433
|
+
const loadErrors = [];
|
|
2434
|
+
for (const specifier of config.checks) {
|
|
2435
|
+
try {
|
|
2436
|
+
const resolved = specifier.startsWith(".") ? pathToFileURL(path13.resolve(config.cwd, specifier)).href : specifier;
|
|
2437
|
+
const mod = await import(resolved);
|
|
2438
|
+
const exported = mod.default;
|
|
2439
|
+
if (!exported) {
|
|
2440
|
+
throw new Error(`Check module "${specifier}" has no default export`);
|
|
2441
|
+
}
|
|
2442
|
+
const defs = Array.isArray(exported) ? exported : [exported];
|
|
2443
|
+
for (const def of defs) {
|
|
2444
|
+
if (def && !config.disabledChecks.includes(def.id)) custom.push(def);
|
|
2445
|
+
}
|
|
2446
|
+
} catch (error) {
|
|
2447
|
+
loadErrors.push({ specifier, message: error instanceof Error ? error.message : String(error) });
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
const allChecks = [...builtins, ...custom];
|
|
2451
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
2452
|
+
const checks = [];
|
|
2453
|
+
for (const check of allChecks) {
|
|
2454
|
+
if (seenIds.has(check.id)) {
|
|
2455
|
+
loadErrors.push({ specifier: check.id, message: `Duplicate check id "${check.id}" \u2014 the later definition was ignored` });
|
|
2456
|
+
continue;
|
|
2457
|
+
}
|
|
2458
|
+
seenIds.add(check.id);
|
|
2459
|
+
checks.push(check);
|
|
2460
|
+
}
|
|
2461
|
+
return { checks, loadErrors };
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
// src/core/runner.ts
|
|
2465
|
+
var BUILD_TIMEOUT_MS = 10 * 6e4;
|
|
2466
|
+
var CHECK_EXEC_TIMEOUT_MS = 3 * 6e4;
|
|
2467
|
+
var PAGE_NAV_TIMEOUT_MS = 15e3;
|
|
2468
|
+
var LIVE_FETCH_TIMEOUT_MS = 15e3;
|
|
2469
|
+
var CheckSkipped = class extends Error {
|
|
2470
|
+
};
|
|
2471
|
+
function computeExitCode(findings, config) {
|
|
2472
|
+
return findings.some((f) => SEVERITY_RANK[f.severity] >= SEVERITY_RANK[config.failOn]) ? 1 : 0;
|
|
2473
|
+
}
|
|
2474
|
+
async function runVortix(config, onProgress = () => {
|
|
2475
|
+
}, liveUrl) {
|
|
2476
|
+
const adapter = resolveAdapter(config.cwd, config.target, config.build);
|
|
2477
|
+
const declaredOutputDir = path14.resolve(config.cwd, adapter.outputDir);
|
|
2478
|
+
if (config.build) {
|
|
2479
|
+
onProgress({ type: "build-start" });
|
|
2480
|
+
try {
|
|
2481
|
+
await exec(adapter.buildCommand, { cwd: config.cwd, timeoutMs: BUILD_TIMEOUT_MS });
|
|
2482
|
+
} catch (error) {
|
|
2483
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2484
|
+
throw new Error(t("errors.buildFailed", { command: adapter.buildCommand, message }));
|
|
2485
|
+
}
|
|
2486
|
+
onProgress({ type: "build-done" });
|
|
2487
|
+
}
|
|
2488
|
+
if (!existsSync5(declaredOutputDir)) {
|
|
2489
|
+
throw new Error(t("errors.outputDirMissing", { dir: declaredOutputDir }));
|
|
2490
|
+
}
|
|
2491
|
+
const outputDir = adapter.resolveServeDir?.(declaredOutputDir) ?? declaredOutputDir;
|
|
2492
|
+
const { checks, loadErrors } = await loadChecks(config);
|
|
2493
|
+
const configErrors = loadErrors.map((e) => `${e.specifier}: ${e.message}`);
|
|
2494
|
+
const staticChecks = checks.filter((c) => c.mode === "static");
|
|
2495
|
+
const dynamicChecks = checks.filter((c) => c.mode === "dynamic");
|
|
2496
|
+
const liveChecks = checks.filter((c) => c.mode === "live");
|
|
2497
|
+
const checkSummaries = new Map(checks.map((c) => [c.id, toSummary(c)]));
|
|
2498
|
+
const pages = await collectPages(outputDir);
|
|
2499
|
+
if (pages.length === 0) {
|
|
2500
|
+
throw new Error(t("errors.noPagesFound", { dir: outputDir }));
|
|
2501
|
+
}
|
|
2502
|
+
const findings = [];
|
|
2503
|
+
const details = [];
|
|
2504
|
+
const skipped = /* @__PURE__ */ new Map();
|
|
2505
|
+
const skip = (reason) => {
|
|
2506
|
+
throw new CheckSkipped(reason);
|
|
2507
|
+
};
|
|
2508
|
+
const baseHelpers = {
|
|
2509
|
+
siteRoot: config.cwd,
|
|
2510
|
+
outputDir,
|
|
2511
|
+
config,
|
|
2512
|
+
exec: (command) => exec(command, { cwd: config.cwd, timeoutMs: CHECK_EXEC_TIMEOUT_MS, allowNonZeroExit: true }),
|
|
2513
|
+
glob: (pattern, options) => globFiles(pattern, { cwd: options?.cwd ?? config.cwd }),
|
|
2514
|
+
readFile: (filePath) => {
|
|
2515
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
2516
|
+
if (ext && [
|
|
2517
|
+
"png",
|
|
2518
|
+
"jpg",
|
|
2519
|
+
"jpeg",
|
|
2520
|
+
"gif",
|
|
2521
|
+
"webp",
|
|
2522
|
+
"avif",
|
|
2523
|
+
"svg",
|
|
2524
|
+
"ico",
|
|
2525
|
+
"woff",
|
|
2526
|
+
"woff2",
|
|
2527
|
+
"ttf",
|
|
2528
|
+
"eot",
|
|
2529
|
+
"otf",
|
|
2530
|
+
"pdf",
|
|
2531
|
+
"zip",
|
|
2532
|
+
"gz",
|
|
2533
|
+
"mp3",
|
|
2534
|
+
"mp4",
|
|
2535
|
+
"webm"
|
|
2536
|
+
].includes(ext)) {
|
|
2537
|
+
throw new Error(`Cannot read binary file: ${filePath}`);
|
|
2538
|
+
}
|
|
2539
|
+
return readFileSync4(filePath, "utf-8");
|
|
2540
|
+
},
|
|
2541
|
+
fileExists: (filePath) => {
|
|
2542
|
+
try {
|
|
2543
|
+
return statSync2(filePath).isFile();
|
|
2544
|
+
} catch {
|
|
2545
|
+
return false;
|
|
2546
|
+
}
|
|
2547
|
+
},
|
|
2548
|
+
skip
|
|
2549
|
+
};
|
|
2550
|
+
onProgress({ type: "static-start", index: 0, total: staticChecks.length });
|
|
2551
|
+
for (const [index, check] of staticChecks.entries()) {
|
|
2552
|
+
onProgress({ type: "static-check", check: toSummary(check), index: index + 1, total: staticChecks.length });
|
|
2553
|
+
const ctx = {
|
|
2554
|
+
...baseHelpers,
|
|
2555
|
+
mode: "static",
|
|
2556
|
+
pages,
|
|
2557
|
+
finding: createFindingFactory(check, config.severity[check.id]),
|
|
2558
|
+
detail: createDetailFactory(check, (d) => details.push(d))
|
|
2559
|
+
};
|
|
2560
|
+
const result = await safeRun(check, ctx, config);
|
|
2561
|
+
findings.push(...result.findings);
|
|
2562
|
+
if (result.skipped) skipped.set(check.id, result.skipped);
|
|
2563
|
+
}
|
|
2564
|
+
onProgress({ type: "static-done", total: staticChecks.length });
|
|
2565
|
+
let pagesChecked = 0;
|
|
2566
|
+
if (dynamicChecks.length > 0 && pages.length > 0) {
|
|
2567
|
+
onProgress({ type: "dynamic-start", index: 0, total: pages.length });
|
|
2568
|
+
const server = await startStaticServer(outputDir);
|
|
2569
|
+
let browser;
|
|
2570
|
+
try {
|
|
2571
|
+
browser = await chromium.launch({ headless: true });
|
|
2572
|
+
} catch {
|
|
2573
|
+
for (const check of dynamicChecks) skipped.set(check.id, t("progress.playwrightMissing"));
|
|
2574
|
+
}
|
|
2575
|
+
if (browser) {
|
|
2576
|
+
try {
|
|
2577
|
+
const browserContext = await browser.newContext();
|
|
2578
|
+
try {
|
|
2579
|
+
for (const [index, pageInfo] of pages.entries()) {
|
|
2580
|
+
onProgress({ type: "dynamic-page-start", pageInfo, index: index + 1, total: pages.length });
|
|
2581
|
+
let page;
|
|
2582
|
+
let capture;
|
|
2583
|
+
try {
|
|
2584
|
+
page = await browserContext.newPage();
|
|
2585
|
+
capture = attachCapture(page);
|
|
2586
|
+
try {
|
|
2587
|
+
await page.goto(server.url + pageInfo.urlPath, { waitUntil: "networkidle", timeout: PAGE_NAV_TIMEOUT_MS });
|
|
2588
|
+
} catch {
|
|
2589
|
+
await page.goto(server.url + pageInfo.urlPath, { waitUntil: "load", timeout: PAGE_NAV_TIMEOUT_MS });
|
|
2590
|
+
}
|
|
2591
|
+
pagesChecked++;
|
|
2592
|
+
for (const check of dynamicChecks) {
|
|
2593
|
+
const ctx = {
|
|
2594
|
+
...baseHelpers,
|
|
2595
|
+
mode: "dynamic",
|
|
2596
|
+
page,
|
|
2597
|
+
pageInfo,
|
|
2598
|
+
consoleMessages: capture.consoleMessages,
|
|
2599
|
+
networkRequests: capture.networkRequests,
|
|
2600
|
+
finding: createFindingFactory(check, config.severity[check.id]),
|
|
2601
|
+
detail: createDetailFactory(check, (d) => details.push(d))
|
|
2602
|
+
};
|
|
2603
|
+
const result = await safeRun(check, ctx, config);
|
|
2604
|
+
findings.push(...result.findings);
|
|
2605
|
+
if (result.skipped) skipped.set(check.id, result.skipped);
|
|
2606
|
+
}
|
|
2607
|
+
} catch (error) {
|
|
2608
|
+
const message = t("errors.pageCrashed", {
|
|
2609
|
+
url: pageInfo.urlPath,
|
|
2610
|
+
message: error instanceof Error ? error.message : String(error)
|
|
2611
|
+
});
|
|
2612
|
+
for (const check of dynamicChecks) {
|
|
2613
|
+
findings.push({
|
|
2614
|
+
checkId: check.id,
|
|
2615
|
+
category: check.category,
|
|
2616
|
+
severity: resolveSeverity(check, config),
|
|
2617
|
+
message,
|
|
2618
|
+
url: pageInfo.urlPath
|
|
2619
|
+
});
|
|
2620
|
+
}
|
|
2621
|
+
} finally {
|
|
2622
|
+
capture?.detach();
|
|
2623
|
+
await page?.close().catch(() => {
|
|
2624
|
+
});
|
|
2625
|
+
}
|
|
2626
|
+
onProgress({ type: "dynamic-page-done", pageInfo, index: index + 1, total: pages.length });
|
|
2627
|
+
}
|
|
2628
|
+
} finally {
|
|
2629
|
+
await browserContext.close();
|
|
2630
|
+
}
|
|
2631
|
+
} finally {
|
|
2632
|
+
await browser.close();
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
await server.close();
|
|
2636
|
+
onProgress({ type: "dynamic-done", total: browser ? pages.length : 0 });
|
|
2637
|
+
}
|
|
2638
|
+
if (liveChecks.length > 0) {
|
|
2639
|
+
if (!liveUrl) {
|
|
2640
|
+
for (const check of liveChecks) skipped.set(check.id, t("progress.liveNoUrl"));
|
|
2641
|
+
} else {
|
|
2642
|
+
onProgress({ type: "live-start", url: liveUrl, index: 0, total: liveChecks.length });
|
|
2643
|
+
try {
|
|
2644
|
+
const response = await fetch(liveUrl, { signal: AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS) });
|
|
2645
|
+
const html = await response.text();
|
|
2646
|
+
const headers = {};
|
|
2647
|
+
response.headers.forEach((value, key) => {
|
|
2648
|
+
headers[key.toLowerCase()] = value;
|
|
2649
|
+
});
|
|
2650
|
+
const setCookieHeaders = response.headers.getSetCookie?.() ?? [];
|
|
2651
|
+
const parsedUrl = new URL(liveUrl);
|
|
2652
|
+
const isHttps = new URL(response.url).protocol === "https:";
|
|
2653
|
+
for (const [index, check] of liveChecks.entries()) {
|
|
2654
|
+
onProgress({ type: "live-check", check: toSummary(check), index: index + 1, total: liveChecks.length });
|
|
2655
|
+
const ctx = {
|
|
2656
|
+
url: liveUrl,
|
|
2657
|
+
parsedUrl,
|
|
2658
|
+
headers,
|
|
2659
|
+
setCookieHeaders,
|
|
2660
|
+
status: response.status,
|
|
2661
|
+
redirected: response.redirected,
|
|
2662
|
+
finalUrl: response.url,
|
|
2663
|
+
isHttps,
|
|
2664
|
+
html,
|
|
2665
|
+
config,
|
|
2666
|
+
finding: createFindingFactory(check, config.severity[check.id]),
|
|
2667
|
+
detail: createDetailFactory(check, (d) => details.push(d)),
|
|
2668
|
+
skip
|
|
2669
|
+
};
|
|
2670
|
+
const result = await safeRun(check, ctx, config);
|
|
2671
|
+
findings.push(...result.findings);
|
|
2672
|
+
if (result.skipped) skipped.set(check.id, result.skipped);
|
|
2673
|
+
}
|
|
2674
|
+
} catch (error) {
|
|
2675
|
+
const message = t("errors.checkCrashed", { message: error instanceof Error ? error.message : String(error) });
|
|
2676
|
+
for (const check of liveChecks) {
|
|
2677
|
+
findings.push({ checkId: check.id, category: check.category, severity: resolveSeverity(check, config), message });
|
|
2678
|
+
skipped.set(check.id, message);
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
onProgress({ type: "live-done", total: liveChecks.length });
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
const exitCode = computeExitCode(findings, config);
|
|
2685
|
+
return {
|
|
2686
|
+
findings,
|
|
2687
|
+
details,
|
|
2688
|
+
checks: [...checkSummaries.values()],
|
|
2689
|
+
skipped: Object.fromEntries(skipped),
|
|
2690
|
+
adapterName: adapter.name,
|
|
2691
|
+
outputDir,
|
|
2692
|
+
pagesChecked,
|
|
2693
|
+
totalPages: pages.length,
|
|
2694
|
+
liveUrl,
|
|
2695
|
+
exitCode,
|
|
2696
|
+
configErrors
|
|
2697
|
+
};
|
|
2698
|
+
}
|
|
2699
|
+
function toSummary(check) {
|
|
2700
|
+
return {
|
|
2701
|
+
id: check.id,
|
|
2702
|
+
name: check.name ?? humanizeCheckId(check.id),
|
|
2703
|
+
category: check.category,
|
|
2704
|
+
mode: check.mode,
|
|
2705
|
+
description: check.description
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
function resolveSeverity(check, config) {
|
|
2709
|
+
return config.severity[check.id] ?? check.severity ?? "warn";
|
|
2710
|
+
}
|
|
2711
|
+
async function safeRun(check, ctx, config) {
|
|
2712
|
+
try {
|
|
2713
|
+
const result = await check.run(
|
|
2714
|
+
ctx
|
|
2715
|
+
);
|
|
2716
|
+
return { findings: result ?? [] };
|
|
2717
|
+
} catch (error) {
|
|
2718
|
+
if (error instanceof CheckSkipped) {
|
|
2719
|
+
return { findings: [], skipped: error.message };
|
|
2720
|
+
}
|
|
2721
|
+
const message = t("errors.checkCrashed", { message: error instanceof Error ? error.message : String(error) });
|
|
2722
|
+
return {
|
|
2723
|
+
findings: [{ checkId: check.id, category: check.category, severity: resolveSeverity(check, config), message }],
|
|
2724
|
+
skipped: message
|
|
2725
|
+
};
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
// src/utils/clipboard.ts
|
|
2730
|
+
import { spawnSync } from "child_process";
|
|
2731
|
+
function candidateCommands() {
|
|
2732
|
+
switch (process.platform) {
|
|
2733
|
+
case "darwin":
|
|
2734
|
+
return [{ command: "pbcopy", args: [] }];
|
|
2735
|
+
case "win32":
|
|
2736
|
+
return [{ command: "clip", args: [] }];
|
|
2737
|
+
case "linux":
|
|
2738
|
+
return [
|
|
2739
|
+
{ command: "wl-copy", args: [] },
|
|
2740
|
+
{ command: "xclip", args: ["-selection", "clipboard"] },
|
|
2741
|
+
{ command: "xsel", args: ["--clipboard", "--input"] }
|
|
2742
|
+
];
|
|
2743
|
+
default:
|
|
2744
|
+
return [];
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
function copyToClipboard(text) {
|
|
2748
|
+
for (const { command, args } of candidateCommands()) {
|
|
2749
|
+
const result = spawnSync(command, args, { input: text, stdio: ["pipe", "ignore", "ignore"] });
|
|
2750
|
+
if (!result.error && result.status === 0) return true;
|
|
2751
|
+
}
|
|
2752
|
+
return false;
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
// src/commands/detail-ui.ts
|
|
2756
|
+
import * as p from "@clack/prompts";
|
|
2757
|
+
import pc2 from "picocolors";
|
|
2758
|
+
var DONE = "__done__";
|
|
2759
|
+
async function runInteractiveReport(result) {
|
|
2760
|
+
for (const message of result.configErrors) {
|
|
2761
|
+
console.log(pc2.yellow(`\u26A0 ${t("report.configErrorPrefix")}: ${message}`));
|
|
2762
|
+
}
|
|
2763
|
+
console.log(`
|
|
2764
|
+
${pc2.bold(buildHeader(result))}
|
|
2765
|
+
`);
|
|
2766
|
+
console.log(buildScoreCard(result));
|
|
2767
|
+
console.log("");
|
|
2768
|
+
const findingsByCheck = groupByCheckId(result.findings);
|
|
2769
|
+
const detailsByCheck = groupByCheckId(result.details);
|
|
2770
|
+
for (; ; ) {
|
|
2771
|
+
const options = result.checks.map((check2) => {
|
|
2772
|
+
const { label, hint } = checkOptionLabel(check2, findingsByCheck.get(check2.id) ?? [], result.skipped[check2.id]);
|
|
2773
|
+
return { value: check2.id, label, hint };
|
|
2774
|
+
});
|
|
2775
|
+
options.push({ value: DONE, label: t("report.detailDoneOption"), hint: "" });
|
|
2776
|
+
const selected = await p.select({ message: t("report.detailSelectPrompt"), options });
|
|
2777
|
+
if (p.isCancel(selected) || selected === DONE) break;
|
|
2778
|
+
const check = result.checks.find((c) => c.id === selected);
|
|
2779
|
+
if (!check) continue;
|
|
2780
|
+
const text = buildCheckDetail(check, findingsByCheck.get(check.id) ?? [], detailsByCheck.get(check.id) ?? [], result.skipped[check.id]);
|
|
2781
|
+
p.note(text, check.name);
|
|
2782
|
+
const goBack = await p.select({
|
|
2783
|
+
message: t("report.detailContinuePrompt"),
|
|
2784
|
+
options: [{ value: true, label: t("report.detailBackOption") }]
|
|
2785
|
+
});
|
|
2786
|
+
if (p.isCancel(goBack)) break;
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
function groupByCheckId(items) {
|
|
2790
|
+
const map = /* @__PURE__ */ new Map();
|
|
2791
|
+
for (const item of items) {
|
|
2792
|
+
const list = map.get(item.checkId) ?? [];
|
|
2793
|
+
list.push(item);
|
|
2794
|
+
map.set(item.checkId, list);
|
|
2795
|
+
}
|
|
2796
|
+
return map;
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
// src/commands/progress-ui.ts
|
|
2800
|
+
import * as p2 from "@clack/prompts";
|
|
2801
|
+
var PROGRESS_BAR_OVERHEAD = 50;
|
|
2802
|
+
var MIN_TEXT_WIDTH = 20;
|
|
2803
|
+
var DEFAULT_COLUMNS = 80;
|
|
2804
|
+
function truncateForLine(text, prefixLength, output) {
|
|
2805
|
+
const columns = output && "columns" in output && typeof output.columns === "number" ? output.columns : process.stdout.columns ?? DEFAULT_COLUMNS;
|
|
2806
|
+
const available = Math.max(MIN_TEXT_WIDTH, columns - PROGRESS_BAR_OVERHEAD - prefixLength);
|
|
2807
|
+
return text.length > available ? `${text.slice(0, available - 1)}\u2026` : text;
|
|
2808
|
+
}
|
|
2809
|
+
function createProgressHandler(output) {
|
|
2810
|
+
const spin = p2.spinner({ output, indicator: "timer" });
|
|
2811
|
+
let bar;
|
|
2812
|
+
return (event) => {
|
|
2813
|
+
switch (event.type) {
|
|
2814
|
+
case "build-start":
|
|
2815
|
+
spin.start(t("progress.building"));
|
|
2816
|
+
break;
|
|
2817
|
+
case "build-done":
|
|
2818
|
+
spin.stop(t("progress.buildDone"));
|
|
2819
|
+
break;
|
|
2820
|
+
case "static-start":
|
|
2821
|
+
if (event.total > 0) spin.start(t("progress.staticStart", { total: event.total }));
|
|
2822
|
+
break;
|
|
2823
|
+
case "static-check":
|
|
2824
|
+
spin.message(t("progress.staticRunning", { index: event.index, total: event.total, name: event.check.name }));
|
|
2825
|
+
break;
|
|
2826
|
+
case "static-done":
|
|
2827
|
+
if (event.total > 0) spin.stop(t("progress.staticDone", { total: event.total }));
|
|
2828
|
+
break;
|
|
2829
|
+
case "dynamic-start":
|
|
2830
|
+
bar = p2.progress({ max: event.total, size: 30, output, indicator: "timer" });
|
|
2831
|
+
bar.start(t("progress.dynamicStart", { total: event.total }));
|
|
2832
|
+
break;
|
|
2833
|
+
case "dynamic-page-start": {
|
|
2834
|
+
const prefix = t("progress.dynamicPageRunning", { index: event.index, total: event.total, urlPath: "" });
|
|
2835
|
+
const urlPath = truncateForLine(event.pageInfo.urlPath, prefix.length, output);
|
|
2836
|
+
bar?.message(t("progress.dynamicPageRunning", { index: event.index, total: event.total, urlPath }));
|
|
2837
|
+
break;
|
|
2838
|
+
}
|
|
2839
|
+
case "dynamic-page-done":
|
|
2840
|
+
bar?.advance(1, t("progress.dynamicPageDone", { index: event.index, total: event.total }));
|
|
2841
|
+
break;
|
|
2842
|
+
case "dynamic-done":
|
|
2843
|
+
bar?.stop(t("progress.dynamicDone", { total: event.total }));
|
|
2844
|
+
break;
|
|
2845
|
+
case "live-start":
|
|
2846
|
+
if (event.total > 0) spin.start(t("progress.liveStart", { url: event.url, total: event.total }));
|
|
2847
|
+
break;
|
|
2848
|
+
case "live-check":
|
|
2849
|
+
spin.message(t("progress.liveRunning", { index: event.index, total: event.total, name: event.check.name }));
|
|
2850
|
+
break;
|
|
2851
|
+
case "live-done":
|
|
2852
|
+
if (event.total > 0) spin.stop(t("progress.liveDone", { total: event.total }));
|
|
2853
|
+
break;
|
|
2854
|
+
}
|
|
2855
|
+
};
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
// src/commands/check.ts
|
|
2859
|
+
async function checkCommand(options = {}) {
|
|
2860
|
+
const config = loadConfig(process.cwd());
|
|
2861
|
+
const result = await runVortix(config, createProgressHandler(), options.url);
|
|
2862
|
+
if (process.stdout.isTTY) {
|
|
2863
|
+
await runInteractiveReport(result);
|
|
2864
|
+
const shouldCopy = await p3.confirm({ message: t("report.copyPrompt"), initialValue: true });
|
|
2865
|
+
if (!p3.isCancel(shouldCopy) && shouldCopy) {
|
|
2866
|
+
const copied = copyToClipboard(buildFullReport(result));
|
|
2867
|
+
if (copied) {
|
|
2868
|
+
p3.log.success(t("report.copied"));
|
|
2869
|
+
} else {
|
|
2870
|
+
p3.log.error(t("report.copyFailed"));
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
} else {
|
|
2874
|
+
printReport(result);
|
|
2875
|
+
}
|
|
2876
|
+
process.exitCode = result.exitCode;
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
// src/commands/ci.ts
|
|
2880
|
+
async function ciCommand(options = {}) {
|
|
2881
|
+
const config = loadConfig(process.cwd());
|
|
2882
|
+
const result = await runVortix(config, createProgressHandler(process.stderr), options.url);
|
|
2883
|
+
console.log(
|
|
2884
|
+
JSON.stringify(
|
|
2885
|
+
{
|
|
2886
|
+
adapter: result.adapterName,
|
|
2887
|
+
pagesChecked: result.pagesChecked,
|
|
2888
|
+
totalPages: result.totalPages,
|
|
2889
|
+
liveUrl: result.liveUrl,
|
|
2890
|
+
checks: result.checks,
|
|
2891
|
+
findings: result.findings,
|
|
2892
|
+
details: result.details,
|
|
2893
|
+
skipped: result.skipped,
|
|
2894
|
+
configErrors: result.configErrors
|
|
2895
|
+
},
|
|
2896
|
+
null,
|
|
2897
|
+
2
|
|
2898
|
+
)
|
|
2899
|
+
);
|
|
2900
|
+
process.exitCode = result.exitCode;
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
// src/commands/config.ts
|
|
2904
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
|
|
2905
|
+
import path18 from "path";
|
|
2906
|
+
import * as p5 from "@clack/prompts";
|
|
2907
|
+
import pc4 from "picocolors";
|
|
2908
|
+
|
|
2909
|
+
// src/commands/init.ts
|
|
2910
|
+
import { existsSync as existsSync7, mkdirSync, writeFileSync } from "fs";
|
|
2911
|
+
import path17 from "path";
|
|
2912
|
+
import * as p4 from "@clack/prompts";
|
|
2913
|
+
|
|
2914
|
+
// src/utils/github-workflow.ts
|
|
2915
|
+
import path15 from "path";
|
|
2916
|
+
var GITHUB_WORKFLOW_PATH = ".github/workflows/vortix.yml";
|
|
2917
|
+
function getGithubWorkflowPath(cwd) {
|
|
2918
|
+
return path15.join(cwd, GITHUB_WORKFLOW_PATH);
|
|
2919
|
+
}
|
|
2920
|
+
var SETUP_STEPS = {
|
|
2921
|
+
npm: ` - uses: actions/setup-node@v4
|
|
2922
|
+
with:
|
|
2923
|
+
node-version: 20
|
|
2924
|
+
cache: npm
|
|
2925
|
+
- run: npm ci`,
|
|
2926
|
+
yarn: ` - uses: actions/setup-node@v4
|
|
2927
|
+
with:
|
|
2928
|
+
node-version: 20
|
|
2929
|
+
cache: yarn
|
|
2930
|
+
- run: yarn install --frozen-lockfile`,
|
|
2931
|
+
pnpm: ` - uses: pnpm/action-setup@v4
|
|
2932
|
+
- uses: actions/setup-node@v4
|
|
2933
|
+
with:
|
|
2934
|
+
node-version: 20
|
|
2935
|
+
cache: pnpm
|
|
2936
|
+
- run: pnpm install --frozen-lockfile`,
|
|
2937
|
+
bun: ` - uses: oven-sh/setup-bun@v2
|
|
2938
|
+
- run: bun install --frozen-lockfile`
|
|
2939
|
+
};
|
|
2940
|
+
var RUN_COMMAND = {
|
|
2941
|
+
npm: "npx vortix ci",
|
|
2942
|
+
yarn: "npx vortix ci",
|
|
2943
|
+
pnpm: "npx vortix ci",
|
|
2944
|
+
bun: "bunx vortix ci"
|
|
2945
|
+
};
|
|
2946
|
+
function buildGithubWorkflowYaml(pm) {
|
|
2947
|
+
return `name: Vortix
|
|
2948
|
+
|
|
2949
|
+
on:
|
|
2950
|
+
push:
|
|
2951
|
+
pull_request:
|
|
2952
|
+
|
|
2953
|
+
jobs:
|
|
2954
|
+
vortix:
|
|
2955
|
+
runs-on: ubuntu-latest
|
|
2956
|
+
steps:
|
|
2957
|
+
- uses: actions/checkout@v4
|
|
2958
|
+
${SETUP_STEPS[pm]}
|
|
2959
|
+
- run: ${RUN_COMMAND[pm]}
|
|
2960
|
+
# Pass a deployed URL as an argument to also run live checks (HSTS, cookies, redirects):
|
|
2961
|
+
# - run: ${RUN_COMMAND[pm]} https://your-deployed-url.example.com
|
|
2962
|
+
`;
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
// src/utils/package-manager.ts
|
|
2966
|
+
import { existsSync as existsSync6 } from "fs";
|
|
2967
|
+
import path16 from "path";
|
|
2968
|
+
function detectPackageManager(cwd) {
|
|
2969
|
+
if (existsSync6(path16.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
2970
|
+
if (existsSync6(path16.join(cwd, "yarn.lock"))) return "yarn";
|
|
2971
|
+
if (existsSync6(path16.join(cwd, "bun.lockb")) || existsSync6(path16.join(cwd, "bun.lock"))) return "bun";
|
|
2972
|
+
return "npm";
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
// src/commands/init.ts
|
|
2976
|
+
import pc3 from "picocolors";
|
|
2977
|
+
async function initCommand() {
|
|
2978
|
+
const cwd = process.cwd();
|
|
2979
|
+
p4.intro(pc3.bold(t("init.intro")));
|
|
2980
|
+
const configPath = getConfigPath(cwd);
|
|
2981
|
+
if (existsSync7(configPath)) {
|
|
2982
|
+
const overwrite = await p4.confirm({ message: t("init.configExists"), initialValue: false });
|
|
2983
|
+
if (p4.isCancel(overwrite) || !overwrite) return abort(t("init.abortedKept"));
|
|
2984
|
+
}
|
|
2985
|
+
const result = await runPrompts(cwd, null);
|
|
2986
|
+
if (result === void 0) return;
|
|
2987
|
+
mkdirSync(path17.join(cwd, CONFIG_DIR), { recursive: true });
|
|
2988
|
+
writeFileSync(configPath, `${JSON.stringify(result, null, 2)}
|
|
2989
|
+
`, "utf-8");
|
|
2990
|
+
await promptGithubWorkflow(cwd);
|
|
2991
|
+
p4.outro(pc3.green(t("init.done", { cmd: pc3.bold("npx vortix check") })));
|
|
2992
|
+
}
|
|
2993
|
+
async function promptGithubWorkflow(cwd) {
|
|
2994
|
+
const setup = await p4.confirm({ message: t("init.pickGithubWorkflow"), initialValue: true });
|
|
2995
|
+
if (p4.isCancel(setup) || !setup) return;
|
|
2996
|
+
const workflowPath = getGithubWorkflowPath(cwd);
|
|
2997
|
+
const relativePath = path17.relative(cwd, workflowPath);
|
|
2998
|
+
if (existsSync7(workflowPath)) {
|
|
2999
|
+
const overwrite = await p4.confirm({ message: t("init.githubWorkflowExists", { path: relativePath }), initialValue: false });
|
|
3000
|
+
if (p4.isCancel(overwrite) || !overwrite) {
|
|
3001
|
+
p4.log.info(t("init.githubWorkflowSkipped"));
|
|
3002
|
+
return;
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
mkdirSync(path17.dirname(workflowPath), { recursive: true });
|
|
3006
|
+
writeFileSync(workflowPath, buildGithubWorkflowYaml(detectPackageManager(cwd)), "utf-8");
|
|
3007
|
+
p4.log.success(t("init.githubWorkflowDone", { path: relativePath }));
|
|
3008
|
+
}
|
|
3009
|
+
async function runPrompts(cwd, existing) {
|
|
3010
|
+
const adapterName = await resolveAdapterChoice(cwd, existing?.target?.adapter);
|
|
3011
|
+
if (adapterName === void 0) return void 0;
|
|
3012
|
+
const initialCategories = existing?.categories ? CATEGORIES.filter((c) => existing.categories?.[c] !== false) : [...CATEGORIES];
|
|
3013
|
+
const categories = await p4.multiselect({
|
|
3014
|
+
message: t("init.pickCategories"),
|
|
3015
|
+
options: CATEGORIES.map((category) => ({ value: category, label: category })),
|
|
3016
|
+
initialValues: initialCategories
|
|
3017
|
+
});
|
|
3018
|
+
if (p4.isCancel(categories)) return abort();
|
|
3019
|
+
const failOn = await p4.select({
|
|
3020
|
+
message: t("init.pickFailOn"),
|
|
3021
|
+
options: [
|
|
3022
|
+
{ value: "error", label: t("init.failOnRecommended") },
|
|
3023
|
+
{ value: "warn", label: "warn" },
|
|
3024
|
+
{ value: "info", label: "info" }
|
|
3025
|
+
],
|
|
3026
|
+
initialValue: existing?.failOn ?? "error"
|
|
3027
|
+
});
|
|
3028
|
+
if (p4.isCancel(failOn)) return abort();
|
|
3029
|
+
const isKnownAdapter = BUILT_IN_ADAPTERS.some((a) => a.name === adapterName);
|
|
3030
|
+
return {
|
|
3031
|
+
...existing,
|
|
3032
|
+
target: isKnownAdapter ? { adapter: adapterName } : {
|
|
3033
|
+
buildCommand: existing?.target?.buildCommand ?? "<your build command>",
|
|
3034
|
+
outputDir: existing?.target?.outputDir ?? "<your output folder>"
|
|
3035
|
+
},
|
|
3036
|
+
categories: Object.fromEntries(CATEGORIES.map((c) => [c, categories.includes(c)])),
|
|
3037
|
+
failOn
|
|
3038
|
+
};
|
|
3039
|
+
}
|
|
3040
|
+
async function resolveAdapterChoice(cwd, currentAdapter) {
|
|
3041
|
+
const detected = detectAdapter(cwd);
|
|
3042
|
+
if (detected && !currentAdapter) {
|
|
3043
|
+
const useDetected = await p4.confirm({ message: t("init.detectedStack", { name: detected.name }), initialValue: true });
|
|
3044
|
+
if (p4.isCancel(useDetected)) return void 0;
|
|
3045
|
+
if (useDetected) return detected.name;
|
|
3046
|
+
} else if (currentAdapter) {
|
|
3047
|
+
return currentAdapter;
|
|
3048
|
+
} else {
|
|
3049
|
+
p4.log.warn(t("init.noGeneratorDetected"));
|
|
3050
|
+
}
|
|
3051
|
+
const choice = await p4.select({
|
|
3052
|
+
message: t("init.pickStack"),
|
|
3053
|
+
options: [
|
|
3054
|
+
...BUILT_IN_ADAPTERS.map((adapter) => ({ value: adapter.name, label: adapter.name })),
|
|
3055
|
+
{ value: "manual", label: t("init.manualOption") }
|
|
3056
|
+
]
|
|
3057
|
+
});
|
|
3058
|
+
if (p4.isCancel(choice)) return void 0;
|
|
3059
|
+
return choice;
|
|
3060
|
+
}
|
|
3061
|
+
function abort(message = t("init.aborted")) {
|
|
3062
|
+
p4.cancel(message);
|
|
3063
|
+
process.exitCode = 1;
|
|
3064
|
+
return void 0;
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
// src/commands/config.ts
|
|
3068
|
+
async function configCommand() {
|
|
3069
|
+
const cwd = process.cwd();
|
|
3070
|
+
const configPath = getConfigPath(cwd);
|
|
3071
|
+
p5.intro(pc4.bold(t("config.intro")));
|
|
3072
|
+
let existing = null;
|
|
3073
|
+
if (existsSync8(configPath)) {
|
|
3074
|
+
try {
|
|
3075
|
+
existing = JSON.parse(readFileSync5(configPath, "utf-8"));
|
|
3076
|
+
} catch {
|
|
3077
|
+
p5.log.warn(t("config.corrupt"));
|
|
3078
|
+
}
|
|
3079
|
+
} else {
|
|
3080
|
+
p5.log.warn(t("config.noConfig"));
|
|
3081
|
+
}
|
|
3082
|
+
const result = await runPrompts(cwd, existing);
|
|
3083
|
+
if (result === void 0) return;
|
|
3084
|
+
mkdirSync2(path18.join(cwd, CONFIG_DIR), { recursive: true });
|
|
3085
|
+
writeFileSync2(configPath, `${JSON.stringify(result, null, 2)}
|
|
3086
|
+
`, "utf-8");
|
|
3087
|
+
p5.outro(pc4.green(t("config.done")));
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
// src/cli.ts
|
|
3091
|
+
import { Command } from "commander";
|
|
3092
|
+
import pc5 from "picocolors";
|
|
3093
|
+
var program = new Command();
|
|
3094
|
+
program.name("vortix").description(t("cli.description")).version("0.1.0");
|
|
3095
|
+
program.command("init").description(t("cli.init.description")).action(async () => {
|
|
3096
|
+
await initCommand();
|
|
3097
|
+
});
|
|
3098
|
+
program.command("config").description(t("cli.config.description")).action(async () => {
|
|
3099
|
+
await configCommand();
|
|
3100
|
+
});
|
|
3101
|
+
program.command("check").description(t("cli.check.description")).argument("[url]", "Optional live URL to check against").action(async (url) => {
|
|
3102
|
+
await checkCommand({ url });
|
|
3103
|
+
});
|
|
3104
|
+
program.command("ci").description(t("cli.ci.description")).argument("[url]", "Optional live URL to check against").action(async (url) => {
|
|
3105
|
+
await ciCommand({ url });
|
|
3106
|
+
});
|
|
3107
|
+
program.parseAsync(process.argv).catch((error) => {
|
|
3108
|
+
console.error(pc5.red(`
|
|
3109
|
+
${error.message}
|
|
3110
|
+
`));
|
|
3111
|
+
process.exitCode = 1;
|
|
3112
|
+
});
|