aeo-ready 1.0.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.
Files changed (33) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/bin/cli.js +9 -62
  3. package/package.json +5 -4
  4. package/src/benchmark/cloudflare.js +75 -0
  5. package/src/benchmark/fern.js +51 -0
  6. package/src/benchmark/index.js +119 -0
  7. package/src/dashboard/generate.js +22 -48
  8. package/src/dashboard/sections/benchmark-details.js +79 -0
  9. package/src/dashboard/sections/history-table.js +10 -7
  10. package/src/dashboard/sections/overall-score.js +55 -118
  11. package/src/dashboard/sections/trend-chart.js +31 -46
  12. package/src/history/index.js +8 -11
  13. package/src/scan.js +58 -294
  14. package/.aeo-ready/dashboard.html +0 -339
  15. package/src/checks/agent-readiness/actionable.js +0 -165
  16. package/src/checks/agent-readiness/capability.js +0 -209
  17. package/src/checks/agent-readiness/content-structure.js +0 -242
  18. package/src/checks/agent-readiness/discovery.js +0 -231
  19. package/src/checks/ai-visibility/authority.js +0 -195
  20. package/src/checks/ai-visibility/citation-readiness.js +0 -228
  21. package/src/checks/ai-visibility/freshness.js +0 -182
  22. package/src/checks/ai-visibility/structured-data.js +0 -180
  23. package/src/dashboard/sections/agent-readiness.js +0 -71
  24. package/src/dashboard/sections/ai-visibility.js +0 -67
  25. package/src/dashboard/sections/recommendations.js +0 -49
  26. package/src/fix/generators/agents-json.js +0 -73
  27. package/src/fix/generators/agents-md.js +0 -85
  28. package/src/fix/generators/llms-txt.js +0 -166
  29. package/src/fix/generators/robots-txt.js +0 -64
  30. package/src/fix/index.js +0 -177
  31. package/src/track/index.js +0 -167
  32. package/src/utils/detect-type.js +0 -99
  33. package/src/utils/tokens.js +0 -18
