cliseo
Version:
Instant AI-Powered SEO Optimization CLI for Developers
376 lines • 16.9 kB
JavaScript
import { existsSync } from 'fs';
import * as fs from 'fs/promises';
import path from 'path';
import { glob } from 'glob';
import * as babel from '@babel/core';
import * as t from '@babel/types';
import jsxSyntax from '@babel/plugin-syntax-jsx';
import typescriptSyntax from '@babel/plugin-syntax-typescript';
import reactJsx from '@babel/plugin-transform-react-jsx';
import typescriptTransform from '@babel/plugin-transform-typescript';
import reactPreset from '@babel/preset-react';
import typescriptPreset from '@babel/preset-typescript';
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
*
* @param {string} file - Absolute path to the source file
*/
async function transformFile(file) {
const source = await fs.readFile(file, 'utf-8');
const ast = babel.parse(source, {
sourceType: 'module',
filename: file,
plugins: [
[jsxSyntax, { allowNamespaces: true }],
[typescriptSyntax, { isTSX: true, allExtensions: true }],
reactJsx,
typescriptTransform
],
presets: [
[reactPreset, { runtime: 'automatic' }],
[typescriptPreset, { allExtensions: true, isTSX: true }]
]
});
if (!ast) {
if (process.env.CLISEO_VERBOSE === 'true') {
console.error(`[cliseo debug] Cannot parse ${file}`);
}
return false;
}
let helmetImported = false;
let modified = false;
babel.traverse(ast, {
Program(path) {
for (const node of path.node.body) {
if (t.isImportDeclaration(node) &&
node.source.value === 'react-helmet') {
helmetImported = true;
break;
}
}
if (!helmetImported) {
const importDecl = t.importDeclaration([t.importSpecifier(t.identifier(helmetImportName), t.identifier(helmetImportName))], t.stringLiteral('react-helmet'));
path.node.body.unshift(importDecl);
helmetImported = true;
}
},
/**
* Handles standard function components (e.g. `function Home() { return (...) }`)
*/
FunctionDeclaration(path) {
if (modified) {
return;
}
path.traverse({
ReturnStatement(returnPath) {
const arg = returnPath.node.argument;
if (t.isJSXElement(arg)) {
const tagName = getJSXElementName(arg.openingElement.name);
const hasHelmet = tagName === 'Helmet' ||
arg.children.some(child => t.isJSXElement(child) && getJSXElementName(child.openingElement.name) === 'Helmet');
if (!hasHelmet) {
arg.children.unshift(helmetJSXElement);
returnPath.stop();
modified = true;
}
}
}
});
},
/**
* Handles arrow or named function expressions assigned to variables
* (e.g. `const Home = () => { return (...) }`)
*/
VariableDeclarator(path) {
if (modified)
return;
if (t.isIdentifier(path.node.id) &&
(t.isArrowFunctionExpression(path.node.init) || t.isFunctionExpression(path.node.init))) {
const func = path.node.init;
if (func.body && t.isJSXElement(func.body)) {
const hasHelmet = func.body.children.some(child => t.isJSXElement(child) && getJSXElementName(child.openingElement.name) === 'Helmet');
if (!hasHelmet) {
func.body.children.unshift(helmetJSXElement);
modified = true;
}
}
else if (func.body && t.isBlockStatement(func.body)) {
path.get('init').traverse({
ReturnStatement(returnPath) {
const arg = returnPath.node.argument;
if (t.isJSXElement(arg)) {
const tagName = getJSXElementName(arg.openingElement.name);
const hasHelmet = tagName === 'Helmet' || arg.children.some(child => t.isJSXElement(child) && getJSXElementName(child.openingElement.name) === 'Helmet');
if (!hasHelmet) {
arg.children.unshift(helmetJSXElement);
modified = true;
returnPath.stop();
}
}
}
});
}
}
},
/**
* Handles class components with a render() method
*/
ClassDeclaration(path) {
if (modified)
return;
const body = path.node.body.body;
const renderMethod = body.find(method => t.isClassMethod(method) &&
t.isIdentifier(method.key) && method.key.name === 'render' &&
t.isBlockStatement(method.body));
if (renderMethod) {
path.get('body').get('body').forEach(methodPath => {
if (methodPath.isClassMethod() && t.isIdentifier(methodPath.node.key) && methodPath.node.key.name === 'render') {
methodPath.traverse({
ReturnStatement(returnPath) {
const arg = returnPath.node.argument;
if (t.isJSXElement(arg)) {
const tagName = getJSXElementName(arg.openingElement.name);
const hasHelmet = tagName === 'Helmet' || arg.children.some(child => t.isJSXElement(child) && getJSXElementName(child.openingElement.name) === 'Helmet');
if (!hasHelmet) {
arg.children.unshift(helmetJSXElement);
modified = true;
returnPath.stop();
}
}
}
});
}
});
}
},
JSXElement(path) {
const opening = path.node.openingElement;
const tagName = getJSXElementName(opening.name);
if (tagName === 'Helmet') {
let hasSchema = false;
let hasTitle = false;
let hasDescription = false;
path.node.children.forEach(child => {
if (t.isJSXElement(child)) {
const childName = getJSXElementName(child.openingElement.name);
if (childName === 'script') {
const typeAttr = child.openingElement.attributes.find(attr => t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name) && attr.name.name === 'type');
if (typeAttr &&
t.isJSXAttribute(typeAttr) &&
typeAttr.value &&
t.isStringLiteral(typeAttr.value) &&
typeAttr.value.value === 'application/ld+json') {
hasSchema = true;
}
}
else if (childName === 'title') {
hasTitle = true;
}
else if (childName === 'meta') {
const nameAttr = child.openingElement.attributes.find(attr => t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name) && attr.name.name === 'name');
if (nameAttr &&
t.isJSXAttribute(nameAttr) &&
nameAttr.value &&
t.isStringLiteral(nameAttr.value) &&
nameAttr.value.value === 'description') {
hasDescription = true;
}
}
}
});
if (!hasTitle) {
path.node.children.unshift(t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier('title'), [], false), t.jsxClosingElement(t.jsxIdentifier('title')), [t.jsxText('Your Site Title')], false));
modified = true;
}
if (!hasDescription) {
path.node.children.unshift(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));
modified = true;
}
if (!hasSchema) {
path.node.children.push(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));
modified = true;
}
}
if (tagName === 'img') {
// Add alt attribute if missing
const hasAlt = opening.attributes.some(attr => t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name) && attr.name.name === 'alt');
if (!hasAlt) {
opening.attributes.push(t.jsxAttribute(t.jsxIdentifier('alt'), t.stringLiteral('Image description')));
modified = true;
}
}
},
});
if (modified) {
if (process.env.CLISEO_VERBOSE === 'true') {
console.log(`[cliseo debug] Modifications made in file: ${file}, writing changes...`);
}
const output = babel.transformFromAstSync(ast, source, {
configFile: true,
generatorOpts: { retainLines: true, compact: false },
});
if (output && output.code) {
// Prettier formatting disabled to minimize diff noise
// If you prefer to format the output, uncomment the block below and add your own Prettier options
/*
const formatted = await prettier.format(output.code, {
parser: 'babel-ts',
semi: true,
singleQuote: false,
jsxSingleQuote: false,
trailingComma: 'none',
printWidth: 80,
tabWidth: 2,
useTabs: false,
});
await fs.writeFile(file, formatted, 'utf-8');
*/
await fs.writeFile(file, output.code, '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