UNPKG

cliseo

Version:

Instant AI-Powered SEO Optimization CLI for Developers

343 lines 16.2 kB
import { readFileSync, writeFileSync, existsSync } from 'fs'; import { glob } from 'glob'; import { parseDocument } from 'htmlparser2'; import { DomUtils } from 'htmlparser2'; import { default as render } from 'dom-serializer'; import * as babel from '@babel/core'; import * as t from '@babel/types'; import generate from '@babel/generator'; import traverse from '@babel/traverse'; import prettier from 'prettier'; /** * Checks if the given component argument is a standalone Angular component. * * @param {t.ObjectExpression} componentArg - The object expression representing the component's metadata. * @returns {boolean} - True if the component is standalone, false otherwise. */ function isStandaloneComponent(componentArg) { return componentArg.properties.some((prop) => t.isObjectProperty(prop) && t.isIdentifier(prop.key, { name: 'standalone' }) && t.isBooleanLiteral(prop.value, { value: true })); } /** * Updates the NgModule imports to include NgOptimizedImage if necessary. * * @param componentArg - The object expression representing the component's metadata. */ function updateNgModuleImports(componentArg) { // Find the 'imports' property, narrowing type to ObjectProperty const importsProp = componentArg.properties.find((p) => t.isObjectProperty(p) && t.isIdentifier(p.key, { name: 'imports' })); if (importsProp) { // importsProp.value must be an ArrayExpression to add new imports if (t.isArrayExpression(importsProp.value)) { // Example: add NgOptimizedImage to the imports array if not already there const hasNgOptimizedImage = importsProp.value.elements.some(el => t.isIdentifier(el, { name: 'NgOptimizedImage' })); if (!hasNgOptimizedImage) { importsProp.value.elements.push(t.identifier('NgOptimizedImage')); } } } else { // No imports property found — create one const newImportsProp = t.objectProperty(t.identifier('imports'), t.arrayExpression([t.identifier('NgOptimizedImage')])); componentArg.properties.push(newImportsProp); } } /** * Optimizes Angular images by converting <img> tags to use ngOptimizedImage. * It also updates the associated TypeScript files to import NgOptimizedImage * and adds it to the component's imports if necessary. * * @param file - The path to the Angular HTML file to optimize. */ export async function optimizeAngularImages(file) { console.log(`Processing ${file}...`); const original = readFileSync(file, 'utf8'); const dom = parseDocument(original); let updated = false; // Modify <img> tags DomUtils.findAll(elem => { if (elem.name === 'img' && elem.attribs && elem.attribs.src) { const src = elem.attribs.src; if (elem.attribs['ngOptimizedImage']) return false; delete elem.attribs.src; console.log(` • Converting <img src="${src}"> to ngOptimizedImage in ${file}`); elem.attribs['ngOptimizedImage'] = ''; elem.attribs['[src]'] = `'${src}'`; updated = true; } return false; // Always return boolean }, dom.children); // TS file variables let ast = null; let tsUpdated = false; let tsFile = null; if (updated) { // Write updated HTML const output = render(dom, { encodeEntities: 'utf8' }); writeFileSync(file, output, 'utf8'); console.log(` • Updated ${file} with ngOptimizedImage.`); // Attempt to locate associated .ts file tsFile = file.replace(/\.html$/, '.ts'); if (existsSync(tsFile)) { const tsCode = readFileSync(tsFile, 'utf8'); ast = babel.parseSync(tsCode, { sourceType: 'module', plugins: [ '@babel/plugin-syntax-typescript', ['@babel/plugin-proposal-decorators', { legacy: true }], ], }); if (!ast) { console.error(`Cannot parse ${tsFile}`); return; } // @ts-ignore – traverse typing mismatch under certain module resolutions traverse(ast, { Program(path) { const hasNgOptimizedImageImport = path.node.body.some((n) => t.isImportDeclaration(n) && n.source.value === '@angular/common' && n.specifiers.some((s) => t.isImportSpecifier(s) && t.isIdentifier(s.imported) && s.imported.name === 'NgOptimizedImage')); if (!hasNgOptimizedImageImport) { path.node.body.unshift(t.importDeclaration([ t.importSpecifier(t.identifier('NgOptimizedImage'), t.identifier('NgOptimizedImage')), ], t.stringLiteral('@angular/common'))); tsUpdated = true; } }, ClassDeclaration(path) { const decorators = path.node.decorators; if (!decorators) return; decorators.forEach((decorator) => { if (t.isDecorator(decorator) && t.isCallExpression(decorator.expression) && t.isIdentifier(decorator.expression.callee, { name: 'Component' })) { const componentArg = decorator.expression.arguments[0]; if (!t.isObjectExpression(componentArg)) return; if (isStandaloneComponent(componentArg)) { updateNgModuleImports(componentArg); // only if standalone tsUpdated = true; } } }); } }); } } if (tsUpdated && ast && tsFile) { // @ts-ignore – generate typing mismatch under certain module resolutions const updatedCode = generate(ast, { retainLines: true, }).code; // Format the updated TypeScript code const formattedCode = await prettier.format(updatedCode, { parser: 'babel-ts', semi: true, singleQuote: false, jsxSingleQuote: false, trailingComma: 'none', printWidth: 80, tabWidth: 2, useTabs: false, }); writeFileSync(tsFile, formattedCode, 'utf8'); } } /** * Transforms Angular component files to ensure they implement OnInit, and Title and Meta services are injected. * * @param file - The path to the Angular component file to transform. */ async function transformAngularComponents(file) { console.log(`Processing ${file}...`); const code = readFileSync(file, 'utf-8'); const ast = babel.parseSync(code, { sourceType: 'module', plugins: [ '@babel/plugin-syntax-jsx', ['@babel/plugin-syntax-typescript', { isTSX: true, allExtensions: true }], ['@babel/plugin-proposal-decorators', { legacy: true }], ], }); if (!ast) { console.error(`Cannot parse ${file}`); return; } let updated = false; let needsSeoLogic = false; // @ts-ignore – traverse typing mismatch under certain module resolutions traverse(ast, { ClassDeclaration(path) { // Add implements OnInit if missing const hasImplementsOnInit = path.node.implements?.some((impl) => t.isTSExpressionWithTypeArguments(impl) && t.isIdentifier(impl.expression, { name: 'OnInit' })); if (!hasImplementsOnInit) { if (!path.node.implements) path.node.implements = []; path.node.implements.push(t.tsExpressionWithTypeArguments(t.identifier('OnInit'))); updated = true; needsSeoLogic = true; } // Ensure constructor injects Title and Meta let constructorFound = false; path.get('body.body').forEach((childPath) => { if (childPath.isClassMethod({ kind: 'constructor' })) { constructorFound = true; const hasParam = (name) => childPath.node.params.some((p) => // constructor parameters could be TSParameterProperty or Identifier with typeAnnotation (t.isTSParameterProperty(p) && t.isIdentifier(p.parameter, { name })) || (t.isIdentifier(p) && p.name === name)); const addParam = (name, typeName) => { const id = t.identifier(name); id.typeAnnotation = t.tsTypeAnnotation(t.tsTypeReference(t.identifier(typeName))); const param = t.tsParameterProperty(id); param.accessibility = 'private'; childPath.node.params.push(param); }; if (!hasParam('titleService')) { addParam('titleService', 'Title'); updated = true; needsSeoLogic = true; } if (!hasParam('metaService')) { addParam('metaService', 'Meta'); updated = true; needsSeoLogic = true; } } }); if (!constructorFound) { // create constructor with injected Title and Meta const titleId = t.identifier('titleService'); titleId.typeAnnotation = t.tsTypeAnnotation(t.tsTypeReference(t.identifier('Title'))); const metaId = t.identifier('metaService'); metaId.typeAnnotation = t.tsTypeAnnotation(t.tsTypeReference(t.identifier('Meta'))); const titleParam = t.tsParameterProperty(titleId); titleParam.accessibility = 'private'; const metaParam = t.tsParameterProperty(metaId); metaParam.accessibility = 'private'; const constructorMethod = t.classMethod('constructor', t.identifier('constructor'), [titleParam, metaParam], t.blockStatement([])); path.node.body.body.unshift(constructorMethod); updated = true; needsSeoLogic = true; } // Add or update ngOnInit method const ngOnInitMethod = path.node.body.body.find((m) => t.isClassMethod(m) && t.isIdentifier(m.key, { name: 'ngOnInit' })); const titleSetCall = t.expressionStatement(t.callExpression(t.memberExpression(t.memberExpression(t.thisExpression(), t.identifier('titleService')), t.identifier('setTitle')), [t.stringLiteral('Example Page')])); const metaUpdateCall = t.expressionStatement(t.callExpression(t.memberExpression(t.memberExpression(t.thisExpression(), t.identifier('metaService')), t.identifier('updateTag')), [ t.objectExpression([ t.objectProperty(t.identifier('name'), t.stringLiteral('description')), t.objectProperty(t.identifier('content'), t.stringLiteral('This is an example page for SEO.')), ]), ])); if (ngOnInitMethod) { const body = ngOnInitMethod.body.body; const hasTitleCall = body.some((stmt) => t.isExpressionStatement(stmt) && t.isCallExpression(stmt.expression) && t.isMemberExpression(stmt.expression.callee) && t.isIdentifier(stmt.expression.callee.property, { name: 'setTitle' })); const hasMetaCall = body.some((stmt) => t.isExpressionStatement(stmt) && t.isCallExpression(stmt.expression) && t.isMemberExpression(stmt.expression.callee) && t.isIdentifier(stmt.expression.callee.property, { name: 'updateTag' })); if (!hasTitleCall) { body.push(titleSetCall); updated = true; needsSeoLogic = true; } if (!hasMetaCall) { body.push(metaUpdateCall); updated = true; needsSeoLogic = true; } } else { const method = t.classMethod('method', t.identifier('ngOnInit'), [], t.blockStatement([titleSetCall, metaUpdateCall])); method.returnType = t.tsTypeAnnotation(t.tsVoidKeyword()); path.node.body.body.push(method); updated = true; needsSeoLogic = true; } }, Program: { exit(path) { if (!needsSeoLogic) return; // Add imports only if needed // Import Title & Meta const hasTitleMetaImport = path.node.body.some((n) => t.isImportDeclaration(n) && n.source.value === '@angular/platform-browser'); if (!hasTitleMetaImport) { path.node.body.unshift(t.importDeclaration([ t.importSpecifier(t.identifier('Title'), t.identifier('Title')), t.importSpecifier(t.identifier('Meta'), t.identifier('Meta')), ], t.stringLiteral('@angular/platform-browser'))); updated = true; } // Ensure OnInit is imported const hasOnInitImport = path.node.body.some((n) => t.isImportDeclaration(n) && n.source.value === '@angular/core' && n.specifiers.some((s) => t.isImportSpecifier(s) && t.isIdentifier(s.imported, { name: 'OnInit' }))); if (!hasOnInitImport) { const angularCoreImport = path.node.body.find((n) => t.isImportDeclaration(n) && n.source.value === '@angular/core'); if (angularCoreImport && t.isImportDeclaration(angularCoreImport)) { angularCoreImport.specifiers.push(t.importSpecifier(t.identifier('OnInit'), t.identifier('OnInit'))); } else { path.node.body.unshift(t.importDeclaration([t.importSpecifier(t.identifier('OnInit'), t.identifier('OnInit'))], t.stringLiteral('@angular/core'))); } updated = true; } }, }, }); if (updated) { // @ts-ignore – generate typing mismatch under certain module resolutions const output = generate(ast, { retainLines: true, retainFunctionParens: true, }, code); const formatted = await prettier.format(output.code, { parser: 'babel-ts', semi: true, singleQuote: false, jsxSingleQuote: false, trailingComma: 'none', printWidth: 80, tabWidth: 2, useTabs: false, }); writeFileSync(file, formatted, 'utf-8'); console.log(` • Updated ${file} with Angular SEO optimizations.`); } } /** * Optimizes Angular project components by ensuring proper title tags, meta tags, and image optimizations. **/ export async function optimizeAngularComponents() { const htmlFiles = glob.sync('src/app/pages/**/*.html'); for (const file of htmlFiles) { try { await optimizeAngularImages(file); } catch (err) { console.error(`Error processing ${file}:`, err); } } const tsFiles = glob.sync('src/app/pages/**/*.component.ts'); for (const file of tsFiles) { try { await transformAngularComponents(file); } catch (err) { console.error(`Error processing ${file}:`, err); } } } //# sourceMappingURL=optimize-angular.js.map