@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.
Files changed (127) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +691 -0
  3. package/README.md +106 -2
  4. package/dist/analyze/fixManifest.d.ts +65 -0
  5. package/dist/analyze/fixManifest.d.ts.map +1 -0
  6. package/dist/analyze/fixManifest.js +588 -0
  7. package/dist/analyze/report.d.ts +283 -0
  8. package/dist/analyze/report.d.ts.map +1 -0
  9. package/dist/analyze/report.js +726 -0
  10. package/dist/analyze/reportTier1.d.ts +46 -0
  11. package/dist/analyze/reportTier1.d.ts.map +1 -0
  12. package/dist/analyze/reportTier1.js +251 -0
  13. package/dist/analyze/stack.d.ts +33 -0
  14. package/dist/analyze/stack.d.ts.map +1 -0
  15. package/dist/analyze/stack.js +188 -0
  16. package/dist/analyze/structuredReport.d.ts +62 -0
  17. package/dist/analyze/structuredReport.d.ts.map +1 -0
  18. package/dist/analyze/structuredReport.js +524 -0
  19. package/dist/analyze.d.ts +3 -0
  20. package/dist/analyze.d.ts.map +1 -0
  21. package/dist/analyze.js +40 -0
  22. package/dist/checks/agent-ready.d.ts +63 -0
  23. package/dist/checks/agent-ready.d.ts.map +1 -0
  24. package/dist/checks/agent-ready.js +219 -0
  25. package/dist/checks/analyze.d.ts +15 -0
  26. package/dist/checks/analyze.d.ts.map +1 -0
  27. package/dist/checks/analyze.js +63 -0
  28. package/dist/checks/crawl.d.ts +28 -0
  29. package/dist/checks/crawl.d.ts.map +1 -0
  30. package/dist/checks/crawl.js +86 -0
  31. package/dist/checks/dns.d.ts +25 -0
  32. package/dist/checks/dns.d.ts.map +1 -0
  33. package/dist/checks/dns.js +80 -0
  34. package/dist/checks/index.d.ts +24 -0
  35. package/dist/checks/index.d.ts.map +1 -0
  36. package/dist/checks/index.js +12 -0
  37. package/dist/checks/issues.d.ts +8 -0
  38. package/dist/checks/issues.d.ts.map +1 -0
  39. package/dist/checks/issues.js +3 -0
  40. package/dist/checks/redirect.d.ts +38 -0
  41. package/dist/checks/redirect.d.ts.map +1 -0
  42. package/dist/checks/redirect.js +209 -0
  43. package/dist/checks/registry.d.ts +8 -0
  44. package/dist/checks/registry.d.ts.map +1 -0
  45. package/dist/checks/registry.js +275 -0
  46. package/dist/checks/robots-txt.d.ts +16 -0
  47. package/dist/checks/robots-txt.d.ts.map +1 -0
  48. package/dist/checks/robots-txt.js +99 -0
  49. package/dist/checks/runner.d.ts +9 -0
  50. package/dist/checks/runner.d.ts.map +1 -0
  51. package/dist/checks/runner.js +110 -0
  52. package/dist/checks/security-headers.d.ts +12 -0
  53. package/dist/checks/security-headers.d.ts.map +1 -0
  54. package/dist/checks/security-headers.js +53 -0
  55. package/dist/checks/sitemap.d.ts +36 -0
  56. package/dist/checks/sitemap.d.ts.map +1 -0
  57. package/dist/checks/sitemap.js +140 -0
  58. package/dist/checks/types.d.ts +72 -0
  59. package/dist/checks/types.d.ts.map +1 -0
  60. package/dist/checks/types.js +20 -0
  61. package/dist/cli.d.ts +3 -0
  62. package/dist/cli.d.ts.map +1 -0
  63. package/dist/cli.js +125 -0
  64. package/dist/crawler/checkGroups.d.ts +7 -0
  65. package/dist/crawler/checkGroups.d.ts.map +1 -0
  66. package/dist/crawler/checkGroups.js +29 -0
  67. package/dist/crawler/crawlState.d.ts +7 -0
  68. package/dist/crawler/crawlState.d.ts.map +1 -0
  69. package/dist/crawler/crawlState.js +6 -0
  70. package/dist/crawler/defaults.d.ts +11 -0
  71. package/dist/crawler/defaults.d.ts.map +1 -0
  72. package/dist/crawler/defaults.js +10 -0
  73. package/dist/crawler/extractors.d.ts +5 -0
  74. package/dist/crawler/extractors.d.ts.map +1 -0
  75. package/dist/crawler/extractors.js +169 -0
  76. package/dist/crawler/jsAssets.d.ts +2 -0
  77. package/dist/crawler/jsAssets.d.ts.map +1 -0
  78. package/dist/crawler/jsAssets.js +22 -0
  79. package/dist/crawler/outputPaths.d.ts +14 -0
  80. package/dist/crawler/outputPaths.d.ts.map +1 -0
  81. package/dist/crawler/outputPaths.js +51 -0
  82. package/dist/crawler/reporter.d.ts +3 -0
  83. package/dist/crawler/reporter.d.ts.map +1 -0
  84. package/dist/crawler/reporter.js +12 -0
  85. package/dist/crawler/robots.d.ts +5 -0
  86. package/dist/crawler/robots.d.ts.map +1 -0
  87. package/dist/crawler/robots.js +29 -0
  88. package/dist/crawler/runCrawler.d.ts +22 -0
  89. package/dist/crawler/runCrawler.d.ts.map +1 -0
  90. package/dist/crawler/runCrawler.js +172 -0
  91. package/dist/crawler/schema.d.ts +87 -0
  92. package/dist/crawler/schema.d.ts.map +1 -0
  93. package/dist/crawler/schema.js +44 -0
  94. package/dist/crawler/secrets.d.ts +4 -0
  95. package/dist/crawler/secrets.d.ts.map +1 -0
  96. package/dist/crawler/secrets.js +70 -0
  97. package/dist/crawler/sitemap.d.ts +8 -0
  98. package/dist/crawler/sitemap.d.ts.map +1 -0
  99. package/dist/crawler/sitemap.js +105 -0
  100. package/dist/crawler/stack.d.ts +3 -0
  101. package/dist/crawler/stack.d.ts.map +1 -0
  102. package/dist/crawler/stack.js +1 -0
  103. package/dist/crawler/stackDetect.d.ts +24 -0
  104. package/dist/crawler/stackDetect.d.ts.map +1 -0
  105. package/dist/crawler/stackDetect.js +604 -0
  106. package/dist/crawler/tier1Extractors.d.ts +5 -0
  107. package/dist/crawler/tier1Extractors.d.ts.map +1 -0
  108. package/dist/crawler/tier1Extractors.js +255 -0
  109. package/dist/crawler/tracking.d.ts +8 -0
  110. package/dist/crawler/tracking.d.ts.map +1 -0
  111. package/dist/crawler/tracking.js +50 -0
  112. package/dist/crawler/urlUtils.d.ts +12 -0
  113. package/dist/crawler/urlUtils.d.ts.map +1 -0
  114. package/dist/crawler/urlUtils.js +53 -0
  115. package/dist/index.d.ts +14 -0
  116. package/dist/index.d.ts.map +1 -0
  117. package/dist/index.js +7 -0
  118. package/docs/agent-fixes.md +17 -0
  119. package/docs/agent-ready.md +44 -0
  120. package/docs/analyze.md +103 -0
  121. package/docs/check-api.md +142 -0
  122. package/docs/checks.md +116 -0
  123. package/docs/crawler.md +158 -0
  124. package/docs/index.md +24 -0
  125. package/docs/qc-checks-plan.md +796 -0
  126. package/docs/stack.md +99 -0
  127. package/package.json +86 -4
