vibe-seo
Version:
AI-friendly SEO generator for modern web frameworks
1,513 lines (1,289 loc) • 66.7 kB
JavaScript
const fs = require('fs-extra');
const path = require('path');
const Mustache = require('mustache');
/**
* Generate sitemap.xml
*/
async function generateSitemap(pages, config, outputDir) {
// Validate required config fields
if (!config.site?.url) {
throw new Error('Missing required config field: site.url. Please configure your site URL in the config file.');
}
console.log(`\nGenerating sitemap.xml for: "${config.site.name || 'Your Site'}"`);
console.log(` Base URL: "${config.site.url}"`);
console.log(` Pages to include: ${pages.length}`);
const sitemapTemplate = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"{{#hasImages}} xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"{{/hasImages}}{{#hasHreflangs}} xmlns:xhtml="http://www.w3.org/1999/xhtml"{{/hasHreflangs}}>
{{#pages}}
<url>
<loc>{{{siteUrl}}}{{{url}}}</loc>
<lastmod>{{{lastmod}}}</lastmod>
<changefreq>{{{changefreq}}}</changefreq>
<priority>{{{priority}}}</priority>
{{#hreflangs}}
<xhtml:link rel="alternate" hreflang="{{{code}}}" href="{{{url}}}"/>
{{/hreflangs}}
{{#hasHreflangs}}
<xhtml:link rel="alternate" hreflang="x-default" href="{{{defaultUrl}}}"/>
{{/hasHreflangs}}
{{#images}}
<image:image>
<image:loc>{{{.}}}</image:loc>
</image:image>
{{/images}}
</url>
{{/pages}}
</urlset>`;
const hasMultipleLanguages = config.languages?.supported && config.languages.supported.length > 1;
const defaultLanguageUrl = getDefaultLanguageUrl(config);
const sitemapData = {
siteUrl: config.site.url.replace(/\/$/, ''),
hasImages: pages.some(page => page.images && page.images.length > 0),
hasHreflangs: hasMultipleLanguages,
pages: pages.map(page => {
const pageHreflangs = generateHreflangs(page, config);
const pageDefaultUrl = hasMultipleLanguages ?
(pageHreflangs.find(h => h.code === config.languages.default)?.url ||
`${defaultLanguageUrl}${page.url === '/' ? '' : page.url}`) :
undefined;
return {
...page,
changefreq: page.changefreq || config.sitemap?.changefreq || 'weekly',
priority: page.priority || config.sitemap?.priority || '0.8',
lastmod: page.lastmod || new Date().toISOString(),
hreflangs: pageHreflangs,
hasHreflangs: pageHreflangs.length > 0,
defaultUrl: pageDefaultUrl
};
})
};
const sitemapXml = Mustache.render(sitemapTemplate, sitemapData);
// Write to output directory
const outputPath = path.join(outputDir, 'sitemap.xml');
await fs.ensureDir(outputDir);
await fs.writeFile(outputPath, sitemapXml);
// Also write to public directory if configured
if (config.paths?.publicDir) {
const publicPath = path.join(config.paths.publicDir, 'sitemap.xml');
await fs.ensureDir(path.dirname(publicPath));
await fs.writeFile(publicPath, sitemapXml);
}
return outputPath;
}
/**
* Generate robots.txt with AI-friendly bot support
*/
async function generateRobots(config, outputDir) {
// Validate required config fields
if (!config.site?.name) {
throw new Error('Missing required config field: site.name. Please configure your site name in the config file.');
}
if (!config.site?.url) {
throw new Error('Missing required config field: site.url. Please configure your site URL in the config file.');
}
console.log(`\nGenerating robots.txt for: "${config.site.name}"`);
console.log(` Site URL: "${config.site.url}"`);
console.log(` Sitemap URL: "${config.site.url.replace(/\/$/, '')}/sitemap.xml"`);
const robotsTemplate = `# Robots.txt for {{{siteName}}}
# Generated by vibe-seo
User-agent: *
{{#disallowPaths}}
Disallow: {{{.}}}
{{/disallowPaths}}
{{^disallowPaths}}
Allow: /
{{/disallowPaths}}
{{#crawlDelay}}
Crawl-delay: {{{crawlDelay}}}
{{/crawlDelay}}
# AI-friendly crawlers
{{#aiBots}}
User-agent: {{{name}}}
{{#allow}}
Allow: /
{{/allow}}
{{#disallow}}
Disallow: {{{.}}}
{{/disallow}}
{{#crawlDelay}}
Crawl-delay: {{{crawlDelay}}}
{{/crawlDelay}}
{{/aiBots}}
# Search engine crawlers
{{#searchBots}}
User-agent: {{{name}}}
{{#allow}}
Allow: /
{{/allow}}
{{#disallow}}
Disallow: {{{.}}}
{{/disallow}}
{{/searchBots}}
# Sitemap location
Sitemap: {{{siteUrl}}}/sitemap.xml
# Additional sitemaps
{{#additionalSitemaps}}
Sitemap: {{{.}}}
{{/additionalSitemaps}}`;
const allowedBots = config.bots?.allow || [];
const disallowedBots = config.bots?.disallow || [];
// Categorize bots
const aiBots = allowedBots.filter(bot =>
['GPTBot', 'PerplexityBot', 'ClaudeBot', 'Meta-ExternalAgent', 'Applebot', 'YouBot'].includes(bot)
);
const searchBots = allowedBots.filter(bot =>
['Googlebot', 'Bingbot', 'DuckDuckBot', 'YandexBot'].includes(bot)
);
const robotsData = {
siteName: config.site.name,
siteUrl: config.site.url.replace(/\/$/, ''),
disallowPaths: config.sitemap?.excludePaths || [],
crawlDelay: config.bots?.crawlDelay,
aiBots: aiBots.map(name => ({
name,
allow: !disallowedBots.includes(name),
disallow: disallowedBots.includes(name) ? ['/'] : [],
crawlDelay: config.bots?.crawlDelay
})),
searchBots: searchBots.map(name => ({
name,
allow: !disallowedBots.includes(name),
disallow: disallowedBots.includes(name) ? ['/'] : []
})),
additionalSitemaps: config.additionalSitemaps || []
};
const robotsTxt = Mustache.render(robotsTemplate, robotsData);
// Write to output directory
const outputPath = path.join(outputDir, 'robots.txt');
await fs.ensureDir(outputDir);
await fs.writeFile(outputPath, robotsTxt);
// Also write to public directory if configured
if (config.paths?.publicDir) {
const publicPath = path.join(config.paths.publicDir, 'robots.txt');
await fs.ensureDir(path.dirname(publicPath));
await fs.writeFile(publicPath, robotsTxt);
}
return outputPath;
}
/**
* Generate meta tags for pages
*/
async function generateMetaTags(pages, config, outputDir) {
// Validate required config fields
if (!config.site?.name) {
throw new Error('Missing required config field: site.name. Please configure your site name in the config file.');
}
if (!config.site?.url) {
throw new Error('Missing required config field: site.url. Please configure your site URL in the config file.');
}
console.log(`\nGenerating meta tags using site configuration:`);
console.log(` Site Name: "${config.site.name}"`);
console.log(` Site URL: "${config.site.url}"`);
console.log(` Site Description: "${config.site.description || 'Not set'}"`);
console.log(` Title Template: "${config.seo?.titleTemplate || '{title} | {siteName}'}"`);
console.log(` Description Template: "${config.seo?.descriptionTemplate || '{description}'}"`);
const metaTemplate = `<!-- SEO Meta Tags for {{{url}}} -->
<!-- Generated by vibe-seo -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Primary Meta Tags -->
<title>{{title}}</title>
<meta name="title" content="{{title}}">
<meta name="description" content="{{description}}">
{{#keywords}}
<meta name="keywords" content="{{keywords}}">
{{/keywords}}
{{#author}}
<meta name="author" content="{{author}}">
{{/author}}
<!-- Search Engine Verification -->
{{#verification}}
{{#google}}
<meta name="google-site-verification" content="{{{google}}}">
{{/google}}
{{#bing}}
<meta name="msvalidate.01" content="{{{bing}}}">
{{/bing}}
{{#yandex}}
<meta name="yandex-verification" content="{{{yandex}}}">
{{/yandex}}
{{#pinterest}}
<meta name="p:domain_verify" content="{{{pinterest}}}">
{{/pinterest}}
{{/verification}}
<!-- Language and Internationalization -->
{{#hreflangs}}
<link rel="alternate" hreflang="{{{code}}}" href="{{{url}}}">
{{/hreflangs}}
{{#hasMultipleLanguages}}
<link rel="alternate" hreflang="x-default" href="{{{defaultLanguageUrl}}}">
{{/hasMultipleLanguages}}
<!-- Open Graph / Facebook -->
<meta property="og:type" content="{{ogType}}">
<meta property="og:url" content="{{{canonicalUrl}}}">
<meta property="og:title" content="{{title}}">
<meta property="og:description" content="{{description}}">
{{#ogImage}}
<meta property="og:image" content="{{{ogImage}}}">
{{/ogImage}}
<meta property="og:site_name" content="{{siteName}}">
{{#locale}}
<meta property="og:locale" content="{{locale}}">
{{/locale}}
{{#alternateLocales}}
<meta property="og:locale:alternate" content="{{.}}">
{{/alternateLocales}}
<!-- Twitter -->
<meta property="twitter:card" content="{{twitterCard}}">
<meta property="twitter:url" content="{{{canonicalUrl}}}">
<meta property="twitter:title" content="{{title}}">
<meta property="twitter:description" content="{{description}}">
{{#ogImage}}
<meta property="twitter:image" content="{{{ogImage}}}">
{{/ogImage}}
<!-- Canonical URL -->
<link rel="canonical" href="{{{canonicalUrl}}}">
<!-- Additional Meta Tags -->
<meta name="robots" content="index, follow">
<meta name="language" content="{{language}}">
{{#favicon}}
<link rel="icon" href="{{{favicon}}}">
{{/favicon}}`;
const metaTagsDir = path.join(outputDir, 'meta-tags');
await fs.ensureDir(metaTagsDir);
const generatedFiles = [];
for (const page of pages) {
const hreflangs = generateHreflangs(page, config);
const alternateLocales = generateAlternateLocales(config);
const hasMultipleLanguages = config.languages?.supported && config.languages.supported.length > 1;
const generatedTitle = generateTitle(page, config);
const generatedDescription = generateDescription(page, config);
console.log(`Generating meta tags for ${page.url}:`);
console.log(` Title: "${generatedTitle}"`);
console.log(` Description: "${generatedDescription}"`);
console.log(` Site: "${config.site.name}"`);
const pageData = {
url: page.url,
title: generatedTitle,
description: generatedDescription,
keywords: page.keywords || config.seo?.defaultKeywords,
author: config.site?.author || config.author,
canonicalUrl: `${config.site.url.replace(/\/$/, '')}${page.url}`,
siteName: config.site.name,
ogType: page.ogType || config.seo?.ogType || 'website',
ogImage: generateOgImage(page, config),
twitterCard: config.seo?.twitterCard || 'summary_large_image',
locale: config.site?.locale || 'en_US',
language: config.site?.language || 'en',
verification: config.verification,
favicon: config.site?.favicon || '/favicon.ico',
// Multilingual data
hreflangs: hreflangs,
alternateLocales: alternateLocales,
hasMultipleLanguages: hasMultipleLanguages,
defaultLanguageUrl: getDefaultLanguageUrl(config)
};
const metaHtml = Mustache.render(metaTemplate, pageData);
// Create filename from URL
const filename = page.url === '/' ? 'index.html' : `${page.url.replace(/^\//, '').replace(/\//g, '-')}.html`;
const filePath = path.join(metaTagsDir, filename);
await fs.writeFile(filePath, metaHtml);
generatedFiles.push(filePath);
}
console.log(`\n✅ Generated ${generatedFiles.length} meta tag files:`);
generatedFiles.forEach(file => {
console.log(` • ${path.basename(file)}`);
});
console.log(`\nExample title for homepage: "${generateTitle({url: '/', title: null}, config)}"`);
console.log(`Example description for homepage: "${generateDescription({url: '/', description: null}, config)}"`);
return generatedFiles;
}
/**
* Generate JSON-LD structured data
*/
async function generateJsonLd(pages, config, outputDir) {
const jsonLdDir = path.join(outputDir, 'jsonld');
await fs.ensureDir(jsonLdDir);
const generatedFiles = [];
// Organization schema
const organizationSchema = {
"@context": "https://schema.org",
"@type": "Organization",
"name": config.site.name,
"url": config.site.url,
"description": config.site.description,
"logo": config.site.logo ? `${config.site.url}${config.site.logo}` : undefined,
"sameAs": config.site.socialLinks || []
};
const orgPath = path.join(jsonLdDir, 'organization.json');
await fs.writeFile(orgPath, JSON.stringify(organizationSchema, null, 2));
generatedFiles.push(orgPath);
// Website schema
const websiteSchema = {
"@context": "https://schema.org",
"@type": "WebSite",
"name": config.site.name,
"url": config.site.url,
"description": config.site.description,
"publisher": {
"@type": "Organization",
"name": config.site.name
}
};
const websitePath = path.join(jsonLdDir, 'website.json');
await fs.writeFile(websitePath, JSON.stringify(websiteSchema, null, 2));
generatedFiles.push(websitePath);
// Page-specific schemas
for (const page of pages) {
const pageSchema = {
"@context": "https://schema.org",
"@type": "WebPage",
"name": generateTitle(page, config),
"description": generateDescription(page, config),
"url": `${config.site.url.replace(/\/$/, '')}${page.url}`,
"isPartOf": {
"@type": "WebSite",
"name": config.site.name,
"url": config.site.url
},
"inLanguage": config.site.language || 'en',
"dateModified": page.lastmod || new Date().toISOString()
};
const filename = page.url === '/' ? 'index.json' : `${page.url.replace(/^\//, '').replace(/\//g, '-')}.json`;
const filePath = path.join(jsonLdDir, filename);
await fs.writeFile(filePath, JSON.stringify(pageSchema, null, 2));
generatedFiles.push(filePath);
}
return generatedFiles;
}
/**
* Generate deployment instructions for Vite React applications
*/
async function generateViteReactDeploymentGuide(config, outputDir) {
const guideContent = `# Vite React Deployment Guide for vibe-seo
## Issue: Static Files Not Accessible
Your Vite React application is experiencing issues with static files (robots.txt, sitemap.xml) because:
1. **React Router Interception**: The catch-all route (\`path="*"\`) intercepts all requests
2. **MIME Type Issues**: robots.txt downloads instead of displaying due to incorrect MIME types
3. **Static File Serving**: Vite doesn't serve static files from public/ directory in development
## Solutions
### Solution 1: React Router Configuration (Recommended)
Update your App.tsx to handle static file requests:
\`\`\`tsx
import { Routes, Route, Navigate } from 'react-router-dom';
function App() {
// Handle static file requests
React.useEffect(() => {
const path = window.location.pathname;
if (path === '/robots.txt' || path === '/sitemap.xml') {
// Redirect to the actual file
window.location.replace(\`\${window.location.origin}\${path}\`);
}
}, []);
return (
<Routes>
{/* Your existing routes */}
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
{/* Catch-all route - but exclude static files */}
<Route path="*" element={<NotFound />} />
</Routes>
);
}
\`\`\`
### Solution 2: Vite Configuration
Update your \`vite.config.ts\`:
\`\`\`typescript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
export default defineConfig({
plugins: [react()],
server: {
// Ensure static files are served correctly
fs: {
allow: ['..']
}
},
build: {
rollupOptions: {
output: {
// Ensure static files are copied to build output
assetFileNames: (assetInfo) => {
if (assetInfo.name === 'robots.txt' || assetInfo.name === 'sitemap.xml') {
return '[name][extname]';
}
return 'assets/[name]-[hash][extname]';
}
}
}
},
// Ensure static files are served with correct MIME types
assetsInclude: ['**/*.txt', '**/*.xml']
});
\`\`\`
### Solution 3: Public Directory Structure
Ensure your files are in the correct location:
\`\`\`
your-project/
├── public/
│ ├── robots.txt # Generated by vibe-seo
│ ├── sitemap.xml # Generated by vibe-seo
│ └── index.html
├── src/
│ └── App.tsx
└── vite.config.ts
\`\`\`
### Solution 4: Server Configuration (Production)
For production deployments, ensure your server serves static files correctly:
**Vercel:**
Create \`vercel.json\`:
\`\`\`json
{
"headers": [
{
"source": "/robots.txt",
"headers": [
{
"key": "Content-Type",
"value": "text/plain"
}
]
},
{
"source": "/sitemap.xml",
"headers": [
{
"key": "Content-Type",
"value": "application/xml"
}
]
}
]
}
\`\`\`
**Netlify:**
Create \`netlify.toml\`:
\`\`\`toml
[[headers]]
for = "/robots.txt"
[headers.values]
Content-Type = "text/plain"
[[headers]]
for = "/sitemap.xml"
[headers.values]
Content-Type = "application/xml"
\`\`\`
## Testing
1. **Development**: \`npm run dev\`
- Test: http://localhost:5173/robots.txt
- Test: http://localhost:5173/sitemap.xml
2. **Production**: \`npm run build && npm run preview\`
- Test: http://localhost:4173/robots.txt
- Test: http://localhost:4173/sitemap.xml
## Troubleshooting
### If robots.txt still downloads:
- Check MIME type configuration in your hosting provider
- Ensure the file is in the public/ directory
- Verify server headers are set correctly
### If sitemap.xml returns 404:
- Ensure React Router isn't intercepting the request
- Check that the file exists in the public/ directory
- Verify the build process copies the file correctly
## vibe-seo Integration
After implementing the above solutions:
1. Run \`npx vibe-seo-gen all\` to generate files
2. Files will be placed in \`public/\` directory
3. Test accessibility of both files
4. Deploy to your hosting provider
## Additional Notes
- **Development**: Static files may not work in Vite dev server
- **Production**: Files should work correctly after proper configuration
- **Hosting**: Different providers may require specific configurations
- **Testing**: Always test in production build, not just development
For more help, visit: https://github.com/onecf/vibe-seo/issues
`;
const guidePath = path.join(outputDir, 'VITE_REACT_DEPLOYMENT_GUIDE.md');
await fs.writeFile(guidePath, guideContent);
console.log(`\n📋 Generated Vite React deployment guide: ${guidePath}`);
console.log(' This guide addresses the static file serving issues you encountered.');
return guidePath;
}
/**
* Generate Vite configuration template
*/
async function generateViteConfigTemplate(outputDir) {
const viteConfigContent = `import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
export default defineConfig({
plugins: [react()],
server: {
// Ensure static files are served correctly
fs: {
allow: ['..']
}
},
build: {
rollupOptions: {
output: {
// Ensure static files are copied to build output
assetFileNames: (assetInfo) => {
if (assetInfo.name === 'robots.txt' || assetInfo.name === 'sitemap.xml') {
return '[name][extname]';
}
return 'assets/[name]-[hash][extname]';
}
}
}
},
// Ensure static files are served with correct MIME types
assetsInclude: ['**/*.txt', '**/*.xml']
});
`;
const configPath = path.join(outputDir, 'vite.config.seo.ts');
await fs.writeFile(configPath, viteConfigContent);
console.log(`\n⚙️ Generated Vite config template: ${configPath}`);
console.log(' Copy this configuration to your vite.config.ts file.');
return configPath;
}
/**
* Generate Next.js App Router layout templates
*/
async function generateNextjsAppRouterTemplates(outputDir, config) {
console.log('\n📋 Generating Next.js App Router layout templates...');
const templates = [];
// Root layout template
const rootLayoutTemplate = `// Root layout for Next.js App Router
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: {
template: '%s | ${config.site?.name || 'Your Site'}',
default: '${config.site?.name || 'Your Site'}',
},
description: '${config.site?.description || 'Your site description'}',
keywords: ['${config.seo?.defaultKeywords?.join("', '") || ''}'],
authors: [{ name: '${config.site?.author || 'Your Name'}' }],
creator: '${config.site?.author || 'Your Name'}',
publisher: '${config.site?.name || 'Your Site'}',
formatDetection: {
email: false,
address: false,
telephone: false,
},
metadataBase: new URL('${config.site?.url || 'https://yoursite.com'}'),
alternates: {
canonical: '/',
},
openGraph: {
title: '${config.site?.name || 'Your Site'}',
description: '${config.site?.description || 'Your site description'}',
url: '${config.site?.url || 'https://yoursite.com'}',
siteName: '${config.site?.name || 'Your Site'}',
locale: '${config.site?.locale || 'en_US'}',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: '${config.site?.name || 'Your Site'}',
description: '${config.site?.description || 'Your site description'}',
},
verification: {
google: '${config.verification?.google || ''}',
yandex: '${config.verification?.yandex || ''}',
yahoo: '${config.verification?.yahoo || ''}',
other: {
'msvalidate.01': '${config.verification?.bing || ''}',
'p:domain_verify': '${config.verification?.pinterest || ''}',
},
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="${config.site?.language || 'en'}">
<body>{children}</body>
</html>
);
}`;
const rootLayoutPath = path.join(outputDir, 'nextjs-app-router', 'layout.tsx');
await fs.ensureDir(path.dirname(rootLayoutPath));
await fs.writeFile(rootLayoutPath, rootLayoutTemplate);
templates.push(rootLayoutPath);
// Route-specific layout template
const routeLayoutTemplate = `// Route-specific layout for Next.js App Router
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Route Title | ${config.site?.name || 'Your Site'}',
description: 'Route-specific description',
openGraph: {
title: 'Route Title | ${config.site?.name || 'Your Site'}',
description: 'Route-specific description',
},
twitter: {
card: 'summary_large_image',
title: 'Route Title | ${config.site?.name || 'Your Site'}',
description: 'Route-specific description',
},
};
export default function RouteLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}`;
const routeLayoutPath = path.join(outputDir, 'nextjs-app-router', 'route-layout.tsx');
await fs.writeFile(routeLayoutPath, routeLayoutTemplate);
templates.push(routeLayoutPath);
// Client component template
const clientComponentTemplate = `// Client component for Next.js App Router
'use client';
import { useState, useEffect } from 'react';
export default function ClientComponent() {
const [state, setState] = useState(null);
useEffect(() => {
// Client-side functionality here
}, []);
return (
<div>
<h1>Client Component</h1>
{/* Your interactive content */}
</div>
);
}`;
const clientComponentPath = path.join(outputDir, 'nextjs-app-router', 'client-component.tsx');
await fs.writeFile(clientComponentPath, clientComponentTemplate);
templates.push(clientComponentPath);
console.log('✅ Generated Next.js App Router templates:');
templates.forEach(template => {
console.log(` • ${path.basename(template)}`);
});
return templates;
}
/**
* Generate server configuration files
*/
async function generateServerConfigs(outputDir) {
// Vercel configuration
const vercelConfig = {
headers: [
{
source: "/robots.txt",
headers: [
{
key: "Content-Type",
value: "text/plain"
}
]
},
{
source: "/sitemap.xml",
headers: [
{
key: "Content-Type",
value: "application/xml"
}
]
}
]
};
const vercelPath = path.join(outputDir, 'vercel.json');
await fs.writeFile(vercelPath, JSON.stringify(vercelConfig, null, 2));
// Netlify configuration
const netlifyConfig = `[[headers]]
for = "/robots.txt"
[headers.values]
Content-Type = "text/plain"
[[headers]]
for = "/sitemap.xml"
[headers.values]
Content-Type = "application/xml"
`;
const netlifyPath = path.join(outputDir, 'netlify.toml');
await fs.writeFile(netlifyPath, netlifyConfig);
console.log(`\n🌐 Generated server configurations:`);
console.log(` • Vercel: ${vercelPath}`);
console.log(` • Netlify: ${netlifyPath}`);
return { vercelPath, netlifyPath };
}
/**
* Validate verification tokens
*/
function validateVerificationTokens(config) {
const warnings = [];
if (config.verification?.google) {
const googleToken = config.verification.google.trim();
if (!/^[A-Za-z0-9_-]+$/.test(googleToken)) {
warnings.push('Invalid Google verification token format - should contain only letters, numbers, hyphens, and underscores');
}
}
if (config.verification?.bing) {
const bingToken = config.verification.bing.trim();
if (!/^[A-Za-z0-9_-]+$/.test(bingToken)) {
warnings.push('Invalid Bing verification token format');
}
}
if (config.verification?.yandex) {
const yandexToken = config.verification.yandex.trim();
if (!/^[A-Za-z0-9_-]+$/.test(yandexToken)) {
warnings.push('Invalid Yandex verification token format');
}
}
if (config.verification?.pinterest) {
const pinterestToken = config.verification.pinterest.trim();
if (!/^[A-Za-z0-9_-]+$/.test(pinterestToken)) {
warnings.push('Invalid Pinterest verification token format');
}
}
return warnings;
}
/**
* Generate page title from template
*/
function generateTitle(page, config) {
const template = config.seo?.titleTemplate || '{title} | {siteName}';
// Generate a smart title based on the URL if no explicit title
let pageTitle = page.title;
if (!pageTitle) {
if (page.url === '/') {
pageTitle = 'Home';
} else {
// Convert URL to readable title: /about-us -> About Us
pageTitle = page.url
.replace(/^\//, '') // Remove leading slash
.replace(/\/$/, '') // Remove trailing slash
.split('/')
.map(segment =>
segment
.replace(/-/g, ' ') // Replace hyphens with spaces
.replace(/[_]/g, ' ') // Replace underscores with spaces
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
)
.join(' - ') || 'Page';
}
}
return template
.replace('{title}', pageTitle)
.replace('{siteName}', config.site.name)
.replace('{url}', page.url)
.replace('{siteUrl}', config.site.url);
}
/**
* Generate page description from template
*/
function generateDescription(page, config) {
const template = config.seo?.descriptionTemplate || '{description}';
// Generate a smart description if none provided
let pageDescription = page.description;
if (!pageDescription) {
if (config.site.description) {
// Use site description as base and customize for the page
if (page.url === '/') {
pageDescription = config.site.description;
} else {
const pageTitle = page.url
.replace(/^\//, '')
.replace(/\/$/, '')
.split('/')
.map(segment =>
segment
.replace(/-/g, ' ')
.replace(/[_]/g, ' ')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
)
.join(' - ');
pageDescription = `${pageTitle} - ${config.site.description}`;
}
} else {
// Fallback description
pageDescription = page.url === '/'
? `Welcome to ${config.site.name}`
: `${page.url.replace(/^\//, '').replace(/-/g, ' ').replace(/\//g, ' - ')} | ${config.site.name}`;
}
}
return template
.replace('{description}', pageDescription)
.replace('{siteName}', config.site.name)
.replace('{siteUrl}', config.site.url)
.replace('{url}', page.url);
}
/**
* Generate Open Graph image URL
*/
function generateOgImage(page, config) {
if (page.ogImage) {
return page.ogImage.startsWith('http') ? page.ogImage : `${config.site.url}${page.ogImage}`;
}
if (config.seo?.imageTemplate) {
const slug = page.url === '/' ? 'home' : page.url.replace(/^\//, '').replace(/\//g, '-');
return config.seo.imageTemplate
.replace('{siteUrl}', config.site.url.replace(/\/$/, ''))
.replace('{slug}', slug);
}
return config.site.logo ? `${config.site.url}${config.site.logo}` : undefined;
}
/**
* Generate hreflang data for multilingual sites
*/
function generateHreflangs(page, config) {
if (!config.languages?.supported || config.languages.supported.length <= 1) {
return [];
}
return config.languages.supported.map(lang => {
// For each language, generate the equivalent URL
let langUrl = lang.url;
// If not the root page, append the page path
if (page.url !== '/') {
// Handle different URL structures
if (langUrl.endsWith('/')) {
langUrl = langUrl.slice(0, -1);
}
langUrl += page.url;
}
return {
code: lang.code,
url: langUrl,
name: lang.name
};
});
}
/**
* Generate alternate locales for Open Graph
*/
function generateAlternateLocales(config) {
if (!config.languages?.supported || config.languages.supported.length <= 1) {
return [];
}
const defaultLocale = config.site?.locale || config.languages.supported.find(lang =>
lang.code === config.languages.default
)?.locale || 'en_US';
return config.languages.supported
.filter(lang => lang.locale !== defaultLocale)
.map(lang => lang.locale);
}
/**
* Get default language URL
*/
function getDefaultLanguageUrl(config) {
if (!config.languages?.supported) {
return config.site.url;
}
const defaultLang = config.languages.supported.find(lang =>
lang.code === config.languages.default
);
return defaultLang ? defaultLang.url : config.site.url;
}
/**
* Generate all SEO files
*/
async function generateAll(pages, config, outputDir) {
console.log('\n🚀 Generating all SEO files...');
const results = {
sitemap: null,
robots: null,
meta: [],
jsonld: [],
deployment: [],
fixes: []
};
try {
// Generate core SEO files
results.sitemap = await generateSitemap(pages, config, outputDir);
results.robots = await generateRobots(config, outputDir);
results.meta = await generateMetaTags(pages, config, outputDir);
results.jsonld = await generateJsonLd(pages, config, outputDir);
// Ensure files are also written to public directory for production access
if (config.paths?.publicDir) {
const publicDir = config.paths.publicDir;
await fs.ensureDir(publicDir);
// Copy sitemap and robots to public directory
if (results.sitemap && await fs.pathExists(results.sitemap)) {
await fs.copy(results.sitemap, path.join(publicDir, 'sitemap.xml'));
console.log(' 📄 Copied sitemap.xml to public directory');
}
if (results.robots && await fs.pathExists(results.robots)) {
await fs.copy(results.robots, path.join(publicDir, 'robots.txt'));
console.log(' 📄 Copied robots.txt to public directory');
}
}
// Automatically apply Vite React compatibility fixes
if (config.framework === 'react') {
console.log('\n🔧 Detected React application - applying automatic compatibility fixes...');
const projectDir = process.cwd();
const fixes = await fixViteReactCompatibility(projectDir, config);
results.fixes = fixes;
if (fixes.length > 0) {
console.log('\n✅ Automatic compatibility fixes applied!');
console.log(' Your Vite React app is now inherently compatible with vibe-seo.');
console.log(' No manual configuration needed - everything should work out of the box.');
}
}
// Generate deployment guides for reference (but fixes are already applied)
if (config.framework === 'react') {
console.log('\n📦 Generating deployment guides for reference...');
const deploymentGuide = await generateViteReactDeploymentGuide(config, outputDir);
const viteConfig = await generateViteConfigTemplate(outputDir);
const serverConfigs = await generateServerConfigs(outputDir);
results.deployment = [deploymentGuide, viteConfig, serverConfigs.vercelPath, serverConfigs.netlifyPath];
}
// Generate Next.js App Router templates if detected
if (config.framework === 'nextjs-app') {
console.log('\n📋 Generating Next.js App Router templates...');
const nextjsTemplates = await generateNextjsAppRouterTemplates(outputDir, config);
results.templates = nextjsTemplates;
}
console.log('\n✅ All SEO files generated successfully!');
console.log(`\n📁 Files generated in: ${outputDir}`);
console.log(` • Sitemap: ${path.basename(results.sitemap)}`);
console.log(` • Robots: ${path.basename(results.robots)}`);
console.log(` • Meta tags: ${results.meta.length} files`);
console.log(` • JSON-LD: ${results.jsonld.length} files`);
if (results.fixes.length > 0) {
console.log(` • Compatibility fixes: ${results.fixes.length} applied`);
}
if (results.deployment.length > 0) {
console.log(` • Deployment guides: ${results.deployment.length} files (for reference)`);
}
return results;
} catch (error) {
console.error('\n❌ Error generating SEO files:', error.message);
throw error;
}
}
/**
* Automatically fix Vite React compatibility issues
*/
async function fixViteReactCompatibility(projectDir, config) {
console.log('\n🔧 Auto-fixing Vite React compatibility issues...');
const fixes = [];
try {
// 1. Fix Vite configuration with enhanced error handling and conflict detection
const viteConfigPath = path.join(projectDir, 'vite.config.ts');
const viteConfigJsPath = path.join(projectDir, 'vite.config.js');
let viteConfigPathToUse = null;
if (await fs.pathExists(viteConfigPath)) {
viteConfigPathToUse = viteConfigPath;
} else if (await fs.pathExists(viteConfigJsPath)) {
viteConfigPathToUse = viteConfigJsPath;
}
if (viteConfigPathToUse) {
console.log(` 📝 Updating ${path.basename(viteConfigPathToUse)}...`);
let viteConfig = await fs.readFile(viteConfigPathToUse, 'utf8');
// Check for existing plugin conflicts
const existingPlugins = [
'vite-seo-plugin',
'vite-static-plugin',
'staticFilePlugin',
'seoPlugin'
];
const conflictingPlugins = existingPlugins.filter(plugin =>
viteConfig.includes(plugin)
);
if (conflictingPlugins.length > 0) {
console.log(` ⚠️ Detected existing plugins: ${conflictingPlugins.join(', ')}`);
console.log(` 💡 vibe-seo will enhance existing functionality instead of replacing it`);
}
// Check if SEO fixes are already applied
if (!viteConfig.includes('robots.txt') && !viteConfig.includes('sitemap.xml')) {
// Add SEO-specific configuration
const seoConfig = `
// SEO static file handling
build: {
rollupOptions: {
output: {
assetFileNames: (assetInfo) => {
if (assetInfo.name === 'robots.txt' || assetInfo.name === 'sitemap.xml') {
return '[name][extname]';
}
return 'assets/[name]-[hash][extname]';
}
}
}
},
// Ensure static files are served with correct MIME types
assetsInclude: ['**/*.txt', '**/*.xml'],`;
// Insert the SEO config before the closing bracket
if (viteConfig.includes('export default defineConfig({')) {
viteConfig = viteConfig.replace(
/export default defineConfig\(\{([\s\S]*?)\}\);/,
(match, configContent) => {
// Remove trailing comma if exists
const cleanConfig = configContent.replace(/,\s*$/, '');
return `export default defineConfig({${cleanConfig}${seoConfig}\n});`;
}
);
await fs.writeFile(viteConfigPathToUse, viteConfig);
fixes.push(`Updated ${path.basename(viteConfigPathToUse)} with SEO static file handling`);
}
} else {
console.log(` ✅ ${path.basename(viteConfigPathToUse)} already has SEO configuration`);
}
}
// 2. Create a robust Vite plugin for proper static file handling
const vitePluginPath = path.join(projectDir, 'vite-seo-plugin.js');
const vitePluginContent = `// Vite SEO Plugin for proper static file handling
// @ts-ignore - Custom plugin without type declarations
import { readFileSync, existsSync } from 'fs';
import { resolve, join } from 'path';
/**
* Vite plugin to handle SEO static files with proper MIME types
* Prevents robots.txt download issues and ensures correct file serving
*
* Note: This plugin is designed to work with any Vite setup and doesn't
* require additional dependencies like @vitejs/plugin-react-swc
*/
export default function seoPlugin() {
return {
name: 'vite-seo-plugin',
// Optional: Plugin configuration
configResolved(config) {
// Log plugin activation for debugging
console.log('🔧 Vibe SEO Plugin: Activated for static file handling');
},
configureServer(server) {
// Handle robots.txt and sitemap.xml requests with proper MIME types
server.middlewares.use((req, res, next) => {
if (req.url === '/robots.txt' || req.url === '/sitemap.xml') {
try {
// Determine file path and MIME type
const fileName = req.url.slice(1);
const publicDir = resolve(process.cwd(), 'public');
const filePath = join(publicDir, fileName);
// Check if file exists
if (!existsSync(filePath)) {
console.warn(\`SEO Plugin: File not found: \${filePath}\`);
next();
return;
}
// Set proper MIME types to prevent download issues
const mimeTypes = {
'robots.txt': 'text/plain; charset=utf-8',
'sitemap.xml': 'application/xml; charset=utf-8'
};
const contentType = mimeTypes[fileName];
if (contentType) {
res.setHeader('Content-Type', contentType);
}
// Read and serve file content
const content = readFileSync(filePath, 'utf8');
res.writeHead(200);
res.end(content);
return;
} catch (error) {
console.warn(\`SEO Plugin: Error serving \${req.url}\`, error.message);
next();
}
}
next();
});
},
// Ensure static files are properly handled in build process
buildStart() {
// Log that static files should be in public directory
console.log('🔧 Vibe SEO Plugin: Static files should be in public/ directory');
console.log('🔧 Vibe SEO Plugin: Ensure robots.txt and sitemap.xml are in public/');
},
// Handle static file serving in production and inject SEO meta tags
transformIndexHtml: {
enforce: 'pre',
transform(html, { path }) {
try {
// Add meta tags to ensure proper MIME type handling
if (html.includes('</head>')) {
let additionalMetaTags = \`
<!-- SEO Plugin: Ensure proper MIME types for static files -->
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">\`;
// Inject SEO meta tags if config is available
try {
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
// Try to load config file
const configPath = path.resolve(process.cwd(), 'seo.config.yaml');
if (fs.existsSync(configPath)) {
const configContent = fs.readFileSync(configPath, 'utf8');
const config = yaml.load(configContent);
// Add verification tags
if (config.verification) {
if (config.verification.google) {
additionalMetaTags += \`\\n <meta name="google-site-verification" content="\${config.verification.google}">\`;
}
if (config.verification.bing) {
additionalMetaTags += \`\\n <meta name="msvalidate.01" content="\${config.verification.bing}">\`;
}
if (config.verification.yandex) {
additionalMetaTags += \`\\n <meta name="yandex-verification" content="\${config.verification.yandex}">\`;
}
if (config.verification.pinterest) {
additionalMetaTags += \`\\n <meta name="p:domain_verify" content="\${config.verification.pinterest}">\`;
}
}
// Add basic SEO meta tags
if (config.site) {
if (config.site.name) {
additionalMetaTags += \`\\n <meta name="application-name" content="\${config.site.name}">\`;
}
if (config.site.description) {
additionalMetaTags += \`\\n <meta name="description" content="\${config.site.description}">\`;
}
if (config.site.language) {
additionalMetaTags += \`\\n <meta name="language" content="\${config.site.language}">\`;
}
}
}
} catch (configError) {
console.warn('SEO Plugin: Could not load config for meta tag injection:', configError.message);
}
return html.replace('</head>', additionalMetaTags + '</head>');
}
return html;
} catch (error) {
console.warn('SEO Plugin: Error in transformIndexHtml', error.message);
return html;
}
}
},
// Ensure verification tags are included in build output
generateBundle(options, bundle) {
try {
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
// Try to load config file
const configPath = path.resolve(process.cwd(), 'seo.config.yaml');
if (fs.existsSync(configPath)) {
const configContent = fs.readFileSync(configPath, 'utf8');
const config = yaml.load(configContent);
// Modify index.html in build output to include verification tags
if (bundle['index.html'] && config.verification?.google) {
let html = bundle['index.html'].source;
const googleToken = config.verification.google.trim();
if (/^[A-Za-z0-9_-]+$/.test(googleToken) && html.includes('</head>')) {
const verificationTag = \` <meta name="google-site-verification" content="\${googleToken}" />\\n \`;
html = html.replace('</head>', verificationTag + '</head>');
bundle['index.html'].source = html;
console.log('🔧 SEO Plugin: Added Google verification tag to build output');
}
}
}
} catch (error) {
console.warn('SEO Plugin: Error in generateBundle', error.message);
}
}
};
}`;
if (!await fs.pathExists(vitePluginPath)) {
await fs.writeFile(vitePluginPath, vitePluginContent);
fixes.push('Created vite-seo-plugin.js for proper static file handling');
}
// Create TypeScript declaration file to prevent type errors
const vitePluginTypesPath = path.join(projectDir, 'vite-seo-plugin.d.ts');
const vitePluginTypesContent = `// TypeScript declarations for vite-seo-plugin
declare module 'vite-seo-plugin' {
import { Plugin } from 'vite';
export default function seoPlugin(): Plugin;
}
declare module './vite-seo-plugin.js' {
import { Plugin } from 'vite';
export default function seoPlugin(): Plugin;
}`;
if (!await fs.pathExists(vitePluginTypesPath)) {
await fs.writeFile(vitePluginTypesPath, vitePluginTypesContent);
fixes.push('Created vite-seo-plugin.d.ts for TypeScript support');
}
// 3. Update Vite config to use the plugin (with better error handling)
if (viteConfigPathToUse) {
let viteConfig = await fs.readFile(viteConfigPathToUse, 'utf8');
// Check if plugin is already configured
if (!viteConfig.includes('seoPlugin')) {
console.log(` 📝 Adding SEO plugin to ${path.basename(viteConfigPathToUse)}...`);
// Add plugin import with proper formatting
const pluginImport = `import seoPlugin from './vite-seo-plugin.js';`;
// Find the best place to add the import
let importAdded = false;
// Look for existing imports
if (viteConfig.includes('import')) {
// Find the last import statement and add after it
const importLines = viteConfig.match(/import.*?;?\n?/g);
if (importLines) {
const lastImportIndex = viteConfig.lastIndexOf(importLines[importLines.length - 1]);
const beforeImports = viteConfig.substring(0, lastImportIndex);
const afterImports = viteConfig.substring(lastImportIndex);
const lastImport = importLines[importLines.length - 1];
// Add new import after the last existing import
viteConfig = beforeImports + lastImport + '\n' + pluginImport + '\n' + afterImports.substring(lastImport.length);
importAdded = true;
}
}
// If no imports found, add at the top
if (!importAdded) {
viteConfig = pluginImport + '\n\n' + viteConfig;
}
// Add plugin to plugins array with enhanced validation and error handling
if (viteConfig.includes('plugins: [')) {
// Find existing plugins array and add our plugin
const pluginsMatch = viteConfig.match(/plugins:\s*\[([\s\S]*?)\]/);
if (pluginsMatch) {
const existingPlugins = pluginsMatch[1].trim();
// Validate existing plugins array syntax
if (existingPlugins.includes(',,') || existingPlugins.includes(',]')) {
console.log(` ⚠️ Detected syntax issues in plugins array, attempting to fix...`);
// Clean up syntax issues
const cleanedPlugins = existingPlugins
.replace(/,,+/g, ',')
.replace(/,\s*\]/g, ']')
.replace(/,\s*$/g, '');
const newPluginsArray = cleanedPlugins
? `plugins: [${cleanedPlugins}, seoPlugin()]`
: `plugins: [seoPlugin()]`;
viteConfig = viteConfig.replace(/plugins:\s*\[([\s\S]*?)\]/, newPluginsArray);
} else {
const newPluginsArray = existingPlugins
? `plugins: [${existingPlugins}, seoPlugin()]`
: `plugins: [seoPlugin()]`;
viteConfig = viteConfig.replace(/plugins:\s*\[([\s\S]*?)\]/, newPluginsArray);
}
}
} else {
// Add plugins array if it doesn't exist
if (viteConfig.includes('export default defineConfig({')) {
// Find the closing bracket and add plugins before it
const configMatch = viteConfig.match(/export default defineConfig\(\{([\s\S]*?)\}\);/);
if (configMatch) {
const configContent = configMatch[1];
// Remove trailing comma if exists
const cleanConfig = configContent.replace(/,\s*$/, '');
const newConfig = cleanConfig
? `export default defineConfig({\n ${cleanConfig},\n plugins: [seoPlugin()]\n});`
: `export default defineConfig({\n plugins: [seoPlugin()]\n});`;
viteConfig = viteConfig.replace(/export default defineConfig\(\{([\s\S]*?)\}\);/s, newConfig);
}
}
}
// Add error handling comment for common issues
const errorHandlingComment = '// =============================================================================\\n' +
'// VIBE-SEO PLUGIN INTEGRATION\\n' +
'// =============================================================================\\n' +
'// This plugin handles SEO static files (robots.txt, sitemap.xml) with proper MIME types\\n' +
'// If you encounter any errors, ensure:\\n' +
'// 1. The vite-seo-plugin.js file exists in your project root\\n' +
'// 2. No conflicting plugins are interfering\\n' +
'// 3. Your Vite version is compatible (4.0+)\\n' +
'// ===========================================================