UNPKG

cliseo

Version:

Instant AI-Powered SEO Optimization CLI for Developers

645 lines 23.6 kB
import { glob } from 'glob'; import * as cheerio from 'cheerio'; import chalk from 'chalk'; import ora from 'ora'; import { readFile } from 'fs/promises'; import { join, dirname, resolve } from 'path'; import { readFileSync } from 'fs'; import { loadConfig } from '../utils/config.js'; import fs from 'fs'; // import { authCommand } from './auth.js'; // Removed - no longer needed // import { file } from '@babel/types'; // Removed - unused import // import { detectFramework, findProjectRoot } from '../utils/detect-framework.js'; // import { scanReactComponent } from '../frameworks/react.js'; import axios from 'axios'; import { requiresEmailVerification } from './verify-email.js'; /** * Find project root (where package.json is) * * @param startDir - Directory to start search from * @returns Path to 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 } /** * 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 ('next' in deps) return 'next.js'; if ('react' in deps || 'react-dom' in deps) return 'react'; if ('vue' in deps) return 'vue'; } catch (e) { // Ignore parse errors } } return 'unknown'; } /** * Scans project for required SEO files (robots.txt, sitemap.xml). * * @returns List of SEO issues found. */ async function checkRequiredSeoFiles() { const issues = []; const root = findProjectRoot(); // Check in both root and public directories for SEO files const possiblePaths = [ { robots: join(root, 'robots.txt'), sitemap: join(root, 'sitemap.xml') }, { robots: join(root, 'public', 'robots.txt'), sitemap: join(root, 'public', 'sitemap.xml') } ]; let robotsFound = false; let sitemapFound = false; // Check all possible locations for (const paths of possiblePaths) { try { await readFile(paths.robots, 'utf-8'); robotsFound = true; break; } catch { // Continue checking other locations } } for (const paths of possiblePaths) { try { await readFile(paths.sitemap, 'utf-8'); sitemapFound = true; break; } catch { // Continue checking other locations } } if (!robotsFound) { issues.push({ type: 'error', message: 'Missing robots.txt file', file: 'robots.txt', fix: 'Run `cliseo optimize` to generate a robots.txt file with recommended settings', }); } if (!sitemapFound) { issues.push({ type: 'error', message: 'Missing sitemap.xml file', file: 'sitemap.xml', fix: 'Run `cliseo optimize` to generate a sitemap.xml file with your site structure', }); } return issues; } /* Basic SEO rules for scan */ const basicSeoRules = { missingTitle: (doc) => !doc('title').length, missingMetaDescription: (doc) => !doc('meta[name="description"]').length, missingAltTags: (doc) => doc('img:not([alt])').length > 0, missingViewport: (doc) => !doc('meta[name="viewport"]').length, missingRobotsTxt: async (projectRoot) => { try { await readFile(join(projectRoot, 'robots.txt')); return false; } catch { return true; } }, }; /** * Checks if a file is a page component that needs meta tag management * * @param filePath - Path to file to scan * @returns True if the file is a page component, false otherwise. */ function isPageComponent(filePath) { // Skip entry point files if (filePath.endsWith('main.tsx') || filePath.endsWith('index.tsx') || filePath.endsWith('App.tsx')) { return false; } // Skip files that don't export a component if (filePath.endsWith('vite-env.d.ts') || filePath.endsWith('.css')) { return false; } const pagePatterns = [ /\/pages\//, // pages directory /\/views\//, // views directory /\/screens\//, // screens directory /\/routes\//, // routes directory /^src\/App\.tsx$/, // App component ]; return pagePatterns.some(pattern => pattern.test(filePath)); } /** * Checks file for missing schema.org markup * * @param filePath - Path to file to scan * @returns List of SEO issues found. */ async function checkSchemaMarkup(filePath) { const issues = []; const content = await readFile(filePath, 'utf-8'); // Only check page components if (!isPageComponent(filePath)) { return issues; } // Check for existing schema markup const hasSchemaScript = content.includes('application/ld+json'); const hasSchemaProps = content.includes('itemScope') || content.includes('itemType'); if (!hasSchemaScript && !hasSchemaProps) { // Determine page type from file path/name const fileName = filePath.toLowerCase(); if (fileName.includes('blog') || fileName.includes('article')) { issues.push({ type: 'warning', message: 'Missing Article schema markup', file: filePath, fix: 'Add Article schema.org markup for better search results', }); } else if (fileName.includes('product')) { issues.push({ type: 'warning', message: 'Missing Product schema markup', file: filePath, fix: 'Add Product schema.org markup for rich product snippets', }); } else { issues.push({ type: 'warning', message: 'Missing WebPage schema markup', file: filePath, fix: 'Add basic WebPage schema.org markup', }); } } return issues; } /** * Checks React component for SEO issues. * * @param filePath - Path to React component file to scan. * @returns List of SEO issues found. */ async function scanReactComponent(filePath) { //ignore entry point files if (filePath.endsWith('main.tsx') || filePath.endsWith('index.tsx') || filePath.endsWith('App.tsx')) { return []; } const issues = []; const content = await readFile(filePath, 'utf-8'); // Check if file uses React Helmet or similar const hasHelmet = content.includes('import { Helmet }') || content.includes("import {Helmet}") || content.includes('import {Head}') || content.includes('import { Head }') || content.includes('next/head') || content.includes('useHead') || content.includes('useSeoMeta'); if (!hasHelmet && isPageComponent(filePath)) { // Check if the file might be using Helmet from a parent component const appContent = await readFile(join(dirname(filePath), '../App.tsx'), 'utf-8').catch(() => ''); const hasParentHelmet = appContent.includes('import { Helmet }') || appContent.includes('<Helmet>'); if (!hasParentHelmet) { issues.push({ type: 'warning', message: 'No meta tag management library found', file: filePath, fix: 'Consider using react-helmet, next/head, or similar for managing meta tags', }); } } // Check for img tags without alt using regex that accounts for JSX const imgTagRegex = /<img[^>]*?>/g; const altRegex = /alt=["'][^"']*["']|alt=\{[^}]+\}/; let match; while ((match = imgTagRegex.exec(content)) !== null) { const imgTag = match[0]; if (!altRegex.test(imgTag)) { issues.push({ type: 'warning', message: 'Image missing alt text', file: filePath, element: imgTag, fix: 'Add descriptive alt text to the image', }); } } const linkRegex = /<(?:Link|a)[^>]*>([^<]*)<\/(?:Link|a)>/g; while ((match = linkRegex.exec(content)) !== null) { const linkTag = match[0]; const linkText = match[1].trim(); // Check for empty links if (!linkText) { issues.push({ type: 'error', message: 'Empty link found', file: filePath, element: linkTag, fix: 'Add descriptive text to the link', }); } } // Check for links that only contain images without alt text const imgLinkRegex = /<(?:Link|a)[^>]*>\s*<img[^>]*?>\s*<\/(?:Link|a)>/g; while ((match = imgLinkRegex.exec(content)) !== null) { const linkTag = match[0]; if (!altRegex.test(linkTag)) { issues.push({ type: 'warning', message: 'Link contains image without alt text', file: filePath, element: linkTag, fix: 'Add alt text to the image to describe the link destination', }); } } // Check for semantic HTML issues const divRegex = /<div[^>]*>.*?<\/div>/g; while ((match = divRegex.exec(content)) !== null) { const divContent = match[0].toLowerCase(); if (divContent.includes('nav') && !divContent.includes('<nav')) { issues.push({ type: 'warning', message: 'Navigation not using semantic <nav> element', file: filePath, element: match[0], fix: 'Replace div with semantic <nav> element for better SEO', }); } if ((divContent.includes('main content') || divContent.includes('content main')) && !divContent.includes('<main')) { issues.push({ type: 'warning', message: 'Main content not using semantic <main> element', file: filePath, element: match[0], fix: 'Replace div with semantic <main> element for better SEO', }); } } // Check for schema markup const schemaIssues = await checkSchemaMarkup(filePath); issues.push(...schemaIssues); return issues; } /** * Checks Vue component for SEO issues. * * @param filePath - Path to Vue component file to scan. * @returns List of SEO issues found. */ async function scanVueComponent(filePath) { const issues = []; const content = await readFile(filePath, 'utf-8'); //entry point files if (filePath.endsWith('main.tsx') || filePath.endsWith('main.js')) { const usesVueMeta = /['"]vue-meta['"]/.test(content) || content.includes('createMetaManager'); if (!usesVueMeta) { issues.push({ type: 'warning', message: 'No meta tag management library found', file: filePath, fix: 'Consider using vue-meta or @vueuse/head for managing meta tags.', }); } } // Check if file uses Vue-meta or similar const hasMeta = /metaInfo\s*\(|\bmeta\s*:\s*\[/.test(content); if (!hasMeta && isPageComponent(filePath)) { issues.push({ type: 'warning', message: 'No meta tag management library found', file: filePath, fix: 'Add a metaInfo() block using vue-meta or define meta:[] for SEO.', }); } // Check for schema markup const schemaIssues = await checkSchemaMarkup(filePath); issues.push(...schemaIssues); return issues; } /** * Checks Next component for SEO issues. * * @param filePath - Path to Next component file to scan. * @returns List of SEO issues found. */ async function scanNextComponent(filePath) { const issues = []; const content = await readFile(filePath, 'utf-8'); if (!isPageComponent(filePath)) return issues; if (!content.includes('<Head>')) { console.error('No <Head> component found in:', filePath); issues.push({ type: 'warning', message: 'No <Head> component found for managing meta tags', file: filePath, fix: 'Consider using next/head, to manage title and meta tags in page components', }); } if (content.includes('<img') && !content.includes('next/image')) { console.error('Image without next/image component found in:', filePath); issues.push({ type: 'warning', message: '<img> used without next/image component', file: filePath, fix: 'Consider using next/image, to optimize images for SEO', }); } console.error('Next.js component scan complete:', filePath); return issues; } /** * Checks file for basic SEO tags (title, meta description, alt tags, viewport). * * @param filePath - Path to file to scan * @returns List of SEO issues found */ async function performBasicScan(filePath) { const framework = await detectFramework(findProjectRoot()); // Skip HTML files for popular frameworks (we assume they handle SEO through components) if (framework != 'unknown') return []; if (!filePath.endsWith('.html')) return []; const issues = []; const content = await readFile(filePath, 'utf-8'); const $ = cheerio.load(content); // Check basic SEO rules if (basicSeoRules.missingTitle($)) { issues.push({ type: 'error', message: 'Missing title tag', file: filePath, fix: 'Add a descriptive title tag', }); } if (basicSeoRules.missingMetaDescription($)) { issues.push({ type: 'warning', message: 'Missing meta description', file: filePath, fix: 'Add a meta description tag', }); } if (basicSeoRules.missingAltTags($)) { const images = $('img:not([alt])'); images.each((_, img) => { issues.push({ type: 'warning', message: 'Image missing alt text', file: filePath, element: $.html(img), fix: 'Add descriptive alt text to the image', }); }); } if (basicSeoRules.missingViewport($)) { issues.push({ type: 'warning', message: 'Missing viewport meta tag', file: filePath, fix: 'Add viewport meta tag for responsive design', }); } return issues; } /** * Main function to scan project for SEO issues. * * @param options - Scan options including AI flag and JSON output */ export async function scanCommand(options) { const spinner = ora({ text: 'Scanning project for SEO issues...', stream: options.json ? process.stderr : process.stdout }).start(); const config = await loadConfig(); let results = []; let framework = 'unknown'; try { // Check authentication if AI is requested if (options.ai) { const { isAuthenticated, hasAiAccess } = await import('../utils/config.js'); const isAuth = await isAuthenticated(); const hasAi = await hasAiAccess(); if (!isAuth) { spinner.stop(); console.log(chalk.yellow('\n⚠️ Authentication required for AI features')); console.log(chalk.cyan('Please authenticate first:')); console.log(chalk.gray(' cliseo auth\n')); return; } if (!hasAi) { spinner.stop(); console.log(chalk.yellow('\n⚠️ AI features are not enabled for your account')); console.log(chalk.gray('Upgrade your plan to access AI features.\n')); return; } // Check email verification for AI features const needsVerification = await requiresEmailVerification(); if (needsVerification) { spinner.stop(); console.log(chalk.yellow('\n⚠️ Email verification required for AI features')); console.log(chalk.cyan('Please verify your email first:')); console.log(chalk.gray(' cliseo verify-email\n')); return; } } const root = findProjectRoot(); const files = await glob('**/*.{html,jsx,tsx,ts,js,vue}', { cwd: root, ignore: [ '**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**', '**/out/**', '**/.git/**', '**/coverage/**', '**/test/**', '**/tests/**', '**/__tests__/**', '**/__mocks__/**', '**/vendor/**', '**/public/**' ], absolute: true, dot: true }); if (files.length === 0) { spinner.fail('No files found to scan. Make sure you are in a project directory.'); return; } if (options.verbose || process.env.CLISEO_VERBOSE === 'true') { console.log(chalk.cyan(`📁 Found ${files.length} files to scan`)); } // Limit files for performance const MAX_FILES = 500; const filteredFiles = files.slice(0, MAX_FILES); if (files.length > MAX_FILES) { console.log(chalk.yellow(`⚠️ Limited scan to first ${MAX_FILES} files for performance`)); } framework = await detectFramework(root); const seoFileIssues = await checkRequiredSeoFiles(); if (seoFileIssues.length > 0) { results.push({ file: 'SEO Files', issues: seoFileIssues, }); } // AI features use backend integration only if (options.ai) { spinner.text = 'Running AI-powered deep analysis...'; } for (const file of filteredFiles) { const basicIssues = await performBasicScan(file); let frameworkIssues = []; if (framework === 'react') { frameworkIssues = await scanReactComponent(file); } else if (framework === 'next.js') { frameworkIssues = await scanNextComponent(file); } else if (framework == 'vue') { frameworkIssues = await scanVueComponent(file); } // Perform AI analysis if enabled let aiIssues = []; if (options.ai) { aiIssues = await performAiScanWithAuth(file); } if (basicIssues.length > 0 || frameworkIssues.length > 0 || aiIssues.length > 0) { results.push({ file, issues: [...basicIssues, ...frameworkIssues, ...aiIssues], }); } } spinner.succeed('Scan completed successfully!'); // Output results if (options.json) { console.log(JSON.stringify(results, null, 2)); } else { displayScanResults(results, framework, options.ai); } } catch (error) { spinner.fail('Scan failed'); if (error instanceof Error) { console.error(chalk.red(error.message)); } else { console.error(chalk.red('An unexpected error occurred')); } process.exit(1); } } /** * Perform AI scan using authenticated backend request */ async function performAiScanWithAuth(file) { try { const { getAuthToken } = await import('../utils/config.js'); const token = await getAuthToken(); if (!token) { return []; } // Read file content const content = await readFile(file, 'utf-8'); // Make request to backend AI endpoint const response = await axios.post('https://a8iza6csua.execute-api.us-east-2.amazonaws.com/ask-openai', { prompt: `Analyze this file for SEO issues and provide specific recommendations:\n\nFile: ${file}\n\nContent:\n${content}`, context: 'seo-analysis' }, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, }); // Parse AI response into issues const aiResponse = response.data.response; // Simple parsing - in a real implementation, you'd want more sophisticated parsing const issues = []; if (aiResponse && aiResponse.includes('SEO')) { issues.push({ type: 'ai-suggestion', message: 'AI-powered SEO recommendations available', file, fix: aiResponse, }); } return issues; } catch (error) { console.warn(chalk.yellow('⚠️ AI analysis failed for file:', file)); return []; } } /** * Helper function to display scan results. * * @param results - Array of ScanResult objects. * @param framework - Detected framework. * @param aiEnabled - Boolean indicating if AI is enabled. */ function displayScanResults(results, framework, aiEnabled) { const frameWorkColor = framework === 'react' ? chalk.blue : framework === 'vue' ? chalk.green : chalk.gray; console.log(chalk.bold('\nDetected Framework: ' + frameWorkColor(framework.toUpperCase()))); results.forEach(result => { if (result.issues.length > 0) { console.log(chalk.underline('\nFile:', result.file)); result.issues.forEach(issue => { const icon = issue.type === 'error' ? '❌' : issue.type === 'ai-suggestion' ? '🤖' : '⚠️'; console.log(`${icon} ${chalk.bold(issue.message)}`); console.log(` ${chalk.gray('Fix:')} ${issue.fix}`); if (issue.element) { console.log(` ${chalk.gray('Element:')} ${issue.element}`); } }); } }); const totalIssues = results.reduce((sum, r) => sum + r.issues.length, 0); const filesWithIssues = results.filter(r => r.issues.length > 0).length; console.log(chalk.bold('\nSummary:')); console.log(`Found ${totalIssues} issue${totalIssues === 1 ? '' : 's'} in ${filesWithIssues} file${filesWithIssues === 1 ? '' : 's'}.`); } //# sourceMappingURL=scan.js.map