geo-ai-search-optimization 2.0.0 → 2.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/src/topics.js ADDED
@@ -0,0 +1,275 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { writeScanOutput } from "./scan.js";
4
+
5
+ const STOP_WORDS = new Set([
6
+ "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with",
7
+ "by", "from", "as", "is", "are", "was", "were", "be", "been", "being", "have", "has",
8
+ "had", "do", "does", "did", "will", "would", "could", "should", "may", "might", "can",
9
+ "shall", "it", "its", "this", "that", "these", "those", "i", "we", "you", "he", "she",
10
+ "they", "them", "their", "my", "your", "our", "his", "her", "not", "no", "if", "so",
11
+ "up", "out", "just", "also", "more", "most", "very", "than", "then", "only", "all",
12
+ "about", "into", "over", "after", "before", "between", "through", "during", "each",
13
+ "any", "some", "such", "what", "which", "who", "how", "when", "where", "there",
14
+ "here", "both", "other", "new", "one", "two", "first", "last", "get", "use", "make",
15
+ "like", "well", "back", "even", "still", "way", "take", "come", "see", "know", "need"
16
+ ]);
17
+
18
+ function stripHtml(text) {
19
+ return text
20
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
21
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
22
+ .replace(/<[^>]+>/g, " ")
23
+ .replace(/&[a-z0-9#]+;/gi, " ")
24
+ .replace(/[^\w\s-]/g, " ")
25
+ .replace(/\s+/g, " ")
26
+ .trim();
27
+ }
28
+
29
+ function extractPlainText(content) {
30
+ if (/<html|<head|<body/i.test(content)) return stripHtml(content);
31
+ return content.replace(/^---[\s\S]*?---/, "").replace(/[^\w\s-]/g, " ").replace(/\s+/g, " ").trim();
32
+ }
33
+
34
+ function tokenize(text) {
35
+ return text.toLowerCase().split(/\s+/).filter((w) => w.length > 2 && !STOP_WORDS.has(w) && !/^\d+$/.test(w));
36
+ }
37
+
38
+ function computeTermFrequency(tokens) {
39
+ const freq = {};
40
+ for (const token of tokens) {
41
+ freq[token] = (freq[token] || 0) + 1;
42
+ }
43
+ return freq;
44
+ }
45
+
46
+ function extractNgrams(tokens, n) {
47
+ const ngrams = {};
48
+ for (let i = 0; i <= tokens.length - n; i++) {
49
+ const gram = tokens.slice(i, i + n).join(" ");
50
+ // Skip if any token is a stop word
51
+ if (n > 1 && tokens.slice(i, i + n).some((t) => STOP_WORDS.has(t))) continue;
52
+ ngrams[gram] = (ngrams[gram] || 0) + 1;
53
+ }
54
+ return ngrams;
55
+ }
56
+
57
+ function computeTfIdf(termFreq, totalTokens) {
58
+ if (totalTokens === 0) return [];
59
+ // Simplified single-document TF-IDF using log normalization
60
+ const results = [];
61
+ for (const [term, count] of Object.entries(termFreq)) {
62
+ const tf = count / totalTokens;
63
+ // Approximate IDF using inverse document frequency heuristic
64
+ // More frequent = less interesting; rare terms get boosted
65
+ const idf = Math.log(1 + totalTokens / (count * 10));
66
+ const tfidf = tf * idf;
67
+ results.push({ term, count, tf: Math.round(tf * 10000) / 10000, tfidf: Math.round(tfidf * 10000) / 10000 });
68
+ }
69
+ return results.sort((a, b) => b.tfidf - a.tfidf);
70
+ }
71
+
72
+ function extractHeadingTopics(content) {
73
+ const headings = [];
74
+ const mdMatches = content.matchAll(/^#{1,6}\s+(.+)$/gm);
75
+ for (const match of mdMatches) headings.push(match[1].trim());
76
+ const htmlMatches = content.matchAll(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/gim);
77
+ for (const match of htmlMatches) headings.push(match[1].replace(/<[^>]+>/g, "").trim());
78
+ return headings;
79
+ }
80
+
81
+ function identifyTopicClusters(keywords, bigrams) {
82
+ const clusters = {};
83
+
84
+ // Group by shared root or co-occurrence
85
+ for (const kw of keywords.slice(0, 30)) {
86
+ const root = kw.term.length > 5 ? kw.term.slice(0, Math.ceil(kw.term.length * 0.7)) : kw.term;
87
+ const related = keywords.filter((k) =>
88
+ k.term !== kw.term && (k.term.startsWith(root) || kw.term.startsWith(k.term.slice(0, Math.ceil(k.term.length * 0.7))))
89
+ );
90
+
91
+ if (related.length > 0) {
92
+ const clusterKey = kw.term;
93
+ if (!clusters[clusterKey]) {
94
+ clusters[clusterKey] = {
95
+ primary: kw.term,
96
+ related: related.map((r) => r.term).slice(0, 5),
97
+ totalFreq: kw.count + related.reduce((sum, r) => sum + r.count, 0)
98
+ };
99
+ }
100
+ }
101
+ }
102
+
103
+ // Add bigram-based topics
104
+ for (const bg of bigrams.slice(0, 15)) {
105
+ clusters[bg.term] = clusters[bg.term] || {
106
+ primary: bg.term,
107
+ related: [],
108
+ totalFreq: bg.count
109
+ };
110
+ }
111
+
112
+ return Object.values(clusters)
113
+ .sort((a, b) => b.totalFreq - a.totalFreq)
114
+ .slice(0, 10);
115
+ }
116
+
117
+ function buildRecommendations(keywords, bigrams, headingTopics, clusters) {
118
+ const recs = [];
119
+
120
+ if (keywords.length < 5) {
121
+ recs.push("Content lacks distinct topic signals. Add more domain-specific terminology.");
122
+ }
123
+
124
+ // Check if top keywords appear in headings
125
+ const headingText = headingTopics.join(" ").toLowerCase();
126
+ const topTerms = keywords.slice(0, 5);
127
+ const missingInHeadings = topTerms.filter((kw) => !headingText.includes(kw.term));
128
+ if (missingInHeadings.length > 0) {
129
+ recs.push(`Top keywords not in headings: ${missingInHeadings.map((k) => `"${k.term}"`).join(", ")}. Consider using them in H2/H3.`);
130
+ }
131
+
132
+ if (clusters.length < 3) {
133
+ recs.push("Content covers few topic clusters. Broaden to cover related subtopics for topical authority.");
134
+ }
135
+
136
+ if (bigrams.length > 0 && bigrams[0].count > 5) {
137
+ recs.push(`Dominant phrase: "${bigrams[0].term}" (${bigrams[0].count}x). Ensure this aligns with your target topic.`);
138
+ }
139
+
140
+ return recs;
141
+ }
142
+
143
+ async function fetchContent(url) {
144
+ const response = await fetch(url, {
145
+ redirect: "follow",
146
+ headers: { "user-agent": "geo-ai-search-optimization/2.2.0" },
147
+ signal: AbortSignal.timeout(10_000)
148
+ });
149
+ if (!response.ok) throw new Error(`Failed to fetch: ${url} (status ${response.status})`);
150
+ return response.text();
151
+ }
152
+
153
+ export async function analyzeTopics(input, options = {}) {
154
+ let rawContent;
155
+ let source;
156
+
157
+ if (/^https?:\/\//i.test(input)) {
158
+ rawContent = await fetchContent(input);
159
+ source = input;
160
+ } else {
161
+ const filePath = path.resolve(input);
162
+ rawContent = await fs.readFile(filePath, "utf8");
163
+ source = filePath;
164
+ }
165
+
166
+ const plainText = extractPlainText(rawContent);
167
+ const tokens = tokenize(plainText);
168
+ const termFreq = computeTermFrequency(tokens);
169
+ const tfidf = computeTfIdf(termFreq, tokens.length);
170
+ const bigrams = Object.entries(extractNgrams(tokens, 2))
171
+ .map(([term, count]) => ({ term, count }))
172
+ .filter((bg) => bg.count >= 2)
173
+ .sort((a, b) => b.count - a.count);
174
+ const trigrams = Object.entries(extractNgrams(tokens, 3))
175
+ .map(([term, count]) => ({ term, count }))
176
+ .filter((tg) => tg.count >= 2)
177
+ .sort((a, b) => b.count - a.count);
178
+
179
+ const headingTopics = extractHeadingTopics(rawContent);
180
+ const clusters = identifyTopicClusters(tfidf, bigrams);
181
+
182
+ const keywords = tfidf.slice(0, 20);
183
+ const recommendations = buildRecommendations(keywords, bigrams, headingTopics, clusters);
184
+
185
+ // Topic coverage score
186
+ let score = 0;
187
+ score += Math.min(keywords.length * 3, 30);
188
+ score += Math.min(bigrams.length * 3, 20);
189
+ score += Math.min(clusters.length * 8, 25);
190
+ score += Math.min(headingTopics.length * 3, 15);
191
+ score += tokens.length >= 200 ? 10 : (tokens.length >= 100 ? 5 : 0);
192
+ score = Math.max(0, Math.min(100, score));
193
+
194
+ return {
195
+ kind: "geo-topics",
196
+ source,
197
+ wordCount: tokens.length,
198
+ uniqueTerms: Object.keys(termFreq).length,
199
+ keywords: keywords.map((k) => ({ term: k.term, count: k.count, tfidf: k.tfidf })),
200
+ bigrams: bigrams.slice(0, 15),
201
+ trigrams: trigrams.slice(0, 10),
202
+ headingTopics,
203
+ topicClusters: clusters,
204
+ score,
205
+ scoreLabel: score >= 70 ? "Rich" : score >= 40 ? "Moderate" : "Thin",
206
+ recommendations,
207
+ summary: `${keywords.length} keywords extracted. Top: ${keywords.slice(0, 5).map((k) => `"${k.term}"`).join(", ")}. ${clusters.length} topic cluster(s) identified. Score: ${score}/100.`
208
+ };
209
+ }
210
+
211
+ export function renderTopicsMarkdown(report) {
212
+ const lines = [
213
+ "# Topic & Keyword Analysis",
214
+ "",
215
+ `- Source: \`${report.source}\``,
216
+ `- Total tokens: \`${report.wordCount}\``,
217
+ `- Unique terms: \`${report.uniqueTerms}\``,
218
+ `- Summary: ${report.summary}`,
219
+ "",
220
+ "## Top Keywords (TF-IDF)",
221
+ "",
222
+ "| Rank | Keyword | Count | TF-IDF |",
223
+ "|------|---------|-------|--------|"
224
+ ];
225
+
226
+ for (let i = 0; i < report.keywords.length; i++) {
227
+ const kw = report.keywords[i];
228
+ lines.push(`| ${i + 1} | ${kw.term} | ${kw.count} | ${kw.tfidf} |`);
229
+ }
230
+
231
+ if (report.bigrams.length > 0) {
232
+ lines.push("", "## Top Bigrams", "");
233
+ for (const bg of report.bigrams.slice(0, 10)) {
234
+ lines.push(`- "${bg.term}" (${bg.count}x)`);
235
+ }
236
+ }
237
+
238
+ if (report.trigrams.length > 0) {
239
+ lines.push("", "## Top Trigrams", "");
240
+ for (const tg of report.trigrams.slice(0, 5)) {
241
+ lines.push(`- "${tg.term}" (${tg.count}x)`);
242
+ }
243
+ }
244
+
245
+ if (report.topicClusters.length > 0) {
246
+ lines.push("", "## Topic Clusters", "");
247
+ for (const cluster of report.topicClusters) {
248
+ const related = cluster.related.length > 0 ? ` → ${cluster.related.join(", ")}` : "";
249
+ lines.push(`- **${cluster.primary}** (freq: ${cluster.totalFreq})${related}`);
250
+ }
251
+ }
252
+
253
+ if (report.headingTopics.length > 0) {
254
+ lines.push("", "## Heading Topics", "");
255
+ for (const h of report.headingTopics) {
256
+ lines.push(`- ${h}`);
257
+ }
258
+ }
259
+
260
+ lines.push("", "## Recommendations", "");
261
+ if (report.recommendations.length === 0) {
262
+ lines.push("- Topic coverage looks good.");
263
+ } else {
264
+ for (const rec of report.recommendations) {
265
+ lines.push(`- ${rec}`);
266
+ }
267
+ }
268
+ lines.push("");
269
+
270
+ return lines.join("\n");
271
+ }
272
+
273
+ export async function writeTopicsOutput(outputPath, content) {
274
+ return writeScanOutput(outputPath, content);
275
+ }
@@ -26,7 +26,7 @@ async function fetchText(url, options = {}) {
26
26
  const response = await fetch(url, {
27
27
  redirect: "follow",
28
28
  headers: {
29
- "user-agent": "geo-ai-search-optimization/1.0.6"
29
+ "user-agent": "geo-ai-search-optimization/2.2.0"
30
30
  }
31
31
  });
32
32
 
@@ -0,0 +1,307 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { writeScanOutput } from "./scan.js";
4
+
5
+ function parseLlmsTxt(content) {
6
+ const lines = content.split("\n");
7
+ const sections = [];
8
+ let currentSection = null;
9
+ let title = null;
10
+ let description = null;
11
+ let urls = [];
12
+
13
+ for (const line of lines) {
14
+ const trimmed = line.trim();
15
+
16
+ if (trimmed.startsWith("# ") && !title) {
17
+ title = trimmed.slice(2).trim();
18
+ continue;
19
+ }
20
+
21
+ if (trimmed.startsWith("> ") && !description) {
22
+ description = trimmed.slice(2).trim();
23
+ continue;
24
+ }
25
+
26
+ if (trimmed.startsWith("## ")) {
27
+ if (currentSection) sections.push(currentSection);
28
+ currentSection = { heading: trimmed.slice(3).trim(), lines: [], urls: [] };
29
+ continue;
30
+ }
31
+
32
+ if (currentSection && trimmed) {
33
+ currentSection.lines.push(trimmed);
34
+ const urlMatch = trimmed.match(/https?:\/\/[^\s)>]+/g);
35
+ if (urlMatch) {
36
+ urls.push(...urlMatch);
37
+ currentSection.urls.push(...urlMatch);
38
+ }
39
+ }
40
+ }
41
+
42
+ if (currentSection) sections.push(currentSection);
43
+
44
+ return { title, description, sections, urls };
45
+ }
46
+
47
+ const REQUIRED_SECTIONS = ["About", "Priority URLs"];
48
+ const RECOMMENDED_SECTIONS = ["Recommended Sources", "AI Assistant Guidance", "Update Policy"];
49
+
50
+ function validateStructure(parsed) {
51
+ const issues = [];
52
+ const warnings = [];
53
+ const sectionNames = parsed.sections.map((s) => s.heading.toLowerCase());
54
+
55
+ if (!parsed.title) {
56
+ issues.push({ severity: "error", message: "Missing title (first # heading)" });
57
+ }
58
+
59
+ if (!parsed.description) {
60
+ warnings.push({ severity: "warning", message: "Missing description (> blockquote after title)" });
61
+ }
62
+
63
+ for (const required of REQUIRED_SECTIONS) {
64
+ if (!sectionNames.includes(required.toLowerCase())) {
65
+ issues.push({ severity: "error", message: `Missing required section: ## ${required}` });
66
+ }
67
+ }
68
+
69
+ for (const recommended of RECOMMENDED_SECTIONS) {
70
+ if (!sectionNames.includes(recommended.toLowerCase())) {
71
+ warnings.push({ severity: "warning", message: `Missing recommended section: ## ${recommended}` });
72
+ }
73
+ }
74
+
75
+ const aboutSection = parsed.sections.find((s) => s.heading.toLowerCase() === "about");
76
+ if (aboutSection && aboutSection.lines.length === 0) {
77
+ issues.push({ severity: "error", message: "About section is empty" });
78
+ }
79
+
80
+ const prioritySection = parsed.sections.find((s) => s.heading.toLowerCase() === "priority urls");
81
+ if (prioritySection && prioritySection.urls.length === 0) {
82
+ issues.push({ severity: "error", message: "Priority URLs section contains no URLs" });
83
+ }
84
+
85
+ return { issues, warnings };
86
+ }
87
+
88
+ function validateUrls(urls) {
89
+ const results = [];
90
+
91
+ for (const url of urls) {
92
+ try {
93
+ const parsed = new URL(url);
94
+ const isHttps = parsed.protocol === "https:";
95
+ results.push({
96
+ url,
97
+ valid: true,
98
+ https: isHttps,
99
+ warning: isHttps ? null : "URL uses HTTP instead of HTTPS"
100
+ });
101
+ } catch {
102
+ results.push({ url, valid: false, https: false, warning: "Invalid URL format" });
103
+ }
104
+ }
105
+
106
+ return results;
107
+ }
108
+
109
+ function validateContent(parsed) {
110
+ const issues = [];
111
+ const lines = parsed.sections.flatMap((s) => s.lines);
112
+ const fullText = lines.join(" ");
113
+
114
+ const placeholders = fullText.match(/\b(example\.com|your[- ]site|your[- ]brand|your[- ]organization|replace this|todo)\b/gi);
115
+ if (placeholders && placeholders.length > 0) {
116
+ issues.push({
117
+ severity: "warning",
118
+ message: `Found ${placeholders.length} placeholder(s): ${[...new Set(placeholders)].join(", ")}`
119
+ });
120
+ }
121
+
122
+ if (fullText.length < 100) {
123
+ issues.push({ severity: "warning", message: "Content is very short (< 100 characters). Consider adding more detail." });
124
+ }
125
+
126
+ const hasCanonical = /canonical:\s*https?:\/\//i.test(fullText) ||
127
+ parsed.sections.some((s) => s.lines.some((l) => /^canonical:/i.test(l)));
128
+ if (!hasCanonical && !lines.some((l) => /canonical/i.test(l))) {
129
+ issues.push({ severity: "warning", message: "No canonical URL declared. Consider adding one." });
130
+ }
131
+
132
+ return issues;
133
+ }
134
+
135
+ function computeScore(structureResult, urlResults, contentIssues) {
136
+ let score = 100;
137
+
138
+ const errors = structureResult.issues.filter((i) => i.severity === "error");
139
+ const warnings = [...structureResult.warnings, ...contentIssues.filter((i) => i.severity === "warning")];
140
+ const invalidUrls = urlResults.filter((u) => !u.valid);
141
+ const httpUrls = urlResults.filter((u) => u.valid && !u.https);
142
+
143
+ score -= errors.length * 15;
144
+ score -= warnings.length * 5;
145
+ score -= invalidUrls.length * 10;
146
+ score -= httpUrls.length * 3;
147
+
148
+ return Math.max(0, Math.min(100, score));
149
+ }
150
+
151
+ function getScoreLabel(score) {
152
+ if (score >= 90) return "Excellent";
153
+ if (score >= 70) return "Good";
154
+ if (score >= 50) return "Needs improvement";
155
+ return "Poor";
156
+ }
157
+
158
+ async function fetchLlmsTxt(url) {
159
+ const parsedUrl = new URL(url);
160
+ const llmsUrl = `${parsedUrl.protocol}//${parsedUrl.host}/llms.txt`;
161
+ const response = await fetch(llmsUrl, {
162
+ redirect: "follow",
163
+ headers: { "user-agent": "geo-ai-search-optimization/2.2.0" },
164
+ signal: AbortSignal.timeout(10_000)
165
+ });
166
+ if (!response.ok) return { found: false, url: llmsUrl, status: response.status, content: "" };
167
+ const content = await response.text();
168
+ return { found: true, url: llmsUrl, status: response.status, content };
169
+ }
170
+
171
+ export async function validateLlmsTxt(input, options = {}) {
172
+ let content;
173
+ let source;
174
+
175
+ if (/^https?:\/\//i.test(input)) {
176
+ const result = await fetchLlmsTxt(input);
177
+ if (!result.found) {
178
+ return {
179
+ kind: "geo-validate-llms",
180
+ source: result.url,
181
+ found: false,
182
+ score: 0,
183
+ scoreLabel: "Not found",
184
+ summary: `No llms.txt found at ${result.url}. Create one with: geo-ai-search-optimization init-llms`,
185
+ parsed: null,
186
+ issues: [{ severity: "error", message: "llms.txt not found" }],
187
+ warnings: [],
188
+ urlValidation: [],
189
+ recommendations: ["Create an llms.txt file. Run: geo-ai-search-optimization init-llms --site-name 'Your Site' --site-url 'https://yoursite.com'"]
190
+ };
191
+ }
192
+ content = result.content;
193
+ source = result.url;
194
+ } else {
195
+ const filePath = path.resolve(input);
196
+ content = await fs.readFile(filePath, "utf8");
197
+ source = filePath;
198
+ }
199
+
200
+ const parsed = parseLlmsTxt(content);
201
+ const structureResult = validateStructure(parsed);
202
+ const urlResults = validateUrls(parsed.urls);
203
+ const contentIssues = validateContent(parsed);
204
+ const score = computeScore(structureResult, urlResults, contentIssues);
205
+ const allIssues = [...structureResult.issues, ...contentIssues.filter((i) => i.severity === "error")];
206
+ const allWarnings = [...structureResult.warnings, ...contentIssues.filter((i) => i.severity === "warning")];
207
+
208
+ const recommendations = [];
209
+ if (allIssues.length > 0) {
210
+ recommendations.push(`Fix ${allIssues.length} error(s): ${allIssues.map((i) => i.message).join("; ")}`);
211
+ }
212
+ if (allWarnings.length > 0) {
213
+ recommendations.push(`Address ${allWarnings.length} warning(s) to improve completeness.`);
214
+ }
215
+ const invalidUrls = urlResults.filter((u) => !u.valid);
216
+ if (invalidUrls.length > 0) {
217
+ recommendations.push(`Fix ${invalidUrls.length} invalid URL(s).`);
218
+ }
219
+
220
+ return {
221
+ kind: "geo-validate-llms",
222
+ source,
223
+ found: true,
224
+ score,
225
+ scoreLabel: getScoreLabel(score),
226
+ summary: `llms.txt validation: ${score}/100 (${getScoreLabel(score)}). ${allIssues.length} error(s), ${allWarnings.length} warning(s).`,
227
+ parsed: {
228
+ title: parsed.title,
229
+ description: parsed.description,
230
+ sectionCount: parsed.sections.length,
231
+ sections: parsed.sections.map((s) => s.heading),
232
+ urlCount: parsed.urls.length
233
+ },
234
+ issues: allIssues,
235
+ warnings: allWarnings,
236
+ urlValidation: urlResults,
237
+ recommendations
238
+ };
239
+ }
240
+
241
+ export function renderValidateLlmsMarkdown(report) {
242
+ const lines = [
243
+ "# llms.txt Validation Report",
244
+ "",
245
+ `- Source: \`${report.source}\``,
246
+ `- Found: \`${report.found}\``,
247
+ `- Score: \`${report.score}/100\` (${report.scoreLabel})`,
248
+ `- Summary: ${report.summary}`,
249
+ ""
250
+ ];
251
+
252
+ if (!report.found) {
253
+ lines.push("## Action Required", "", "- Create an llms.txt file for your site.", "");
254
+ return lines.join("\n");
255
+ }
256
+
257
+ if (report.parsed) {
258
+ lines.push(
259
+ "## Structure",
260
+ "",
261
+ `- Title: \`${report.parsed.title || "missing"}\``,
262
+ `- Description: \`${report.parsed.description || "missing"}\``,
263
+ `- Sections: \`${report.parsed.sectionCount}\` (${report.parsed.sections.join(", ")})`,
264
+ `- URLs found: \`${report.parsed.urlCount}\``,
265
+ ""
266
+ );
267
+ }
268
+
269
+ if (report.issues.length > 0) {
270
+ lines.push("## Errors", "");
271
+ for (const issue of report.issues) {
272
+ lines.push(`- ❌ ${issue.message}`);
273
+ }
274
+ lines.push("");
275
+ }
276
+
277
+ if (report.warnings.length > 0) {
278
+ lines.push("## Warnings", "");
279
+ for (const warning of report.warnings) {
280
+ lines.push(`- ⚠️ ${warning.message}`);
281
+ }
282
+ lines.push("");
283
+ }
284
+
285
+ if (report.urlValidation.length > 0) {
286
+ lines.push("## URL Validation", "");
287
+ for (const url of report.urlValidation) {
288
+ const icon = url.valid ? (url.https ? "✅" : "⚠️") : "❌";
289
+ lines.push(`- ${icon} \`${url.url}\`${url.warning ? ` — ${url.warning}` : ""}`);
290
+ }
291
+ lines.push("");
292
+ }
293
+
294
+ if (report.recommendations.length > 0) {
295
+ lines.push("## Recommendations", "");
296
+ for (const rec of report.recommendations) {
297
+ lines.push(`- ${rec}`);
298
+ }
299
+ lines.push("");
300
+ }
301
+
302
+ return lines.join("\n");
303
+ }
304
+
305
+ export async function writeValidateLlmsOutput(outputPath, content) {
306
+ return writeScanOutput(outputPath, content);
307
+ }