UNPKG

vibe-seo

Version:

AI-friendly SEO generator for modern web frameworks

1,513 lines (1,289 loc) 66.7 kB
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' + '// ===========================================================