Next.js website for Rocky Mountain Vending company featuring: - Product catalog with Stripe integration - Service areas and parts pages - Admin dashboard with Clerk authentication - SEO optimized pages with JSON-LD structured data Co-authored-by: Cursor <cursoragent@cursor.com>
1074 lines
No EOL
30 KiB
JavaScript
1074 lines
No EOL
30 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import { fileURLToPath } from 'url';
|
||
import { execSync } from 'child_process';
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const __dirname = path.dirname(__filename);
|
||
|
||
// Get the project root
|
||
const PROJECT_ROOT = path.join(__dirname, '..');
|
||
const APP_DIR = path.join(PROJECT_ROOT, 'app');
|
||
|
||
// Import utilities
|
||
import { discoverPages, generateSitemapXml, analyzeInternalLinks } from '../lib/seo-utils.js';
|
||
import {
|
||
defaultConfig as defaultLinkConfig,
|
||
calculatePageScore,
|
||
getKeywordsForTarget,
|
||
getAnchorTexts,
|
||
isExcludedRoute,
|
||
getMaxLinksForPage,
|
||
getMinWordsBetweenLinks,
|
||
findKeywordsInContent
|
||
} from '../lib/internal-link-config.js';
|
||
|
||
// Import readline only when needed
|
||
let readline;
|
||
|
||
/**
|
||
* Display colored output in console
|
||
*/
|
||
function colorize(text, color) {
|
||
const colors = {
|
||
reset: '\x1b[0m',
|
||
red: '\x1b[31m',
|
||
green: '\x1b[32m',
|
||
yellow: '\x1b[33m',
|
||
blue: '\x1b[34m',
|
||
magenta: '\x1b[35m',
|
||
cyan: '\x1b[36m',
|
||
white: '\x1b[37m'
|
||
};
|
||
return `${colors[color]}${text}${colors.reset}`;
|
||
}
|
||
|
||
/**
|
||
* Display section header
|
||
*/
|
||
function displayHeader(title) {
|
||
console.log(`\n${colorize('='.repeat(50), 'cyan')}`);
|
||
console.log(colorize(` ${title}`, 'cyan'));
|
||
console.log(colorize('='.repeat(50), 'cyan'));
|
||
}
|
||
|
||
/**
|
||
* Display success message
|
||
*/
|
||
function displaySuccess(message) {
|
||
console.log(colorize(`✓ ${message}`, 'green'));
|
||
}
|
||
|
||
/**
|
||
* Display error message
|
||
*/
|
||
function displayError(message) {
|
||
console.log(colorize(`✗ ${message}`, 'red'));
|
||
}
|
||
|
||
/**
|
||
* Display warning message
|
||
*/
|
||
function displayWarning(message) {
|
||
console.log(colorize(`⚠ ${message}`, 'yellow'));
|
||
}
|
||
|
||
/**
|
||
* Display info message
|
||
*/
|
||
function displayInfo(message) {
|
||
console.log(colorize(`ℹ ${message}`, 'blue'));
|
||
}
|
||
|
||
/**
|
||
* Show help information
|
||
*/
|
||
function showHelp() {
|
||
displayHeader('SEO Internal Link Tool - Help');
|
||
console.log(colorize('Usage:', 'white'));
|
||
console.log(' node seo-internal-link-tool.js <command> [options]');
|
||
console.log('');
|
||
console.log(colorize('Commands:', 'white'));
|
||
console.log(' sitemap Generate sitemap from React pages');
|
||
console.log(' analyze Analyze internal links');
|
||
console.log(' optimize Optimize internal links');
|
||
console.log(' report Generate comprehensive SEO report');
|
||
console.log(' json-ld Generate JSON-LD structured data');
|
||
console.log(' interactive Start interactive mode');
|
||
console.log('');
|
||
console.log(colorize('Options:', 'white'));
|
||
console.log(' --output <file> Output file path');
|
||
console.log(' --format <format> Output format (json, html, csv, markdown)');
|
||
console.log(' --verbose Enable verbose output');
|
||
console.log(' --help, -h Show help information');
|
||
console.log('');
|
||
console.log(colorize('Examples:', 'white'));
|
||
console.log(' node seo-internal-link-tool.js sitemap');
|
||
console.log(' node seo-internal-link-tool.js analyze --output report.json');
|
||
console.log(' node seo-internal-link-tool.js interactive');
|
||
}
|
||
|
||
/**
|
||
* Read file content with error handling
|
||
*/
|
||
function readFileContent(filePath) {
|
||
try {
|
||
return fs.readFileSync(filePath, 'utf8');
|
||
} catch (error) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Extract content from page file
|
||
*/
|
||
function extractPageContent(page) {
|
||
return readFileContent(page.filePath);
|
||
}
|
||
|
||
/**
|
||
* Generate JSON-LD structured data for a page
|
||
*/
|
||
function generatePageJsonLD(page, content, linkConfig = defaultLinkConfig) {
|
||
const businessConfig = {
|
||
name: linkConfig.businessName,
|
||
websiteUrl: linkConfig.websiteUrl,
|
||
description: extractPageDescription(content) || `${linkConfig.businessName} - ${page.title}`
|
||
};
|
||
|
||
const jsonLD = {
|
||
'@context': 'https://schema.org',
|
||
'@type': getSchemaTypeForRoute(page.route),
|
||
name: page.title,
|
||
description: businessConfig.description,
|
||
url: `${businessConfig.websiteUrl}${page.url}`,
|
||
datePublished: page.lastModified.toISOString(),
|
||
dateModified: page.lastModified.toISOString(),
|
||
publisher: {
|
||
'@type': 'Organization',
|
||
name: businessConfig.name,
|
||
url: businessConfig.websiteUrl,
|
||
}
|
||
};
|
||
|
||
// Add route-specific structured data
|
||
if (page.route === '') {
|
||
jsonLD['@type'] = 'WebSite';
|
||
jsonLD.about = {
|
||
'@type': 'LocalBusiness',
|
||
name: businessConfig.name,
|
||
description: businessConfig.description,
|
||
url: businessConfig.websiteUrl,
|
||
};
|
||
} else if (page.route.includes('service') || page.route.includes('repair')) {
|
||
jsonLD['@type'] = 'Service';
|
||
jsonLD.serviceType = page.title;
|
||
jsonLD.provider = {
|
||
'@type': 'LocalBusiness',
|
||
name: businessConfig.name,
|
||
url: businessConfig.websiteUrl,
|
||
};
|
||
} else if (page.route.includes('contact-us')) {
|
||
jsonLD['@type'] = 'ContactPage';
|
||
} else if (page.route.includes('about')) {
|
||
jsonLD['@type'] = 'AboutPage';
|
||
}
|
||
|
||
return jsonLD;
|
||
}
|
||
|
||
/**
|
||
* Extract page description from content
|
||
*/
|
||
function extractPageDescription(content) {
|
||
const descMatch = content.match(/description:\s*['"`]([^'"`]+)['"`]/);
|
||
return descMatch ? descMatch[1] : null;
|
||
}
|
||
|
||
/**
|
||
* Get schema type based on route
|
||
*/
|
||
function getSchemaTypeForRoute(route) {
|
||
if (route === '') return 'WebSite';
|
||
if (route.includes('service') || route.includes('repair')) return 'Service';
|
||
if (route.includes('contact-us')) return 'ContactPage';
|
||
if (route.includes('about')) return 'AboutPage';
|
||
if (route.includes('product') || route.includes('vending')) return 'Product';
|
||
return 'WebPage';
|
||
}
|
||
|
||
/**
|
||
* Generate sitemap from React pages
|
||
*/
|
||
async function runSitemapGeneration(options) {
|
||
displayHeader('Generating Sitemap from React Pages');
|
||
|
||
try {
|
||
// Discover pages
|
||
displayInfo('Discovering React pages...');
|
||
const pages = discoverPages(APP_DIR);
|
||
displaySuccess(`Found ${pages.length} pages`);
|
||
|
||
// Generate sitemap XML
|
||
displayInfo('Generating sitemap XML...');
|
||
const sitemapXml = generateSitemapXml(pages);
|
||
|
||
// Output results
|
||
if (options.output) {
|
||
fs.writeFileSync(options.output, sitemapXml);
|
||
displaySuccess(`Sitemap saved to: ${options.output}`);
|
||
} else {
|
||
console.log(colorize('\nGenerated Sitemap:', 'white'));
|
||
console.log(sitemapXml);
|
||
}
|
||
|
||
// Also save to conventional location
|
||
const sitemapPath = path.join(PROJECT_ROOT, 'out', 'sitemap.xml');
|
||
fs.writeFileSync(sitemapPath, sitemapXml);
|
||
displaySuccess(`Default sitemap location: ${sitemapPath}`);
|
||
|
||
} catch (error) {
|
||
displayError(`Failed to generate sitemap: ${error.message}`);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Analyze internal links
|
||
*/
|
||
async function runLinkAnalysis(options) {
|
||
displayHeader('Analyzing Internal Links');
|
||
|
||
try {
|
||
// Discover pages
|
||
displayInfo('Discovering React pages...');
|
||
const pages = discoverPages(APP_DIR);
|
||
displaySuccess(`Found ${pages.length} pages`);
|
||
|
||
// Extract content from each page
|
||
displayInfo('Extracting page content...');
|
||
const contentByRoute = {};
|
||
for (const page of pages) {
|
||
contentByRoute[page.route] = extractPageContent(page);
|
||
}
|
||
|
||
// Analyze internal links
|
||
displayInfo('Analyzing internal links...');
|
||
const analysis = analyzeInternalLinks(pages, contentByRoute);
|
||
|
||
// Display results
|
||
displayLinkAnalysisResults(analysis, options);
|
||
|
||
// Save results if output file specified
|
||
if (options.output) {
|
||
saveAnalysisResults(analysis, options.output, options.format);
|
||
displaySuccess(`Analysis saved to: ${options.output}`);
|
||
}
|
||
|
||
} catch (error) {
|
||
displayError(`Failed to analyze links: ${error.message}`);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Display link analysis results
|
||
*/
|
||
function displayLinkAnalysisResults(analysis, options) {
|
||
console.log(colorize(`\n📊 Link Analysis Summary`, 'white'));
|
||
console.log(colorize(`Total Pages: ${analysis.totalPages}`, 'cyan'));
|
||
console.log(colorize(`Internal Links: ${analysis.internalLinks}`, 'cyan'));
|
||
console.log(colorize(`Average Link Density: ${analysis.averageLinkDensity.toFixed(2)} links per page`, 'cyan'));
|
||
|
||
if (analysis.orphanedPages.length > 0) {
|
||
displayWarning(`Orphaned Pages: ${analysis.orphanedPages.length}`);
|
||
analysis.orphanedPages.forEach(page => {
|
||
console.log(colorize(` - ${page}`, 'yellow'));
|
||
});
|
||
} else {
|
||
displaySuccess('No orphaned pages found');
|
||
}
|
||
|
||
if (analysis.brokenLinks.length > 0) {
|
||
displayError(`Broken Links: ${analysis.brokenLinks.length}`);
|
||
analysis.brokenLinks.forEach(link => {
|
||
console.log(colorize(` - ${link}`, 'red'));
|
||
});
|
||
} else {
|
||
displaySuccess('No broken links found');
|
||
}
|
||
|
||
// Page types breakdown
|
||
console.log(colorize(`\n📋 Page Types:`, 'white'));
|
||
for (const [type, count] of Object.entries(analysis.pageTypes)) {
|
||
console.log(colorize(` ${type}: ${count} pages`, 'blue'));
|
||
}
|
||
|
||
// Pages with issues
|
||
if (analysis.pagesWithIssues.length > 0) {
|
||
displayWarning(`Pages with Issues: ${analysis.pagesWithIssues.length}`);
|
||
analysis.pagesWithIssues.forEach(page => {
|
||
console.log(colorize(` - ${page.url}:`, 'yellow'));
|
||
page.issues.forEach(issue => {
|
||
console.log(colorize(` * ${issue}`, 'yellow'));
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Save analysis results
|
||
*/
|
||
function saveAnalysisResults(results, outputPath, format) {
|
||
const output = {
|
||
timestamp: new Date().toISOString(),
|
||
summary: {
|
||
totalPages: results.totalPages,
|
||
internalLinks: results.internalLinks,
|
||
orphanedPages: results.orphanedPages.length,
|
||
brokenLinks: results.brokenLinks.length,
|
||
averageLinkDensity: results.averageLinkDensity
|
||
},
|
||
details: results
|
||
};
|
||
|
||
let content;
|
||
switch (format) {
|
||
case 'json':
|
||
content = JSON.stringify(output, null, 2);
|
||
break;
|
||
case 'csv':
|
||
content = convertToCSV(results);
|
||
break;
|
||
case 'markdown':
|
||
content = convertToMarkdown(results);
|
||
break;
|
||
case 'html':
|
||
content = convertToHTML(results);
|
||
break;
|
||
default:
|
||
content = JSON.stringify(output, null, 2);
|
||
}
|
||
|
||
fs.writeFileSync(outputPath, content);
|
||
}
|
||
|
||
/**
|
||
* Convert analysis results to CSV
|
||
*/
|
||
function convertToCSV(results) {
|
||
let csv = 'Page,Route,Links,Issues\n';
|
||
|
||
// Add pages with issues
|
||
results.pagesWithIssues.forEach(page => {
|
||
csv += `"${page.url}","${page.route}","0","${page.issues.join('; ')}"\n`;
|
||
});
|
||
|
||
// Add pages without issues
|
||
const allPages = Array.from(new Set([
|
||
...results.orphanedPages,
|
||
...results.brokenLinks.map(link => link.split(' -> ')[0])
|
||
]));
|
||
|
||
allPages.forEach(page => {
|
||
csv += `"${page}","${page}","0","No issues"\n`;
|
||
});
|
||
|
||
return csv;
|
||
}
|
||
|
||
/**
|
||
* Convert analysis results to Markdown
|
||
*/
|
||
function convertToMarkdown(results) {
|
||
let md = '# SEO Link Analysis Report\n\n';
|
||
md += `*Generated: ${new Date().toISOString()}*\n\n`;
|
||
|
||
md += '## Summary\n\n';
|
||
md += `- Total Pages: ${results.totalPages}\n`;
|
||
md += `- Internal Links: ${results.internalLinks}\n`;
|
||
md += `- Orphaned Pages: ${results.orphanedPages.length}\n`;
|
||
md += `- Broken Links: ${results.brokenLinks.length}\n`;
|
||
md += `- Average Link Density: ${results.averageLinkDensity.toFixed(2)}\n\n`;
|
||
|
||
md += '## Page Types\n\n';
|
||
for (const [type, count] of Object.entries(results.pageTypes)) {
|
||
md += `- ${type}: ${count} pages\n`;
|
||
}
|
||
md += '\n';
|
||
|
||
if (results.orphanedPages.length > 0) {
|
||
md += '## Orphaned Pages\n\n';
|
||
results.orphanedPages.forEach(page => {
|
||
md += `- ${page}\n`;
|
||
});
|
||
md += '\n';
|
||
}
|
||
|
||
if (results.brokenLinks.length > 0) {
|
||
md += '## Broken Links\n\n';
|
||
results.brokenLinks.forEach(link => {
|
||
md += `- ${link}\n`;
|
||
});
|
||
md += '\n';
|
||
}
|
||
|
||
if (results.pagesWithIssues.length > 0) {
|
||
md += '## Pages with Issues\n\n';
|
||
results.pagesWithIssues.forEach(page => {
|
||
md += `### ${page.url}\n`;
|
||
page.issues.forEach(issue => {
|
||
md += `- ${issue}\n`;
|
||
});
|
||
md += '\n';
|
||
});
|
||
}
|
||
|
||
return md;
|
||
}
|
||
|
||
/**
|
||
* Convert analysis results to HTML
|
||
*/
|
||
function convertToHTML(results) {
|
||
return `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>SEO Link Analysis Report</title>
|
||
<style>
|
||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||
h1, h2 { color: #333; }
|
||
.summary { background: #f5f5f5; padding: 15px; border-radius: 5px; }
|
||
.issue { color: #d32f2f; }
|
||
.warning { color: #f57c00; }
|
||
.success { color: #388e3c; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>SEO Link Analysis Report</h1>
|
||
<p>Generated: ${new Date().toISOString()}</p>
|
||
|
||
<div class="summary">
|
||
<h2>Summary</h2>
|
||
<p><strong>Total Pages:</strong> ${results.totalPages}</p>
|
||
<p><strong>Internal Links:</strong> ${results.internalLinks}</p>
|
||
<p><strong>Orphaned Pages:</strong> ${results.orphanedPages.length}</p>
|
||
<p><strong>Broken Links:</strong> ${results.brokenLinks.length}</p>
|
||
<p><strong>Average Link Density:</strong> ${results.averageLinkDensity.toFixed(2)}</p>
|
||
</div>
|
||
|
||
${results.orphanedPages.length > 0 ? `
|
||
<div>
|
||
<h2 class="warning">Orphaned Pages</h2>
|
||
<ul>
|
||
${results.orphanedPages.map(page => `<li class="warning">${page}</li>`).join('')}
|
||
</ul>
|
||
</div>` : ''}
|
||
|
||
${results.brokenLinks.length > 0 ? `
|
||
<div>
|
||
<h2 class="issue">Broken Links</h2>
|
||
<ul>
|
||
${results.brokenLinks.map(link => `<li class="issue">${link}</li>`).join('')}
|
||
</ul>
|
||
</div>` : ''}
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
/**
|
||
* Optimize internal links
|
||
*/
|
||
async function runLinkOptimization(options) {
|
||
displayHeader('Optimizing Internal Links');
|
||
|
||
try {
|
||
// Load configuration
|
||
displayInfo('Loading configuration...');
|
||
const linkConfig = defaultLinkConfig;
|
||
|
||
// Discover pages
|
||
displayInfo('Discovering React pages...');
|
||
const pages = discoverPages(APP_DIR);
|
||
displaySuccess(`Found ${pages.length} pages`);
|
||
|
||
// Extract content from each page
|
||
displayInfo('Extracting page content...');
|
||
const contentByRoute = {};
|
||
for (const page of pages) {
|
||
contentByRoute[page.route] = extractPageContent(page);
|
||
}
|
||
|
||
// Optimize links page by page
|
||
let totalLinksAdded = 0;
|
||
const optimizationResults = [];
|
||
|
||
for (const page of pages) {
|
||
if (isExcludedRoute(page.route, linkConfig)) {
|
||
continue;
|
||
}
|
||
|
||
const maxLinks = getMaxLinksForPage(page.route, linkConfig);
|
||
const minWordsBetween = getMinWordsBetweenLinks(page.route, linkConfig);
|
||
const content = contentByRoute[page.route];
|
||
|
||
// Calculate SEO score
|
||
const internalLinks = extractInternalLinksFromContent(content);
|
||
const seoScore = calculatePageScore(page.route, content, internalLinks, linkConfig);
|
||
|
||
// Find keywords to link
|
||
const keywordsToLink = findKeywordsInContent(content, linkConfig);
|
||
const linksAdded = addOptimalLinks(content, keywordsToLink, pages, linkConfig);
|
||
|
||
if (linksAdded > 0) {
|
||
totalLinksAdded += linksAdded;
|
||
optimizationResults.push({
|
||
page: page.route,
|
||
linksAdded,
|
||
seoScore: seoScore,
|
||
keywordsFound: keywordsToLink.length
|
||
});
|
||
displaySuccess(`${page.route}: Added ${linksAdded} link(s)`);
|
||
}
|
||
}
|
||
|
||
displaySuccess(`Total links added: ${totalLinksAdded}`);
|
||
|
||
// Save results
|
||
if (options.output) {
|
||
const results = {
|
||
timestamp: new Date().toISOString(),
|
||
totalLinksAdded,
|
||
pagesOptimized: optimizationResults.length,
|
||
details: optimizationResults
|
||
};
|
||
|
||
fs.writeFileSync(options.output, JSON.stringify(results, null, 2));
|
||
displaySuccess(`Optimization results saved to: ${options.output}`);
|
||
}
|
||
|
||
} catch (error) {
|
||
displayError(`Failed to optimize links: ${error.message}`);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Extract internal links from content
|
||
*/
|
||
function extractInternalLinksFromContent(content) {
|
||
const links = [];
|
||
const linkRegex = /<a[^>]+href="([^"]+)"[^>]*>([^<]+)<\/a>/g;
|
||
|
||
let match;
|
||
while ((match = linkRegex.exec(content)) !== null) {
|
||
if (isInternalLink(match[1])) {
|
||
links.push({
|
||
to: match[1],
|
||
text: match[2].trim()
|
||
});
|
||
}
|
||
}
|
||
|
||
return links;
|
||
}
|
||
|
||
/**
|
||
* Check if link is internal
|
||
*/
|
||
function isInternalLink(href) {
|
||
return !href.startsWith('http') && !href.startsWith('mailto:') && !href.startsWith('tel:');
|
||
}
|
||
|
||
/**
|
||
* Add optimal links to content
|
||
*/
|
||
function addOptimalLinks(content, keywords, pages, config) {
|
||
let linksAdded = 0;
|
||
const linkPositions = [];
|
||
|
||
for (const keyword of keywords) {
|
||
if (linksAdded >= config.linkDensity.maxLinksPerPage) {
|
||
break;
|
||
}
|
||
|
||
// Find keyword in content
|
||
const keywordRegex = new RegExp(`\\b${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi');
|
||
let match;
|
||
|
||
while ((match = keywordRegex.exec(content)) !== null) {
|
||
if (linksAdded >= config.linkDensity.maxLinksPerPage) {
|
||
break;
|
||
}
|
||
|
||
// Check distance from other links
|
||
const tooClose = linkPositions.some(pos =>
|
||
Math.abs(pos - match.index) < config.linkDensity.minWordsBetweenLinks * 5
|
||
);
|
||
|
||
if (tooClose) continue;
|
||
|
||
// Add link
|
||
const targetPage = pages.find(p =>
|
||
p.title.toLowerCase().includes(keyword.toLowerCase()) ||
|
||
p.route.toLowerCase().includes(keyword.toLowerCase())
|
||
);
|
||
|
||
if (targetPage && !isExcludedRoute(targetPage.route, config)) {
|
||
const anchorTexts = getAnchorTexts(targetPage.route, config);
|
||
const anchorText = anchorTexts[0] || keyword;
|
||
|
||
// Insert link (simplified - in real implementation, would need to properly handle HTML)
|
||
content = insertLink(content, match.index, match.index + match[0].length, targetPage.url, anchorText);
|
||
|
||
linkPositions.push(match.index);
|
||
linksAdded++;
|
||
break; // Only link first occurrence per keyword
|
||
}
|
||
}
|
||
}
|
||
|
||
return linksAdded;
|
||
}
|
||
|
||
/**
|
||
* Insert link into content
|
||
*/
|
||
function insertLink(content, startIndex, endIndex, url, anchorText) {
|
||
const before = content.substring(0, startIndex);
|
||
const after = content.substring(endIndex);
|
||
const link = `<a href="${url}">${anchorText}</a>`;
|
||
return before + link + after;
|
||
}
|
||
|
||
/**
|
||
* Generate SEO report
|
||
*/
|
||
async function runSEOReport(options) {
|
||
displayHeader('Generating SEO Report');
|
||
|
||
try {
|
||
// Discover pages
|
||
displayInfo('Discovering React pages...');
|
||
const pages = discoverPages(APP_DIR);
|
||
displaySuccess(`Found ${pages.length} pages`);
|
||
|
||
// Extract content from each page
|
||
displayInfo('Extracting page content...');
|
||
const contentByRoute = {};
|
||
const pageScores = [];
|
||
|
||
for (const page of pages) {
|
||
contentByRoute[page.route] = extractPageContent(page);
|
||
|
||
// Calculate SEO score
|
||
const internalLinks = extractInternalLinksFromContent(contentByRoute[page.route]);
|
||
const seoScore = calculatePageScore(page.route, contentByRoute[page.route], internalLinks);
|
||
|
||
pageScores.push({
|
||
page: page.route,
|
||
url: page.url,
|
||
title: page.title,
|
||
priority: page.priority,
|
||
score: seoScore,
|
||
internalLinks: internalLinks.length,
|
||
lastModified: page.lastModified.toISOString()
|
||
});
|
||
}
|
||
|
||
// Sort pages by score
|
||
pageScores.sort((a, b) => b.score - a.score);
|
||
|
||
// Display report
|
||
displaySEOResults(pageScores, options);
|
||
|
||
// Save results
|
||
if (options.output) {
|
||
const report = {
|
||
timestamp: new Date().toISOString(),
|
||
totalPages: pages.length,
|
||
averageScore: pageScores.reduce((sum, p) => sum + p.score, 0) / pageScores.length,
|
||
pages: pageScores
|
||
};
|
||
|
||
let content;
|
||
switch (options.format) {
|
||
case 'json':
|
||
content = JSON.stringify(report, null, 2);
|
||
break;
|
||
case 'markdown':
|
||
content = generateMarkdownReport(report);
|
||
break;
|
||
case 'html':
|
||
content = generateHTMLReport(report);
|
||
break;
|
||
default:
|
||
content = JSON.stringify(report, null, 2);
|
||
}
|
||
|
||
fs.writeFileSync(options.output, content);
|
||
displaySuccess(`Report saved to: ${options.output}`);
|
||
}
|
||
|
||
} catch (error) {
|
||
displayError(`Failed to generate report: ${error.message}`);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Display SEO results
|
||
*/
|
||
function displaySEOResults(pageScores, options) {
|
||
console.log(colorize(`\n📊 SEO Analysis Summary`, 'white'));
|
||
console.log(colorize(`Total Pages Analyzed: ${pageScores.length}`, 'cyan'));
|
||
|
||
const averageScore = pageScores.reduce((sum, p) => sum + p.score, 0) / pageScores.length;
|
||
console.log(colorize(`Average SEO Score: ${averageScore.toFixed(1)}/100`, 'cyan'));
|
||
|
||
// Top performing pages
|
||
console.log(colorize(`\n🏆 Top Performing Pages:`, 'white'));
|
||
const topPages = pageScores.slice(0, 5);
|
||
topPages.forEach((page, index) => {
|
||
const scoreColor = page.score >= 80 ? 'green' : page.score >= 60 ? 'yellow' : 'red';
|
||
console.log(colorize(`${index + 1}. ${page.title} (${page.route}) - Score: ${page.score.toFixed(1)}`, scoreColor));
|
||
});
|
||
|
||
// Pages needing improvement
|
||
const poorPages = pageScores.filter(p => p.score < 60);
|
||
if (poorPages.length > 0) {
|
||
console.log(colorize(`\n⚠ Pages Needing Improvement:`, 'yellow'));
|
||
poorPages.slice(0, 3).forEach(page => {
|
||
console.log(colorize(`- ${page.title} (${page.route}) - Score: ${page.score.toFixed(1)}`, 'red'));
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate markdown report
|
||
*/
|
||
function generateMarkdownReport(report) {
|
||
let md = '# SEO Report\n\n';
|
||
md += `*Generated: ${report.timestamp}*\n\n`;
|
||
|
||
md += '## Summary\n\n';
|
||
md += `- Total Pages: ${report.totalPages}\n`;
|
||
md += `- Average Score: ${report.averageScore.toFixed(1)}/100\n\n`;
|
||
|
||
md += '## Page Scores\n\n';
|
||
report.pages.forEach((page, index) => {
|
||
const status = page.score >= 80 ? 'Excellent' : page.score >= 60 ? 'Good' : 'Needs Improvement';
|
||
const statusColor = page.score >= 80 ? 'green' : page.score >= 60 ? 'yellow' : 'red';
|
||
md += `${index + 1}. **${page.title}** (${page.route})\n`;
|
||
md += ` - Score: ${page.score.toFixed(1)}/100 (${status})\n`;
|
||
md += ` - Internal Links: ${page.internalLinks}\n`;
|
||
md += ` - Priority: ${page.priority}\n`;
|
||
md += ` - Last Modified: ${new Date(page.lastModified).toLocaleDateString()}\n\n`;
|
||
});
|
||
|
||
return md;
|
||
}
|
||
|
||
/**
|
||
* Generate HTML report
|
||
*/
|
||
function generateHTMLReport(report) {
|
||
return `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>SEO Report</title>
|
||
<style>
|
||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||
h1, h2 { color: #333; }
|
||
.page { margin: 20px 0; padding: 15px; border: 1px solid #ddd; border-radius: 5px; }
|
||
.score { font-size: 24px; font-weight: bold; }
|
||
.excellent { color: #388e3c; }
|
||
.good { color: #f57c00; }
|
||
.poor { color: #d32f2f; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>SEO Report</h1>
|
||
<p>Generated: ${report.timestamp}</p>
|
||
|
||
<h2>Summary</h2>
|
||
<p><strong>Total Pages:</strong> ${report.totalPages}</p>
|
||
<p><strong>Average Score:</strong> ${report.averageScore.toFixed(1)}/100</p>
|
||
|
||
<h2>Page Scores</h2>
|
||
${report.pages.map((page, index) => {
|
||
const status = page.score >= 80 ? 'excellent' : page.score >= 60 ? 'good' : 'poor';
|
||
return `
|
||
<div class="page">
|
||
<h3>${index + 1}. ${page.title} (${page.route})</h3>
|
||
<p class="score ${status}">${page.score.toFixed(1)}/100</p>
|
||
<p>Internal Links: ${page.internalLinks}</p>
|
||
<p>Priority: ${page.priority}</p>
|
||
<p>Last Modified: ${new Date(page.lastModified).toLocaleDateString()}</p>
|
||
</div>`;
|
||
}).join('')}
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
/**
|
||
* Generate JSON-LD structured data
|
||
*/
|
||
async function runJsonLDGeneration(options) {
|
||
displayHeader('Generating JSON-LD Structured Data');
|
||
|
||
try {
|
||
// Discover pages
|
||
displayInfo('Discovering React pages...');
|
||
const pages = discoverPages(APP_DIR);
|
||
displaySuccess(`Found ${pages.length} pages`);
|
||
|
||
// Generate JSON-LD for each page
|
||
const jsonLDData = {};
|
||
|
||
for (const page of pages) {
|
||
const content = extractPageContent(page);
|
||
const jsonLD = generatePageJsonLD(page, content);
|
||
jsonLDData[page.route] = jsonLD;
|
||
|
||
if (options.verbose) {
|
||
displaySuccess(`Generated JSON-LD for: ${page.route}`);
|
||
}
|
||
}
|
||
|
||
// Output results
|
||
if (options.output) {
|
||
fs.writeFileSync(options.output, JSON.stringify(jsonLDData, null, 2));
|
||
displaySuccess(`JSON-LD data saved to: ${options.output}`);
|
||
} else {
|
||
console.log(colorize('\nGenerated JSON-LD Structured Data:', 'white'));
|
||
console.log(JSON.stringify(jsonLDData, null, 2));
|
||
}
|
||
|
||
// Also save individual files (skip dynamic routes to avoid filesystem issues)
|
||
const outputDir = path.join(PROJECT_ROOT, 'public', 'json-ld');
|
||
if (!fs.existsSync(outputDir)) {
|
||
fs.mkdirSync(outputDir, { recursive: true });
|
||
}
|
||
|
||
for (const [route, jsonLD] of Object.entries(jsonLDData)) {
|
||
// Skip dynamic routes
|
||
if (route.includes(':') || route.includes('...')) {
|
||
continue;
|
||
}
|
||
|
||
let filename;
|
||
if (route === '') {
|
||
filename = 'home.jsonld';
|
||
} else {
|
||
filename = `${route.replace(/\//g, '_')}.jsonld`;
|
||
}
|
||
|
||
const filepath = path.join(outputDir, filename);
|
||
fs.writeFileSync(filepath, JSON.stringify(jsonLD, null, 2));
|
||
}
|
||
|
||
displaySuccess(`Individual JSON-LD files saved to: ${outputDir}`);
|
||
|
||
} catch (error) {
|
||
displayError(`Failed to generate JSON-LD: ${error.message}`);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start interactive mode
|
||
*/
|
||
async function startInteractiveMode(options) {
|
||
displayHeader('SEO Internal Link Tool - Interactive Mode');
|
||
displayInfo('Type "help" for available commands or "exit" to quit');
|
||
|
||
// Import readline only when needed
|
||
try {
|
||
readline = await import('readline');
|
||
} catch (error) {
|
||
displayError('Interactive mode requires Node.js readline module');
|
||
process.exit(1);
|
||
}
|
||
|
||
const rl = readline.createInterface({
|
||
input: process.stdin,
|
||
output: process.stdout
|
||
});
|
||
|
||
const question = (query) => new Promise(resolve => rl.question(query, resolve));
|
||
|
||
while (true) {
|
||
const command = await question('\nseo-tool> ');
|
||
|
||
if (command.toLowerCase() === 'exit' || command.toLowerCase() === 'quit') {
|
||
break;
|
||
}
|
||
|
||
if (command.toLowerCase() === 'help') {
|
||
showHelp();
|
||
continue;
|
||
}
|
||
|
||
const args = command.split(' ');
|
||
const cmd = args[0];
|
||
const cmdOptions = {
|
||
output: null,
|
||
format: 'console',
|
||
verbose: false
|
||
};
|
||
|
||
// Parse options
|
||
for (let i = 1; i < args.length; i++) {
|
||
const arg = args[i];
|
||
|
||
if (arg === '--output' && i + 1 < args.length) {
|
||
cmdOptions.output = args[i + 1];
|
||
i++;
|
||
} else if (arg === '--format' && i + 1 < args.length) {
|
||
cmdOptions.format = args[i + 1];
|
||
i++;
|
||
} else if (arg === '--verbose' || arg === '-v') {
|
||
cmdOptions.verbose = true;
|
||
}
|
||
}
|
||
|
||
try {
|
||
switch (cmd) {
|
||
case 'sitemap':
|
||
await runSitemapGeneration(cmdOptions);
|
||
break;
|
||
case 'analyze':
|
||
await runLinkAnalysis(cmdOptions);
|
||
break;
|
||
case 'optimize':
|
||
await runLinkOptimization(cmdOptions);
|
||
break;
|
||
case 'report':
|
||
await runSEOReport(cmdOptions);
|
||
break;
|
||
case 'json-ld':
|
||
await runJsonLDGeneration(cmdOptions);
|
||
break;
|
||
default:
|
||
displayError(`Unknown command: ${cmd}`);
|
||
showHelp();
|
||
}
|
||
} catch (error) {
|
||
displayError(`Error: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
rl.close();
|
||
displaySuccess('Goodbye!');
|
||
}
|
||
|
||
/**
|
||
* Main CLI dispatcher
|
||
*/
|
||
function main() {
|
||
const args = process.argv.slice(2);
|
||
|
||
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
||
showHelp();
|
||
return;
|
||
}
|
||
|
||
const command = args[0];
|
||
const options = {
|
||
output: null,
|
||
format: 'console',
|
||
verbose: false
|
||
};
|
||
|
||
// Parse options
|
||
for (let i = 1; i < args.length; i++) {
|
||
const arg = args[i];
|
||
|
||
if (arg === '--output' && i + 1 < args.length) {
|
||
options.output = args[i + 1];
|
||
i++;
|
||
} else if (arg === '--format' && i + 1 < args.length) {
|
||
options.format = args[i + 1];
|
||
i++;
|
||
} else if (arg === '--verbose' || arg === '-v') {
|
||
options.verbose = true;
|
||
}
|
||
}
|
||
|
||
// Check if we need to import readline for interactive mode
|
||
if (command === 'interactive') {
|
||
try {
|
||
readline = require('readline');
|
||
} catch (error) {
|
||
displayError('Interactive mode requires Node.js readline module');
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
switch (command) {
|
||
case 'sitemap':
|
||
executeCommand(runSitemapGeneration, options);
|
||
break;
|
||
case 'analyze':
|
||
executeCommand(runLinkAnalysis, options);
|
||
break;
|
||
case 'optimize':
|
||
executeCommand(runLinkOptimization, options);
|
||
break;
|
||
case 'report':
|
||
executeCommand(runSEOReport, options);
|
||
break;
|
||
case 'json-ld':
|
||
executeCommand(runJsonLDGeneration, options);
|
||
break;
|
||
case 'interactive':
|
||
startInteractiveMode(options);
|
||
break;
|
||
default:
|
||
displayError(`Unknown command: ${command}`);
|
||
console.log('');
|
||
showHelp();
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Execute function and handle errors
|
||
*/
|
||
async function executeCommand(commandFn, options) {
|
||
try {
|
||
await commandFn(options);
|
||
} catch (error) {
|
||
displayError(`Error: ${error.message}`);
|
||
if (options.verbose) {
|
||
console.error(error);
|
||
}
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// Export functions for external use
|
||
export {
|
||
main,
|
||
executeCommand,
|
||
displayHeader,
|
||
displaySuccess,
|
||
displayError,
|
||
displayWarning,
|
||
displayInfo,
|
||
runSitemapGeneration,
|
||
runLinkAnalysis,
|
||
runLinkOptimization,
|
||
runSEOReport,
|
||
runJsonLDGeneration
|
||
};
|
||
|
||
// Run if called directly
|
||
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('seo-internal-link-tool.js')) {
|
||
main();
|
||
} |