@nebulacomponents/citable 1.15.1 → 1.16.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 (211) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +3 -3
  3. package/dist/universal/.agents/skills/citable/SKILL.md +5 -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 +14 -11
  10. package/dist/universal/.agents/skills/citable/schemas/experiment.schema.json +1 -1
  11. package/dist/universal/.agents/skills/citable/schemas/fleet.schema.json +93 -0
  12. package/dist/universal/.agents/skills/citable/schemas/funnel.schema.json +116 -0
  13. package/dist/universal/.agents/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  14. package/dist/universal/.agents/skills/citable/schemas/observation.schema.json +1 -1
  15. package/dist/universal/.claude/agents/citable/manifest.json +2 -2
  16. package/dist/universal/.claude/skills/citable/SKILL.md +5 -1
  17. package/dist/universal/.claude/skills/citable/VERSION +1 -1
  18. package/dist/universal/.claude/skills/citable/commands/README.md +3 -1
  19. package/dist/universal/.claude/skills/citable/commands/governance.md +12 -0
  20. package/dist/universal/.claude/skills/citable/commands/measurement.md +20 -0
  21. package/dist/universal/.claude/skills/citable/commands/reporting.md +16 -0
  22. package/dist/universal/.claude/skills/citable/manifest.json +14 -11
  23. package/dist/universal/.claude/skills/citable/schemas/experiment.schema.json +1 -1
  24. package/dist/universal/.claude/skills/citable/schemas/fleet.schema.json +93 -0
  25. package/dist/universal/.claude/skills/citable/schemas/funnel.schema.json +116 -0
  26. package/dist/universal/.claude/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  27. package/dist/universal/.claude/skills/citable/schemas/observation.schema.json +1 -1
  28. package/dist/universal/.cursor/skills/citable/SKILL.md +5 -1
  29. package/dist/universal/.cursor/skills/citable/VERSION +1 -1
  30. package/dist/universal/.cursor/skills/citable/commands/README.md +3 -1
  31. package/dist/universal/.cursor/skills/citable/commands/governance.md +12 -0
  32. package/dist/universal/.cursor/skills/citable/commands/measurement.md +20 -0
  33. package/dist/universal/.cursor/skills/citable/commands/reporting.md +16 -0
  34. package/dist/universal/.cursor/skills/citable/manifest.json +14 -11
  35. package/dist/universal/.cursor/skills/citable/schemas/experiment.schema.json +1 -1
  36. package/dist/universal/.cursor/skills/citable/schemas/fleet.schema.json +93 -0
  37. package/dist/universal/.cursor/skills/citable/schemas/funnel.schema.json +116 -0
  38. package/dist/universal/.cursor/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  39. package/dist/universal/.cursor/skills/citable/schemas/observation.schema.json +1 -1
  40. package/dist/universal/.gemini/skills/citable/SKILL.md +5 -1
  41. package/dist/universal/.gemini/skills/citable/VERSION +1 -1
  42. package/dist/universal/.gemini/skills/citable/commands/README.md +3 -1
  43. package/dist/universal/.gemini/skills/citable/commands/governance.md +12 -0
  44. package/dist/universal/.gemini/skills/citable/commands/measurement.md +20 -0
  45. package/dist/universal/.gemini/skills/citable/commands/reporting.md +16 -0
  46. package/dist/universal/.gemini/skills/citable/manifest.json +14 -11
  47. package/dist/universal/.gemini/skills/citable/schemas/experiment.schema.json +1 -1
  48. package/dist/universal/.gemini/skills/citable/schemas/fleet.schema.json +93 -0
  49. package/dist/universal/.gemini/skills/citable/schemas/funnel.schema.json +116 -0
  50. package/dist/universal/.gemini/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  51. package/dist/universal/.gemini/skills/citable/schemas/observation.schema.json +1 -1
  52. package/dist/universal/.github/skills/citable/SKILL.md +5 -1
  53. package/dist/universal/.github/skills/citable/VERSION +1 -1
  54. package/dist/universal/.github/skills/citable/commands/README.md +3 -1
  55. package/dist/universal/.github/skills/citable/commands/governance.md +12 -0
  56. package/dist/universal/.github/skills/citable/commands/measurement.md +20 -0
  57. package/dist/universal/.github/skills/citable/commands/reporting.md +16 -0
  58. package/dist/universal/.github/skills/citable/manifest.json +14 -11
  59. package/dist/universal/.github/skills/citable/schemas/experiment.schema.json +1 -1
  60. package/dist/universal/.github/skills/citable/schemas/fleet.schema.json +93 -0
  61. package/dist/universal/.github/skills/citable/schemas/funnel.schema.json +116 -0
  62. package/dist/universal/.github/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  63. package/dist/universal/.github/skills/citable/schemas/observation.schema.json +1 -1
  64. package/dist/universal/.kiro/skills/citable/SKILL.md +5 -1
  65. package/dist/universal/.kiro/skills/citable/VERSION +1 -1
  66. package/dist/universal/.kiro/skills/citable/commands/README.md +3 -1
  67. package/dist/universal/.kiro/skills/citable/commands/governance.md +12 -0
  68. package/dist/universal/.kiro/skills/citable/commands/measurement.md +20 -0
  69. package/dist/universal/.kiro/skills/citable/commands/reporting.md +16 -0
  70. package/dist/universal/.kiro/skills/citable/manifest.json +14 -11
  71. package/dist/universal/.kiro/skills/citable/schemas/experiment.schema.json +1 -1
  72. package/dist/universal/.kiro/skills/citable/schemas/fleet.schema.json +93 -0
  73. package/dist/universal/.kiro/skills/citable/schemas/funnel.schema.json +116 -0
  74. package/dist/universal/.kiro/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  75. package/dist/universal/.kiro/skills/citable/schemas/observation.schema.json +1 -1
  76. package/dist/universal/.opencode/skills/citable/SKILL.md +5 -1
  77. package/dist/universal/.opencode/skills/citable/VERSION +1 -1
  78. package/dist/universal/.opencode/skills/citable/commands/README.md +3 -1
  79. package/dist/universal/.opencode/skills/citable/commands/governance.md +12 -0
  80. package/dist/universal/.opencode/skills/citable/commands/measurement.md +20 -0
  81. package/dist/universal/.opencode/skills/citable/commands/reporting.md +16 -0
  82. package/dist/universal/.opencode/skills/citable/manifest.json +14 -11
  83. package/dist/universal/.opencode/skills/citable/schemas/experiment.schema.json +1 -1
  84. package/dist/universal/.opencode/skills/citable/schemas/fleet.schema.json +93 -0
  85. package/dist/universal/.opencode/skills/citable/schemas/funnel.schema.json +116 -0
  86. package/dist/universal/.opencode/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  87. package/dist/universal/.opencode/skills/citable/schemas/observation.schema.json +1 -1
  88. package/dist/universal/.pi/agent/skills/citable/SKILL.md +5 -1
  89. package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
  90. package/dist/universal/.pi/agent/skills/citable/commands/README.md +3 -1
  91. package/dist/universal/.pi/agent/skills/citable/commands/governance.md +12 -0
  92. package/dist/universal/.pi/agent/skills/citable/commands/measurement.md +20 -0
  93. package/dist/universal/.pi/agent/skills/citable/commands/reporting.md +16 -0
  94. package/dist/universal/.pi/agent/skills/citable/manifest.json +14 -11
  95. package/dist/universal/.pi/agent/skills/citable/schemas/experiment.schema.json +1 -1
  96. package/dist/universal/.pi/agent/skills/citable/schemas/fleet.schema.json +93 -0
  97. package/dist/universal/.pi/agent/skills/citable/schemas/funnel.schema.json +116 -0
  98. package/dist/universal/.pi/agent/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  99. package/dist/universal/.pi/agent/skills/citable/schemas/observation.schema.json +1 -1
  100. package/dist/universal/.qoder/skills/citable/SKILL.md +5 -1
  101. package/dist/universal/.qoder/skills/citable/VERSION +1 -1
  102. package/dist/universal/.qoder/skills/citable/commands/README.md +3 -1
  103. package/dist/universal/.qoder/skills/citable/commands/governance.md +12 -0
  104. package/dist/universal/.qoder/skills/citable/commands/measurement.md +20 -0
  105. package/dist/universal/.qoder/skills/citable/commands/reporting.md +16 -0
  106. package/dist/universal/.qoder/skills/citable/manifest.json +14 -11
  107. package/dist/universal/.qoder/skills/citable/schemas/experiment.schema.json +1 -1
  108. package/dist/universal/.qoder/skills/citable/schemas/fleet.schema.json +93 -0
  109. package/dist/universal/.qoder/skills/citable/schemas/funnel.schema.json +116 -0
  110. package/dist/universal/.qoder/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  111. package/dist/universal/.qoder/skills/citable/schemas/observation.schema.json +1 -1
  112. package/dist/universal/.rovodev/skills/citable/SKILL.md +5 -1
  113. package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
  114. package/dist/universal/.rovodev/skills/citable/commands/README.md +3 -1
  115. package/dist/universal/.rovodev/skills/citable/commands/governance.md +12 -0
  116. package/dist/universal/.rovodev/skills/citable/commands/measurement.md +20 -0
  117. package/dist/universal/.rovodev/skills/citable/commands/reporting.md +16 -0
  118. package/dist/universal/.rovodev/skills/citable/manifest.json +14 -11
  119. package/dist/universal/.rovodev/skills/citable/schemas/experiment.schema.json +1 -1
  120. package/dist/universal/.rovodev/skills/citable/schemas/fleet.schema.json +93 -0
  121. package/dist/universal/.rovodev/skills/citable/schemas/funnel.schema.json +116 -0
  122. package/dist/universal/.rovodev/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  123. package/dist/universal/.rovodev/skills/citable/schemas/observation.schema.json +1 -1
  124. package/dist/universal/.trae/skills/citable/SKILL.md +5 -1
  125. package/dist/universal/.trae/skills/citable/VERSION +1 -1
  126. package/dist/universal/.trae/skills/citable/commands/README.md +3 -1
  127. package/dist/universal/.trae/skills/citable/commands/governance.md +12 -0
  128. package/dist/universal/.trae/skills/citable/commands/measurement.md +20 -0
  129. package/dist/universal/.trae/skills/citable/commands/reporting.md +16 -0
  130. package/dist/universal/.trae/skills/citable/manifest.json +14 -11
  131. package/dist/universal/.trae/skills/citable/schemas/experiment.schema.json +1 -1
  132. package/dist/universal/.trae/skills/citable/schemas/fleet.schema.json +93 -0
  133. package/dist/universal/.trae/skills/citable/schemas/funnel.schema.json +116 -0
  134. package/dist/universal/.trae/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  135. package/dist/universal/.trae/skills/citable/schemas/observation.schema.json +1 -1
  136. package/dist/universal/.trae-cn/skills/citable/SKILL.md +5 -1
  137. package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
  138. package/dist/universal/.trae-cn/skills/citable/commands/README.md +3 -1
  139. package/dist/universal/.trae-cn/skills/citable/commands/governance.md +12 -0
  140. package/dist/universal/.trae-cn/skills/citable/commands/measurement.md +20 -0
  141. package/dist/universal/.trae-cn/skills/citable/commands/reporting.md +16 -0
  142. package/dist/universal/.trae-cn/skills/citable/manifest.json +14 -11
  143. package/dist/universal/.trae-cn/skills/citable/schemas/experiment.schema.json +1 -1
  144. package/dist/universal/.trae-cn/skills/citable/schemas/fleet.schema.json +93 -0
  145. package/dist/universal/.trae-cn/skills/citable/schemas/funnel.schema.json +116 -0
  146. package/dist/universal/.trae-cn/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
  147. package/dist/universal/.trae-cn/skills/citable/schemas/observation.schema.json +1 -1
  148. package/dist/universal/manifest.json +172 -136
  149. package/package.json +1 -1
  150. package/schemas/experiment.schema.json +1 -1
  151. package/schemas/fleet.schema.json +93 -0
  152. package/schemas/funnel.schema.json +116 -0
  153. package/schemas/mcp-transport-envelope.schema.json +244 -0
  154. package/schemas/observation.schema.json +1 -1
  155. package/skill/SKILL.md +5 -1
  156. package/skill/commands/README.md +3 -1
  157. package/skill/commands/governance.md +12 -0
  158. package/skill/commands/measurement.md +20 -0
  159. package/skill/commands/reporting.md +16 -0
  160. package/src/cli/index.js +383 -8
  161. package/src/commands/attributeImpact.js +54 -0
  162. package/src/commands/connect.js +3 -0
  163. package/src/commands/edgeRules.js +120 -0
  164. package/src/commands/exceptions.js +241 -0
  165. package/src/commands/fleet.js +104 -0
  166. package/src/commands/generateLlmsTxt.js +111 -0
  167. package/src/commands/inspectAeo.js +74 -0
  168. package/src/commands/inspectCro.js +82 -0
  169. package/src/commands/inspectGeo.js +52 -0
  170. package/src/commands/inspectSerp.js +317 -0
  171. package/src/commands/lintComponents.js +132 -0
  172. package/src/commands/monitor.js +138 -0
  173. package/src/commands/observe.js +6 -1
  174. package/src/commands/planExperiment.js +68 -0
  175. package/src/commands/probe.js +70 -0
  176. package/src/commands/reportConsensus.js +48 -0
  177. package/src/commands/testFunnel.js +107 -0
  178. package/src/connectors/index.js +6 -1
  179. package/src/connectors/indexnow.js +431 -0
  180. package/src/connectors/mcp/allowlist.js +107 -0
  181. package/src/connectors/mcp/client.js +239 -0
  182. package/src/connectors/mcp/diagnostics.js +214 -0
  183. package/src/connectors/mcp/gscServer.js +155 -0
  184. package/src/connectors/mcp/http.js +130 -0
  185. package/src/connectors/mcp/index.js +7 -0
  186. package/src/connectors/mcp/pilot.js +138 -0
  187. package/src/connectors/mcp/pilotServer.js +80 -0
  188. package/src/connectors/mcp/sanitizer.js +38 -0
  189. package/src/connectors/mcp/stdio.js +178 -0
  190. package/src/crawler/ipRanges.js +161 -0
  191. package/src/detectors/agent.js +127 -0
  192. package/src/detectors/ans.js +282 -0
  193. package/src/detectors/crawl.js +46 -0
  194. package/src/detectors/cro.js +777 -0
  195. package/src/detectors/cwv.js +38 -1
  196. package/src/detectors/evd.js +101 -0
  197. package/src/detectors/framework.js +1 -1
  198. package/src/detectors/geoReco.js +408 -0
  199. package/src/detectors/index.js +4 -2
  200. package/src/detectors/link.js +112 -1
  201. package/src/detectors/page.js +101 -0
  202. package/src/detectors/schemaData.js +529 -0
  203. package/src/detectors/tech.js +306 -0
  204. package/src/extractor/page.js +186 -2
  205. package/src/extractor/site.js +9 -2
  206. package/src/installer/index.js +45 -2
  207. package/src/monitoring/alertDelivery.js +101 -7
  208. package/src/observations/attribution.js +217 -0
  209. package/src/registries/index.js +14 -0
  210. package/src/reporting/consensus.js +416 -0
  211. package/src/reporting/executiveExport.js +132 -0
