@nebulacomponents/citable 1.15.1 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +18 -5
  3. package/dist/universal/.agents/skills/citable/SKILL.md +51 -1
  4. package/dist/universal/.agents/skills/citable/VERSION +1 -1
  5. package/dist/universal/.agents/skills/citable/commands/README.md +3 -1
  6. package/dist/universal/.agents/skills/citable/commands/governance.md +12 -0
  7. package/dist/universal/.agents/skills/citable/commands/measurement.md +20 -0
  8. package/dist/universal/.agents/skills/citable/commands/reporting.md +16 -0
  9. package/dist/universal/.agents/skills/citable/manifest.json +17 -12
  10. package/dist/universal/.agents/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  11. package/dist/universal/.agents/skills/citable/schemas/experiment.schema.json +1 -1
  12. package/dist/universal/.agents/skills/citable/schemas/finding.schema.json +69 -0
  13. package/dist/universal/.agents/skills/citable/schemas/fleet.schema.json +93 -0
  14. package/dist/universal/.agents/skills/citable/schemas/funnel.schema.json +116 -0
  15. package/dist/universal/.agents/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  16. package/dist/universal/.agents/skills/citable/schemas/observation.schema.json +1 -1
  17. package/dist/universal/.agents/skills/citable/schemas/remediation-verification.schema.json +98 -0
  18. package/dist/universal/.claude/agents/citable/manifest.json +2 -2
  19. package/dist/universal/.claude/skills/citable/SKILL.md +51 -1
  20. package/dist/universal/.claude/skills/citable/VERSION +1 -1
  21. package/dist/universal/.claude/skills/citable/commands/README.md +3 -1
  22. package/dist/universal/.claude/skills/citable/commands/governance.md +12 -0
  23. package/dist/universal/.claude/skills/citable/commands/measurement.md +20 -0
  24. package/dist/universal/.claude/skills/citable/commands/reporting.md +16 -0
  25. package/dist/universal/.claude/skills/citable/manifest.json +17 -12
  26. package/dist/universal/.claude/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  27. package/dist/universal/.claude/skills/citable/schemas/experiment.schema.json +1 -1
  28. package/dist/universal/.claude/skills/citable/schemas/finding.schema.json +69 -0
  29. package/dist/universal/.claude/skills/citable/schemas/fleet.schema.json +93 -0
  30. package/dist/universal/.claude/skills/citable/schemas/funnel.schema.json +116 -0
  31. package/dist/universal/.claude/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  32. package/dist/universal/.claude/skills/citable/schemas/observation.schema.json +1 -1
  33. package/dist/universal/.claude/skills/citable/schemas/remediation-verification.schema.json +98 -0
  34. package/dist/universal/.cursor/skills/citable/SKILL.md +51 -1
  35. package/dist/universal/.cursor/skills/citable/VERSION +1 -1
  36. package/dist/universal/.cursor/skills/citable/commands/README.md +3 -1
  37. package/dist/universal/.cursor/skills/citable/commands/governance.md +12 -0
  38. package/dist/universal/.cursor/skills/citable/commands/measurement.md +20 -0
  39. package/dist/universal/.cursor/skills/citable/commands/reporting.md +16 -0
  40. package/dist/universal/.cursor/skills/citable/manifest.json +17 -12
  41. package/dist/universal/.cursor/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  42. package/dist/universal/.cursor/skills/citable/schemas/experiment.schema.json +1 -1
  43. package/dist/universal/.cursor/skills/citable/schemas/finding.schema.json +69 -0
  44. package/dist/universal/.cursor/skills/citable/schemas/fleet.schema.json +93 -0
  45. package/dist/universal/.cursor/skills/citable/schemas/funnel.schema.json +116 -0
  46. package/dist/universal/.cursor/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  47. package/dist/universal/.cursor/skills/citable/schemas/observation.schema.json +1 -1
  48. package/dist/universal/.cursor/skills/citable/schemas/remediation-verification.schema.json +98 -0
  49. package/dist/universal/.gemini/skills/citable/SKILL.md +51 -1
  50. package/dist/universal/.gemini/skills/citable/VERSION +1 -1
  51. package/dist/universal/.gemini/skills/citable/commands/README.md +3 -1
  52. package/dist/universal/.gemini/skills/citable/commands/governance.md +12 -0
  53. package/dist/universal/.gemini/skills/citable/commands/measurement.md +20 -0
  54. package/dist/universal/.gemini/skills/citable/commands/reporting.md +16 -0
  55. package/dist/universal/.gemini/skills/citable/manifest.json +17 -12
  56. package/dist/universal/.gemini/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  57. package/dist/universal/.gemini/skills/citable/schemas/experiment.schema.json +1 -1
  58. package/dist/universal/.gemini/skills/citable/schemas/finding.schema.json +69 -0
  59. package/dist/universal/.gemini/skills/citable/schemas/fleet.schema.json +93 -0
  60. package/dist/universal/.gemini/skills/citable/schemas/funnel.schema.json +116 -0
  61. package/dist/universal/.gemini/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  62. package/dist/universal/.gemini/skills/citable/schemas/observation.schema.json +1 -1
  63. package/dist/universal/.gemini/skills/citable/schemas/remediation-verification.schema.json +98 -0
  64. package/dist/universal/.github/skills/citable/SKILL.md +51 -1
  65. package/dist/universal/.github/skills/citable/VERSION +1 -1
  66. package/dist/universal/.github/skills/citable/commands/README.md +3 -1
  67. package/dist/universal/.github/skills/citable/commands/governance.md +12 -0
  68. package/dist/universal/.github/skills/citable/commands/measurement.md +20 -0
  69. package/dist/universal/.github/skills/citable/commands/reporting.md +16 -0
  70. package/dist/universal/.github/skills/citable/manifest.json +17 -12
  71. package/dist/universal/.github/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  72. package/dist/universal/.github/skills/citable/schemas/experiment.schema.json +1 -1
  73. package/dist/universal/.github/skills/citable/schemas/finding.schema.json +69 -0
  74. package/dist/universal/.github/skills/citable/schemas/fleet.schema.json +93 -0
  75. package/dist/universal/.github/skills/citable/schemas/funnel.schema.json +116 -0
  76. package/dist/universal/.github/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  77. package/dist/universal/.github/skills/citable/schemas/observation.schema.json +1 -1
  78. package/dist/universal/.github/skills/citable/schemas/remediation-verification.schema.json +98 -0
  79. package/dist/universal/.kiro/skills/citable/SKILL.md +51 -1
  80. package/dist/universal/.kiro/skills/citable/VERSION +1 -1
  81. package/dist/universal/.kiro/skills/citable/commands/README.md +3 -1
  82. package/dist/universal/.kiro/skills/citable/commands/governance.md +12 -0
  83. package/dist/universal/.kiro/skills/citable/commands/measurement.md +20 -0
  84. package/dist/universal/.kiro/skills/citable/commands/reporting.md +16 -0
  85. package/dist/universal/.kiro/skills/citable/manifest.json +17 -12
  86. package/dist/universal/.kiro/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  87. package/dist/universal/.kiro/skills/citable/schemas/experiment.schema.json +1 -1
  88. package/dist/universal/.kiro/skills/citable/schemas/finding.schema.json +69 -0
  89. package/dist/universal/.kiro/skills/citable/schemas/fleet.schema.json +93 -0
  90. package/dist/universal/.kiro/skills/citable/schemas/funnel.schema.json +116 -0
  91. package/dist/universal/.kiro/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  92. package/dist/universal/.kiro/skills/citable/schemas/observation.schema.json +1 -1
  93. package/dist/universal/.kiro/skills/citable/schemas/remediation-verification.schema.json +98 -0
  94. package/dist/universal/.opencode/skills/citable/SKILL.md +51 -1
  95. package/dist/universal/.opencode/skills/citable/VERSION +1 -1
  96. package/dist/universal/.opencode/skills/citable/commands/README.md +3 -1
  97. package/dist/universal/.opencode/skills/citable/commands/governance.md +12 -0
  98. package/dist/universal/.opencode/skills/citable/commands/measurement.md +20 -0
  99. package/dist/universal/.opencode/skills/citable/commands/reporting.md +16 -0
  100. package/dist/universal/.opencode/skills/citable/manifest.json +17 -12
  101. package/dist/universal/.opencode/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  102. package/dist/universal/.opencode/skills/citable/schemas/experiment.schema.json +1 -1
  103. package/dist/universal/.opencode/skills/citable/schemas/finding.schema.json +69 -0
  104. package/dist/universal/.opencode/skills/citable/schemas/fleet.schema.json +93 -0
  105. package/dist/universal/.opencode/skills/citable/schemas/funnel.schema.json +116 -0
  106. package/dist/universal/.opencode/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  107. package/dist/universal/.opencode/skills/citable/schemas/observation.schema.json +1 -1
  108. package/dist/universal/.opencode/skills/citable/schemas/remediation-verification.schema.json +98 -0
  109. package/dist/universal/.pi/agent/skills/citable/SKILL.md +51 -1
  110. package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
  111. package/dist/universal/.pi/agent/skills/citable/commands/README.md +3 -1
  112. package/dist/universal/.pi/agent/skills/citable/commands/governance.md +12 -0
  113. package/dist/universal/.pi/agent/skills/citable/commands/measurement.md +20 -0
  114. package/dist/universal/.pi/agent/skills/citable/commands/reporting.md +16 -0
  115. package/dist/universal/.pi/agent/skills/citable/manifest.json +17 -12
  116. package/dist/universal/.pi/agent/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  117. package/dist/universal/.pi/agent/skills/citable/schemas/experiment.schema.json +1 -1
  118. package/dist/universal/.pi/agent/skills/citable/schemas/finding.schema.json +69 -0
  119. package/dist/universal/.pi/agent/skills/citable/schemas/fleet.schema.json +93 -0
  120. package/dist/universal/.pi/agent/skills/citable/schemas/funnel.schema.json +116 -0
  121. package/dist/universal/.pi/agent/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  122. package/dist/universal/.pi/agent/skills/citable/schemas/observation.schema.json +1 -1
  123. package/dist/universal/.pi/agent/skills/citable/schemas/remediation-verification.schema.json +98 -0
  124. package/dist/universal/.qoder/skills/citable/SKILL.md +51 -1
  125. package/dist/universal/.qoder/skills/citable/VERSION +1 -1
  126. package/dist/universal/.qoder/skills/citable/commands/README.md +3 -1
  127. package/dist/universal/.qoder/skills/citable/commands/governance.md +12 -0
  128. package/dist/universal/.qoder/skills/citable/commands/measurement.md +20 -0
  129. package/dist/universal/.qoder/skills/citable/commands/reporting.md +16 -0
  130. package/dist/universal/.qoder/skills/citable/manifest.json +17 -12
  131. package/dist/universal/.qoder/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  132. package/dist/universal/.qoder/skills/citable/schemas/experiment.schema.json +1 -1
  133. package/dist/universal/.qoder/skills/citable/schemas/finding.schema.json +69 -0
  134. package/dist/universal/.qoder/skills/citable/schemas/fleet.schema.json +93 -0
  135. package/dist/universal/.qoder/skills/citable/schemas/funnel.schema.json +116 -0
  136. package/dist/universal/.qoder/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  137. package/dist/universal/.qoder/skills/citable/schemas/observation.schema.json +1 -1
  138. package/dist/universal/.qoder/skills/citable/schemas/remediation-verification.schema.json +98 -0
  139. package/dist/universal/.rovodev/skills/citable/SKILL.md +51 -1
  140. package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
  141. package/dist/universal/.rovodev/skills/citable/commands/README.md +3 -1
  142. package/dist/universal/.rovodev/skills/citable/commands/governance.md +12 -0
  143. package/dist/universal/.rovodev/skills/citable/commands/measurement.md +20 -0
  144. package/dist/universal/.rovodev/skills/citable/commands/reporting.md +16 -0
  145. package/dist/universal/.rovodev/skills/citable/manifest.json +17 -12
  146. package/dist/universal/.rovodev/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  147. package/dist/universal/.rovodev/skills/citable/schemas/experiment.schema.json +1 -1
  148. package/dist/universal/.rovodev/skills/citable/schemas/finding.schema.json +69 -0
  149. package/dist/universal/.rovodev/skills/citable/schemas/fleet.schema.json +93 -0
  150. package/dist/universal/.rovodev/skills/citable/schemas/funnel.schema.json +116 -0
  151. package/dist/universal/.rovodev/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  152. package/dist/universal/.rovodev/skills/citable/schemas/observation.schema.json +1 -1
  153. package/dist/universal/.rovodev/skills/citable/schemas/remediation-verification.schema.json +98 -0
  154. package/dist/universal/.trae/skills/citable/SKILL.md +51 -1
  155. package/dist/universal/.trae/skills/citable/VERSION +1 -1
  156. package/dist/universal/.trae/skills/citable/commands/README.md +3 -1
  157. package/dist/universal/.trae/skills/citable/commands/governance.md +12 -0
  158. package/dist/universal/.trae/skills/citable/commands/measurement.md +20 -0
  159. package/dist/universal/.trae/skills/citable/commands/reporting.md +16 -0
  160. package/dist/universal/.trae/skills/citable/manifest.json +17 -12
  161. package/dist/universal/.trae/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  162. package/dist/universal/.trae/skills/citable/schemas/experiment.schema.json +1 -1
  163. package/dist/universal/.trae/skills/citable/schemas/finding.schema.json +69 -0
  164. package/dist/universal/.trae/skills/citable/schemas/fleet.schema.json +93 -0
  165. package/dist/universal/.trae/skills/citable/schemas/funnel.schema.json +116 -0
  166. package/dist/universal/.trae/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  167. package/dist/universal/.trae/skills/citable/schemas/observation.schema.json +1 -1
  168. package/dist/universal/.trae/skills/citable/schemas/remediation-verification.schema.json +98 -0
  169. package/dist/universal/.trae-cn/skills/citable/SKILL.md +51 -1
  170. package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
  171. package/dist/universal/.trae-cn/skills/citable/commands/README.md +3 -1
  172. package/dist/universal/.trae-cn/skills/citable/commands/governance.md +12 -0
  173. package/dist/universal/.trae-cn/skills/citable/commands/measurement.md +20 -0
  174. package/dist/universal/.trae-cn/skills/citable/commands/reporting.md +16 -0
  175. package/dist/universal/.trae-cn/skills/citable/manifest.json +17 -12
  176. package/dist/universal/.trae-cn/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  177. package/dist/universal/.trae-cn/skills/citable/schemas/experiment.schema.json +1 -1
  178. package/dist/universal/.trae-cn/skills/citable/schemas/finding.schema.json +69 -0
  179. package/dist/universal/.trae-cn/skills/citable/schemas/fleet.schema.json +93 -0
  180. package/dist/universal/.trae-cn/skills/citable/schemas/funnel.schema.json +116 -0
  181. package/dist/universal/.trae-cn/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  182. package/dist/universal/.trae-cn/skills/citable/schemas/observation.schema.json +1 -1
  183. package/dist/universal/.trae-cn/skills/citable/schemas/remediation-verification.schema.json +98 -0
  184. package/dist/universal/manifest.json +208 -148
  185. package/package.json +1 -1
  186. package/schemas/cli-output-envelope.schema.json +30 -0
  187. package/schemas/experiment.schema.json +1 -1
  188. package/schemas/finding.schema.json +69 -0
  189. package/schemas/fleet.schema.json +93 -0
  190. package/schemas/funnel.schema.json +116 -0
  191. package/schemas/mcp-transport-envelope.schema.json +244 -0
  192. package/schemas/observation.schema.json +1 -1
  193. package/schemas/remediation-verification.schema.json +98 -0
  194. package/skill/SKILL.md +51 -1
  195. package/skill/commands/README.md +3 -1
  196. package/skill/commands/governance.md +12 -0
  197. package/skill/commands/measurement.md +20 -0
  198. package/skill/commands/reporting.md +16 -0
  199. package/src/analysis/saliency.js +119 -0
  200. package/src/cli/index.js +651 -13
  201. package/src/commands/attributeImpact.js +54 -0
  202. package/src/commands/audit.js +2 -2
  203. package/src/commands/ciWorkflow.js +63 -0
  204. package/src/commands/compatibility.js +205 -0
  205. package/src/commands/connect.js +3 -0
  206. package/src/commands/context.js +2 -1
  207. package/src/commands/edgeRules.js +196 -0
  208. package/src/commands/edgeSecurity.js +192 -0
  209. package/src/commands/exceptions.js +241 -0
  210. package/src/commands/experimentGuardrails.js +213 -0
  211. package/src/commands/fleet.js +104 -0
  212. package/src/commands/generateLlmsTxt.js +111 -0
  213. package/src/commands/goldenCorpus.js +155 -0
  214. package/src/commands/implementationKit.js +202 -0
  215. package/src/commands/inspectAeo.js +74 -0
  216. package/src/commands/inspectCro.js +142 -0
  217. package/src/commands/inspectGeo.js +52 -0
  218. package/src/commands/inspectSerp.js +317 -0
  219. package/src/commands/lintComponents.js +132 -0
  220. package/src/commands/monitor.js +138 -0
  221. package/src/commands/observe.js +6 -1
  222. package/src/commands/planExperiment.js +120 -0
  223. package/src/commands/previewCro.js +241 -0
  224. package/src/commands/probe.js +70 -0
  225. package/src/commands/remediate.js +461 -0
  226. package/src/commands/reportConsensus.js +48 -0
  227. package/src/commands/testFunnel.js +107 -0
  228. package/src/commands/verifyRemediation.js +204 -0
  229. package/src/commands/visualRegression.js +165 -0
  230. package/src/components/index.js +556 -0
  231. package/src/connectors/index.js +6 -1
  232. package/src/connectors/indexnow.js +431 -0
  233. package/src/connectors/mcp/allowlist.js +107 -0
  234. package/src/connectors/mcp/client.js +239 -0
  235. package/src/connectors/mcp/diagnostics.js +214 -0
  236. package/src/connectors/mcp/gscServer.js +155 -0
  237. package/src/connectors/mcp/http.js +130 -0
  238. package/src/connectors/mcp/index.js +7 -0
  239. package/src/connectors/mcp/pilot.js +138 -0
  240. package/src/connectors/mcp/pilotServer.js +80 -0
  241. package/src/connectors/mcp/sanitizer.js +38 -0
  242. package/src/connectors/mcp/stdio.js +178 -0
  243. package/src/crawler/ipRanges.js +161 -0
  244. package/src/detectors/agent.js +127 -0
  245. package/src/detectors/ans.js +282 -0
  246. package/src/detectors/crawl.js +46 -0
  247. package/src/detectors/cro.js +825 -0
  248. package/src/detectors/cwv.js +38 -1
  249. package/src/detectors/evd.js +101 -0
  250. package/src/detectors/framework.js +32 -2
  251. package/src/detectors/geoReco.js +408 -0
  252. package/src/detectors/index.js +4 -2
  253. package/src/detectors/link.js +112 -1
  254. package/src/detectors/page.js +101 -0
  255. package/src/detectors/schemaData.js +529 -0
  256. package/src/detectors/tech.js +306 -0
  257. package/src/extractor/page.js +186 -2
  258. package/src/extractor/site.js +9 -2
  259. package/src/installer/index.js +45 -2
  260. package/src/monitoring/alertDelivery.js +101 -7
  261. package/src/observations/attribution.js +217 -0
  262. package/src/registries/index.js +14 -0
  263. package/src/reporting/consensus.js +416 -0
  264. package/src/reporting/executiveExport.js +244 -0
