vibe-seo
Version:
AI-friendly SEO generator for modern web frameworks
848 lines (717 loc) ⢠26.3 kB
JavaScript
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
};