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/sitemap.js ADDED
@@ -0,0 +1,323 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { writeScanOutput } from "./scan.js";
4
+
5
+ function parseXmlTag(xml, tag) {
6
+ const results = [];
7
+ const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "gi");
8
+ let match;
9
+ while ((match = regex.exec(xml)) !== null) {
10
+ results.push(match[1].trim());
11
+ }
12
+ return results;
13
+ }
14
+
15
+ function extractTextContent(xml, tag) {
16
+ const match = xml.match(new RegExp(`<${tag}[^>]*>([^<]*)</${tag}>`, "i"));
17
+ return match ? match[1].trim() : null;
18
+ }
19
+
20
+ function parseUrlEntries(sitemapXml) {
21
+ const urlBlocks = parseXmlTag(sitemapXml, "url");
22
+ return urlBlocks.map((block) => ({
23
+ loc: extractTextContent(block, "loc"),
24
+ lastmod: extractTextContent(block, "lastmod"),
25
+ changefreq: extractTextContent(block, "changefreq"),
26
+ priority: extractTextContent(block, "priority")
27
+ })).filter((entry) => entry.loc);
28
+ }
29
+
30
+ function parseSitemapIndex(xml) {
31
+ const sitemapBlocks = parseXmlTag(xml, "sitemap");
32
+ return sitemapBlocks.map((block) => ({
33
+ loc: extractTextContent(block, "loc"),
34
+ lastmod: extractTextContent(block, "lastmod")
35
+ })).filter((entry) => entry.loc);
36
+ }
37
+
38
+ function isSitemapIndex(xml) {
39
+ return /<sitemapindex/i.test(xml);
40
+ }
41
+
42
+ function analyzeUrls(entries) {
43
+ const total = entries.length;
44
+ const withLastmod = entries.filter((e) => e.lastmod).length;
45
+ const withChangefreq = entries.filter((e) => e.changefreq).length;
46
+ const withPriority = entries.filter((e) => e.priority).length;
47
+
48
+ // Date analysis
49
+ const dates = entries
50
+ .filter((e) => e.lastmod)
51
+ .map((e) => {
52
+ try { return new Date(e.lastmod); } catch { return null; }
53
+ })
54
+ .filter((d) => d && !Number.isNaN(d.getTime()));
55
+
56
+ const now = new Date();
57
+ const ageInDays = dates.map((d) => Math.floor((now - d) / (1000 * 60 * 60 * 24)));
58
+
59
+ const freshCount = ageInDays.filter((d) => d <= 30).length;
60
+ const recentCount = ageInDays.filter((d) => d > 30 && d <= 180).length;
61
+ const staleCount = ageInDays.filter((d) => d > 180 && d <= 365).length;
62
+ const oldCount = ageInDays.filter((d) => d > 365).length;
63
+
64
+ // Priority distribution
65
+ const priorities = entries.filter((e) => e.priority).map((e) => Number.parseFloat(e.priority));
66
+ const avgPriority = priorities.length > 0
67
+ ? Math.round((priorities.reduce((a, b) => a + b, 0) / priorities.length) * 100) / 100
68
+ : null;
69
+
70
+ // Changefreq distribution
71
+ const changefreqDist = {};
72
+ for (const entry of entries) {
73
+ if (entry.changefreq) {
74
+ changefreqDist[entry.changefreq] = (changefreqDist[entry.changefreq] || 0) + 1;
75
+ }
76
+ }
77
+
78
+ // URL pattern analysis
79
+ const pathDepths = entries.map((e) => {
80
+ try { return new URL(e.loc).pathname.split("/").filter(Boolean).length; } catch { return 0; }
81
+ });
82
+ const avgDepth = pathDepths.length > 0
83
+ ? Math.round((pathDepths.reduce((a, b) => a + b, 0) / pathDepths.length) * 10) / 10
84
+ : 0;
85
+
86
+ return {
87
+ total,
88
+ withLastmod,
89
+ withChangefreq,
90
+ withPriority,
91
+ lastmodCoverage: total > 0 ? Math.round((withLastmod / total) * 100) : 0,
92
+ freshness: { fresh: freshCount, recent: recentCount, stale: staleCount, old: oldCount },
93
+ avgPriority,
94
+ changefreqDistribution: changefreqDist,
95
+ avgDepth,
96
+ maxDepth: pathDepths.length > 0 ? Math.max(...pathDepths) : 0
97
+ };
98
+ }
99
+
100
+ function validateSitemap(xml, entries, options = {}) {
101
+ const issues = [];
102
+ const warnings = [];
103
+
104
+ if (!xml.includes('<?xml')) {
105
+ issues.push({ severity: "error", message: "Missing XML declaration" });
106
+ }
107
+
108
+ if (!/<urlset|<sitemapindex/i.test(xml)) {
109
+ issues.push({ severity: "error", message: "Missing <urlset> or <sitemapindex> root element" });
110
+ }
111
+
112
+ if (entries.length === 0 && !options.isIndex) {
113
+ issues.push({ severity: "error", message: "Sitemap contains no URL entries" });
114
+ }
115
+
116
+ if (entries.length > 50000) {
117
+ issues.push({ severity: "error", message: `Sitemap exceeds 50,000 URL limit (${entries.length} found). Split into multiple sitemaps.` });
118
+ }
119
+
120
+ const urlStats = analyzeUrls(entries);
121
+ if (urlStats.lastmodCoverage < 50 && entries.length > 0) {
122
+ warnings.push({ severity: "warning", message: `Only ${urlStats.lastmodCoverage}% of URLs have lastmod. Add dates for better crawl efficiency.` });
123
+ }
124
+
125
+ if (urlStats.freshness.old > entries.length * 0.3) {
126
+ warnings.push({ severity: "warning", message: `${urlStats.freshness.old} URLs have lastmod older than 1 year. Review and update stale content.` });
127
+ }
128
+
129
+ const invalidUrls = entries.filter((e) => {
130
+ try { new URL(e.loc); return false; } catch { return true; }
131
+ });
132
+ if (invalidUrls.length > 0) {
133
+ issues.push({ severity: "error", message: `${invalidUrls.length} URL(s) are malformed` });
134
+ }
135
+
136
+ const duplicates = entries.length - new Set(entries.map((e) => e.loc)).size;
137
+ if (duplicates > 0) {
138
+ warnings.push({ severity: "warning", message: `${duplicates} duplicate URL(s) found` });
139
+ }
140
+
141
+ return { issues, warnings };
142
+ }
143
+
144
+ function computeScore(entries, validation) {
145
+ if (entries.length === 0) return 0;
146
+
147
+ let score = 40; // Base: sitemap exists and has entries
148
+ const urlStats = analyzeUrls(entries);
149
+
150
+ score += Math.min(Math.round(urlStats.lastmodCoverage * 0.2), 20);
151
+ if (urlStats.freshness.fresh > 0) score += 10;
152
+ if (urlStats.withPriority > entries.length * 0.5) score += 10;
153
+ if (urlStats.withChangefreq > entries.length * 0.5) score += 5;
154
+
155
+ score -= validation.issues.length * 10;
156
+ score -= validation.warnings.length * 3;
157
+
158
+ if (entries.length >= 10) score += 5;
159
+ if (entries.length >= 50) score += 5;
160
+ if (entries.length >= 100) score += 5;
161
+
162
+ return Math.max(0, Math.min(100, score));
163
+ }
164
+
165
+ async function fetchSitemap(url) {
166
+ const parsedUrl = new URL(url);
167
+ const sitemapUrl = `${parsedUrl.protocol}//${parsedUrl.host}/sitemap.xml`;
168
+ const response = await fetch(sitemapUrl, {
169
+ redirect: "follow",
170
+ headers: { "user-agent": "geo-ai-search-optimization/2.2.0" },
171
+ signal: AbortSignal.timeout(15_000)
172
+ });
173
+ if (!response.ok) return { found: false, url: sitemapUrl, content: "" };
174
+ return { found: true, url: sitemapUrl, content: await response.text() };
175
+ }
176
+
177
+ export async function analyzeSitemap(input, options = {}) {
178
+ let xml;
179
+ let source;
180
+
181
+ if (/^https?:\/\//i.test(input)) {
182
+ const result = await fetchSitemap(input);
183
+ if (!result.found) {
184
+ return {
185
+ kind: "geo-sitemap",
186
+ source: result.url,
187
+ found: false,
188
+ score: 0,
189
+ scoreLabel: "Not found",
190
+ summary: `No sitemap found at ${result.url}`,
191
+ entries: [],
192
+ urlStats: null,
193
+ validation: { issues: [{ severity: "error", message: "Sitemap not found" }], warnings: [] },
194
+ recommendations: ["Create an XML sitemap and submit it to search engines."]
195
+ };
196
+ }
197
+ xml = result.content;
198
+ source = result.url;
199
+ } else {
200
+ const filePath = path.resolve(input);
201
+ xml = await fs.readFile(filePath, "utf8");
202
+ source = filePath;
203
+ }
204
+
205
+ const isIndex = isSitemapIndex(xml);
206
+ let entries;
207
+ let childSitemaps = [];
208
+
209
+ if (isIndex) {
210
+ childSitemaps = parseSitemapIndex(xml);
211
+ entries = []; // Index doesn't have direct URL entries
212
+ } else {
213
+ entries = parseUrlEntries(xml);
214
+ }
215
+
216
+ const urlStats = entries.length > 0 ? analyzeUrls(entries) : null;
217
+ const validation = validateSitemap(xml, entries, { isIndex });
218
+ const score = isIndex ? 60 : computeScore(entries, validation); // Index gets base 60
219
+
220
+ const recommendations = [];
221
+ if (isIndex && childSitemaps.length === 0) {
222
+ recommendations.push("Sitemap index is empty. Add child sitemap references.");
223
+ }
224
+ if (!isIndex && entries.length === 0) {
225
+ recommendations.push("Sitemap has no URL entries. Add your important pages.");
226
+ }
227
+ if (urlStats && urlStats.lastmodCoverage < 80) {
228
+ recommendations.push("Add lastmod dates to all URLs for better crawl scheduling.");
229
+ }
230
+ if (urlStats && urlStats.freshness.stale + urlStats.freshness.old > entries.length * 0.3) {
231
+ recommendations.push("Review and update stale content (30%+ of URLs have old lastmod).");
232
+ }
233
+ for (const issue of validation.issues) {
234
+ recommendations.push(`Fix: ${issue.message}`);
235
+ }
236
+
237
+ return {
238
+ kind: "geo-sitemap",
239
+ source,
240
+ found: true,
241
+ isIndex,
242
+ childSitemaps,
243
+ entryCount: entries.length,
244
+ entries: entries.slice(0, options.maxEntries || 50),
245
+ urlStats,
246
+ validation,
247
+ score,
248
+ scoreLabel: score >= 80 ? "Good" : score >= 50 ? "Fair" : "Needs work",
249
+ recommendations,
250
+ summary: isIndex
251
+ ? `Sitemap index with ${childSitemaps.length} child sitemap(s).`
252
+ : `Sitemap with ${entries.length} URL(s). Score: ${score}/100. lastmod coverage: ${urlStats?.lastmodCoverage || 0}%.`
253
+ };
254
+ }
255
+
256
+ export function renderSitemapMarkdown(report) {
257
+ const lines = [
258
+ "# Sitemap Analysis",
259
+ "",
260
+ `- Source: \`${report.source}\``,
261
+ `- Found: \`${report.found}\``,
262
+ `- Score: \`${report.score}/100\` (${report.scoreLabel})`,
263
+ `- Summary: ${report.summary}`,
264
+ ""
265
+ ];
266
+
267
+ if (!report.found) {
268
+ lines.push("## Action Required", "", "- Create and submit an XML sitemap.", "");
269
+ return lines.join("\n");
270
+ }
271
+
272
+ if (report.isIndex) {
273
+ lines.push("## Sitemap Index", "", `- Child sitemaps: \`${report.childSitemaps.length}\``, "");
274
+ for (const child of report.childSitemaps) {
275
+ lines.push(`- \`${child.loc}\`${child.lastmod ? ` (${child.lastmod})` : ""}`);
276
+ }
277
+ lines.push("");
278
+ } else if (report.urlStats) {
279
+ lines.push(
280
+ "## URL Statistics",
281
+ "",
282
+ `- Total URLs: \`${report.urlStats.total}\``,
283
+ `- With lastmod: \`${report.urlStats.withLastmod}\` (${report.urlStats.lastmodCoverage}%)`,
284
+ `- With changefreq: \`${report.urlStats.withChangefreq}\``,
285
+ `- With priority: \`${report.urlStats.withPriority}\``,
286
+ `- Average priority: \`${report.urlStats.avgPriority ?? "—"}\``,
287
+ `- Average URL depth: \`${report.urlStats.avgDepth}\``,
288
+ "",
289
+ "## Content Freshness",
290
+ "",
291
+ `- Fresh (< 30 days): \`${report.urlStats.freshness.fresh}\``,
292
+ `- Recent (30-180 days): \`${report.urlStats.freshness.recent}\``,
293
+ `- Stale (180-365 days): \`${report.urlStats.freshness.stale}\``,
294
+ `- Old (> 1 year): \`${report.urlStats.freshness.old}\``,
295
+ ""
296
+ );
297
+ }
298
+
299
+ if (report.validation.issues.length > 0 || report.validation.warnings.length > 0) {
300
+ lines.push("## Validation", "");
301
+ for (const issue of report.validation.issues) {
302
+ lines.push(`- ❌ ${issue.message}`);
303
+ }
304
+ for (const warning of report.validation.warnings) {
305
+ lines.push(`- ⚠️ ${warning.message}`);
306
+ }
307
+ lines.push("");
308
+ }
309
+
310
+ if (report.recommendations.length > 0) {
311
+ lines.push("## Recommendations", "");
312
+ for (const rec of report.recommendations) {
313
+ lines.push(`- ${rec}`);
314
+ }
315
+ lines.push("");
316
+ }
317
+
318
+ return lines.join("\n");
319
+ }
320
+
321
+ export async function writeSitemapOutput(outputPath, content) {
322
+ return writeScanOutput(outputPath, content);
323
+ }
@@ -0,0 +1,293 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { writeScanOutput } from "./scan.js";
4
+
5
+ const OG_TAGS = [
6
+ { property: "og:title", required: true, label: "Title" },
7
+ { property: "og:description", required: true, label: "Description" },
8
+ { property: "og:image", required: true, label: "Image" },
9
+ { property: "og:url", required: true, label: "URL" },
10
+ { property: "og:type", required: false, label: "Type" },
11
+ { property: "og:site_name", required: false, label: "Site Name" },
12
+ { property: "og:locale", required: false, label: "Locale" },
13
+ { property: "og:image:width", required: false, label: "Image Width" },
14
+ { property: "og:image:height", required: false, label: "Image Height" },
15
+ { property: "og:image:alt", required: false, label: "Image Alt" }
16
+ ];
17
+
18
+ const TWITTER_TAGS = [
19
+ { name: "twitter:card", required: true, label: "Card Type" },
20
+ { name: "twitter:title", required: false, label: "Title" },
21
+ { name: "twitter:description", required: false, label: "Description" },
22
+ { name: "twitter:image", required: false, label: "Image" },
23
+ { name: "twitter:site", required: false, label: "Site Handle" },
24
+ { name: "twitter:creator", required: false, label: "Creator Handle" },
25
+ { name: "twitter:image:alt", required: false, label: "Image Alt" }
26
+ ];
27
+
28
+ function extractMetaTags(content) {
29
+ const tags = {};
30
+
31
+ // og: tags
32
+ const ogMatches = content.matchAll(/<meta[^>]+property=["'](og:[^"']+)["'][^>]+content=["']([^"']*)["']/gi);
33
+ for (const match of ogMatches) {
34
+ tags[match[1].toLowerCase()] = match[2].trim();
35
+ }
36
+
37
+ // Reverse order (content before property)
38
+ const ogReverseMatches = content.matchAll(/<meta[^>]+content=["']([^"']*)["'][^>]+property=["'](og:[^"']+)["']/gi);
39
+ for (const match of ogReverseMatches) {
40
+ const key = match[2].toLowerCase();
41
+ if (!tags[key]) tags[key] = match[1].trim();
42
+ }
43
+
44
+ // twitter: tags (both name= and property= variants)
45
+ const twitterMatches = content.matchAll(/<meta[^>]+(?:name|property)=["'](twitter:[^"']+)["'][^>]+content=["']([^"']*)["']/gi);
46
+ for (const match of twitterMatches) {
47
+ tags[match[1].toLowerCase()] = match[2].trim();
48
+ }
49
+
50
+ const twitterReverseMatches = content.matchAll(/<meta[^>]+content=["']([^"']*)["'][^>]+(?:name|property)=["'](twitter:[^"']+)["']/gi);
51
+ for (const match of twitterReverseMatches) {
52
+ const key = match[2].toLowerCase();
53
+ if (!tags[key]) tags[key] = match[1].trim();
54
+ }
55
+
56
+ return tags;
57
+ }
58
+
59
+ function validateOgTags(tags) {
60
+ const results = [];
61
+
62
+ for (const spec of OG_TAGS) {
63
+ const value = tags[spec.property];
64
+ const found = Boolean(value);
65
+
66
+ const issues = [];
67
+ if (spec.required && !found) {
68
+ issues.push({ severity: "error", message: `Missing required tag: ${spec.property}` });
69
+ }
70
+
71
+ if (found) {
72
+ if (spec.property === "og:title" && value.length > 60) {
73
+ issues.push({ severity: "warning", message: `og:title is long (${value.length} chars). Recommended: ≤ 60.` });
74
+ }
75
+ if (spec.property === "og:description" && value.length > 200) {
76
+ issues.push({ severity: "warning", message: `og:description is long (${value.length} chars). Recommended: ≤ 200.` });
77
+ }
78
+ if (spec.property === "og:image") {
79
+ if (!/^https:\/\//i.test(value)) {
80
+ issues.push({ severity: "warning", message: "og:image should use absolute HTTPS URL" });
81
+ }
82
+ }
83
+ if (spec.property === "og:url") {
84
+ try {
85
+ new URL(value);
86
+ } catch {
87
+ issues.push({ severity: "error", message: "og:url is not a valid URL" });
88
+ }
89
+ }
90
+ }
91
+
92
+ results.push({
93
+ tag: spec.property,
94
+ label: spec.label,
95
+ required: spec.required,
96
+ found,
97
+ value: value || null,
98
+ issues
99
+ });
100
+ }
101
+
102
+ return results;
103
+ }
104
+
105
+ function validateTwitterTags(tags) {
106
+ const results = [];
107
+
108
+ for (const spec of TWITTER_TAGS) {
109
+ const value = tags[spec.name];
110
+ const found = Boolean(value);
111
+
112
+ const issues = [];
113
+ if (spec.required && !found) {
114
+ issues.push({ severity: "error", message: `Missing required tag: ${spec.name}` });
115
+ }
116
+
117
+ if (found && spec.name === "twitter:card") {
118
+ const validTypes = ["summary", "summary_large_image", "app", "player"];
119
+ if (!validTypes.includes(value)) {
120
+ issues.push({ severity: "error", message: `Invalid twitter:card type: "${value}". Valid: ${validTypes.join(", ")}` });
121
+ }
122
+ }
123
+
124
+ results.push({
125
+ tag: spec.name,
126
+ label: spec.label,
127
+ required: spec.required,
128
+ found,
129
+ value: value || null,
130
+ issues
131
+ });
132
+ }
133
+
134
+ return results;
135
+ }
136
+
137
+ function computeScore(ogResults, twitterResults) {
138
+ const allResults = [...ogResults, ...twitterResults];
139
+ const required = allResults.filter((r) => r.required);
140
+ const requiredFound = required.filter((r) => r.found).length;
141
+ const optional = allResults.filter((r) => !r.required);
142
+ const optionalFound = optional.filter((r) => r.found).length;
143
+ const errors = allResults.reduce((sum, r) => sum + r.issues.filter((i) => i.severity === "error").length, 0);
144
+ const warnings = allResults.reduce((sum, r) => sum + r.issues.filter((i) => i.severity === "warning").length, 0);
145
+
146
+ let score = 0;
147
+ score += required.length > 0 ? Math.round((requiredFound / required.length) * 60) : 60;
148
+ score += optional.length > 0 ? Math.round((optionalFound / optional.length) * 25) : 25;
149
+ score += 15; // base
150
+ score -= errors * 8;
151
+ score -= warnings * 3;
152
+
153
+ return Math.max(0, Math.min(100, score));
154
+ }
155
+
156
+ function buildRecommendations(ogResults, twitterResults) {
157
+ const recs = [];
158
+
159
+ const missingRequired = [...ogResults, ...twitterResults]
160
+ .filter((r) => r.required && !r.found);
161
+
162
+ if (missingRequired.length > 0) {
163
+ recs.push(`Add missing required tags: ${missingRequired.map((r) => r.tag).join(", ")}`);
164
+ }
165
+
166
+ const ogImage = ogResults.find((r) => r.tag === "og:image");
167
+ if (ogImage && !ogImage.found && !missingRequired.some((r) => r.tag === "og:image")) {
168
+ recs.push("Add og:image. Shared links with images get 2-3x more engagement.");
169
+ }
170
+
171
+ const imageAlt = ogResults.find((r) => r.tag === "og:image:alt");
172
+ if (ogImage?.found && !imageAlt?.found) {
173
+ recs.push("Add og:image:alt for accessibility and better AI understanding of the image.");
174
+ }
175
+
176
+ const imageDims = ogResults.filter((r) => r.tag.includes("image:width") || r.tag.includes("image:height"));
177
+ if (ogImage?.found && imageDims.every((r) => !r.found)) {
178
+ recs.push("Add og:image:width and og:image:height to prevent layout shift on social platforms.");
179
+ }
180
+
181
+ const twitterCard = twitterResults.find((r) => r.tag === "twitter:card");
182
+ if (!twitterCard?.found) {
183
+ recs.push("Add twitter:card (summary_large_image recommended for articles).");
184
+ }
185
+
186
+ return recs;
187
+ }
188
+
189
+ async function fetchContent(url) {
190
+ const response = await fetch(url, {
191
+ redirect: "follow",
192
+ headers: { "user-agent": "geo-ai-search-optimization/2.2.0" },
193
+ signal: AbortSignal.timeout(10_000)
194
+ });
195
+ if (!response.ok) throw new Error(`Failed to fetch: ${url} (status ${response.status})`);
196
+ return response.text();
197
+ }
198
+
199
+ export async function analyzeSocialMeta(input, options = {}) {
200
+ let content;
201
+ let source;
202
+
203
+ if (/^https?:\/\//i.test(input)) {
204
+ content = await fetchContent(input);
205
+ source = input;
206
+ } else {
207
+ const filePath = path.resolve(input);
208
+ content = await fs.readFile(filePath, "utf8");
209
+ source = filePath;
210
+ }
211
+
212
+ const tags = extractMetaTags(content);
213
+ const ogResults = validateOgTags(tags);
214
+ const twitterResults = validateTwitterTags(tags);
215
+ const score = computeScore(ogResults, twitterResults);
216
+ const recommendations = buildRecommendations(ogResults, twitterResults);
217
+
218
+ const ogFound = ogResults.filter((r) => r.found).length;
219
+ const twitterFound = twitterResults.filter((r) => r.found).length;
220
+
221
+ return {
222
+ kind: "geo-social-meta",
223
+ source,
224
+ openGraph: {
225
+ found: ogFound,
226
+ total: ogResults.length,
227
+ tags: ogResults
228
+ },
229
+ twitter: {
230
+ found: twitterFound,
231
+ total: twitterResults.length,
232
+ tags: twitterResults
233
+ },
234
+ score,
235
+ scoreLabel: score >= 80 ? "Good" : score >= 50 ? "Fair" : "Needs work",
236
+ recommendations,
237
+ summary: `Social meta: ${score}/100. OG tags: ${ogFound}/${ogResults.length}. Twitter tags: ${twitterFound}/${twitterResults.length}.`
238
+ };
239
+ }
240
+
241
+ export function renderSocialMetaMarkdown(report) {
242
+ const lines = [
243
+ "# Social Meta Analysis",
244
+ "",
245
+ `- Source: \`${report.source}\``,
246
+ `- Score: \`${report.score}/100\` (${report.scoreLabel})`,
247
+ `- Summary: ${report.summary}`,
248
+ "",
249
+ "## Open Graph Tags",
250
+ "",
251
+ "| Tag | Status | Value |",
252
+ "|-----|--------|-------|"
253
+ ];
254
+
255
+ for (const tag of report.openGraph.tags) {
256
+ const icon = tag.found ? "✅" : (tag.required ? "❌" : "—");
257
+ const value = tag.value ? tag.value.slice(0, 60) + (tag.value.length > 60 ? "..." : "") : "";
258
+ lines.push(`| ${tag.tag} | ${icon} | ${value} |`);
259
+ }
260
+
261
+ lines.push("", "## Twitter Card Tags", "", "| Tag | Status | Value |", "|-----|--------|-------|");
262
+
263
+ for (const tag of report.twitter.tags) {
264
+ const icon = tag.found ? "✅" : (tag.required ? "❌" : "—");
265
+ const value = tag.value ? tag.value.slice(0, 60) + (tag.value.length > 60 ? "..." : "") : "";
266
+ lines.push(`| ${tag.tag} | ${icon} | ${value} |`);
267
+ }
268
+
269
+ const allIssues = [...report.openGraph.tags, ...report.twitter.tags].flatMap((t) => t.issues);
270
+ if (allIssues.length > 0) {
271
+ lines.push("", "## Issues", "");
272
+ for (const issue of allIssues) {
273
+ const icon = issue.severity === "error" ? "❌" : "⚠️";
274
+ lines.push(`- ${icon} ${issue.message}`);
275
+ }
276
+ }
277
+
278
+ lines.push("", "## Recommendations", "");
279
+ if (report.recommendations.length === 0) {
280
+ lines.push("- Social meta tags are well-configured.");
281
+ } else {
282
+ for (const rec of report.recommendations) {
283
+ lines.push(`- ${rec}`);
284
+ }
285
+ }
286
+ lines.push("");
287
+
288
+ return lines.join("\n");
289
+ }
290
+
291
+ export async function writeSocialMetaOutput(outputPath, content) {
292
+ return writeScanOutput(outputPath, content);
293
+ }