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,209 @@
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
+ }
@@ -0,0 +1,242 @@
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
+ }
@@ -0,0 +1,231 @@
1
+ import { estimateTokens } from "../../utils/tokens.js";
2
+
3
+ const AI_CRAWLERS = [
4
+ "GPTBot",
5
+ "OAI-SearchBot",
6
+ "ClaudeBot",
7
+ "Claude-User",
8
+ "Claude-SearchBot",
9
+ "Google-Extended",
10
+ "PerplexityBot",
11
+ "Meta-ExternalAgent",
12
+ "CCBot",
13
+ ];
14
+
15
+ export async function runDiscoveryChecks(context) {
16
+ const checks = [
17
+ checkLlmsTxt(context),
18
+ checkRobotsTxt(context),
19
+ checkSitemap(context),
20
+ checkAiMetaTags(context),
21
+ ];
22
+
23
+ const score = checks.reduce((sum, c) => sum + (c.passed ? c.points : 0), 0);
24
+ return { score, maxScore: 12, checks };
25
+ }
26
+
27
+ function checkLlmsTxt(context) {
28
+ const content = getContent(context, "llms.txt", "/llms.txt");
29
+ if (!content) {
30
+ return fail(
31
+ "llms.txt",
32
+ 4,
33
+ "No llms.txt found. Create one describing what your site does for AI agents.",
34
+ );
35
+ }
36
+
37
+ const tokens = estimateTokens(content);
38
+ if (tokens > 5000) {
39
+ return partial(
40
+ "llms.txt",
41
+ 2,
42
+ 4,
43
+ `llms.txt exists but is ${tokens} tokens (budget: <5000).`,
44
+ );
45
+ }
46
+
47
+ const hasUsefulContent =
48
+ content.length > 100 && !content.toLowerCase().includes("lorem");
49
+ if (!hasUsefulContent) {
50
+ return partial(
51
+ "llms.txt",
52
+ 1,
53
+ 4,
54
+ "llms.txt exists but has minimal/placeholder content.",
55
+ );
56
+ }
57
+
58
+ const frontLoaded = checkFrontLoading(content);
59
+ return frontLoaded
60
+ ? pass("llms.txt", 4)
61
+ : partial(
62
+ "llms.txt",
63
+ 3,
64
+ 4,
65
+ "llms.txt exists but doesn't front-load what/why/how in first 500 tokens.",
66
+ );
67
+ }
68
+
69
+ function checkRobotsTxt(context) {
70
+ const content = getContent(context, "robots.txt", "/robots.txt");
71
+ if (!content) {
72
+ return fail(
73
+ "robots.txt AI crawlers",
74
+ 3,
75
+ "No robots.txt found. Add one with explicit AI crawler rules.",
76
+ );
77
+ }
78
+
79
+ const allowed = AI_CRAWLERS.filter(
80
+ (bot) => content.includes(bot) && hasAllow(content, bot),
81
+ );
82
+ const blocked = AI_CRAWLERS.filter(
83
+ (bot) => content.includes(bot) && hasDisallow(content, bot),
84
+ );
85
+
86
+ if (allowed.length >= 5) {
87
+ return pass("robots.txt AI crawlers", 3);
88
+ }
89
+ if (blocked.length > 3) {
90
+ return fail(
91
+ "robots.txt AI crawlers",
92
+ 3,
93
+ `Blocks ${blocked.length} AI crawlers. Consider allowing for discoverability.`,
94
+ );
95
+ }
96
+ if (allowed.length > 0) {
97
+ return partial(
98
+ "robots.txt AI crawlers",
99
+ 1,
100
+ 3,
101
+ `Only ${allowed.length}/9 AI crawlers explicitly allowed.`,
102
+ );
103
+ }
104
+ return partial(
105
+ "robots.txt AI crawlers",
106
+ 1,
107
+ 3,
108
+ "robots.txt exists but doesn't mention AI crawlers. Add explicit Allow rules.",
109
+ );
110
+ }
111
+
112
+ function checkSitemap(context) {
113
+ const content = getContent(context, "sitemap.xml", "/sitemap.xml");
114
+ if (!content) {
115
+ return fail("sitemap.xml", 2, "No sitemap.xml found.");
116
+ }
117
+ const hasUrls = content.includes("<url>") || content.includes("<loc>");
118
+ return hasUrls
119
+ ? pass("sitemap.xml", 2)
120
+ : partial(
121
+ "sitemap.xml",
122
+ 1,
123
+ 2,
124
+ "sitemap.xml exists but has no URL entries.",
125
+ );
126
+ }
127
+
128
+ function checkAiMetaTags(context) {
129
+ const html = context.html || "";
130
+ if (!html) {
131
+ return fail(
132
+ "AI-friendly meta tags",
133
+ 3,
134
+ "No HTML available to check meta tags.",
135
+ );
136
+ }
137
+
138
+ let points = 0;
139
+ if (html.includes("og:title") || html.includes("og:description")) points += 1;
140
+ if (
141
+ html.includes('meta name="description"') ||
142
+ html.includes("meta name='description'")
143
+ )
144
+ points += 1;
145
+ if (html.includes("application/ld+json")) points += 1;
146
+
147
+ if (points === 3) return pass("AI-friendly meta tags", 3);
148
+ if (points > 0)
149
+ return partial(
150
+ "AI-friendly meta tags",
151
+ points,
152
+ 3,
153
+ "Some meta tags present but incomplete (need og:, description, JSON-LD).",
154
+ );
155
+ return fail(
156
+ "AI-friendly meta tags",
157
+ 3,
158
+ "No AI-friendly meta tags (og:, description, JSON-LD) found.",
159
+ );
160
+ }
161
+
162
+ const PAGE_KEY_MAP = {
163
+ "llms.txt": "llmsTxt",
164
+ "robots.txt": "robotsTxt",
165
+ "sitemap.xml": "sitemap",
166
+ "agents.json": "agentJson",
167
+ "openapi.json": "openapi",
168
+ "openapi.yaml": "openapi",
169
+ };
170
+
171
+ function getContent(context, filename) {
172
+ if (context.mode === "url") {
173
+ const key = PAGE_KEY_MAP[filename];
174
+ const page = key ? context.pages[key] : null;
175
+ return page && page.status === 200 ? page.text : null;
176
+ }
177
+ const match = context.files.find(
178
+ (f) =>
179
+ f === filename ||
180
+ f.endsWith(`/${filename}`) ||
181
+ f === `public/${filename}`,
182
+ );
183
+ return match ? context.fileContents[match] : null;
184
+ }
185
+
186
+ function toCamelCase(filename) {
187
+ const name = filename.replace(/\.[^.]+$/, "");
188
+ return name.replace(/[-_.](.)/g, (_, c) => c.toUpperCase());
189
+ }
190
+
191
+ function hasAllow(robotsTxt, bot) {
192
+ const section = extractBotSection(robotsTxt, bot);
193
+ return (
194
+ section &&
195
+ /Allow:\s*\//.test(section) &&
196
+ !/Disallow:\s*\/\s*$/m.test(section)
197
+ );
198
+ }
199
+
200
+ function hasDisallow(robotsTxt, bot) {
201
+ const section = extractBotSection(robotsTxt, bot);
202
+ return section && /Disallow:\s*\/\s*$/m.test(section);
203
+ }
204
+
205
+ function extractBotSection(robotsTxt, bot) {
206
+ const regex = new RegExp(
207
+ `User-agent:\\s*${bot}[\\s\\S]*?(?=User-agent:|$)`,
208
+ "i",
209
+ );
210
+ const match = robotsTxt.match(regex);
211
+ return match ? match[0] : null;
212
+ }
213
+
214
+ function checkFrontLoading(text) {
215
+ const first500Tokens = text.slice(0, 2000);
216
+ const hasWhat = /what|does|is/i.test(first500Tokens);
217
+ const hasHow = /how|get started|install|use/i.test(first500Tokens);
218
+ return hasWhat && hasHow;
219
+ }
220
+
221
+ function pass(name, points) {
222
+ return { name, passed: true, points, maxPoints: points };
223
+ }
224
+
225
+ function partial(name, points, maxPoints, fix) {
226
+ return { name, passed: false, points, maxPoints, fix };
227
+ }
228
+
229
+ function fail(name, maxPoints, fix) {
230
+ return { name, passed: false, points: 0, maxPoints, fix };
231
+ }