@@ -0,0 +1,82 @@
1
+ import { buildContext } from './context.js';
2
+ import { registryPageFor, safePath } from '../detectors/framework.js';
3
+ import { selectDetectors } from '../detectors/index.js';
4
+ import { runDetectors } from '../detectors/framework.js';
5
+
6
+ /**
7
+ * `citable inspect cro <page>` — evaluate page conversion readiness, CTA visibility, form friction, and trust badges.
8
+ */
9
+ export async function inspectCro(root, pageRef, { target, baseUrl, refDate } = {}) {
10
+ const ctx = await buildContext(root, { target, baseUrl, refDate });
11
+ if (!ctx.site) throw new Error('inspect cro requires a site target (built output directory or URL)');
12
+ const wanted = safePath(pageRef, ctx.site.baseUrl);
13
+ const page = ctx.site.pages.find((p) => safePath(p.url) === wanted || p.sourceFile === pageRef);
14
+ if (!page) throw new Error(`page not found in audited output: ${pageRef}`);
15
+
16
+ const reg = registryPageFor(ctx, page);
17
+ const declaredAction = reg?.conversion_action ?? null;
18
+
19
+ // Evaluate findings for this single page
20
+ const single = { ...ctx, site: { ...ctx.site, pages: [page] } };
21
+ const { findings } = runDetectors(selectDetectors({ namespaces: ['CRO'] }), single);
22
+ const croFindings = findings.filter((f) => f.subject.identifier === page.url || f.subject.url === page.url);
23
+
24
+ const ctas = page.ctas || [];
25
+ const forms = page.forms || [];
26
+ const trustBadges = page.trustBadges || [];
27
+
28
+ let status = 'ready';
29
+ if (croFindings.some((f) => f.classification.severity === 'high')) {
30
+ status = 'critical_friction';
31
+ } else if (croFindings.length > 0) {
32
+ status = 'needs_attention';
33
+ } else if (!declaredAction && ctas.length === 0) {
34
+ status = 'unmonitored';
35
+ }
36
+
37
+ const titleStem = page.title ? page.title.split(/[|—–-]/)[0].trim() : '';
38
+ const h1Text = page.h1s?.[0]?.text || '';
39
+
40
+ return {
41
+ url: page.url,
42
+ sourceFile: page.sourceFile,
43
+ status: page.status,
44
+ declared_conversion_action: declaredAction,
45
+ primary_intent: reg?.primary_intent ?? null,
46
+ page_type: reg?.page_type ?? null,
47
+ conversion_status: status,
48
+ title_to_h1_alignment: {
49
+ titleStem,
50
+ h1Text,
51
+ hasScentGap: croFindings.some((f) => f.detector_id === 'CRO-005'),
52
+ },
53
+ hero_cta_count: ctas.filter((c) => c.inHero).length,
54
+ has_choice_overload: croFindings.some((f) => f.detector_id === 'CRO-010'),
55
+ analytics_installed: (page.analyticsTags || []).length > 0,
56
+ analytics_tags: page.analyticsTags || [],
57
+ nav_links_count: page.navLinksCount || 0,
58
+ ctas: ctas.map((c) => ({
59
+ text: c.text,
60
+ tag: c.tag,
61
+ target: c.target,
62
+ isPrimary: c.isPrimary,
63
+ inHero: c.inHero || false,
64
+ inNav: c.inNav || false,
65
+ })),
66
+ forms: forms.map((f) => ({
67
+ action: f.action,
68
+ method: f.method,
69
+ fieldCount: f.fieldCount,
70
+ hasSubmit: f.hasSubmit,
71
+ fields: f.inputs.map((inp) => inp.name || inp.type),
72
+ inputsWithAutocomplete: f.inputs.filter((inp) => Boolean(inp.autocomplete)).length,
73
+ inputsWithLabel: f.inputs.filter((inp) => Boolean(inp.hasLabel)).length,
74
+ })),
75
+ trustBadges: trustBadges.map((t) => t.signal),
76
+ findings: croFindings.map((f) => ({
77
+ detector_id: f.detector_id,
78
+ severity: f.classification.severity,
79
+ summary: f.observation.summary,
80
+ })),
81
+ };
82
+ }
@@ -0,0 +1,52 @@
1
+ import { buildContext } from './context.js';
2
+ import { registryPageFor, safePath } from '../detectors/framework.js';
3
+ import { selectDetectors } from '../detectors/index.js';
4
+ import { runDetectors } from '../detectors/framework.js';
5
+
6
+ /**
7
+ * `citable inspect geo <page>` — evaluate page RAG chunkability, section word distribution,
8
+ * entity density, and citation anchoring for generative engine retrieval.
9
+ */
10
+ export async function inspectGeo(root, pageRef, { target, baseUrl, refDate } = {}) {
11
+ const ctx = await buildContext(root, { target, baseUrl, refDate });
12
+ if (!ctx.site) throw new Error('inspect geo requires a site target (built output directory or URL)');
13
+ const wanted = safePath(pageRef, ctx.site.baseUrl);
14
+ const page = ctx.site.pages.find((p) => safePath(p.url) === wanted || p.sourceFile === pageRef);
15
+ if (!page) throw new Error(`page not found in audited output: ${pageRef}`);
16
+
17
+ const reg = registryPageFor(ctx, page);
18
+
19
+ // Evaluate findings for this single page across GEO and RECO namespaces
20
+ const single = { ...ctx, site: { ...ctx.site, pages: [page] } };
21
+ const { findings } = runDetectors(selectDetectors({ namespaces: ['GEO', 'RECO'] }), single);
22
+ const geoFindings = findings.filter((f) => f.subject.identifier === page.url || f.subject.url === page.url || f.subject.identifier?.startsWith(page.url));
23
+
24
+ const totalWords = page.rawVisibleWordCount || page.wordCount || 0;
25
+ const estimatedChunks = Math.max(1, Math.ceil(totalWords / 300));
26
+
27
+ let retrievalPosture = 'optimal';
28
+ if (geoFindings.some((f) => f.classification.severity === 'high')) {
29
+ retrievalPosture = 'erased_or_severed';
30
+ } else if (geoFindings.length > 0) {
31
+ retrievalPosture = 'needs_chunk_optimization';
32
+ }
33
+
34
+ return {
35
+ url: page.url,
36
+ sourceFile: page.sourceFile,
37
+ status: page.status,
38
+ page_type: reg?.page_type ?? null,
39
+ word_count: totalWords,
40
+ retrieval_posture: retrievalPosture,
41
+ rag_metrics: {
42
+ estimated_chunks_300w: estimatedChunks,
43
+ total_headings: (page.headings || []).length,
44
+ average_words_per_chunk: Math.round(totalWords / estimatedChunks),
45
+ },
46
+ findings: geoFindings.map((f) => ({
47
+ detector_id: f.detector_id,
48
+ severity: f.classification.severity,
49
+ summary: f.observation.summary,
50
+ })),
51
+ };
52
+ }
@@ -0,0 +1,317 @@
1
+ import { buildContext } from './context.js';
2
+ import { safePath } from '../detectors/framework.js';
3
+ import { selectDetectors } from '../detectors/index.js';
4
+ import { runDetectors } from '../detectors/framework.js';
5
+
6
+ /**
7
+ * Approximate Google SERP title pixel width in Arial ~20px font.
8
+ */
9
+ export function estimateTitlePixelWidth(title) {
10
+ if (!title) return 0;
11
+ let px = 0;
12
+ for (const ch of title) {
13
+ if (/[wmWM]/.test(ch)) px += 17;
14
+ else if (/[ijlI|!.:;, '`\-]/.test(ch)) px += 5;
15
+ else if (/[frt]/.test(ch)) px += 7;
16
+ else if (/[A-Z]/.test(ch)) px += 13;
17
+ else px += 10;
18
+ }
19
+ return px;
20
+ }
21
+
22
+ /**
23
+ * Truncate title to fit pixel limit (e.g. 600px for desktop).
24
+ */
25
+ export function truncateTitleByPixels(title, maxPixels = 600) {
26
+ if (!title) return { text: '', truncated: false, pixelWidth: 0 };
27
+ const totalPx = estimateTitlePixelWidth(title);
28
+ if (totalPx <= maxPixels) {
29
+ return { text: title, truncated: false, pixelWidth: totalPx };
30
+ }
31
+
32
+ let curr = '';
33
+ for (const ch of title) {
34
+ if (estimateTitlePixelWidth(curr + ch + ' ...') > maxPixels) {
35
+ break;
36
+ }
37
+ curr += ch;
38
+ }
39
+ return {
40
+ text: curr.trim() + ' ...',
41
+ truncated: true,
42
+ pixelWidth: totalPx,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Truncate description text by character count.
48
+ */
49
+ export function truncateSnippet(text, maxChars = 160) {
50
+ if (!text) return { text: '', truncated: false };
51
+ if (text.length <= maxChars) {
52
+ return { text, truncated: false };
53
+ }
54
+ return {
55
+ text: text.slice(0, maxChars - 4).trim() + ' ...',
56
+ truncated: true,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Build simulated Google SERP breadcrumb string.
62
+ */
63
+ export function buildSerpBreadcrumb(page) {
64
+ // Check for schema BreadcrumbList first
65
+ for (const j of page.jsonLd || []) {
66
+ for (const b of j.blocks || []) {
67
+ const type = [].concat(b['@type'] || []).join(',');
68
+ if (/BreadcrumbList/i.test(type) && Array.isArray(b.itemListElement) && b.itemListElement.length > 0) {
69
+ const sorted = [...b.itemListElement].sort((a, b) => (Number(a.position) || 0) - (Number(b.position) || 0));
70
+ const names = sorted.map((it) => it?.name || it?.item?.name).filter(Boolean);
71
+ if (names.length > 0) {
72
+ return {
73
+ source: 'schema',
74
+ display: names.join(' > '),
75
+ items: names,
76
+ };
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ // Fallback to URL path segments
83
+ try {
84
+ const u = new URL(page.url);
85
+ const host = u.hostname.replace(/^www\./, '');
86
+ const parts = u.pathname.split('/').filter(Boolean);
87
+ const display = [host, ...parts].join(' > ');
88
+ return {
89
+ source: 'url_fallback',
90
+ display,
91
+ items: [host, ...parts],
92
+ };
93
+ } catch {
94
+ return {
95
+ source: 'raw',
96
+ display: page.url,
97
+ items: [page.url],
98
+ };
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Evaluate Google SERP rich snippet feature eligibility.
104
+ */
105
+ export function evaluateRichSnippetEligibility(page) {
106
+ const blocks = [];
107
+ for (const j of page.jsonLd || []) {
108
+ for (const b of j.blocks || []) {
109
+ if (b && typeof b === 'object') blocks.push(b);
110
+ }
111
+ }
112
+
113
+ const results = {};
114
+
115
+ // 1. BreadcrumbList
116
+ const breadcrumbs = blocks.filter((b) => /BreadcrumbList/i.test([].concat(b['@type'] || []).join(',')));
117
+ if (breadcrumbs.length === 0) {
118
+ results.breadcrumb = { status: 'missing', eligible: false, issues: ['No BreadcrumbList JSON-LD block found'] };
119
+ } else {
120
+ const b = breadcrumbs[0];
121
+ const items = Array.isArray(b.itemListElement) ? b.itemListElement : [];
122
+ const issues = [];
123
+ if (items.length < 2) issues.push('Breadcrumbs should have at least 2 navigation levels');
124
+ for (let i = 0; i < items.length; i++) {
125
+ const it = items[i];
126
+ if (!it?.name && !it?.item?.name) issues.push(`Item ${i + 1} missing name`);
127
+ if (i < items.length - 1 && !it?.item && !it?.url) issues.push(`Item ${i + 1} missing item URL`);
128
+ }
129
+ results.breadcrumb = {
130
+ status: issues.length === 0 ? 'eligible' : 'ineligible',
131
+ eligible: issues.length === 0,
132
+ itemCount: items.length,
133
+ issues,
134
+ };
135
+ }
136
+
137
+ // 2. Product
138
+ const products = blocks.filter((b) => /Product/i.test([].concat(b['@type'] || []).join(',')));
139
+ if (products.length === 0) {
140
+ results.product = { status: 'not_applicable', eligible: false, issues: [] };
141
+ } else {
142
+ const prod = products[0];
143
+ const issues = [];
144
+ if (!prod.name) issues.push('Product missing name');
145
+ const hasOffer = prod.offers && (prod.offers.price !== undefined || Array.isArray(prod.offers));
146
+ const hasRating = prod.aggregateRating && prod.aggregateRating.ratingValue !== undefined;
147
+ if (!hasOffer && !hasRating) issues.push('Product requires either offers (with price) or aggregateRating (with ratingValue)');
148
+ results.product = {
149
+ status: issues.length === 0 ? 'eligible' : 'ineligible',
150
+ eligible: issues.length === 0,
151
+ hasOffers: !!hasOffer,
152
+ hasAggregateRating: !!hasRating,
153
+ issues,
154
+ };
155
+ }
156
+
157
+ // 3. FAQPage
158
+ const faqs = blocks.filter((b) => /FAQPage/i.test([].concat(b['@type'] || []).join(',')));
159
+ if (faqs.length === 0) {
160
+ results.faq = { status: 'not_applicable', eligible: false, issues: [] };
161
+ } else {
162
+ const faq = faqs[0];
163
+ const issues = [];
164
+ const questions = [].concat(faq.mainEntity || []);
165
+ if (questions.length === 0) issues.push('FAQPage has no mainEntity question entries');
166
+ for (let i = 0; i < questions.length; i++) {
167
+ const q = questions[i];
168
+ if (!q.name) issues.push(`Question ${i + 1} missing name`);
169
+ const ans = q.acceptedAnswer?.text;
170
+ if (!ans) issues.push(`Question ${i + 1} missing acceptedAnswer.text`);
171
+ }
172
+ results.faq = {
173
+ status: issues.length === 0 ? 'eligible' : 'ineligible',
174
+ eligible: issues.length === 0,
175
+ questionCount: questions.length,
176
+ issues,
177
+ };
178
+ }
179
+
180
+ // 4. Article / BlogPosting / NewsArticle
181
+ const articles = blocks.filter((b) => /Article|BlogPosting|NewsArticle/i.test([].concat(b['@type'] || []).join(',')));
182
+ if (articles.length === 0) {
183
+ results.article = { status: 'not_applicable', eligible: false, issues: [] };
184
+ } else {
185
+ const art = articles[0];
186
+ const issues = [];
187
+ if (!art.headline) issues.push('Article missing headline');
188
+ if (!art.image) issues.push('Article missing image');
189
+ if (!art.datePublished) issues.push('Article missing datePublished');
190
+ if (!art.author) issues.push('Article missing author');
191
+ results.article = {
192
+ status: issues.length === 0 ? 'eligible' : 'ineligible',
193
+ eligible: issues.length === 0,
194
+ issues,
195
+ };
196
+ }
197
+
198
+ // 5. VideoObject
199
+ const videos = blocks.filter((b) => /VideoObject/i.test([].concat(b['@type'] || []).join(',')));
200
+ if (videos.length === 0) {
201
+ results.video = { status: 'not_applicable', eligible: false, issues: [] };
202
+ } else {
203
+ const vid = videos[0];
204
+ const issues = [];
205
+ if (!vid.name) issues.push('Video missing name');
206
+ if (!vid.description) issues.push('Video missing description');
207
+ if (!vid.uploadDate) issues.push('Video missing uploadDate');
208
+ if (!vid.thumbnailUrl && !vid.thumbnail) issues.push('Video missing thumbnailUrl');
209
+ if (!vid.contentUrl && !vid.embedUrl) issues.push('Video missing contentUrl or embedUrl');
210
+ results.video = {
211
+ status: issues.length === 0 ? 'eligible' : 'ineligible',
212
+ eligible: issues.length === 0,
213
+ issues,
214
+ };
215
+ }
216
+
217
+ // 6. Organization
218
+ const orgs = blocks.filter((b) => /Organization|Corporation/i.test([].concat(b['@type'] || []).join(',')));
219
+ if (orgs.length === 0) {
220
+ results.organization = { status: 'not_applicable', eligible: false, issues: [] };
221
+ } else {
222
+ const org = orgs[0];
223
+ const issues = [];
224
+ if (!org.name) issues.push('Organization missing name');
225
+ if (!org.url) issues.push('Organization missing url');
226
+ if (!org.logo && !org.image) issues.push('Organization missing logo');
227
+ results.organization = {
228
+ status: issues.length === 0 ? 'eligible' : 'ineligible',
229
+ eligible: issues.length === 0,
230
+ issues,
231
+ };
232
+ }
233
+
234
+ return results;
235
+ }
236
+
237
+ /**
238
+ * `citable inspect serp <page>` — simulate Google SERP representation and evaluate rich snippet eligibility.
239
+ */
240
+ export function renderSerpVisualCard({ breadcrumb, title, snippet, richSnippets = {} }) {
241
+ const eligibleBadges = Object.entries(richSnippets)
242
+ .filter(([, v]) => v.status === 'eligible')
243
+ .map(([k]) => k);
244
+ const badgeStr = eligibleBadges.length > 0 ? ` [Rich Features: ${eligibleBadges.join(', ')}]` : '';
245
+ const cleanTitle = (title || '').slice(0, 64);
246
+ const cleanBc = (breadcrumb || '').slice(0, 64);
247
+ const cleanSnip = (snippet || '').slice(0, 70);
248
+
249
+ return [
250
+ ' ┌────────────────────────────────────────────────────────────────────────┐',
251
+ ` │ 🌐 ${cleanBc.padEnd(68)} │`,
252
+ ` │ ${cleanTitle.padEnd(70)} │`,
253
+ ` │ ${cleanSnip.padEnd(70)} │`,
254
+ ...(badgeStr ? [` │ ${badgeStr.padEnd(70)} │`] : []),
255
+ ' └────────────────────────────────────────────────────────────────────────┘',
256
+ ].join('\n');
257
+ }
258
+
259
+ export async function inspectSerp(root, pageRef, { target, baseUrl, refDate } = {}) {
260
+ const ctx = await buildContext(root, { target, baseUrl, refDate });
261
+ if (!ctx.site) throw new Error('inspect serp requires a site target (built output directory or URL)');
262
+ const wanted = safePath(pageRef, ctx.site.baseUrl);
263
+ const page = ctx.site.pages.find((p) => safePath(p.url) === wanted || p.sourceFile === pageRef);
264
+ if (!page) throw new Error(`page not found in audited output: ${pageRef}`);
265
+
266
+ const breadcrumb = buildSerpBreadcrumb(page);
267
+ const desktopTitle = truncateTitleByPixels(page.title || '', 600);
268
+ const desktopSnippet = truncateSnippet(page.metaDescription || page.paragraphs?.[0] || '', 160);
269
+
270
+ const mobileTitle = truncateTitleByPixels(page.title || '', 960);
271
+ const mobileSnippet = truncateSnippet(page.metaDescription || page.paragraphs?.[0] || '', 200);
272
+
273
+ const richSnippets = evaluateRichSnippetEligibility(page);
274
+
275
+ // Run SCHEMA detectors specifically on this page
276
+ const single = { ...ctx, site: { ...ctx.site, pages: [page] } };
277
+ const { findings } = runDetectors(selectDetectors({ namespaces: ['SCHEMA'] }), single);
278
+ const pageFindings = findings.filter((f) => f.subject.identifier?.startsWith(page.url) || f.subject.url === page.url);
279
+
280
+ const visualCard = renderSerpVisualCard({
281
+ breadcrumb: desktopTitle.text ? desktopSnippet.text ? breadcrumb.display : breadcrumb.display : breadcrumb.display,
282
+ title: desktopTitle.text,
283
+ snippet: desktopSnippet.text,
284
+ richSnippets,
285
+ });
286
+
287
+ return {
288
+ url: page.url,
289
+ sourceFile: page.sourceFile,
290
+ title: page.title || '(none)',
291
+ metaDescription: page.metaDescription || '(none)',
292
+ breadcrumb,
293
+ visualCard,
294
+ desktop: {
295
+ breadcrumb: breadcrumb.display,
296
+ title: desktopTitle.text,
297
+ titlePixelWidth: desktopTitle.pixelWidth,
298
+ isTitleTruncated: desktopTitle.truncated,
299
+ snippet: desktopSnippet.text,
300
+ isSnippetTruncated: desktopSnippet.truncated,
301
+ },
302
+ mobile: {
303
+ breadcrumb: breadcrumb.display,
304
+ title: mobileTitle.text,
305
+ titlePixelWidth: mobileTitle.pixelWidth,
306
+ isTitleTruncated: mobileTitle.truncated,
307
+ snippet: mobileSnippet.text,
308
+ isSnippetTruncated: mobileSnippet.truncated,
309
+ },
310
+ richSnippets,
311
+ findings: pageFindings.map((f) => ({
312
+ detector_id: f.detector_id,
313
+ severity: f.classification.severity,
314
+ summary: f.observation.summary,
315
+ })),
316
+ };
317
+ }
@@ -0,0 +1,132 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ function findComponentFiles(dir, fileList = []) {
5
+ if (!fs.existsSync(dir)) return fileList;
6
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
7
+ for (const entry of entries) {
8
+ const fullPath = path.join(dir, entry.name);
9
+ if (entry.isDirectory()) {
10
+ if (entry.name !== "node_modules" && entry.name !== ".git" && entry.name !== "dist") {
11
+ findComponentFiles(fullPath, fileList);
12
+ }
13
+ } else if (/\.(jsx|tsx|vue|svelte|html)$/i.test(entry.name)) {
14
+ fileList.push(fullPath);
15
+ }
16
+ }
17
+ return fileList;
18
+ }
19
+
20
+ /**
21
+ * Statically lints React/JSX/TSX/Vue UI component source code for SEO/AEO/CRO anti-patterns.
22
+ */
23
+ export async function lintComponents(targetDir) {
24
+ const resolvedDir = path.resolve(process.cwd(), targetDir || "src/components");
25
+ const files = findComponentFiles(resolvedDir);
26
+
27
+ const findings = [];
28
+
29
+ for (const file of files) {
30
+ const content = fs.readFileSync(file, "utf8");
31
+ const lines = content.split("\n");
32
+ const relPath = path.relative(process.cwd(), file);
33
+
34
+ for (let lineNum = 1; lineNum <= lines.length; lineNum++) {
35
+ const line = lines[lineNum - 1];
36
+
37
+ // COMP-001: Touch target < 44px
38
+ if (/<(button|a)\b[^>]*\b(h-[6-9]|h-10|height:\s*(?:2|3)\dpx)[^>]*>/i.test(line)) {
39
+ findings.push({
40
+ rule_id: "COMP-001",
41
+ name: "Interactive component explicitly styled below 44px touch target",
42
+ file: relPath,
43
+ line: lineNum,
44
+ severity: "low",
45
+ snippet: line.trim(),
46
+ remediation: "Ensure interactive buttons declare a minimum height of 44px (min-h-11 or min-h-12 in Tailwind).",
47
+ });
48
+ }
49
+
50
+ // COMP-002: Unlabelled icon button
51
+ if (/<button\b(?![^>]*(aria-label|aria-labelledby|title))[^>]*>\s*<(svg|i|Icon)\b/i.test(line)) {
52
+ findings.push({
53
+ rule_id: "COMP-002",
54
+ name: "Icon-only button lacking accessible name or aria-label",
55
+ file: relPath,
56
+ line: lineNum,
57
+ severity: "medium",
58
+ snippet: line.trim(),
59
+ remediation: "Add aria-label or accessible text to the button so screen readers and search crawlers can determine its purpose.",
60
+ });
61
+ }
62
+
63
+ // COMP-003: Generic CTA text
64
+ if (/<button\b[^>]*>(?:\s|&nbsp;)*(Click here|Submit|Read more|Learn more)(?:\s|&nbsp;)*<\/button>/i.test(line)) {
65
+ findings.push({
66
+ rule_id: "COMP-003",
67
+ name: "Generic low-intent microcopy on conversion CTA",
68
+ file: relPath,
69
+ line: lineNum,
70
+ severity: "low",
71
+ snippet: line.trim(),
72
+ remediation: 'Replace generic text with action-oriented, value-aligned verbs (e.g. "Explore Components", "Start Free Trial").',
73
+ });
74
+ }
75
+
76
+ // COMP-004: Missing input label or aria-label
77
+ if (/<input\b(?![^>]*(aria-label|aria-labelledby|id=[^\s>]+|type=["']hidden["']))[^>]*>/i.test(line)) {
78
+ findings.push({
79
+ rule_id: "COMP-004",
80
+ name: "Form input lacking visible label association or aria-label",
81
+ file: relPath,
82
+ line: lineNum,
83
+ severity: "medium",
84
+ snippet: line.trim(),
85
+ remediation: "Associate an id with a <label htmlFor=...> or declare an aria-label attribute.",
86
+ });
87
+ }
88
+
89
+ // COMP-005: Contact input missing autocomplete
90
+ if (/<input\b[^>]*\b(name=["'](email|phone|tel|name)["']|type=["']email["'])(?![^>]*autocomplete)[^>]*>/i.test(line)) {
91
+ findings.push({
92
+ rule_id: "COMP-005",
93
+ name: "Contact input field missing HTML5 autocomplete attribute",
94
+ file: relPath,
95
+ line: lineNum,
96
+ severity: "low",
97
+ snippet: line.trim(),
98
+ remediation: 'Declare standard autocomplete (e.g. autocomplete="email" or autocomplete="tel").',
99
+ });
100
+ }
101
+
102
+ // COMP-006: Image missing alt or dimensions
103
+ if (/<img\b(?![^>]*(alt=[^\s>]+))[^>]*>/i.test(line)) {
104
+ findings.push({
105
+ rule_id: "COMP-006",
106
+ name: "Image element missing required alt text attribute",
107
+ file: relPath,
108
+ line: lineNum,
109
+ severity: "medium",
110
+ snippet: line.trim(),
111
+ remediation: 'Provide meaningful alt text for content images or alt="" for purely decorative graphics.',
112
+ });
113
+ }
114
+ }
115
+ }
116
+
117
+ return {
118
+ scanned_directory: relPathOrDir(resolvedDir),
119
+ total_files_scanned: files.length,
120
+ total_findings: findings.length,
121
+ findings,
122
+ status: findings.length === 0 ? "clean" : "issues_detected",
123
+ };
124
+ }
125
+
126
+ function relPathOrDir(p) {
127
+ try {
128
+ return path.relative(process.cwd(), p) || ".";
129
+ } catch {
130
+ return p;
131
+ }
132
+ }