aeo-ready 1.3.0 → 1.3.1

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,43 @@
1
+ # Changelog
2
+
3
+ ## 1.3.1
4
+
5
+ - Add progress indicator during scan
6
+ - Add `history` command to view past scans
7
+ - Fix silent catch on corrupt history file — now warns instead of silently returning empty
8
+ - Fix error detail missing from `agentic-seo init` and `afdocs` failures
9
+ - Fix User-Agent from old "agent-web/1.0" to "aeo-ready/1.3"
10
+ - Add programmatic API docs to README
11
+ - Add `files` field to package.json — excludes dead dashboard code from npm (~12KB smaller)
12
+ - Add `repository`, `homepage`, `bugs` fields to package.json
13
+ - Add issue templates (bug report, feature request)
14
+ - Add CHANGELOG.md
15
+ - Link best-practices.md from README
16
+
17
+ ## 1.3.0
18
+
19
+ - Terminal-only output, remove dashboard auto-open
20
+ - Interactive "Fix now?" prompt after scan
21
+ - Fetch site pages for agentic-seo instead of URL-only mode
22
+ - Add .aeo-ready/ to gitignore
23
+
24
+ ## 1.2.0
25
+
26
+ - Rewrite as pure aggregator — removed proprietary scoring
27
+ - Interactive "Fix now?" prompt after scan runs `agentic-seo init` + `afdocs`
28
+ - Terminal-only output, removed dashboard auto-open
29
+
30
+ ## 1.1.0
31
+
32
+ - Aggregator benchmarks: agentic-seo + Cloudflare + Fern in one scan
33
+ - Per-check detail with pass/fail and company comparisons
34
+ - `--dir` flag for local agentic-seo scanning (92 vs 23 in URL-only mode)
35
+ - `--json` and `--threshold` flags for CI
36
+ - Score history in `.aeo-ready/history.json`
37
+ - Dashboard generation (HTML)
38
+
39
+ ## 1.0.0
40
+
41
+ - Initial release
42
+ - Unified AEO CLI with two scorecards, fix mode, dashboard
43
+ - `agent-web` skill for Claude Code
package/README.md CHANGED
@@ -15,7 +15,7 @@ Runs every major AEO (Agentic Engine Optimization) benchmark against your site i
15
15
  | Benchmark | What it checks | Checks |
16
16
  |-----------|---------------|--------|
17
17
  | **agentic-seo** (Addy Osmani) | Discovery, content structure, token economics, capability signaling, UX bridge | 10 |
18
- | **Cloudflare** (isitagentready.com) | Discoverability, content accessibility, bot access, API/auth/MCP/A2A discovery, commerce | 19 |
18
+ | **Cloudflare** (isitagentready.com) | Discoverability, content accessibility, bot access, API/MCP/A2A discovery, commerce | 19 |
19
19
  | **Fern** (afdocs) | llms.txt quality, markdown availability, page size, content structure, URL stability, auth | 23 |
20
20
 
21
21
  ## Usage
@@ -39,53 +39,80 @@ With --dir: agentic-seo 92/100 (A)
39
39
  ## Output
40
40
 
41
41
  ```
42
- aeo-ready — AEO benchmark aggregator
42
+ aeo-ready — yoursite.com
43
43
 
44
- ─── Benchmarks ────────────────────────────────────
45
-
46
- ███████████████░ agentic-seo 92/100 (A)
44
+ agentic-seo ·································· 91/100 A
47
45
  ✓ Discovery 25/25
48
- ◑ Content Structure 19/25
46
+ ◑ Content Structure 18/25
49
47
  ✓ Token Economics 25/25
50
48
  ✓ Capability Signaling 15/15
51
- ✓ UX Bridge 8/10
52
- compare: Cloudflare 55 · Supabase 52 · Vercel 48
49
+ ✓ UX Bridge 8/10
50
+ vs Cloudflare 55 · Supabase 52 · Vercel 48 · Stripe 17
51
+
52
+ Cloudflare ···································· 4/5 B
53
+ 10 passed 2 failed
54
+ ✗ robotsTxtAiRules No rules for AI bots found
55
+ ✗ contentSignals No content signals in robots.txt
56
+ vs Cloudflare 5 · Vercel 4 · Supabase 3 · Stripe 2
57
+
58
+ Fern ········································ 83/100 B
59
+ 9 passed 4 failed
60
+ ✗ llms-txt-links-markdown Links point to HTML, no markdown
61
+ ✗ content-start-position 2 pages have content past 50%
62
+ ✗ llms-txt-coverage Covers 67% of sitemap
63
+ ✗ markdown-content-parity 4 pages have content differences
64
+ vs Stripe 85 · Supabase 78 · Anthropic 72 · Vercel 60
65
+
66
+ ──────────────────────────────────────────────────
67
+ Overall 85/100
68
+
69
+ Next steps
70
+ npx agentic-seo init scaffold llms.txt, AGENTS.md
71
+ npx afdocs https://yoursite.com 4 Fern issues
72
+ npx skills add katrinalaszlo/agent-serve make your product agent-ready
73
+
74
+ Fix now? [y/N]
75
+ ```
76
+
77
+ Say `y` and it runs `npx agentic-seo init` to scaffold missing files (llms.txt, AGENTS.md, skill.md), then `npx afdocs` for Fern issues. Non-interactive in CI (`--json` or non-TTY).
78
+
79
+ ## CI Mode
53
80
 