@@ -0,0 +1,726 @@
1
+ import { z } from "zod";
2
+ import { aggregateStackFromRecords } from "./stack.js";
3
+ const iconSchema = z.object({
4
+ rel: z.string(),
5
+ href: z.string(),
6
+ type: z.string(),
7
+ sizes: z.string(),
8
+ media: z.string(),
9
+ });
10
+ const trackingItemSchema = z.object({
11
+ provider: z.string(),
12
+ id: z.string(),
13
+ });
14
+ const secretFindingSchema = z.object({
15
+ type: z.string(),
16
+ provider: z.string(),
17
+ severity: z.enum(["critical", "review"]),
18
+ masked: z.string(),
19
+ source: z.string(),
20
+ });
21
+ const crawlIssueSchema = z.object({
22
+ id: z.string(),
23
+ severity: z.enum(["critical", "error", "warning", "review"]),
24
+ message: z.string(),
25
+ });
26
+ const stackDetectionSchema = z.object({
27
+ name: z.string(),
28
+ categories: z.array(z.string()),
29
+ confidence: z.enum(["high", "medium", "low"]),
30
+ evidence: z.array(z.string()),
31
+ website: z.string().optional(),
32
+ version: z.string().optional(),
33
+ detector: z.literal("flightcheck-v1").optional(),
34
+ });
35
+ const recordSchema = z.object({
36
+ url: z.string(),
37
+ status: z.number().nullable(),
38
+ h1: z.string(),
39
+ title: z.string(),
40
+ metaTitle: z.string(),
41
+ metaDescription: z.string(),
42
+ robots: z.string(),
43
+ template: z.string().optional().default(""),
44
+ mainCount: z.number().nullable().optional().default(null),
45
+ charset: z.string(),
46
+ language: z.string(),
47
+ colorScheme: z.string(),
48
+ themeColor: z.string(),
49
+ doctype: z.string(),
50
+ viewport: z.string(),
51
+ canonical: z.string(),
52
+ favicon: z.string(),
53
+ icons: z.array(iconSchema),
54
+ og: z.object({
55
+ title: z.string(),
56
+ description: z.string(),
57
+ image: z.string(),
58
+ url: z.string(),
59
+ type: z.string(),
60
+ siteName: z.string(),
61
+ }),
62
+ twitter: z.object({
63
+ card: z.string(),
64
+ title: z.string(),
65
+ description: z.string(),
66
+ image: z.string(),
67
+ }),
68
+ tracking: z.object({
69
+ tagManager: z.array(trackingItemSchema),
70
+ analytics: z.array(trackingItemSchema),
71
+ }),
72
+ stack: z
73
+ .object({
74
+ detections: z.array(stackDetectionSchema),
75
+ })
76
+ .optional(),
77
+ security: z
78
+ .object({
79
+ secrets: z.array(secretFindingSchema),
80
+ scannedAssets: z.number(),
81
+ })
82
+ .optional(),
83
+ images: z.object({ issues: z.array(crawlIssueSchema) }).optional(),
84
+ a11yStatic: z.object({ issues: z.array(crawlIssueSchema) }).optional(),
85
+ note: z.string().optional(),
86
+ error: z.string().optional(),
87
+ });
88
+ export const reportSchema = z.array(recordSchema);
89
+ export function parseReport(json) {
90
+ return reportSchema.parse(json);
91
+ }
92
+ const fieldSelectors = [
93
+ { label: "h1", get: (r) => r.h1 },
94
+ { label: "title", get: (r) => r.title },
95
+ { label: "metaTitle", get: (r) => r.metaTitle },
96
+ { label: "metaDescription", get: (r) => r.metaDescription },
97
+ { label: "charset", get: (r) => r.charset },
98
+ { label: "language", get: (r) => r.language },
99
+ { label: "colorScheme", get: (r) => r.colorScheme },
100
+ { label: "themeColor", get: (r) => r.themeColor },
101
+ { label: "doctype", get: (r) => r.doctype },
102
+ { label: "viewport", get: (r) => r.viewport },
103
+ { label: "canonical", get: (r) => r.canonical },
104
+ { label: "favicon", get: (r) => r.favicon },
105
+ { label: "og.title", get: (r) => r.og.title },
106
+ { label: "og.description", get: (r) => r.og.description },
107
+ { label: "og.image", get: (r) => r.og.image },
108
+ { label: "og.url", get: (r) => r.og.url },
109
+ { label: "og.type", get: (r) => r.og.type },
110
+ { label: "og.siteName", get: (r) => r.og.siteName },
111
+ { label: "twitter.card", get: (r) => r.twitter.card },
112
+ { label: "twitter.title", get: (r) => r.twitter.title },
113
+ { label: "twitter.description", get: (r) => r.twitter.description },
114
+ { label: "twitter.image", get: (r) => r.twitter.image },
115
+ { label: "icons", get: (r) => (r.icons.length > 0 ? ["present"] : []) },
116
+ ];
117
+ function isMissingValue(value) {
118
+ if (Array.isArray(value))
119
+ return value.length === 0;
120
+ return value.trim() === "";
121
+ }
122
+ function parseRobotsDirectives(robots) {
123
+ return new Set(robots
124
+ .toLowerCase()
125
+ .split(/[\s,]+/)
126
+ .map((token) => token.trim())
127
+ .filter((token) => token.length > 0));
128
+ }
129
+ function collectWarnings(record) {
130
+ const warnings = [];
131
+ if (record.template.trim() === "") {
132
+ warnings.push("Missing template meta tag — adding `<meta name=\"template\">` helps flightcheck give better tailored reports.");
133
+ }
134
+ const robots = record.robots.trim();
135
+ if (robots === "")
136
+ return warnings;
137
+ const directives = parseRobotsDirectives(robots);
138
+ if (directives.has("noindex") || directives.has("none")) {
139
+ warnings.push(`Page is set to noindex (robots: "${robots}") — search engines are told not to index it.`);
140
+ }
141
+ if (directives.has("nofollow") || directives.has("none")) {
142
+ warnings.push(`Page is set to nofollow (robots: "${robots}") — search engines are told not to follow its links.`);
143
+ }
144
+ return warnings;
145
+ }
146
+ function collectMainLandmarkIssues(mainCount) {
147
+ if (mainCount == null || mainCount === 1) {
148
+ return { todos: [], issueKeys: [] };
149
+ }
150
+ const todo = buildMainLandmarkTodo(mainCount, "page");
151
+ if (mainCount === 0) {
152
+ return {
153
+ todos: todo ? [todo] : [],
154
+ issueKeys: ["main-missing"],
155
+ };
156
+ }
157
+ return {
158
+ todos: todo ? [todo] : [],
159
+ issueKeys: ["main-multiple"],
160
+ };
161
+ }
162
+ const WARNING_LABELS = {
163
+ noindex: "noindex",
164
+ nofollow: "nofollow",
165
+ "missing-template": "missing template meta",
166
+ "main-missing": "missing main landmark",
167
+ "main-multiple": "multiple main landmarks",
168
+ };
169
+ const IMAGE_TIER1_IDS = new Set(["missing-alt", "empty-alt", "generic-alt"]);
170
+ const TIER1_LABELS = {
171
+ "missing-alt": "missing image alt",
172
+ "empty-alt": "empty image alt",
173
+ "generic-alt": "generic image alt",
174
+ "heading-skip": "heading hierarchy skip",
175
+ "multiple-h1": "multiple h1",
176
+ "unlabeled-input": "unlabeled form input",
177
+ "missing-lang": "missing html lang (a11y)",
178
+ "positive-tabindex": "positive tabindex",
179
+ };
180
+ const TIER1_SCOPE_TODOS = {
181
+ "missing-alt": {
182
+ page: "Add alt text to images on page",
183
+ template: "Add alt text to images in template",
184
+ site: "Add alt text to images in shared components",
185
+ },
186
+ "empty-alt": {
187
+ page: "Review empty alt attributes on page images",
188
+ template: "Review empty alt attributes on template images",
189
+ site: "Review empty alt attributes on shared images",
190
+ },
191
+ "generic-alt": {
192
+ page: "Replace generic image alt text on page",
193
+ template: "Replace generic image alt text in template",
194
+ site: "Replace generic image alt text in shared components",
195
+ },
196
+ "heading-skip": {
197
+ page: "Fix heading hierarchy skip on page",
198
+ template: "Fix heading hierarchy skip in template",
199
+ site: "Fix heading hierarchy skip in shared layout",
200
+ },
201
+ "multiple-h1": {
202
+ page: "Use a single h1 on page",
203
+ template: "Use a single h1 in template",
204
+ site: "Use a single h1 in shared layout",
205
+ },
206
+ "unlabeled-input": {
207
+ page: "Add labels to form inputs on page",
208
+ template: "Add labels to form inputs in template",
209
+ site: "Add labels to form inputs in shared components",
210
+ },
211
+ "missing-lang": {
212
+ page: "Add lang attribute to html element on page",
213
+ template: "Add lang attribute to html element in template",
214
+ site: "Add lang attribute to html element in site layout",
215
+ },
216
+ "positive-tabindex": {
217
+ page: "Remove positive tabindex values on page",
218
+ template: "Remove positive tabindex values in template",
219
+ site: "Remove positive tabindex values in shared layout",
220
+ },
221
+ };
222
+ const PAGE_TODO_MESSAGES = {
223
+ h1: "Add h1 to page",
224
+ title: "Add title element to page head",
225
+ metaTitle: "Add meta title to page head",
226
+ metaDescription: "Add meta description tag to page head",
227
+ charset: "Add charset meta tag to page head",
228
+ language: "Add lang attribute to html element",
229
+ colorScheme: "Add color-scheme meta tag to page head",
230
+ themeColor: "Add theme-color meta tag to page head",
231
+ doctype: "Add doctype to page",
232
+ viewport: "Add viewport meta tag to page head",
233
+ canonical: "Add canonical link to page head",
234
+ favicon: "Add favicon link to page head",
235
+ icons: "Add icon links to page head",
236
+ };
237
+ const OG_FIELD_LABELS = {
238
+ "og.title": "og:title",
239
+ "og.description": "og:description",
240
+ "og.image": "og:image",
241
+ "og.url": "og:url",
242
+ "og.type": "og:type",
243
+ "og.siteName": "og:site_name",
244
+ };
245
+ const TWITTER_FIELD_LABELS = {
246
+ "twitter.card": "twitter:card",
247
+ "twitter.title": "twitter:title",
248
+ "twitter.description": "twitter:description",
249
+ "twitter.image": "twitter:image",
250
+ };
251
+ function scopedHead(scope) {
252
+ if (scope === "site")
253
+ return "site layout head";
254
+ if (scope === "template")
255
+ return "template head";
256
+ return "page head";
257
+ }
258
+ function pageTodoForField(field, scope = "page") {
259
+ const message = PAGE_TODO_MESSAGES[field] ?? `Add ${field} to page`;
260
+ if (scope === "page")
261
+ return message;
262
+ if (scope === "site") {
263
+ return message
264
+ .replace(/ to page head/g, " to site layout head")
265
+ .replace(/ to page/g, " in site layout")
266
+ .replace(/ to html element/g, " on html element in site layout");
267
+ }
268
+ return message
269
+ .replace(/ to page head/g, " to template head")
270
+ .replace(/ to page/g, " to template")
271
+ .replace(/ to html element/g, " in template");
272
+ }
273
+ function buildBundledFieldTodos(missingFields, scope) {
274
+ const ogFields = [];
275
+ const twitterFields = [];
276
+ const otherFields = [];
277
+ for (const field of missingFields) {
278
+ if (field.startsWith("og.")) {
279
+ ogFields.push(field);
280
+ }
281
+ else if (field.startsWith("twitter.")) {
282
+ twitterFields.push(field);
283
+ }
284
+ else {
285
+ otherFields.push(field);
286
+ }
287
+ }
288
+ const head = scopedHead(scope);
289
+ const todos = [];
290
+ for (const field of otherFields) {
291
+ if (field === "fetch") {
292
+ if (scope === "page")
293
+ todos.push("- [ ] Resolve page fetch error");
294
+ else if (scope === "template")
295
+ todos.push("- [ ] Fix fetch handling in template");
296
+ else
297
+ todos.push("- [ ] Fix fetch handling in site routing or server config");
298
+ }
299
+ else {
300
+ todos.push(`- [ ] ${pageTodoForField(field, scope)}`);
301
+ }
302
+ }
303
+ if (ogFields.length > 0) {
304
+ const labels = ogFields.map((field) => OG_FIELD_LABELS[field] ?? field).join(", ");
305
+ todos.push(`- [ ] Add Open Graph meta tags to ${head}: ${labels}`);
306
+ }
307
+ if (twitterFields.length > 0) {
308
+ const labels = twitterFields.map((field) => TWITTER_FIELD_LABELS[field] ?? field).join(", ");
309
+ todos.push(`- [ ] Add Twitter meta tags to ${head}: ${labels}`);
310
+ }
311
+ return todos;
312
+ }
313
+ function buildMainLandmarkTodo(mainCount, scope) {
314
+ if (mainCount == null || mainCount === 1)
315
+ return null;
316
+ if (mainCount === 0) {
317
+ if (scope === "site")
318
+ return "- [ ] Add main landmark to site layout";
319
+ if (scope === "template")
320
+ return "- [ ] Add main landmark to template";
321
+ return "- [ ] Add main landmark to page";
322
+ }
323
+ if (scope === "page")
324
+ return `- [ ] Ensure page has exactly one main landmark (found ${mainCount})`;
325
+ if (scope === "site")
326
+ return "- [ ] Ensure site layout renders exactly one main landmark";
327
+ return "- [ ] Ensure template renders exactly one main landmark";
328
+ }
329
+ function fieldsMissingOnAllPages(items) {
330
+ if (items.length === 0)
331
+ return [];
332
+ const total = items.length;
333
+ const fieldCounts = new Map();
334
+ for (const item of items) {
335
+ for (const field of item.missingFields) {
336
+ fieldCounts.set(field, (fieldCounts.get(field) ?? 0) + 1);
337
+ }
338
+ }
339
+ return [...fieldCounts.entries()].filter(([, count]) => count === total).map(([field]) => field);
340
+ }
341
+ function warningKeysOnAllPages(items) {
342
+ if (items.length === 0)
343
+ return [];
344
+ const total = items.length;
345
+ const keyCounts = new Map();
346
+ for (const item of items) {
347
+ for (const key of item.warningKeys) {
348
+ keyCounts.set(key, (keyCounts.get(key) ?? 0) + 1);
349
+ }
350
+ }
351
+ return [...keyCounts.entries()].filter(([, count]) => count === total).map(([key]) => key);
352
+ }
353
+ function tier1KeysOnAllPages(items) {
354
+ if (items.length === 0)
355
+ return [];
356
+ const total = items.length;
357
+ const keyCounts = new Map();
358
+ for (const item of items) {
359
+ for (const key of item.tier1IssueKeys) {
360
+ keyCounts.set(key, (keyCounts.get(key) ?? 0) + 1);
361
+ }
362
+ }
363
+ return [...keyCounts.entries()].filter(([, count]) => count === total).map(([key]) => key);
364
+ }
365
+ function countTier1IssueOccurrences(items) {
366
+ const counts = new Map();
367
+ for (const item of items) {
368
+ for (const key of item.tier1IssueKeys) {
369
+ counts.set(key, (counts.get(key) ?? 0) + 1);
370
+ }
371
+ }
372
+ return counts;
373
+ }
374
+ const SITE_WARNING_TODOS = {
375
+ noindex: "Remove noindex from shared robots meta tag in site layout",
376
+ nofollow: "Remove nofollow from shared robots meta tag in site layout",
377
+ "missing-template": "Add template meta tag to site layout head — helps flightcheck give better tailored reports",
378
+ };
379
+ const TEMPLATE_WARNING_TODOS = {
380
+ noindex: "Remove noindex from template robots meta tag",
381
+ nofollow: "Remove nofollow from template robots meta tag",
382
+ };
383
+ const PAGE_WARNING_TODOS = {
384
+ noindex: "Remove noindex from page robots meta tag",
385
+ nofollow: "Remove nofollow from page robots meta tag",
386
+ "missing-template": "Add template meta tag to page head — helps flightcheck give better tailored reports",
387
+ };
388
+ function buildWarningTodos(keys, scope) {
389
+ const messages = scope === "site" ? SITE_WARNING_TODOS : scope === "template" ? TEMPLATE_WARNING_TODOS : PAGE_WARNING_TODOS;
390
+ const todos = [];
391
+ for (const key of keys) {
392
+ if (key === "main-missing" || key === "main-multiple")
393
+ continue;
394
+ const message = messages[key];
395
+ if (message)
396
+ todos.push(`- [ ] ${message}`);
397
+ }
398
+ if (keys.includes("main-missing")) {
399
+ const todo = buildMainLandmarkTodo(0, scope);
400
+ if (todo)
401
+ todos.push(todo);
402
+ }
403
+ else if (keys.includes("main-multiple")) {
404
+ const todo = buildMainLandmarkTodo(2, scope);
405
+ if (todo)
406
+ todos.push(todo);
407
+ }
408
+ return todos;
409
+ }
410
+ function buildTier1Todos(keys, scope) {
411
+ const scopeKey = scope === "site" ? "site" : scope === "template" ? "template" : "page";
412
+ return keys
413
+ .map((key) => TIER1_SCOPE_TODOS[key]?.[scopeKey])
414
+ .filter((message) => Boolean(message))
415
+ .map((message) => `- [ ] ${message}`);
416
+ }
417
+ function buildImageIssueTodos(issues) {
418
+ return issues.map((issue) => `- [ ] ${issue.message}`);
419
+ }
420
+ function buildScopedFixTodos(items, scope, excludedFields = new Set(), excludedWarningKeys = new Set(), excludedTier1Keys = new Set()) {
421
+ const universalFields = fieldsMissingOnAllPages(items).filter((field) => !excludedFields.has(field));
422
+ const universalWarningKeys = warningKeysOnAllPages(items).filter((key) => !excludedWarningKeys.has(key));
423
+ const universalTier1Keys = tier1KeysOnAllPages(items).filter((key) => !excludedTier1Keys.has(key));
424
+ return [
425
+ ...buildBundledFieldTodos(universalFields, scope),
426
+ ...buildWarningTodos(universalWarningKeys, scope),
427
+ ...buildTier1Todos(universalTier1Keys, scope),
428
+ ];
429
+ }
430
+ function buildRemainingPageTodos(item, coveredFields, coveredWarningKeys, coveredTier1Keys) {
431
+ const remainingFields = item.missingFields.filter((field) => !coveredFields.has(field));
432
+ const remainingWarningKeys = item.warningKeys.filter((key) => !coveredWarningKeys.has(key));
433
+ const remainingTier1Keys = item.tier1IssueKeys.filter((key) => !coveredTier1Keys.has(key));
434
+ const remainingNonImageTier1Keys = remainingTier1Keys.filter((key) => !IMAGE_TIER1_IDS.has(key));
435
+ const todos = [...buildBundledFieldTodos(remainingFields, "page")];
436
+ for (const key of remainingWarningKeys) {
437
+ if (key === "main-missing" || key === "main-multiple")
438
+ continue;
439
+ const message = PAGE_WARNING_TODOS[key];
440
+ if (message)
441
+ todos.push(`- [ ] ${message}`);
442
+ }
443
+ if (remainingWarningKeys.includes("main-missing")) {
444
+ todos.push("- [ ] Add main landmark to page");
445
+ }
446
+ else if (remainingWarningKeys.includes("main-multiple")) {
447
+ const todo = buildMainLandmarkTodo(item.mainCount, "page");
448
+ if (todo)
449
+ todos.push(todo);
450
+ }
451
+ todos.push(...buildTier1Todos(remainingNonImageTier1Keys, "page"));
452
+ const uncoveredImageIssues = item.imageIssues.filter((issue) => !coveredTier1Keys.has(issue.id));
453
+ todos.push(...buildImageIssueTodos(uncoveredImageIssues));
454
+ return todos;
455
+ }
456
+ /**
457
+ * Structured counterpart to buildRemainingPageTodos: returns the open issues for a page
458
+ * after site-wide and template coverage is removed, split by surface so the manifest can
459
+ * route them to the right work package (SEO markup vs best-practice vs a11y/content).
460
+ * Landmarks stay in warningKeys (owned by best-practices) so they are not double-counted in a11y.
461
+ */
462
+ function buildRemainingPageData(item, coveredFields, coveredWarningKeys, coveredTier1Keys) {
463
+ const remainingFields = item.missingFields.filter((field) => !coveredFields.has(field));
464
+ const remainingWarningKeys = item.warningKeys.filter((key) => !coveredWarningKeys.has(key));
465
+ const remainingTier1Keys = item.tier1IssueKeys.filter((key) => !coveredTier1Keys.has(key));
466
+ const remainingNonImageTier1Keys = remainingTier1Keys.filter((key) => !IMAGE_TIER1_IDS.has(key));
467
+ const remainingA11yIssues = [];
468
+ for (const key of remainingNonImageTier1Keys) {
469
+ const message = TIER1_SCOPE_TODOS[key]?.page;
470
+ if (message)
471
+ remainingA11yIssues.push({ id: key, message });
472
+ }
473
+ for (const issue of item.imageIssues.filter((entry) => !coveredTier1Keys.has(entry.id))) {
474
+ remainingA11yIssues.push({ id: issue.id, message: issue.message });
475
+ }
476
+ return { remainingFields, remainingWarningKeys, remainingA11yIssues };
477
+ }
478
+ function collectTier1IssueKeys(record) {
479
+ const issues = [...(record.images?.issues ?? []), ...(record.a11yStatic?.issues ?? [])];
480
+ return [...new Set(issues.map((entry) => entry.id))];
481
+ }
482
+ function analyzeRecord(record) {
483
+ const missingFields = fieldSelectors
484
+ .filter((selector) => isMissingValue(selector.get(record)))
485
+ .map((selector) => selector.label);
486
+ if (record.error) {
487
+ missingFields.unshift("fetch");
488
+ }
489
+ const warnings = collectWarnings(record);
490
+ const { todos: mainLandmarkTodos, issueKeys: mainIssueKeys } = collectMainLandmarkIssues(record.mainCount);
491
+ const warningKeys = [...mainIssueKeys];
492
+ if (record.template.trim() === "") {
493
+ warningKeys.push("missing-template");
494
+ }
495
+ if (warnings.length > 0) {
496
+ const directives = parseRobotsDirectives(record.robots);
497
+ if (directives.has("noindex") || directives.has("none"))
498
+ warningKeys.push("noindex");
499
+ if (directives.has("nofollow") || directives.has("none"))
500
+ warningKeys.push("nofollow");
501
+ }
502
+ return {
503
+ url: record.url,
504
+ status: record.status,
505
+ template: record.template.trim(),
506
+ missingFields,
507
+ warnings,
508
+ warningKeys,
509
+ tier1IssueKeys: collectTier1IssueKeys(record),
510
+ imageIssues: record.images?.issues ?? [],
511
+ a11yStaticIssues: record.a11yStatic?.issues ?? [],
512
+ mainLandmarkTodos,
513
+ mainCount: record.mainCount,
514
+ note: record.note,
515
+ error: record.error,
516
+ };
517
+ }
518
+ function countFieldOccurrences(items) {
519
+ const counts = new Map();
520
+ for (const item of items) {
521
+ for (const field of item.missingFields) {
522
+ counts.set(field, (counts.get(field) ?? 0) + 1);
523
+ }
524
+ }
525
+ return counts;
526
+ }
527
+ function countWarningOccurrences(items) {
528
+ const counts = new Map();
529
+ for (const item of items) {
530
+ for (const key of item.warningKeys) {
531
+ counts.set(key, (counts.get(key) ?? 0) + 1);
532
+ }
533
+ }
534
+ return counts;
535
+ }
536
+ function resolveIssueScope(issueKey, count, totalPages, siteWideKeys, templateKeyMap) {
537
+ if (siteWideKeys.has(issueKey) || count === totalPages)
538
+ return "site-wide";
539
+ for (const [template, keys] of templateKeyMap) {
540
+ if (keys.has(issueKey))
541
+ return `template: ${template}`;
542
+ }
543
+ return "page-specific";
544
+ }
545
+ function buildCoverageSection(allItems, totalPages, siteWideFields, siteWideWarningKeys, siteWideTier1Keys, templateFieldMap, templateWarningKeyMap, templateTier1KeyMap) {
546
+ const fieldCounts = countFieldOccurrences(allItems);
547
+ const warningCounts = countWarningOccurrences(allItems);
548
+ const tier1Counts = countTier1IssueOccurrences(allItems);
549
+ const lines = ["## Issue coverage", "", "How widely each issue appears across scanned pages:", ""];
550
+ if (fieldCounts.size === 0 && warningCounts.size === 0 && tier1Counts.size === 0) {
551
+ lines.push("- No issues found.");
552
+ return lines;
553
+ }
554
+ const sortedFields = [...fieldCounts.entries()].sort((a, b) => b[1] - a[1]);
555
+ for (const [field, count] of sortedFields) {
556
+ const scope = resolveIssueScope(field, count, totalPages, siteWideFields, templateFieldMap);
557
+ lines.push(`- \`${field}\`: ${count}/${totalPages} pages (${scope})`);
558
+ }
559
+ const sortedWarnings = [...warningCounts.entries()].sort((a, b) => b[1] - a[1]);
560
+ for (const [key, count] of sortedWarnings) {
561
+ const label = WARNING_LABELS[key] ?? key;
562
+ const scope = resolveIssueScope(key, count, totalPages, siteWideWarningKeys, templateWarningKeyMap);
563
+ lines.push(`- \`${label}\`: ${count}/${totalPages} pages (${scope})`);
564
+ }
565
+ const sortedTier1 = [...tier1Counts.entries()].sort((a, b) => b[1] - a[1]);
566
+ for (const [key, count] of sortedTier1) {
567
+ const label = TIER1_LABELS[key] ?? key;
568
+ const scope = resolveIssueScope(key, count, totalPages, siteWideTier1Keys, templateTier1KeyMap);
569
+ lines.push(`- \`${label}\`: ${count}/${totalPages} pages (${scope})`);
570
+ }
571
+ return lines;
572
+ }
573
+ function formatTemplatesThatNeedFixes(templatesWithFixes, templateGroupCount, totalPages) {
574
+ if (templatesWithFixes.length > 0) {
575
+ return templatesWithFixes.map((template) => `\`${template}\``).join(", ");
576
+ }
577
+ if (totalPages > 0 && templateGroupCount === 0) {
578
+ return "Unavailable, template meta missing";
579
+ }
580
+ return "none";
581
+ }
582
+ function buildHowToUseSection(redirectOnly) {
583
+ const lines = ["## How to use this report", ""];
584
+ if (redirectOnly) {
585
+ lines.push("1. Fix redirect issues listed below (status code, Location target, HTTPS/HSTS).");
586
+ lines.push("2. Re-run flightcheck to confirm the redirect is healthy.");
587
+ lines.push("3. Run against the final destination URL if you need a full site crawl.");
588
+ }
589
+ else {
590
+ lines.push("1. Fix **site-wide** items once in shared layout/head.");
591
+ lines.push("2. Fix **template** items in each template file.");
592
+ lines.push("3. Fix **per-page** items in page content or route-specific files.");
593
+ lines.push("4. Re-run flightcheck and confirm issue counts drop to zero.");
594
+ }
595
+ lines.push("");
596
+ return lines;
597
+ }
598
+ const BEST_PRACTICE_WARNING_KEYS = new Set(["noindex", "nofollow", "missing-template", "main-missing", "main-multiple"]);
599
+ function countCrawlIssueCategories(allItems, records) {
600
+ let seoIssueCount = 0;
601
+ let seoPageCount = 0;
602
+ let a11yIssueCount = 0;
603
+ let a11yPageCount = 0;
604
+ let bestPracticesIssueCount = 0;
605
+ let bestPracticesPageCount = 0;
606
+ let securityIssueCount = 0;
607
+ let securityPageCount = 0;
608
+ for (const item of allItems) {
609
+ if (item.missingFields.length > 0) {
610
+ seoIssueCount += item.missingFields.length;
611
+ seoPageCount += 1;
612
+ }
613
+ const a11yOnPage = item.imageIssues.length + item.a11yStaticIssues.length;
614
+ if (a11yOnPage > 0) {
615
+ a11yIssueCount += a11yOnPage;
616
+ a11yPageCount += 1;
617
+ }
618
+ const bestPracticeKeys = item.warningKeys.filter((key) => BEST_PRACTICE_WARNING_KEYS.has(key));
619
+ if (bestPracticeKeys.length > 0) {
620
+ bestPracticesIssueCount += bestPracticeKeys.length;
621
+ bestPracticesPageCount += 1;
622
+ }
623
+ }
624
+ for (const record of records) {
625
+ const secrets = record.security?.secrets.length ?? 0;
626
+ if (secrets > 0) {
627
+ securityIssueCount += secrets;
628
+ securityPageCount += 1;
629
+ }
630
+ }
631
+ return {
632
+ seo: { issueCount: seoIssueCount, pageCount: seoPageCount },
633
+ a11y: { issueCount: a11yIssueCount, pageCount: a11yPageCount },
634
+ bestPractices: { issueCount: bestPracticesIssueCount, pageCount: bestPracticesPageCount },
635
+ security: { issueCount: securityIssueCount, pageCount: securityPageCount },
636
+ };
637
+ }
638
+ export async function buildReportAnalysis(records, tier1 = {}, options = {}) {
639
+ const redirectOnly = options.redirectOnly ?? false;
640
+ const allItems = records.map(analyzeRecord);
641
+ const totalPages = allItems.length;
642
+ const crawlIssueCounts = countCrawlIssueCategories(allItems, records);
643
+ const siteWideFields = new Set(fieldsMissingOnAllPages(allItems));
644
+ const siteWideWarningKeys = new Set(warningKeysOnAllPages(allItems));
645
+ const siteWideTier1Keys = new Set(tier1KeysOnAllPages(allItems));
646
+ const siteWideFixTodos = buildScopedFixTodos(allItems, "site");
647
+ const templateGroups = new Map();
648
+ for (const item of allItems) {
649
+ if (item.template === "")
650
+ continue;
651
+ const group = templateGroups.get(item.template);
652
+ if (group)
653
+ group.push(item);
654
+ else
655
+ templateGroups.set(item.template, [item]);
656
+ }
657
+ const templateFieldMap = new Map();
658
+ const templateWarningKeyMap = new Map();
659
+ const templateTier1KeyMap = new Map();
660
+ for (const [template, items] of templateGroups) {
661
+ templateFieldMap.set(template, new Set(fieldsMissingOnAllPages(items).filter((field) => !siteWideFields.has(field))));
662
+ templateWarningKeyMap.set(template, new Set(warningKeysOnAllPages(items).filter((key) => !siteWideWarningKeys.has(key))));
663
+ templateTier1KeyMap.set(template, new Set(tier1KeysOnAllPages(items).filter((key) => !siteWideTier1Keys.has(key))));
664
+ }
665
+ const coveredFieldsByTemplate = new Map();
666
+ const coveredWarningKeysByTemplate = new Map();
667
+ const coveredTier1KeysByTemplate = new Map();
668
+ for (const [template, fields] of templateFieldMap) {
669
+ coveredFieldsByTemplate.set(template, new Set([...siteWideFields, ...fields]));
670
+ }
671
+ for (const [template, keys] of templateWarningKeyMap) {
672
+ coveredWarningKeysByTemplate.set(template, new Set([...siteWideWarningKeys, ...keys]));
673
+ }
674
+ for (const [template, keys] of templateTier1KeyMap) {
675
+ coveredTier1KeysByTemplate.set(template, new Set([...siteWideTier1Keys, ...keys]));
676
+ }
677
+ const pageFixes = [];
678
+ for (const item of allItems) {
679
+ const coveredFields = coveredFieldsByTemplate.get(item.template) ?? siteWideFields;
680
+ const coveredWarningKeys = coveredWarningKeysByTemplate.get(item.template) ?? siteWideWarningKeys;
681
+ const coveredTier1Keys = coveredTier1KeysByTemplate.get(item.template) ?? siteWideTier1Keys;
682
+ const remainingTodos = buildRemainingPageTodos(item, coveredFields, coveredWarningKeys, coveredTier1Keys);
683
+ const { remainingFields, remainingWarningKeys, remainingA11yIssues } = buildRemainingPageData(item, coveredFields, coveredWarningKeys, coveredTier1Keys);
684
+ const hasSecrets = records.some((record) => record.url === item.url && (record.security?.secrets.length ?? 0) > 0);
685
+ if (remainingTodos.length === 0 && !hasSecrets)
686
+ continue;
687
+ pageFixes.push({ item, remainingTodos, remainingFields, remainingWarningKeys, remainingA11yIssues });
688
+ }
689
+ const securityPages = records
690
+ .map((record) => ({ url: record.url, secrets: record.security?.secrets ?? [] }))
691
+ .filter((entry) => entry.secrets.length > 0);
692
+ const stackProfile = options.stackProfile ??
693
+ (redirectOnly
694
+ ? { detections: [], tracking: { tagManager: [], analytics: [] } }
695
+ : aggregateStackFromRecords(records));
696
+ return {
697
+ allItems,
698
+ totalPages,
699
+ redirectOnly,
700
+ siteWideFields,
701
+ siteWideWarningKeys,
702
+ siteWideTier1Keys,
703
+ siteWideFixTodos,
704
+ templateGroups,
705
+ templateFieldMap,
706
+ templateWarningKeyMap,
707
+ templateTier1KeyMap,
708
+ coveredFieldsByTemplate,
709
+ coveredWarningKeysByTemplate,
710
+ coveredTier1KeysByTemplate,
711
+ pageFixes,
712
+ securityPages,
713
+ stackProfile,
714
+ crawlIssueCounts,
715
+ };
716
+ }
717
+ export async function buildMarkdown(inputPath, records, includeOnlyErrors, tier1 = {}, options = {}) {
718
+ const { buildStructuredReport, renderReportMarkdown } = await import("./structuredReport.js");
719
+ const report = await buildStructuredReport(inputPath, records, tier1, options);
720
+ if (!includeOnlyErrors)
721
+ return renderReportMarkdown(report);
722
+ return renderReportMarkdown({
723
+ ...report,
724
+ findings: report.findings.filter((finding) => finding.severity === "critical" || finding.severity === "error"),
725
+ });
726
+ }