@aexol/spectral 0.9.126 → 0.9.128

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 (89) 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/dist/sdk/coding-agent/core/extensions/runner.d.ts.map +1 -1
  65. package/dist/sdk/coding-agent/core/extensions/runner.js +15 -1
  66. package/dist/sdk/coding-agent/core/tools/apply-patch.d.ts +7 -2
  67. package/dist/sdk/coding-agent/core/tools/apply-patch.d.ts.map +1 -1
  68. package/dist/sdk/coding-agent/core/tools/apply-patch.js +219 -41
  69. package/dist/sdk/coding-agent/core/tools/atomic-write.d.ts +30 -0
  70. package/dist/sdk/coding-agent/core/tools/atomic-write.d.ts.map +1 -0
  71. package/dist/sdk/coding-agent/core/tools/atomic-write.js +42 -0
  72. package/dist/sdk/coding-agent/core/tools/edit-diff.d.ts +10 -4
  73. package/dist/sdk/coding-agent/core/tools/edit-diff.d.ts.map +1 -1
  74. package/dist/sdk/coding-agent/core/tools/edit-diff.js +44 -24
  75. package/dist/sdk/coding-agent/core/tools/edit.d.ts +6 -0
  76. package/dist/sdk/coding-agent/core/tools/edit.d.ts.map +1 -1
  77. package/dist/sdk/coding-agent/core/tools/edit.js +31 -5
  78. package/dist/sdk/coding-agent/core/tools/file-mutation-queue.d.ts +9 -3
  79. package/dist/sdk/coding-agent/core/tools/file-mutation-queue.d.ts.map +1 -1
  80. package/dist/sdk/coding-agent/core/tools/file-mutation-queue.js +42 -3
  81. package/dist/sdk/coding-agent/core/tools/read.d.ts.map +1 -1
  82. package/dist/sdk/coding-agent/core/tools/read.js +79 -47
  83. package/dist/sdk/coding-agent/core/tools/truncate.d.ts +4 -2
  84. package/dist/sdk/coding-agent/core/tools/truncate.d.ts.map +1 -1
  85. package/dist/sdk/coding-agent/core/tools/truncate.js +43 -23
  86. package/dist/sdk/coding-agent/core/tools/write.d.ts +5 -8
  87. package/dist/sdk/coding-agent/core/tools/write.d.ts.map +1 -1
  88. package/dist/sdk/coding-agent/core/tools/write.js +29 -11
  89. package/package.json +1 -1
@@ -1,14 +1,15 @@
1
1
  import { auditTechnical } from "../analyzers/technical.js";
2
2
  import { analyzeContentQuality } from "../analyzers/content.js";
3
3
  import { auditSchema } from "../analyzers/schema.js";
4
- import { auditImages } from "../analyzers/images.js";
4
+ import { auditImages, checkImageUrls } from "../analyzers/images.js";
5
5
  import { auditPreload } from "../analyzers/preload.js";
6
6
  import { auditAgentUx } from "../analyzers/agent-ux.js";
7
+ import { calculatePerformanceFromFetch, analyzePageWeight } from "../analyzers/performance.js";
7
8
  import { fetchPage } from "../utils/fetcher.js";
8
9
  import { renderPage, isRendererAvailable } from "../utils/renderer.js";
9
10
  import { parsePage, extractText } from "../utils/parser.js";
