UNPKG

cliseo

Version:

Instant AI-Powered SEO Optimization CLI for Developers

465 lines (451 loc) 17.5 kB
import { writeFile, access } from 'fs/promises'; import { readFileSync, existsSync } from 'fs'; import * as cheerio from 'cheerio'; import { join, dirname, resolve } from 'path'; import chalk from 'chalk'; import ora from 'ora'; import { glob } from 'glob'; import fs from 'fs'; import path from 'path'; import { optimizeReactComponents } from './optimize-react.js'; import { optimizeAngularComponents } from './optimize-angular.js'; import { optimizeNextjsComponents } from './optimize-next.js'; /** * Finds the project root directory * * @param startDir - Directory to start searching from (default: current working directory) * @returns Path to the project root directory */ function findProjectRoot(startDir = process.cwd()) { let dir = resolve(startDir); while (dir !== dirname(dir)) { if (fs.existsSync(join(dir, 'package.json'))) return dir; dir = dirname(dir); } return process.cwd(); // fallback } /** * Ensures the existence of SEO files */ async function ensureSeoFiles(framework, projectRoot) { let robotsCreated = false; let sitemapCreated = false; // decide target directory based on framework let targetDir = projectRoot; if (framework === 'react' || framework === 'next.js') { targetDir = join(projectRoot, 'public'); } else if (framework === 'angular') { targetDir = join(projectRoot, 'src', 'assets'); } // make sure directory exists try { await fs.promises.mkdir(targetDir, { recursive: true }); } catch { } const robotsPath = join(targetDir, 'robots.txt'); const sitemapPath = join(targetDir, 'sitemap.xml'); // Enhanced robots.txt template const robotsContent = `# robots.txt for ${projectRoot.split('/').pop() || 'your site'} # Allow all crawlers User-agent: * Allow: / # Disallow admin and private areas Disallow: /admin/ Disallow: /private/ Disallow: /api/ Disallow: /_next/ Disallow: /static/ # Crawl-delay for specific bots User-agent: GPTBot Crawl-delay: 1 User-agent: ChatGPT-User Crawl-delay: 1 # Sitemap location Sitemap: https://yourdomain.com/sitemap.xml`; // Enhanced sitemap.xml template const sitemapContent = `<?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"> <!-- Homepage --> <url> <loc>https://yourdomain.com/</loc> <lastmod>${new Date().toISOString()}</lastmod> <changefreq>daily</changefreq> <priority>1.0</priority> </url> <!-- Common pages --> <url> <loc>https://yourdomain.com/about</loc> <lastmod>${new Date().toISOString()}</lastmod> <changefreq>monthly</changefreq> <priority>0.8</priority> </url> <url> <loc>https://yourdomain.com/contact</loc> <lastmod>${new Date().toISOString()}</lastmod> <changefreq>monthly</changefreq> <priority>0.8</priority> </url> <!-- Blog section --> <url> <loc>https://yourdomain.com/blog</loc> <lastmod>${new Date().toISOString()}</lastmod> <changefreq>weekly</changefreq> <priority>0.9</priority> </url> </urlset>`; try { await access(robotsPath); } catch { await writeFile(robotsPath, robotsContent); robotsCreated = true; } try { await access(sitemapPath); } catch { await writeFile(sitemapPath, sitemapContent); sitemapCreated = true; } // special note for angular: remind user to include assets path if (framework === 'angular' && sitemapCreated) { if (process.env.CLISEO_VERBOSE === 'true') { console.log(chalk.yellow('ℹ️ Remember to ensure sitemap.xml is listed under assets in angular.json so it is copied to the build output.')); } } return { robotsCreated, sitemapCreated }; } /** * Determines the pages directory for a given framework */ function getPagesDirectory(projectRoot, framework) { const directories = []; switch (framework) { case 'next.js': // Check for both pages/ and app/ directories (Next.js supports both) if (existsSync(join(projectRoot, 'pages'))) { directories.push(join(projectRoot, 'pages')); } if (existsSync(join(projectRoot, 'app'))) { directories.push(join(projectRoot, 'app')); } break; case 'react': // Check for src/pages/ directory if (existsSync(join(projectRoot, 'src', 'pages'))) { directories.push(join(projectRoot, 'src', 'pages')); } break; case 'angular': // Check for src/app/pages/ first, then fallback to src/app/ if (existsSync(join(projectRoot, 'src', 'app', 'pages'))) { directories.push(join(projectRoot, 'src', 'app', 'pages')); } else if (existsSync(join(projectRoot, 'src', 'app'))) { directories.push(join(projectRoot, 'src', 'app')); } break; } return directories; } /** * Gets framework-specific file extensions to optimize */ function getFrameworkFileExtensions(framework) { switch (framework) { case 'next.js': case 'react': return ['**/*.{js,jsx,ts,tsx}']; // React/Next.js component files case 'angular': return ['**/*.ts', '**/*.html']; // Angular TypeScript components + templates default: return ['**/*.html']; // Fallback to HTML only } } /** * Gets all files to optimize based on framework and pages directory */ async function getFilesToOptimize(projectRoot, framework) { const pagesDirectories = getPagesDirectory(projectRoot, framework); const files = []; if (pagesDirectories.length === 0) { console.log(chalk.yellow(`⚠️ No pages directory found for ${framework}. Skipping file optimizations.`)); return files; } if (process.env.CLISEO_VERBOSE === 'true') { console.log(chalk.cyan(`📁 Found pages directories: ${pagesDirectories.map(dir => path.relative(projectRoot, dir)).join(', ')}`)); } // Get framework-specific file extensions const extensions = getFrameworkFileExtensions(framework); for (const pagesDir of pagesDirectories) { for (const ext of extensions) { const foundFiles = await glob(ext, { cwd: pagesDir, absolute: true, ignore: ['**/node_modules/**'] }); files.push(...foundFiles); } } if (process.env.CLISEO_VERBOSE === 'true') { console.log(chalk.cyan(`📄 Found ${files.length} files to optimize`)); } return files; } /** * Adds meta tags to HTML files in the pages directory. */ async function addMetaTagsToHtmlFiles(projectRoot, framework) { const files = await getFilesToOptimize(projectRoot, framework); // Only process HTML files - framework-specific optimizers handle component files const htmlFiles = files.filter(file => file.endsWith('.html')); if (htmlFiles.length === 0) { if (process.env.CLISEO_VERBOSE === 'true') { console.log(chalk.gray(`No HTML files found in pages directory. Component files will be handled by ${framework} optimizer.`)); } return; } console.log(chalk.cyan(`🔧 Adding meta tags to ${htmlFiles.length} HTML files...`)); for (const file of htmlFiles) { const content = await fs.promises.readFile(file, 'utf-8'); const $ = cheerio.load(content); // Add title if missing if (!$('title').length) { $('head').append('<title>Your Site Title</title>'); } // Add viewport meta if missing if (!$('meta[name="viewport"]').length) { $('head').append('<meta name="viewport" content="width=device-width, initial-scale=1.0">'); } // Add description meta if missing if (!$('meta[name="description"]').length) { $('head').append('<meta name="description" content="Your site description">'); } await fs.promises.writeFile(file, $.html()); } } /** * Adds alt attributes to images in files in the pages directory. */ async function addImagesAltAttributes(projectRoot, framework) { const files = await getFilesToOptimize(projectRoot, framework); // Only process HTML files for image alt attributes const htmlFiles = files.filter(file => file.endsWith('.html')); if (htmlFiles.length === 0) { if (process.env.CLISEO_VERBOSE === 'true') { console.log(chalk.gray(`No HTML files found in pages directory. Image alt attributes will be handled by framework-specific optimizers.`)); } return; } console.log(chalk.cyan(`🖼️ Adding alt attributes to images in ${htmlFiles.length} HTML files...`)); for (const file of htmlFiles) { const content = await fs.promises.readFile(file, 'utf-8'); const $ = cheerio.load(content); $('img').each((_, element) => { if (!$(element).attr('alt')) { const src = $(element).attr('src') || ''; const alt = path.basename(src, path.extname(src)) .replace(/[-_]/g, ' ') .replace(/([A-Z])/g, ' $1') .trim(); $(element).attr('alt', alt); } }); await fs.promises.writeFile(file, $.html()); } } /** * Scans all package.json files in the project for framework dependencies. * Returns the first framework found, or 'unknown' if none found. */ async function detectFramework(projectRoot) { // Find all package.json files, excluding node_modules and common build/test dirs const packageJsonFiles = await glob('**/package.json', { cwd: projectRoot, ignore: [ '**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**', '**/out/**', '**/.git/**', '**/coverage/**', '**/test/**', '**/tests/**', '**/__tests__/**', '**/__mocks__/**', '**/vendor/**', '**/public/**' ], absolute: true, dot: true }); for (const pkgPath of packageJsonFiles) { try { const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); const deps = { ...pkg.dependencies, ...pkg.devDependencies }; if ('@angular/core' in deps) return 'angular'; if ('next' in deps) return 'next.js'; if ('react' in deps || 'react-dom' in deps) return 'react'; } catch (e) { // Ignore parse errors } } return 'unknown'; } /** * * Main function to optimize SEO for the project. */ export async function optimizeCommand(directory, options) { const dir = resolve(directory || '.'); const spinner = ora('Starting SEO optimization...').start(); try { // Skip AI analysis since it's been removed if (options.ai) { spinner.warn(chalk.yellow('⚠️ AI analysis is currently disabled.')); console.log(''); } // Continue with the rest of the optimization... spinner.start('Running standard SEO optimizations...'); spinner.text = 'Checking SEO files...'; const framework = await detectFramework(dir); const { robotsCreated, sitemapCreated } = await ensureSeoFiles(framework, dir); if (process.env.CLISEO_VERBOSE === 'true') { spinner.succeed('SEO file check complete!'); } else { spinner.stop(); } if (process.env.CLISEO_VERBOSE === 'true') { if (robotsCreated) console.log(chalk.green('✔ Created robots.txt')); if (sitemapCreated) console.log(chalk.green('✔ Created sitemap.xml')); if (!robotsCreated && !sitemapCreated) console.log(chalk.gray('robots.txt and sitemap.xml already exist.')); } spinner.text = 'Adding proper tags to HTML files...'; // Only process HTML files in the main function - framework-specific optimizers handle component files if (framework !== 'unknown') { await addMetaTagsToHtmlFiles(dir, framework); await addImagesAltAttributes(dir, framework); } if (process.env.CLISEO_VERBOSE === 'true') { spinner.succeed('Tags added to HTML files!'); } else { spinner.stop(); } const frameWorkColor = framework === 'angular' ? chalk.red : framework === 'react' ? chalk.blue : chalk.gray; console.log(chalk.bold('\nDetected Framework: ' + frameWorkColor(framework.toUpperCase()))); // Framework-specific optimizations if (framework === 'react') { spinner.text = 'Optimizing React components...'; try { await optimizeReactComponents(dir); if (process.env.CLISEO_VERBOSE === 'true') spinner.succeed('React components optimized successfully!'); else spinner.stop(); } catch (err) { spinner.fail('Failed to optimize React components.'); console.error(err); } } else if (framework === 'angular') { spinner.text = 'Optimizing Angular components...'; try { await optimizeAngularComponents(); if (process.env.CLISEO_VERBOSE === 'true') spinner.succeed('Angular components optimized successfully!'); else spinner.stop(); } catch (err) { spinner.fail('Failed to optimize Angular components.'); console.error(err); } } else if (framework === 'next.js') { spinner.text = 'Optimizing Next.js components...'; try { await optimizeNextjsComponents(dir); if (process.env.CLISEO_VERBOSE === 'true') spinner.succeed('Next.js components optimized successfully!'); else spinner.stop(); } catch (err) { spinner.fail('Failed to optimize Next.js components.'); console.error(err); } } else if (framework === 'unknown') { console.log(chalk.yellow('⚠️ Unknown framework detected. Only basic SEO files were created.')); } console.log(chalk.bold.green('\n✅ SEO optimization complete!')); // Skip interactive prompts in CI/non-TTY environments or when --yes flag is provided const skipPrompts = options.yes || process.env.CI === 'true' || !process.stdin.isTTY; if (!skipPrompts) { // --- PR creation functionality temporarily disabled --- /* // Check if we're in a git repository try { execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' }); // Ask about creating a PR const { createPr } = await inquirer.prompt([ { type: 'confirm', name: 'createPr', message: 'Would you like to create a Pull Request with these changes?', default: false } ]); if (createPr) { spinner.start('Creating Pull Request...'); try { // Create a new branch const branchName = `seo-optimization-${Date.now()}`; execSync(`git checkout -b ${branchName}`); // Add and commit changes execSync('git add .'); execSync('git commit -m "chore: SEO optimizations"'); // Push branch and create PR execSync(`git push -u origin ${branchName}`); // Get the current repository URL const repoUrl = execSync('git config --get remote.origin.url').toString().trim(); const prUrl = repoUrl.replace('.git', `/compare/${branchName}`); spinner.succeed(chalk.green('Pull Request created!')); console.log(chalk.cyan(`PR URL: ${prUrl}`)); } catch (error) { spinner.fail(chalk.red('Failed to create Pull Request.')); console.error(error); } } } catch (error) { // Not inside a git repository; silently skip PR creation } */ // --- End PR creation functionality --- } } catch (error) { spinner.fail(chalk.red('\n❌ An unexpected error occurred during optimization.')); if (error instanceof Error) console.error(chalk.red(error.message)); process.exit(1); } } //# sourceMappingURL=optimize.js.map