@aexol/spectral 0.9.126 → 0.9.127

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 (64) hide show
  1. package/dist/agent/index.d.ts.map +1 -1
  2. package/dist/agent/index.js +5 -0
  3. package/dist/extensions/seo/analyzers/content.d.ts +29 -6
  4. package/dist/extensions/seo/analyzers/content.d.ts.map +1 -1
  5. package/dist/extensions/seo/analyzers/content.js +243 -8
  6. package/dist/extensions/seo/analyzers/crawler.d.ts.map +1 -1
  7. package/dist/extensions/seo/analyzers/crawler.js +133 -7
  8. package/dist/extensions/seo/analyzers/drift.d.ts.map +1 -1
  9. package/dist/extensions/seo/analyzers/drift.js +86 -29
  10. package/dist/extensions/seo/analyzers/images.d.ts +10 -4
  11. package/dist/extensions/seo/analyzers/images.d.ts.map +1 -1
  12. package/dist/extensions/seo/analyzers/images.js +153 -5
  13. package/dist/extensions/seo/analyzers/performance.d.ts +3 -3
  14. package/dist/extensions/seo/analyzers/performance.d.ts.map +1 -1
  15. package/dist/extensions/seo/analyzers/performance.js +77 -6
  16. package/dist/extensions/seo/analyzers/readability.d.ts.map +1 -1
  17. package/dist/extensions/seo/analyzers/readability.js +17 -9
  18. package/dist/extensions/seo/analyzers/schema.d.ts.map +1 -1
  19. package/dist/extensions/seo/analyzers/schema.js +99 -10
  20. package/dist/extensions/seo/analyzers/sitemap.d.ts.map +1 -1
  21. package/dist/extensions/seo/analyzers/sitemap.js +57 -16
  22. package/dist/extensions/seo/analyzers/technical.d.ts +0 -7
  23. package/dist/extensions/seo/analyzers/technical.d.ts.map +1 -1
  24. package/dist/extensions/seo/analyzers/technical.js +408 -37
  25. package/dist/extensions/seo/config.d.ts +2 -0
  26. package/dist/extensions/seo/config.d.ts.map +1 -1
  27. package/dist/extensions/seo/config.js +2 -0
  28. package/dist/extensions/seo/tools/batch.js +1 -1
  29. package/dist/extensions/seo/tools/content.d.ts.map +1 -1
  30. package/dist/extensions/seo/tools/content.js +5 -3
  31. package/dist/extensions/seo/tools/full-audit.d.ts +1 -1
  32. package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -1
  33. package/dist/extensions/seo/tools/full-audit.js +391 -66
  34. package/dist/extensions/seo/tools/helpers.d.ts +15 -4
  35. package/dist/extensions/seo/tools/helpers.d.ts.map +1 -1
  36. package/dist/extensions/seo/tools/helpers.js +57 -6
  37. package/dist/extensions/seo/tools/performance.d.ts.map +1 -1
  38. package/dist/extensions/seo/tools/performance.js +17 -11
  39. package/dist/extensions/seo/tools/report.d.ts.map +1 -1
  40. package/dist/extensions/seo/tools/report.js +513 -47
  41. package/dist/extensions/seo/types.d.ts +295 -3
  42. package/dist/extensions/seo/types.d.ts.map +1 -1
  43. package/dist/extensions/seo/utils/fetcher.d.ts +4 -0
  44. package/dist/extensions/seo/utils/fetcher.d.ts.map +1 -1
  45. package/dist/extensions/seo/utils/fetcher.js +77 -11
  46. package/dist/extensions/seo/utils/parser.d.ts +3 -1
  47. package/dist/extensions/seo/utils/parser.d.ts.map +1 -1
  48. package/dist/extensions/seo/utils/parser.js +105 -25
  49. package/dist/extensions/seo/utils/renderer.d.ts +6 -1
  50. package/dist/extensions/seo/utils/renderer.d.ts.map +1 -1
  51. package/dist/extensions/seo/utils/renderer.js +207 -1
  52. package/dist/extensions/seo/utils/url-safety.d.ts.map +1 -1
  53. package/dist/extensions/seo/utils/url-safety.js +19 -3
  54. package/dist/extensions/web/tools/fetch.js +2 -2
  55. package/dist/memory/tools/compact-context.d.ts.map +1 -1
  56. package/dist/memory/tools/compact-context.js +10 -3
  57. package/dist/sdk/agent-core/agent-loop.d.ts.map +1 -1
  58. package/dist/sdk/agent-core/agent-loop.js +87 -4
  59. package/dist/sdk/agent-core/types.d.ts +1 -0
  60. package/dist/sdk/agent-core/types.d.ts.map +1 -1
  61. package/dist/sdk/ai/utils/validation.d.ts +0 -7
  62. package/dist/sdk/ai/utils/validation.d.ts.map +1 -1
  63. package/dist/sdk/ai/utils/validation.js +21 -1
  64. package/package.json +1 -1
@@ -1,27 +1,28 @@
1
- /**
2
- * seo_report tool — generate formatted reports from audit results.
3
- */
4
1
  import { mkdirSync, writeFileSync } from "node:fs";
