vibe-code-build
Version:
Real-time code monitoring with teaching explanations, CLAUDE.md compliance checking, and interactive chat
1,215 lines (1,075 loc) ⢠46.2 kB
JavaScript
import fs from 'fs/promises';
import path from 'path';
import chalk from 'chalk';
import ora from 'ora';
export class SEOChecker {
constructor(projectPath = process.cwd(), options = {}) {
this.projectPath = projectPath;
this.options = options;
this.results = {
technical: {},
content: {},
social: {},
performance: {},
overall: {}
};
}
async checkAll() {
const spinner = this.options.silent ? null : ora('Running comprehensive SEO analysis...').start();
try {
if (spinner) spinner.text = 'Checking technical SEO...';
this.results.technical = await this.checkTechnicalSEO();
if (spinner) spinner.text = 'Analyzing content SEO...';
this.results.content = await this.checkContentSEO();
if (spinner) spinner.text = 'Validating social media SEO...';
this.results.social = await this.checkSocialSEO();
if (spinner) spinner.text = 'Measuring SEO performance factors...';
this.results.performance = await this.checkPerformanceSEO();
if (spinner) spinner.text = 'Calculating SEO score...';
this.results.overall = this.calculateOverallScore();
if (spinner) spinner.succeed('SEO analysis completed');
return this.results;
} catch (error) {
if (spinner) spinner.fail('SEO analysis failed');
throw error;
}
}
async checkTechnicalSEO() {
const issues = [];
const checks = {
robots: { found: false, valid: false },
sitemap: { found: false, valid: false },
canonical: { found: false, issues: [] },
structuredData: { found: false, valid: false },
https: { enabled: false },
mobileConfig: { found: false }
};
// Check robots.txt
try {
const robotsPath = path.join(this.projectPath, 'public', 'robots.txt');
const robotsContent = await fs.readFile(robotsPath, 'utf8');
checks.robots.found = true;
if (robotsContent.includes('User-agent:')) {
checks.robots.valid = true;
} else {
issues.push({
type: 'invalid-robots',
severity: 'medium',
message: 'robots.txt exists but appears to be invalid',
explanation: 'A valid robots.txt helps search engines understand which parts of your site to crawl',
recommendation: 'Add proper User-agent and crawling directives',
example: `User-agent: *\nAllow: /\nSitemap: https://yoursite.com/sitemap.xml`
});
}
if (!robotsContent.includes('Sitemap:')) {
issues.push({
type: 'missing-sitemap-ref',
severity: 'low',
message: 'robots.txt missing sitemap reference',
explanation: 'Including your sitemap URL in robots.txt helps search engines find it faster',
recommendation: 'Add Sitemap: directive to robots.txt',
example: 'Sitemap: https://yoursite.com/sitemap.xml'
});
}
} catch {
issues.push({
type: 'missing-robots',
severity: 'high',
message: 'No robots.txt file found',
explanation: 'robots.txt controls how search engines crawl your site. Without it, you have no control over crawling behavior',
recommendation: 'Create a robots.txt file in your public directory',
impact: 'Search engines may crawl pages you don\'t want indexed',
example: `User-agent: *\nAllow: /\n\n# Block admin pages\nDisallow: /admin/\nDisallow: /api/\n\nSitemap: https://yoursite.com/sitemap.xml`
});
}
// Check sitemap.xml
try {
const sitemapPath = path.join(this.projectPath, 'public', 'sitemap.xml');
const sitemapContent = await fs.readFile(sitemapPath, 'utf8');
checks.sitemap.found = true;
if (sitemapContent.includes('<urlset') && sitemapContent.includes('<url>')) {
checks.sitemap.valid = true;
// Check for important sitemap elements
if (!sitemapContent.includes('<lastmod>')) {
issues.push({
type: 'sitemap-no-lastmod',
severity: 'low',
message: 'Sitemap missing lastmod dates',
explanation: 'Last modification dates help search engines prioritize crawling updated content',
recommendation: 'Add <lastmod> tags to your sitemap entries',
example: '<lastmod>2024-01-20T09:00:00+00:00</lastmod>'
});
}
if (!sitemapContent.includes('<priority>')) {
issues.push({
type: 'sitemap-no-priority',
severity: 'low',
message: 'Sitemap missing priority values',
explanation: 'Priority helps search engines understand the relative importance of your pages',
recommendation: 'Add <priority> tags (0.0-1.0) to important pages',
example: '<priority>0.8</priority>'
});
}
} else {
checks.sitemap.valid = false;
issues.push({
type: 'invalid-sitemap',
severity: 'high',
message: 'sitemap.xml exists but appears to be invalid',
explanation: 'An invalid sitemap prevents search engines from discovering all your pages',
recommendation: 'Ensure sitemap follows XML sitemap protocol',
example: `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://yoursite.com/</loc>
<lastmod>2024-01-20</lastmod>
<priority>1.0</priority>
</url>
</urlset>`
});
}
} catch {
issues.push({
type: 'missing-sitemap',
severity: 'high',
message: 'No sitemap.xml file found',
explanation: 'Sitemaps help search engines discover and index all your important pages efficiently',
recommendation: 'Generate a sitemap.xml file listing all public pages',
impact: 'Search engines may miss important pages, leading to incomplete indexing',
tools: ['next-sitemap', 'sitemap.js', 'gatsby-plugin-sitemap']
});
}
// Check for structured data
const htmlFiles = await this.findFiles(['.html', '.jsx', '.tsx']);
let structuredDataFound = false;
for (const file of htmlFiles.slice(0, 10)) {
try {
const content = await fs.readFile(file, 'utf8');
if (content.includes('application/ld+json') || content.includes('itemscope') || content.includes('itemtype')) {
structuredDataFound = true;
checks.structuredData.found = true;
break;
}
} catch {}
}
if (!structuredDataFound) {
issues.push({
type: 'missing-structured-data',
severity: 'medium',
message: 'No structured data (Schema.org) found',
explanation: 'Structured data helps search engines understand your content and can enable rich snippets in search results',
recommendation: 'Add JSON-LD structured data to your pages',
impact: 'Missing out on rich snippets, knowledge graph inclusion, and better SERP visibility',
example: `<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Your Site Name",
"url": "https://yoursite.com",
"potentialAction": {
"@type": "SearchAction",
"target": "https://yoursite.com/search?q={search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>`
});
}
// Check for SSL/HTTPS configuration
try {
const packageJson = JSON.parse(await fs.readFile(path.join(this.projectPath, 'package.json'), 'utf8'));
if (packageJson.homepage && packageJson.homepage.startsWith('https://')) {
checks.https.enabled = true;
} else if (packageJson.homepage && packageJson.homepage.startsWith('http://')) {
issues.push({
type: 'no-https',
severity: 'high',
message: 'Site not using HTTPS',
explanation: 'HTTPS is a ranking factor and required for many modern web features',
recommendation: 'Enable SSL/TLS certificate for your domain',
impact: 'Lower search rankings, security warnings in browsers, reduced user trust'
});
}
} catch {}
// Check for mobile configuration
for (const file of htmlFiles.slice(0, 10)) {
try {
const content = await fs.readFile(file, 'utf8');
if (content.includes('viewport') && content.includes('width=device-width')) {
checks.mobileConfig.found = true;
break;
}
} catch {}
}
if (!checks.mobileConfig.found) {
issues.push({
type: 'missing-viewport',
severity: 'high',
message: 'No mobile viewport meta tag found',
explanation: 'Viewport meta tag is essential for mobile responsiveness, a key ranking factor',
recommendation: 'Add viewport meta tag to all HTML pages',
impact: 'Poor mobile experience, lower mobile search rankings',
example: '<meta name="viewport" content="width=device-width, initial-scale=1.0">'
});
}
return {
status: issues.filter(i => i.severity === 'high').length > 2 ? 'failed' :
issues.length > 5 ? 'warning' : 'passed',
message: `Found ${issues.length} technical SEO issues`,
score: this.calculateTechnicalScore(checks, issues),
checks,
issues,
summary: {
critical: issues.filter(i => i.severity === 'critical').length,
high: issues.filter(i => i.severity === 'high').length,
medium: issues.filter(i => i.severity === 'medium').length,
low: issues.filter(i => i.severity === 'low').length
}
};
}
async checkContentSEO() {
const issues = [];
const htmlFiles = await this.findFiles(['.html', '.jsx', '.tsx', '.vue', '.svelte']);
const contentStats = {
pagesAnalyzed: 0,
titlesFound: 0,
descriptionsFound: 0,
h1Found: 0,
imagesWithAlt: 0,
totalImages: 0,
headingHierarchy: { correct: 0, incorrect: 0 }
};
for (const file of htmlFiles.slice(0, 30)) {
try {
const content = await fs.readFile(file, 'utf8');
const relativePath = path.relative(this.projectPath, file);
contentStats.pagesAnalyzed++;
// Check title tag
const titleMatch = content.match(/<title[^>]*>([^<]+)<\/title>/i) ||
content.match(/title\s*[:=]\s*['"`]([^'"`]+)['"`]/i);
if (titleMatch) {
contentStats.titlesFound++;
const title = titleMatch[1];
if (title.length < 30) {
issues.push({
type: 'short-title',
severity: 'medium',
file: relativePath,
message: `Title too short (${title.length} chars): "${title}"`,
explanation: 'Short titles don\'t utilize the full ~60 character limit in SERPs',
recommendation: 'Expand title to 50-60 characters with relevant keywords',
example: 'Original: "Home" ā Better: "YourBrand - Professional Web Development Services"'
});
} else if (title.length > 60) {
issues.push({
type: 'long-title',
severity: 'medium',
file: relativePath,
message: `Title too long (${title.length} chars): "${title}"`,
explanation: 'Titles over 60 characters get truncated in search results',
recommendation: 'Shorten title to under 60 characters while keeping main keywords',
impact: 'Important keywords might be cut off in search results'
});
}
if (title.toLowerCase() === 'untitled' || title.toLowerCase() === 'react app') {
issues.push({
type: 'generic-title',
severity: 'high',
file: relativePath,
message: `Generic title found: "${title}"`,
explanation: 'Generic titles provide no value for SEO or user understanding',
recommendation: 'Use descriptive, keyword-rich titles unique to each page',
impact: 'Poor click-through rates, missed ranking opportunities'
});
}
} else {
issues.push({
type: 'missing-title',
severity: 'critical',
file: relativePath,
message: 'No title tag found',
explanation: 'Title tags are the most important on-page SEO element',
recommendation: 'Add a unique, descriptive title tag to every page',
impact: 'Page may not appear properly in search results',
example: '<title>Your Page Title - Brand Name</title>'
});
}
// Check meta description
const descMatch = content.match(/meta\s+name=["']description["']\s+content=["']([^"']+)["']/i) ||
content.match(/description\s*[:=]\s*['"`]([^'"`]+)['"`]/i);
if (descMatch) {
contentStats.descriptionsFound++;
const description = descMatch[1];
if (description.length < 120) {
issues.push({
type: 'short-description',
severity: 'low',
file: relativePath,
message: `Description too short (${description.length} chars)`,
explanation: 'Short descriptions don\'t fully utilize the ~160 character limit',
recommendation: 'Expand to 150-160 characters with compelling copy and keywords',
example: 'Include a call-to-action and unique value proposition'
});
} else if (description.length > 160) {
issues.push({
type: 'long-description',
severity: 'low',
file: relativePath,
message: `Description too long (${description.length} chars)`,
explanation: 'Descriptions over 160 characters get truncated in search results',
recommendation: 'Shorten to under 160 characters while maintaining clarity'
});
}
} else {
issues.push({
type: 'missing-description',
severity: 'high',
file: relativePath,
message: 'No meta description found',
explanation: 'Meta descriptions influence click-through rates from search results',
recommendation: 'Add compelling meta descriptions to improve CTR',
impact: 'Search engines will auto-generate snippets, often poorly',
example: '<meta name="description" content="Your compelling 150-160 character description with keywords and call-to-action.">'
});
}
// Check heading structure
const h1Matches = content.match(/<h1[^>]*>/gi) || [];
const h2Matches = content.match(/<h2[^>]*>/gi) || [];
const h3Matches = content.match(/<h3[^>]*>/gi) || [];
if (h1Matches.length > 0) {
contentStats.h1Found++;
if (h1Matches.length > 1) {
issues.push({
type: 'multiple-h1',
severity: 'medium',
file: relativePath,
message: `Multiple H1 tags found (${h1Matches.length})`,
explanation: 'Each page should have exactly one H1 tag for clear content hierarchy',
recommendation: 'Use only one H1 per page, use H2-H6 for subsections',
impact: 'Confuses search engines about the main topic of the page'
});
}
} else if (relativePath.includes('index') || relativePath.includes('home')) {
issues.push({
type: 'missing-h1',
severity: 'high',
file: relativePath,
message: 'No H1 tag found',
explanation: 'H1 tags signal the main topic of your page to search engines',
recommendation: 'Add a descriptive H1 tag that includes target keywords',
example: '<h1>Your Main Page Heading with Primary Keywords</h1>'
});
}
// Check for proper heading hierarchy
if (h3Matches.length > 0 && h2Matches.length === 0) {
contentStats.headingHierarchy.incorrect++;
issues.push({
type: 'broken-heading-hierarchy',
severity: 'low',
file: relativePath,
message: 'H3 found without H2 - broken heading hierarchy',
explanation: 'Proper heading hierarchy (H1āH2āH3) helps search engines understand content structure',
recommendation: 'Use headings in sequential order without skipping levels'
});
} else if (h2Matches.length > 0) {
contentStats.headingHierarchy.correct++;
}
// Check images for alt text
const imgMatches = content.match(/<img[^>]+>/gi) || [];
contentStats.totalImages += imgMatches.length;
for (const img of imgMatches) {
if (img.includes('alt=')) {
contentStats.imagesWithAlt++;
// Check for empty alt text
if (img.match(/alt=["'][\s]*["']/)) {
issues.push({
type: 'empty-alt-text',
severity: 'medium',
file: relativePath,
message: 'Image with empty alt text found',
explanation: 'Empty alt text provides no value for accessibility or SEO',
recommendation: 'Add descriptive alt text or use alt="" only for decorative images'
});
}
} else {
issues.push({
type: 'missing-alt-text',
severity: 'medium',
file: relativePath,
message: 'Image missing alt text',
explanation: 'Alt text improves accessibility and helps search engines understand images',
recommendation: 'Add descriptive alt text to all informational images',
example: '<img src="product.jpg" alt="Blue ceramic coffee mug with company logo">'
});
}
}
// Check for keyword stuffing indicators
const textContent = content.replace(/<[^>]+>/g, ' ').toLowerCase();
const words = textContent.split(/\s+/);
const wordFrequency = {};
words.forEach(word => {
if (word.length > 3) {
wordFrequency[word] = (wordFrequency[word] || 0) + 1;
}
});
const suspiciousWords = Object.entries(wordFrequency)
.filter(([word, count]) => count > 20 && count / words.length > 0.03)
.sort((a, b) => b[1] - a[1]);
if (suspiciousWords.length > 0) {
issues.push({
type: 'keyword-stuffing',
severity: 'medium',
file: relativePath,
message: `Possible keyword stuffing: "${suspiciousWords[0][0]}" appears ${suspiciousWords[0][1]} times`,
explanation: 'Overusing keywords can result in search engine penalties',
recommendation: 'Use keywords naturally, focus on readability and user value',
impact: 'Risk of algorithmic penalties, poor user experience'
});
}
} catch (error) {
// Continue with next file
}
}
const score = this.calculateContentScore(contentStats, issues);
return {
status: issues.filter(i => i.severity === 'critical' || i.severity === 'high').length > 5 ? 'failed' :
issues.length > 10 ? 'warning' : 'passed',
message: `Analyzed ${contentStats.pagesAnalyzed} pages, found ${issues.length} content SEO issues`,
score,
stats: contentStats,
issues: issues.slice(0, 20),
summary: {
critical: issues.filter(i => i.severity === 'critical').length,
high: issues.filter(i => i.severity === 'high').length,
medium: issues.filter(i => i.severity === 'medium').length,
low: issues.filter(i => i.severity === 'low').length
},
recommendations: this.getContentRecommendations(contentStats, issues)
};
}
async checkSocialSEO() {
const issues = [];
const htmlFiles = await this.findFiles(['.html', '.jsx', '.tsx']);
const socialStats = {
ogTags: { found: 0, complete: 0 },
twitterCards: { found: 0, complete: 0 },
pagesChecked: 0
};
for (const file of htmlFiles.slice(0, 20)) {
try {
const content = await fs.readFile(file, 'utf8');
const relativePath = path.relative(this.projectPath, file);
socialStats.pagesChecked++;
// Check Open Graph tags
const ogTitle = content.includes('og:title');
const ogDescription = content.includes('og:description');
const ogImage = content.includes('og:image');
const ogUrl = content.includes('og:url');
const ogType = content.includes('og:type');
if (ogTitle || ogDescription || ogImage) {
socialStats.ogTags.found++;
if (ogTitle && ogDescription && ogImage && ogUrl) {
socialStats.ogTags.complete++;
} else {
const missing = [];
if (!ogTitle) missing.push('og:title');
if (!ogDescription) missing.push('og:description');
if (!ogImage) missing.push('og:image');
if (!ogUrl) missing.push('og:url');
issues.push({
type: 'incomplete-og-tags',
severity: 'medium',
file: relativePath,
message: `Missing Open Graph tags: ${missing.join(', ')}`,
explanation: 'Complete OG tags ensure your content looks great when shared on social media',
recommendation: 'Add all essential OG tags for better social sharing',
example: `<meta property="og:title" content="Your Page Title">
<meta property="og:description" content="Page description">
<meta property="og:image" content="https://yoursite.com/image.jpg">
<meta property="og:url" content="https://yoursite.com/page">`
});
}
if (ogImage && !content.includes('og:image:width')) {
issues.push({
type: 'missing-og-image-dimensions',
severity: 'low',
file: relativePath,
message: 'OG image dimensions not specified',
explanation: 'Specifying image dimensions helps social platforms render previews faster',
recommendation: 'Add og:image:width and og:image:height tags',
example: `<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">`
});
}
} else if (relativePath.includes('index') || relativePath.includes('home')) {
issues.push({
type: 'missing-og-tags',
severity: 'high',
file: relativePath,
message: 'No Open Graph tags found',
explanation: 'OG tags control how your pages appear when shared on Facebook, LinkedIn, and other platforms',
recommendation: 'Add Open Graph meta tags to all important pages',
impact: 'Poor social media presence, missed viral potential'
});
}
// Check Twitter Card tags
const twitterCard = content.includes('twitter:card');
const twitterTitle = content.includes('twitter:title');
const twitterDescription = content.includes('twitter:description');
const twitterImage = content.includes('twitter:image');
if (twitterCard || twitterTitle) {
socialStats.twitterCards.found++;
if (twitterCard && twitterTitle && twitterDescription && twitterImage) {
socialStats.twitterCards.complete++;
} else {
const missing = [];
if (!twitterCard) missing.push('twitter:card');
if (!twitterTitle) missing.push('twitter:title');
if (!twitterDescription) missing.push('twitter:description');
if (!twitterImage) missing.push('twitter:image');
issues.push({
type: 'incomplete-twitter-cards',
severity: 'low',
file: relativePath,
message: `Missing Twitter Card tags: ${missing.join(', ')}`,
explanation: 'Twitter Cards enhance how your content appears on Twitter/X',
recommendation: 'Add complete Twitter Card metadata',
example: `<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Your Title">
<meta name="twitter:description" content="Your Description">
<meta name="twitter:image" content="https://yoursite.com/twitter-image.jpg">`
});
}
}
// Check for social sharing optimization
if (!content.includes('article:author') && (content.includes('article') || content.includes('blog'))) {
issues.push({
type: 'missing-article-metadata',
severity: 'low',
file: relativePath,
message: 'Article metadata missing for blog/article content',
explanation: 'Article metadata improves how blog posts appear in social feeds',
recommendation: 'Add article:author, article:published_time for blog posts',
example: `<meta property="article:author" content="Author Name">
<meta property="article:published_time" content="2024-01-20T08:00:00+00:00">`
});
}
} catch {}
}
const score = this.calculateSocialScore(socialStats, issues);
return {
status: socialStats.ogTags.found === 0 ? 'failed' :
socialStats.ogTags.complete < socialStats.pagesChecked / 2 ? 'warning' : 'passed',
message: `Checked ${socialStats.pagesChecked} pages for social SEO`,
score,
stats: socialStats,
issues,
summary: {
high: issues.filter(i => i.severity === 'high').length,
medium: issues.filter(i => i.severity === 'medium').length,
low: issues.filter(i => i.severity === 'low').length
},
recommendations: [
'Implement Open Graph tags on all public-facing pages',
'Add Twitter Card tags for better Twitter/X visibility',
'Use 1200x630px images for optimal social sharing',
'Test social previews with Facebook Debugger and Twitter Card Validator'
]
};
}
async checkPerformanceSEO() {
const issues = [];
const performanceFactors = {
largeImages: [],
renderBlocking: [],
noLazyLoad: 0,
noCaching: 0,
noCompression: 0
};
// Check for large images
const imageFiles = await this.findFiles(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
for (const imagePath of imageFiles.slice(0, 50)) {
try {
const stats = await fs.stat(imagePath);
const sizeInKB = stats.size / 1024;
if (sizeInKB > 500) {
performanceFactors.largeImages.push({
file: path.relative(this.projectPath, imagePath),
size: sizeInKB
});
issues.push({
type: 'large-image',
severity: sizeInKB > 1000 ? 'high' : 'medium',
file: path.relative(this.projectPath, imagePath),
message: `Large image file: ${Math.round(sizeInKB)} KB`,
explanation: 'Large images slow down page load, affecting SEO rankings',
recommendation: 'Compress images and use modern formats like WebP',
tools: ['imagemin', 'squoosh', 'sharp'],
impact: 'Slower page loads, higher bounce rates, lower Core Web Vitals scores'
});
}
// Check if it's not WebP format
if (!imagePath.endsWith('.webp') && sizeInKB > 100) {
issues.push({
type: 'non-webp-image',
severity: 'low',
file: path.relative(this.projectPath, imagePath),
message: 'Consider using WebP format',
explanation: 'WebP provides 25-35% better compression than JPEG/PNG',
recommendation: 'Convert images to WebP with fallbacks for older browsers',
example: `<picture>
<source srcset="image.webp" type="image/webp">
<img src="image.jpg" alt="Description">
</picture>`
});
}
} catch {}
}
// Check HTML files for performance issues
const htmlFiles = await this.findFiles(['.html', '.jsx', '.tsx']);
for (const file of htmlFiles.slice(0, 20)) {
try {
const content = await fs.readFile(file, 'utf8');
const relativePath = path.relative(this.projectPath, file);
// Check for render-blocking resources
const scriptTags = content.match(/<script[^>]*>/gi) || [];
const renderBlockingScripts = scriptTags.filter(tag =>
!tag.includes('async') && !tag.includes('defer') && tag.includes('src=')
);
if (renderBlockingScripts.length > 2) {
performanceFactors.renderBlocking.push(relativePath);
issues.push({
type: 'render-blocking-scripts',
severity: 'medium',
file: relativePath,
message: `${renderBlockingScripts.length} render-blocking scripts found`,
explanation: 'Render-blocking resources delay First Contentful Paint, affecting SEO',
recommendation: 'Add async or defer attributes to non-critical scripts',
example: '<script src="script.js" defer></script>',
impact: 'Lower PageSpeed scores, poor Core Web Vitals'
});
}
// Check for lazy loading
const imgTags = content.match(/<img[^>]*>/gi) || [];
const lazyImages = imgTags.filter(tag => tag.includes('loading='));
if (imgTags.length > 5 && lazyImages.length === 0) {
performanceFactors.noLazyLoad++;
issues.push({
type: 'no-lazy-loading',
severity: 'medium',
file: relativePath,
message: 'Images not using lazy loading',
explanation: 'Lazy loading improves initial page load and Core Web Vitals',
recommendation: 'Add loading="lazy" to below-the-fold images',
example: '<img src="image.jpg" loading="lazy" alt="Description">',
impact: 'Unnecessary bandwidth usage, slower initial page loads'
});
}
// Check for preconnect/prefetch
if (!content.includes('rel="preconnect"') &&
(content.includes('fonts.googleapis.com') || content.includes('cdn.'))) {
issues.push({
type: 'missing-preconnect',
severity: 'low',
file: relativePath,
message: 'Missing preconnect for external resources',
explanation: 'Preconnect hints speed up connections to third-party domains',
recommendation: 'Add preconnect links for critical third-party resources',
example: '<link rel="preconnect" href="https://fonts.googleapis.com">'
});
}
} catch {}
}
// Check for caching headers (in server config files)
const configFiles = ['.htaccess', 'nginx.conf', 'vercel.json', 'netlify.toml'];
let cachingConfigFound = false;
for (const configFile of configFiles) {
try {
const configPath = path.join(this.projectPath, configFile);
const content = await fs.readFile(configPath, 'utf8');
if (content.includes('cache') || content.includes('Cache-Control') || content.includes('max-age')) {
cachingConfigFound = true;
break;
}
} catch {}
}
if (!cachingConfigFound) {
performanceFactors.noCaching = 1;
issues.push({
type: 'no-caching-config',
severity: 'medium',
message: 'No caching configuration found',
explanation: 'Proper caching reduces server load and improves repeat visit performance',
recommendation: 'Configure Cache-Control headers for static assets',
example: `# .htaccess example
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpg "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType text/css "access plus 1 month"
</IfModule>`
});
}
const score = this.calculatePerformanceScore(performanceFactors, issues);
return {
status: issues.filter(i => i.severity === 'high').length > 3 ? 'failed' :
issues.length > 10 ? 'warning' : 'passed',
message: `Found ${issues.length} performance-related SEO issues`,
score,
factors: performanceFactors,
issues,
summary: {
high: issues.filter(i => i.severity === 'high').length,
medium: issues.filter(i => i.severity === 'medium').length,
low: issues.filter(i => i.severity === 'low').length
},
coreWebVitals: {
explanation: 'Core Web Vitals are now a ranking factor',
metrics: [
'LCP (Largest Contentful Paint): Aim for < 2.5s',
'FID (First Input Delay): Aim for < 100ms',
'CLS (Cumulative Layout Shift): Aim for < 0.1'
],
tools: ['PageSpeed Insights', 'Lighthouse', 'Web Vitals Extension']
}
};
}
calculateOverallScore() {
const weights = {
technical: 0.3,
content: 0.35,
social: 0.15,
performance: 0.2
};
const technicalScore = this.results.technical?.score || 0;
const contentScore = this.results.content?.score || 0;
const socialScore = this.results.social?.score || 0;
const performanceScore = this.results.performance?.score || 0;
const overallScore = Math.round(
technicalScore * weights.technical +
contentScore * weights.content +
socialScore * weights.social +
performanceScore * weights.performance
);
const totalIssues =
(this.results.technical?.issues?.length || 0) +
(this.results.content?.issues?.length || 0) +
(this.results.social?.issues?.length || 0) +
(this.results.performance?.issues?.length || 0);
const criticalIssues = [
...(this.results.technical?.issues || []),
...(this.results.content?.issues || []),
...(this.results.social?.issues || []),
...(this.results.performance?.issues || [])
].filter(i => i.severity === 'critical' || i.severity === 'high').length;
return {
score: overallScore,
grade: this.getGrade(overallScore),
status: overallScore >= 80 ? 'passed' : overallScore >= 60 ? 'warning' : 'failed',
message: `SEO Score: ${overallScore}/100 (${this.getGrade(overallScore)})`,
breakdown: {
technical: { score: technicalScore, weight: weights.technical },
content: { score: contentScore, weight: weights.content },
social: { score: socialScore, weight: weights.social },
performance: { score: performanceScore, weight: weights.performance }
},
summary: {
totalIssues,
criticalIssues,
topPriorities: this.getTopPriorities()
},
recommendations: this.getOverallRecommendations()
};
}
calculateTechnicalScore(checks, issues) {
let score = 100;
// Deduct points for missing critical elements
if (!checks.robots.found) score -= 15;
if (!checks.sitemap.found) score -= 15;
if (!checks.https.enabled) score -= 20;
if (!checks.mobileConfig.found) score -= 20;
if (!checks.structuredData.found) score -= 10;
// Deduct for issues based on severity
issues.forEach(issue => {
switch (issue.severity) {
case 'critical': score -= 10; break;
case 'high': score -= 5; break;
case 'medium': score -= 2; break;
case 'low': score -= 1; break;
}
});
return Math.max(0, Math.min(100, score));
}
calculateContentScore(stats, issues) {
let score = 100;
// Calculate ratios
const titleRatio = stats.pagesAnalyzed > 0 ? stats.titlesFound / stats.pagesAnalyzed : 0;
const descriptionRatio = stats.pagesAnalyzed > 0 ? stats.descriptionsFound / stats.pagesAnalyzed : 0;
const h1Ratio = stats.pagesAnalyzed > 0 ? stats.h1Found / stats.pagesAnalyzed : 0;
const altTextRatio = stats.totalImages > 0 ? stats.imagesWithAlt / stats.totalImages : 1;
// Deduct points based on ratios
score -= (1 - titleRatio) * 20;
score -= (1 - descriptionRatio) * 15;
score -= (1 - h1Ratio) * 15;
score -= (1 - altTextRatio) * 10;
// Deduct for issues
issues.forEach(issue => {
switch (issue.severity) {
case 'critical': score -= 8; break;
case 'high': score -= 4; break;
case 'medium': score -= 2; break;
case 'low': score -= 1; break;
}
});
return Math.max(0, Math.min(100, score));
}
calculateSocialScore(stats, issues) {
let score = 100;
const ogRatio = stats.pagesChecked > 0 ? stats.ogTags.found / stats.pagesChecked : 0;
const ogCompleteRatio = stats.ogTags.found > 0 ? stats.ogTags.complete / stats.ogTags.found : 0;
score -= (1 - ogRatio) * 30;
score -= (1 - ogCompleteRatio) * 20;
// Twitter cards are less critical
const twitterRatio = stats.pagesChecked > 0 ? stats.twitterCards.found / stats.pagesChecked : 0;
score -= (1 - twitterRatio) * 10;
issues.forEach(issue => {
switch (issue.severity) {
case 'high': score -= 5; break;
case 'medium': score -= 3; break;
case 'low': score -= 1; break;
}
});
return Math.max(0, Math.min(100, score));
}
calculatePerformanceScore(factors, issues) {
let score = 100;
// Large images have significant impact
score -= factors.largeImages.length * 3;
// Render blocking resources
score -= factors.renderBlocking.length * 2;
// Other factors
if (factors.noLazyLoad > 0) score -= 10;
if (factors.noCaching > 0) score -= 15;
issues.forEach(issue => {
switch (issue.severity) {
case 'high': score -= 5; break;
case 'medium': score -= 3; break;
case 'low': score -= 1; break;
}
});
return Math.max(0, Math.min(100, score));
}
getGrade(score) {
if (score >= 90) return 'A+';
if (score >= 85) return 'A';
if (score >= 80) return 'A-';
if (score >= 75) return 'B+';
if (score >= 70) return 'B';
if (score >= 65) return 'B-';
if (score >= 60) return 'C+';
if (score >= 55) return 'C';
if (score >= 50) return 'C-';
if (score >= 45) return 'D+';
if (score >= 40) return 'D';
return 'F';
}
getContentRecommendations(stats, issues) {
const recommendations = [];
if (stats.titlesFound < stats.pagesAnalyzed * 0.9) {
recommendations.push({
priority: 'high',
action: 'Add unique title tags to all pages',
impact: 'Significant improvement in search visibility and CTR'
});
}
if (stats.descriptionsFound < stats.pagesAnalyzed * 0.8) {
recommendations.push({
priority: 'high',
action: 'Write compelling meta descriptions for all pages',
impact: 'Better click-through rates from search results'
});
}
if (stats.imagesWithAlt < stats.totalImages * 0.9) {
recommendations.push({
priority: 'medium',
action: 'Add descriptive alt text to all images',
impact: 'Improved accessibility and image search rankings'
});
}
if (issues.some(i => i.type === 'keyword-stuffing')) {
recommendations.push({
priority: 'high',
action: 'Review and rewrite content to avoid keyword stuffing',
impact: 'Avoid potential search engine penalties'
});
}
return recommendations;
}
getTopPriorities() {
const priorities = [];
const allIssues = [
...(this.results.technical?.issues || []),
...(this.results.content?.issues || []),
...(this.results.social?.issues || []),
...(this.results.performance?.issues || [])
];
const criticalIssues = allIssues.filter(i => i.severity === 'critical');
const highIssues = allIssues.filter(i => i.severity === 'high');
if (criticalIssues.length > 0) {
priorities.push({
level: 'critical',
count: criticalIssues.length,
examples: criticalIssues.slice(0, 3).map(i => i.message)
});
}
if (highIssues.length > 0) {
priorities.push({
level: 'high',
count: highIssues.length,
examples: highIssues.slice(0, 3).map(i => i.message)
});
}
return priorities;
}
getOverallRecommendations() {
const recommendations = [];
const score = this.results.overall?.score || 0;
if (score < 60) {
recommendations.push({
priority: 'critical',
title: 'Foundation First',
description: 'Focus on technical SEO basics: robots.txt, sitemap.xml, and meta tags',
timeframe: 'Immediate (1-2 days)',
impact: 'Essential for search engine visibility'
});
}
if (!this.results.technical?.checks?.robots?.found) {
recommendations.push({
priority: 'high',
title: 'Create robots.txt',
description: 'Add a robots.txt file to control search engine crawling',
timeframe: '1 hour',
impact: 'Prevents indexing of unwanted pages'
});
}
if (!this.results.technical?.checks?.sitemap?.found) {
recommendations.push({
priority: 'high',
title: 'Generate XML Sitemap',
description: 'Create and submit an XML sitemap to search engines',
timeframe: '2-3 hours',
impact: 'Ensures all pages are discovered and indexed'
});
}
if (this.results.content?.stats?.titlesFound < this.results.content?.stats?.pagesAnalyzed * 0.8) {
recommendations.push({
priority: 'high',
title: 'Optimize Page Titles',
description: 'Write unique, keyword-rich titles for all pages (50-60 chars)',
timeframe: '1-2 days',
impact: 'Major impact on rankings and click-through rates'
});
}
if (this.results.performance?.factors?.largeImages?.length > 5) {
recommendations.push({
priority: 'medium',
title: 'Optimize Images',
description: 'Compress large images and implement lazy loading',
timeframe: '1 day',
impact: 'Faster page loads, better Core Web Vitals'
});
}
if (!this.results.social?.stats?.ogTags?.found) {
recommendations.push({
priority: 'medium',
title: 'Add Social Meta Tags',
description: 'Implement Open Graph and Twitter Card tags',
timeframe: '3-4 hours',
impact: 'Better social media visibility and engagement'
});
}
// Sort by priority
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
recommendations.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
return recommendations.slice(0, 10); // Top 10 recommendations
}
async findFiles(extensions) {
const files = [];
async function scan(dir) {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory() &&
!entry.name.startsWith('.') &&
entry.name !== 'node_modules' &&
entry.name !== 'dist' &&
entry.name !== 'build' &&
entry.name !== '.git' &&
entry.name !== 'coverage') {
await scan(fullPath);
} else if (entry.isFile() && extensions.some(ext => entry.name.endsWith(ext))) {
files.push(fullPath);
}
}
} catch {}
}
await scan(this.projectPath);
return files;
}
formatResults() {
const sections = [];
const colors = {
passed: chalk.green,
warning: chalk.yellow,
failed: chalk.red,
critical: chalk.bgRed.white,
high: chalk.red,
medium: chalk.yellow,
low: chalk.gray
};
// Overall score
if (this.results.overall) {
const { score, grade, status } = this.results.overall;
const color = colors[status] || chalk.white;
sections.push(color.bold(`\nšÆ SEO Score: ${score}/100 (${grade})\n`));
}
// Category summaries
['technical', 'content', 'social', 'performance'].forEach(category => {
const result = this.results[category];
if (!result) return;
const icon = result.status === 'passed' ? 'ā
' :
result.status === 'failed' ? 'ā' : 'ā ļø';
const color = colors[result.status] || chalk.white;
sections.push(color(`${icon} ${category.toUpperCase()}: ${result.message}`));
if (result.score !== undefined) {
sections.push(chalk.gray(` Score: ${result.score}/100`));
}
// Show top issues
if (result.issues && result.issues.length > 0) {
const topIssues = result.issues
.filter(i => i.severity === 'critical' || i.severity === 'high')
.slice(0, 3);
topIssues.forEach(issue => {
const issueColor = colors[issue.severity] || chalk.white;
sections.push(issueColor(` ⢠${issue.message}`));
if (issue.recommendation) {
sections.push(chalk.gray(` ā ${issue.recommendation}`));
}
});
}
});
// Top recommendations
if (this.results.overall?.recommendations?.length > 0) {
sections.push(chalk.bold('\nš Top Recommendations:'));
this.results.overall.recommendations.slice(0, 5).forEach((rec, i) => {
const color = colors[rec.priority] || chalk.white;
sections.push(color(`${i + 1}. ${rec.title}`));
sections.push(chalk.gray(` ${rec.description}`));
sections.push(chalk.gray(` ā±ļø ${rec.timeframe} | š” ${rec.impact}\n`));
});
}
return sections.join('\n');
}
}