UNPKG

cliseo

Version:

Instant AI-Powered SEO Optimization CLI for Developers

259 lines 11.6 kB
import { existsSync } from 'fs'; import * as fs from 'fs/promises'; import path from 'path'; import { glob } from 'glob'; import { parse } from '@babel/parser'; import _traverse from '@babel/traverse'; import _generate from '@babel/generator'; import * as t from '@babel/types'; import chalk from 'chalk'; const traverse = _traverse.default; const generate = _generate.default; /** * Recursively walks up the directory tree to find the project root. * Assumes the root contains a `package.json`. * * @param {string} [startDir=process.cwd()] - Directory to start search from * @returns {string} - Project root directory path */ function findProjectRoot(startDir = process.cwd()) { let dir = path.resolve(startDir); while (dir !== path.dirname(dir)) { if (existsSync(path.join(dir, 'package.json'))) return dir; dir = path.dirname(dir); } return process.cwd(); } /** * Determines if a given file path is likely a Next.js "page" component * based on common naming or folder conventions. * * @param {string} filePath - Absolute or relative path to the file * @returns {boolean} - True if the file is likely a page component */ function isLikelyPageFile(filePath) { const normalized = filePath.replace(/\\/g, '/'); const fileName = path.basename(filePath); // Exclude config and utility files if (/(config|\.config|\.d\.ts|types|utils|constants|hooks|context)/.test(fileName)) { return false; } // Include Next.js App Router files if (/\/(app|pages|routes|views)\//.test(normalized)) { return true; } // Include component files that might be pages if (/components\//.test(normalized) && /(tsx?|jsx?)$/.test(fileName)) { return true; } // Include files with page-like names if (/(Page|Screen|Route|page|layout)\.(jsx?|tsx?|js?|ts?)$/.test(fileName)) { return true; } return false; } const headNode = () => t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier('Head'), [], false), t.jsxClosingElement(t.jsxIdentifier('Head')), [ // <title>{title}</title> t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier('title'), [], false), t.jsxClosingElement(t.jsxIdentifier('title')), [t.jsxExpressionContainer(t.stringLiteral('title'))], false), // <meta name="description" content={description} /> t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier('meta'), [ t.jsxAttribute(t.jsxIdentifier('name'), t.stringLiteral('description')), t.jsxAttribute(t.jsxIdentifier('content'), t.stringLiteral('description')), ], true), null, [], true), // <meta name="robots" content="index, follow" /> t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier('meta'), [ t.jsxAttribute(t.jsxIdentifier('name'), t.stringLiteral('robots')), t.jsxAttribute(t.jsxIdentifier('content'), t.stringLiteral('index, follow')), ], true), null, [], true), // <link rel="canonical" href={canonicalUrl} /> t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier('link'), [ t.jsxAttribute(t.jsxIdentifier('rel'), t.stringLiteral('canonical')), t.jsxAttribute(t.jsxIdentifier('href'), t.stringLiteral('canonicalUrl')), ], true), null, [], true), // <meta property="og:title" content={title} /> t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier('meta'), [ t.jsxAttribute(t.jsxIdentifier('property'), t.stringLiteral('og:title')), t.jsxAttribute(t.jsxIdentifier('content'), t.stringLiteral('title')), ], true), null, [], true), // <meta property="og:description" content={description} /> t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier('meta'), [ t.jsxAttribute(t.jsxIdentifier('property'), t.stringLiteral('og:description')), t.jsxAttribute(t.jsxIdentifier('content'), t.stringLiteral('description')), ], true), null, [], true), // <meta property="og:image" content={ogImage} /> t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier('meta'), [ t.jsxAttribute(t.jsxIdentifier('property'), t.stringLiteral('og:image')), t.jsxAttribute(t.jsxIdentifier('content'), t.stringLiteral('ogImage')), ], true), null, [], true), // <meta property="og:type" content="website" /> t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier('meta'), [ t.jsxAttribute(t.jsxIdentifier('property'), t.stringLiteral('og:type')), t.jsxAttribute(t.jsxIdentifier('content'), t.stringLiteral('website')), ], true), null, [], true), // <meta property="og:url" content={canonicalUrl} /> t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier('meta'), [ t.jsxAttribute(t.jsxIdentifier('property'), t.stringLiteral('og:url')), t.jsxAttribute(t.jsxIdentifier('content'), t.stringLiteral('canonicalUrl')), ], true), null, [], true), // <meta name="twitter:card" content="summary_large_image" /> t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier('meta'), [ t.jsxAttribute(t.jsxIdentifier('name'), t.stringLiteral('twitter:card')), t.jsxAttribute(t.jsxIdentifier('content'), t.stringLiteral('summary_large_image')), ], true), null, [], true), // <meta name="twitter:title" content={title} /> t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier('meta'), [ t.jsxAttribute(t.jsxIdentifier('name'), t.stringLiteral('twitter:title')), t.jsxAttribute(t.jsxIdentifier('content'), t.stringLiteral('title')), ], true), null, [], true), // <meta name="twitter:description" content={description} /> t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier('meta'), [ t.jsxAttribute(t.jsxIdentifier('name'), t.stringLiteral('twitter:description')), t.jsxAttribute(t.jsxIdentifier('content'), t.stringLiteral('description')), ], true), null, [], true), // <meta name="twitter:image" content={ogImage} /> t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier('meta'), [ t.jsxAttribute(t.jsxIdentifier('name'), t.stringLiteral('twitter:image')), t.jsxAttribute(t.jsxIdentifier('content'), t.stringLiteral('ogImage')), ], true), null, [], true), ], false); const seoHeadJSXElement = headNode(); /** * Gets the name of a JSX element from its identifier. * * @param name - The name of the JSX element * @returns {string} - The name of the JSX element as a string */ function getJSXElementName(name) { return t.isJSXIdentifier(name) ? name.name : ''; } /** * Injects next/head tags into a Next.js component file. * * @param {string} file - Path to the file to transform */ export async function transformFile(file) { console.log(`Reading file: ${file}`); const code = await fs.readFile(file, 'utf8'); if (code.includes('@next/head')) { console.log(chalk.yellow(`Skipping file with @next/head import: ${file}`)); return; } const ast = parse(code, { sourceType: 'module', plugins: ['jsx', 'typescript'], }); let metadataVariable = null; let modified = false; traverse(ast, { ExportNamedDeclaration(path) { if (t.isVariableDeclaration(path.node.declaration)) { for (const declarator of path.node.declaration.declarations) { if (t.isIdentifier(declarator.id) && declarator.id.name === 'metadata') { metadataVariable = declarator; break; } } } }, }); const seoProperties = [ t.objectProperty(t.identifier('description'), t.stringLiteral('SEO optimized description')), t.objectProperty(t.identifier('robots'), t.stringLiteral('index, follow')), t.objectProperty(t.identifier('openGraph'), t.objectExpression([ t.objectProperty(t.identifier('title'), t.stringLiteral('OpenGraph Title')), t.objectProperty(t.identifier('description'), t.stringLiteral('OpenGraph Description')), ])), ]; if (metadataVariable && t.isObjectExpression(metadataVariable.init)) { // seoProperties.forEach(prop => { if (t.isObjectProperty(prop)) { const key = prop.key.name; const exists = metadataVariable.init; if (!exists.properties.some(p => t.isObjectProperty(p) && p.key.name === key)) { metadataVariable.init.properties.push(prop); modified = true; } } }); } else { // const metadataExport = t.exportNamedDeclaration(t.variableDeclaration('const', [ t.variableDeclarator(t.identifier('metadata'), t.objectExpression(seoProperties)), ])); // const program = ast.program; program.body.unshift(metadataExport); modified = true; } if (modified) { const output = generate(ast, {}, code); // Format the code with Prettier to ensure proper formatting let formattedCode; try { // Detect file type from extension const isTypeScript = file.endsWith('.tsx') || file.endsWith('.ts'); const parser = isTypeScript ? 'typescript' : 'babel'; const prettier = await import('prettier'); formattedCode = await prettier.format(output.code, { parser, semi: true, singleQuote: true, trailingComma: 'es5', tabWidth: 2, printWidth: 80, }); } catch (prettierError) { // If Prettier import or formatting fails, fall back to unformatted code if (process.env.CLISEO_VERBOSE === 'true') { console.warn(`Prettier formatting failed for ${file}, using unformatted code:`, prettierError); } formattedCode = output.code; } await fs.writeFile(file, formattedCode, 'utf8'); console.log(chalk.green(` • Successfully injected SEO optimizations in file: ${file}`)); } else { console.log(`No modifications needed for: ${file}`); } } /** * Optimizes Next.js components by injecting SEO-friendly <Head> tags. */ export async function optimizeNextjsComponents(targetDir) { const rootDir = targetDir || process.cwd(); console.log(chalk.blue(`Processing Next.js components from root: ${rootDir}`)); const pagesDir = path.join(rootDir, 'pages'); const appDir = path.join(rootDir, 'app'); const srcPagesDir = path.join(rootDir, 'src', 'pages'); const srcAppDir = path.join(rootDir, 'src', 'app'); const componentDirs = [pagesDir, appDir, srcPagesDir, srcAppDir].filter(dir => existsSync(dir)); if (componentDirs.length === 0) { console.log(chalk.yellow('No pages or app directory found. Skipping component optimization.')); return; } let totalFilesProcessed = 0; for (const dir of componentDirs) { console.log(chalk.cyan(`Searching for Next.js files in: ${dir}`)); const files = await glob('**/*.{js,jsx,ts,tsx}', { cwd: dir, absolute: true, ignore: ['**/node_modules/**', '**/*.test.*', '**/*.spec.*'], }); console.log(chalk.cyan(`Found ${files.length} files in ${dir}`)); for (const file of files) { try { await transformFile(file); totalFilesProcessed++; } catch (error) { console.error(chalk.red(`Failed to transform ${file}:`), error); } } } console.log(`\nProcessed ${totalFilesProcessed} Next.js files total.\n`); } //# sourceMappingURL=optimize-next.js.map