5
- import { join } from "node:path";
2
+ import { resolve, relative, isAbsolute } from "node:path";
6
3
  import { errorResult, textResult } from "./helpers.js";
7
4
  export function registerReportTools(ext, config) {
8
5
  const reportTool = {
9
6
  name: "seo_report",
10
7
  label: "SEO Report Generator",
11
8
  description: "Generate a formatted SEO report from audit results. " +
12
- "Accepts a FullAuditResult object (from seo_audit) and produces a Markdown, JSON, or HTML report. " +
9
+ "Accepts a FullAuditResult object (from seo_audit) OR an array of FullAuditResult objects for batch reporting. " +
10
+ "Produces a Markdown, JSON, SARIF, or HTML report. " +
13
11
  "Optionally writes the report to a file in the SEO output directory (default: ~/.spectral/seo-reports/).",
14
- promptSnippet: "`seo_report { auditResult, format?, outputPath? }` — generate formatted SEO report",
12
+ promptSnippet: "`seo_report { auditResult, format?, outputPath? }` — generate formatted SEO report (single or batch)",
15
13
  parameters: {
16
14
  type: "object",
17
15
  properties: {
18
16
  auditResult: {
19
- type: "object",
20
- description: "The FullAuditResult object from seo_audit.",
17
+ oneOf: [
18
+ { type: "object", description: "A single FullAuditResult object from seo_audit." },
19
+ { type: "array", items: { type: "object" }, description: "An array of FullAuditResult objects for batch reporting." },
20
+ ],
21
+ description: "The FullAuditResult object (or array of them) from seo_audit.",
21
22
  },
22
23
  format: {
23
24
  type: "string",
24
- enum: ["markdown", "json", "html"],
25
+ enum: ["markdown", "json", "sarif", "html"],
25
26
  description: "Output format. Default: 'markdown'.",
26
27
  },
27
28
  outputPath: {
@@ -36,26 +37,57 @@ export function registerReportTools(ext, config) {
36
37
  try {
37
38
  const fmt = format ?? "markdown";
38
39
  let content;
39
- switch (fmt) {
40
- case "json":
41
- content = JSON.stringify(auditResult, null, 2);
42
- break;
43
- case "html":
44
- content = generateHtmlReport(auditResult);
45
- break;
46
- case "markdown":
47
- default:
48
- content = generateMarkdownReport(auditResult);
49
- break;
40
+ if (Array.isArray(auditResult)) {
41
+ switch (fmt) {
42
+ case "json":
43
+ content = JSON.stringify(auditResult, null, 2);
44
+ break;
45
+ case "sarif":
46
+ const allResults = Array.isArray(auditResult) ? auditResult : [auditResult];
47
+ content = JSON.stringify(generateSarifBatchReport(allResults, config), null, 2);
48
+ break;
49
+ case "html":
50
+ content = auditResult.map((r) => generateHtmlReport(r, config)).join("\n<hr>\n");
51
+ break;
52
+ case "markdown":
53
+ default:
54
+ content = generateMarkdownReport(auditResult, config);
55
+ break;
56
+ }
57
+ }
58
+ else {
59
+ // Cast to extended type for Phase 3B fields
60
+ const result = auditResult;
61
+ switch (fmt) {
62
+ case "json":
63
+ content = JSON.stringify(result, null, 2);
64
+ break;
65
+ case "sarif":
66
+ content = JSON.stringify(generateSarifReport(result), null, 2);
67
+ break;
68
+ case "html":
69
+ content = generateHtmlReport(result, config);
70
+ break;
71
+ case "markdown":
72
+ default:
73
+ content = generateMarkdownReport(result, config);
74
+ break;
75
+ }
50
76
  }
51
77
  let savedPath;
52
78
  if (outputPath) {
53
- const fullPath = outputPath.startsWith("/")
54
- ? outputPath
55
- : join(config.outputDir, outputPath);
56
- mkdirSync(join(fullPath, ".."), { recursive: true });
57
- writeFileSync(fullPath, content, "utf-8");
58
- savedPath = fullPath;
79
+ const resolvedOutputDir = resolve(config.outputDir);
80
+ const resolvedPath = isAbsolute(outputPath)
81
+ ? resolve(outputPath)
82
+ : resolve(config.outputDir, outputPath);
83
+ // Path traversal guard: resolved path must stay within outputDir
84
+ const rel = relative(resolvedOutputDir, resolvedPath);
85
+ if (rel.startsWith("..") || isAbsolute(rel)) {
86
+ throw new Error(`SECURITY: Output path must be within the configured output directory (${resolvedOutputDir})`);
87
+ }
88
+ mkdirSync(resolve(resolvedPath, ".."), { recursive: true });
89
+ writeFileSync(resolvedPath, content, "utf-8");
90
+ savedPath = resolvedPath;
59
91
  }
60
92
  const summary = `✓ Report generated (${fmt})\n` +
61
93
  (savedPath ? `Saved to: ${savedPath}\n` : "") +
@@ -73,20 +105,89 @@ export function registerReportTools(ext, config) {
73
105
  };
74
106
  ext.registerTool(reportTool);
75
107
  }
76
- function generateMarkdownReport(audit) {
108
+ // ---------------------------------------------------------------------------
109
+ // Report generators
110
+ // ---------------------------------------------------------------------------
111
+ function generateMarkdownReport(audit, config) {
112
+ if (Array.isArray(audit)) {
113
+ return generateBatchMarkdownReport(audit, config);
114
+ }
115
+ const esc = escapeMarkdown;
77
116
  const lines = [
78
117
  `# SEO Audit Report`,
79
118
  ``,
80
- `**URL:** ${audit.url}`,
81
- `**Date:** ${audit.auditedAt}`,
119
+ `**URL:** ${esc(audit.url)}`,
120
+ `**Date:** ${esc(audit.auditedAt)}`,
82
121
  `**Overall Score:** ${audit.overallScore}/100`,
83
122
  ``,
84
123
  ];
124
+ // --- Severity Summary ---
125
+ lines.push(...buildSeveritySummary(audit.allIssues));
126
+ lines.push(``);
127
+ // Unified score breakdown table
128
+ lines.push(`## Category Scores`);
129
+ lines.push(`| Category | Score | Status |`);
130
+ lines.push(`|----------|-------|--------|`);
131
+ if (audit.technical) {
132
+ lines.push(`| Technical SEO | ${audit.technical.score}/100 | ✅ |`);
133
+ }
134
+ if (audit.content) {
135
+ lines.push(`| Content Quality | ${audit.content.overallQuality}/100 | ✅ |`);
136
+ }
137
+ if (audit.schema) {
138
+ lines.push(`| Schema | ${audit.schema.score}/100 | ✅ |`);
139
+ }
140
+ if (audit.images) {
141
+ lines.push(`| Images | ${audit.images.score}/100 | ✅ |`);
142
+ }
143
+ if (audit.preload) {
144
+ lines.push(`| Preload | ${audit.preload.score}/100 | ✅ |`);
145
+ }
146
+ if (audit.agentUx) {
147
+ lines.push(`| Agent UX | ${audit.agentUx.agentUxScore}/100 | ✅ |`);
148
+ }
149
+ else if (audit.skippedCategories.includes("agentUx") || !audit.rendererAvailable) {
150
+ lines.push(`| Agent UX | — | ⏭️ Skipped |`);
151
+ }
152
+ if (audit.performance) {
153
+ const perfSource = audit.performance.source ? ` (${audit.performance.source})` : "";
154
+ lines.push(`| Performance | ${audit.performance.score}/100 | ✅${perfSource} |`);
155
+ }
156
+ lines.push(``);
157
+ // --- Reproducibility ---
158
+ lines.push(`## Reproducibility`, ``);
159
+ lines.push(`- **User Agent:** \`${esc(config.userAgent)}\``);
160
+ lines.push(`- **Viewport:** ${esc(config.defaultViewport)}`);
161
+ lines.push(`- **Timeout:** ${config.timeout}ms`);
162
+ lines.push(`- **Timestamp:** ${esc(audit.auditedAt)}`);
163
+ lines.push(`- **Renderer:** ${audit.rendererAvailable ? "Available (Playwright/Chromium)" : "Unavailable"}`);
164
+ lines.push(`- **Audit Version:** ${audit.version ?? 1}`);
165
+ lines.push(``);
166
+ // Skipped / Failed Categories
167
+ if (audit.skippedCategories.length > 0) {
168
+ lines.push(`## Skipped / Failed Categories`);
169
+ lines.push(``);
170
+ for (const cat of audit.skippedCategories) {
171
+ lines.push(`- **${esc(cat)}** — not analyzed in this audit`);
172
+ }
173
+ if (!audit.rendererAvailable) {
174
+ lines.push(``);
175
+ lines.push(`_Browser rendering was not available. Agent UX and measured performance data are unavailable._`);
176
+ }
177
+ lines.push(``);
178
+ }
179
+ else if (!audit.rendererAvailable) {
180
+ lines.push(`## Skipped / Failed Categories`);
181
+ lines.push(``);
182
+ lines.push(`_Browser rendering was not available. Agent UX and measured performance data are unavailable._`);
183
+ lines.push(``);
184
+ }
185
+ // --- Technical SEO ---
85
186
  if (audit.technical) {
86
187
  lines.push(`## Technical SEO`, `**Score:** ${audit.technical.score}/100`, ``);
87
188
  lines.push(`| Category | Score |`, `|----------|-------|`);
88
189
  for (const [cat, score] of Object.entries(audit.technical.categoryScores)) {
89
- lines.push(`| ${cat} | ${score}/100 |`);
190
+ lines.push(`| ${esc(cat)} | ${score}/100 |`);
90
191
  }
91
192
  lines.push(``);
92
193
  lines.push(`**HTTPS:** ${audit.technical.isHttps ? "✓" : "✗"}`);
@@ -94,49 +195,225 @@ function generateMarkdownReport(audit) {
94
195
  lines.push(`**robots.txt:** ${audit.technical.robotsTxt.exists ? "✓" : "✗"}`);
95
196
  lines.push(``);
96
197
  }
198
+ // --- Content Quality ---
97
199
  if (audit.content) {
98
- lines.push(`## Content Quality`, `**Overall Quality:** ${audit.content.overallQuality}/100`, ``, `- Word count: ${audit.content.wordCount}`, `- Filler score: ${audit.content.fillerScore}/100`, `- AI pattern score: ${audit.content.aiPatternScore}/100`, `- Information density: ${audit.content.informationDensity}/100`, `- Repetition score: ${audit.content.repetitionScore}/100`, `- Flags: ${audit.content.flags.join(", ") || "none"}`, ``);
200
+ lines.push(`## Content Quality`, `**Overall Quality:** ${audit.content.overallQuality}/100`, ``, `- Word count: ${audit.content.wordCount}`, `- Filler score: ${audit.content.fillerScore}/100`, `- AI pattern score: ${audit.content.aiPatternScore}/100`, `- Information density: ${audit.content.informationDensity}/100`, `- Repetition score: ${audit.content.repetitionScore}/100`, `- Flags: ${audit.content.flags.map((f) => esc(f)).join(", ") || "none"}`, ``);
99
201
  }
202
+ // --- Schema Markup ---
100
203
  if (audit.schema) {
101
- lines.push(`## Schema Markup`, `**Detected schemas:** ${audit.schema.detectedSchemas.length}`, `**Deprecated types:** ${audit.schema.deprecatedTypes.join(", ") || "none"}`, ``);
204
+ lines.push(`## Schema Markup`, `**Detected schemas:** ${audit.schema.detectedSchemas.length}`, `**Deprecated types:** ${audit.schema.deprecatedTypes.map((t) => esc(t)).join(", ") || "none"}`, ``);
102
205
  if (audit.schema.recommendations.length > 0) {
103
206
  lines.push(`**Recommendations:**`);
104
207
  for (const rec of audit.schema.recommendations) {
105
- lines.push(`- ${rec}`);
208
+ lines.push(`- ${esc(rec)}`);
106
209
  }
107
210
  lines.push(``);
108
211
  }
109
212
  }
213
+ // --- Images ---
110
214
  if (audit.images) {
111
215
  lines.push(`## Images`, `**Total:** ${audit.images.totalImages}`, `**Missing alt:** ${audit.images.missingAlt}`, `**Missing dimensions:** ${audit.images.missingDimensions}`, `**Modern formats:** ${audit.images.modernFormats}`, ``);
112
216
  }
217
+ // --- Preload & bfcache ---
113
218
  if (audit.preload) {
114
219
  lines.push(`## Preload & bfcache`, `**Score:** ${audit.preload.score}/100`, `**Speculation rules:** ${audit.preload.speculationRules.inlineBlocks} inline blocks`, `**Preload hints:** ${audit.preload.preloadHints}`, ``);
115
220
  }
221
+ // --- Agent UX ---
116
222
  if (audit.agentUx) {
117
223
  lines.push(`## Agent UX`, `**Score:** ${audit.agentUx.agentUxScore}/100`, `**Real buttons:** ${audit.agentUx.html.realButtons}`, `**Real anchors:** ${audit.agentUx.html.realAnchors}`, `**div onclick widgets:** ${audit.agentUx.html.divOnclickWidgets}`, ``);
118
224
  }
225
+ // --- Performance ---
226
+ if (audit.performance) {
227
+ const perf = audit.performance;
228
+ lines.push(`## Performance`, `**Score:** ${perf.score}/100`, ``);
229
+ lines.push(`- **LCP:** ${perf.lcp.value.toFixed(1)}ms (${perf.lcp.rating})` +
230
+ (perf.lcp.element
231
+ ? typeof perf.lcp.element === "string"
232
+ ? ` — element: \`${esc(perf.lcp.element)}\``
233
+ : ` — element: \`<${perf.lcp.element.tagName}>\`${perf.lcp.element.id ? ` #${perf.lcp.element.id}` : ""}${perf.lcp.element.src ? ` src=${perf.lcp.element.src}` : ""}${perf.lcp.element.size ? ` ${perf.lcp.element.size}` : ""}`
234
+ : ""));
235
+ lines.push(`- **CLS:** ${perf.cls.value.toFixed(3)} (${perf.cls.rating})`);
236
+ lines.push(`- **TBT:** ${perf.tbt.value.toFixed(1)}ms (${perf.tbt.rating})`);
237
+ lines.push(`- **FCP:** ${perf.fcp.value.toFixed(1)}ms (${perf.fcp.rating})`);
238
+ lines.push(`- **TTFB:** ${perf.ttfb.value.toFixed(1)}ms (${perf.ttfb.rating})`);
239
+ if (perf.source) {
240
+ lines.push(`- **Source:** ${perf.source}`);
241
+ }
242
+ lines.push(``);
243
+ if (perf.desktop) {
244
+ const d = perf.desktop;
245
+ lines.push(`### Desktop vs Mobile Comparison`, ``);
246
+ lines.push(`| Metric | Mobile | Desktop | Difference |`);
247
+ lines.push(`|--------|--------|---------|------------|`);
248
+ lines.push(`| LCP | ${perf.lcp.value.toFixed(1)}ms (${perf.lcp.rating}) | ${d.lcp.value.toFixed(1)}ms (${d.lcp.rating}) | ${diffMs(perf.lcp.value, d.lcp.value)} |`);
249
+ lines.push(`| CLS | ${perf.cls.value.toFixed(3)} (${perf.cls.rating}) | ${d.cls.value.toFixed(3)} (${d.cls.rating}) | ${diffMs(perf.cls.value, d.cls.value)} |`);
250
+ lines.push(`| TBT | ${perf.tbt.value.toFixed(1)}ms (${perf.tbt.rating}) | ${d.tbt.value.toFixed(1)}ms (${d.tbt.rating}) | ${diffMs(perf.tbt.value, d.tbt.value)} |`);
251
+ lines.push(``);
252
+ }
253
+ }
254
+ // --- Page Weight ---
255
+ if (audit.pageWeight) {
256
+ const pw = audit.pageWeight;
257
+ lines.push(`## Page Weight`, `**Total:** ${pw.totalKb.toFixed(1)} KB`, `**Resources:** ${pw.resourceCount}`, ``, `| Type | Size (KB) |`, `|------|-----------|`, `| HTML | ${pw.htmlKb.toFixed(1)} |`, `| CSS | ${pw.cssKb.toFixed(1)} |`, `| JS | ${pw.jsKb.toFixed(1)} |`, `| Images | ${pw.imageKb.toFixed(1)} |`, `| Fonts | ${pw.fontKb.toFixed(1)} |`, `| Other | ${pw.otherKb.toFixed(1)} |`, ``);
258
+ if (pw.topHeavyResources.length > 0) {
259
+ lines.push(`**Heaviest resources:**`);
260
+ for (const r of pw.topHeavyResources) {
261
+ lines.push(`- \`${esc(r.url)}\` — ${r.sizeKb.toFixed(1)} KB (${esc(r.type)})`);
262
+ }
263
+ lines.push(``);
264
+ }
265
+ }
266
+ // --- Render Blocking ---
267
+ if (audit.renderBlocking) {
268
+ const rb = audit.renderBlocking;
269
+ lines.push(`## Render Blocking`, `**Total blocking URLs:** ${rb.totalBlockingUrls}`, ``);
270
+ if (rb.blockingCss.length > 0) {
271
+ lines.push(`**Blocking CSS:**`);
272
+ for (const css of rb.blockingCss) {
273
+ lines.push(`- \`${esc(css.href)}\` (est. ${css.sizeEstimate} bytes)`);
274
+ }
275
+ lines.push(``);
276
+ }
277
+ if (rb.blockingJs.length > 0) {
278
+ lines.push(`**Blocking JS:**`);
279
+ for (const js of rb.blockingJs) {
280
+ lines.push(`- \`${esc(js.src)}\` (est. ${js.sizeEstimate} bytes)`);
281
+ }
282
+ lines.push(``);
283
+ }
284
+ if (rb.recommendations.length > 0) {
285
+ lines.push(`**Recommendations:**`);
286
+ for (const rec of rb.recommendations) {
287
+ lines.push(`- ${esc(rec)}`);
288
+ }
289
+ lines.push(``);
290
+ }
291
+ }
292
+ // --- Font Audit ---
293
+ if (audit.fontAudit) {
294
+ const fa = audit.fontAudit;
295
+ lines.push(`## Font Audit`, `**Score:** ${fa.score}/100`, `**Has font-display:** ${fa.hasFontDisplay ? "✓" : "✗"}`, `**Fonts found:** ${fa.fontsFound.length}`, ``);
296
+ if (fa.fontsFound.length > 0) {
297
+ lines.push(`| Family | Source | Format | font-display | Subset |`);
298
+ lines.push(`|--------|--------|--------|--------------|--------|`);
299
+ for (const f of fa.fontsFound) {
300
+ lines.push(`| ${esc(f.family)} | ${esc(f.source)} | ${esc(f.format)} | ${f.hasDisplay ? "✓" : "✗"} | ${f.isSubset ? "✓" : "✗"} |`);
301
+ }
302
+ lines.push(``);
303
+ }
304
+ if (fa.recommendations.length > 0) {
305
+ lines.push(`**Recommendations:**`);
306
+ for (const rec of fa.recommendations) {
307
+ lines.push(`- ${esc(rec)}`);
308
+ }
309
+ lines.push(``);
310
+ }
311
+ }
312
+ // --- Sitemap ---
313
+ if (audit.sitemap) {
314
+ const sm = audit.sitemap;
315
+ lines.push(`## Sitemap`, `**URL:** ${esc(sm.url)}`, `**URL count:** ${sm.urlCount}`, `**Size:** ${(sm.sizeBytes / 1024).toFixed(1)} KB`, `**Lastmod coverage:** ${(sm.lastmodCoverage * 100).toFixed(0)}%`, `**Invalid entries:** ${sm.invalidEntries}`, `**Is sitemap index:** ${sm.isSitemapIndex ? "Yes" : "No"}`, ``);
316
+ if (sm.warnings.length > 0) {
317
+ lines.push(`**Warnings:**`);
318
+ for (const w of sm.warnings) {
319
+ lines.push(`- ${esc(w)}`);
320
+ }
321
+ lines.push(``);
322
+ }
323
+ if (sm.childSitemaps.length > 0) {
324
+ lines.push(`**Child sitemaps:**`);
325
+ for (const cs of sm.childSitemaps) {
326
+ lines.push(`- ${esc(cs)}`);
327
+ }
328
+ lines.push(``);
329
+ }
330
+ }
331
+ // --- Issues ---
119
332
  if (audit.allIssues.length > 0) {
120
333
  lines.push(`## Issues (${audit.allIssues.length})`, ``);
121
334
  const sorted = audit.allIssues.sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
122
335
  for (const issue of sorted) {
123
- lines.push(`### [${issue.severity.toUpperCase()}] ${issue.title}`);
124
- lines.push(`**Category:** ${issue.category}`);
125
- lines.push(`**Description:** ${issue.description}`);
126
- lines.push(`**Recommendation:** ${issue.recommendation}`);
336
+ lines.push(`### [${issue.severity.toUpperCase()}] ${esc(issue.title)}`);
337
+ lines.push(`**Category:** ${esc(issue.category)}`);
338
+ lines.push(`**Description:** ${esc(issue.description)}`);
339
+ lines.push(`**Recommendation:** ${esc(issue.recommendation)}`);
127
340
  lines.push(``);
128
341
  }
129
342
  }
130
343
  return lines.join("\n");
131
344
  }
132
- function generateHtmlReport(audit) {
133
- const md = generateMarkdownReport(audit);
134
- // Simple HTML wrapper around markdown content.
345
+ function generateBatchMarkdownReport(audits, config) {
346
+ const esc = escapeMarkdown;
347
+ if (audits.length === 0)
348
+ return "# Batch SEO Report\n\nNo results provided.";
349
+ const lines = [
350
+ `# Batch SEO Report`,
351
+ ``,
352
+ `**Pages audited:** ${audits.length}`,
353
+ `**Generated:** ${new Date().toISOString()}`,
354
+ ``,
355
+ ];
356
+ const scores = audits.map((a) => a.overallScore);
357
+ const avgScore = Math.round(scores.reduce((a, b) => a + b, 0) / scores.length);
358
+ const minScore = Math.min(...scores);
359
+ const maxScore = Math.max(...scores);
360
+ lines.push(`## Summary`, ``, `- **Average score:** ${avgScore}/100`, `- **Range:** ${minScore} — ${maxScore}`, `- **Worst page:** ${esc(audits.find((a) => a.overallScore === minScore)?.url ?? "-")}`, `- **Best page:** ${esc(audits.find((a) => a.overallScore === maxScore)?.url ?? "-")}`, ``);
361
+ const allIssues = audits.flatMap((a) => a.allIssues);
362
+ const severityCounts = { critical: 0, high: 0, medium: 0, low: 0 };
363
+ for (const issue of allIssues) {
364
+ severityCounts[issue.severity] = (severityCounts[issue.severity] ?? 0) + 1;
365
+ }
366
+ lines.push(`## Aggregate Severity`, ``, `| Severity | Count |`, `|----------|-------|`, `| Critical | ${severityCounts.critical} |`, `| High | ${severityCounts.high} |`, `| Medium | ${severityCounts.medium} |`, `| Low | ${severityCounts.low} |`, `| **Total** | **${allIssues.length}** |`, ``);
367
+ // Skipped categories summary in batch
368
+ const pagesWithSkipped = audits.filter((a) => a.skippedCategories.length > 0);
369
+ if (pagesWithSkipped.length > 0) {
370
+ lines.push(`## Skipped / Failed Categories`, ``, `**${pagesWithSkipped.length}** of **${audits.length}** pages had skipped categories (e.g. renderer unavailable, fetch failed).`, ``);
371
+ }
372
+ lines.push(`## Per-Page Scores`, ``, `| URL | Score | Issues |`, `|-----|-------|--------|`);
373
+ const sorted = [...audits].sort((a, b) => b.overallScore - a.overallScore);
374
+ for (const a of sorted) {
375
+ lines.push(`| ${esc(a.url)} | ${a.overallScore}/100 | ${a.allIssues.length} |`);
376
+ }
377
+ lines.push(``);
378
+ const issueFreq = new Map();
379
+ for (const issue of allIssues) {
380
+ const key = issue.title.toLowerCase();
381
+ if (issueFreq.has(key)) {
382
+ issueFreq.get(key).count++;
383
+ }
384
+ else {
385
+ issueFreq.set(key, { title: issue.title, count: 1, severity: issue.severity });
386
+ }
387
+ }
388
+ const topIssues = [...issueFreq.values()]
389
+ .sort((a, b) => b.count - a.count)
390
+ .slice(0, 10);
391
+ if (topIssues.length > 0) {
392
+ lines.push(`## Top 10 Issues Across All Pages`, ``, `| Issue | Severity | Pages Affected |`, `|-------|----------|----------------|`);
393
+ for (const ti of topIssues) {
394
+ lines.push(`| ${esc(ti.title)} | ${ti.severity} | ${ti.count} |`);
395
+ }
396
+ lines.push(``);
397
+ }
398
+ return lines.join("\n");
399
+ }
400
+ function diffMs(a, b) {
401
+ const delta = a - b;
402
+ const sign = delta > 0 ? "+" : "";
403
+ const absDelta = Math.abs(delta);
404
+ if (absDelta < 1)
405
+ return `${sign}${delta.toFixed(3)}`;
406
+ if (absDelta < 10)
407
+ return `${sign}${delta.toFixed(1)}ms`;
408
+ return `${sign}${Math.round(delta)}ms`;
409
+ }
410
+ function generateHtmlReport(audit, config) {
411
+ const md = generateMarkdownReport(audit, config);
135
412
  return `<!DOCTYPE html>
136
413
  <html>
137
414
  <head>
138
415
  <meta charset="utf-8">
139
- <title>SEO Audit Report — ${audit.url}</title>
416
+ <title>SEO Audit Report — ${escapeHtml(audit.url)}</title>
140
417
  <style>
141
418
  body { font-family: system-ui, sans-serif; max-width: 900px; margin: 2rem auto; padding: 0 1rem; line-height: 1.6; }
142
419
  h1 { border-bottom: 2px solid #333; }
@@ -150,18 +427,207 @@ td, th { border: 1px solid #ddd; padding: 0.5rem; }
150
427
  </body>
151
428
  </html>`;
152
429
  }
430
+ function generateSarifReport(audit) {
431
+ return generateSarifSingleReport(audit, audit.allIssues ?? []);
432
+ }
433
+ function generateSarifSingleReport(audit, issues) {
434
+ // Build unique rules
435
+ const rules = [];
436
+ const seenRuleIds = new Set();
437
+ for (const issue of issues) {
438
+ const categorySlug = issue.category.toLowerCase().replace(/\s+/g, "-");
439
+ const ruleId = issue.issueId ?? `seo-${categorySlug}-${issue.severity}`;
440
+ if (!seenRuleIds.has(ruleId)) {
441
+ seenRuleIds.add(ruleId);
442
+ rules.push({
443
+ id: ruleId,
444
+ name: ruleId,
445
+ shortDescription: { text: `${issue.category}: ${issue.title}` },
446
+ });
447
+ }
448
+ }
449
+ // Build results with ruleIndex, properties, partialFingerprints
450
+ const results = issues.map((issue, i) => {
451
+ const categorySlug = issue.category.toLowerCase().replace(/\s+/g, "-");
452
+ const ruleId = issue.issueId ?? `seo-${categorySlug}-${issue.severity}`;
453
+ const ruleIndex = rules.findIndex((r) => r.id === ruleId);
454
+ return {
455
+ ruleId,
456
+ ruleIndex,
457
+ level: severityToSarifLevel(issue.severity),
458
+ message: { text: `${issue.title}: ${issue.description} \u2014 ${issue.recommendation}` },
459
+ locations: [{
460
+ physicalLocation: { artifactLocation: { uri: audit.url } },
461
+ }],
462
+ properties: {
463
+ category: issue.category,
464
+ severity: issue.severity,
465
+ score: audit.overallScore,
466
+ },
467
+ partialFingerprints: {
468
+ issueHash: `${ruleId}-${i}`,
469
+ },
470
+ };
471
+ });
472
+ return {
473
+ $schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
474
+ version: "2.1.0",
475
+ runs: [
476
+ {
477
+ tool: {
478
+ driver: {
479
+ name: "Spectral SEO Auditor",
480
+ version: "1.0.0",
481
+ semanticVersion: "1.0.0",
482
+ informationUri: "https://aexol.com",
483
+ rules,
484
+ },
485
+ },
486
+ invocation: {
487
+ executionSuccessful: true,
488
+ startTimeUtc: audit.auditedAt,
489
+ endTimeUtc: new Date().toISOString(),
490
+ arguments: [
491
+ `url=${audit.url}`,
492
+ ],
493
+ },
494
+ results,
495
+ },
496
+ ],
497
+ };
498
+ }
499
+ function generateSarifBatchReport(results, _config) {
500
+ const allIssues = results.flatMap((r) => r.allIssues ?? []);
501
+ // Build unique rules across all audits
502
+ const rules = [];
503
+ const seenRuleIds = new Set();
504
+ for (const issue of allIssues) {
505
+ const categorySlug = issue.category.toLowerCase().replace(/\s+/g, "-");
506
+ const ruleId = issue.issueId ?? `seo-${categorySlug}-${issue.severity}`;
507
+ if (!seenRuleIds.has(ruleId)) {
508
+ seenRuleIds.add(ruleId);
509
+ rules.push({
510
+ id: ruleId,
511
+ name: ruleId,
512
+ shortDescription: { text: `${issue.category}: ${issue.title}` },
513
+ });
514
+ }
515
+ }
516
+ const sarifResults = allIssues.map((issue, i) => {
517
+ const categorySlug = issue.category.toLowerCase().replace(/\s+/g, "-");
518
+ const ruleId = issue.issueId ?? `seo-${categorySlug}-${issue.severity}`;
519
+ const ruleIndex = rules.findIndex((r) => r.id === ruleId);
520
+ return {
521
+ ruleId,
522
+ ruleIndex,
523
+ level: severityToSarifLevel(issue.severity),
524
+ message: { text: `${issue.title}: ${issue.description} \u2014 ${issue.recommendation}` },
525
+ locations: [{
526
+ physicalLocation: { artifactLocation: { uri: results[0]?.url ?? "unknown" } },
527
+ }],
528
+ properties: {
529
+ category: issue.category,
530
+ severity: issue.severity,
531
+ },
532
+ partialFingerprints: {
533
+ issueHash: `${ruleId}-${i}`,
534
+ },
535
+ };
536
+ });
537
+ return {
538
+ $schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
539
+ version: "2.1.0",
540
+ runs: [
541
+ {
542
+ tool: {
543
+ driver: {
544
+ name: "Spectral SEO Auditor",
545
+ version: "1.0.0",
546
+ semanticVersion: "1.0.0",
547
+ informationUri: "https://aexol.com",
548
+ rules,
549
+ },
550
+ },
551
+ invocation: {
552
+ executionSuccessful: true,
553
+ startTimeUtc: results.reduce((earliest, r) => r.auditedAt < earliest ? r.auditedAt : earliest, results[0]?.auditedAt ?? new Date().toISOString()),
554
+ endTimeUtc: new Date().toISOString(),
555
+ arguments: results.map((r) => `url=${r.url}`),
556
+ },
557
+ results: sarifResults,
558
+ },
559
+ ],
560
+ };
561
+ }
562
+ // ---------------------------------------------------------------------------
563
+ // Helpers
564
+ // ---------------------------------------------------------------------------
565
+ function buildSeveritySummary(issues) {
566
+ const counts = { critical: 0, high: 0, medium: 0, low: 0 };
567
+ for (const issue of issues) {
568
+ counts[issue.severity] = (counts[issue.severity] ?? 0) + 1;
569
+ }
570
+ const total = issues.length;
571
+ return [
572
+ `## Severity Summary`,
573
+ ``,
574
+ `| Severity | Count |`,
575
+ `|----------|-------|`,
576
+ `| Critical | ${counts.critical} |`,
577
+ `| High | ${counts.high} |`,
578
+ `| Medium | ${counts.medium} |`,
579
+ `| Low | ${counts.low} |`,
580
+ `| **Total** | **${total}** |`,
581
+ ];
582
+ }
583
+ // toSarifResult removed — logic inlined in generateSarifSingleReport and generateSarifBatchReport
584
+ function severityToSarifLevel(s) {
585
+ switch (s) {
586
+ case "critical":
587
+ case "high":
588
+ return "error";
589
+ case "medium":
590
+ return "warning";
591
+ case "low":
592
+ return "note";
593
+ default:
594
+ return "none";
595
+ }
596
+ }
597
+ /** HTML-escape user-supplied text to prevent injection and Markdown breakage. */
153
598
  function escapeHtml(text) {
154
599
  return text
155
600
  .replace(/&/g, "&amp;")
156
601
  .replace(/</g, "&lt;")
157
- .replace(/>/g, "&gt;");
602
+ .replace(/>/g, "&gt;")
603
+ .replace(/"/g, "&quot;")
604
+ .replace(/'/g, "&#39;")
605
+ .replace(/=/g, "&#x3D;");
606
+ }
607
+ /** Escape user-supplied values for safe embedding in Markdown content. */
608
+ function escapeMarkdown(text) {
609
+ return escapeHtml(text)
610
+ .replace(/\|/g, "\\|")
611
+ .replace(/`/g, "\\`")
612
+ .replace(/\*/g, "\\*")
613
+ .replace(/_/g, "\\_")
614
+ .replace(/\[/g, "\\[")
615
+ .replace(/\]/g, "\\]")
616
+ .replace(/#/g, "\\#")
617
+ .replace(/!/g, "\\!")
618
+ .replace(/~/g, "\\~");
158
619
  }
159
620
  function severityRank(s) {
160
621
  switch (s) {
161
- case "critical": return 0;
162
- case "high": return 1;
163
- case "medium": return 2;
164
- case "low": return 3;
165
- default: return 4;
622
+ case "critical":
623
+ return 0;
624
+ case "high":
625
+ return 1;
626
+ case "medium":
627
+ return 2;
628
+ case "low":
629
+ return 3;
630
+ default:
631
+ return 4;
166
632
  }
167
633
  }