aeo-ready 1.0.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 (34) hide show
  1. package/.aeo-ready/dashboard.html +339 -0
  2. package/README.md +116 -0
  3. package/bin/cli.js +106 -0
  4. package/package.json +35 -0
  5. package/skills/agent-web/SKILL.md +135 -0
  6. package/skills/agent-web/best-practices.md +303 -0
  7. package/src/benchmark/agentic-seo.js +40 -0
  8. package/src/checks/agent-readiness/actionable.js +165 -0
  9. package/src/checks/agent-readiness/capability.js +209 -0
  10. package/src/checks/agent-readiness/content-structure.js +242 -0
  11. package/src/checks/agent-readiness/discovery.js +231 -0
  12. package/src/checks/ai-visibility/authority.js +195 -0
  13. package/src/checks/ai-visibility/citation-readiness.js +228 -0
  14. package/src/checks/ai-visibility/freshness.js +182 -0
  15. package/src/checks/ai-visibility/structured-data.js +180 -0
  16. package/src/dashboard/generate.js +156 -0
  17. package/src/dashboard/sections/agent-readiness.js +71 -0
  18. package/src/dashboard/sections/ai-visibility.js +67 -0
  19. package/src/dashboard/sections/history-table.js +36 -0
  20. package/src/dashboard/sections/overall-score.js +128 -0
  21. package/src/dashboard/sections/recommendations.js +49 -0
  22. package/src/dashboard/sections/trend-chart.js +78 -0
  23. package/src/fix/generators/agents-json.js +73 -0
  24. package/src/fix/generators/agents-md.js +85 -0
  25. package/src/fix/generators/llms-txt.js +166 -0
  26. package/src/fix/generators/robots-txt.js +64 -0
  27. package/src/fix/index.js +177 -0
  28. package/src/history/index.js +47 -0
  29. package/src/index.js +2 -0
  30. package/src/scan.js +358 -0
  31. package/src/track/index.js +167 -0
  32. package/src/utils/detect-type.js +99 -0
  33. package/src/utils/fetch.js +42 -0
  34. package/src/utils/tokens.js +18 -0
