aeo-ready 1.1.0 → 1.2.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.
- package/bin/cli.js +9 -62
- package/package.json +2 -2
- package/src/dashboard/generate.js +22 -48
- package/src/dashboard/sections/benchmark-details.js +79 -0
- package/src/dashboard/sections/history-table.js +10 -7
- package/src/dashboard/sections/overall-score.js +55 -118
- package/src/dashboard/sections/trend-chart.js +31 -46
- package/src/history/index.js +8 -15
- package/src/scan.js +59 -293
- package/src/checks/agent-readiness/actionable.js +0 -165
- package/src/checks/agent-readiness/capability.js +0 -209
- package/src/checks/agent-readiness/content-structure.js +0 -242
- package/src/checks/agent-readiness/discovery.js +0 -231
- package/src/checks/ai-visibility/authority.js +0 -195
- package/src/checks/ai-visibility/citation-readiness.js +0 -228
- package/src/checks/ai-visibility/freshness.js +0 -182
- package/src/checks/ai-visibility/structured-data.js +0 -180
- package/src/dashboard/sections/agent-readiness.js +0 -71
- package/src/dashboard/sections/ai-visibility.js +0 -67
- package/src/dashboard/sections/recommendations.js +0 -196
- package/src/fix/generators/agents-json.js +0 -73
- package/src/fix/generators/agents-md.js +0 -85
- package/src/fix/generators/llms-txt.js +0 -166
- package/src/fix/generators/robots-txt.js +0 -64
- package/src/fix/index.js +0 -177
- package/src/track/index.js +0 -167
- package/src/utils/detect-type.js +0 -99
- package/src/utils/tokens.js +0 -18
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
export async function runActionableChecks(context) {
|
|
2
|
-
const checks = [
|
|
3
|
-
checkContact(context),
|
|
4
|
-
checkPricing(context),
|
|
5
|
-
checkApiEndpoint(context),
|
|
6
|
-
checkSdkManifest(context),
|
|
7
|
-
];
|
|
8
|
-
|
|
9
|
-
const score = checks.reduce((sum, c) => sum + (c.passed ? c.points : 0), 0);
|
|
10
|
-
return { score, maxScore: 10, checks };
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function checkContact(context) {
|
|
14
|
-
const html = context.html || "";
|
|
15
|
-
const allContent = getAllContent(context);
|
|
16
|
-
|
|
17
|
-
const hasEmail = /[\w.+-]+@[\w-]+\.[\w.]+/.test(allContent);
|
|
18
|
-
const hasCalLink = /cal\.com|calendly\.com|hubspot\.com\/meetings/.test(
|
|
19
|
-
allContent,
|
|
20
|
-
);
|
|
21
|
-
const hasStructuredContact =
|
|
22
|
-
allContent.includes("ContactPoint") || allContent.includes('"email"');
|
|
23
|
-
|
|
24
|
-
let points = 0;
|
|
25
|
-
if (hasEmail) points += 1;
|
|
26
|
-
if (hasCalLink) points += 1;
|
|
27
|
-
if (hasStructuredContact) points += 1;
|
|
28
|
-
|
|
29
|
-
if (points === 3) return pass("Machine-readable contact", 3);
|
|
30
|
-
if (points > 0) {
|
|
31
|
-
const missing = [];
|
|
32
|
-
if (!hasEmail) missing.push("email");
|
|
33
|
-
if (!hasCalLink) missing.push("booking link");
|
|
34
|
-
if (!hasStructuredContact) missing.push("structured ContactPoint");
|
|
35
|
-
return partial(
|
|
36
|
-
"Machine-readable contact",
|
|
37
|
-
points,
|
|
38
|
-
3,
|
|
39
|
-
`Add: ${missing.join(", ")}.`,
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
return fail(
|
|
43
|
-
"Machine-readable contact",
|
|
44
|
-
3,
|
|
45
|
-
"No machine-readable contact info. Add email, cal.com link, or ContactPoint schema.",
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function checkPricing(context) {
|
|
50
|
-
if (context.siteType === "personal" || context.siteType === "content") {
|
|
51
|
-
return {
|
|
52
|
-
name: "Programmatic pricing",
|
|
53
|
-
passed: true,
|
|
54
|
-
points: 3,
|
|
55
|
-
maxPoints: 3,
|
|
56
|
-
note: "N/A for site type",
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const allContent = getAllContent(context);
|
|
61
|
-
const files = context.files || [];
|
|
62
|
-
|
|
63
|
-
const hasPricingJson = files.some((f) => f.includes("pricing.json"));
|
|
64
|
-
const hasPriceSchema =
|
|
65
|
-
allContent.includes("PriceSpecification") || allContent.includes("offers");
|
|
66
|
-
const hasPricingPage =
|
|
67
|
-
allContent.includes("/pricing") || allContent.includes("pricing");
|
|
68
|
-
|
|
69
|
-
if (hasPricingJson || hasPriceSchema) return pass("Programmatic pricing", 3);
|
|
70
|
-
if (hasPricingPage)
|
|
71
|
-
return partial(
|
|
72
|
-
"Programmatic pricing",
|
|
73
|
-
1,
|
|
74
|
-
3,
|
|
75
|
-
"Pricing page exists but not in structured format. Add schema.org PriceSpecification or pricing.json.",
|
|
76
|
-
);
|
|
77
|
-
return fail(
|
|
78
|
-
"Programmatic pricing",
|
|
79
|
-
3,
|
|
80
|
-
"No programmatic pricing. Agents comparing you to competitors need structured plan data.",
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function checkApiEndpoint(context) {
|
|
85
|
-
if (context.siteType === "personal") {
|
|
86
|
-
return {
|
|
87
|
-
name: "API endpoint",
|
|
88
|
-
passed: true,
|
|
89
|
-
points: 2,
|
|
90
|
-
maxPoints: 2,
|
|
91
|
-
note: "N/A for site type",
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const allContent = getAllContent(context);
|
|
96
|
-
const hasApiRef = /\/api\/|\/v[12]\/|api-key|apikey/i.test(allContent);
|
|
97
|
-
const hasOpenApi =
|
|
98
|
-
context.files?.some((f) => f.includes("openapi")) ||
|
|
99
|
-
context.pages?.openapi?.status === 200;
|
|
100
|
-
|
|
101
|
-
if (hasApiRef && hasOpenApi) return pass("API endpoint", 2);
|
|
102
|
-
if (hasApiRef)
|
|
103
|
-
return partial(
|
|
104
|
-
"API endpoint",
|
|
105
|
-
1,
|
|
106
|
-
2,
|
|
107
|
-
"API references found but no formal spec. Add OpenAPI spec.",
|
|
108
|
-
);
|
|
109
|
-
return fail("API endpoint", 2, "No API endpoint detected.");
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function checkSdkManifest(context) {
|
|
113
|
-
if (context.siteType === "personal" || context.siteType === "content") {
|
|
114
|
-
return {
|
|
115
|
-
name: "SDK / integration manifest",
|
|
116
|
-
passed: true,
|
|
117
|
-
points: 2,
|
|
118
|
-
maxPoints: 2,
|
|
119
|
-
note: "N/A for site type",
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const allContent = getAllContent(context);
|
|
124
|
-
const hasSdk = /npm install|pip install|go get|cargo add|maven|gradle/i.test(
|
|
125
|
-
allContent,
|
|
126
|
-
);
|
|
127
|
-
const hasIntegrations = /integrat|connect|plugin|extension|mcp/i.test(
|
|
128
|
-
allContent,
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
if (hasSdk) return pass("SDK / integration manifest", 2);
|
|
132
|
-
if (hasIntegrations)
|
|
133
|
-
return partial(
|
|
134
|
-
"SDK / integration manifest",
|
|
135
|
-
1,
|
|
136
|
-
2,
|
|
137
|
-
"Integration mentions found but no install commands or SDK manifest.",
|
|
138
|
-
);
|
|
139
|
-
return fail(
|
|
140
|
-
"SDK / integration manifest",
|
|
141
|
-
2,
|
|
142
|
-
"No SDK or integration manifest detected.",
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function getAllContent(context) {
|
|
147
|
-
if (context.mode === "url") {
|
|
148
|
-
return Object.values(context.pages || {})
|
|
149
|
-
.map((p) => p?.text || "")
|
|
150
|
-
.join("\n");
|
|
151
|
-
}
|
|
152
|
-
return Object.values(context.fileContents || {}).join("\n");
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function pass(name, points) {
|
|
156
|
-
return { name, passed: true, points, maxPoints: points };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function partial(name, points, maxPoints, fix) {
|
|
160
|
-
return { name, passed: false, points, maxPoints, fix };
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function fail(name, maxPoints, fix) {
|
|
164
|
-
return { name, passed: false, points: 0, maxPoints, fix };
|
|
165
|
-
}
|
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
export async function runCapabilityChecks(context) {
|
|
2
|
-
const checks = [
|
|
3
|
-
checkAgentInstructions(context),
|
|
4
|
-
checkAgentManifest(context),
|
|
5
|
-
checkOpenApiSpec(context),
|
|
6
|
-
checkContentNegotiation(context),
|
|
7
|
-
];
|
|
8
|
-
|
|
9
|
-
const score = checks.reduce((sum, c) => sum + (c.passed ? c.points : 0), 0);
|
|
10
|
-
return { score, maxScore: 13, checks };
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function checkAgentInstructions(context) {
|
|
14
|
-
const names = ["AGENTS.md", "CLAUDE.md", "agents.md", "claude.md"];
|
|
15
|
-
const skillFiles = ["skill.md", "skills/"];
|
|
16
|
-
|
|
17
|
-
if (context.mode === "url") {
|
|
18
|
-
const html = context.html || "";
|
|
19
|
-
const hasAgentFile = names.some((n) => html.includes(n));
|
|
20
|
-
return hasAgentFile
|
|
21
|
-
? partial(
|
|
22
|
-
"AGENTS.md / CLAUDE.md",
|
|
23
|
-
2,
|
|
24
|
-
4,
|
|
25
|
-
"Referenced in HTML but not directly accessible at known path.",
|
|
26
|
-
)
|
|
27
|
-
: fail(
|
|
28
|
-
"AGENTS.md / CLAUDE.md",
|
|
29
|
-
4,
|
|
30
|
-
"No agent instruction file (AGENTS.md, CLAUDE.md) detected.",
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const found = context.files.find((f) => {
|
|
35
|
-
const base = f.split("/").pop();
|
|
36
|
-
return names.includes(base) || skillFiles.some((s) => f.includes(s));
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
if (!found) {
|
|
40
|
-
return fail(
|
|
41
|
-
"AGENTS.md / CLAUDE.md",
|
|
42
|
-
4,
|
|
43
|
-
"No agent instruction file found. Add AGENTS.md or CLAUDE.md to repo root.",
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const content = context.fileContents[found] || "";
|
|
48
|
-
const hasStructure = content.includes("#") && content.length > 200;
|
|
49
|
-
const hasConstraints = /constraint|limit|rate|require/i.test(content);
|
|
50
|
-
const hasCapabilities = /capabilit|can do|feature|endpoint/i.test(content);
|
|
51
|
-
|
|
52
|
-
let points = 2;
|
|
53
|
-
if (hasStructure && hasCapabilities) points = 3;
|
|
54
|
-
if (hasStructure && hasCapabilities && hasConstraints) points = 4;
|
|
55
|
-
|
|
56
|
-
if (points === 4) return pass("AGENTS.md / CLAUDE.md", 4);
|
|
57
|
-
const missing = [];
|
|
58
|
-
if (!hasCapabilities) missing.push("capabilities");
|
|
59
|
-
if (!hasConstraints) missing.push("constraints/limits");
|
|
60
|
-
return partial(
|
|
61
|
-
"AGENTS.md / CLAUDE.md",
|
|
62
|
-
points,
|
|
63
|
-
4,
|
|
64
|
-
`Agent file exists but missing: ${missing.join(", ")}.`,
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function checkAgentManifest(context) {
|
|
69
|
-
const content = getContent(context, "agents.json", "/.well-known/agent.json");
|
|
70
|
-
|
|
71
|
-
if (!content) {
|
|
72
|
-
return fail(
|
|
73
|
-
"agents.json manifest",
|
|
74
|
-
3,
|
|
75
|
-
"No agents.json or .well-known/agent.json found.",
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
try {
|
|
80
|
-
const manifest = JSON.parse(content);
|
|
81
|
-
const hasName = !!manifest.name;
|
|
82
|
-
const hasCapabilities =
|
|
83
|
-
Array.isArray(manifest.capabilities) && manifest.capabilities.length > 0;
|
|
84
|
-
const hasInterfaces = !!manifest.interfaces;
|
|
85
|
-
|
|
86
|
-
if (hasName && hasCapabilities && hasInterfaces)
|
|
87
|
-
return pass("agents.json manifest", 3);
|
|
88
|
-
const missing = [];
|
|
89
|
-
if (!hasName) missing.push("name");
|
|
90
|
-
if (!hasCapabilities) missing.push("capabilities");
|
|
91
|
-
if (!hasInterfaces) missing.push("interfaces");
|
|
92
|
-
return partial(
|
|
93
|
-
"agents.json manifest",
|
|
94
|
-
1,
|
|
95
|
-
3,
|
|
96
|
-
`agents.json exists but missing: ${missing.join(", ")}.`,
|
|
97
|
-
);
|
|
98
|
-
} catch {
|
|
99
|
-
return partial(
|
|
100
|
-
"agents.json manifest",
|
|
101
|
-
1,
|
|
102
|
-
3,
|
|
103
|
-
"agents.json exists but is not valid JSON.",
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function checkOpenApiSpec(context) {
|
|
109
|
-
const jsonContent = getContent(context, "openapi.json", "/openapi.json");
|
|
110
|
-
const yamlContent = getContent(context, "openapi.yaml", "/openapi.yaml");
|
|
111
|
-
const content = jsonContent || yamlContent;
|
|
112
|
-
|
|
113
|
-
if (!content) {
|
|
114
|
-
if (context.siteType === "content" || context.siteType === "personal") {
|
|
115
|
-
return {
|
|
116
|
-
name: "OpenAPI spec",
|
|
117
|
-
passed: true,
|
|
118
|
-
points: 3,
|
|
119
|
-
maxPoints: 3,
|
|
120
|
-
note: "N/A for site type",
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
return fail(
|
|
124
|
-
"OpenAPI spec",
|
|
125
|
-
3,
|
|
126
|
-
"No OpenAPI spec found. Add openapi.json or openapi.yaml.",
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const hasEndpoints = content.includes("paths") || content.includes("/api");
|
|
131
|
-
const hasVersion = content.includes("openapi") || content.includes("swagger");
|
|
132
|
-
|
|
133
|
-
if (hasEndpoints && hasVersion) return pass("OpenAPI spec", 3);
|
|
134
|
-
return partial(
|
|
135
|
-
"OpenAPI spec",
|
|
136
|
-
2,
|
|
137
|
-
3,
|
|
138
|
-
"OpenAPI spec exists but may be incomplete.",
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function checkContentNegotiation(context) {
|
|
143
|
-
if (context.mode === "url") {
|
|
144
|
-
const headers = context.pages.home?.headers || {};
|
|
145
|
-
const acceptsMarkdown =
|
|
146
|
-
headers["content-type"]?.includes("markdown") ||
|
|
147
|
-
headers["vary"]?.includes("Accept");
|
|
148
|
-
return acceptsMarkdown
|
|
149
|
-
? pass("Content negotiation", 3)
|
|
150
|
-
: fail(
|
|
151
|
-
"Content negotiation",
|
|
152
|
-
3,
|
|
153
|
-
"No content negotiation detected. Serve markdown when Accept: text/markdown is requested.",
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const hasMiddleware = context.files.some(
|
|
158
|
-
(f) => f.includes("middleware") || f.includes("negotiat"),
|
|
159
|
-
);
|
|
160
|
-
if (hasMiddleware)
|
|
161
|
-
return partial(
|
|
162
|
-
"Content negotiation",
|
|
163
|
-
2,
|
|
164
|
-
3,
|
|
165
|
-
"Middleware detected — verify Accept: text/markdown is handled.",
|
|
166
|
-
);
|
|
167
|
-
return fail(
|
|
168
|
-
"Content negotiation",
|
|
169
|
-
3,
|
|
170
|
-
"No content negotiation setup. Consider serving markdown for Accept: text/markdown requests.",
|
|
171
|
-
);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const PAGE_KEY_MAP = {
|
|
175
|
-
"llms.txt": "llmsTxt",
|
|
176
|
-
"robots.txt": "robotsTxt",
|
|
177
|
-
"sitemap.xml": "sitemap",
|
|
178
|
-
"agents.json": "agentJson",
|
|
179
|
-
"openapi.json": "openapi",
|
|
180
|
-
"openapi.yaml": "openapi",
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
function getContent(context, filename) {
|
|
184
|
-
if (context.mode === "url") {
|
|
185
|
-
const key = PAGE_KEY_MAP[filename];
|
|
186
|
-
const page = key ? context.pages[key] : null;
|
|
187
|
-
return page && page.status === 200 ? page.text : null;
|
|
188
|
-
}
|
|
189
|
-
const match = context.files.find(
|
|
190
|
-
(f) =>
|
|
191
|
-
f === filename ||
|
|
192
|
-
f.endsWith(`/${filename}`) ||
|
|
193
|
-
f === `.well-known/${filename}` ||
|
|
194
|
-
f === `public/.well-known/${filename}`,
|
|
195
|
-
);
|
|
196
|
-
return match ? context.fileContents[match] : null;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function pass(name, points) {
|
|
200
|
-
return { name, passed: true, points, maxPoints: points };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function partial(name, points, maxPoints, fix) {
|
|
204
|
-
return { name, passed: false, points, maxPoints, fix };
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function fail(name, maxPoints, fix) {
|
|
208
|
-
return { name, passed: false, points: 0, maxPoints, fix };
|
|
209
|
-
}
|
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
import { estimateTokens, TOKEN_BUDGETS } from "../../utils/tokens.js";
|
|
2
|
-
|
|
3
|
-
export async function runContentStructureChecks(context) {
|
|
4
|
-
const checks = [
|
|
5
|
-
checkMarkdownAvailability(context),
|
|
6
|
-
checkHeadingHierarchy(context),
|
|
7
|
-
checkTokenBudgets(context),
|
|
8
|
-
checkFrontLoading(context),
|
|
9
|
-
checkCopyForAi(context),
|
|
10
|
-
];
|
|
11
|
-
|
|
12
|
-
const score = checks.reduce((sum, c) => sum + (c.passed ? c.points : 0), 0);
|
|
13
|
-
return { score, maxScore: 15, checks };
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function checkMarkdownAvailability(context) {
|
|
17
|
-
if (context.mode === "url") {
|
|
18
|
-
const html = context.html || "";
|
|
19
|
-
const hasMarkdownLink =
|
|
20
|
-
html.includes(".md") || html.includes("text/markdown");
|
|
21
|
-
return hasMarkdownLink
|
|
22
|
-
? pass("Markdown content available", 3)
|
|
23
|
-
: fail(
|
|
24
|
-
"Markdown content available",
|
|
25
|
-
3,
|
|
26
|
-
"No markdown versions of content detected. Serve .md files or support Accept: text/markdown.",
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const mdFiles = context.files.filter((f) => f.endsWith(".md"));
|
|
31
|
-
if (mdFiles.length === 0) {
|
|
32
|
-
return fail(
|
|
33
|
-
"Markdown content available",
|
|
34
|
-
3,
|
|
35
|
-
"No markdown files found in project.",
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
if (mdFiles.length >= 3) return pass("Markdown content available", 3);
|
|
39
|
-
return partial(
|
|
40
|
-
"Markdown content available",
|
|
41
|
-
2,
|
|
42
|
-
3,
|
|
43
|
-
`Only ${mdFiles.length} markdown files. More content should be in .md format.`,
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function checkHeadingHierarchy(context) {
|
|
48
|
-
const content = getAllTextContent(context);
|
|
49
|
-
if (!content) {
|
|
50
|
-
return fail(
|
|
51
|
-
"Heading hierarchy",
|
|
52
|
-
3,
|
|
53
|
-
"No content available to analyze headings.",
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const headings = extractHeadings(content);
|
|
58
|
-
if (headings.length === 0) {
|
|
59
|
-
return fail("Heading hierarchy", 3, "No headings found in content.");
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const hasH1 = headings.some((h) => h.level === 1);
|
|
63
|
-
const hierarchyValid = checkHierarchyOrder(headings);
|
|
64
|
-
const noSkips = !hasLevelSkips(headings);
|
|
65
|
-
|
|
66
|
-
let points = 0;
|
|
67
|
-
if (hasH1) points += 1;
|
|
68
|
-
if (hierarchyValid) points += 1;
|
|
69
|
-
if (noSkips) points += 1;
|
|
70
|
-
|
|
71
|
-
if (points === 3) return pass("Heading hierarchy", 3);
|
|
72
|
-
const issues = [];
|
|
73
|
-
if (!hasH1) issues.push("no H1");
|
|
74
|
-
if (!hierarchyValid) issues.push("inconsistent hierarchy");
|
|
75
|
-
if (!noSkips) issues.push("skipped heading levels");
|
|
76
|
-
return partial(
|
|
77
|
-
"Heading hierarchy",
|
|
78
|
-
points,
|
|
79
|
-
3,
|
|
80
|
-
`Heading issues: ${issues.join(", ")}.`,
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function checkTokenBudgets(context) {
|
|
85
|
-
const content = getAllTextContent(context);
|
|
86
|
-
if (!content) {
|
|
87
|
-
return fail(
|
|
88
|
-
"Token budget compliance",
|
|
89
|
-
4,
|
|
90
|
-
"No content to measure token counts.",
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const pages = getPageContents(context);
|
|
95
|
-
if (pages.length === 0) {
|
|
96
|
-
return partial(
|
|
97
|
-
"Token budget compliance",
|
|
98
|
-
2,
|
|
99
|
-
4,
|
|
100
|
-
"Could not identify individual pages to check budgets.",
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const overBudget = pages.filter((p) => estimateTokens(p.content) > 25000);
|
|
105
|
-
const avgTokens =
|
|
106
|
-
pages.reduce((sum, p) => sum + estimateTokens(p.content), 0) / pages.length;
|
|
107
|
-
|
|
108
|
-
if (overBudget.length === 0 && avgTokens < 15000)
|
|
109
|
-
return pass("Token budget compliance", 4);
|
|
110
|
-
if (overBudget.length === 0)
|
|
111
|
-
return partial(
|
|
112
|
-
"Token budget compliance",
|
|
113
|
-
3,
|
|
114
|
-
4,
|
|
115
|
-
`Average page is ${Math.round(avgTokens)} tokens. Under 15K is ideal.`,
|
|
116
|
-
);
|
|
117
|
-
return partial(
|
|
118
|
-
"Token budget compliance",
|
|
119
|
-
1,
|
|
120
|
-
4,
|
|
121
|
-
`${overBudget.length} pages exceed 25K token budget.`,
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function checkFrontLoading(context) {
|
|
126
|
-
const pages = getPageContents(context);
|
|
127
|
-
if (pages.length === 0) {
|
|
128
|
-
return fail(
|
|
129
|
-
"Front-loading (first 500 tokens)",
|
|
130
|
-
3,
|
|
131
|
-
"No pages to analyze for front-loading.",
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
let frontLoaded = 0;
|
|
136
|
-
for (const page of pages.slice(0, 10)) {
|
|
137
|
-
const first2000Chars = page.content.slice(0, 2000);
|
|
138
|
-
const hasWhat = /\b(what|is|does|provides?|offers?)\b/i.test(
|
|
139
|
-
first2000Chars,
|
|
140
|
-
);
|
|
141
|
-
const hasWhy = /\b(why|because|benefit|solves?|helps?)\b/i.test(
|
|
142
|
-
first2000Chars,
|
|
143
|
-
);
|
|
144
|
-
if (hasWhat && hasWhy) frontLoaded++;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const ratio = frontLoaded / Math.min(pages.length, 10);
|
|
148
|
-
if (ratio >= 0.8) return pass("Front-loading (first 500 tokens)", 3);
|
|
149
|
-
if (ratio >= 0.5)
|
|
150
|
-
return partial(
|
|
151
|
-
"Front-loading (first 500 tokens)",
|
|
152
|
-
2,
|
|
153
|
-
3,
|
|
154
|
-
`Only ${Math.round(ratio * 100)}% of pages front-load what/why in first 500 tokens.`,
|
|
155
|
-
);
|
|
156
|
-
return partial(
|
|
157
|
-
"Front-loading (first 500 tokens)",
|
|
158
|
-
1,
|
|
159
|
-
3,
|
|
160
|
-
`Most pages don\'t answer what/why/how in first 500 tokens. Lead with the point.`,
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function checkCopyForAi(context) {
|
|
165
|
-
const html = context.html || "";
|
|
166
|
-
const files = context.files || [];
|
|
167
|
-
|
|
168
|
-
const hasCopyButton =
|
|
169
|
-
html.includes("copy") &&
|
|
170
|
-
(html.includes("markdown") || html.includes("clipboard"));
|
|
171
|
-
const hasMarkdownExport = files.some(
|
|
172
|
-
(f) => f.includes("export") && f.endsWith(".md"),
|
|
173
|
-
);
|
|
174
|
-
|
|
175
|
-
if (hasCopyButton) return pass("Copy-for-AI affordances", 2);
|
|
176
|
-
if (hasMarkdownExport)
|
|
177
|
-
return partial(
|
|
178
|
-
"Copy-for-AI affordances",
|
|
179
|
-
1,
|
|
180
|
-
2,
|
|
181
|
-
"Markdown export exists but no copy-as-markdown button detected.",
|
|
182
|
-
);
|
|
183
|
-
return fail(
|
|
184
|
-
"Copy-for-AI affordances",
|
|
185
|
-
2,
|
|
186
|
-
'No copy-for-AI affordances. Add a "Copy as Markdown" button on doc pages.',
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function getAllTextContent(context) {
|
|
191
|
-
if (context.mode === "url") return context.html || "";
|
|
192
|
-
const textFiles = context.files.filter(
|
|
193
|
-
(f) => f.endsWith(".md") || f.endsWith(".html") || f.endsWith(".txt"),
|
|
194
|
-
);
|
|
195
|
-
return textFiles.map((f) => context.fileContents[f] || "").join("\n");
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function getPageContents(context) {
|
|
199
|
-
if (context.mode === "url") {
|
|
200
|
-
return context.html ? [{ name: "homepage", content: context.html }] : [];
|
|
201
|
-
}
|
|
202
|
-
return context.files
|
|
203
|
-
.filter((f) => f.endsWith(".md") || f.endsWith(".html"))
|
|
204
|
-
.map((f) => ({ name: f, content: context.fileContents[f] || "" }))
|
|
205
|
-
.filter((p) => p.content.length > 0);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function extractHeadings(content) {
|
|
209
|
-
const mdHeadings = [...content.matchAll(/^(#{1,6})\s+(.+)$/gm)].map((m) => ({
|
|
210
|
-
level: m[1].length,
|
|
211
|
-
text: m[2],
|
|
212
|
-
}));
|
|
213
|
-
const htmlHeadings = [...content.matchAll(/<h([1-6])[^>]*>([^<]+)/gi)].map(
|
|
214
|
-
(m) => ({ level: parseInt(m[1]), text: m[2] }),
|
|
215
|
-
);
|
|
216
|
-
return mdHeadings.length > htmlHeadings.length ? mdHeadings : htmlHeadings;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function checkHierarchyOrder(headings) {
|
|
220
|
-
if (headings.length < 2) return true;
|
|
221
|
-
const firstLevel = headings[0].level;
|
|
222
|
-
return headings.slice(1).every((h) => h.level >= firstLevel);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function hasLevelSkips(headings) {
|
|
226
|
-
for (let i = 1; i < headings.length; i++) {
|
|
227
|
-
if (headings[i].level - headings[i - 1].level > 1) return true;
|
|
228
|
-
}
|
|
229
|
-
return false;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function pass(name, points) {
|
|
233
|
-
return { name, passed: true, points, maxPoints: points };
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function partial(name, points, maxPoints, fix) {
|
|
237
|
-
return { name, passed: false, points, maxPoints, fix };
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function fail(name, maxPoints, fix) {
|
|
241
|
-
return { name, passed: false, points: 0, maxPoints, fix };
|
|
242
|
-
}
|