@@ -1,3 +1,4 @@
1
+ import { parse } from 'node-html-parser';
1
2
  import { defineDetector, hasHtmlDocumentMarkup, indexTargets, isHtmlDocument, isHtmlDocumentUrl, registryPageFor, pageSubject, safePath } from './framework.js';
2
3
  import { isAllowed } from '../crawler/robots.js';
3
4
 
@@ -358,6 +359,311 @@ D.push(defineDetector({
358
359
  },
359
360
  }));
360
361
 
362
+ D.push(defineDetector({
363
+ id: 'TECH-019', name: 'Open Graph URL conflicts with canonical', namespace: 'TECH',
364
+ description: 'Open Graph URL (og:url) disagrees with the declared canonical URL, sending conflicting canonicalization and entity identity signals.',
365
+ discipline: ['seo', 'aeo', 'geo'], severity: 'medium', deterministic: true, requires: ['site'],
366
+ impact: { retrieval: 'low', ranking: 'medium', citation: 'medium' },
367
+ applicable_requirement: 'SEO §2 canonical signals should agree across Open Graph and HTML declarations; AEO §2 consistent identity signals',
368
+ remediation: 'Align og:url with the rel=canonical target on the index-target document, or omit og:url if self-canonicalizing.',
369
+ verification: 'Compare og:url and rel=canonical in rendered HTML.',
370
+ check(ctx) {
371
+ const hits = [];
372
+ for (const p of ctx.site.pages) {
373
+ if (p.canonicals.length !== 1 || !p.ogUrl) continue;
374
+ const declared = p.canonicals[0];
375
+ let normCanonical;
376
+ let normOg;
377
+ try {
378
+ normCanonical = ctx.site.normalize(new URL(declared, p.url).href);
379
+ normOg = ctx.site.normalize(new URL(p.ogUrl, p.url).href);
380
+ } catch {
381
+ continue;
382
+ }
383
+ if (normCanonical !== normOg) {
384
+ hits.push({
385
+ subject: pageSubject(p),
386
+ summary: `Open Graph URL (${p.ogUrl}) disagrees with rel=canonical (${declared})`,
387
+ evidence: [`rel=canonical: ${declared}`, `og:url: ${p.ogUrl}`],
388
+ captured: { canonical: declared, ogUrl: p.ogUrl },
389
+ expected: 'matching canonical and og:url',
390
+ });
391
+ }
392
+ }
393
+ return hits;
394
+ },
395
+ }));
396
+
397
+ D.push(defineDetector({
398
+ id: 'TECH-020', name: 'Missing reciprocal hreflang return link', namespace: 'TECH',
399
+ description: 'An alternate hreflang URL targets an internal page, but that target page does not reciprocate with a matching hreflang link back to the source page.',
400
+ discipline: ['seo', 'geo'], severity: 'high', deterministic: true, requires: ['site'],
401
+ impact: { retrieval: 'high', ranking: 'medium', citation: 'medium' },
402
+ applicable_requirement: 'SEO §2 internationalization controls; RFC 8288 and search engine hreflang reciprocity requirement',
403
+ remediation: 'Add a reciprocal <link rel="alternate" hreflang="..." href="..."> tag on the target page pointing back to the referencing page.',
404
+ verification: 'Extract hreflang alternate links on both URLs and confirm bidirectional reciprocity.',
405
+ check(ctx) {
406
+ const hits = [];
407
+ for (const p of ctx.site.pages) {
408
+ if (!p.hreflangs || p.hreflangs.length === 0) continue;
409
+ const normSource = ctx.site.normalize(p.url);
410
+ for (const alt of p.hreflangs) {
411
+ if (!alt.href || alt.lang === 'x-default') continue;
412
+ let normTarget;
413
+ try {
414
+ normTarget = ctx.site.normalize(new URL(alt.href, p.url).href);
415
+ } catch {
416
+ continue;
417
+ }
418
+ if (normTarget === normSource) continue;
419
+ const targetPage = ctx.site.byUrl.get(normTarget);
420
+ if (!targetPage) continue;
421
+ const hasReciprocal = (targetPage.hreflangs || []).some((tAlt) => {
422
+ try {
423
+ const normBack = ctx.site.normalize(new URL(tAlt.href, targetPage.url).href);
424
+ return normBack === normSource;
425
+ } catch {
426
+ return false;
427
+ }
428
+ });
429
+ if (!hasReciprocal) {
430
+ hits.push({
431
+ subject: pageSubject(p),
432
+ summary: `Missing reciprocal hreflang link from ${alt.href} back to ${p.url}`,
433
+ evidence: [
434
+ `${p.url} references alternate ${alt.href} (hreflang="${alt.lang}")`,
435
+ `${normTarget} does not link back to ${p.url} in its hreflang annotations`,
436
+ ],
437
+ captured: { target: normTarget, hreflang: alt.lang },
438
+ expected: `reciprocal link to ${p.url}`,
439
+ });
440
+ }
441
+ }
442
+ }
443
+ return hits;
444
+ },
445
+ }));
446
+
447
+ D.push(defineDetector({
448
+ id: 'TECH-021', name: 'Invalid or non-standard BCP 47 hreflang syntax', namespace: 'TECH',
449
+ description: 'An hreflang annotation uses an invalid language code, malformed region subtag, deprecated territory, or relative URL.',
450
+ discipline: ['seo', 'geo'], severity: 'high', deterministic: true, requires: ['site'],
451
+ impact: { retrieval: 'high', ranking: 'medium', citation: 'medium' },
452
+ applicable_requirement: 'SEO §2 BCP 47 language/region standards (ISO 639-1 language, ISO 3166-1 alpha-2 region)',
453
+ remediation: 'Use lowercase two-letter ISO 639-1 language codes, optional uppercase ISO 3166-1 region codes (e.g. en-US, es-ES, de-DE), or x-default with absolute URLs.',
454
+ verification: 'Validate all hreflang attribute values against BCP 47 and confirm absolute URLs.',
455
+ check(ctx) {
456
+ const hits = [];
457
+ const BCP47_REGEX = /^(?:x-default|[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?)$/;
458
+ for (const p of ctx.site.pages) {
459
+ if (!p.hreflangs || p.hreflangs.length === 0) continue;
460
+ for (const alt of p.hreflangs) {
461
+ const code = alt.lang;
462
+ const href = alt.href;
463
+ const issues = [];
464
+ if (!code) {
465
+ issues.push('hreflang attribute is empty');
466
+ } else {
467
+ if (code.toLowerCase() === 'x-default') {
468
+ if (code !== 'x-default') issues.push(`x-default must be lowercase (got "${code}")`);
469
+ } else if (code.includes('_')) {
470
+ issues.push(`hreflang uses underscore instead of hyphen ("${code}")`);
471
+ } else if (/\ben-uk\b/i.test(code)) {
472
+ issues.push(`"UK" is not an ISO 3166-1 alpha-2 country code; use "GB" (got "${code}")`);
473
+ } else if (!BCP47_REGEX.test(code)) {
474
+ issues.push(`malformed BCP 47 code format ("${code}")`);
475
+ }
476
+ }
477
+ if (href && !/^https?:\/\//i.test(href)) {
478
+ issues.push(`hreflang target must be an absolute URL with scheme (got "${href}")`);
479
+ }
480
+ if (issues.length > 0) {
481
+ hits.push({
482
+ subject: pageSubject(p),
483
+ summary: `Invalid hreflang annotation: ${issues.join('; ')}`,
484
+ evidence: issues.map((iss) => `${p.url}: ${iss}`),
485
+ captured: { hreflang: code, href },
486
+ expected: 'valid BCP 47 language/region code and absolute URL',
487
+ });
488
+ }
489
+ }
490
+ }
491
+ return hits;
492
+ },
493
+ }));
494
+
495
+ D.push(defineDetector({
496
+ id: 'TECH-022', name: 'Hreflang alternate target conflicts with rel=canonical', namespace: 'TECH',
497
+ description: 'An alternate hreflang URL points to a page that declares a different rel=canonical target, breaking the alternate cluster.',
498
+ discipline: ['seo', 'geo'], severity: 'critical', deterministic: true, requires: ['site'],
499
+ impact: { retrieval: 'high', ranking: 'high', citation: 'high' },
500
+ applicable_requirement: 'SEO §2 canonical and alternate harmony; localized pages in an hreflang cluster must be self-canonical',
501
+ remediation: 'Ensure each localized alternate page declares a self-referencing rel=canonical matching its own URL.',
502
+ verification: 'Check that target rel=canonical matches the alternate hreflang URL.',
503
+ check(ctx) {
504
+ const hits = [];
505
+ for (const p of ctx.site.pages) {
506
+ if (!p.hreflangs || p.hreflangs.length === 0) continue;
507
+ for (const alt of p.hreflangs) {
508
+ if (!alt.href) continue;
509
+ let normTarget;
510
+ try {
511
+ normTarget = ctx.site.normalize(new URL(alt.href, p.url).href);
512
+ } catch {
513
+ continue;
514
+ }
515
+ const targetPage = ctx.site.byUrl.get(normTarget);
516
+ if (!targetPage || targetPage.canonicals.length !== 1) continue;
517
+ let targetCanonical;
518
+ try {
519
+ targetCanonical = ctx.site.normalize(new URL(targetPage.canonicals[0], targetPage.url).href);
520
+ } catch {
521
+ continue;
522
+ }
523
+ if (targetCanonical !== normTarget) {
524
+ hits.push({
525
+ subject: pageSubject(p),
526
+ summary: `Hreflang alternate ${alt.href} points to non-canonical page (canonical is ${targetCanonical})`,
527
+ evidence: [
528
+ `${p.url} lists ${alt.href} as alternate (${alt.lang})`,
529
+ `${normTarget} declares canonical ${targetCanonical}`,
530
+ ],
531
+ captured: { target: normTarget, canonical: targetCanonical },
532
+ expected: `self-canonical target (${normTarget})`,
533
+ });
534
+ }
535
+ }
536
+ }
537
+ return hits;
538
+ },
539
+ }));
540
+
541
+ D.push(defineDetector({
542
+ id: 'TECH-023', name: 'Hydration gap: critical metadata or schema missing from initial server HTML response', namespace: 'TECH',
543
+ description: 'A single-page application or client-rendered document returns empty content mounting shells with virtually no static text (<20 words) in initial server HTML, breaking crawler and answer engine extraction without full headless JS rendering.',
544
+ discipline: ['seo', 'aeo'], severity: 'high', deterministic: true, requires: ['site'],
545
+ impact: { retrieval: 'high', ranking: 'medium', citation: 'medium' },
546
+ applicable_requirement: 'SEO §2 crawlable HTML and server-side rendering; AEO §2 immediate extractability without execution delay',
547
+ remediation: 'Implement Server-Side Rendering (SSR) or Static Site Generation (SSG) so critical content and metadata are present in initial server HTML.',
548
+ verification: 'Inspect initial server HTML response to confirm main content and metadata are present before JavaScript execution.',
549
+ check(ctx) {
550
+ const hits = [];
551
+ for (const p of indexTargets(ctx)) {
552
+ if (p.status !== 200 || !p.rawHtml) continue;
553
+ const doc = parse(p.rawHtml);
554
+ const hasMountShell = doc.querySelector('#root, #__next, #app, [data-reactroot]');
555
+ const wordCount = p.rawVisibleWordCount || p.wordCount || 0;
556
+ const hasScripts = (p.scriptBytes || 0) > 0 || doc.querySelectorAll('script').length > 0;
557
+
558
+ if (hasMountShell && wordCount < 20 && hasScripts) {
559
+ hits.push({
560
+ subject: pageSubject(p),
561
+ summary: `Client-rendered mounting shell (${hasMountShell.tagName.toLowerCase()}${hasMountShell.id ? '#' + hasMountShell.id : ''}) contains only ${wordCount} words in initial HTML response`,
562
+ evidence: [
563
+ `shell element: <${hasMountShell.tagName.toLowerCase()} id="${hasMountShell.id || ''}">`,
564
+ `initial static word count: ${wordCount}`,
565
+ 'search engines and AI agents requiring raw HTML cannot extract content without full JavaScript rendering',
566
+ ],
567
+ captured: wordCount,
568
+ expected: '>= 20 words with server-rendered content',
569
+ });
570
+ }
571
+ }
572
+ return hits;
573
+ },
574
+ }));
575
+
576
+ D.push(defineDetector({
577
+ id: 'TECH-024', name: 'Excessive render-blocking script payload exceeding crawler execution budget', namespace: 'TECH',
578
+ description: 'The page delivers excessive inline/head script payloads (>1.2MB) or an excessive number of render-blocking head scripts (>12 without defer/async), exhausting crawler execution budgets and delaying indexing.',
579
+ discipline: ['seo', 'aeo'], severity: 'medium', deterministic: true, requires: ['site'],
580
+ impact: { retrieval: 'high', ranking: 'low' },
581
+ applicable_requirement: 'SEO §2 crawler execution budget; Google Search Central JavaScript rendering guidelines',
582
+ remediation: 'Reduce JavaScript payload size, code-split vendor bundles, and add defer or async to head script tags.',
583
+ verification: 'Verify total initial script payload is under 1.2MB and head script tags are deferred or asynchronous.',
584
+ check(ctx) {
585
+ const hits = [];
586
+ for (const p of indexTargets(ctx)) {
587
+ if (p.status !== 200 || !p.rawHtml) continue;
588
+ const doc = parse(p.rawHtml);
589
+ const headScripts = doc.querySelectorAll('head script');
590
+ let blockingCount = 0;
591
+ for (const s of headScripts) {
592
+ const type = (s.getAttribute('type') || '').toLowerCase();
593
+ if (type === 'application/ld+json') continue;
594
+ const isAsync = s.hasAttribute('async');
595
+ const isDefer = s.hasAttribute('defer');
596
+ const isModule = type === 'module';
597
+ if (!isAsync && !isDefer && !isModule && s.hasAttribute('src')) {
598
+ blockingCount++;
599
+ }
600
+ }
601
+
602
+ const scriptBytes = p.scriptBytes || 0;
603
+ if (scriptBytes > 1200000 || blockingCount > 12) {
604
+ hits.push({
605
+ subject: pageSubject(p),
606
+ summary: `Page exceeds crawler script execution budget (${scriptBytes > 1200000 ? `${(scriptBytes / 1000000).toFixed(2)}MB script bytes` : ''}${scriptBytes > 1200000 && blockingCount > 12 ? '; ' : ''}${blockingCount > 12 ? `${blockingCount} render-blocking head scripts` : ''})`,
607
+ evidence: [
608
+ `script bytes: ${scriptBytes}`,
609
+ `render-blocking scripts in head: ${blockingCount}`,
610
+ 'crawlers and generative search spiders may abort JavaScript execution under heavy script payloads',
611
+ ],
612
+ captured: { scriptBytes, blockingCount },
613
+ expected: 'scriptBytes <= 1.2MB and blockingCount <= 12',
614
+ });
615
+ }
616
+ }
617
+ return hits;
618
+ },
619
+ }));
620
+
621
+ D.push(defineDetector({
622
+ id: 'TECH-025', name: 'Oversized uncompressed image asset or heavy inline image payload', namespace: 'TECH',
623
+ description: 'The page embeds an excessively large inline image data URI (>100KB) or references raw uncompressed image payloads exceeding 1MB, delaying Largest Contentful Paint (LCP) and exhausting mobile data budgets.',
624
+ discipline: ['seo', 'aeo'], severity: 'medium', deterministic: true, requires: ['site'],
625
+ impact: { retrieval: 'medium', ranking: 'low' },
626
+ applicable_requirement: 'SEO §2 crawler media payload budget; Google Search Central image optimization guidelines',
627
+ remediation: 'Compress image assets using modern formats (WebP/AVIF), resize to display dimensions, and externalize inline base64 image data.',
628
+ verification: 'Confirm all image references are under 1MB and inline data URIs are under 100KB.',
629
+ check(ctx) {
630
+ const hits = [];
631
+ for (const p of indexTargets(ctx)) {
632
+ if (p.status !== 200 || !p.images) continue;
633
+ for (const img of p.images) {
634
+ const src = img.src || '';
635
+ if (src.startsWith('data:image/') && src.length > 100000) {
636
+ hits.push({
637
+ subject: pageSubject(p),
638
+ summary: `Heavy inline base64 image payload (${Math.round(src.length / 1024)}KB) bloats HTML document size`,
639
+ evidence: [
640
+ `data URI length: ${src.length} characters (~${Math.round(src.length / 1024)}KB)`,
641
+ 'inline image payloads delay initial HTML parsing and waste crawler network budgets',
642
+ ],
643
+ captured: { dataUriLength: src.length },
644
+ expected: 'inline image data URI <= 100KB',
645
+ });
646
+ break;
647
+ } else if (img.bytes && img.bytes > 1000000) {
648
+ hits.push({
649
+ subject: pageSubject(p),
650
+ summary: `Oversized image asset "${src}" (${(img.bytes / 1000000).toFixed(2)}MB) delays LCP`,
651
+ evidence: [
652
+ `image source: ${src}`,
653
+ `image size: ${img.bytes} bytes`,
654
+ 'images exceeding 1MB exhaust mobile rendering budgets and degrade Largest Contentful Paint',
655
+ ],
656
+ captured: { src, bytes: img.bytes },
657
+ expected: 'image bytes <= 1MB',
658
+ });
659
+ break;
660
+ }
661
+ }
662
+ }
663
+ return hits;
664
+ },
665
+ }));
666
+
361
667
  export function canonicalTargetProblem(ctx, predicate, label) {
362
668
  const hits = [];
363
669
  for (const p of ctx.site.pages) {
@@ -19,6 +19,35 @@ export function extractPage({ url, html, status = 200, headers = {}, sourceFile
19
19
  .map((l) => l.getAttribute('href'))
20
20
  .filter(Boolean);
21
21
 
22
+ const hreflangs = root
23
+ .querySelectorAll('link[hreflang]')
24
+ .filter((l) => {
25
+ const rel = (l.getAttribute('rel') || '').toLowerCase();
26
+ return rel.split(/\s+/).includes('alternate');
27
+ })
28
+ .map((l) => ({
29
+ lang: (l.getAttribute('hreflang') || '').trim(),
30
+ href: (l.getAttribute('href') || '').trim(),
31
+ }))
32
+ .filter((item) => item.lang || item.href);
33
+
34
+ const linkHeader = headers['link'] || headers['Link'];
35
+ if (linkHeader) {
36
+ const headerStr = Array.isArray(linkHeader) ? linkHeader.join(', ') : String(linkHeader);
37
+ const parts = headerStr.split(/,(?=\s*<)/);
38
+ for (const part of parts) {
39
+ const hrefMatch = part.match(/<([^>]+)>/);
40
+ const relMatch = part.match(/rel=["']?([^"';\s]+)["']?/i);
41
+ const hreflangMatch = part.match(/hreflang=["']?([^"';\s]+)["']?/i);
42
+ if (hrefMatch && relMatch && relMatch[1].toLowerCase() === 'alternate' && hreflangMatch) {
43
+ hreflangs.push({
44
+ lang: hreflangMatch[1].trim(),
45
+ href: hrefMatch[1].trim(),
46
+ });
47
+ }
48
+ }
49
+ }
50
+
22
51
  const robotsMetaRaw = [...(metas['robots'] || []), ...(metas['googlebot'] || [])].join(',').toLowerCase();
23
52
  const xRobots = String(headers['x-robots-tag'] || '').toLowerCase();
24
53
  const robotsDirectives = new Set(
@@ -42,6 +71,9 @@ export function extractPage({ url, html, status = 200, headers = {}, sourceFile
42
71
  const images = root.querySelectorAll('img').map((i) => ({
43
72
  src: i.getAttribute('src') || '',
44
73
  alt: i.getAttribute('alt'),
74
+ width: i.getAttribute('width') || null,
75
+ height: i.getAttribute('height') || null,
76
+ loading: (i.getAttribute('loading') || '').toLowerCase(),
45
77
  }));
46
78
 
47
79
  const jsonLd = [];
@@ -102,10 +134,154 @@ export function extractPage({ url, html, status = 200, headers = {}, sourceFile
102
134
  if (t) hiddenTexts.push(t);
103
135
  }
104
136
  }
105
- for (const c of root.querySelectorAll('*')) {
106
- // comments handled below via rawHtml scan in detectors
137
+ // Extract interactive CTAs (buttons, links with button role/classes, submit inputs)
138
+ const CTA_TEXT_RX = /\b(sign\s*up|get\s*started|request\s*(a\s*)?demo|book\s*(a\s*)?demo|schedule\s*(a\s*)?demo|try\s*(it\s*)?free|start\s*(free\s*)?trial|contact\s*sales|talk\s*to\s*sales|buy\s*now|subscribe|view\s*pricing|see\s*pricing|get\s*a\s*quote|download\s*whitepaper|download\s*report|join\s*waitlist)\b/i;
139
+ const CTA_ATTR_RX = /\b(btn|cta|button|primary-action|action-btn|submit)\b/i;
140
+
141
+ const ctas = [];
142
+ for (const el of root.querySelectorAll('button, a, input[type=submit], input[type=button]')) {
143
+ const tag = el.tagName.toLowerCase();
144
+ const text = (tag === 'input' ? el.getAttribute('value') : el.text) || '';
145
+ const cleanText = text.replace(/\s+/g, ' ').trim();
146
+ const href = el.getAttribute('href') || null;
147
+ const action = el.getAttribute('formaction') || null;
148
+ const role = (el.getAttribute('role') || '').toLowerCase();
149
+ const cls = el.getAttribute('class') || '';
150
+
151
+ const matchesKeyword = CTA_TEXT_RX.test(cleanText);
152
+ const matchesAttr = CTA_ATTR_RX.test(cls) || role === 'button' || tag === 'button' || el.getAttribute('type') === 'submit';
153
+
154
+ if (matchesKeyword || (matchesAttr && cleanText.length > 0 && cleanText.length <= 40)) {
155
+ let inNav = false;
156
+ let inHero = false;
157
+ let cur = el.parentNode;
158
+ while (cur && cur !== root) {
159
+ const tn = cur.tagName ? cur.tagName.toLowerCase() : '';
160
+ const c = cur.getAttribute ? (cur.getAttribute('class') || '') : '';
161
+ const id = cur.getAttribute ? (cur.getAttribute('id') || '') : '';
162
+ const r = cur.getAttribute ? (cur.getAttribute('role') || '') : '';
163
+ if (tn === 'nav' || tn === 'header' || r === 'navigation' || r === 'banner') inNav = true;
164
+ if (tn === 'header' || c.includes('hero') || id.includes('hero') || c.includes('banner') || id.includes('banner')) inHero = true;
165
+ cur = cur.parentNode;
166
+ }
167
+
168
+ const style = (el.getAttribute('style') || '').toLowerCase();
169
+ const widthMatch = style.match(/width:\s*(\d+)px/);
170
+ const heightMatch = style.match(/height:\s*(\d+)px/);
171
+ const fontSizeMatch = style.match(/font-size:\s*(\d+)px/);
172
+ const inlineWidth = widthMatch ? Number(widthMatch[1]) : (el.getAttribute('width') ? Number(el.getAttribute('width')) : null);
173
+ const inlineHeight = heightMatch ? Number(heightMatch[1]) : (el.getAttribute('height') ? Number(el.getAttribute('height')) : null);
174
+ const inlineFontSize = fontSizeMatch ? Number(fontSizeMatch[1]) : null;
175
+
176
+ ctas.push({
177
+ tag,
178
+ text: cleanText,
179
+ target: href || action || null,
180
+ role: role || null,
181
+ className: cls || null,
182
+ isPrimary: matchesKeyword,
183
+ inNav,
184
+ inHero,
185
+ style: style || null,
186
+ inlineWidth,
187
+ inlineHeight,
188
+ inlineFontSize,
189
+ });
190
+ }
107
191
  }
108
192
 
193
+ // Extract form elements
194
+ const forms = [];
195
+ for (const form of root.querySelectorAll('form')) {
196
+ const action = form.getAttribute('action') || null;
197
+ const method = (form.getAttribute('method') || 'GET').toUpperCase();
198
+ const inputEls = form.querySelectorAll('input, select, textarea');
199
+ const inputs = [];
200
+ let hasSubmit = !!form.querySelector('button[type=submit], input[type=submit]');
201
+ if (!hasSubmit && form.querySelector('button:not([type=button]):not([type=reset])')) {
202
+ hasSubmit = true;
203
+ }
204
+
205
+ let nonHiddenCount = 0;
206
+ for (const inp of inputEls) {
207
+ const type = (inp.getAttribute('type') || (inp.tagName.toLowerCase() === 'textarea' ? 'textarea' : 'text')).toLowerCase();
208
+ const name = inp.getAttribute('name') || null;
209
+ const id = inp.getAttribute('id') || null;
210
+ const required = inp.hasAttribute('required');
211
+ const autocomplete = inp.getAttribute('autocomplete')?.trim() || null;
212
+ const inputmode = inp.getAttribute('inputmode')?.trim() || null;
213
+ const placeholder = inp.getAttribute('placeholder') || null;
214
+
215
+ let hasLabel = inp.hasAttribute('aria-label') || inp.hasAttribute('aria-labelledby');
216
+ if (!hasLabel && id) {
217
+ hasLabel = !!root.querySelector(`label[for="${id}"]`);
218
+ }
219
+ if (!hasLabel) {
220
+ let parent = inp.parentNode;
221
+ while (parent && parent !== form) {
222
+ if (parent.tagName && parent.tagName.toLowerCase() === 'label') {
223
+ hasLabel = true;
224
+ break;
225
+ }
226
+ parent = parent.parentNode;
227
+ }
228
+ }
229
+
230
+ if (type !== 'hidden') nonHiddenCount++;
231
+ inputs.push({ name, id, type, required, autocomplete, inputmode, placeholder, hasLabel });
232
+ }
233
+
234
+ forms.push({
235
+ action,
236
+ method,
237
+ inputs,
238
+ fieldCount: nonHiddenCount,
239
+ hasSubmit,
240
+ });
241
+ }
242
+
243
+ // Extract trust / proof signals in DOM
244
+ const TRUST_RX = /\b(soc\s*2|iso\s*27001|gdpr|hipaa|pci[- ]dss|money[- ]back\s*guarantee|cancel\s*anytime|no\s*credit\s*card\s*required|trusted\s*by|featured\s*in|customer\s*reviews|satisfaction\s*guarantee|sla\s*guarantee)\b/i;
245
+ const trustBadges = [];
246
+ for (const el of root.querySelectorAll('div, section, p, span, img, li, footer')) {
247
+ const txt = el.text.replace(/\s+/g, ' ').trim();
248
+ const alt = el.getAttribute('alt') || '';
249
+ const m = TRUST_RX.exec(txt) || TRUST_RX.exec(alt);
250
+ if (m && !trustBadges.some((t) => t.signal.toLowerCase() === m[1].toLowerCase())) {
251
+ trustBadges.push({
252
+ signal: m[1],
253
+ context: txt ? txt.slice(0, 100) : alt,
254
+ });
255
+ }
256
+ }
257
+
258
+ // Analytics & Tag Manager detection
259
+ const ANALYTICS_RX = /\b(googletagmanager\.com|google-analytics\.com|gtag\(|posthog|segment\.com\/analytics|mixpanel|rudderstack|plausible|fathom)\b/i;
260
+ const analyticsTags = [];
261
+ for (const s of root.querySelectorAll('script')) {
262
+ const src = s.getAttribute('src') || '';
263
+ const content = s.text || '';
264
+ if (ANALYTICS_RX.test(src) || ANALYTICS_RX.test(content)) {
265
+ analyticsTags.push(src || 'inline-tag');
266
+ }
267
+ }
268
+
269
+ // Navigation link count
270
+ const navLinksCount = root.querySelectorAll('nav a, header a, [role=navigation] a').length;
271
+
272
+ let domNodeCount = 0;
273
+ let maxDomDepth = 0;
274
+ const walkDom = (node, depth) => {
275
+ domNodeCount++;
276
+ if (depth > maxDomDepth) maxDomDepth = depth;
277
+ for (const child of node.childNodes || []) {
278
+ if (child.nodeType === 1) {
279
+ walkDom(child, depth + 1);
280
+ }
281
+ }
282
+ };
283
+ walkDom(root, 1);
284
+
109
285
  return {
110
286
  url,
111
287
  sourceFile,
@@ -118,6 +294,7 @@ export function extractPage({ url, html, status = 200, headers = {}, sourceFile
118
294
  metaDescription: metas['description']?.[0] ?? null,
119
295
  metas,
120
296
  canonicals,
297
+ hreflangs,
121
298
  robotsDirectives: [...robotsDirectives],
122
299
  noindex: robotsDirectives.has('noindex') || robotsDirectives.has('none'),
123
300
  nosnippet: robotsDirectives.has('nosnippet') || [...robotsDirectives].some((d) => /^max-snippet:\s*0$/.test(d)),
@@ -138,5 +315,12 @@ export function extractPage({ url, html, status = 200, headers = {}, sourceFile
138
315
  orderedLists,
139
316
  tables,
140
317
  hiddenTexts,
318
+ ctas,
319
+ forms,
320
+ trustBadges,
321
+ analyticsTags,
322
+ navLinksCount,
323
+ domNodeCount,
324
+ maxDomDepth,
141
325
  };
142
326
  }
@@ -45,6 +45,11 @@ export function buildSiteFromDir(dir, { baseUrl = 'https://example.test' } = {})
45
45
  const robotsPath = path.join(dir, 'robots.txt');
46
46
  const robotsText = fs.existsSync(robotsPath) ? fs.readFileSync(robotsPath, 'utf8') : null;
47
47
 
48
+ const llmsPath = path.join(dir, 'llms.txt');
49
+ const llmsText = fs.existsSync(llmsPath) ? fs.readFileSync(llmsPath, 'utf8') : null;
50
+ const llmsFullPath = path.join(dir, 'llms-full.txt');
51
+ const llmsFullText = fs.existsSync(llmsFullPath) ? fs.readFileSync(llmsFullPath, 'utf8') : null;
52
+
48
53
  const sitemaps = [];
49
54
  for (const f of fs.readdirSync(dir)) {
50
55
  if (/sitemap.*\.xml$/i.test(f)) {
@@ -52,7 +57,7 @@ export function buildSiteFromDir(dir, { baseUrl = 'https://example.test' } = {})
52
57
  }
53
58
  }
54
59
 
55
- return assembleSite({ baseUrl, pages, robotsText, sitemaps, transport, mode: 'built_output', location: dir });
60
+ return assembleSite({ baseUrl, pages, robotsText, sitemaps, transport, mode: 'built_output', location: dir, llmsText, llmsFullText });
56
61
  }
57
62
 
58
63
  /** Build a SiteModel by fetching a deployed URL set (target URL + same-origin discovery, bounded). */
@@ -122,7 +127,7 @@ export async function buildSiteFromUrl(startUrl, {
122
127
  return site;
123
128
  }
124
129
 
125
- function assembleSite({ baseUrl, pages, robotsText, sitemaps, transport, mode, location, crawl = null }) {
130
+ function assembleSite({ baseUrl, pages, robotsText, sitemaps, transport, mode, location, crawl = null, llmsText = null, llmsFullText = null }) {
126
131
  const byUrl = new Map();
127
132
  for (const p of pages) byUrl.set(normalize(p.url), p);
128
133
 
@@ -179,6 +184,8 @@ function assembleSite({ baseUrl, pages, robotsText, sitemaps, transport, mode, l
179
184
  depth,
180
185
  normalize,
181
186
  crawl,
187
+ llmsTxt: llmsText != null ? { raw: llmsText, found: true, path: '/llms.txt' } : null,
188
+ llmsFullTxt: llmsFullText != null ? { raw: llmsFullText, found: true, path: '/llms-full.txt' } : null,
182
189
  };
183
190
  }
184
191
 
@@ -15,6 +15,7 @@ import {
15
15
  } from './providers.js';
16
16
 
17
17
  export { PROVIDERS, PROVIDER_IDS, parseProviderList, normalizeProviderId } from './providers.js';
18
+ import { diagnoseMcpTransports } from '../connectors/mcp/diagnostics.js';
18
19
 
19
20
  export const EXIT_CODES = Object.freeze({
20
21
  success: 0,
@@ -106,6 +107,7 @@ export function parseInstallerArgs(argv) {
106
107
  help: false,
107
108
  version: false,
108
109
  failOnUpdate: false,
110
+ mcp: false,
109
111
  installMode: 'copy',
110
112
  skillSelection: null,
111
113
  _: [],
@@ -121,6 +123,7 @@ export function parseInstallerArgs(argv) {
121
123
  else if (arg === '--dry-run') args.dryRun = true;
122
124
  else if (arg === '--force') args.force = true;
123
125
  else if (arg === '--json') args.json = true;
126
+ else if (arg === '--mcp') args.mcp = true;
124
127
  else if (arg === '--all') { args.all = true; args.yes = true; }
125
128
  else if (arg === '--help' || arg === '-h') args.help = true;
126
129
  else if (arg === '--version' || arg === '-v') args.version = true;
@@ -1039,7 +1042,8 @@ function combineInstalledState(skillState, profileState) {
1039
1042
  export async function doctorCommand(args, options = {}) {
1040
1043
  const context = resolveTargets('doctor', args, options);
1041
1044
  const packageInfo = loadPackageInfo(context.roots.packageRoot);
1042
- const capabilities = await runtimeCapabilities(options);
1045
+ const isMcp = Boolean(args?.mcp || options?.mcp);
1046
+ const capabilities = await runtimeCapabilities({ ...options, mcp: isMcp, packageRoot: context.roots.packageRoot });
1043
1047
  const checks = [];
1044
1048
  const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10);
1045
1049
  checks.push({ level: nodeMajor >= 20 ? 'PASS' : 'FAIL', message: `Node.js ${process.versions.node}` });
@@ -1073,8 +1077,21 @@ export async function doctorCommand(args, options = {}) {
1073
1077
  }
1074
1078
  }
1075
1079
 
1080
+ let mcpSummary = null;
1081
+ if (isMcp) {
1082
+ const mcpDiag = await diagnoseMcpTransports({
1083
+ packageRoot: context.roots.packageRoot,
1084
+ projectRoot: context.roots.projectRoot,
1085
+ ...options,
1086
+ });
1087
+ checks.push(...mcpDiag.checks);
1088
+ mcpSummary = mcpDiag.summary;
1089
+ }
1090
+
1076
1091
  const hasFail = checks.some((check) => check.level === 'FAIL');
1077
- return { ok: !hasFail, command: 'doctor', checks, capabilities, exitCode: hasFail ? EXIT_CODES.integrityFailure : EXIT_CODES.success };
1092
+ const result = { ok: !hasFail, command: 'doctor', checks, capabilities, exitCode: hasFail ? EXIT_CODES.integrityFailure : EXIT_CODES.success };
1093
+ if (mcpSummary) result.mcp = mcpSummary;
1094
+ return result;
1078
1095
  }
1079
1096
 
1080
1097
  async function optionalDependencyAvailable(name, options = {}) {
@@ -1148,6 +1165,24 @@ export async function runtimeCapabilities(options = {}) {
1148
1165
  limitations: [configured ? definition.readyLimitation : `${definition.credential} is not configured`],
1149
1166
  });
1150
1167
  }
1168
+
1169
+ if (options.mcp) {
1170
+ const pkgRoot = options.packageRoot || packageRoot();
1171
+ const pilotPath = path.resolve(pkgRoot, 'src/connectors/mcp/pilotServer.js');
1172
+ const pilotAvailable = exists(pilotPath);
1173
+ capabilities.push({
1174
+ id: 'mcp_transport',
1175
+ state: pilotAvailable ? 'ready' : 'missing_dependency',
1176
+ requirements: ['node:child_process', 'node:readline', 'pilotServer.js'],
1177
+ limitations: pilotAvailable
1178
+ ? [
1179
+ 'MCP transport is read-only and restricted to explicit allowlisted servers and tools',
1180
+ 'remote endpoints require HTTPS and must not resolve to private or loopback ranges',
1181
+ ]
1182
+ : ['MCP stdio pilotServer.js script was not found'],
1183
+ });
1184
+ }
1185
+
1151
1186
  return capabilities;
1152
1187
  }
1153
1188
 
@@ -1350,6 +1385,13 @@ function renderList(result) {
1350
1385
 
1351
1386
  function renderDoctor(result) {
1352
1387
  const lines = ['Citable doctor', '', ...result.checks.map((check) => `${check.level.padEnd(5)} ${check.message}`)];
1388
+ if (result.mcp) {
1389
+ lines.push('', 'MCP transport diagnostic');
1390
+ lines.push(` allowlist: ${result.mcp.servers_count} server(s), ${result.mcp.tools_count} read-only tool(s)`);
1391
+ lines.push(` stdio execution: ${result.mcp.stdio_ready ? 'ready' : 'failed'}`);
1392
+ lines.push(` http guards: ${result.mcp.http_guards_active ? 'active' : 'failed'}`);
1393
+ lines.push(` registry bindings: ${result.mcp.connections_found} configured connection(s)`);
1394
+ }
1353
1395
  if (result.capabilities?.length) {
1354
1396
  lines.push('', 'Runtime capabilities');
1355
1397
  for (const capability of result.capabilities) {
@@ -1391,6 +1433,7 @@ Options
1391
1433
  --project, --local, -p Install/check project-local skill locations
1392
1434
  --global, --user, -g Install/check user-global skill locations
1393
1435
  --scope=<project|global> Scope equivalent
1436
+ --mcp Run MCP transport diagnostics (stdio, HTTP guards, allowlist, connections)
1394
1437
  --yes, -y Confirm non-interactively
1395
1438
  --dry-run Preview filesystem changes without mutation
1396
1439
  --force Replace locally modified or unmanaged citable directory