54
- █████████████░░░ Cloudflare 4/5 (B)
55
- + robotsTxt, + sitemap, + linkHeaders, + agentSkills...
56
- compare: Cloudflare 5 · Vercel 4 · Supabase 3
81
+ ```yaml
82
+ - run: npx aeo-ready scan yoursite.com --dir ./public --threshold 50
83
+ ```
57
84
 
58
- █████████████░░░ Fern 83/100 (B)
59
- + llms-txt-exists, + rendering-strategy, - content-negotiation...
60
- compare: Stripe 85 · Supabase 78 · Anthropic 72
85
+ ## History
61
86
 
62
- Average across all sources: 85/100
87
+ Scores persist in `.aeo-ready/history.json`. Re-scan to track improvement over time.
63
88
 
64
- Fix it:
65
- npx agentic-seo init scaffold llms.txt, AGENTS.md
66
- Fern: 6 issues — run npx afdocs https://yoursite.com
89
+ ```bash
90
+ npx aeo-ready history # show last 10 scans
67
91
  ```
68
92
 
69
- ## CI Mode
93
+ ## Programmatic API
70
94
 
71
- ```yaml
72
- - run: npx aeo-ready scan yoursite.com --dir ./public --threshold 50
95
+ ```js
96
+ import { scan, getHistory } from "aeo-ready";
97
+
98
+ const result = await scan({ url: "https://yoursite.com", dir: "./public", json: true });
99
+ // result.averageScore, result.benchmarks.agenticSeo, .cloudflare, .fern
100
+
101
+ const history = getHistory(process.cwd());
102
+ // history.scans — array of past scan results
73
103
  ```
74
104
 
75
- ## Dashboard
105
+ ## Next step: make your product agent-ready
76
106
 
77
- Each scan generates a self-contained HTML dashboard at `.aeo-ready/dashboard.html` with:
78
- - Score cards for each benchmark
79
- - Per-check detail (expandable)
80
- - Company comparisons
81
- - Score trends over time (inline SVG)
82
- - Scan history with deltas
107
+ `aeo-ready` tells you how discoverable your site is to AI agents. To actually serve those agents — structured content, tool definitions, skill endpoints — use [agent-serve](https://github.com/katrinalaszlo/agent-serve):
83
108
 
84
- Auto-opens in browser after each scan.
109
+ ```bash
110
+ npx skills add katrinalaszlo/agent-serve
111
+ ```
85
112
 
86
- ## History
113
+ ## Best practices by site type
87
114
 
88
- Scores persist in `.aeo-ready/history.json`. Re-scan to track improvement over time.
115
+ See [skills/agent-web/best-practices.md](skills/agent-web/best-practices.md) for an opinionated AEO framework covering SaaS, personal/portfolio, API/developer tools, and content/blog sites.
89
116
 
90
117
  ## Author
91
118
 
package/bin/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Command } from "commander";
4
4
  import { scan } from "../src/scan.js";
5
+ import { getHistory } from "../src/history/index.js";
5
6
  import { readFileSync } from "fs";
6
7
  import { dirname, join } from "path";
7
8
  import { fileURLToPath } from "url";
@@ -61,4 +62,29 @@ program
61
62
  }
62
63
  });
63
64
 
65
+ program
66
+ .command("history")
67
+ .description("Show past scan scores")
68
+ .action(() => {
69
+ const history = getHistory(process.cwd());
70
+ if (history.scans.length === 0) {
71
+ console.log(" No scans yet. Run: npx aeo-ready scan <url>");
72
+ return;
73
+ }
74
+ console.log("");
75
+ const rows = history.scans.slice(-10).map((s) => {
76
+ const date = new Date(s.timestamp).toLocaleDateString();
77
+ const parts = [];
78
+ if (s.agenticSeo != null) parts.push(`seo:${s.agenticSeo}`);
79
+ if (s.cloudflare != null)
80
+ parts.push(`cf:${s.cloudflare}/${s.cloudflareMax || "?"}`);
81
+ if (s.fern != null) parts.push(`fern:${s.fern}`);
82
+ return ` ${date.padEnd(12)} ${String(s.averageScore).padEnd(6)} ${parts.join(" ")} ${s.url}`;
83
+ });
84
+ console.log(` ${"Date".padEnd(12)} ${"Avg".padEnd(6)} Scores`);
85
+ console.log(` ${"─".repeat(60)}`);
86
+ for (const row of rows) console.log(row);
87
+ console.log("");
88
+ });
89
+
64
90
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aeo-ready",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "AEO benchmark aggregator. One scan, every score. Collects agentic-seo, Cloudflare, and Fern in one report.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,6 +25,25 @@
25
25
  ],
26
26
  "author": "Kat Laszlo <kat@tansohq.com>",
27
27
  "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/katrinalaszlo/aeo-ready.git"
31
+ },
32
+ "homepage": "https://github.com/katrinalaszlo/aeo-ready#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/katrinalaszlo/aeo-ready/issues"
35
+ },
36
+ "files": [
37
+ "bin/",
38
+ "src/benchmark/",
39
+ "src/history/",
40
+ "src/utils/",
41
+ "src/index.js",
42
+ "src/scan.js",
43
+ "skills/",
44
+ "README.md",
45
+ "CHANGELOG.md"
46
+ ],
28
47
  "engines": {
29
48
  "node": ">=18.0.0"
30
49
  },
@@ -27,6 +27,8 @@ const REFERENCE_SCORES = {
27
27
  },
28
28
  };
29
29
 
30
+ const W = 52;
31
+
30
32
  export async function runAllBenchmarks(target, dir) {
31
33
  const isUrl = target && target.startsWith("http");
32
34
 
@@ -47,9 +49,7 @@ export function printBenchmarks(benchmarks) {
47
49
  const any = Object.values(benchmarks).some((b) => b && b.available);
48
50
  if (!any) return;
49
51
 
50
- console.log(
51
- chalk.bold("\n ─── Benchmarks ────────────────────────────────────\n"),
52
- );
52
+ console.log("");
53
53
 
54
54
  if (benchmarks.agenticSeo?.available) {
55
55
  printBenchmarkBlock("agentic-seo", "agenticSeo", benchmarks.agenticSeo);
@@ -62,30 +62,42 @@ export function printBenchmarks(benchmarks) {
62
62
  }
63
63
  }
64
64
 
65
+ function scoreColor(pct) {
66
+ return pct >= 80 ? chalk.green : pct >= 50 ? chalk.yellow : chalk.red;
67
+ }
68
+
65
69
  function printBenchmarkBlock(name, key, b) {
66
70
  const pct = b.maxScore > 0 ? Math.round((b.score / b.maxScore) * 100) : 0;
67
- const color = pct >= 80 ? chalk.green : pct >= 50 ? chalk.yellow : chalk.red;
68
- const barW = 16;
69
- const filled = Math.round((pct / 100) * barW);
70
- const bar = color("█".repeat(filled)) + chalk.dim("░".repeat(barW - filled));
71
+ const color = scoreColor(pct);
72
+ const scoreStr = `${b.score}/${b.maxScore}`;
73
+ const gradeStr = b.grade ? ` ${b.grade}` : "";
74
+ const right = `${scoreStr}${gradeStr}`;
75
+ const dots = W - name.length - right.length;
76
+ const leader = dots > 2 ? " " + chalk.dim("·".repeat(dots - 2)) + " " : " ";
71
77
 
72
- console.log(
73
- ` ${bar} ${chalk.bold(name.padEnd(16))} ${chalk.dim(`${b.score}/${b.maxScore}`)}${b.grade ? chalk.dim(` (${b.grade})`) : ""}`,
74
- );
78
+ console.log(` ${chalk.bold(name)}${leader}${color(right)}`);
75
79
 
76
80
  if (b.checks && b.checks.length > 0) {
77
- for (const check of b.checks) {
78
- if (check.status === "pass") {
79
- console.log(
80
- chalk.green(` + ${check.id}`) +
81
- chalk.dim(check.message ? ` ${check.message.slice(0, 60)}` : ""),
82
- );
83
- } else if (check.status === "fail") {
84
- console.log(
85
- chalk.red(` - ${check.id}`) +
86
- chalk.dim(check.message ? ` ${check.message.slice(0, 60)}` : ""),
87
- );
88
- }
81
+ const passed = b.checks.filter((c) => c.status === "pass");
82
+ const failed = b.checks.filter(
83
+ (c) => c.status === "fail" || c.status === "warn",
84
+ );
85
+
86
+ if (passed.length > 0 && failed.length > 0) {
87
+ console.log(
88
+ chalk.dim(
89
+ ` ${chalk.green(passed.length + " passed")} ${chalk.red(failed.length + " failed")}`,
90
+ ),
91
+ );
92
+ } else if (passed.length > 0) {
93
+ console.log(chalk.dim(` ${chalk.green(passed.length + " passed")}`));
94
+ }
95
+
96
+ for (const check of failed) {
97
+ const msg = check.message
98
+ ? chalk.dim(` ${check.message.slice(0, 50)}`)
99
+ : "";
100
+ console.log(` ${chalk.red("✗")} ${check.id}${msg}`);
89
101
  }
90
102
  } else if (b.categories) {
91
103
  for (const [, cat] of Object.entries(b.categories)) {
@@ -99,20 +111,18 @@ function printBenchmarkBlock(name, key, b) {
99
111
  ? chalk.yellow("◑")
100
112
  : chalk.red("✗");
101
113
  console.log(
102
- chalk.dim(
103
- ` ${icon} ${(cat.name || "").padEnd(22)} ${cat.score}/${cat.maxScore}`,
104
- ),
114
+ ` ${icon} ${chalk.dim((cat.name || "").padEnd(22))} ${chalk.dim(`${cat.score}/${cat.maxScore}`)}`,
105
115
  );
106
116
  }
107
117
  }
108
118
 
109
119
  const refs = REFERENCE_SCORES[key];
110
120
  if (refs) {
111
- const names = Object.entries(refs)
121
+ const cmp = Object.entries(refs)
112
122
  .sort((a, b) => b[1] - a[1])
113
123
  .map(([n, s]) => `${n} ${s}`)
114
- .join(chalk.dim(" · "));
115
- console.log(chalk.dim(` compare: ${names}`));
124
+ .join(" · ");
125
+ console.log(chalk.dim(` vs ${cmp}`));
116
126
  }
117
127
 
118
128
  console.log("");
@@ -29,7 +29,11 @@ export function loadHistory(historyPath) {
29
29
  if (existsSync(historyPath)) {
30
30
  try {
31
31
  return JSON.parse(readFileSync(historyPath, "utf8"));
32
- } catch {}
32
+ } catch (err) {
33
+ console.warn(
34
+ `Warning: corrupt history file ${historyPath} — ${err.message}`,
35
+ );
36
+ }
33
37
  }
34
38
  return { scans: [] };
35
39
  }
package/src/scan.js CHANGED
@@ -1,17 +1,15 @@
1
1
  import chalk from "chalk";
2
+ import { createInterface } from "readline";
3
+ import { execSync } from "child_process";
2
4
  import { runAllBenchmarks, printBenchmarks } from "./benchmark/index.js";
3
5
  import { saveResult } from "./history/index.js";
4
- import { generateDashboard } from "./dashboard/generate.js";
5
- import { exec } from "child_process";
6
6
 
7
7
  export async function scan(opts) {
8
8
  const { url, dir, json } = opts;
9
9
 
10
10
  if (!json) {
11
- console.log(
12
- chalk.bold("\n aeo-ready") + chalk.dim(" AEO benchmark aggregator\n"),
13
- );
14
- console.log(chalk.dim(` Scanning ${url}...\n`));
11
+ console.log(chalk.bold("\n aeo-ready") + chalk.dim(` — ${url}\n`));
12
+ console.log(chalk.dim(" Checking agentic-seo · Cloudflare · Fern...\n"));
15
13
  }
16
14
 
17
15
  const benchmarks = await runAllBenchmarks(url, dir);
@@ -35,10 +33,8 @@ export async function scan(opts) {
35
33
  const baseDir = process.cwd();
36
34
  await saveResult(result, baseDir);
37
35
 
38
- if (!json) {
39
- const dashPath = await generateDashboard(result, baseDir);
40
- console.log(chalk.dim(` Dashboard: ${dashPath}\n`));
41
- openInBrowser(dashPath);
36
+ if (!json && averageScore < 100 && process.stdin.isTTY) {
37
+ await promptFix(result, dir);
42
38
  }
43
39
 
44
40
  return result;
@@ -73,50 +69,97 @@ function printReport(result) {
73
69
  : averageScore >= 50
74
70
  ? chalk.yellow
75
71
  : chalk.red;
72
+
73
+ console.log(chalk.dim(" " + "─".repeat(50)));
76
74
  console.log(
77
- chalk.dim(" ─────────────────────────────────────────────────\n"),
78
- );
79
- console.log(
80
- ` Average across all sources: ${gc.bold(`${averageScore}/100`)}\n`,
75
+ ` ${chalk.bold("Overall")}${" ".repeat(37)}${gc.bold(`${averageScore}/100`)}\n`,
81
76
  );
82
77
 
78
+ printNextSteps(result);
79
+ }
80
+
81
+ function printNextSteps(result) {
82
+ const { benchmarks, averageScore } = result;
83
+ const steps = [];
84
+
83
85
  if (averageScore < 80) {
84
- console.log(chalk.bold(" Fix it:\n"));
85
- console.log(
86
- chalk.dim(" npx agentic-seo init") +
87
- " scaffold llms.txt, AGENTS.md, skill.md",
88
- );
86
+ steps.push(["npx agentic-seo init", "scaffold llms.txt, AGENTS.md"]);
87
+ }
89
88
 
90
- const cfFails =
91
- benchmarks.cloudflare?.checks?.filter((c) => c.status === "fail") || [];
92
- for (const fail of cfFails.slice(0, 2)) {
93
- console.log(
94
- chalk.dim(` Cloudflare: ${fail.id}`) +
95
- chalk.dim(` — see isitagentready.com for fix`),
96
- );
97
- }
89
+ const cfFails =
90
+ benchmarks.cloudflare?.checks?.filter((c) => c.status === "fail") || [];
91
+ if (cfFails.length > 0) {
92
+ steps.push([
93
+ `Cloudflare: ${cfFails.length} failing`,
94
+ "see isitagentready.com",
95
+ ]);
96
+ }
98
97
 
99
- const fernFails =
100
- benchmarks.fern?.checks?.filter(
101
- (c) => c.status === "fail" || c.status === "warn",
102
- ) || [];
103
- if (fernFails.length > 0) {
104
- console.log(
105
- chalk.dim(` Fern: ${fernFails.length} issues`) +
106
- chalk.dim(` — run npx afdocs ${result.url}`),
107
- );
108
- }
98
+ const fernFails =
99
+ benchmarks.fern?.checks?.filter(
100
+ (c) => c.status === "fail" || c.status === "warn",
101
+ ) || [];
102
+ if (fernFails.length > 0) {
103
+ steps.push([`npx afdocs ${result.url}`, `${fernFails.length} Fern issues`]);
104
+ }
109
105
 
110
- console.log(chalk.dim("\n Fix, then re-scan to track improvement.\n"));
106
+ steps.push([
107
+ "npx skills add katrinalaszlo/agent-serve",
108
+ "make your product agent-ready",
109
+ ]);
110
+
111
+ if (steps.length > 0) {
112
+ console.log(chalk.bold(" Next steps\n"));
113
+ const maxCmd = Math.max(...steps.map(([cmd]) => cmd.length));
114
+ for (const [cmd, desc] of steps) {
115
+ console.log(` ${cmd.padEnd(maxCmd + 4)}${chalk.dim(desc)}`);
116
+ }
117
+ console.log("");
111
118
  }
112
119
  }
113
120
 
114
- function openInBrowser(filePath) {
115
- const cmd =
116
- process.platform === "darwin"
117
- ? "open"
118
- : process.platform === "win32"
119
- ? "start"
120
- : "xdg-open";
121
- exec(`${cmd} "${filePath}"`, () => {});
121
+ function ask(question) {
122
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
123
+ return new Promise((resolve) => {
124
+ rl.question(question, (answer) => {
125
+ rl.close();
126
+ resolve(answer.trim().toLowerCase());
127
+ });
128
+ });
129
+ }
130
+
131
+ async function promptFix(result, dir) {
132
+ const answer = await ask(chalk.bold(" Fix now? ") + chalk.dim("[y/N] "));
133
+ if (answer !== "y" && answer !== "yes") return;
134
+
135
+ console.log("");
136
+
137
+ const targetDir = dir || ".";
138
+
139
+ console.log(chalk.dim(` Running: npx agentic-seo init ${targetDir}\n`));
140
+ try {
141
+ execSync(`npx agentic-seo init ${targetDir}`, {
142
+ stdio: "inherit",
143
+ });
144
+ } catch (err) {
145
+ console.log(chalk.red(`\n agentic-seo init failed: ${err.message}\n`));
146
+ }
147
+
148
+ const fernFails =
149
+ result.benchmarks.fern?.checks?.filter(
150
+ (c) => c.status === "fail" || c.status === "warn",
151
+ ) || [];
152
+ if (fernFails.length > 0) {
153
+ console.log(chalk.dim(`\n Running: npx afdocs ${result.url}\n`));
154
+ try {
155
+ execSync(`npx afdocs ${result.url}`, { stdio: "inherit" });
156
+ } catch (err) {
157
+ console.log(chalk.red(`\n afdocs failed: ${err.message}\n`));
158
+ }
159
+ }
160
+
161
+ console.log(
162
+ chalk.dim("\n Re-scan to verify: ") +
163
+ `npx aeo-ready scan ${result.url}${dir ? ` --dir ${dir}` : ""}\n`,
164
+ );
122
165
  }
@@ -1,5 +1,5 @@
1
1
  const DEFAULT_TIMEOUT = 10000;
2
- const USER_AGENT = "agent-web/1.0 (AI readiness auditor)";
2
+ const USER_AGENT = "aeo-ready/1.3 (AEO benchmark aggregator)";
3
3
 
4
4
  export async function fetchUrl(url, opts = {}) {
5
5
  const controller = new AbortController();
@@ -1,7 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "WebSearch"
5
- ]
6
- }
7
- }
@@ -1,130 +0,0 @@
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 { renderHistoryTable } from "./sections/history-table.js";
6
- import { renderTrendChart } from "./sections/trend-chart.js";
7
- import { renderBenchmarkDetails } from "./sections/benchmark-details.js";
8
-
9
- const DASHBOARD_DIR = ".aeo-ready";
10
- const DASHBOARD_FILE = "dashboard.html";
11
-
12
- export async function generateDashboard(scanResult, dir, opts = {}) {
13
- const dashDir = join(dir, DASHBOARD_DIR);
14
- if (!existsSync(dashDir)) mkdirSync(dashDir, { recursive: true });
15
-
16
- const dashPath = join(dashDir, DASHBOARD_FILE);
17
- const history = getHistory(dir);
18
-
19
- const sections = {
20
- "overall-score": renderOverallScore(scanResult),
21
- "benchmark-details": renderBenchmarkDetails(scanResult),
22
- "trend-chart": renderTrendChart(history),
23
- "history-table": renderHistoryTable(history),
24
- };
25
-
26
- let html;
27
- if (existsSync(dashPath)) {
28
- html = readFileSync(dashPath, "utf8");
29
- for (const [name, content] of Object.entries(sections)) {
30
- html = replaceSection(html, name, content);
31
- }
32
- } else {
33
- html = buildFullDashboard(sections, scanResult);
34
- }
35
-
36
- writeFileSync(dashPath, html);
37
- return dashPath;
38
- }
39
-
40
- function replaceSection(html, name, content) {
41
- const regex = new RegExp(
42
- `(<!-- SECTION:${name} -->)[\\s\\S]*?(<!-- /SECTION:${name} -->)`,
43
- "g",
44
- );
45
- if (regex.test(html)) {
46
- return html.replace(regex, `$1\n${content}\n$2`);
47
- }
48
- return html;
49
- }
50
-
51
- function buildFullDashboard(sections, scanResult) {
52
- const timestamp = new Date().toISOString().slice(0, 10);
53
-
54
- return `<!DOCTYPE html>
55
- <html lang="en">
56
- <head>
57
- <meta charset="UTF-8">
58
- <title>aeo-ready — AEO Benchmark Dashboard</title>
59
- <style>
60
- ${CSS}
61
- </style>
62
- </head>
63
- <body>
64
-
65
- <nav>
66
- <h2>aeo-ready</h2>
67
- <a href="#overall" class="section-head">Scores</a>
68
- <a href="#details" class="section-head">Benchmark Details</a>
69
- <a href="#trends" class="section-head">Trends</a>
70
- <a href="#history" class="section-head">History</a>
71
- </nav>
72
-
73
- <main>
74
-
75
- <h1>AEO Benchmark Dashboard</h1>
76
- <p class="subtitle">${scanResult.url} · ${timestamp}</p>
77
-
78
- <!-- SECTION:overall-score -->
79
- ${sections["overall-score"]}
80
- <!-- /SECTION:overall-score -->
81
-
82
- <!-- SECTION:benchmark-details -->
83
- ${sections["benchmark-details"]}
84
- <!-- /SECTION:benchmark-details -->
85
-
86
- <!-- SECTION:trend-chart -->
87
- ${sections["trend-chart"]}
88
- <!-- /SECTION:trend-chart -->
89
-
90
- <!-- SECTION:history-table -->
91
- ${sections["history-table"]}
92
- <!-- /SECTION:history-table -->
93
-
94
- </main>
95
- </body>
96
- </html>`;
97
- }
98
-
99
- const CSS = `* { box-sizing: border-box; margin: 0; padding: 0; }
100
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0d1117; color: #c9d1d9; display: flex; }
101
- 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; }
102
- nav h2 { color: #f0f6fc; font-size: 13px; letter-spacing: 0.02em; padding: 20px 16px 14px; border-bottom: 1px solid #21262d; margin: 0 0 8px; }
103
- nav a { display: block; padding: 6px 16px; font-size: 12px; color: #8b949e; text-decoration: none; transition: color 0.15s; }
104
- nav a:hover { color: #f0f6fc; }
105
- nav a.section-head { color: #c9d1d9; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; margin-top: 12px; }
106
- main { margin-left: 200px; padding: 32px 40px; max-width: 900px; width: 100%; }
107
- h1 { color: #f0f6fc; font-size: 24px; margin-bottom: 4px; }
108
- .subtitle { color: #8b949e; font-size: 13px; margin-bottom: 32px; }
109
- h2 { color: #f0f6fc; font-size: 18px; margin-top: 40px; margin-bottom: 16px; padding-top: 16px; border-top: 1px solid #21262d; }
110
- h3 { color: #f0f6fc; font-size: 14px; margin-top: 16px; margin-bottom: 8px; }
111
- .scores { display: flex; gap: 16px; flex-wrap: wrap; margin: 16px 0; }
112
- .score-card { background: #161b22; border: 1px solid #21262d; border-radius: 8px; padding: 20px; flex: 1; min-width: 180px; text-align: center; }
113
- .score-card .name { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #8b949e; margin-bottom: 8px; }
114
- .score-card .grade { font-size: 36px; font-weight: 700; }
115
- .score-card .number { font-size: 14px; color: #8b949e; margin-top: 4px; }
116
- .score-card .bar { margin: 8px auto; width: 100%; height: 4px; background: #21262d; border-radius: 2px; overflow: hidden; }
117
- .score-card .bar-fill { height: 100%; border-radius: 2px; }
118
- .grade-a { color: #3fb950; } .grade-b { color: #79c0ff; } .grade-c { color: #d29922; } .grade-d { color: #f85149; } .grade-f { color: #f85149; }
119
- .bar-a { background: #3fb950; } .bar-b { background: #79c0ff; } .bar-c { background: #d29922; } .bar-d { background: #f85149; } .bar-f { background: #f85149; }
120
- details { background: #161b22; border: 1px solid #21262d; border-radius: 8px; padding: 16px; margin: 12px 0; cursor: pointer; }
121
- details summary { font-size: 14px; font-weight: 500; color: #f0f6fc; display: flex; justify-content: space-between; }
122
- .check { font-size: 12px; padding: 2px 0 2px 16px; color: #8b949e; }
123
- .check.pass { color: #3fb950; }
124
- .check.fail { color: #f85149; }
125
- .compare { font-size: 11px; color: #8b949e; margin-top: 8px; padding-top: 8px; border-top: 1px solid #21262d; }
126
- table { width: 100%; border-collapse: collapse; margin: 12px 0; }
127
- th, td { text-align: left; padding: 8px 12px; font-size: 12px; border-bottom: 1px solid #21262d; }
128
- th { color: #8b949e; font-weight: 600; }
129
- .trend { color: #3fb950; } .regression { color: #f85149; }
130
- svg { display: block; margin: 16px 0; }`;
@@ -1,79 +0,0 @@
1
- const REFERENCE_SCORES = {
2
- agenticSeo: {
3
- Cloudflare: 55,
4
- Supabase: 52,
5
- Vercel: 48,
6
- Average: 25,
7
- Stripe: 17,
8
- },
9
- cloudflare: { Cloudflare: 5, Vercel: 4, Supabase: 3, Stripe: 2, Average: 2 },
10
- fern: { Stripe: 85, Supabase: 78, Anthropic: 72, Vercel: 60, Average: 45 },
11
- };
12
-
13
- export function renderBenchmarkDetails(result) {
14
- const { benchmarks } = result;
15
-
16
- let html = `<h2 id="details">Benchmark Details</h2>
17
- <p style="color:#8b949e;font-size:12px;margin-bottom:12px;">Expand each source to see per-check results and how other companies score.</p>`;
18
-
19
- if (benchmarks.agenticSeo?.available) {
20
- html += renderSource("agentic-seo", "agenticSeo", benchmarks.agenticSeo);
21
- }
22
- if (benchmarks.cloudflare?.available) {
23
- html += renderSource("Cloudflare", "cloudflare", benchmarks.cloudflare);
24
- }
25
- if (benchmarks.fern?.available) {
26
- html += renderSource("Fern", "fern", benchmarks.fern);
27
- }
28
-
29
- return html;
30
- }
31
-
32
- function renderSource(name, key, data) {
33
- const pct =
34
- data.maxScore > 0 ? Math.round((data.score / data.maxScore) * 100) : 0;
35
-
36
- let html = `\n<details>
37
- <summary>
38
- <span>${esc(name)}</span>
39
- <span style="color:#8b949e;">${data.score}/${data.maxScore}${data.grade ? ` (${data.grade})` : ""}</span>
40
- </summary>
41
- <div style="margin-top:12px;">`;
42
-
43
- if (data.checks && data.checks.length > 0) {
44
- for (const check of data.checks) {
45
- if (check.status === "pass") {
46
- html += `\n <div class="check pass">+ ${esc(check.id)} ${check.message ? `<span style="color:#8b949e;font-size:11px;">${esc(check.message.slice(0, 80))}</span>` : ""}</div>`;
47
- } else if (check.status === "fail") {
48
- html += `\n <div class="check fail">- ${esc(check.id)} ${check.message ? `<span style="color:#8b949e;font-size:11px;">${esc(check.message.slice(0, 80))}</span>` : ""}</div>`;
49
- }
50
- }
51
- } else if (data.categories) {
52
- for (const [, cat] of Object.entries(data.categories)) {
53
- const catPct =
54
- cat.maxScore > 0 ? Math.round((cat.score / cat.maxScore) * 100) : 0;
55
- const icon = catPct >= 80 ? "+" : catPct >= 40 ? "~" : "-";
56
- const cls = catPct >= 80 ? "pass" : "fail";
57
- html += `\n <div class="check ${cls}">${icon} ${esc(cat.name || "")} ${cat.score}/${cat.maxScore}</div>`;
58
- }
59
- }
60
-
61
- const refs = REFERENCE_SCORES[key];
62
- if (refs) {
63
- const lines = Object.entries(refs)
64
- .sort((a, b) => b[1] - a[1])
65
- .map(([n, s]) => `${n}: ${s}`)
66
- .join(" · ");
67
- html += `\n <div class="compare">Others: ${lines}</div>`;
68
- }
69
-
70
- html += `\n </div>\n</details>`;
71
- return html;
72
- }
73
-
74
- function esc(s) {
75
- return (s || "")
76
- .replace(/&/g, "&amp;")
77
- .replace(/</g, "&lt;")
78
- .replace(/>/g, "&gt;");
79
- }
@@ -1,39 +0,0 @@
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>agentic-seo</th><th>Cloudflare</th><th>Fern</th><th>Avg</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.averageScore - prev.averageScore : 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 cf =
23
- s.cloudflare != null && s.cloudflareMax
24
- ? `${s.cloudflare}/${s.cloudflareMax}`
25
- : "—";
26
-
27
- html += `\n<tr>
28
- <td>${date}</td>
29
- <td>${s.agenticSeo ?? "—"}</td>
30
- <td>${cf}</td>
31
- <td>${s.fern ?? "—"}</td>
32
- <td>${s.averageScore ?? "—"}</td>
33
- <td>${deltaStr}</td>
34
- </tr>`;
35
- }
36
-
37
- html += `\n</table>`;
38
- return html;
39
- }
@@ -1,65 +0,0 @@
1
- export function renderOverallScore(result) {
2
- const { benchmarks, averageScore } = result;
3
-
4
- let html = `<div id="overall" class="scores">`;
5
-
6
- if (benchmarks.agenticSeo?.available) {
7
- html += scoreCard(
8
- "agentic-seo",
9
- benchmarks.agenticSeo.score,
10
- 100,
11
- benchmarks.agenticSeo.grade,
12
- );
13
- }
14
- if (benchmarks.cloudflare?.available) {
15
- html += scoreCard(
16
- "Cloudflare",
17
- benchmarks.cloudflare.score,
18
- benchmarks.cloudflare.maxScore,
19
- benchmarks.cloudflare.grade,
20
- );
21
- }
22
- if (benchmarks.fern?.available) {
23
- html += scoreCard(
24
- "Fern",
25
- benchmarks.fern.score,
26
- 100,
27
- benchmarks.fern.grade,
28
- );
29
- }
30
-
31
- html += `</div>`;
32
-
33
- const gc =
34
- averageScore >= 80 ? "grade-a" : averageScore >= 50 ? "grade-c" : "grade-f";
35
- html += `<div style="text-align:center;margin:16px 0;font-size:14px;color:#8b949e;">Average across sources: <strong class="${gc}">${averageScore}/100</strong></div>`;
36
-
37
- return html;
38
- }
39
-
40
- function scoreCard(name, score, maxScore, grade) {
41
- const pct = maxScore > 0 ? Math.round((score / maxScore) * 100) : 0;
42
- const gc = grade
43
- ? grade.toLowerCase()
44
- : pct >= 80
45
- ? "a"
46
- : pct >= 65
47
- ? "b"
48
- : pct >= 50
49
- ? "c"
50
- : pct >= 35
51
- ? "d"
52
- : "f";
53
-
54
- return `
55
- <div class="score-card">
56
- <div class="name">${esc(name)}</div>
57
- <div class="grade grade-${gc}">${score}/${maxScore}</div>
58
- <div class="number">${grade || ""}</div>
59
- <div class="bar"><div class="bar-fill bar-${gc}" style="width:${pct}%"></div></div>
60
- </div>`;
61
- }
62
-
63
- function esc(s) {
64
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
65
- }
@@ -1,63 +0,0 @@
1
- export function renderTrendChart(history) {
2
- if (!history.scans || history.scans.length < 2) {
3
- return `<h2 id="trends">Score Trends</h2>\n<p style="color:#8b949e;font-size:13px;">Need at least 2 scans for trend data.</p>`;
4
- }
5
-
6
- const scans = history.scans.slice(-20);
7
- const width = 700;
8
- const height = 200;
9
- const pad = { top: 20, right: 20, bottom: 30, left: 40 };
10
- const chartW = width - pad.left - pad.right;
11
- const chartH = height - pad.top - pad.bottom;
12
- const xStep = scans.length > 1 ? chartW / (scans.length - 1) : chartW;
13
-
14
- function toY(value) {
15
- return pad.top + chartH - (value / 100) * chartH;
16
- }
17
-
18
- function polyline(values, color) {
19
- const pts = values.map(
20
- (v, i) =>
21
- `${i === 0 ? "M" : "L"} ${(pad.left + i * xStep).toFixed(1)} ${toY(v ?? 0).toFixed(1)}`,
22
- );
23
- return `<path d="${pts.join(" ")}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`;
24
- }
25
-
26
- const agenticSeo = scans.map((s) => s.agenticSeo ?? 0);
27
- const cloudflare = scans.map((s) =>
28
- s.cloudflare != null && s.cloudflareMax
29
- ? Math.round((s.cloudflare / s.cloudflareMax) * 100)
30
- : 0,
31
- );
32
- const fern = scans.map((s) => s.fern ?? 0);
33
-
34
- const grid = [0, 25, 50, 75, 100]
35
- .map((v) => {
36
- const y = toY(v);
37
- return `<line x1="${pad.left}" y1="${y}" x2="${width - pad.right}" y2="${y}" stroke="#21262d"/><text x="${pad.left - 8}" y="${y + 4}" text-anchor="end" fill="#8b949e" font-size="10">${v}</text>`;
38
- })
39
- .join("\n");
40
-
41
- const labels = scans
42
- .map((s, i) => {
43
- if (scans.length <= 10 || i % Math.ceil(scans.length / 8) === 0) {
44
- const x = pad.left + i * xStep;
45
- const label = s.timestamp ? s.timestamp.slice(5, 10) : "";
46
- return `<text x="${x}" y="${height - 5}" text-anchor="middle" fill="#8b949e" font-size="10">${label}</text>`;
47
- }
48
- return "";
49
- })
50
- .join("\n");
51
-
52
- return `<h2 id="trends">Score Trends</h2>
53
- <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
54
- ${grid}
55
- ${labels}
56
- ${polyline(agenticSeo, "#f85149")}
57
- ${polyline(cloudflare, "#3fb950")}
58
- ${polyline(fern, "#79c0ff")}
59
- <text x="${width - pad.right}" y="${pad.top - 5}" text-anchor="end" fill="#f85149" font-size="10">agentic-seo</text>
60
- <text x="${width - pad.right - 90}" y="${pad.top - 5}" text-anchor="end" fill="#3fb950" font-size="10">Cloudflare</text>
61
- <text x="${width - pad.right - 170}" y="${pad.top - 5}" text-anchor="end" fill="#79c0ff" font-size="10">Fern</text>
62
- </svg>`;
63
- }