@@ -1,180 +0,0 @@
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
- }
@@ -1,71 +0,0 @@
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
- }
@@ -1,67 +0,0 @@
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
- }
@@ -1,49 +0,0 @@
1
- export function renderRecommendations(result) {
2
- const failed = [];
3
-
4
- for (const scorecard of [result.agentReadiness, result.aiVisibility]) {
5
- for (const [catName, cat] of Object.entries(scorecard.categories)) {
6
- for (const check of cat.checks) {
7
- if (!check.passed && check.fix) {
8
- const impact = check.maxPoints - (check.points || 0);
9
- failed.push({ ...check, category: catName, impact });
10
- }
11
- }
12
- }
13
- }
14
-
15
- failed.sort((a, b) => b.impact - a.impact);
16
- const top = failed.slice(0, 8);
17
-
18
- if (top.length === 0) {
19
- return `<h2 id="recommendations">Recommendations</h2>\n<p style="color:#3fb950;font-size:13px;">All checks passing. Nice.</p>`;
20
- }
21
-
22
- let html = `<h2 id="recommendations">Top Recommendations</h2>\n<p style="color:#8b949e;font-size:12px;margin-bottom:12px;">Sorted by point impact — fix these first.</p>`;
23
-
24
- for (const rec of top) {
25
- const category = formatName(rec.category);
26
- html += `\n<div class="rec">
27
- <div class="rec-title">${escapeHtml(rec.name)}</div>
28
- <div class="rec-fix">${escapeHtml(rec.fix)}</div>
29
- <div class="rec-impact">+${rec.impact} points · ${category}</div>
30
- </div>`;
31
- }
32
-
33
- return html;
34
- }
35
-
36
- function formatName(camelCase) {
37
- return camelCase
38
- .replace(/([A-Z])/g, " $1")
39
- .replace(/^./, (c) => c.toUpperCase())
40
- .trim();
41
- }
42
-
43
- function escapeHtml(str) {
44
- return str
45
- .replace(/&/g, "&amp;")
46
- .replace(/</g, "&lt;")
47
- .replace(/>/g, "&gt;")
48
- .replace(/"/g, "&quot;");
49
- }
@@ -1,73 +0,0 @@
1
- import { existsSync, readFileSync } from "fs";
2
- import { join } from "path";
3
-
4
- export function generateAgentsJson(check, scanResult, dir) {
5
- const { siteType, target } = scanResult;
6
-
7
- const hostname = extractHostname(target);
8
- const name = inferName(dir, hostname);
9
-
10
- const manifest = {
11
- schema_version: "1.0",
12
- name,
13
- description: "",
14
- url: target || "",
15
- site_type: siteType,
16
- interfaces: {
17
- human: "/",
18
- llm: "/llms.txt",
19
- structured: "/.well-known/agent.json",
20
- },
21
- capabilities: getCapabilities(siteType),
22
- contact: {},
23
- };
24
-
25
- if (siteType === "saas" || siteType === "api") {
26
- manifest.api_base = "";
27
- manifest.openapi = "/openapi.json";
28
- manifest.protocols = ["rest"];
29
- }
30
-
31
- return {
32
- file: ".well-known/agent.json",
33
- description: `Agent manifest for ${siteType} site — fill in description and contact`,
34
- draft: true,
35
- content: JSON.stringify(manifest, null, 2) + "\n",
36
- };
37
- }
38
-
39
- function getCapabilities(siteType) {
40
- switch (siteType) {
41
- case "saas":
42
- return ["pricing-lookup", "api-integration", "trial-signup"];
43
- case "api":
44
- return ["api-integration", "sdk-install", "docs-lookup"];
45
- case "content":
46
- return ["content-search", "topic-lookup", "citation"];
47
- case "personal":
48
- return ["contact", "expertise-lookup", "project-listing"];
49
- default:
50
- return [];
51
- }
52
- }
53
-
54
- function extractHostname(target) {
55
- if (!target) return null;
56
- try {
57
- return new URL(target).hostname.replace("www.", "");
58
- } catch {
59
- return null;
60
- }
61
- }
62
-
63
- function inferName(dir, hostname) {
64
- const pkgPath = join(dir, "package.json");
65
- if (existsSync(pkgPath)) {
66
- try {
67
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
68
- if (pkg.name) return pkg.name;
69
- } catch {}
70
- }
71
- if (hostname) return hostname.split(".")[0];
72
- return "";
73
- }
@@ -1,85 +0,0 @@
1
- import { existsSync, readFileSync, readdirSync } from "fs";
2
- import { join } from "path";
3
-
4
- export function generateAgentsMd(check, scanResult, dir) {
5
- const { siteType, target } = scanResult;
6
- const name = inferName(dir);
7
- const structure = inferStructure(dir);
8
-
9
- const content = `# ${name || "[Project Name]"}
10
-
11
- > [One sentence: what this project does]
12
-
13
- ## Project structure
14
-
15
- ${structure || "```\n[Add key directories and their purpose]\n```"}
16
-
17
- ## Key files
18
-
19
- - Entry point: [path]
20
- - Config: [path]
21
- - API routes: [path]
22
-
23
- ## Development
24
-
25
- \`\`\`bash
26
- [install command]
27
- [run command]
28
- \`\`\`
29
-
30
- ## Conventions
31
-
32
- - [Language/framework convention 1]
33
- - [Language/framework convention 2]
34
-
35
- ## Constraints
36
-
37
- - [Rate limits, auth requirements, etc]
38
- - [External dependencies]
39
- `;
40
-
41
- return {
42
- file: "AGENTS.md",
43
- description: "Draft AGENTS.md — REVIEW AND EDIT with real project details",
44
- draft: true,
45
- content,
46
- };
47
- }
48
-
49
- function inferName(dir) {
50
- const pkgPath = join(dir, "package.json");
51
- if (existsSync(pkgPath)) {
52
- try {
53
- return JSON.parse(readFileSync(pkgPath, "utf8")).name || "";
54
- } catch {}
55
- }
56
- const pomPath = join(dir, "pom.xml");
57
- if (existsSync(pomPath)) {
58
- const pom = readFileSync(pomPath, "utf8");
59
- const match = pom.match(/<artifactId>([^<]+)<\/artifactId>/);
60
- if (match) return match[1];
61
- }
62
- return "";
63
- }
64
-
65
- function inferStructure(dir) {
66
- try {
67
- const entries = readdirSync(dir, { withFileTypes: true });
68
- const dirs = entries
69
- .filter(
70
- (e) =>
71
- e.isDirectory() &&
72
- !e.name.startsWith(".") &&
73
- e.name !== "node_modules",
74
- )
75
- .map((e) => e.name)
76
- .slice(0, 10);
77
-
78
- if (dirs.length === 0) return null;
79
-
80
- const lines = dirs.map((d) => `${d}/`);
81
- return "```\n" + lines.join("\n") + "\n```";
82
- } catch {
83
- return null;
84
- }
85
- }
@@ -1,166 +0,0 @@
1
- import { existsSync, readFileSync } from "fs";
2
- import { join } from "path";
3
-
4
- export function generateLlmsTxt(check, scanResult, dir) {
5
- const { siteType, target } = scanResult;
6
- const hostname = extractHostname(target);
7
- const name = inferName(dir, hostname);
8
-
9
- const content = generateByType(siteType, name, target);
10
-
11
- return {
12
- file: "llms.txt",
13
- description: `Draft llms.txt for ${siteType} site — REVIEW AND EDIT before publishing`,
14
- draft: true,
15
- content,
16
- };
17
- }
18
-
19
- function generateByType(siteType, name, target) {
20
- const base = target || "https://example.com";
21
-
22
- switch (siteType) {
23
- case "saas":
24
- return `# ${name || "[Company Name]"}
25
-
26
- > [One sentence: what problem you solve and for whom]
27
-
28
- ## What it does
29
- - [Core capability 1]
30
- - [Core capability 2]
31
- - [Core capability 3]
32
-
33
- ## Pricing
34
- - [Plan 1]: $X/mo — [what's included]
35
- - [Plan 2]: $X/mo — [what's included]
36
- - Details: ${base}/pricing
37
-
38
- ## Integration
39
- - API docs: ${base}/docs
40
- - OpenAPI spec: ${base}/openapi.json
41
- - Auth: [API key / OAuth / etc]
42
-
43
- ## Get started
44
- 1. [Signup step]
45
- 2. [First API call or setup]
46
- 3. [See results]
47
-
48
- ## Links
49
- - Documentation: ${base}/docs
50
- - Changelog: ${base}/changelog
51
- - Status: ${base}/status
52
- `;
53
-
54
- case "api":
55
- return `# ${name || "[Tool Name]"}
56
-
57
- > [One sentence: what this tool does]
58
-
59
- ## Quick start
60
- \`\`\`bash
61
- [install command]
62
- \`\`\`
63
-
64
- ## Key concepts
65
- - [Concept 1]: [one-line explanation]
66
- - [Concept 2]: [one-line explanation]
67
-
68
- ## API reference
69
- - Base URL: ${base}/api/v1
70
- - Auth: [method]
71
- - OpenAPI: ${base}/openapi.json
72
-
73
- ## SDKs
74
- - Node.js: \`npm install [package]\`
75
- - Python: \`pip install [package]\`
76
-
77
- ## Links
78
- - Full docs: ${base}/docs
79
- - Examples: ${base}/examples
80
- - Changelog: ${base}/changelog
81
- `;
82
-
83
- case "personal":
84
- return `# ${name || "[Your Name]"}
85
-
86
- > [Role/title] — [what you're known for]
87
-
88
- ## Expertise
89
- - [Area 1]
90
- - [Area 2]
91
- - [Area 3]
92
-
93
- ## Notable work
94
- - [Project 1]: [one-line description] — [url]
95
- - [Project 2]: [one-line description] — [url]
96
-
97
- ## Writing & speaking
98
- - [Topic you cover]
99
- - [Where to find your content]
100
-
101
- ## Contact
102
- - Email: [email]
103
- - LinkedIn: [url]
104
- - Available for: [consulting / speaking / hiring / etc]
105
- `;
106
-
107
- case "content":
108
- return `# ${name || "[Publication Name]"}
109
-
110
- > [What topics you cover and your angle/expertise]
111
-
112
- ## Topics
113
- - [Topic 1]: [what you cover, how many pieces]
114
- - [Topic 2]: [what you cover, how many pieces]
115
-
116
- ## Best starting points
117
- - [Most important article]: ${base}/[path]
118
- - [Second article]: ${base}/[path]
119
- - [Third article]: ${base}/[path]
120
-
121
- ## About
122
- - Author(s): [who writes]
123
- - Cadence: [how often you publish]
124
- - Angle: [what makes your coverage unique]
125
-
126
- ## Links
127
- - All articles: ${base}/blog
128
- - RSS: ${base}/feed.xml
129
- - Newsletter: ${base}/subscribe
130
- `;
131
-
132
- default:
133
- return `# ${name || "[Site Name]"}
134
-
135
- > [One sentence: what this site is and who it's for]
136
-
137
- ## What you'll find here
138
- - [Section 1]: [description]
139
- - [Section 2]: [description]
140
-
141
- ## Links
142
- - Homepage: ${base}
143
- `;
144
- }
145
- }
146
-
147
- function extractHostname(target) {
148
- if (!target) return null;
149
- try {
150
- return new URL(target).hostname.replace("www.", "");
151
- } catch {
152
- return null;
153
- }
154
- }
155
-
156
- function inferName(dir, hostname) {
157
- const pkgPath = join(dir, "package.json");
158
- if (existsSync(pkgPath)) {
159
- try {
160
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
161
- if (pkg.name) return pkg.name;
162
- } catch {}
163
- }
164
- if (hostname) return hostname.split(".")[0];
165
- return "";
166
- }