UNPKG

vibe-seo

Version:

AI-friendly SEO generator for modern web frameworks

848 lines (717 loc) • 26.3 kB
const fs = require('fs-extra'); const path = require('path'); const { glob } = require('glob'); const { promisify } = require('util'); // In glob v8+, glob is already async, no need for promisify const globAsync = glob; /** * Detect the framework used in the project */ async function detectFramework(projectDir) { const packageJsonPath = path.join(projectDir, 'package.json'); // Check if package.json exists if (await fs.pathExists(packageJsonPath)) { const packageJson = await fs.readJson(packageJsonPath); const dependencies = { ...packageJson.dependencies, ...packageJson.devDependencies }; // Next.js detection if (dependencies['next']) { // Check for App Router (Next.js 13+) in multiple locations const possibleAppDirs = [ path.join(projectDir, 'app'), path.join(projectDir, 'src', 'app') ]; for (const appDir of possibleAppDirs) { if (await fs.pathExists(appDir)) { console.log(`Found Next.js App Router directory: ${appDir}`); return 'nextjs-app'; } } // Check for Pages Router const possiblePagesDirs = [ path.join(projectDir, 'pages'), path.join(projectDir, 'src', 'pages') ]; for (const pagesDir of possiblePagesDirs) { if (await fs.pathExists(pagesDir)) { console.log(`Found Next.js Pages Router directory: ${pagesDir}`); return 'nextjs-pages'; } } console.log('Next.js detected but no app or pages directory found, defaulting to nextjs-pages'); return 'nextjs-pages'; } // Vite React detection (check for Vite + React) if (dependencies['vite'] && dependencies['react'] && !dependencies['next']) { console.log('Detected Vite React application'); return 'react'; } // React detection (general React apps) if (dependencies['react'] && !dependencies['next']) { console.log('Detected React application'); return 'react'; } // Vue detection if (dependencies['vue'] || dependencies['@vue/cli-service']) { return 'vue'; } // Angular detection if (dependencies['@angular/core']) { return 'angular'; } } // Check for framework-specific files const frameworkFiles = [ { file: 'next.config.js', framework: 'nextjs' }, { file: 'vite.config.js', framework: 'react' }, // Vite config indicates React/Vue { file: 'vite.config.ts', framework: 'react' }, { file: 'vue.config.js', framework: 'vue' }, { file: 'angular.json', framework: 'angular' }, { file: 'gatsby-config.js', framework: 'gatsby' }, { file: 'nuxt.config.js', framework: 'nuxt' } ]; for (const { file, framework } of frameworkFiles) { if (await fs.pathExists(path.join(projectDir, file))) { console.log(`Found framework config file: ${file}`); return framework; } } return 'static'; } /** * Enhanced Next.js App Router detection and guidance */ async function detectNextjsAppRouter(projectDir) { const possibleAppDirs = [ path.join(projectDir, 'app'), path.join(projectDir, 'src', 'app') ]; for (const appDir of possibleAppDirs) { if (await fs.pathExists(appDir)) { // Check for layout.tsx files (App Router indicator) const layoutFiles = await globAsync('**/layout.tsx', { cwd: appDir }); const pageFiles = await globAsync('**/page.tsx', { cwd: appDir }); if (layoutFiles.length > 0 || pageFiles.length > 0) { console.log('āœ… Detected Next.js App Router structure'); console.log(' šŸ“ App directory:', appDir); console.log(' šŸ“„ Layout files found:', layoutFiles.length); console.log(' šŸ“„ Page files found:', pageFiles.length); return { isAppRouter: true, appDir, layoutFiles, pageFiles }; } } } return { isAppRouter: false }; } /** * Generate Next.js App Router specific guidance */ function generateNextjsAppRouterGuidance(appDir, layoutFiles, pageFiles) { return ` ## Next.js App Router Detection Your project uses Next.js App Router (Next.js 13+). This requires special handling for metadata: ### āš ļø Important: Metadata Export Requirements **Client Components** ('use client') cannot export metadata. You must: 1. **Use Server Components** for pages that need metadata 2. **Create Layout Files** for metadata exports 3. **Keep Client Components** for interactive functionality ### šŸ“ Recommended Structure \`\`\` src/app/ ā”œā”€ā”€ layout.tsx # Root layout with metadata ā”œā”€ā”€ page.tsx # Homepage (server component) ā”œā”€ā”€ vibe-seo/ │ ā”œā”€ā”€ layout.tsx # Vibe SEO layout with metadata │ ā”œā”€ā”€ page.tsx # Vibe SEO page (client component) │ └── docs/ │ ā”œā”€ā”€ layout.tsx # Docs layout with metadata │ └── page.tsx # Docs page (client component) \`\`\` ### šŸ”§ Implementation Example **Layout File (Server Component):** \`\`\`tsx // src/app/vibe-seo/layout.tsx export const metadata = { title: 'Vibe SEO - AI-Friendly SEO Generator', description: 'AI-friendly SEO generator for modern web frameworks', keywords: ['SEO', 'AI-friendly', 'Google Tag Manager', 'Vite React', 'Next.js'], openGraph: { title: 'Vibe SEO - AI-Friendly SEO Generator', description: 'AI-friendly SEO generator for modern web frameworks', }, twitter: { card: 'summary_large_image', title: 'Vibe SEO - AI-Friendly SEO Generator', description: 'AI-friendly SEO generator for modern web frameworks', }, verification: { google: 'your-google-verification-token', }, }; export default function VibeSeoLayout({ children, }: { children: React.ReactNode; }) { return <>{children}</>; } \`\`\` **Page File (Client Component):** \`\`\`tsx // src/app/vibe-seo/page.tsx 'use client'; export default function VibeSeoPage() { // Interactive functionality here return ( <div> <h1>Vibe SEO</h1> {/* Your interactive content */} </div> ); } \`\`\` ### 🚨 Common Issues to Avoid 1. **Don't export metadata from client components** 2. **Don't use 'use client' in layout files** 3. **Don't mix metadata exports with useState/useEffect** ### šŸ“‹ Checklist - [ ] Create layout.tsx files for each route that needs metadata - [ ] Keep page.tsx files as client components for interactivity - [ ] Export metadata only from server components (layout files) - [ ] Test metadata generation with \`npm run build\` - [ ] Verify metadata in production deployment ### šŸ” Debugging Check your build logs for metadata warnings: \`\`\`bash npm run build # Look for warnings about metadata exports \`\`\` For more help: https://nextjs.org/docs/app/building-your-application/optimizing/metadata `; } /** * Scan for pages in the project based on framework */ async function scanPages(config) { const framework = config.framework; const projectDir = process.cwd(); console.log(`\nScanning pages for framework: ${framework}`); console.log(`Project directory: ${projectDir}`); console.log(`Configured paths:`, config.paths); // Additional debugging for React/Vite apps if (framework === 'react') { console.log('\nšŸ” React/Vite specific debugging:'); const srcDir = path.resolve(projectDir, config.paths?.srcDir || './src'); console.log(` Checking src directory: ${srcDir}`); console.log(` Src directory exists: ${await fs.pathExists(srcDir)}`); if (await fs.pathExists(srcDir)) { const srcContents = await fs.readdir(srcDir); console.log(` Src directory contents:`, srcContents); } // Check for common Vite React files const viteFiles = [ 'vite.config.js', 'vite.config.ts', 'index.html' ]; for (const file of viteFiles) { const filePath = path.join(projectDir, file); console.log(` ${file} exists: ${await fs.pathExists(filePath)}`); } } let pages = []; try { switch (framework) { case 'nextjs-app': console.log('Scanning Next.js App Router pages...'); pages = await scanNextjsAppRouter(projectDir, config); break; case 'nextjs-pages': case 'nextjs': console.log('Scanning Next.js Pages Router pages...'); pages = await scanNextjsPagesRouter(projectDir, config); break; case 'react': console.log('Scanning React pages...'); pages = await scanReactPages(projectDir, config); break; case 'vue': console.log('Scanning Vue pages...'); pages = await scanVuePages(projectDir, config); break; case 'angular': console.log('Scanning Angular pages...'); pages = await scanAngularPages(projectDir, config); break; default: console.log('Scanning static HTML pages...'); pages = await scanStaticPages(projectDir, config); } console.log(`Raw pages found: ${pages.length}`); // Filter out excluded paths if (config.sitemap && config.sitemap.excludePaths) { const originalLength = pages.length; pages = pages.filter(page => { return !config.sitemap.excludePaths.some(exclude => { const pattern = exclude.replace('*', '.*'); return new RegExp(pattern).test(page.url); }); }); console.log(`After filtering exclusions: ${pages.length} (excluded ${originalLength - pages.length})`); } console.log(`Final pages detected: ${pages.length}`); if (pages.length > 0) { console.log('Pages:'); pages.forEach(page => console.log(` - ${page.url}`)); } else { console.log('āš ļø No pages detected! This might indicate:'); console.log(' - Files are in unexpected locations'); console.log(' - Framework detection is incorrect'); console.log(' - Project structure is non-standard'); console.log(' - Try running with --debug for more details'); } return pages; } catch (error) { console.warn(`Warning: Failed to scan pages: ${error.message}`); console.warn('Stack trace:', error.stack); return []; } } /** * Scan Next.js App Router pages */ async function scanNextjsAppRouter(projectDir, config) { console.log('\nScanning Next.js App Router pages...'); // Enhanced App Router detection const appRouterInfo = await detectNextjsAppRouter(projectDir); if (!appRouterInfo.isAppRouter) { console.log('āŒ No Next.js App Router structure detected'); return []; } console.log(`šŸ“ Scanning app directory: ${appRouterInfo.appDir}`); console.log(`šŸ“„ Found ${appRouterInfo.layoutFiles.length} layout files`); console.log(`šŸ“„ Found ${appRouterInfo.pageFiles.length} page files`); // Generate and display App Router guidance const guidance = generateNextjsAppRouterGuidance( appRouterInfo.appDir, appRouterInfo.layoutFiles, appRouterInfo.pageFiles ); console.log('\nšŸ“‹ Next.js App Router Guidance:'); console.log(guidance); const pages = []; for (const pageFile of appRouterInfo.pageFiles) { const fullPath = path.join(appRouterInfo.appDir, pageFile); const relativePath = path.relative(appRouterInfo.appDir, fullPath); // Convert file path to URL let url = '/' + relativePath.replace(/\/page\.tsx$/, '').replace(/\/page\.jsx?$/, ''); url = url.replace(/\/index$/, '/'); url = url === '/index' ? '/' : url; // Get file stats for last modified const stats = await fs.stat(fullPath); const lastmod = stats.mtime.toISOString(); // Check if this page has a corresponding layout file const layoutPath = fullPath.replace(/\/page\.tsx$/, '/layout.tsx'); const hasLayout = await fs.pathExists(layoutPath); pages.push({ url, file: pageFile, fullPath, lastmod, framework: 'nextjs-app', hasLayout, needsMetadata: hasLayout // Pages with layouts likely need metadata }); console.log(` šŸ“„ ${pageFile} → ${url} ${hasLayout ? '(has layout)' : '(no layout)'}`); } // Provide specific recommendations const pagesWithoutLayout = pages.filter(p => !p.hasLayout); if (pagesWithoutLayout.length > 0) { console.log('\nāš ļø Pages without layout files detected:'); pagesWithoutLayout.forEach(page => { console.log(` • ${page.url} - Consider creating layout.tsx for metadata`); }); } return pages; } /** * Scan Next.js Pages Router pages */ async function scanNextjsPagesRouter(projectDir, config) { // Try multiple possible pages directory locations const possiblePagesDirs = [ config.paths?.pagesDir || './pages', './pages', './src/pages' ].map(dir => path.resolve(projectDir, dir)); let pagesDir = null; for (const dir of possiblePagesDirs) { if (await fs.pathExists(dir)) { pagesDir = dir; console.log(`Found pages directory: ${pagesDir}`); break; } } if (!pagesDir) { console.log(`Pages directory not found in any of these locations:`, possiblePagesDirs); return []; } const pattern = path.join(pagesDir, '**/*.{js,jsx,ts,tsx}'); const pageFiles = await globAsync(pattern); console.log(`Found ${pageFiles.length} page files in ${pagesDir}`); return pageFiles .filter(file => { const basename = path.basename(file, path.extname(file)); const isExcluded = ['_app', '_document', '_error', '404', '500'].includes(basename); if (isExcluded) { console.log(`Excluding file: ${basename}`); } return !isExcluded; }) .map(file => { const relativePath = path.relative(pagesDir, file); const route = relativePath.replace(/\.(js|jsx|ts|tsx)$/, ''); const url = route === 'index' ? '/' : `/${route}`; console.log(`Mapped file ${file} -> URL: ${url}`); return { url: url.replace(/\\/g, '/'), file, type: 'page', framework: 'nextjs-pages', lastmod: new Date().toISOString() }; }); } /** * Scan React pages (including Lovable stack structure) */ async function scanReactPages(projectDir, config) { // Use configured src directory or default const srcDirPath = config.paths?.srcDir || './src'; const srcDir = path.resolve(projectDir, srcDirPath); // Check if src directory exists if (!await fs.pathExists(srcDir)) { console.log(`Src directory not found: ${srcDir}`); return []; } console.log(`Scanning React pages in: ${srcDir}`); const pages = []; // 1. First, scan for file-based pages in /src/pages/ (Lovable stack structure) const pagesDir = path.join(srcDir, 'pages'); if (await fs.pathExists(pagesDir)) { console.log(`Found pages directory: ${pagesDir}`); try { const pageFiles = await globAsync(path.join(pagesDir, '**/*.{js,jsx,ts,tsx}')); for (const file of pageFiles) { const relativePath = path.relative(pagesDir, file); const fileName = path.basename(file, path.extname(file)); const dirName = path.dirname(path.relative(pagesDir, file)); // Convert file path to URL let url; if (fileName === 'index') { // index.tsx in pages/ -> / // index.tsx in pages/about/ -> /about url = dirName === '.' ? '/' : `/${dirName}`; } else { // about.tsx in pages/ -> /about // contact.tsx in pages/ -> /contact url = dirName === '.' ? `/${fileName}` : `/${dirName}/${fileName}`; } // Clean up URL (remove double slashes, etc.) url = url.replace(/\/+/g, '/'); pages.push({ url, file, type: 'page', framework: 'react', lastmod: new Date().toISOString() }); console.log(`Found page file: ${path.relative(projectDir, file)} -> ${url}`); } } catch (error) { console.log(`Error scanning pages directory: ${error.message}`); } } // 2. If no pages found in /src/pages/, scan for route definitions in other files if (pages.length === 0) { console.log('No pages found in /src/pages/, scanning for route definitions...'); // Look for common page patterns in Vite React apps const patterns = [ path.join(srcDir, '**/*.{js,jsx,ts,tsx}'), path.join(srcDir, 'components/**/*.{js,jsx,ts,tsx}'), path.join(srcDir, 'views/**/*.{js,jsx,ts,tsx}'), path.join(srcDir, 'routes/**/*.{js,jsx,ts,tsx}') ]; const allFiles = []; for (const pattern of patterns) { try { const files = await globAsync(pattern); allFiles.push(...files); } catch (error) { console.log(`Pattern ${pattern} not found or error: ${error.message}`); } } console.log(`Found ${allFiles.length} potential React files`); // Look for route definitions in files const processedFiles = new Set(); for (const file of allFiles) { if (processedFiles.has(file)) continue; processedFiles.add(file); try { const content = await fs.readFile(file, 'utf8'); // Look for various route patterns const routePatterns = [ // React Router patterns /path:\s*["']([^"']+)["']/g, /path\s*=\s*["']([^"']+)["']/g, // Component-based routing patterns /Route\s+path\s*=\s*["']([^"']+)["']/g, // File-based routing (common in Vite) /export\s+default\s+function\s+(\w+)/g, /const\s+(\w+)\s*=\s*\(\)\s*=>/g ]; let foundRoutes = false; for (const pattern of routePatterns) { const matches = content.match(pattern); if (matches) { matches.forEach(match => { let routePath; if (pattern.source.includes('path')) { // Extract path from route definition const pathMatch = match.match(/["']([^"']+)["']/); routePath = pathMatch ? pathMatch[1] : null; } else { // Extract component name and convert to route const componentMatch = match.match(/(\w+)/); if (componentMatch) { const componentName = componentMatch[1]; // Convert component name to route path routePath = componentName.toLowerCase() .replace(/([a-z])([A-Z])/g, '$1-$2') .replace(/page$|component$/, ''); routePath = routePath ? `/${routePath}` : '/'; } } if (routePath && routePath !== '/index' && routePath !== '/home') { pages.push({ url: routePath === '/' ? '/' : routePath, file, type: 'page', framework: 'react', lastmod: new Date().toISOString() }); foundRoutes = true; } }); } } // If no explicit routes found, check if this looks like a main page component if (!foundRoutes) { const fileName = path.basename(file, path.extname(file)); const isMainComponent = ['App', 'index', 'main', 'home', 'page'].some(name => fileName.toLowerCase().includes(name.toLowerCase()) ); if (isMainComponent && content.includes('export default')) { pages.push({ url: '/', file, type: 'page', framework: 'react', lastmod: new Date().toISOString() }); } } } catch (error) { console.log(`Could not read file ${file}: ${error.message}`); } } } // 3. If still no pages found, add default pages based on common Vite React structure if (pages.length === 0) { console.log('No explicit routes found, adding default pages for Vite React app'); // Check for common page files const commonPages = [ { file: path.join(srcDir, 'App.js'), url: '/' }, { file: path.join(srcDir, 'App.jsx'), url: '/' }, { file: path.join(srcDir, 'App.ts'), url: '/' }, { file: path.join(srcDir, 'App.tsx'), url: '/' }, { file: path.join(srcDir, 'index.js'), url: '/' }, { file: path.join(srcDir, 'index.jsx'), url: '/' }, { file: path.join(srcDir, 'index.ts'), url: '/' }, { file: path.join(srcDir, 'index.tsx'), url: '/' }, { file: path.join(srcDir, 'pages', 'Home.js'), url: '/' }, { file: path.join(srcDir, 'pages', 'Home.jsx'), url: '/' }, { file: path.join(srcDir, 'pages', 'Home.ts'), url: '/' }, { file: path.join(srcDir, 'pages', 'Home.tsx'), url: '/' }, { file: path.join(srcDir, 'components', 'Home.js'), url: '/' }, { file: path.join(srcDir, 'components', 'Home.jsx'), url: '/' }, { file: path.join(srcDir, 'components', 'Home.ts'), url: '/' }, { file: path.join(srcDir, 'components', 'Home.tsx'), url: '/' } ]; for (const page of commonPages) { if (await fs.pathExists(page.file)) { pages.push({ url: page.url, file: page.file, type: 'page', framework: 'react', lastmod: new Date().toISOString() }); console.log(`Found default page: ${page.file} -> ${page.url}`); } } } // Remove duplicates based on URL const uniquePages = []; const seenUrls = new Set(); for (const page of pages) { if (!seenUrls.has(page.url)) { seenUrls.add(page.url); uniquePages.push(page); } } console.log(`Detected ${uniquePages.length} unique React pages`); uniquePages.forEach(page => { console.log(` - ${page.url} (from ${path.relative(projectDir, page.file)})`); }); return uniquePages; } /** * Scan Vue pages */ async function scanVuePages(projectDir, config) { const srcDir = path.join(projectDir, 'src'); const pagesPattern = path.join(srcDir, '**/*.vue'); const pageFiles = await globAsync(pagesPattern); return pageFiles.map(file => { const relativePath = path.relative(srcDir, file); const route = relativePath.replace(/\.vue$/, '').toLowerCase(); const url = route === 'app' || route === 'index' ? '/' : `/${route}`; return { url: url.replace(/\\/g, '/'), file, type: 'page', framework: 'vue', lastmod: new Date().toISOString() }; }); } /** * Scan Angular pages */ async function scanAngularPages(projectDir, config) { const srcDir = path.join(projectDir, 'src'); const componentPattern = path.join(srcDir, '**/*.component.ts'); const componentFiles = await globAsync(componentPattern); // This is a simplified approach - in reality, you'd want to parse the routing module return componentFiles.map(file => { const relativePath = path.relative(srcDir, file); const componentName = path.basename(file, '.component.ts'); const url = componentName === 'app' ? '/' : `/${componentName.toLowerCase()}`; return { url, file, type: 'page', framework: 'angular', lastmod: new Date().toISOString() }; }); } /** * Scan static HTML pages */ async function scanStaticPages(projectDir, config) { // Use configured public directory or default const publicDirPath = config.paths?.publicDir || './public'; const publicDir = path.resolve(projectDir, publicDirPath); // Check if public directory exists if (!await fs.pathExists(publicDir)) { console.log(`Public directory not found: ${publicDir}`); return []; } const htmlPattern = path.join(publicDir, '**/*.html'); const htmlFiles = await globAsync(htmlPattern); console.log(`Found ${htmlFiles.length} HTML files in ${publicDir}`); return htmlFiles.map(file => { const relativePath = path.relative(publicDir, file); const route = relativePath.replace(/\.html$/, ''); const url = route === 'index' ? '/' : `/${route}`; console.log(`Mapped file ${file} -> URL: ${url}`); return { url: url.replace(/\\/g, '/'), file, type: 'page', framework: 'static', lastmod: new Date().toISOString() }; }); } /** * Copy template files to the project */ async function copyTemplates(outputDir) { const templatesDir = path.join(__dirname, '..', 'templates'); const targetTemplatesDir = path.join(outputDir, 'templates'); // Create templates directory await fs.ensureDir(targetTemplatesDir); // Create subdirectories await fs.ensureDir(path.join(targetTemplatesDir, 'jsonld')); await fs.ensureDir(path.join(outputDir, 'output')); await fs.ensureDir(path.join(outputDir, 'output', 'verification')); // Copy template files (we'll create these next) const templateFiles = [ 'robots.txt.template', 'sitemap.xml.template', 'meta-tags.html.template' ]; for (const templateFile of templateFiles) { const sourcePath = path.join(templatesDir, templateFile); const targetPath = path.join(targetTemplatesDir, templateFile); if (await fs.pathExists(sourcePath)) { await fs.copy(sourcePath, targetPath); } } return targetTemplatesDir; } /** * Get file modification time */ async function getFileModTime(filePath) { try { const stats = await fs.stat(filePath); return stats.mtime.toISOString(); } catch (error) { return new Date().toISOString(); } } /** * Normalize URL path */ function normalizeUrl(url, baseUrl = '') { if (!url.startsWith('/')) { url = '/' + url; } // Remove trailing slash except for root if (url !== '/' && url.endsWith('/')) { url = url.slice(0, -1); } return baseUrl + url; } module.exports = { detectFramework, detectNextjsAppRouter, generateNextjsAppRouterGuidance, scanPages, copyTemplates, getFileModTime, normalizeUrl, scanNextjsAppRouter, scanNextjsPagesRouter, scanReactPages, scanVuePages, scanAngularPages, scanStaticPages };