10
- import { errorResult, textResult } from "./helpers.js";
11
- const ALL_CATEGORIES = ["technical", "content", "schema", "images", "preload", "agentUx"];
11
+ import { errorResult, textResult, validateToolUrl } from "./helpers.js";
12
+ const ALL_CATEGORIES = ["technical", "content", "schema", "images", "preload", "agentUx", "performance"];
12
13
  export function registerFullAuditTools(ext, config) {
13
14
  const fullAuditTool = {
14
15
  name: "seo_audit",
@@ -17,8 +18,9 @@ export function registerFullAuditTools(ext, config) {
17
18
  "content quality (filler, AI patterns, density), schema audit (detection, validation), image audit (alt, dimensions, formats), " +
18
19
  "preload audit (speculation rules, bfcache), and agent UX audit (accessibility, semantic HTML). " +
19
20
  "Returns an overall score and all issues prioritized by severity. " +
20
- "Use 'include' to run only specific sub-audits.",
21
- promptSnippet: "`seo_audit { url, include? }` full SEO audit (technical + content + schema + images + preload + agentUx)",
21
+ "Use 'include' to run only specific sub-audits. " +
22
+ "When 'performance' is included and a browser is available, real Web Vitals are measured via Playwright.",
23
+ promptSnippet: "`seo_audit { url, include? }` — full SEO audit (technical + content + schema + images + preload + agentUx + performance)",
22
24
  promptGuidelines: [
23
25
  "When the user asks about SEO, search optimization, page audits, schema markup, sitemaps, Core Web Vitals, or technical SEO for any website or URL, use the seo_* tools. Start with seo_audit for comprehensive analysis — it runs all sub-audits in one call. For specific concerns use the targeted tools (seo_technical_audit, seo_content_quality, seo_schema_audit, seo_image_audit, seo_preload_audit, seo_agent_ux_audit). These tools are offline-first and require no API keys.",
24
26
  ],
@@ -30,7 +32,7 @@ export function registerFullAuditTools(ext, config) {
30
32
  type: "array",
31
33
  items: {
32
34
  type: "string",
33
- enum: ["technical", "content", "schema", "images", "preload", "agentUx"],
35
+ enum: ["technical", "content", "schema", "images", "preload", "agentUx", "performance"],
34
36
  },
35
37
  description: "Sub-audits to include. If omitted, runs all sub-audits.",
36
38
  },
@@ -38,8 +40,9 @@ export function registerFullAuditTools(ext, config) {
38
40
  required: ["url"],
39
41
  },
40
42
  async execute(_toolCallId, params) {
41
- const { url, include } = params;
43
+ const { url: rawUrl, include } = params;
42
44
  try {
45
+ const url = validateToolUrl(rawUrl);
43
46
  const categories = include ?? ALL_CATEGORIES;
44
47
  const result = await runFullAudit(url, config, categories);
45
48
  const issueLines = result.allIssues
@@ -49,14 +52,18 @@ export function registerFullAuditTools(ext, config) {
49
52
  .join("\n");
50
53
  const summary = `✓ Full SEO Audit: ${result.url}\n` +
51
54
  `Audited At: ${result.auditedAt}\n` +
52
- `Overall Score: ${result.overallScore}/100\n\n` +
55
+ `Overall Score: ${result.overallScore}/100\n` +
56
+ `Coverage: ${result.coverage}% | Confidence: ${result.confidence}\n` +
57
+ `Skipped: ${result.skippedCategories.join(", ") || "none"}\n` +
53
58
  `Sub-audits run:\n` +
54
59
  ` Technical: ${result.technical ? `${result.technical.score}/100` : "skipped"}\n` +
55
60
  ` Content: ${result.content ? `${result.content.overallQuality}/100` : "skipped"}\n` +
56
61
  ` Schema: ${result.schema ? `${result.schema.detectedSchemas.length} blocks` : "skipped"}\n` +
57
62
  ` Images: ${result.images ? `${result.images.missingAlt} missing alt` : "skipped"}\n` +
58
63
  ` Preload: ${result.preload ? `${result.preload.score}/100` : "skipped"}\n` +
59
- ` Agent UX: ${result.agentUx ? `${result.agentUx.agentUxScore}/100` : "skipped"}\n\n` +
64
+ ` Agent UX: ${result.agentUx ? `${result.agentUx.agentUxScore}/100` : "skipped"}\n` +
65
+ ` Performance: ${result.performance ? `${result.performance.score}/100 (${result.performance.source ?? "heuristic"})` : "skipped"}\n` +
66
+ `Renderer: ${result.rendererAvailable ? "available" : "unavailable"}${result.renderComparison ? ` | Render diff: ${result.renderComparison.textDelta > 0 ? "+" : ""}${result.renderComparison.textDelta} words${result.renderComparison.isSpa ? " (SPA detected)" : ""}` : ""}\n\n` +
60
67
  `Top Issues (${result.allIssues.length} total):\n${issueLines || " (none)"}`;
61
68
  return textResult(summary, { result });
62
69
  }
@@ -70,89 +77,316 @@ export function registerFullAuditTools(ext, config) {
70
77
  async function runFullAudit(url, config, categories) {
71
78
  // Fetch the page once and reuse for all sub-audits.
72
79
  const fetchResult = await fetchPage(url, config);
73
- const pageData = parsePage(fetchResult.content, fetchResult.finalUrl);
74
80
  const allIssues = [];
75
- let scores = [];
76
- // Technical audit.
77
- let technical;
81
+ const skippedCategories = ALL_CATEGORIES.filter((category) => !categories.includes(category));
82
+ // Handle fetch failures as structured audit issues instead of generic errors.
83
+ if (fetchResult.error || fetchResult.statusCode === 0 || fetchResult.statusCode >= 400) {
84
+ const severity = fetchResult.statusCode >= 500 || fetchResult.statusCode === 0 ? "critical" : "high";
85
+ const title = fetchResult.statusCode === 0
86
+ ? `Fetch failed: ${fetchResult.error ?? "unknown error"}`
87
+ : `HTTP ${fetchResult.statusCode} ${fetchResult.statusText}`;
88
+ allIssues.push({
89
+ category: "Fetching",
90
+ severity,
91
+ title,
92
+ description: fetchResult.error
93
+ ? `Unable to fetch ${url}: ${fetchResult.error}`
94
+ : `Server returned ${fetchResult.statusCode} ${fetchResult.statusText}.`,
95
+ recommendation: fetchResult.statusCode >= 500
96
+ ? "Investigate server-side errors; the page may be temporarily unavailable."
97
+ : fetchResult.statusCode === 404
98
+ ? "The page was not found. Check the URL or restore the resource."
99
+ : fetchResult.statusCode === 0
100
+ ? "Verify DNS, network connectivity, and that the server is reachable."
101
+ : "Review server configuration and access controls.",
102
+ });
103
+ const summary = `SEO audit for ${url} — fetch failed (${fetchResult.statusCode || "error"}), ${allIssues.length} issues found.`;
104
+ return {
105
+ url,
106
+ auditedAt: new Date().toISOString(),
107
+ overallScore: 0,
108
+ coverage: 0,
109
+ confidence: "low",
110
+ skippedCategories: ALL_CATEGORIES,
111
+ rendererAvailable: false,
112
+ version: 2,
113
+ warnings: ["Fetch failed, sub-audits were not run."],
114
+ allIssues,
115
+ summary,
116
+ };
117
+ }
118
+ const pageData = parsePage(fetchResult.content, fetchResult.finalUrl);
119
+ // Concurrent sub-audit execution. Each sub-audit runs independently; a
120
+ // failure in one does not crash the others (Promise.allSettled).
121
+ const scores = [];
122
+ const controller = new AbortController();
123
+ const timeout = setTimeout(() => controller.abort(), config.timeout * 4);
124
+ const subAuditFns = [];
78
125
  if (categories.includes("technical")) {
79
- technical = await auditTechnical(url, config, pageData, fetchResult);
80
- allIssues.push(...technical.issues);
81
- scores.push(technical.score);
126
+ subAuditFns.push(async () => {
127
+ const technical = await auditTechnical(url, config, pageData, fetchResult);
128
+ return {
129
+ key: "technical",
130
+ score: technical.score,
131
+ issues: technical.issues,
132
+ result: technical,
133
+ };
134
+ });
82
135
  }
83
- // Content quality.
84
- let content;
85
136
  if (categories.includes("content")) {
86
- const text = extractText(fetchResult.content);
87
- content = analyzeContentQuality(text, fetchResult.finalUrl);
88
- scores.push(content.overallQuality);
137
+ subAuditFns.push(async () => {
138
+ const text = extractText(fetchResult.content);
139
+ const content = analyzeContentQuality(text, fetchResult.finalUrl, pageData);
140
+ return {
141
+ key: "content",
142
+ score: content.overallQuality,
143
+ issues: [],
144
+ result: content,
145
+ };
146
+ });
89
147
  }
90
- // Schema audit.
91
- let schema;
92
148
  if (categories.includes("schema")) {
93
- schema = auditSchema(fetchResult.content, fetchResult.finalUrl);
94
- // Convert schema recommendations to issues.
95
- for (const rec of schema.recommendations) {
96
- allIssues.push({
149
+ subAuditFns.push(async () => {
150
+ const schema = auditSchema(fetchResult.content, fetchResult.finalUrl);
151
+ const issues = schema.recommendations.map((rec) => ({
97
152
  category: "Schema",
98
153
  severity: "medium",
99
154
  title: rec,
100
155
  description: rec,
101
156
  recommendation: rec,
102
- });
103
- }
157
+ }));
158
+ return {
159
+ key: "schema",
160
+ score: schema.score,
161
+ issues,
162
+ result: schema,
163
+ };
164
+ });
104
165
  }
105
- // Image audit.
106
- let images;
107
166
  if (categories.includes("images")) {
108
- images = auditImages(fetchResult.content, fetchResult.finalUrl);
109
- if (images.missingAlt > 0) {
110
- allIssues.push({
111
- category: "Images",
112
- severity: "medium",
113
- title: `${images.missingAlt} images missing alt text`,
114
- description: `${images.missingAlt} of ${images.totalImages} images have no alt attribute.`,
115
- recommendation: "Add descriptive alt text to all informative images.",
116
- });
117
- }
118
- if (images.missingDimensions > 0) {
119
- allIssues.push({
120
- category: "Images",
121
- severity: "low",
122
- title: `${images.missingDimensions} images missing dimensions`,
123
- description: `${images.missingDimensions} images lack width/height attributes, causing layout shift.`,
124
- recommendation: "Add width and height attributes to prevent CLS.",
125
- });
126
- }
167
+ subAuditFns.push(async () => {
168
+ const images = auditImages(fetchResult.content, fetchResult.finalUrl, pageData);
169
+ const issues = [];
170
+ if (images.missingAlt > 0) {
171
+ issues.push({
172
+ category: "Images",
173
+ severity: "medium",
174
+ title: `${images.missingAlt} images missing alt text`,
175
+ description: `${images.missingAlt} of ${images.totalImages} images have no alt attribute.`,
176
+ recommendation: "Add descriptive alt text to all informative images.",
177
+ });
178
+ }
179
+ if (images.missingDimensions > 0) {
180
+ issues.push({
181
+ category: "Images",
182
+ severity: "low",
183
+ title: `${images.missingDimensions} images missing dimensions`,
184
+ description: `${images.missingDimensions} images lack width/height attributes, causing layout shift.`,
185
+ recommendation: "Add width and height attributes to prevent CLS.",
186
+ });
187
+ }
188
+ if (images.fetchpriorityIssues?.length) {
189
+ for (const fi of images.fetchpriorityIssues) {
190
+ issues.push({
191
+ category: "Images",
192
+ severity: "medium",
193
+ title: fi,
194
+ description: fi,
195
+ recommendation: "Set fetchpriority=\"high\" on the LCP image and avoid it on small images.",
196
+ });
197
+ }
198
+ }
199
+ if (images.ratioIssues?.length) {
200
+ issues.push({
201
+ category: "Images",
202
+ severity: "low",
203
+ title: `${images.ratioIssues.length} image(s) with extreme aspect ratios`,
204
+ description: `Extreme aspect ratios detected: ${images.ratioIssues.join("; ")}`,
205
+ recommendation: "Review images with extreme aspect ratios; they may appear distorted.",
206
+ });
207
+ }
208
+ if (images.responsiveCoverage !== undefined && images.responsiveCoverage < 50) {
209
+ issues.push({
210
+ category: "Images",
211
+ severity: "medium",
212
+ title: `Low responsive image coverage (${images.responsiveCoverage}%)`,
213
+ description: `Only ${images.responsiveImages ?? 0} of ${images.totalImages} images use srcset, sizes, or picture elements.`,
214
+ recommendation: "Add srcset and sizes attributes for responsive delivery.",
215
+ });
216
+ }
217
+ if (images.svgMissingTitle && images.svgMissingTitle > 0) {
218
+ issues.push({
219
+ category: "Images",
220
+ severity: "medium",
221
+ title: `${images.svgMissingTitle} SVG(s) missing accessible title`,
222
+ description: `${images.svgMissingTitle} inline SVGs lack a <title>, role="img", or aria-label.`,
223
+ recommendation: "Add <title>, <desc>, or role=\"img\" with aria-label to SVGs.",
224
+ });
225
+ }
226
+ // Check for broken image URLs
227
+ if (images.images.length > 0) {
228
+ const { brokenUrls } = await checkImageUrls(images.images, config);
229
+ for (const url of brokenUrls) {
230
+ issues.push({
231
+ category: "Images",
232
+ severity: "high",
233
+ title: `Broken image: ${url}`,
234
+ description: `Image returned a non-2xx status code: ${url}`,
235
+ recommendation: "Replace or remove the broken image.",
236
+ });
237
+ }
238
+ }
239
+ return {
240
+ key: "images",
241
+ score: images.score,
242
+ issues,
243
+ result: images,
244
+ };
245
+ });
127
246
  }
128
- // Preload audit.
129
- let preload;
130
247
  if (categories.includes("preload")) {
131
- preload = auditPreload(fetchResult);
132
- scores.push(preload.score);
133
- for (const rec of preload.recommendations) {
134
- allIssues.push({
248
+ subAuditFns.push(async () => {
249
+ const preload = auditPreload(fetchResult);
250
+ const issues = preload.recommendations.map((rec) => ({
135
251
  category: "Preload",
136
252
  severity: "medium",
137
253
  title: rec,
138
254
  description: rec,
139
255
  recommendation: rec,
140
- });
141
- }
256
+ }));
257
+ return {
258
+ key: "preload",
259
+ score: preload.score,
260
+ issues,
261
+ result: preload,
262
+ };
263
+ });
264
+ }
265
+ let performanceFn;
266
+ if (categories.includes("performance")) {
267
+ performanceFn = async () => {
268
+ const available = await isRendererAvailable();
269
+ if (!available) {
270
+ // Fall back to heuristic performance analysis.
271
+ const perf = calculatePerformanceFromFetch(fetchResult, fetchResult.finalUrl);
272
+ const weight = analyzePageWeight(fetchResult, fetchResult.finalUrl);
273
+ return {
274
+ key: "performance",
275
+ score: perf.score,
276
+ issues: [],
277
+ result: { performance: perf, pageWeight: weight },
278
+ };
279
+ }
280
+ try {
281
+ const [mobileRender, desktopRender] = await Promise.allSettled([
282
+ renderPage(url, config, {
283
+ viewport: "mobile",
284
+ capturePerformance: true,
285
+ captureA11y: false,
286
+ captureConsole: false,
287
+ blockResources: [],
288
+ timeout: config.timeout * 2,
289
+ signal: controller.signal,
290
+ }),
291
+ renderPage(url, config, {
292
+ viewport: "desktop",
293
+ capturePerformance: true,
294
+ captureA11y: false,
295
+ captureConsole: false,
296
+ blockResources: [],
297
+ timeout: config.timeout * 2,
298
+ signal: controller.signal,
299
+ }),
300
+ ]);
301
+ const mobileOk = mobileRender.status === "fulfilled";
302
+ const desktopOk = desktopRender.status === "fulfilled";
303
+ if (!mobileOk && !desktopOk) {
304
+ // Both renders failed — fall back to heuristic.
305
+ const perf = calculatePerformanceFromFetch(fetchResult, fetchResult.finalUrl);
306
+ const weight = analyzePageWeight(fetchResult, fetchResult.finalUrl);
307
+ return {
308
+ key: "performance",
309
+ score: perf.score,
310
+ issues: [],
311
+ result: { performance: perf, pageWeight: weight },
312
+ };
313
+ }
314
+ const mobileMeasured = mobileOk ? mobileRender.value.measuredPerformance : undefined;
315
+ const desktopMeasured = desktopOk ? desktopRender.value.measuredPerformance : undefined;
316
+ // Mobile-first SEO standard: main result = mobile
317
+ const perf = calculatePerformanceFromFetch(fetchResult, fetchResult.finalUrl, mobileMeasured);
318
+ let desktopPerf;
319
+ if (desktopMeasured) {
320
+ desktopPerf = calculatePerformanceFromFetch(fetchResult, fetchResult.finalUrl, desktopMeasured);
321
+ perf.desktop = desktopPerf;
322
+ }
323
+ const weight = analyzePageWeight(fetchResult, fetchResult.finalUrl, mobileMeasured);
324
+ // Weighted score: 60% mobile, 40% desktop
325
+ const mobileScore = perf.score;
326
+ const desktopScore = desktopPerf ? desktopPerf.score : mobileScore;
327
+ const weightedScore = Math.round(mobileScore * 0.6 + desktopScore * 0.4);
328
+ perf.score = weightedScore;
329
+ return {
330
+ key: "performance",
331
+ score: weightedScore,
332
+ issues: [],
333
+ result: { performance: perf, pageWeight: weight },
334
+ };
335
+ }
336
+ catch {
337
+ // Fall back to heuristic on any render failure.
338
+ const perf = calculatePerformanceFromFetch(fetchResult, fetchResult.finalUrl);
339
+ const weight = analyzePageWeight(fetchResult, fetchResult.finalUrl);
340
+ return {
341
+ key: "performance",
342
+ score: perf.score,
343
+ issues: [],
344
+ result: { performance: perf, pageWeight: weight },
345
+ };
346
+ }
347
+ };
142
348
  }
143
- // Agent UX audit (requires rendering).
144
- let agentUx;
349
+ let agentUxFn;
145
350
  if (categories.includes("agentUx")) {
146
- const available = await isRendererAvailable();
147
- if (available) {
351
+ agentUxFn = async () => {
352
+ const available = await isRendererAvailable();
353
+ if (!available) {
354
+ return {
355
+ key: "agentUx",
356
+ issues: [
357
+ {
358
+ category: "Agent UX",
359
+ severity: "low",
360
+ title: "Renderer unavailable — Agent UX audit skipped",
361
+ description: "Headless renderer is not available; agent UX audit could not run.",
362
+ recommendation: "Install or enable a headless browser to run the agent UX audit.",
363
+ },
364
+ ],
365
+ skipped: true,
366
+ result: { rendererAvailable: false },
367
+ };
368
+ }
148
369
  const renderResult = await renderPage(url, config, {
149
370
  captureA11y: true,
150
371
  captureConsole: false,
372
+ signal: controller.signal,
151
373
  });
152
- agentUx = auditAgentUx(renderResult);
153
- scores.push(agentUx.agentUxScore);
374
+ const comparison = compareStaticRendered(fetchResult, renderResult);
375
+ const issues = [];
376
+ if (comparison.isSpa || comparison.contentGrew) {
377
+ issues.push({
378
+ category: "Rendering",
379
+ severity: comparison.isSpa ? "high" : "medium",
380
+ title: comparison.isSpa
381
+ ? "Page appears to be a JavaScript-rendered SPA"
382
+ : "Rendered content differs significantly from static HTML",
383
+ description: comparison.notes.join(" ") || "Rendered DOM is significantly larger than static HTML.",
384
+ recommendation: "Ensure critical content is present in server-rendered HTML or use SSR/SSG for SEO-critical content.",
385
+ });
386
+ }
387
+ const agentUx = auditAgentUx(renderResult);
154
388
  for (const opp of agentUx.opportunities) {
155
- allIssues.push({
389
+ issues.push({
156
390
  category: "Agent UX",
157
391
  severity: "medium",
158
392
  title: opp,
@@ -160,22 +394,73 @@ async function runFullAudit(url, config, categories) {
160
394
  recommendation: opp,
161
395
  });
162
396
  }
397
+ return {
398
+ key: "agentUx",
399
+ score: agentUx.agentUxScore,
400
+ issues,
401
+ result: { agentUx, renderComparison: comparison, rendererAvailable: true },
402
+ };
403
+ };
404
+ }
405
+ const outcomes = await Promise.allSettled([...subAuditFns, ...(agentUxFn ? [agentUxFn] : []), ...(performanceFn ? [performanceFn] : [])].map((fn) => fn()));
406
+ clearTimeout(timeout);
407
+ const resultMap = new Map();
408
+ for (const outcome of outcomes) {
409
+ if (outcome.status === "fulfilled") {
410
+ const value = outcome.value;
411
+ resultMap.set(value.key, value);
412
+ allIssues.push(...value.issues);
413
+ if (typeof value.score === "number") {
414
+ scores.push(value.score);
415
+ }
416
+ }
417
+ else {
418
+ allIssues.push({
419
+ category: "Audit",
420
+ severity: "medium",
421
+ title: "Sub-audit failed",
422
+ description: `A sub-audit rejected: ${outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason)}`,
423
+ recommendation: "Review the failing sub-audit and re-run the full audit.",
424
+ });
163
425
  }
164
426
  }
427
+ const technical = resultMap.get("technical")?.result;
428
+ const content = resultMap.get("content")?.result;
429
+ const schema = resultMap.get("schema")?.result;
430
+ const images = resultMap.get("images")?.result;
431
+ const preload = resultMap.get("preload")?.result;
432
+ const agentUx = resultMap.get("agentUx")?.result;
433
+ const rendererAvailable = agentUx && "rendererAvailable" in agentUx ? agentUx.rendererAvailable : false;
434
+ const renderComparison = agentUx && "renderComparison" in agentUx ? agentUx.renderComparison : undefined;
435
+ const agentUxResult = agentUx && "agentUx" in agentUx ? agentUx.agentUx : undefined;
436
+ const performancePayload = resultMap.get("performance")?.result;
437
+ const performanceResult = performancePayload?.performance;
438
+ const pageWeightResult = performancePayload?.pageWeight;
165
439
  const overallScore = scores.length > 0
166
440
  ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length)
167
441
  : 0;
442
+ const coverage = Math.round((scores.length / ALL_CATEGORIES.length) * 100);
443
+ const confidence = coverage === 100 ? "high" : coverage >= 50 ? "medium" : "low";
168
444
  const summary = `SEO audit for ${fetchResult.finalUrl} — Overall score: ${overallScore}/100, ${allIssues.length} issues found.`;
169
445
  return {
170
446
  url: fetchResult.finalUrl,
171
447
  auditedAt: new Date().toISOString(),
172
448
  overallScore,
449
+ coverage,
450
+ confidence,
451
+ skippedCategories,
452
+ rendererAvailable,
453
+ renderComparison,
454
+ version: 2,
455
+ warnings: [],
173
456
  technical,
174
457
  content,
175
458
  schema,
176
459
  images,
177
460
  preload,
178
- agentUx,
461
+ agentUx: agentUxResult,
462
+ performance: performanceResult,
463
+ pageWeight: pageWeightResult,
179
464
  allIssues,
180
465
  summary,
181
466
  };
@@ -189,3 +474,43 @@ function severityRank(s) {
189
474
  default: return 4;
190
475
  }
191
476
  }
477
+ function compareStaticRendered(fetchResult, renderResult) {
478
+ const staticPage = parsePage(fetchResult.content, fetchResult.finalUrl);
479
+ const renderedPage = parsePage(renderResult.content, renderResult.finalUrl);
480
+ const staticText = extractText(fetchResult.content).split(/\s+/).filter(Boolean);
481
+ const renderedText = extractText(renderResult.content).split(/\s+/).filter(Boolean);
482
+ const textDelta = renderedText.length - staticText.length;
483
+ const headingDelta = renderedPage.headings.length - staticPage.headings.length;
484
+ const linkDelta = renderedPage.internalLinkCount + renderedPage.externalLinkCount -
485
+ (staticPage.internalLinkCount + staticPage.externalLinkCount);
486
+ const imageDelta = renderedPage.images.length - staticPage.images.length;
487
+ const schemaDelta = renderedPage.schemaBlocks.length - staticPage.schemaBlocks.length;
488
+ const notes = [];
489
+ if (renderResult.isSpa)
490
+ notes.push("Initial HTML is small relative to rendered DOM (SPA signature).");
491
+ if (textDelta > staticText.length * 0.25)
492
+ notes.push(`Rendered text grew by ${textDelta} words (${Math.round((textDelta / Math.max(1, staticText.length)) * 100)}%).`);
493
+ if (headingDelta > 0)
494
+ notes.push(`${headingDelta} new headings appeared after render.`);
495
+ if (linkDelta > 0)
496
+ notes.push(`${linkDelta} new links appeared after render.`);
497
+ if (imageDelta > 0)
498
+ notes.push(`${imageDelta} new images appeared after render.`);
499
+ if (schemaDelta > 0)
500
+ notes.push(`${schemaDelta} new JSON-LD blocks appeared after render.`);
501
+ if (renderResult.consoleErrors.length > 0)
502
+ notes.push(`${renderResult.consoleErrors.length} console errors during render.`);
503
+ return {
504
+ staticByteLength: Buffer.byteLength(fetchResult.content, "utf-8"),
505
+ renderedByteLength: Buffer.byteLength(renderResult.content, "utf-8"),
506
+ contentGrew: textDelta > staticText.length * 0.25 ||
507
+ (renderResult.rawContent.length > 0 && renderResult.content.length > renderResult.rawContent.length * 1.5),
508
+ isSpa: renderResult.isSpa,
509
+ textDelta,
510
+ headingDelta,
511
+ linkDelta,
512
+ imageDelta,
513
+ schemaDelta,
514
+ notes,
515
+ };
516
+ }
@@ -2,12 +2,23 @@
2
2
  * Helper utilities for SEO tool implementations.
3
3
  */
4
4
  import type { AgentToolResult } from "../../../sdk/coding-agent/index.js";
5
- /** Create a text-only tool result. */
6
- export declare function textResult(text: string, details?: Record<string, unknown>): AgentToolResult<Record<string, unknown>>;
7
- /** Create an error tool result. */
8
- export declare function errorResult(message: string, details?: Record<string, unknown>): AgentToolResult<Record<string, unknown>>;
5
+ /** Structured details for tool results. */
6
+ export interface ToolDetails<T = unknown> {
7
+ isError?: boolean;
8
+ result?: T;
9
+ warnings?: string[];
10
+ metadata?: Record<string, unknown>;
11
+ }
12
+ /** Version of the SEO tool result schema. Increment on breaking changes. */
13
+ export declare const SEO_RESULT_VERSION = 2;
14
+ /** Create a text tool result. Accepts either legacy (text, details) or new (text, result, warnings). */
15
+ export declare function textResult(text: string, detailsOrResult?: Record<string, unknown> | unknown, warnings?: string[]): AgentToolResult<ToolDetails>;
16
+ /** Create an error tool result. Accepts either legacy (msg, details) or new (msg, result, warnings). */
17
+ export declare function errorResult(message: string, detailsOrResult?: Record<string, unknown> | unknown, warnings?: string[]): AgentToolResult<ToolDetails>;
9
18
  /** Format a JSON object as pretty-printed text for tool output. */
10
19
  export declare function jsonText(obj: unknown): string;
11
20
  /** Truncate text to a maximum length. */
12
21
  export declare function truncate(text: string, maxLen: number): string;
22
+ /** Validate and normalize a tool URL input, enforcing length limits. */
23
+ export declare function validateToolUrl(raw: string, maxLen?: number): string;
13
24
  //# sourceMappingURL=helpers.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/tools/helpers.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAE1E,sCAAsC;AACtC,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAAG,eAAe,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAKxH;AAED,mCAAmC;AACnC,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAAG,eAAe,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAK5H;AAED,mEAAmE;AACnE,wBAAgB,QAAQ,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAE7C;AAED,yCAAyC;AACzC,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAG7D"}
1
+ {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/tools/helpers.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAE1E,2CAA2C;AAC3C,MAAM,WAAW,WAAW,CAAC,CAAC,GAAG,OAAO;IACtC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,CAAC,CAAC;IACX,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,4EAA4E;AAC5E,eAAO,MAAM,kBAAkB,IAAI,CAAC;AAEpC,wGAAwG;AACxG,wBAAgB,UAAU,CACxB,IAAI,EAAE,MAAM,EACZ,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,EACnD,QAAQ,CAAC,EAAE,MAAM,EAAE,GAClB,eAAe,CAAC,WAAW,CAAC,CAwB9B;AAED,wGAAwG;AACxG,wBAAgB,WAAW,CACzB,OAAO,EAAE,MAAM,EACf,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,EACnD,QAAQ,CAAC,EAAE,MAAM,EAAE,GAClB,eAAe,CAAC,WAAW,CAAC,CAyB9B;AAED,mEAAmE;AACnE,wBAAgB,QAAQ,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAE7C;AAED,yCAAyC;AACzC,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAG7D;AAED,wEAAwE;AACxE,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,SAAO,GAAG,MAAM,CAOlE"}