@eightmedia/flightcheck 0.0.0-stage → 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/CHANGELOG.md +15 -0
- package/LICENSE +691 -0
- package/README.md +106 -2
- package/dist/analyze/fixManifest.d.ts +65 -0
- package/dist/analyze/fixManifest.d.ts.map +1 -0
- package/dist/analyze/fixManifest.js +588 -0
- package/dist/analyze/report.d.ts +283 -0
- package/dist/analyze/report.d.ts.map +1 -0
- package/dist/analyze/report.js +726 -0
- package/dist/analyze/reportTier1.d.ts +46 -0
- package/dist/analyze/reportTier1.d.ts.map +1 -0
- package/dist/analyze/reportTier1.js +251 -0
- package/dist/analyze/stack.d.ts +33 -0
- package/dist/analyze/stack.d.ts.map +1 -0
- package/dist/analyze/stack.js +188 -0
- package/dist/analyze/structuredReport.d.ts +62 -0
- package/dist/analyze/structuredReport.d.ts.map +1 -0
- package/dist/analyze/structuredReport.js +524 -0
- package/dist/analyze.d.ts +3 -0
- package/dist/analyze.d.ts.map +1 -0
- package/dist/analyze.js +40 -0
- package/dist/checks/agent-ready.d.ts +63 -0
- package/dist/checks/agent-ready.d.ts.map +1 -0
- package/dist/checks/agent-ready.js +219 -0
- package/dist/checks/analyze.d.ts +15 -0
- package/dist/checks/analyze.d.ts.map +1 -0
- package/dist/checks/analyze.js +63 -0
- package/dist/checks/crawl.d.ts +28 -0
- package/dist/checks/crawl.d.ts.map +1 -0
- package/dist/checks/crawl.js +86 -0
- package/dist/checks/dns.d.ts +25 -0
- package/dist/checks/dns.d.ts.map +1 -0
- package/dist/checks/dns.js +80 -0
- package/dist/checks/index.d.ts +24 -0
- package/dist/checks/index.d.ts.map +1 -0
- package/dist/checks/index.js +12 -0
- package/dist/checks/issues.d.ts +8 -0
- package/dist/checks/issues.d.ts.map +1 -0
- package/dist/checks/issues.js +3 -0
- package/dist/checks/redirect.d.ts +38 -0
- package/dist/checks/redirect.d.ts.map +1 -0
- package/dist/checks/redirect.js +209 -0
- package/dist/checks/registry.d.ts +8 -0
- package/dist/checks/registry.d.ts.map +1 -0
- package/dist/checks/registry.js +275 -0
- package/dist/checks/robots-txt.d.ts +16 -0
- package/dist/checks/robots-txt.d.ts.map +1 -0
- package/dist/checks/robots-txt.js +99 -0
- package/dist/checks/runner.d.ts +9 -0
- package/dist/checks/runner.d.ts.map +1 -0
- package/dist/checks/runner.js +110 -0
- package/dist/checks/security-headers.d.ts +12 -0
- package/dist/checks/security-headers.d.ts.map +1 -0
- package/dist/checks/security-headers.js +53 -0
- package/dist/checks/sitemap.d.ts +36 -0
- package/dist/checks/sitemap.d.ts.map +1 -0
- package/dist/checks/sitemap.js +140 -0
- package/dist/checks/types.d.ts +72 -0
- package/dist/checks/types.d.ts.map +1 -0
- package/dist/checks/types.js +20 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +125 -0
- package/dist/crawler/checkGroups.d.ts +7 -0
- package/dist/crawler/checkGroups.d.ts.map +1 -0
- package/dist/crawler/checkGroups.js +29 -0
- package/dist/crawler/crawlState.d.ts +7 -0
- package/dist/crawler/crawlState.d.ts.map +1 -0
- package/dist/crawler/crawlState.js +6 -0
- package/dist/crawler/defaults.d.ts +11 -0
- package/dist/crawler/defaults.d.ts.map +1 -0
- package/dist/crawler/defaults.js +10 -0
- package/dist/crawler/extractors.d.ts +5 -0
- package/dist/crawler/extractors.d.ts.map +1 -0
- package/dist/crawler/extractors.js +169 -0
- package/dist/crawler/jsAssets.d.ts +2 -0
- package/dist/crawler/jsAssets.d.ts.map +1 -0
- package/dist/crawler/jsAssets.js +22 -0
- package/dist/crawler/outputPaths.d.ts +14 -0
- package/dist/crawler/outputPaths.d.ts.map +1 -0
- package/dist/crawler/outputPaths.js +51 -0
- package/dist/crawler/reporter.d.ts +3 -0
- package/dist/crawler/reporter.d.ts.map +1 -0
- package/dist/crawler/reporter.js +12 -0
- package/dist/crawler/robots.d.ts +5 -0
- package/dist/crawler/robots.d.ts.map +1 -0
- package/dist/crawler/robots.js +29 -0
- package/dist/crawler/runCrawler.d.ts +22 -0
- package/dist/crawler/runCrawler.d.ts.map +1 -0
- package/dist/crawler/runCrawler.js +172 -0
- package/dist/crawler/schema.d.ts +87 -0
- package/dist/crawler/schema.d.ts.map +1 -0
- package/dist/crawler/schema.js +44 -0
- package/dist/crawler/secrets.d.ts +4 -0
- package/dist/crawler/secrets.d.ts.map +1 -0
- package/dist/crawler/secrets.js +70 -0
- package/dist/crawler/sitemap.d.ts +8 -0
- package/dist/crawler/sitemap.d.ts.map +1 -0
- package/dist/crawler/sitemap.js +105 -0
- package/dist/crawler/stack.d.ts +3 -0
- package/dist/crawler/stack.d.ts.map +1 -0
- package/dist/crawler/stack.js +1 -0
- package/dist/crawler/stackDetect.d.ts +24 -0
- package/dist/crawler/stackDetect.d.ts.map +1 -0
- package/dist/crawler/stackDetect.js +604 -0
- package/dist/crawler/tier1Extractors.d.ts +5 -0
- package/dist/crawler/tier1Extractors.d.ts.map +1 -0
- package/dist/crawler/tier1Extractors.js +255 -0
- package/dist/crawler/tracking.d.ts +8 -0
- package/dist/crawler/tracking.d.ts.map +1 -0
- package/dist/crawler/tracking.js +50 -0
- package/dist/crawler/urlUtils.d.ts +12 -0
- package/dist/crawler/urlUtils.d.ts.map +1 -0
- package/dist/crawler/urlUtils.js +53 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/docs/agent-fixes.md +17 -0
- package/docs/agent-ready.md +44 -0
- package/docs/analyze.md +103 -0
- package/docs/check-api.md +142 -0
- package/docs/checks.md +116 -0
- package/docs/crawler.md +158 -0
- package/docs/index.md +24 -0
- package/docs/qc-checks-plan.md +796 -0
- package/docs/stack.md +99 -0
- package/package.json +86 -4
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import { buildReportAnalysis, } from "./report.js";
|
|
2
|
+
export const FLIGHTCHECK_REPORT_VERSION = 1;
|
|
3
|
+
const A11Y_IDS = new Set([
|
|
4
|
+
"missing-alt",
|
|
5
|
+
"empty-alt",
|
|
6
|
+
"generic-alt",
|
|
7
|
+
"heading-skip",
|
|
8
|
+
"multiple-h1",
|
|
9
|
+
"unlabeled-input",
|
|
10
|
+
"missing-lang",
|
|
11
|
+
"positive-tabindex",
|
|
12
|
+
]);
|
|
13
|
+
const BEST_PRACTICE_IDS = new Set([
|
|
14
|
+
"noindex",
|
|
15
|
+
"nofollow",
|
|
16
|
+
"missing-template",
|
|
17
|
+
"main-missing",
|
|
18
|
+
"main-multiple",
|
|
19
|
+
]);
|
|
20
|
+
const FIELD_TITLES = {
|
|
21
|
+
h1: "Missing main heading (h1)",
|
|
22
|
+
title: "Missing page title",
|
|
23
|
+
metaTitle: "Missing meta title",
|
|
24
|
+
metaDescription: "Missing meta description",
|
|
25
|
+
charset: "Missing character set",
|
|
26
|
+
language: "Missing language attribute",
|
|
27
|
+
colorScheme: "Missing color-scheme meta tag",
|
|
28
|
+
themeColor: "Missing theme-color meta tag",
|
|
29
|
+
doctype: "Missing doctype",
|
|
30
|
+
viewport: "Missing viewport meta tag",
|
|
31
|
+
canonical: "Missing canonical URL",
|
|
32
|
+
favicon: "Missing favicon",
|
|
33
|
+
icons: "Missing icon links",
|
|
34
|
+
"og.title": "Missing Open Graph title",
|
|
35
|
+
"og.description": "Missing Open Graph description",
|
|
36
|
+
"og.image": "Missing Open Graph image",
|
|
37
|
+
"og.url": "Missing Open Graph URL",
|
|
38
|
+
"og.type": "Missing Open Graph type",
|
|
39
|
+
"og.siteName": "Missing Open Graph site name",
|
|
40
|
+
"twitter.card": "Missing Twitter card type",
|
|
41
|
+
"twitter.title": "Missing Twitter title",
|
|
42
|
+
"twitter.description": "Missing Twitter description",
|
|
43
|
+
"twitter.image": "Missing Twitter image",
|
|
44
|
+
};
|
|
45
|
+
const BEST_PRACTICE_COPY = {
|
|
46
|
+
noindex: {
|
|
47
|
+
title: "Pages tell search engines not to index them",
|
|
48
|
+
message: "Remove noindex from the robots meta tag so search engines can include these pages.",
|
|
49
|
+
severity: "warning",
|
|
50
|
+
},
|
|
51
|
+
nofollow: {
|
|
52
|
+
title: "Pages tell search engines not to follow links",
|
|
53
|
+
message: "Remove nofollow from the robots meta tag unless this is intentional.",
|
|
54
|
+
severity: "warning",
|
|
55
|
+
},
|
|
56
|
+
"missing-template": {
|
|
57
|
+
title: "Template meta tag is missing",
|
|
58
|
+
message: "Add a template meta tag so reports can group issues by page type.",
|
|
59
|
+
severity: "review",
|
|
60
|
+
},
|
|
61
|
+
"main-missing": {
|
|
62
|
+
title: "Main content landmark is missing",
|
|
63
|
+
message: "Add a single <main> landmark so assistive technology can find the page content.",
|
|
64
|
+
severity: "review",
|
|
65
|
+
},
|
|
66
|
+
"main-multiple": {
|
|
67
|
+
title: "Multiple main landmarks found",
|
|
68
|
+
message: "Keep exactly one <main> landmark per page.",
|
|
69
|
+
severity: "review",
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
const A11Y_COPY = {
|
|
73
|
+
"missing-alt": {
|
|
74
|
+
title: "Images without alt text",
|
|
75
|
+
message: "Add descriptive alt text to images so screen readers can describe them.",
|
|
76
|
+
},
|
|
77
|
+
"empty-alt": {
|
|
78
|
+
title: "Images with empty alt text",
|
|
79
|
+
message: "Review empty alt attributes — decorative images are fine; informative ones need a description.",
|
|
80
|
+
},
|
|
81
|
+
"generic-alt": {
|
|
82
|
+
title: "Images with generic alt text",
|
|
83
|
+
message: "Replace generic alt text (for example “image”) with a meaningful description.",
|
|
84
|
+
},
|
|
85
|
+
"heading-skip": {
|
|
86
|
+
title: "Heading levels are skipped",
|
|
87
|
+
message: "Keep heading levels in order (h1 → h2 → h3) without skipping.",
|
|
88
|
+
},
|
|
89
|
+
"multiple-h1": {
|
|
90
|
+
title: "More than one h1 on the page",
|
|
91
|
+
message: "Use a single h1 that describes the main topic of the page.",
|
|
92
|
+
},
|
|
93
|
+
"unlabeled-input": {
|
|
94
|
+
title: "Form fields without labels",
|
|
95
|
+
message: "Give every form input a visible or accessible label.",
|
|
96
|
+
},
|
|
97
|
+
"missing-lang": {
|
|
98
|
+
title: "Page language is not set",
|
|
99
|
+
message: "Add a lang attribute on the html element (for example lang=\"nl\" or lang=\"en\").",
|
|
100
|
+
},
|
|
101
|
+
"positive-tabindex": {
|
|
102
|
+
title: "Positive tabindex values",
|
|
103
|
+
message: "Remove positive tabindex values; they disrupt natural keyboard order.",
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
const SEVERITY_ORDER = ["critical", "error", "warning", "review"];
|
|
107
|
+
const SEVERITY_HEADINGS = {
|
|
108
|
+
critical: "Critical",
|
|
109
|
+
error: "Errors",
|
|
110
|
+
warning: "Warnings",
|
|
111
|
+
review: "Worth reviewing",
|
|
112
|
+
};
|
|
113
|
+
function emptyCounts() {
|
|
114
|
+
return { critical: 0, error: 0, warning: 0, review: 0 };
|
|
115
|
+
}
|
|
116
|
+
function countFindings(findings) {
|
|
117
|
+
const counts = emptyCounts();
|
|
118
|
+
for (const finding of findings)
|
|
119
|
+
counts[finding.severity] += 1;
|
|
120
|
+
return counts;
|
|
121
|
+
}
|
|
122
|
+
function sortFindings(findings) {
|
|
123
|
+
return [...findings].sort((a, b) => {
|
|
124
|
+
const severityDiff = SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity);
|
|
125
|
+
if (severityDiff !== 0)
|
|
126
|
+
return severityDiff;
|
|
127
|
+
const categoryDiff = a.category.localeCompare(b.category);
|
|
128
|
+
if (categoryDiff !== 0)
|
|
129
|
+
return categoryDiff;
|
|
130
|
+
return a.title.localeCompare(b.title);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
function stackEntries(profile) {
|
|
134
|
+
return profile.detections.map((detection) => ({
|
|
135
|
+
name: detection.name,
|
|
136
|
+
categories: detection.categories,
|
|
137
|
+
confidence: detection.confidence,
|
|
138
|
+
evidence: detection.evidence,
|
|
139
|
+
pageCount: detection.pageCount,
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
function reviewReason(url, status) {
|
|
143
|
+
if (status === 200 || status === null)
|
|
144
|
+
return null;
|
|
145
|
+
if (status === 404)
|
|
146
|
+
return "Page returned 404 — often a bad link or crawler artifact.";
|
|
147
|
+
if (status === 401 || status === 403)
|
|
148
|
+
return "Page requires authentication or is blocked.";
|
|
149
|
+
if (status >= 500)
|
|
150
|
+
return "Server error while fetching this page.";
|
|
151
|
+
return `Unexpected HTTP status ${status}.`;
|
|
152
|
+
}
|
|
153
|
+
function fieldTitle(field) {
|
|
154
|
+
return FIELD_TITLES[field] ?? `Missing ${field}`;
|
|
155
|
+
}
|
|
156
|
+
function fieldMessage(field, scope, template) {
|
|
157
|
+
const where = scope === "site-wide"
|
|
158
|
+
? "the shared site layout"
|
|
159
|
+
: scope === "template"
|
|
160
|
+
? `the “${template ?? "template"}” template`
|
|
161
|
+
: "this page";
|
|
162
|
+
return `Add ${FIELD_TITLES[field]?.replace(/^Missing /i, "").toLowerCase() ?? field} in ${where}.`;
|
|
163
|
+
}
|
|
164
|
+
function findingFromCheck(issue, category, scope, extras = {}) {
|
|
165
|
+
return {
|
|
166
|
+
id: issue.id,
|
|
167
|
+
severity: issue.severity,
|
|
168
|
+
category,
|
|
169
|
+
scope,
|
|
170
|
+
title: humanizeId(issue.id),
|
|
171
|
+
message: issue.message,
|
|
172
|
+
...extras,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function humanizeId(id) {
|
|
176
|
+
return id
|
|
177
|
+
.replace(/[-_]/g, " ")
|
|
178
|
+
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
179
|
+
}
|
|
180
|
+
function pushUnique(findings, finding) {
|
|
181
|
+
findings.push(finding);
|
|
182
|
+
}
|
|
183
|
+
function collectSeoFindings(fields, scope, count, extras = {}) {
|
|
184
|
+
return fields.map((field) => ({
|
|
185
|
+
id: field,
|
|
186
|
+
severity: "error",
|
|
187
|
+
category: "seo",
|
|
188
|
+
scope,
|
|
189
|
+
title: fieldTitle(field),
|
|
190
|
+
message: fieldMessage(field, scope, extras.template),
|
|
191
|
+
count,
|
|
192
|
+
...extras,
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
function collectBestPracticeFindings(keys, scope, count, extras = {}) {
|
|
196
|
+
return keys
|
|
197
|
+
.filter((key) => BEST_PRACTICE_IDS.has(key))
|
|
198
|
+
.map((key) => {
|
|
199
|
+
const copy = BEST_PRACTICE_COPY[key];
|
|
200
|
+
return {
|
|
201
|
+
id: key,
|
|
202
|
+
severity: copy?.severity ?? "review",
|
|
203
|
+
category: "best-practices",
|
|
204
|
+
scope,
|
|
205
|
+
title: copy?.title ?? humanizeId(key),
|
|
206
|
+
message: copy?.message ?? `Fix ${key}.`,
|
|
207
|
+
count,
|
|
208
|
+
...extras,
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
function collectA11yFindings(keys, scope, count, extras = {}) {
|
|
213
|
+
return keys
|
|
214
|
+
.filter((key) => A11Y_IDS.has(key))
|
|
215
|
+
.map((key) => {
|
|
216
|
+
const copy = A11Y_COPY[key];
|
|
217
|
+
return {
|
|
218
|
+
id: key,
|
|
219
|
+
severity: "warning",
|
|
220
|
+
category: "a11y",
|
|
221
|
+
scope,
|
|
222
|
+
title: copy?.title ?? humanizeId(key),
|
|
223
|
+
message: copy?.message ?? `Fix accessibility issue: ${key}.`,
|
|
224
|
+
count,
|
|
225
|
+
...extras,
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
function aggregatePageFindings(pageFixes, pick) {
|
|
230
|
+
const map = new Map();
|
|
231
|
+
for (const fix of pageFixes) {
|
|
232
|
+
if (fix.item.status !== 200)
|
|
233
|
+
continue;
|
|
234
|
+
for (const entry of pick(fix)) {
|
|
235
|
+
const current = map.get(entry.id) ?? { urls: [], message: entry.message };
|
|
236
|
+
current.urls.push(fix.item.url);
|
|
237
|
+
if (entry.message)
|
|
238
|
+
current.message = entry.message;
|
|
239
|
+
map.set(entry.id, current);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return map;
|
|
243
|
+
}
|
|
244
|
+
function buildFindings(analysis, tier1) {
|
|
245
|
+
const findings = [];
|
|
246
|
+
const { totalPages } = analysis;
|
|
247
|
+
if (tier1.redirect) {
|
|
248
|
+
for (const issue of tier1.redirect.issues) {
|
|
249
|
+
pushUnique(findings, findingFromCheck(issue, "infra", "origin"));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (tier1.dns) {
|
|
253
|
+
for (const issue of tier1.dns.issues) {
|
|
254
|
+
pushUnique(findings, findingFromCheck(issue, "infra", "origin"));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (tier1.securityHeaders) {
|
|
258
|
+
for (const issue of tier1.securityHeaders.issues) {
|
|
259
|
+
pushUnique(findings, findingFromCheck(issue, "infra", "origin"));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (tier1.robotsTxt) {
|
|
263
|
+
for (const issue of tier1.robotsTxt.issues) {
|
|
264
|
+
pushUnique(findings, findingFromCheck(issue, "infra", "origin"));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (tier1.sitemap) {
|
|
268
|
+
for (const issue of tier1.sitemap.issues) {
|
|
269
|
+
pushUnique(findings, findingFromCheck(issue, "infra", "origin"));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (tier1.agentReady) {
|
|
273
|
+
for (const issue of tier1.agentReady.issues) {
|
|
274
|
+
pushUnique(findings, findingFromCheck(issue, "infra", "origin"));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const page of analysis.securityPages) {
|
|
278
|
+
for (const secret of page.secrets) {
|
|
279
|
+
pushUnique(findings, {
|
|
280
|
+
id: `secret-${secret.provider}-${secret.type}`,
|
|
281
|
+
severity: secret.severity === "critical" ? "critical" : "review",
|
|
282
|
+
category: "security",
|
|
283
|
+
scope: "page",
|
|
284
|
+
title: `Exposed ${secret.provider} ${secret.type}`,
|
|
285
|
+
message: `Rotate or remove this secret. Masked value: ${secret.masked} (source: ${secret.source}).`,
|
|
286
|
+
urls: [page.url],
|
|
287
|
+
count: 1,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
findings.push(...collectSeoFindings([...analysis.siteWideFields], "site-wide", totalPages), ...collectBestPracticeFindings([...analysis.siteWideWarningKeys], "site-wide", totalPages), ...collectA11yFindings([...analysis.siteWideTier1Keys], "site-wide", totalPages));
|
|
292
|
+
for (const [template, fields] of analysis.templateFieldMap) {
|
|
293
|
+
if (fields.size === 0)
|
|
294
|
+
continue;
|
|
295
|
+
const count = analysis.templateGroups.get(template)?.length ?? 0;
|
|
296
|
+
findings.push(...collectSeoFindings([...fields], "template", count, { template }));
|
|
297
|
+
}
|
|
298
|
+
for (const [template, keys] of analysis.templateWarningKeyMap) {
|
|
299
|
+
const filtered = [...keys].filter((key) => BEST_PRACTICE_IDS.has(key));
|
|
300
|
+
if (filtered.length === 0)
|
|
301
|
+
continue;
|
|
302
|
+
const count = analysis.templateGroups.get(template)?.length ?? 0;
|
|
303
|
+
findings.push(...collectBestPracticeFindings(filtered, "template", count, { template }));
|
|
304
|
+
}
|
|
305
|
+
for (const [template, keys] of analysis.templateTier1KeyMap) {
|
|
306
|
+
const filtered = [...keys].filter((key) => A11Y_IDS.has(key));
|
|
307
|
+
if (filtered.length === 0)
|
|
308
|
+
continue;
|
|
309
|
+
const count = analysis.templateGroups.get(template)?.length ?? 0;
|
|
310
|
+
findings.push(...collectA11yFindings(filtered, "template", count, { template }));
|
|
311
|
+
}
|
|
312
|
+
if (!analysis.redirectOnly) {
|
|
313
|
+
const seoById = aggregatePageFindings(analysis.pageFixes, (fix) => fix.remainingFields.map((id) => ({ id })));
|
|
314
|
+
for (const [id, { urls }] of seoById) {
|
|
315
|
+
findings.push({
|
|
316
|
+
id,
|
|
317
|
+
severity: "error",
|
|
318
|
+
category: "seo",
|
|
319
|
+
scope: "page",
|
|
320
|
+
title: fieldTitle(id),
|
|
321
|
+
message: fieldMessage(id, "page"),
|
|
322
|
+
urls,
|
|
323
|
+
count: urls.length,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
const bestById = aggregatePageFindings(analysis.pageFixes, (fix) => fix.remainingWarningKeys.filter((key) => BEST_PRACTICE_IDS.has(key)).map((id) => ({ id })));
|
|
327
|
+
for (const [id, { urls }] of bestById) {
|
|
328
|
+
const copy = BEST_PRACTICE_COPY[id];
|
|
329
|
+
findings.push({
|
|
330
|
+
id,
|
|
331
|
+
severity: copy?.severity ?? "review",
|
|
332
|
+
category: "best-practices",
|
|
333
|
+
scope: "page",
|
|
334
|
+
title: copy?.title ?? humanizeId(id),
|
|
335
|
+
message: copy?.message ?? `Fix ${id}.`,
|
|
336
|
+
urls,
|
|
337
|
+
count: urls.length,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
const a11yById = aggregatePageFindings(analysis.pageFixes, (fix) => fix.remainingA11yIssues.map((issue) => ({ id: issue.id, message: issue.message })));
|
|
341
|
+
for (const [id, { urls, message }] of a11yById) {
|
|
342
|
+
const copy = A11Y_COPY[id];
|
|
343
|
+
findings.push({
|
|
344
|
+
id,
|
|
345
|
+
severity: "warning",
|
|
346
|
+
category: "a11y",
|
|
347
|
+
scope: "page",
|
|
348
|
+
title: copy?.title ?? humanizeId(id),
|
|
349
|
+
message: message ?? copy?.message ?? `Fix accessibility issue: ${id}.`,
|
|
350
|
+
urls,
|
|
351
|
+
count: urls.length,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return sortFindings(findings);
|
|
356
|
+
}
|
|
357
|
+
function buildDnsSummary(tier1) {
|
|
358
|
+
if (!tier1.dns)
|
|
359
|
+
return undefined;
|
|
360
|
+
const dns = tier1.dns;
|
|
361
|
+
const summary = [];
|
|
362
|
+
if (dns.records.a.length > 0)
|
|
363
|
+
summary.push(`A: ${dns.records.a.join(", ")}`);
|
|
364
|
+
if (dns.records.aaaa.length > 0)
|
|
365
|
+
summary.push(`AAAA: ${dns.records.aaaa.join(", ")}`);
|
|
366
|
+
if (dns.records.mx.length > 0) {
|
|
367
|
+
summary.push(`MX: ${dns.records.mx.map((row) => `${row.exchange} (${row.priority})`).join(", ")}`);
|
|
368
|
+
}
|
|
369
|
+
if (dns.mail.spf)
|
|
370
|
+
summary.push(`SPF: ${dns.mail.spf}`);
|
|
371
|
+
if (dns.mail.dmarc)
|
|
372
|
+
summary.push(`DMARC: ${dns.mail.dmarc}`);
|
|
373
|
+
return { hostname: dns.hostname, summary };
|
|
374
|
+
}
|
|
375
|
+
function buildAnalysisBlock(analysis, tier1, findings, records) {
|
|
376
|
+
const pagesWithIssues = analysis.allItems.filter((item) => item.missingFields.length > 0 ||
|
|
377
|
+
item.warningKeys.length > 0 ||
|
|
378
|
+
item.tier1IssueKeys.length > 0 ||
|
|
379
|
+
records.some((record) => record.url === item.url && (record.security?.secrets.length ?? 0) > 0)).length;
|
|
380
|
+
const reviewPages = [];
|
|
381
|
+
if (!analysis.redirectOnly) {
|
|
382
|
+
for (const fix of analysis.pageFixes) {
|
|
383
|
+
const reason = reviewReason(fix.item.url, fix.item.status);
|
|
384
|
+
if (reason) {
|
|
385
|
+
reviewPages.push({ url: fix.item.url, status: fix.item.status, reason });
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
pagesAnalyzed: analysis.totalPages,
|
|
391
|
+
pagesWithIssues,
|
|
392
|
+
findingCounts: countFindings(findings),
|
|
393
|
+
stack: stackEntries(analysis.stackProfile),
|
|
394
|
+
templates: [...analysis.templateGroups.keys()].sort(),
|
|
395
|
+
dns: buildDnsSummary(tier1),
|
|
396
|
+
redirect: tier1.redirect
|
|
397
|
+
? {
|
|
398
|
+
url: tier1.redirect.url,
|
|
399
|
+
status: tier1.redirect.status,
|
|
400
|
+
location: tier1.redirect.location,
|
|
401
|
+
issueCount: tier1.redirect.issues.length,
|
|
402
|
+
}
|
|
403
|
+
: undefined,
|
|
404
|
+
reviewPages,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
export async function buildStructuredReport(sourcePath, records, tier1 = {}, options = {}) {
|
|
408
|
+
const analysis = await buildReportAnalysis(records, tier1, options);
|
|
409
|
+
const findings = buildFindings(analysis, tier1);
|
|
410
|
+
return {
|
|
411
|
+
version: FLIGHTCHECK_REPORT_VERSION,
|
|
412
|
+
generatedAt: options.generatedAt ?? new Date().toISOString(),
|
|
413
|
+
source: sourcePath,
|
|
414
|
+
redirectOnly: analysis.redirectOnly,
|
|
415
|
+
analysis: buildAnalysisBlock(analysis, tier1, findings, records),
|
|
416
|
+
findings,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
function scopeLabel(finding) {
|
|
420
|
+
if (finding.scope === "site-wide") {
|
|
421
|
+
return finding.count ? `All ${finding.count} scanned pages` : "Entire site";
|
|
422
|
+
}
|
|
423
|
+
if (finding.scope === "template") {
|
|
424
|
+
const template = finding.template ? `template “${finding.template}”` : "a template";
|
|
425
|
+
return finding.count ? `${finding.count} page(s) using ${template}` : template;
|
|
426
|
+
}
|
|
427
|
+
if (finding.scope === "origin")
|
|
428
|
+
return "Site / hosting configuration";
|
|
429
|
+
if (finding.urls && finding.urls.length === 1)
|
|
430
|
+
return finding.urls[0];
|
|
431
|
+
if (finding.count)
|
|
432
|
+
return `${finding.count} page(s)`;
|
|
433
|
+
return "Specific pages";
|
|
434
|
+
}
|
|
435
|
+
function formatFindingMarkdown(finding) {
|
|
436
|
+
const lines = [`#### ${finding.title}`, "", finding.message, "", `- Where: ${scopeLabel(finding)}`];
|
|
437
|
+
if (finding.category)
|
|
438
|
+
lines.push(`- Category: ${finding.category}`);
|
|
439
|
+
if (finding.urls && finding.urls.length > 1) {
|
|
440
|
+
lines.push("- Pages:");
|
|
441
|
+
for (const url of finding.urls.slice(0, 20)) {
|
|
442
|
+
lines.push(` - ${url}`);
|
|
443
|
+
}
|
|
444
|
+
if (finding.urls.length > 20) {
|
|
445
|
+
lines.push(` - …and ${finding.urls.length - 20} more`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
lines.push("");
|
|
449
|
+
return lines;
|
|
450
|
+
}
|
|
451
|
+
/** Human-friendly Markdown rendered from the structured report (single source of truth). */
|
|
452
|
+
export function renderReportMarkdown(report) {
|
|
453
|
+
const { analysis, findings, redirectOnly } = report;
|
|
454
|
+
const counts = analysis.findingCounts;
|
|
455
|
+
const totalFindings = findings.length;
|
|
456
|
+
const lines = [
|
|
457
|
+
"# Flightcheck report",
|
|
458
|
+
"",
|
|
459
|
+
`Generated: ${report.generatedAt}`,
|
|
460
|
+
`Source: \`${report.source}\``,
|
|
461
|
+
"",
|
|
462
|
+
"## At a glance",
|
|
463
|
+
"",
|
|
464
|
+
];
|
|
465
|
+
if (redirectOnly) {
|
|
466
|
+
lines.push("This site redirects at the start URL. Flightcheck checked the redirect setup and skipped a full page crawl.", "");
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
lines.push("A plain-language summary of what the scan found. Work from Critical downward.", "");
|
|
470
|
+
}
|
|
471
|
+
lines.push(`- Pages checked: ${analysis.pagesAnalyzed}`);
|
|
472
|
+
lines.push(`- Pages with issues: ${analysis.pagesWithIssues}`);
|
|
473
|
+
lines.push(`- Findings: ${totalFindings} total` +
|
|
474
|
+
` (${counts.critical} critical, ${counts.error} errors, ${counts.warning} warnings, ${counts.review} to review)`);
|
|
475
|
+
lines.push("");
|
|
476
|
+
if (analysis.stack.length > 0) {
|
|
477
|
+
lines.push("### Stack", "");
|
|
478
|
+
for (const entry of analysis.stack) {
|
|
479
|
+
const categories = entry.categories.join(", ");
|
|
480
|
+
const evidence = entry.evidence[0] ? ` — ${entry.evidence[0]}` : "";
|
|
481
|
+
lines.push(`- **${entry.name}** (${categories}, ${entry.confidence})${evidence}`);
|
|
482
|
+
}
|
|
483
|
+
lines.push("");
|
|
484
|
+
}
|
|
485
|
+
if (analysis.dns) {
|
|
486
|
+
lines.push("### DNS", "");
|
|
487
|
+
lines.push(`- Hostname: \`${analysis.dns.hostname}\``);
|
|
488
|
+
for (const row of analysis.dns.summary)
|
|
489
|
+
lines.push(`- ${row}`);
|
|
490
|
+
lines.push("");
|
|
491
|
+
}
|
|
492
|
+
if (analysis.redirect) {
|
|
493
|
+
lines.push("### Redirect", "");
|
|
494
|
+
lines.push(`- URL: \`${analysis.redirect.url}\``);
|
|
495
|
+
lines.push(`- Status: \`${analysis.redirect.status ?? "unreachable"}\``);
|
|
496
|
+
if (analysis.redirect.location)
|
|
497
|
+
lines.push(`- Location: \`${analysis.redirect.location}\``);
|
|
498
|
+
lines.push("");
|
|
499
|
+
}
|
|
500
|
+
if (analysis.reviewPages.length > 0) {
|
|
501
|
+
lines.push("### Pages to double-check", "");
|
|
502
|
+
lines.push("These URLs did not return a normal page, so they were not turned into fix todos.", "");
|
|
503
|
+
for (const page of analysis.reviewPages) {
|
|
504
|
+
lines.push(`- \`${page.url}\` (${page.status ?? "error"}): ${page.reason}`);
|
|
505
|
+
}
|
|
506
|
+
lines.push("");
|
|
507
|
+
}
|
|
508
|
+
lines.push("## Findings", "");
|
|
509
|
+
if (findings.length === 0) {
|
|
510
|
+
lines.push("No issues found. Nice work.", "");
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
for (const severity of SEVERITY_ORDER) {
|
|
514
|
+
const group = findings.filter((finding) => finding.severity === severity);
|
|
515
|
+
if (group.length === 0)
|
|
516
|
+
continue;
|
|
517
|
+
lines.push(`### ${SEVERITY_HEADINGS[severity]}`, "");
|
|
518
|
+
for (const finding of group) {
|
|
519
|
+
lines.push(...formatFindingMarkdown(finding));
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
|
|
524
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"analyze.d.ts","sourceRoot":"","sources":["../src/analyze.ts"],"names":[],"mappings":""}
|
package/dist/analyze.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { basename, resolve } from "node:path";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { runAnalyzeCheck } from "./checks/analyze.js";
|
|
5
|
+
import { defaultReportPathForInput } from "./crawler/outputPaths.js";
|
|
6
|
+
async function main() {
|
|
7
|
+
const sanitizedArgv = [...process.argv];
|
|
8
|
+
const separatorIndex = sanitizedArgv.indexOf("--");
|
|
9
|
+
if (separatorIndex >= 2) {
|
|
10
|
+
sanitizedArgv.splice(separatorIndex, 1);
|
|
11
|
+
}
|
|
12
|
+
const program = new Command();
|
|
13
|
+
program
|
|
14
|
+
.name("flightcheck-analyze")
|
|
15
|
+
.description("Analyze crawl JSON and generate structured JSON + Markdown reports.")
|
|
16
|
+
.argument("<input>", "Input JSON report file path")
|
|
17
|
+
.option("--output <file>", "Output Markdown file path")
|
|
18
|
+
.option("--errors-only", "Include only critical/error findings in the Markdown report", false)
|
|
19
|
+
.action(async (input, options) => {
|
|
20
|
+
const inputPath = resolve(input);
|
|
21
|
+
const result = await runAnalyzeCheck({
|
|
22
|
+
inputPath,
|
|
23
|
+
reportPath: resolve(options.output ?? defaultReportPathForInput(inputPath)),
|
|
24
|
+
errorsOnly: options.errorsOnly,
|
|
25
|
+
});
|
|
26
|
+
process.stdout.write(`${JSON.stringify({
|
|
27
|
+
message: "Report generated",
|
|
28
|
+
input: basename(result.inputPath),
|
|
29
|
+
report: result.reportPath,
|
|
30
|
+
reportJson: result.reportJsonPath,
|
|
31
|
+
pagesAnalyzed: result.pagesAnalyzed,
|
|
32
|
+
findings: result.report.findings.length,
|
|
33
|
+
}, null, 2)}\n`);
|
|
34
|
+
});
|
|
35
|
+
await program.parseAsync(sanitizedArgv);
|
|
36
|
+
}
|
|
37
|
+
main().catch((error) => {
|
|
38
|
+
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type CheckIssue } from "./issues.js";
|
|
2
|
+
/** AI crawler user-agents commonly expected in robots.txt (isitagentready / IETF drafts). */
|
|
3
|
+
export declare const AI_BOT_USER_AGENTS: readonly ["GPTBot", "OAI-SearchBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "anthropic-ai", "Google-Extended", "Amazonbot", "Applebot-Extended", "Bytespider", "CCBot", "PerplexityBot"];
|
|
4
|
+
/** Link relation types useful for agent discovery (RFC 8288 / RFC 9727). */
|
|
5
|
+
export declare const AGENT_LINK_RELS: readonly ["api-catalog", "service-desc", "service-doc", "describedby"];
|
|
6
|
+
export type AgentReadyProbeStatus = {
|
|
7
|
+
url: string;
|
|
8
|
+
status: number | null;
|
|
9
|
+
contentType?: string;
|
|
10
|
+
body?: string;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
};
|
|
13
|
+
export type AgentReadyFindings = {
|
|
14
|
+
robotsTxt: {
|
|
15
|
+
status: number | null;
|
|
16
|
+
hasContentSignals: boolean;
|
|
17
|
+
contentSignals: string[];
|
|
18
|
+
aiBotUserAgents: string[];
|
|
19
|
+
};
|
|
20
|
+
llmsTxt: {
|
|
21
|
+
url: string;
|
|
22
|
+
status: number | null;
|
|
23
|
+
hasTitle: boolean;
|
|
24
|
+
};
|
|
25
|
+
markdownNegotiation: {
|
|
26
|
+
url: string;
|
|
27
|
+
status: number | null;
|
|
28
|
+
contentType?: string;
|
|
29
|
+
supportsMarkdown: boolean;
|
|
30
|
+
};
|
|
31
|
+
linkHeaders: {
|
|
32
|
+
relations: string[];
|
|
33
|
+
};
|
|
34
|
+
wellKnown: {
|
|
35
|
+
mcpServerCard: boolean;
|
|
36
|
+
agentSkills: boolean;
|
|
37
|
+
apiCatalog: boolean;
|
|
38
|
+
ard: boolean;
|
|
39
|
+
a2aAgentCard: boolean;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
export type AgentReadyCheckResult = {
|
|
43
|
+
url: string;
|
|
44
|
+
findings: AgentReadyFindings;
|
|
45
|
+
issues: CheckIssue[];
|
|
46
|
+
};
|
|
47
|
+
export type AnalyzeAgentReadyInput = {
|
|
48
|
+
origin: string;
|
|
49
|
+
robotsTxt?: AgentReadyProbeStatus;
|
|
50
|
+
llmsTxt?: AgentReadyProbeStatus;
|
|
51
|
+
markdown?: AgentReadyProbeStatus;
|
|
52
|
+
homepage?: AgentReadyProbeStatus;
|
|
53
|
+
wellKnown?: {
|
|
54
|
+
mcpServerCard?: AgentReadyProbeStatus;
|
|
55
|
+
agentSkills?: AgentReadyProbeStatus;
|
|
56
|
+
apiCatalog?: AgentReadyProbeStatus;
|
|
57
|
+
ard?: AgentReadyProbeStatus;
|
|
58
|
+
a2aAgentCard?: AgentReadyProbeStatus;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
export declare function analyzeAgentReady(input: AnalyzeAgentReadyInput): AgentReadyCheckResult;
|
|
62
|
+
export declare function runAgentReadyCheck(startUrl: string, timeoutMs?: number): Promise<AgentReadyCheckResult>;
|
|
63
|
+
//# sourceMappingURL=agent-ready.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-ready.d.ts","sourceRoot":"","sources":["../../src/checks/agent-ready.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAGrD,6FAA6F;AAC7F,eAAO,MAAM,kBAAkB,8LAarB,CAAC;AAEX,4EAA4E;AAC5E,eAAO,MAAM,eAAe,wEAAyE,CAAC;AAEtG,MAAM,MAAM,qBAAqB,GAAG;IAClC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,SAAS,EAAE;QACT,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;QACtB,iBAAiB,EAAE,OAAO,CAAC;QAC3B,cAAc,EAAE,MAAM,EAAE,CAAC;QACzB,eAAe,EAAE,MAAM,EAAE,CAAC;KAC3B,CAAC;IACF,OAAO,EAAE;QACP,GAAG,EAAE,MAAM,CAAC;QACZ,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;QACtB,QAAQ,EAAE,OAAO,CAAC;KACnB,CAAC;IACF,mBAAmB,EAAE;QACnB,GAAG,EAAE,MAAM,CAAC;QACZ,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;QACtB,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,gBAAgB,EAAE,OAAO,CAAC;KAC3B,CAAC;IACF,WAAW,EAAE;QACX,SAAS,EAAE,MAAM,EAAE,CAAC;KACrB,CAAC;IACF,SAAS,EAAE;QACT,aAAa,EAAE,OAAO,CAAC;QACvB,WAAW,EAAE,OAAO,CAAC;QACrB,UAAU,EAAE,OAAO,CAAC;QACpB,GAAG,EAAE,OAAO,CAAC;QACb,YAAY,EAAE,OAAO,CAAC;KACvB,CAAC;CACH,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,MAAM,EAAE,UAAU,EAAE,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,qBAAqB,CAAC;IAClC,OAAO,CAAC,EAAE,qBAAqB,CAAC;IAChC,QAAQ,CAAC,EAAE,qBAAqB,CAAC;IACjC,QAAQ,CAAC,EAAE,qBAAqB,CAAC;IACjC,SAAS,CAAC,EAAE;QACV,aAAa,CAAC,EAAE,qBAAqB,CAAC;QACtC,WAAW,CAAC,EAAE,qBAAqB,CAAC;QACpC,UAAU,CAAC,EAAE,qBAAqB,CAAC;QACnC,GAAG,CAAC,EAAE,qBAAqB,CAAC;QAC5B,YAAY,CAAC,EAAE,qBAAqB,CAAC;KACtC,CAAC;CACH,CAAC;AAoDF,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,sBAAsB,GAAG,qBAAqB,CAoItF;AAmCD,wBAAsB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,SAAS,GAAG,OAAO,CAAC,qBAAqB,CAAC,CA+C7G"}
|