@@ -0,0 +1,180 @@
1
+ export async function runStructuredDataChecks(context) {
2
+ const checks = [
3
+ checkSchemaOrg(context),
4
+ checkFaqMarkup(context),
5
+ checkRichSchemas(context),
6
+ checkOpenGraph(context),
7
+ ];
8
+
9
+ const score = checks.reduce((sum, c) => sum + (c.passed ? c.points : 0), 0);
10
+ return { score, maxScore: 12, checks };
11
+ }
12
+
13
+ function checkSchemaOrg(context) {
14
+ const allContent = getAllContent(context);
15
+ const html = context.html || "";
16
+
17
+ const jsonLdBlocks = extractJsonLd(html);
18
+ if (jsonLdBlocks.length === 0 && !allContent.includes("schema.org")) {
19
+ return fail(
20
+ "Schema.org markup",
21
+ 4,
22
+ "No schema.org structured data found. Add JSON-LD with appropriate type (SoftwareApplication, Person, Article, Organization).",
23
+ );
24
+ }
25
+
26
+ let points = 2;
27
+ const types = jsonLdBlocks.map((b) => b["@type"]).filter(Boolean);
28
+ const hasCorrectType = hasAppropriateType(types, context.siteType);
29
+ if (hasCorrectType) points += 1;
30
+ const hasRequiredFields = jsonLdBlocks.some((b) => hasMinimumFields(b));
31
+ if (hasRequiredFields) points += 1;
32
+
33
+ if (points === 4) return pass("Schema.org markup", 4);
34
+ const issues = [];
35
+ if (!hasCorrectType) issues.push("type doesn't match site category");
36
+ if (!hasRequiredFields) issues.push("missing required fields");
37
+ return partial(
38
+ "Schema.org markup",
39
+ points,
40
+ 4,
41
+ `Schema.org present but: ${issues.join(", ")}.`,
42
+ );
43
+ }
44
+
45
+ function checkFaqMarkup(context) {
46
+ const html = context.html || "";
47
+ const allContent = getAllContent(context);
48
+
49
+ const hasFaqSchema =
50
+ allContent.includes("FAQPage") || allContent.includes("Question");
51
+ const hasFaqContent = /faq|frequently asked|common questions/i.test(
52
+ allContent,
53
+ );
54
+
55
+ if (hasFaqSchema) return pass("FAQ markup", 3);
56
+ if (hasFaqContent) {
57
+ return partial(
58
+ "FAQ markup",
59
+ 1,
60
+ 3,
61
+ "FAQ content exists but not marked up with FAQPage schema. Add JSON-LD FAQPage.",
62
+ );
63
+ }
64
+ return fail(
65
+ "FAQ markup",
66
+ 3,
67
+ "No FAQ markup. Add FAQPage schema for common questions — these surface directly in AI answers.",
68
+ );
69
+ }
70
+
71
+ function checkRichSchemas(context) {
72
+ const allContent = getAllContent(context);
73
+
74
+ const schemas = [];
75
+ if (allContent.includes("BreadcrumbList")) schemas.push("Breadcrumb");
76
+ if (allContent.includes("HowTo")) schemas.push("HowTo");
77
+ if (
78
+ allContent.includes("Product") ||
79
+ allContent.includes("SoftwareApplication")
80
+ )
81
+ schemas.push("Product");
82
+ if (allContent.includes("VideoObject")) schemas.push("Video");
83
+ if (allContent.includes("Review") || allContent.includes("AggregateRating"))
84
+ schemas.push("Review");
85
+
86
+ if (schemas.length >= 2)
87
+ return pass("Rich schemas (Breadcrumb/HowTo/Product)", 3);
88
+ if (schemas.length === 1)
89
+ return partial(
90
+ "Rich schemas (Breadcrumb/HowTo/Product)",
91
+ 2,
92
+ 3,
93
+ `Only ${schemas[0]} schema found. Add more rich schemas for better AI comprehension.`,
94
+ );
95
+ return fail(
96
+ "Rich schemas (Breadcrumb/HowTo/Product)",
97
+ 3,
98
+ "No rich schemas (BreadcrumbList, HowTo, Product). These help AI understand page context and relationships.",
99
+ );
100
+ }
101
+
102
+ function checkOpenGraph(context) {
103
+ const html = context.html || "";
104
+
105
+ const hasOgTitle = /og:title/i.test(html);
106
+ const hasOgDesc = /og:description/i.test(html);
107
+ const hasMetaDesc = /name=["']description["']/i.test(html);
108
+
109
+ let points = 0;
110
+ if (hasOgTitle) points += 1;
111
+ if (hasOgDesc || hasMetaDesc) points += 1;
112
+
113
+ if (points === 2) return pass("Open Graph + meta description", 2);
114
+ if (points === 1)
115
+ return partial(
116
+ "Open Graph + meta description",
117
+ 1,
118
+ 2,
119
+ "Partial OG/meta tags. Need both og:title and description.",
120
+ );
121
+ return fail(
122
+ "Open Graph + meta description",
123
+ 2,
124
+ "No Open Graph or meta description tags. AI systems use these for page summaries.",
125
+ );
126
+ }
127
+
128
+ function extractJsonLd(html) {
129
+ const blocks = [];
130
+ const regex =
131
+ /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
132
+ let match;
133
+ while ((match = regex.exec(html)) !== null) {
134
+ try {
135
+ const parsed = JSON.parse(match[1]);
136
+ if (Array.isArray(parsed)) blocks.push(...parsed);
137
+ else blocks.push(parsed);
138
+ } catch {
139
+ /* skip invalid */
140
+ }
141
+ }
142
+ return blocks;
143
+ }
144
+
145
+ function hasAppropriateType(types, siteType) {
146
+ const typeMap = {
147
+ saas: ["SoftwareApplication", "WebApplication", "Product", "Organization"],
148
+ api: ["SoftwareApplication", "WebAPI", "TechArticle"],
149
+ content: ["Article", "BlogPosting", "WebSite", "Organization"],
150
+ personal: ["Person", "ProfilePage", "WebSite"],
151
+ };
152
+ const expected = typeMap[siteType] || [];
153
+ return types.some((t) => expected.includes(t));
154
+ }
155
+
156
+ function hasMinimumFields(block) {
157
+ const required = ["name", "@type"];
158
+ return required.every((f) => block[f]);
159
+ }
160
+
161
+ function getAllContent(context) {
162
+ if (context.mode === "url") {
163
+ return Object.values(context.pages || {})
164
+ .map((p) => p?.text || "")
165
+ .join("\n");
166
+ }
167
+ return Object.values(context.fileContents || {}).join("\n");
168
+ }
169
+
170
+ function pass(name, points) {
171
+ return { name, passed: true, points, maxPoints: points };
172
+ }
173
+
174
+ function partial(name, points, maxPoints, fix) {
175
+ return { name, passed: false, points, maxPoints, fix };
176
+ }
177
+
178
+ function fail(name, maxPoints, fix) {
179
+ return { name, passed: false, points: 0, maxPoints, fix };
180
+ }
@@ -0,0 +1,156 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
2
+ import { join } from "path";
3
+ import { getHistory } from "../history/index.js";
4
+ import { renderOverallScore } from "./sections/overall-score.js";
5
+ import { renderAgentReadiness } from "./sections/agent-readiness.js";
6
+ import { renderAiVisibility } from "./sections/ai-visibility.js";
7
+ import { renderHistoryTable } from "./sections/history-table.js";
8
+ import { renderTrendChart } from "./sections/trend-chart.js";
9
+ import { renderRecommendations } from "./sections/recommendations.js";
10
+
11
+ const DASHBOARD_DIR = ".aeo-ready";
12
+ const DASHBOARD_FILE = "dashboard.html";
13
+
14
+ export async function generateDashboard(scanResult, dir, opts = {}) {
15
+ const dashDir = join(dir, DASHBOARD_DIR);
16
+ if (!existsSync(dashDir)) mkdirSync(dashDir, { recursive: true });
17
+
18
+ const dashPath = join(dashDir, DASHBOARD_FILE);
19
+ const history = getHistory(dir);
20
+
21
+ const sections = {
22
+ "overall-score": renderOverallScore(scanResult, opts.beforeResult || null),
23
+ "agent-readiness-scorecard": renderAgentReadiness(scanResult),
24
+ "ai-visibility-scorecard": renderAiVisibility(scanResult),
25
+ "history-table": renderHistoryTable(history),
26
+ "trend-chart": renderTrendChart(history),
27
+ recommendations: renderRecommendations(scanResult),
28
+ };
29
+
30
+ let html;
31
+ if (existsSync(dashPath)) {
32
+ html = readFileSync(dashPath, "utf8");
33
+ for (const [name, content] of Object.entries(sections)) {
34
+ html = replaceSection(html, name, content);
35
+ }
36
+ } else {
37
+ html = buildFullDashboard(sections, scanResult);
38
+ }
39
+
40
+ writeFileSync(dashPath, html);
41
+ return dashPath;
42
+ }
43
+
44
+ function replaceSection(html, name, content) {
45
+ const regex = new RegExp(
46
+ `(<!-- SECTION:${name} -->)[\\s\\S]*?(<!-- /SECTION:${name} -->)`,
47
+ "g",
48
+ );
49
+ if (regex.test(html)) {
50
+ return html.replace(regex, `$1\n${content}\n$2`);
51
+ }
52
+ return html;
53
+ }
54
+
55
+ function buildFullDashboard(sections, scanResult) {
56
+ const timestamp = new Date().toISOString().slice(0, 10);
57
+ const target = scanResult.target || "Local";
58
+
59
+ return `<!DOCTYPE html>
60
+ <html lang="en">
61
+ <head>
62
+ <meta charset="UTF-8">
63
+ <title>aeo-ready — AI Readiness Dashboard</title>
64
+ <style>
65
+ ${CSS}
66
+ </style>
67
+ </head>
68
+ <body>
69
+
70
+ <nav>
71
+ <h2>aeo-ready</h2>
72
+ <a href="#overall" class="section-head">Overall Score</a>
73
+ <a href="#agent-readiness" class="section-head">Agent Readiness</a>
74
+ <a href="#ai-visibility" class="section-head">AI Visibility</a>
75
+ <a href="#trends" class="section-head">Trends</a>
76
+ <a href="#history" class="section-head">History</a>
77
+ <a href="#recommendations" class="section-head">Recommendations</a>
78
+ </nav>
79
+
80
+ <main>
81
+
82
+ <h1>AI Readiness Dashboard</h1>
83
+ <p class="subtitle">${target} · Updated ${timestamp}</p>
84
+
85
+ <!-- SECTION:overall-score -->
86
+ ${sections["overall-score"]}
87
+ <!-- /SECTION:overall-score -->
88
+
89
+ <!-- SECTION:agent-readiness-scorecard -->
90
+ ${sections["agent-readiness-scorecard"]}
91
+ <!-- /SECTION:agent-readiness-scorecard -->
92
+
93
+ <!-- SECTION:ai-visibility-scorecard -->
94
+ ${sections["ai-visibility-scorecard"]}
95
+ <!-- /SECTION:ai-visibility-scorecard -->
96
+
97
+ <!-- SECTION:trend-chart -->
98
+ ${sections["trend-chart"]}
99
+ <!-- /SECTION:trend-chart -->
100
+
101
+ <!-- SECTION:history-table -->
102
+ ${sections["history-table"]}
103
+ <!-- /SECTION:history-table -->
104
+
105
+ <!-- SECTION:recommendations -->
106
+ ${sections["recommendations"]}
107
+ <!-- /SECTION:recommendations -->
108
+
109
+ </main>
110
+ </body>
111
+ </html>`;
112
+ }
113
+
114
+ const CSS = `* { box-sizing: border-box; margin: 0; padding: 0; }
115
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0d1117; color: #c9d1d9; display: flex; }
116
+ nav { position: fixed; top: 0; left: 0; width: 200px; height: 100vh; background: #010409; border-right: 1px solid #21262d; padding: 0; overflow-y: auto; z-index: 10; }
117
+ nav h2 { color: #f0f6fc; font-size: 13px; letter-spacing: 0.02em; padding: 20px 16px 14px; border-bottom: 1px solid #21262d; margin: 0 0 8px; }
118
+ nav a { display: block; padding: 6px 16px; font-size: 12px; color: #8b949e; text-decoration: none; transition: color 0.15s; }
119
+ nav a:hover { color: #f0f6fc; }
120
+ nav a.section-head { color: #c9d1d9; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; margin-top: 12px; }
121
+ main { margin-left: 200px; padding: 32px 40px; max-width: 900px; width: 100%; }
122
+ h1 { color: #f0f6fc; font-size: 24px; margin-bottom: 4px; }
123
+ .subtitle { color: #8b949e; font-size: 13px; margin-bottom: 32px; }
124
+ h2 { color: #f0f6fc; font-size: 18px; margin-top: 40px; margin-bottom: 16px; padding-top: 16px; border-top: 1px solid #21262d; }
125
+ h3 { color: #f0f6fc; font-size: 14px; margin-top: 16px; margin-bottom: 8px; }
126
+ .score-hero { text-align: center; padding: 32px 0; }
127
+ .score-hero .grade { font-size: 64px; font-weight: 700; }
128
+ .score-hero .number { font-size: 24px; color: #8b949e; margin-top: 4px; }
129
+ .score-hero .bar { margin: 16px auto; width: 300px; height: 8px; background: #21262d; border-radius: 4px; overflow: hidden; }
130
+ .score-hero .bar-fill { height: 100%; border-radius: 4px; }
131
+ .grade-a { color: #3fb950; } .grade-b { color: #79c0ff; } .grade-c { color: #d29922; } .grade-d { color: #f85149; } .grade-f { color: #f85149; }
132
+ .bar-a { background: #3fb950; } .bar-b { background: #79c0ff; } .bar-c { background: #d29922; } .bar-d { background: #f85149; } .bar-f { background: #f85149; }
133
+ .scorecard { background: #161b22; border: 1px solid #21262d; border-radius: 8px; padding: 20px; margin: 16px 0; }
134
+ .scorecard-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
135
+ .scorecard-header h3 { margin: 0; }
136
+ .scorecard-header .pct { font-size: 20px; font-weight: 600; }
137
+ .category { margin: 12px 0; padding: 8px 0; border-bottom: 1px solid #21262d; }
138
+ .category:last-child { border-bottom: none; }
139
+ .cat-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
140
+ .cat-name { font-size: 13px; font-weight: 500; flex: 1; }
141
+ .cat-score { font-size: 12px; color: #8b949e; }
142
+ .cat-bar { width: 120px; height: 4px; background: #21262d; border-radius: 2px; overflow: hidden; }
143
+ .cat-bar-fill { height: 100%; border-radius: 2px; }
144
+ .check { font-size: 12px; padding: 2px 0 2px 16px; color: #8b949e; }
145
+ .check.pass { color: #3fb950; }
146
+ .check.fail { color: #f85149; }
147
+ .check .fix { display: block; color: #8b949e; font-size: 11px; padding-left: 16px; margin-top: 2px; }
148
+ table { width: 100%; border-collapse: collapse; margin: 12px 0; }
149
+ th, td { text-align: left; padding: 8px 12px; font-size: 12px; border-bottom: 1px solid #21262d; }
150
+ th { color: #8b949e; font-weight: 600; }
151
+ .trend { color: #3fb950; } .regression { color: #f85149; }
152
+ .rec { background: #161b22; border: 1px solid #21262d; border-radius: 6px; padding: 12px 16px; margin: 8px 0; }
153
+ .rec-title { font-size: 13px; font-weight: 500; color: #f0f6fc; }
154
+ .rec-fix { font-size: 12px; color: #8b949e; margin-top: 4px; }
155
+ .rec-impact { font-size: 11px; color: #d29922; margin-top: 4px; }
156
+ svg { display: block; margin: 16px 0; }`;
@@ -0,0 +1,71 @@
1
+ export function renderAgentReadiness(result) {
2
+ return renderScorecard(
3
+ "Agent Readiness",
4
+ result.agentReadiness,
5
+ "agent-readiness",
6
+ );
7
+ }
8
+
9
+ function renderScorecard(title, scorecard, id) {
10
+ const pct = Math.round((scorecard.score / scorecard.maxScore) * 100);
11
+ const gradeClass =
12
+ pct >= 80
13
+ ? "grade-a"
14
+ : pct >= 60
15
+ ? "grade-b"
16
+ : pct >= 40
17
+ ? "grade-c"
18
+ : "grade-d";
19
+
20
+ let html = `<div class="scorecard" id="${id}">
21
+ <div class="scorecard-header">
22
+ <h3>${title}</h3>
23
+ <span class="pct ${gradeClass}">${scorecard.score}/${scorecard.maxScore} (${pct}%)</span>
24
+ </div>`;
25
+
26
+ for (const [catName, cat] of Object.entries(scorecard.categories)) {
27
+ const label = formatName(catName);
28
+ const catPct = Math.round((cat.score / cat.maxScore) * 100);
29
+ const barColor =
30
+ catPct >= 80 ? "#3fb950" : catPct >= 50 ? "#d29922" : "#f85149";
31
+
32
+ html += `\n <div class="category">
33
+ <div class="cat-header">
34
+ <span class="cat-name">${label}</span>
35
+ <span class="cat-score">${cat.score}/${cat.maxScore}</span>
36
+ <div class="cat-bar"><div class="cat-bar-fill" style="width:${catPct}%;background:${barColor}"></div></div>
37
+ </div>`;
38
+
39
+ for (const check of cat.checks) {
40
+ if (check.passed) {
41
+ html += `\n <div class="check pass">+ ${check.name} [${check.points}]</div>`;
42
+ } else {
43
+ html += `\n <div class="check fail">- ${check.name} [${check.points || 0}/${check.maxPoints}]`;
44
+ if (check.fix) {
45
+ html += `<span class="fix">${escapeHtml(check.fix)}</span>`;
46
+ }
47
+ html += `</div>`;
48
+ }
49
+ }
50
+
51
+ html += `\n </div>`;
52
+ }
53
+
54
+ html += `\n</div>`;
55
+ return html;
56
+ }
57
+
58
+ function formatName(camelCase) {
59
+ return camelCase
60
+ .replace(/([A-Z])/g, " $1")
61
+ .replace(/^./, (c) => c.toUpperCase())
62
+ .trim();
63
+ }
64
+
65
+ function escapeHtml(str) {
66
+ return str
67
+ .replace(/&/g, "&amp;")
68
+ .replace(/</g, "&lt;")
69
+ .replace(/>/g, "&gt;")
70
+ .replace(/"/g, "&quot;");
71
+ }
@@ -0,0 +1,67 @@
1
+ export function renderAiVisibility(result) {
2
+ return renderScorecard("AI Visibility", result.aiVisibility, "ai-visibility");
3
+ }
4
+
5
+ function renderScorecard(title, scorecard, id) {
6
+ const pct = Math.round((scorecard.score / scorecard.maxScore) * 100);
7
+ const gradeClass =
8
+ pct >= 80
9
+ ? "grade-a"
10
+ : pct >= 60
11
+ ? "grade-b"
12
+ : pct >= 40
13
+ ? "grade-c"
14
+ : "grade-d";
15
+
16
+ let html = `<div class="scorecard" id="${id}">
17
+ <div class="scorecard-header">
18
+ <h3>${title}</h3>
19
+ <span class="pct ${gradeClass}">${scorecard.score}/${scorecard.maxScore} (${pct}%)</span>
20
+ </div>`;
21
+
22
+ for (const [catName, cat] of Object.entries(scorecard.categories)) {
23
+ const label = formatName(catName);
24
+ const catPct = Math.round((cat.score / cat.maxScore) * 100);
25
+ const barColor =
26
+ catPct >= 80 ? "#3fb950" : catPct >= 50 ? "#d29922" : "#f85149";
27
+
28
+ html += `\n <div class="category">
29
+ <div class="cat-header">
30
+ <span class="cat-name">${label}</span>
31
+ <span class="cat-score">${cat.score}/${cat.maxScore}</span>
32
+ <div class="cat-bar"><div class="cat-bar-fill" style="width:${catPct}%;background:${barColor}"></div></div>
33
+ </div>`;
34
+
35
+ for (const check of cat.checks) {
36
+ if (check.passed) {
37
+ html += `\n <div class="check pass">+ ${check.name} [${check.points}]</div>`;
38
+ } else {
39
+ html += `\n <div class="check fail">- ${check.name} [${check.points || 0}/${check.maxPoints}]`;
40
+ if (check.fix) {
41
+ html += `<span class="fix">${escapeHtml(check.fix)}</span>`;
42
+ }
43
+ html += `</div>`;
44
+ }
45
+ }
46
+
47
+ html += `\n </div>`;
48
+ }
49
+
50
+ html += `\n</div>`;
51
+ return html;
52
+ }
53
+
54
+ function formatName(camelCase) {
55
+ return camelCase
56
+ .replace(/([A-Z])/g, " $1")
57
+ .replace(/^./, (c) => c.toUpperCase())
58
+ .trim();
59
+ }
60
+
61
+ function escapeHtml(str) {
62
+ return str
63
+ .replace(/&/g, "&amp;")
64
+ .replace(/</g, "&lt;")
65
+ .replace(/>/g, "&gt;")
66
+ .replace(/"/g, "&quot;");
67
+ }
@@ -0,0 +1,36 @@
1
+ export function renderHistoryTable(history) {
2
+ if (!history.scans || history.scans.length === 0) {
3
+ return `<h2 id="history">Scan History</h2>\n<p style="color:#8b949e;font-size:13px;">No scan history yet.</p>`;
4
+ }
5
+
6
+ const scans = [...history.scans].reverse().slice(0, 20);
7
+
8
+ let html = `<h2 id="history">Scan History</h2>\n<table>\n<tr><th>Date</th><th>Score</th><th>Grade</th><th>Agent</th><th>Visibility</th><th>Delta</th></tr>`;
9
+
10
+ for (let i = 0; i < scans.length; i++) {
11
+ const s = scans[i];
12
+ const prev = scans[i + 1];
13
+ const delta = prev ? s.score - prev.score : 0;
14
+ const deltaStr =
15
+ delta > 0
16
+ ? `<span class="trend">+${delta}</span>`
17
+ : delta < 0
18
+ ? `<span class="regression">${delta}</span>`
19
+ : `<span style="color:#8b949e">—</span>`;
20
+
21
+ const date = s.timestamp ? s.timestamp.slice(0, 10) : "—";
22
+ const gradeClass = `grade-${(s.grade || "f").toLowerCase()}`;
23
+
24
+ html += `\n<tr>
25
+ <td>${date}</td>
26
+ <td>${s.score}/100</td>
27
+ <td class="${gradeClass}">${s.grade || "?"}</td>
28
+ <td>${s.agentReadiness ?? "—"}/50</td>
29
+ <td>${s.aiVisibility ?? "—"}/50</td>
30
+ <td>${deltaStr}</td>
31
+ </tr>`;
32
+ }
33
+
34
+ html += `\n</table>`;
35
+ return html;
36
+ }
@@ -0,0 +1,128 @@
1
+ export function renderOverallScore(result, beforeResult) {
2
+ if (beforeResult) {
3
+ return renderBeforeAfter(beforeResult, result);
4
+ }
5
+ return renderSingle(result);
6
+ }
7
+
8
+ function renderSingle(result) {
9
+ const { score, grade } = result;
10
+ const gradeClass = `grade-${grade.toLowerCase()}`;
11
+ const barClass = `bar-${grade.toLowerCase()}`;
12
+
13
+ return `<div class="score-hero" id="overall">
14
+ <div class="grade ${gradeClass}">${grade}</div>
15
+ <div class="number">${score} / 100</div>
16
+ <div class="bar"><div class="bar-fill ${barClass}" style="width:${score}%"></div></div>
17
+ <div style="display:flex;justify-content:center;gap:40px;margin-top:16px;font-size:13px;">
18
+ <span>Agent Readiness: <strong>${result.agentReadiness.score}/50</strong></span>
19
+ <span>AI Visibility: <strong>${result.aiVisibility.score}/50</strong></span>
20
+ </div>
21
+ </div>
22
+ ${renderInsight(result)}
23
+ ${renderBenchmarkComparison(score)}`;
24
+ }
25
+
26
+ function renderBeforeAfter(before, after) {
27
+ const delta = after.score - before.score;
28
+ const deltaClass = delta > 0 ? "trend" : delta < 0 ? "regression" : "";
29
+ const deltaStr = delta > 0 ? `+${delta}` : `${delta}`;
30
+
31
+ return `<div id="overall" style="display:flex;align-items:center;justify-content:center;gap:24px;padding:32px 0;">
32
+
33
+ <div style="text-align:center;flex:1;">
34
+ <div style="font-size:11px;text-transform:uppercase;letter-spacing:0.05em;color:#8b949e;margin-bottom:8px;">Before</div>
35
+ <div class="grade-${before.grade.toLowerCase()}" style="font-size:48px;font-weight:700;">${before.grade}</div>
36
+ <div style="font-size:20px;color:#8b949e;margin-top:4px;">${before.score} / 100</div>
37
+ <div class="bar" style="margin:12px auto;width:200px;height:6px;background:#21262d;border-radius:3px;overflow:hidden;">
38
+ <div class="bar-fill bar-${before.grade.toLowerCase()}" style="width:${before.score}%;height:100%;border-radius:3px;"></div>
39
+ </div>
40
+ <div style="font-size:12px;color:#8b949e;">
41
+ Agent: ${before.agentReadiness.score}/50 · Visibility: ${before.aiVisibility.score}/50
42
+ </div>
43
+ </div>
44
+
45
+ <div style="font-size:32px;color:#8b949e;padding:0 8px;">→</div>
46
+
47
+ <div style="text-align:center;flex:1;">
48
+ <div style="font-size:11px;text-transform:uppercase;letter-spacing:0.05em;color:#8b949e;margin-bottom:8px;">After</div>
49
+ <div class="grade-${after.grade.toLowerCase()}" style="font-size:48px;font-weight:700;">${after.grade}</div>
50
+ <div style="font-size:20px;color:#8b949e;margin-top:4px;">${after.score} / 100</div>
51
+ <div class="bar" style="margin:12px auto;width:200px;height:6px;background:#21262d;border-radius:3px;overflow:hidden;">
52
+ <div class="bar-fill bar-${after.grade.toLowerCase()}" style="width:${after.score}%;height:100%;border-radius:3px;"></div>
53
+ </div>
54
+ <div style="font-size:12px;color:#8b949e;">
55
+ Agent: ${after.agentReadiness.score}/50 · Visibility: ${after.aiVisibility.score}/50
56
+ </div>
57
+ </div>
58
+
59
+ </div>
60
+ ${delta !== 0 ? `<div style="text-align:center;margin-top:-16px;margin-bottom:16px;"><span class="${deltaClass}" style="font-size:16px;font-weight:600;">${deltaStr} points</span></div>` : ""}
61
+ ${renderInsight(after)}
62
+ ${renderBenchmarkComparison(after.score)}`;
63
+ }
64
+
65
+ function renderInsight(result) {
66
+ const agent = result.agentReadiness.score;
67
+ const vis = result.aiVisibility.score;
68
+
69
+ if (agent < 25) {
70
+ return `<div style="text-align:center;padding:12px 24px;margin:16px auto;max-width:500px;background:#d2992210;border:1px solid #d2992233;border-radius:6px;font-size:13px;color:#d29922;">
71
+ <strong>Agent Readiness is low.</strong> AI engines can't cite what they can't read.<br>
72
+ <span style="color:#8b949e;">Fix the agent side first. Visibility follows.</span>
73
+ </div>`;
74
+ }
75
+
76
+ if (agent >= 40 && vis < 25) {
77
+ return `<div style="text-align:center;padding:12px 24px;margin:16px auto;max-width:500px;background:#79c0ff10;border:1px solid #79c0ff33;border-radius:6px;font-size:13px;color:#79c0ff;">
78
+ <strong>Agents can read your site — now make it citable.</strong><br>
79
+ <span style="color:#8b949e;">Add direct answer summaries, question headings, and structured claims.</span>
80
+ </div>`;
81
+ }
82
+
83
+ if (agent >= 40 && vis >= 40) {
84
+ return `<div style="text-align:center;padding:12px 24px;margin:16px auto;max-width:500px;background:#3fb95010;border:1px solid #3fb95033;border-radius:6px;font-size:13px;color:#3fb950;">
85
+ <strong>Strong on both sides.</strong> Agents can find you and AI engines can cite you.
86
+ </div>`;
87
+ }
88
+
89
+ return "";
90
+ }
91
+
92
+ const REFERENCE_SCORES = [
93
+ { name: "Stripe", score: 33, type: "SaaS" },
94
+ { name: "Anthropic Docs", score: 43, type: "API" },
95
+ { name: "Vercel", score: 48, type: "SaaS" },
96
+ { name: "Supabase", score: 52, type: "API" },
97
+ { name: "Cloudflare", score: 55, type: "SaaS" },
98
+ { name: "Average site", score: 25, type: "" },
99
+ ];
100
+
101
+ function renderBenchmarkComparison(yourScore) {
102
+ const sorted = [
103
+ ...REFERENCE_SCORES,
104
+ { name: "You", score: yourScore, type: "", you: true },
105
+ ].sort((a, b) => b.score - a.score);
106
+
107
+ let html = `<div style="margin-top:24px;padding:20px;background:#161b22;border:1px solid #21262d;border-radius:8px;">
108
+ <div style="font-size:13px;font-weight:600;color:#f0f6fc;margin-bottom:12px;">How you compare</div>`;
109
+
110
+ for (const entry of sorted) {
111
+ const isYou = entry.you;
112
+ const color = isYou ? "#d29922" : "#30363d";
113
+ const nameColor = isYou ? "#f0f6fc" : "#8b949e";
114
+ const weight = isYou ? "font-weight:600;" : "";
115
+
116
+ html += `
117
+ <div style="display:flex;align-items:center;gap:8px;margin:6px 0;">
118
+ <span style="width:120px;font-size:12px;color:${nameColor};${weight}">${entry.name}</span>
119
+ <div style="flex:1;height:4px;background:#21262d;border-radius:2px;overflow:hidden;">
120
+ <div style="width:${entry.score}%;height:100%;background:${color};border-radius:2px;"></div>
121
+ </div>
122
+ <span style="font-size:11px;color:${nameColor};width:32px;text-align:right;${weight}">${entry.score}</span>
123
+ </div>`;
124
+ }
125
+
126
+ html += `\n</div>`;
127
+ return html;
128
+ }