cliseo
Version:
Instant AI-Powered SEO Optimization CLI for Developers
235 lines • 9.71 kB
JavaScript
import { existsSync } from 'fs';
import * as fs from 'fs/promises';
import path from 'path';
import { glob } from 'glob';
import * as t from '@babel/types';
const helmetImportName = 'Helmet';
const schemaObject = {
"@context": "https://schema.org",
"@type": "WebPage",
"name": "Your Page Title",
"description": "Your page description",
"url": "https://yourdomain.com/current-page"
};
const helmetJSXElement = t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier('Helmet'), [], false), t.jsxClosingElement(t.jsxIdentifier('Helmet')), [
// title element
t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier('title'), [], false), t.jsxClosingElement(t.jsxIdentifier('title')), [t.jsxText('Your Site Title')], false),
// meta description
t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier('meta'), [
t.jsxAttribute(t.jsxIdentifier('name'), t.stringLiteral('description')),
t.jsxAttribute(t.jsxIdentifier('content'), t.stringLiteral('Default description for this page'))
], true), null, [], true),
// canonical link
t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier('link'), [
t.jsxAttribute(t.jsxIdentifier('rel'), t.stringLiteral('canonical')),
t.jsxAttribute(t.jsxIdentifier('href'), t.stringLiteral('https://yourdomain.com/current-page'))
], true), null, [], true),
// schema script tag with dangerouslySetInnerHTML
t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier('script'), [
t.jsxAttribute(t.jsxIdentifier('type'), t.stringLiteral('application/ld+json')),
t.jsxAttribute(t.jsxIdentifier('dangerouslySetInnerHTML'), t.jsxExpressionContainer(t.objectExpression([
t.objectProperty(t.identifier('__html'), t.stringLiteral(JSON.stringify(schemaObject, null, 2)))
])))
], true), null, [], true)
], false);
/**
* Determines if a given file path is likely a React "page" component
* based on common naming or folder conventions.
* Skips main entry files like App.tsx.
*/
function isLikelyPageFile(filePath) {
const normalized = filePath.replace(/\\/g, '/');
const base = path.basename(filePath);
// Must be in pages directory
if (!/\/(pages|routes|views)\//.test(normalized)) {
return false;
}
// Exclude main entry files like App.tsx, App.jsx, index.tsx, index.jsx
if (/^(App|index)\.(jsx?|tsx?)$/.test(base)) {
return false;
}
return true;
}
/**
* 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();
}
/**
* Helper function to get the name of a JSX element (or nested member expression).
*
* @param {t.JSXIdentifier | t.JSXMemberExpression} node - JSX node to extract name from
* @returns {string | null} - The extracted name or null
*/
function getJSXElementName(node) {
if (!node)
return null;
if (node.type === 'JSXIdentifier')
return node.name;
if (node.type === 'JSXMemberExpression') {
return `${getJSXElementName(node.object)}.${getJSXElementName(node.property)}`;
}
return null;
}
/**
* Parses and transforms a React component file by injecting Helmet metadata.
*
* - Detects if the file is already using `react-helmet`
* - Adds an import and JSX element if not present
* - Preserves original formatting by using targeted string manipulation
*
* @param {string} file - Absolute path to the source file
*/
async function transformFile(file) {
const source = await fs.readFile(file, 'utf-8');
let modifiedSource = source;
let modified = false;
// Check if react-helmet is already imported
const hasHelmetImport = /import.*{.*Helmet.*}.*from.*['"]react-helmet['"]/.test(source);
// Check if Helmet is already being used
const hasHelmetUsage = /<Helmet[\s>]/.test(source);
if (hasHelmetUsage) {
if (process.env.CLISEO_VERBOSE === 'true') {
console.log(`[cliseo debug] File already has Helmet usage: ${file}`);
}
return false;
}
// Add import if missing
if (!hasHelmetImport) {
const importMatch = modifiedSource.match(/^(import.*from.*['"][^'"]*['"];?\s*\n)*/m);
if (importMatch) {
const insertPos = importMatch[0].length;
modifiedSource =
modifiedSource.slice(0, insertPos) +
`import { Helmet } from 'react-helmet';\n` +
modifiedSource.slice(insertPos);
modified = true;
}
}
// Find JSX return statements and add Helmet
const returnMatches = [...modifiedSource.matchAll(/return\s*\(\s*\n?\s*(<[^>]+>)/g)];
for (const match of returnMatches) {
const returnIndex = match.index;
const jsxStart = match.index + match[0].length - match[1].length;
// Find the opening JSX tag
const openingTag = match[1];
const tagEnd = modifiedSource.indexOf('>', jsxStart) + 1;
// Find the indentation of the JSX element
const lines = modifiedSource.slice(0, jsxStart).split('\n');
const lastLine = lines[lines.length - 1];
const indentation = lastLine.match(/^\s*/)?.[0] || ' ';
// Create properly formatted Helmet element
const helmetElement = `<Helmet>
${indentation} <title>Your Site Title</title>
${indentation} <meta name="description" content="Default description for this page" />
${indentation} <link rel="canonical" href="https://yourdomain.com/current-page" />
${indentation} <script
${indentation} type="application/ld+json"
${indentation} dangerouslySetInnerHTML={{
${indentation} __html: JSON.stringify({
${indentation} "@context": "https://schema.org",
${indentation} "@type": "WebPage",
${indentation} "name": "Your Page Title",
${indentation} "description": "Your page description",
${indentation} "url": "https://yourdomain.com/current-page"
${indentation} }, null, 2)
${indentation} }}
${indentation} />
${indentation}</Helmet>
${indentation}`;
// Insert Helmet right after the opening tag
modifiedSource =
modifiedSource.slice(0, tagEnd) +
'\n' + indentation + helmetElement +
modifiedSource.slice(tagEnd);
modified = true;
break; // Only modify the first return statement
}
// Add alt attributes to images missing them
const imgMatches = [...modifiedSource.matchAll(/<img\s+[^>]*src=[^>]*(?!alt\s*=)[^>]*>/g)];
for (const match of imgMatches.reverse()) { // Reverse to maintain indices
const imgTag = match[0];
const imgIndex = match.index;
// Check if it already has alt attribute
if (!/\salt\s*=/.test(imgTag)) {
const modifiedImg = imgTag.replace(/(\s*\/?>)$/, ' alt="Image description"$1');
modifiedSource =
modifiedSource.slice(0, imgIndex) +
modifiedImg +
modifiedSource.slice(imgIndex + imgTag.length);
modified = true;
}
}
if (modified) {
if (process.env.CLISEO_VERBOSE === 'true') {
console.log(`[cliseo debug] Modifications made in file: ${file}, writing changes...`);
}
await fs.writeFile(file, modifiedSource, 'utf-8');
return true;
}
else {
if (process.env.CLISEO_VERBOSE === 'true') {
console.log(`[cliseo debug] No modifications needed for file: ${file}`);
}
return false;
}
}
async function findReactFiles(dir) {
// Only look in the pages directory
const pagesDir = path.join(dir, 'pages');
if (!existsSync(pagesDir)) {
console.log(`No pages directory found at ${pagesDir}`);
return [];
}
const files = await glob('**/*.{js,jsx,ts,tsx}', { cwd: pagesDir, absolute: true });
return files;
}
/**
* Injects Helmet metadata into all relevant React page files in the project.
* This skips files that don't appear to be top-level page components.
*/
export async function optimizeReactComponents(projectRoot) {
const srcDir = path.join(projectRoot, 'src');
const files = await findReactFiles(srcDir);
if (files.length === 0) {
if (process.env.CLISEO_VERBOSE === 'true') {
console.log('No React page files found to optimize.');
}
return;
}
if (process.env.CLISEO_VERBOSE === 'true') {
console.log(`Found ${files.length} React page files to optimize:`);
}
let modifiedCount = 0;
for (const file of files) {
if (!isLikelyPageFile(file)) {
if (process.env.CLISEO_VERBOSE === 'true')
console.log(`Skipping: ${path.relative(projectRoot, file)}`);
continue;
}
if (process.env.CLISEO_VERBOSE === 'true')
console.log(`Processing: ${path.relative(projectRoot, file)}`);
try {
const changed = await transformFile(file);
if (changed)
modifiedCount++;
}
catch (error) {
console.error(`Failed to transform ${file}: ${error}`);
}
}
const summaryMsg = `React components optimized${modifiedCount ? `: modified ${modifiedCount} file(s)` : ' (no changes needed)'}`;
console.log(summaryMsg);
}
//# sourceMappingURL=optimize-react.js.map