rankforge 0.3.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/src/report.mjs ADDED
@@ -0,0 +1,898 @@
1
+ const priorityRank = { P0: 0, P1: 1, P2: 2, P3: 3 };
2
+
3
+ const normalizeInline = (value, fallback = "n/a") => {
4
+ if (value === null || value === undefined || value === "") return fallback;
5
+ const normalized = String(value).replace(/\s+/g, " ").trim();
6
+ return normalized || fallback;
7
+ };
8
+
9
+ const escapeInline = (value, fallback = "n/a") => normalizeInline(value, fallback).replace(/\|/g, "\\|");
10
+
11
+ const escapeCell = (value, fallback = "n/a") => escapeInline(value, fallback);
12
+
13
+ const plural = (count, singular, pluralValue = `${singular}s`) => `${count} ${count === 1 ? singular : pluralValue}`;
14
+
15
+ const rowCount = (integration) => (Array.isArray(integration?.rows) ? integration.rows.length : 0);
16
+
17
+ const valuePresent = (value) => value !== null && value !== undefined && value !== "";
18
+
19
+ const sourceUrl = (source) => (typeof source === "string" ? source : source?.url);
20
+
21
+ const sourceId = (source) => (typeof source === "string" ? source : source?.id);
22
+
23
+ const dedupeBy = (items, keyFn) => {
24
+ const seen = new Set();
25
+ const deduped = [];
26
+
27
+ for (const item of items || []) {
28
+ const key = keyFn(item);
29
+ if (!valuePresent(key) || seen.has(key)) continue;
30
+ seen.add(key);
31
+ deduped.push(item);
32
+ }
33
+
34
+ return deduped;
35
+ };
36
+
37
+ const dedupeSources = (sources = []) => dedupeBy(sources, sourceUrl);
38
+
39
+ const formatSourceList = (sources = []) =>
40
+ dedupeSources(sources)
41
+ .map((source) => sourceUrl(source))
42
+ .filter(valuePresent)
43
+ .map((url) => escapeCell(url))
44
+ .join("; ") || "n/a";
45
+
46
+ const formatList = (values = []) =>
47
+ (Array.isArray(values) ? values : [values])
48
+ .filter(valuePresent)
49
+ .map((value) => escapeCell(value))
50
+ .join("; ") || "n/a";
51
+
52
+ const escapeHtml = (value, fallback = "n/a") =>
53
+ normalizeInline(value, fallback)
54
+ .replace(/&/g, "&")
55
+ .replace(/</g, "&lt;")
56
+ .replace(/>/g, "&gt;")
57
+ .replace(/"/g, "&quot;")
58
+ .replace(/'/g, "&#39;");
59
+
60
+ const htmlListText = (values = []) =>
61
+ (Array.isArray(values) ? values : [values])
62
+ .filter(valuePresent)
63
+ .map((value) => escapeHtml(value))
64
+ .join("; ") || "n/a";
65
+
66
+ const htmlAffectedUrls = (urls = []) => {
67
+ const affectedUrls = (Array.isArray(urls) ? urls : [urls]).filter(valuePresent);
68
+ if (!affectedUrls.length) return "n/a";
69
+
70
+ const visibleUrls = affectedUrls.slice(0, 3).map((url) => escapeHtml(url));
71
+ const omittedCount = affectedUrls.length - visibleUrls.length;
72
+ if (omittedCount > 0) visibleUrls.push(`(+${omittedCount} more)`);
73
+
74
+ return visibleUrls.join("; ");
75
+ };
76
+
77
+ const htmlSources = (sources = []) =>
78
+ dedupeSources(sources)
79
+ .map((source) => sourceUrl(source))
80
+ .filter(valuePresent)
81
+ .map((url) => {
82
+ const escapedUrl = escapeHtml(url);
83
+ return /^https?:\/\//i.test(url) ? `<a href="${escapedUrl}">${escapedUrl}</a>` : escapedUrl;
84
+ })
85
+ .join("; ") || "n/a";
86
+
87
+ const htmlRows = (rows) => rows.join("\n");
88
+
89
+ const formatAffectedUrls = (urls = []) => {
90
+ const affectedUrls = (Array.isArray(urls) ? urls : [urls]).filter(valuePresent);
91
+ if (!affectedUrls.length) return "n/a";
92
+
93
+ const visibleUrls = affectedUrls.slice(0, 3).map((url) => escapeCell(url));
94
+ const omittedCount = affectedUrls.length - visibleUrls.length;
95
+ if (omittedCount > 0) visibleUrls.push(`(+${omittedCount} more)`);
96
+
97
+ return visibleUrls.join("; ");
98
+ };
99
+
100
+ const findingRuleId = (finding) => normalizeInline(finding?.ruleId ?? finding?.id);
101
+
102
+ const firstAffectedUrl = (finding) => {
103
+ const urls = Array.isArray(finding?.affectedUrls) ? finding.affectedUrls : [];
104
+ return normalizeInline(urls[0], "");
105
+ };
106
+
107
+ const compareText = (left, right) => normalizeInline(left, "").localeCompare(normalizeInline(right, ""));
108
+
109
+ const compareFindings = (left, right) => {
110
+ const severityDelta = (priorityRank[left?.severity] ?? 9) - (priorityRank[right?.severity] ?? 9);
111
+ if (severityDelta !== 0) return severityDelta;
112
+
113
+ const ruleDelta = compareText(findingRuleId(left), findingRuleId(right));
114
+ if (ruleDelta !== 0) return ruleDelta;
115
+
116
+ const titleDelta = compareText(left?.title, right?.title);
117
+ if (titleDelta !== 0) return titleDelta;
118
+
119
+ return compareText(firstAffectedUrl(left), firstAffectedUrl(right));
120
+ };
121
+
122
+ const sortedPageFindings = (audit) => [...(audit.findings || [])].sort(compareFindings);
123
+
124
+ const highestSeverity = (findings) => {
125
+ const severity = findings
126
+ .map((finding) => finding.severity)
127
+ .filter((value) => Object.prototype.hasOwnProperty.call(priorityRank, value))
128
+ .sort((left, right) => priorityRank[left] - priorityRank[right])[0];
129
+
130
+ return severity || "n/a";
131
+ };
132
+
133
+ const titleizeDimension = (dimension) =>
134
+ normalizeInline(dimension)
135
+ .split(/[_\s-]+/)
136
+ .map((word) => (word ? `${word[0].toUpperCase()}${word.slice(1)}` : word))
137
+ .join(" ");
138
+
139
+ const hasMeasuredVisibility = (integrations = {}) =>
140
+ rowCount(integrations.searchConsole) > 0 || rowCount(integrations.serp) > 0 || rowCount(integrations.aiAnswers) > 0;
141
+
142
+ const evidenceType = (audit) => {
143
+ const labels = [];
144
+ const integrations = audit.integrations || {};
145
+
146
+ if (audit.repo) labels.push("source-repository audit");
147
+ if (hasMeasuredVisibility(integrations)) labels.push("includes measured visibility imports");
148
+ if (integrations.lighthouse) labels.push("includes imported performance evidence");
149
+
150
+ return labels.length ? labels.join("; ") : "readiness-only audit";
151
+ };
152
+
153
+ const formatCrawlScope = (audit) => {
154
+ const crawl = audit.run?.crawl ?? audit.crawl;
155
+ if (!crawl) return "n/a";
156
+
157
+ return `${escapeInline(crawl.mode)}, max ${escapeInline(crawl.maxPages)} pages, depth ${escapeInline(crawl.maxDepth)}`;
158
+ };
159
+
160
+ const formatMetric = (value, suffix = "") => (Number.isFinite(value) ? `${value}${suffix}` : "n/a");
161
+
162
+ const buildResult = (build) => {
163
+ if (!build) return "n/a";
164
+
165
+ const exitCode = valuePresent(build.exitCode) ? normalizeInline(build.exitCode) : "n/a";
166
+ const duration = valuePresent(build.durationMs) ? `${normalizeInline(build.durationMs)} ms` : "n/a";
167
+ return `exit ${exitCode} in ${duration}`;
168
+ };
169
+
170
+ const appendHeader = (lines, audit) => {
171
+ lines.push(
172
+ "# RankForge GEO/SEO Audit Report",
173
+ "",
174
+ `Target: ${escapeInline(audit.run?.target ?? audit.target ?? "unknown")}`,
175
+ `Generated: ${escapeInline(audit.run?.endedAt ?? new Date().toISOString())}`,
176
+ `Audit mode: ${escapeInline(audit.run?.mode)}`,
177
+ `Crawl scope: ${formatCrawlScope(audit)}`,
178
+ `Evidence type: ${evidenceType(audit)}`,
179
+ );
180
+ };
181
+
182
+ const appendExecutiveSummary = (lines, audit, findings) => {
183
+ const scoredDimensionCount = Object.keys(audit.scores || {}).length;
184
+ const auditedPageCount = Array.isArray(audit.pages) ? audit.pages.length : 0;
185
+ const affectedPageCount = new Set(findings.flatMap((finding) => finding.affectedUrls || []).filter(valuePresent)).size;
186
+ const sourceFindingCount = audit.repo?.sourceFindings?.length ?? 0;
187
+ const evidenceGapCount = audit.evidenceGaps?.length ?? 0;
188
+ const findingNoun = findings.length === 1 ? "finding" : "findings";
189
+ const dimensionNoun = scoredDimensionCount === 1 ? "dimension" : "dimensions";
190
+ const visibilityNote = hasMeasuredVisibility(audit.integrations || {})
191
+ ? "Measured visibility imports are present and are reported separately from readiness findings."
192
+ : "This report evaluates SEO/GEO readiness. It does not measure rankings, SERP positions, or AI-answer visibility unless imported evidence is present.";
193
+
194
+ lines.push(
195
+ "",
196
+ "## Executive Summary",
197
+ "",
198
+ `Found ${findings.length} deterministic ${findingNoun} across ${scoredDimensionCount} scored ${dimensionNoun}.`,
199
+ `Highest severity: ${highestSeverity(findings)}`,
200
+ `Audited pages: ${auditedPageCount}`,
201
+ `Affected pages: ${affectedPageCount}`,
202
+ `Repository source findings: ${sourceFindingCount}`,
203
+ `Evidence gaps: ${evidenceGapCount}`,
204
+ "",
205
+ visibilityNote,
206
+ );
207
+ };
208
+
209
+ const appendTopPriorities = (lines, findings) => {
210
+ lines.push("", "## Top Priorities", "");
211
+
212
+ const priorities = findings.slice(0, 5);
213
+ if (!priorities.length) {
214
+ lines.push("No top priorities.");
215
+ return;
216
+ }
217
+
218
+ for (const finding of priorities) {
219
+ const task = finding.implementationTask || {};
220
+ const nextAction = task.summary ?? finding.recommendation;
221
+ const affectedCount = Array.isArray(finding.affectedUrls) ? finding.affectedUrls.filter(valuePresent).length : 0;
222
+
223
+ lines.push(`- **${escapeInline(finding.severity)}** \`${escapeInline(findingRuleId(finding))}\` - ${escapeInline(finding.title)}`);
224
+ lines.push(` - Affected URLs: ${affectedCount}`);
225
+ lines.push(` - Impact: ${escapeInline(finding.impact)}`);
226
+ lines.push(` - Next action: ${escapeInline(nextAction)}`);
227
+ }
228
+ };
229
+
230
+ const appendFindingsByDimension = (lines, findings) => {
231
+ lines.push("", "## Findings By Dimension", "");
232
+
233
+ if (!findings.length) {
234
+ lines.push("No page findings.");
235
+ return;
236
+ }
237
+
238
+ const groups = new Map();
239
+ for (const finding of findings) {
240
+ const dimension = normalizeInline(finding.dimension, "uncategorized");
241
+ if (!groups.has(dimension)) groups.set(dimension, []);
242
+ groups.get(dimension).push(finding);
243
+ }
244
+
245
+ for (const dimension of [...groups.keys()].sort(compareText)) {
246
+ lines.push(`### ${titleizeDimension(dimension)}`, "");
247
+ lines.push("| Severity | Rule | Finding | Affected URLs | Evidence | Sources |");
248
+ lines.push("|---|---|---|---|---|---|");
249
+
250
+ for (const finding of [...groups.get(dimension)].sort(compareFindings)) {
251
+ lines.push(
252
+ `| ${escapeCell(finding.severity)} | ${escapeCell(findingRuleId(finding))} | ${escapeCell(finding.title)} | ${formatAffectedUrls(finding.affectedUrls)} | ${formatList(finding.evidence)} | ${formatSourceList(finding.sources)} |`,
253
+ );
254
+ }
255
+
256
+ lines.push("");
257
+ }
258
+ };
259
+
260
+ const appendScores = (lines, audit) => {
261
+ lines.push("", "## Scores", "");
262
+
263
+ const scoreEntries = Object.entries(audit.scores || {}).sort(([left], [right]) => compareText(left, right));
264
+ if (!scoreEntries.length) {
265
+ lines.push("No scored dimensions.");
266
+ return;
267
+ }
268
+
269
+ lines.push("| Dimension | Score | Findings |");
270
+ lines.push("|---|---:|---|");
271
+ for (const [dimension, score] of scoreEntries) {
272
+ lines.push(`| ${escapeCell(dimension)} | ${escapeCell(score?.score)} | ${formatList(score?.findings || [])} |`);
273
+ }
274
+ };
275
+
276
+ const actionOwner = (finding) => normalizeInline(finding.implementationTask?.owner ?? finding.owner, "Unassigned");
277
+
278
+ const actionEffort = (finding) => normalizeInline(finding.implementationTask?.effort ?? finding.effort);
279
+
280
+ const actionSummary = (finding) =>
281
+ normalizeInline(finding.implementationTask?.summary ?? finding.recommendation ?? finding.title);
282
+
283
+ const acceptanceCriteria = (finding) => formatList(finding.implementationTask?.acceptanceCriteria || []);
284
+
285
+ const appendDeveloperActionPlan = (lines, findings) => {
286
+ lines.push("", "## Developer Action Plan", "");
287
+
288
+ if (!findings.length) {
289
+ lines.push("No developer actions recorded.");
290
+ return;
291
+ }
292
+
293
+ const groups = new Map();
294
+ for (const finding of findings) {
295
+ const owner = actionOwner(finding);
296
+ if (!groups.has(owner)) groups.set(owner, []);
297
+ groups.get(owner).push(finding);
298
+ }
299
+
300
+ for (const [owner, ownerFindings] of groups) {
301
+ lines.push(`### ${escapeInline(owner)}`, "");
302
+
303
+ for (const finding of ownerFindings) {
304
+ lines.push(
305
+ `- **${escapeInline(finding.severity)}** \`${escapeInline(findingRuleId(finding))}\` - Effort: ${escapeInline(actionEffort(finding))} - ${escapeInline(actionSummary(finding))}`,
306
+ );
307
+ lines.push(` - Affected URLs: ${formatAffectedUrls(finding.affectedUrls)}`);
308
+ lines.push(` - Acceptance criteria: ${acceptanceCriteria(finding)}`);
309
+ }
310
+
311
+ lines.push("");
312
+ }
313
+ };
314
+
315
+ const appendRepositoryEvidence = (lines, repo) => {
316
+ if (!repo) return;
317
+
318
+ const routes = repo.routeSources || [];
319
+ const manifests = repo.frameworkManifests || [];
320
+ const sourceFindings = repo.sourceFindings || [];
321
+
322
+ lines.push("", "## Repository Audit Evidence", "");
323
+ lines.push(`- Path: ${escapeInline(repo.path)}`);
324
+ lines.push(`- Framework: ${escapeInline(repo.detectedFramework)}`);
325
+ lines.push(`- Package manager: ${escapeInline(repo.packageManager)}`);
326
+ lines.push(`- Static dir: ${escapeInline(repo.staticDirRelative ?? repo.staticDir)}`);
327
+ lines.push(`- Preview command: ${escapeInline(repo.previewCommand)}`);
328
+ lines.push(`- Preview URL: ${escapeInline(repo.previewUrl)}`);
329
+ if (repo.buildCommand) lines.push(`- Build command: ${escapeInline(repo.buildCommand)}`);
330
+ lines.push(`- Build executed: ${repo.build ? (repo.build.executed ? "yes" : "no") : "n/a"}`);
331
+ lines.push(`- Build result: ${buildResult(repo.build)}`);
332
+ lines.push(`- Route list: ${escapeInline(repo.routeList)}`);
333
+ lines.push(`- Route sources: ${routes.length}`);
334
+ lines.push(`- Framework manifests: ${manifests.length}`);
335
+ lines.push(`- Repository source findings: ${sourceFindings.length}`);
336
+
337
+ lines.push("", "### Repository Routes", "");
338
+ lines.push("| Type | Route | Source |");
339
+ lines.push("|---|---|---|");
340
+ for (const route of routes.slice(0, 20)) {
341
+ lines.push(`| ${escapeCell(route.type)} | ${escapeCell(route.route ?? route.path)} | ${escapeCell(route.path ?? route.source)} |`);
342
+ }
343
+ if (routes.length > 20) lines.push(`| omitted | (+${routes.length - 20} more) | n/a |`);
344
+
345
+ lines.push("", "### Framework Route Manifests", "");
346
+ lines.push("| Type | Routes | Path |");
347
+ lines.push("|---|---:|---|");
348
+ for (const manifest of manifests) {
349
+ const routesCount = Array.isArray(manifest.routes) ? manifest.routes.length : 0;
350
+ lines.push(`| ${escapeCell(manifest.type)} | ${routesCount} | ${escapeCell(manifest.path)} |`);
351
+ }
352
+
353
+ lines.push("", "### Repository Source Findings", "");
354
+ lines.push("| Severity | Source Finding | Message | Evidence | Recommendation |");
355
+ lines.push("|---|---|---|---|---|");
356
+ for (const finding of sourceFindings) {
357
+ lines.push(
358
+ `| ${escapeCell(finding.severity)} | ${escapeCell(finding.id)} | ${escapeCell(finding.message)} | ${escapeCell(finding.evidence)} | ${escapeCell(finding.recommendation)} |`,
359
+ );
360
+ }
361
+ };
362
+
363
+ const appendImportedMeasurements = (lines, integrations = {}) => {
364
+ lines.push("", "## Imported Measurements", "");
365
+
366
+ const hasMeasurements =
367
+ integrations.searchConsole || integrations.serp || integrations.aiAnswers || integrations.lighthouse;
368
+
369
+ if (!hasMeasurements) {
370
+ lines.push(
371
+ "No imported measurements. Ranking, SERP, AI-answer, and Lighthouse measurements are reported only when supplied as evidence imports.",
372
+ );
373
+ return;
374
+ }
375
+
376
+ lines.push(
377
+ integrations.searchConsole
378
+ ? `- Search Console: ${plural(rowCount(integrations.searchConsole), "row")} of observed query/page performance.`
379
+ : "- Search Console: not supplied.",
380
+ );
381
+ lines.push(
382
+ integrations.serp
383
+ ? `- SERP export: ${plural(rowCount(integrations.serp), "row")} of observed search-result evidence.`
384
+ : "- SERP export: not supplied.",
385
+ );
386
+ lines.push(
387
+ integrations.aiAnswers
388
+ ? `- AI-answer export: ${plural(rowCount(integrations.aiAnswers), "row")} of supplied AI-answer evidence.`
389
+ : "- AI-answer export: not supplied.",
390
+ );
391
+
392
+ if (integrations.lighthouse) {
393
+ const lighthouse = integrations.lighthouse;
394
+ const metrics = lighthouse.metrics || {};
395
+ const formFactor = lighthouse.formFactor ? ` (${escapeInline(lighthouse.formFactor)})` : "";
396
+ lines.push(
397
+ `Lighthouse: ${formatMetric(lighthouse.performanceScore, "/100")} performance score${formFactor}; LCP ${formatMetric(metrics.lcpMs, " ms")}; CLS ${formatMetric(metrics.cls)}; TBT ${formatMetric(metrics.tbtMs, " ms")}.`,
398
+ );
399
+ }
400
+ };
401
+
402
+ const appendEvidenceGaps = (lines, audit) => {
403
+ lines.push("", "## Evidence Gaps", "");
404
+
405
+ if (audit.evidenceGaps?.length) {
406
+ for (const gap of audit.evidenceGaps) lines.push(`- ${escapeInline(gap.id)}: ${escapeInline(gap.message)}`);
407
+ } else {
408
+ lines.push("No evidence gaps recorded.");
409
+ }
410
+
411
+ lines.push(
412
+ "",
413
+ "How to close common gaps:",
414
+ "- Add `--search-console`, `--serp`, or `--ai-answers` to report observed visibility.",
415
+ "- Add `--lighthouse` to report imported performance evidence.",
416
+ "- Increase crawl scope when important templates or page types are missing.",
417
+ "- Use trusted rendering when important content depends on client-side JavaScript.",
418
+ );
419
+ };
420
+
421
+ const appendSources = (lines, audit) => {
422
+ lines.push("", "## Sources", "");
423
+
424
+ const sources = dedupeSources(audit.sources || []);
425
+ if (!sources.length) {
426
+ lines.push("No sources recorded.");
427
+ return;
428
+ }
429
+
430
+ for (const source of sources) {
431
+ lines.push(`- ${escapeInline(sourceId(source))}: ${escapeInline(sourceUrl(source))}`);
432
+ }
433
+ };
434
+
435
+ const reportStyles = `
436
+ :root {
437
+ color-scheme: light;
438
+ --bg: #f7f8fa;
439
+ --panel: #ffffff;
440
+ --text: #162033;
441
+ --muted: #5d687a;
442
+ --line: #dde3ec;
443
+ --accent: #0f766e;
444
+ --accent-soft: #e6f5f2;
445
+ --danger: #b42318;
446
+ --warning: #b54708;
447
+ --info: #175cd3;
448
+ --radius: 8px;
449
+ }
450
+ * { box-sizing: border-box; }
451
+ body {
452
+ margin: 0;
453
+ background: var(--bg);
454
+ color: var(--text);
455
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
456
+ line-height: 1.5;
457
+ }
458
+ a { color: var(--info); overflow-wrap: anywhere; }
459
+ .report-shell { max-width: 1180px; margin: 0 auto; padding: 32px 20px 48px; }
460
+ .report-header {
461
+ display: grid;
462
+ gap: 16px;
463
+ border: 1px solid var(--line);
464
+ border-radius: var(--radius);
465
+ background: var(--panel);
466
+ padding: 24px;
467
+ }
468
+ .eyebrow {
469
+ margin: 0;
470
+ color: var(--accent);
471
+ font-size: 0.78rem;
472
+ font-weight: 700;
473
+ letter-spacing: 0;
474
+ text-transform: uppercase;
475
+ }
476
+ h1, h2, h3 { margin: 0; line-height: 1.2; letter-spacing: 0; }
477
+ h1 { font-size: clamp(2rem, 4vw, 3.4rem); }
478
+ h2 { font-size: 1.35rem; }
479
+ h3 { font-size: 1rem; }
480
+ .target { color: var(--muted); overflow-wrap: anywhere; }
481
+ .meta-grid, .summary-grid {
482
+ display: grid;
483
+ gap: 12px;
484
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
485
+ }
486
+ .meta-item, .summary-card {
487
+ border: 1px solid var(--line);
488
+ border-radius: var(--radius);
489
+ background: #fbfcfe;
490
+ padding: 12px;
491
+ }
492
+ .meta-label, .summary-label {
493
+ display: block;
494
+ color: var(--muted);
495
+ font-size: 0.78rem;
496
+ font-weight: 700;
497
+ text-transform: uppercase;
498
+ }
499
+ .summary-value { display: block; margin-top: 4px; font-size: 1.45rem; font-weight: 800; }
500
+ .report-section {
501
+ margin-top: 20px;
502
+ border: 1px solid var(--line);
503
+ border-radius: var(--radius);
504
+ background: var(--panel);
505
+ padding: 22px;
506
+ }
507
+ .section-intro { color: var(--muted); margin: 8px 0 0; }
508
+ .priority-list, .action-list, .gap-list, .measurement-list, .source-list { padding-left: 1.2rem; }
509
+ .priority-list li, .action-list li, .gap-list li, .measurement-list li, .source-list li { margin: 10px 0; }
510
+ .priority-card {
511
+ border: 1px solid var(--line);
512
+ border-radius: var(--radius);
513
+ padding: 14px;
514
+ margin-top: 12px;
515
+ }
516
+ .severity {
517
+ display: inline-flex;
518
+ align-items: center;
519
+ min-width: 2.4rem;
520
+ justify-content: center;
521
+ border-radius: 999px;
522
+ padding: 2px 8px;
523
+ color: #fff;
524
+ font-size: 0.78rem;
525
+ font-weight: 800;
526
+ }
527
+ .severity-P0, .severity-P1 { background: var(--danger); }
528
+ .severity-P2 { background: var(--warning); }
529
+ .severity-P3 { background: var(--info); }
530
+ .rule-id { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--muted); }
531
+ .table-wrap { overflow-x: auto; margin-top: 12px; }
532
+ table { width: 100%; border-collapse: collapse; font-size: 0.92rem; }
533
+ th, td { border-bottom: 1px solid var(--line); padding: 10px 8px; text-align: left; vertical-align: top; }
534
+ th { color: var(--muted); font-size: 0.76rem; text-transform: uppercase; }
535
+ .empty-state { color: var(--muted); }
536
+ .repo-facts { margin: 12px 0 0; padding: 0; display: grid; gap: 8px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
537
+ .repo-facts div { border: 1px solid var(--line); border-radius: var(--radius); padding: 10px; background: #fbfcfe; }
538
+ .repo-facts dt { color: var(--muted); font-size: 0.78rem; font-weight: 700; text-transform: uppercase; }
539
+ .repo-facts dd { margin: 4px 0 0; overflow-wrap: anywhere; }
540
+ `;
541
+
542
+ const htmlSection = (id, title, content, intro = "") => `
543
+ <section class="report-section" aria-labelledby="${id}-heading">
544
+ <h2 id="${id}-heading">${escapeHtml(title)}</h2>
545
+ ${intro ? `<p class="section-intro">${escapeHtml(intro)}</p>` : ""}
546
+ ${content}
547
+ </section>`;
548
+
549
+ const htmlSeverity = (severity) => {
550
+ const safeSeverity = escapeHtml(severity);
551
+ return `<span class="severity severity-${safeSeverity}">${safeSeverity}</span>`;
552
+ };
553
+
554
+ const htmlTable = (headers, rows, emptyText) => {
555
+ if (!rows.length) return `<p class="empty-state">${escapeHtml(emptyText)}</p>`;
556
+ return `
557
+ <div class="table-wrap">
558
+ <table>
559
+ <thead><tr>${headers.map((header) => `<th>${escapeHtml(header)}</th>`).join("")}</tr></thead>
560
+ <tbody>
561
+ ${htmlRows(rows.map((cells) => `<tr>${cells.map((cell) => `<td>${cell}</td>`).join("")}</tr>`))}
562
+ </tbody>
563
+ </table>
564
+ </div>`;
565
+ };
566
+
567
+ const htmlHeader = (audit) => {
568
+ const target = audit.run?.target ?? audit.target ?? "unknown";
569
+ return `
570
+ <header class="report-header">
571
+ <div>
572
+ <p class="eyebrow">RankForge GEO/SEO Audit Report</p>
573
+ <h1>RankForge GEO/SEO Audit Report</h1>
574
+ <p class="target">${escapeHtml(target)}</p>
575
+ </div>
576
+ <div class="meta-grid">
577
+ <div class="meta-item"><span class="meta-label">Generated</span>${escapeHtml(audit.run?.endedAt ?? new Date().toISOString())}</div>
578
+ <div class="meta-item"><span class="meta-label">Audit mode</span>${escapeHtml(audit.run?.mode)}</div>
579
+ <div class="meta-item"><span class="meta-label">Crawl scope</span>${escapeHtml(formatCrawlScope(audit))}</div>
580
+ <div class="meta-item"><span class="meta-label">Evidence type</span>${escapeHtml(evidenceType(audit))}</div>
581
+ </div>
582
+ </header>`;
583
+ };
584
+
585
+ const htmlExecutiveSummary = (audit, findings) => {
586
+ const scoredDimensionCount = Object.keys(audit.scores || {}).length;
587
+ const auditedPageCount = Array.isArray(audit.pages) ? audit.pages.length : 0;
588
+ const affectedPageCount = new Set(findings.flatMap((finding) => finding.affectedUrls || []).filter(valuePresent)).size;
589
+ const sourceFindingCount = audit.repo?.sourceFindings?.length ?? 0;
590
+ const evidenceGapCount = audit.evidenceGaps?.length ?? 0;
591
+ const visibilityNote = hasMeasuredVisibility(audit.integrations || {})
592
+ ? "Measured visibility imports are present and are reported separately from readiness findings."
593
+ : "This report evaluates SEO/GEO readiness. It does not measure rankings, SERP positions, or AI-answer visibility unless imported evidence is present.";
594
+
595
+ const cards = [
596
+ ["Findings", findings.length],
597
+ ["Highest severity", highestSeverity(findings)],
598
+ ["Audited pages", auditedPageCount],
599
+ ["Affected pages", affectedPageCount],
600
+ ["Repo source findings", sourceFindingCount],
601
+ ["Evidence gaps", evidenceGapCount],
602
+ ["Scored dimensions", scoredDimensionCount],
603
+ ];
604
+
605
+ return htmlSection(
606
+ "executive-summary",
607
+ "Executive Summary",
608
+ `
609
+ <div class="summary-grid">
610
+ ${cards
611
+ .map(
612
+ ([label, value]) =>
613
+ `<div class="summary-card"><span class="summary-label">${escapeHtml(label)}</span><span class="summary-value">${escapeHtml(value)}</span></div>`,
614
+ )
615
+ .join("")}
616
+ </div>
617
+ <p class="section-intro">${escapeHtml(visibilityNote)}</p>`,
618
+ );
619
+ };
620
+
621
+ const htmlTopPriorities = (findings) => {
622
+ const priorities = findings.slice(0, 5);
623
+ if (!priorities.length) return htmlSection("top-priorities", "Top Priorities", `<p class="empty-state">No top priorities.</p>`);
624
+
625
+ const cards = priorities
626
+ .map((finding) => {
627
+ const task = finding.implementationTask || {};
628
+ const nextAction = task.summary ?? finding.recommendation;
629
+ const affectedCount = Array.isArray(finding.affectedUrls) ? finding.affectedUrls.filter(valuePresent).length : 0;
630
+ return `
631
+ <article class="priority-card">
632
+ <h3>${htmlSeverity(finding.severity)} <span class="rule-id">${escapeHtml(findingRuleId(finding))}</span> - ${escapeHtml(finding.title)}</h3>
633
+ <p><strong>Affected URLs:</strong> ${escapeHtml(affectedCount)}</p>
634
+ <p><strong>Impact:</strong> ${escapeHtml(finding.impact)}</p>
635
+ <p><strong>Next action:</strong> ${escapeHtml(nextAction)}</p>
636
+ </article>`;
637
+ })
638
+ .join("");
639
+
640
+ return htmlSection("top-priorities", "Top Priorities", cards);
641
+ };
642
+
643
+ const htmlFindingsByDimension = (findings) => {
644
+ if (!findings.length) return htmlSection("findings-by-dimension", "Findings By Dimension", `<p class="empty-state">No page findings.</p>`);
645
+
646
+ const groups = new Map();
647
+ for (const finding of findings) {
648
+ const dimension = normalizeInline(finding.dimension, "uncategorized");
649
+ if (!groups.has(dimension)) groups.set(dimension, []);
650
+ groups.get(dimension).push(finding);
651
+ }
652
+
653
+ const content = [...groups.keys()]
654
+ .sort(compareText)
655
+ .map((dimension) => {
656
+ const rows = [...groups.get(dimension)].sort(compareFindings).map((finding) => [
657
+ htmlSeverity(finding.severity),
658
+ `<span class="rule-id">${escapeHtml(findingRuleId(finding))}</span>`,
659
+ escapeHtml(finding.title),
660
+ htmlAffectedUrls(finding.affectedUrls),
661
+ htmlListText(finding.evidence),
662
+ htmlSources(finding.sources),
663
+ ]);
664
+
665
+ return `
666
+ <h3>${escapeHtml(titleizeDimension(dimension))}</h3>
667
+ ${htmlTable(["Severity", "Rule", "Finding", "Affected URLs", "Evidence", "Sources"], rows, "No findings in this dimension.")}`;
668
+ })
669
+ .join("");
670
+
671
+ return htmlSection("findings-by-dimension", "Findings By Dimension", content);
672
+ };
673
+
674
+ const htmlScores = (audit) => {
675
+ const rows = Object.entries(audit.scores || {})
676
+ .sort(([left], [right]) => compareText(left, right))
677
+ .map(([dimension, score]) => [escapeHtml(dimension), escapeHtml(score?.score), htmlListText(score?.findings || [])]);
678
+
679
+ return htmlSection("scores", "Scores", htmlTable(["Dimension", "Score", "Findings"], rows, "No scored dimensions."));
680
+ };
681
+
682
+ const htmlDeveloperActionPlan = (findings) => {
683
+ if (!findings.length) {
684
+ return htmlSection("developer-action-plan", "Developer Action Plan", `<p class="empty-state">No developer actions recorded.</p>`);
685
+ }
686
+
687
+ const groups = new Map();
688
+ for (const finding of findings) {
689
+ const owner = actionOwner(finding);
690
+ if (!groups.has(owner)) groups.set(owner, []);
691
+ groups.get(owner).push(finding);
692
+ }
693
+
694
+ const content = [...groups.entries()]
695
+ .map(([owner, ownerFindings]) => {
696
+ const items = ownerFindings
697
+ .map(
698
+ (finding) => `
699
+ <li>
700
+ ${htmlSeverity(finding.severity)} <span class="rule-id">${escapeHtml(findingRuleId(finding))}</span>
701
+ - Effort: ${escapeHtml(actionEffort(finding))} - ${escapeHtml(actionSummary(finding))}
702
+ <br><strong>Affected URLs:</strong> ${htmlAffectedUrls(finding.affectedUrls)}
703
+ <br><strong>Acceptance criteria:</strong> ${escapeHtml(acceptanceCriteria(finding))}
704
+ </li>`,
705
+ )
706
+ .join("");
707
+ return `<h3>${escapeHtml(owner)}</h3><ul class="action-list">${items}</ul>`;
708
+ })
709
+ .join("");
710
+
711
+ return htmlSection("developer-action-plan", "Developer Action Plan", content);
712
+ };
713
+
714
+ const htmlRepositoryEvidence = (repo) => {
715
+ if (!repo) return "";
716
+
717
+ const routes = repo.routeSources || [];
718
+ const manifests = repo.frameworkManifests || [];
719
+ const sourceFindings = repo.sourceFindings || [];
720
+ const facts = [
721
+ ["Path", repo.path],
722
+ ["Framework", repo.detectedFramework],
723
+ ["Package manager", repo.packageManager],
724
+ ["Static dir", repo.staticDirRelative ?? repo.staticDir],
725
+ ["Preview command", repo.previewCommand],
726
+ ["Preview URL", repo.previewUrl],
727
+ ["Build command", repo.buildCommand],
728
+ ["Build executed", repo.build ? (repo.build.executed ? "yes" : "no") : "n/a"],
729
+ ["Build result", buildResult(repo.build)],
730
+ ["Route list", repo.routeList],
731
+ ["Route sources", routes.length],
732
+ ["Framework manifests", manifests.length],
733
+ ["Repository source findings", sourceFindings.length],
734
+ ];
735
+
736
+ const routeRows = routes.slice(0, 20).map((route) => [
737
+ escapeHtml(route.type),
738
+ escapeHtml(route.route ?? route.path),
739
+ escapeHtml(route.path ?? route.source),
740
+ ]);
741
+ if (routes.length > 20) routeRows.push(["omitted", escapeHtml(`(+${routes.length - 20} more)`), "n/a"]);
742
+
743
+ const manifestRows = manifests.map((manifest) => [
744
+ escapeHtml(manifest.type),
745
+ escapeHtml(Array.isArray(manifest.routes) ? manifest.routes.length : 0),
746
+ escapeHtml(manifest.path),
747
+ ]);
748
+
749
+ const sourceFindingRows = sourceFindings.map((finding) => [
750
+ htmlSeverity(finding.severity),
751
+ `<span class="rule-id">${escapeHtml(finding.id)}</span>`,
752
+ escapeHtml(finding.message),
753
+ escapeHtml(finding.evidence),
754
+ escapeHtml(finding.recommendation),
755
+ ]);
756
+
757
+ return htmlSection(
758
+ "repository-audit-evidence",
759
+ "Repository Audit Evidence",
760
+ `
761
+ <dl class="repo-facts">
762
+ ${facts.map(([label, value]) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`).join("")}
763
+ </dl>
764
+ <h3>Repository Routes</h3>
765
+ ${htmlTable(["Type", "Route", "Source"], routeRows, "No repository routes recorded.")}
766
+ <h3>Framework Route Manifests</h3>
767
+ ${htmlTable(["Type", "Routes", "Path"], manifestRows, "No framework route manifests recorded.")}
768
+ <h3>Repository Source Findings</h3>
769
+ ${htmlTable(["Severity", "Source Finding", "Message", "Evidence", "Recommendation"], sourceFindingRows, "No repository source findings recorded.")}`,
770
+ );
771
+ };
772
+
773
+ const htmlImportedMeasurements = (integrations = {}) => {
774
+ const hasMeasurements =
775
+ integrations.searchConsole || integrations.serp || integrations.aiAnswers || integrations.lighthouse;
776
+
777
+ if (!hasMeasurements) {
778
+ return htmlSection(
779
+ "imported-measurements",
780
+ "Imported Measurements",
781
+ `<p class="empty-state">No imported measurements. Ranking, SERP, AI-answer, and Lighthouse measurements are reported only when supplied as evidence imports.</p>`,
782
+ );
783
+ }
784
+
785
+ const items = [
786
+ integrations.searchConsole
787
+ ? `Search Console: ${plural(rowCount(integrations.searchConsole), "row")} of observed query/page performance.`
788
+ : "Search Console: not supplied.",
789
+ integrations.serp
790
+ ? `SERP export: ${plural(rowCount(integrations.serp), "row")} of observed search-result evidence.`
791
+ : "SERP export: not supplied.",
792
+ integrations.aiAnswers
793
+ ? `AI-answer export: ${plural(rowCount(integrations.aiAnswers), "row")} of supplied AI-answer evidence.`
794
+ : "AI-answer export: not supplied.",
795
+ ];
796
+
797
+ if (integrations.lighthouse) {
798
+ const lighthouse = integrations.lighthouse;
799
+ const metrics = lighthouse.metrics || {};
800
+ const formFactor = lighthouse.formFactor ? ` (${normalizeInline(lighthouse.formFactor)})` : "";
801
+ items.push(
802
+ `Lighthouse: ${formatMetric(lighthouse.performanceScore, "/100")} performance score${formFactor}; LCP ${formatMetric(metrics.lcpMs, " ms")}; CLS ${formatMetric(metrics.cls)}; TBT ${formatMetric(metrics.tbtMs, " ms")}.`,
803
+ );
804
+ }
805
+
806
+ return htmlSection(
807
+ "imported-measurements",
808
+ "Imported Measurements",
809
+ `<ul class="measurement-list">${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>`,
810
+ );
811
+ };
812
+
813
+ const htmlEvidenceGaps = (audit) => {
814
+ const gaps = audit.evidenceGaps?.length
815
+ ? audit.evidenceGaps.map((gap) => `<li><span class="rule-id">${escapeHtml(gap.id)}</span>: ${escapeHtml(gap.message)}</li>`).join("")
816
+ : "<li>No evidence gaps recorded.</li>";
817
+
818
+ return htmlSection(
819
+ "evidence-gaps",
820
+ "Evidence Gaps",
821
+ `
822
+ <ul class="gap-list">${gaps}</ul>
823
+ <p class="section-intro">How to close common gaps:</p>
824
+ <ul class="gap-list">
825
+ <li>Add <span class="rule-id">--search-console</span>, <span class="rule-id">--serp</span>, or <span class="rule-id">--ai-answers</span> to report observed visibility.</li>
826
+ <li>Add <span class="rule-id">--lighthouse</span> to report imported performance evidence.</li>
827
+ <li>Increase crawl scope when important templates or page types are missing.</li>
828
+ <li>Use trusted rendering when important content depends on client-side JavaScript.</li>
829
+ </ul>`,
830
+ );
831
+ };
832
+
833
+ const htmlSourceList = (audit) => {
834
+ const sources = dedupeSources(audit.sources || []);
835
+ const content = sources.length
836
+ ? `<ul class="source-list">${sources
837
+ .map((source) => {
838
+ const url = sourceUrl(source);
839
+ const escapedUrl = escapeHtml(url);
840
+ const linkedUrl = /^https?:\/\//i.test(url) ? `<a href="${escapedUrl}">${escapedUrl}</a>` : escapedUrl;
841
+ return `<li><span class="rule-id">${escapeHtml(sourceId(source))}</span>: ${linkedUrl}</li>`;
842
+ })
843
+ .join("")}</ul>`
844
+ : `<p class="empty-state">No sources recorded.</p>`;
845
+
846
+ return htmlSection("sources", "Sources", content);
847
+ };
848
+
849
+ export const generateHtmlReport = (audit) => {
850
+ const safeAudit = audit || {};
851
+ const findings = sortedPageFindings(safeAudit);
852
+ const target = safeAudit.run?.target ?? safeAudit.target ?? "unknown";
853
+ const title = `RankForge GEO/SEO Audit Report - ${normalizeInline(target)}`;
854
+
855
+ return `<!doctype html>
856
+ <html lang="en">
857
+ <head>
858
+ <meta charset="utf-8">
859
+ <meta name="viewport" content="width=device-width, initial-scale=1">
860
+ <title>${escapeHtml(title)}</title>
861
+ <style>${reportStyles}</style>
862
+ </head>
863
+ <body>
864
+ <main class="report-shell">
865
+ ${htmlHeader(safeAudit)}
866
+ ${htmlExecutiveSummary(safeAudit, findings)}
867
+ ${htmlTopPriorities(findings)}
868
+ ${htmlFindingsByDimension(findings)}
869
+ ${htmlScores(safeAudit)}
870
+ ${htmlDeveloperActionPlan(findings)}
871
+ ${htmlRepositoryEvidence(safeAudit.repo)}
872
+ ${htmlImportedMeasurements(safeAudit.integrations || {})}
873
+ ${htmlEvidenceGaps(safeAudit)}
874
+ ${htmlSourceList(safeAudit)}
875
+ </main>
876
+ </body>
877
+ </html>
878
+ `;
879
+ };
880
+
881
+ export const generateMarkdownReport = (audit) => {
882
+ const safeAudit = audit || {};
883
+ const findings = sortedPageFindings(safeAudit);
884
+ const lines = [];
885
+
886
+ appendHeader(lines, safeAudit);
887
+ appendExecutiveSummary(lines, safeAudit, findings);
888
+ appendTopPriorities(lines, findings);
889
+ appendFindingsByDimension(lines, findings);
890
+ appendScores(lines, safeAudit);
891
+ appendDeveloperActionPlan(lines, findings);
892
+ appendRepositoryEvidence(lines, safeAudit.repo);
893
+ appendImportedMeasurements(lines, safeAudit.integrations || {});
894
+ appendEvidenceGaps(lines, safeAudit);
895
+ appendSources(lines, safeAudit);
896
+
897
+ return `${lines.join("\n").replace(/\n{3,}/g, "\n\n")}\n`;
898
+ };