userpravah
Version:
UserPravah is an extensible, framework-agnostic tool for analyzing user flows and navigation patterns in web applications. It supports multiple frameworks (Angular, React) and output formats (DOT/Graphviz, JSON) with a plugin-based architecture for easy e
657 lines (656 loc) • 84 kB
JavaScript
#!/usr/bin/env node
import { Project, SyntaxKind, Node } from 'ts-morph';
import { parse as parseHTML } from 'node-html-parser';
import { digraph, toDot, attribute } from 'ts-graphviz'; // Removed Style import
import * as fs from 'fs';
import * as path from 'path';
import glob from 'fast-glob';
import { execSync } from 'child_process';
class AngularFlowAnalyzer {
// Helper to convert kebab-case to PascalCase for component names
kebabToPascalCase(filename) {
// Get the base name without any extension
let name = path.basename(filename, path.extname(filename));
// Remove common Angular "type" suffixes if they appear after a dot (e.g., app.component -> app)
// Also handles cases like 'my-feature.component' becoming 'MyFeature'
// And removes suffixes if they are part of the kebab name before a dot, e.g., 'my-component-name.routes' -> 'MyComponentName'
name = name.replace(/\.(component|service|module|pipe|directive|guard|routes|page|config|store|effects|reducer|action|model|interface|enum|util|helper|constant|schema|validator|interceptor|resolver|adapter|facade|query|command|event|subscriber|listener|dto|vo|entity|repository|provider|factory|builder|handler|operator|stream|source|sink|transform|aggregator|projector|saga|orchestrator|coordinator|mediator|gateway|client|proxy|stub|mock|dummy|fake|spec|test|e2e|stories|bench)$/, '');
// Convert kebab-case (e.g., 'my-awesome-feature') to PascalCase
return name
.split('-')
.filter(part => part.length > 0) // Filter out empty strings from multiple hyphens like 'a--b'
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
}
constructor(angularProjectPath) {
this.routes = [];
this.flows = [];
this.menus = [];
this.processedRouteObjects = new Set(); // Track processed route object literals
this.processedLazyLoads = new Set(); // To track lazy loads already processed
this.angularProjectPath = angularProjectPath;
// Initialize ts-morph project
this.project = new Project({
tsConfigFilePath: path.join(this.angularProjectPath, 'tsconfig.json')
});
}
async analyze() {
console.log('🚀 Script execution started...');
console.log('🔍 Starting Angular project analysis...');
await this.addSourceFiles();
console.log(`올 Total TypeScript source files loaded: ${this.project.getSourceFiles().length}`); // Enhanced Log
console.log('📍 Analyzing routing modules (initial pass)...');
await this.analyzeRoutingModules('/');
console.log('🔎 Analyzing template files and TS for navigation...');
await this.analyzeSourceFilesForNavigation();
console.log('📊 Generating flow diagram...');
if (this.routes.length === 0 && this.flows.length === 0) {
console.warn('⚠️ No routes or flows were extracted. The generated DOT file will be empty or minimal.');
}
await this.generateGraph();
}
async addSourceFiles() {
// Add all TypeScript files
const tsFiles = await glob('**/*.ts', {
cwd: this.angularProjectPath,
ignore: ['node_modules/**', 'dist/**']
});
// Add all HTML template files
const htmlFiles = await glob('**/*.html', {
cwd: this.angularProjectPath,
ignore: ['node_modules/**', 'dist/**']
});
this.project.addSourceFilesAtPaths(tsFiles.map(f => path.join(this.angularProjectPath, f)));
}
async analyzeRoutingModules(parentPathForThisContext, sourceFilesToSearch) {
let filesToProcess;
if (sourceFilesToSearch) {
filesToProcess = sourceFilesToSearch;
console.log(` [AnalyzeRoutes] Specific scan for ${filesToProcess.length} file(s) with parent context: '${parentPathForThisContext}'`);
}
else {
// INITIAL CALL - only find and process app.config.ts or app.module.ts
console.log(` [AnalyzeRoutes] Initial scan for top-level routing configuration. Parent context for this scan: '${parentPathForThisContext}' (this should be '/')`);
if (parentPathForThisContext !== '/') {
console.warn(` ⚠️ Initial scan for top-level routes was called with parent context '${parentPathForThisContext}' instead of '/'. This might lead to incorrect root paths.`);
}
const appConfigTs = this.project.getSourceFile(sf => /app\.config\.ts$/.test(sf.getFilePath()));
const appModuleTs = this.project.getSourceFile(sf => /app\.module\.ts$/.test(sf.getFilePath()));
// More general top-level module patterns if app.module.ts is not standard
const otherTopLevelModules = this.project.getSourceFiles().filter(sf => /src\/app\/[^\/]+\.module\.ts$/.test(sf.getFilePath()) && sf !== appModuleTs);
filesToProcess = [];
if (appConfigTs) {
console.log(` Found app.config.ts: ${appConfigTs.getFilePath()}`);
filesToProcess.push(appConfigTs);
}
// If app.config.ts (with provideRouter) exists, it's usually the main source of routes.
// If not, app.module.ts (with RouterModule.forRoot) is the next candidate.
if (appModuleTs && filesToProcess.length === 0) {
console.log(` Found app.module.ts: ${appModuleTs.getFilePath()}`);
filesToProcess.push(appModuleTs);
}
else if (appModuleTs && filesToProcess.length > 0) {
console.log(` app.config.ts already found, app.module.ts (${appModuleTs.getFilePath()}) will be scanned if it contains RouterModule.forRoot/forChild, but app.config.ts usually takes precedence for provideRouter.`);
// Potentially add appModuleTs too if it might contain RouterModule and routes not in app.config.ts
// However, this can lead to conflicts if routes are duplicated. For now, prioritize one.
}
if (filesToProcess.length === 0 && otherTopLevelModules.length > 0) {
console.log(` No app.config.ts or app.module.ts found. Checking other potential top-level modules in src/app/ (e.g., main.ts might import one).`);
// This is a heuristic: pick the first one or a common one like 'core.module.ts'
// A more robust solution would trace imports from main.ts
// For now, let's be cautious to avoid too many false positives for the root context.
// filesToProcess.push(otherTopLevelModules[0]);
// console.log(` Using ${otherTopLevelModules[0].getFilePath()} as a potential top-level module.`);
}
if (filesToProcess.length === 0) {
console.warn(" ⚠️ No clear top-level routing configuration file (app.config.ts, app.module.ts, or other obvious top-level module in src/app) found for initial route scan. Analysis might be incomplete or start from an arbitrary point if any other file defines routes with root-like paths.");
// As a last resort, if absolutely no top-level config is found, the old behavior of scanning many files might be needed,
// but it's prone to the context errors we're trying to solve. So, we'll proceed with an empty set for now if none are explicitly found.
}
}
for (const sourceFile of filesToProcess) {
const filePath = sourceFile.getFilePath();
// For the initial scan, parentPathForThisContext will be '/', which is correct for app.config/app.module.
// For subsequent scans (from loadChildren), parentPathForThisContext is the crucial parent's fullPath.
console.log(` [AnalyzeRoutes] Processing file: ${filePath} using parent context: '${parentPathForThisContext}'`);
this.extractRoutes(sourceFile, parentPathForThisContext);
}
}
extractRoutes(sourceFile, parentPathForThisFileContext) {
console.log(` [ExtractRoutes] SourceFile: ${sourceFile.getFilePath()}, Using ParentContextPath: '${parentPathForThisFileContext}'`);
const processedArrayLiterals = new Set(); // Track arrays processed in this specific call to extractRoutes
// Priority 1: Find calls to provideRouter, RouterModule.forRoot, RouterModule.forChild
const routingFunctionCalls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).filter(call => {
const exprText = call.getExpression().getText();
return exprText.endsWith('provideRouter') || exprText.endsWith('RouterModule.forRoot') || exprText.endsWith('RouterModule.forChild');
});
for (const call of routingFunctionCalls) {
if (call.getArguments().length > 0) {
const firstArg = call.getArguments()[0];
if (Node.isArrayLiteralExpression(firstArg)) {
// console.log(` Found direct ArrayLiteral in ${call.getExpression().getText()} in ${sourceFile.getFilePath()}`);
this.processRouteArrayLiteral(firstArg, parentPathForThisFileContext, sourceFile);
processedArrayLiterals.add(firstArg);
}
else if (Node.isIdentifier(firstArg)) {
const identifierName = firstArg.getText();
// console.log(` Found Identifier '${identifierName}' in ${call.getExpression().getText()} in ${sourceFile.getFilePath()}. Attempting to resolve...`);
try {
const definitions = firstArg.getDefinitionNodes();
for (const def of definitions) {
if (Node.isVariableDeclaration(def)) {
const initializer = def.getInitializer();
if (initializer && Node.isArrayLiteralExpression(initializer)) {
// console.log(` Resolved '${identifierName}' to an ArrayLiteral in the same file.`);
this.processRouteArrayLiteral(initializer, parentPathForThisFileContext, sourceFile);
processedArrayLiterals.add(initializer);
}
}
else if (Node.isImportSpecifier(def) || Node.isExportSpecifier(def) || Node.isNamespaceImport(def) || Node.isExportAssignment(def)) {
const importSourceFile = def.getSourceFile();
if (importSourceFile && importSourceFile !== sourceFile) {
console.log(` Identifier '${identifierName}' is imported from ${importSourceFile.getFilePath()}. Analyzing imported file with context '${parentPathForThisFileContext}'.`);
this.project.addSourceFileAtPathIfExists(importSourceFile.getFilePath());
this.project.resolveSourceFileDependencies();
// Call analyzeRoutingModules for the imported file, ensuring it gets the correct parent context.
// This is better than directly calling extractRoutes here, as analyzeRoutingModules handles specific file processing.
this.analyzeRoutingModules(parentPathForThisFileContext, [importSourceFile]);
// We assume analyzeRoutingModules will set its own flag or handle processing, so don't set mainRoutesArrayFoundAndProcessed here.
}
else if (importSourceFile === sourceFile) {
const localDeclarations = sourceFile.getVariableDeclarations().filter(vd => vd.getName() === identifierName);
for (const localDec of localDeclarations) {
const initializer = localDec.getInitializer();
if (initializer && Node.isArrayLiteralExpression(initializer)) {
this.processRouteArrayLiteral(initializer, parentPathForThisFileContext, sourceFile);
processedArrayLiterals.add(initializer);
break;
}
}
}
}
}
}
catch (error) {
console.error(` Error resolving identifier '${identifierName}' in ${sourceFile.getFilePath()}: ${error}`);
}
}
}
}
// ---- START NEW LOGIC TO PARSE NGMODULE IMPORTS ----
if (sourceFile.getFilePath().endsWith('.module.ts')) {
console.log(` [ExtractRoutes - NgModuleScan] File ${sourceFile.getFilePath()} is a module. Scanning @NgModule imports.`);
const ngModuleDecorators = sourceFile.getDescendantsOfKind(SyntaxKind.Decorator).filter(d => d.getName() === 'NgModule');
for (const decorator of ngModuleDecorators) {
const decoratorArg = decorator.getArguments()[0];
if (decoratorArg && Node.isObjectLiteralExpression(decoratorArg)) {
const importsProperty = decoratorArg.getProperty('imports');
if (importsProperty && Node.isPropertyAssignment(importsProperty)) {
const importsInitializer = importsProperty.getInitializer();
if (importsInitializer && Node.isArrayLiteralExpression(importsInitializer)) {
console.log(` [ExtractRoutes - NgModuleScan] Found imports array in @NgModule of ${sourceFile.getFilePath()}`);
for (const imp of importsInitializer.getElements()) {
if (Node.isIdentifier(imp)) {
const importName = imp.getText();
console.log(` [ExtractRoutes - NgModuleScan] Found imported identifier: ${importName}`);
try {
const definitionNodes = imp.getDefinitionNodes();
let foundAndProcessedImport = false;
// Detailed logging (can be kept or removed after debugging)
if (importName === 'AppRoutingModule' || importName === 'AuthModule') {
console.log(` [ExtractRoutes - NgModuleScan - DETAIL] For '${importName}', found ${definitionNodes.length} definition nodes:`);
definitionNodes.forEach((dn, index) => {
console.log(` [ExtractRoutes - NgModuleScan - DETAIL] DefNode #${index}: Kind='${dn.getKindName()}', File='${dn.getSourceFile().getFilePath()}'`);
if (Node.isImportSpecifier(dn)) {
const importDecl = dn.getImportDeclaration();
console.log(` [ExtractRoutes - NgModuleScan - DETAIL] ImportSpecifier details: ModuleSpecifier='${importDecl.getModuleSpecifierValue()}', ImportedFromFile='${importDecl.getModuleSpecifierSourceFile()?.getFilePath() || 'COULD NOT RESOLVE SPECIFIER SOURCE FILE'}'`);
}
});
}
for (const defNode of definitionNodes) {
const definitionSourceFile = defNode.getSourceFile();
// Check if the definition node (e.g., ClassDeclaration) is in a DIFFERENT file
if (definitionSourceFile && definitionSourceFile.getFilePath() !== sourceFile.getFilePath()) {
// We're interested if this definition from another file is a class (typical for Angular modules)
// or other relevant top-level declarations that might contain routes.
if (Node.isClassDeclaration(defNode) || Node.isFunctionDeclaration(defNode) /* potentially add other kinds if needed */) {
console.log(` [ExtractRoutes - NgModuleScan] ACTION: '${importName}' definition (Kind: ${defNode.getKindName()}) found in different file '${definitionSourceFile.getFilePath()}'. Scheduling analysis.`);
this.project.addSourceFileAtPathIfExists(definitionSourceFile.getFilePath());
this.project.resolveSourceFileDependencies();
// Analyze this newly discovered source file
this.analyzeRoutingModules(parentPathForThisFileContext, [definitionSourceFile]);
foundAndProcessedImport = true;
break; // Found the relevant cross-file module declaration
}
}
else if (Node.isImportSpecifier(defNode)) {
// This handles cases where the definition IS an import specifier itself,
// which might still be useful if the above direct ClassDeclaration check fails for some reason.
const importDeclaration = defNode.getImportDeclaration();
const importedSourceFileViaSpecifier = importDeclaration.getModuleSpecifierSourceFile();
if (importedSourceFileViaSpecifier && importedSourceFileViaSpecifier.getFilePath() !== sourceFile.getFilePath()) {
console.log(` [ExtractRoutes - NgModuleScan] ACTION (via ImportSpecifier): ${importName} is imported from ${importedSourceFileViaSpecifier.getFilePath()}. Scheduling analysis.`);
this.project.addSourceFileAtPathIfExists(importedSourceFileViaSpecifier.getFilePath());
this.project.resolveSourceFileDependencies();
this.analyzeRoutingModules(parentPathForThisFileContext, [importedSourceFileViaSpecifier]);
foundAndProcessedImport = true;
break;
}
}
}
if (!foundAndProcessedImport) {
console.log(` [ExtractRoutes - NgModuleScan] No actionable cross-file module definition found for ${importName} after checking ${definitionNodes.length} definition(s).`);
definitionNodes.forEach(dn => {
console.log(` [ExtractRoutes - NgModuleScan] Checked DefNode Kind for ${importName}: ${dn.getKindName()} in File: ${dn.getSourceFile().getFilePath()}`);
});
}
}
catch (err) {
console.warn(` [ExtractRoutes - NgModuleScan] Error resolving identifier ${importName} in @NgModule imports: ${err}`);
}
}
}
}
}
}
}
}
// ---- END NEW LOGIC TO PARSE NGMODULE IMPORTS ----
// Fallback or General Scan:
// Always run this for all files, but skip arrays already processed by the targeted scan above.
// console.log(` [ExtractRoutes] Performing general scan for route arrays in ${sourceFile.getFilePath()}`);
const allArrayLiterals = sourceFile.getDescendantsOfKind(SyntaxKind.ArrayLiteralExpression);
for (const arr of allArrayLiterals) {
if (processedArrayLiterals.has(arr)) {
// console.log(` [ExtractRoutes] Skipping array already processed by targeted scan.`);
continue;
}
// Basic structural check: does it look like it might contain route objects?
const hasRouteLikeElements = arr.getElements().some(el => {
if (Node.isObjectLiteralExpression(el)) {
return el.getProperties().some(prop => Node.isPropertyAssignment(prop) &&
(prop.getName() === 'path' || prop.getName() === 'component' || prop.getName() === 'loadChildren' || prop.getName() === 'redirectTo' || prop.getName() === 'children'));
}
return false;
});
if (hasRouteLikeElements) {
// console.log(` [ExtractRoutes] General scan found a potential route array in ${sourceFile.getFilePath()} at line ${arr.getStartLineNumber()}. Processing it.`);
this.processRouteArrayLiteral(arr, parentPathForThisFileContext, sourceFile);
}
else {
// console.log(` [ExtractRoutes] General scan found an array in ${sourceFile.getFilePath()} at line ${arr.getStartLineNumber()} that does not appear to contain route objects.`);
}
}
}
// Helper method to process a resolved ArrayLiteralExpression containing routes
processRouteArrayLiteral(routeArrayNode, parentRouteContextFullPath, sourceFile) {
console.log(` [ProcessArrayLiteral] START processing array from file: ${sourceFile.getFilePath()}, ParentContext: '${parentRouteContextFullPath}', Number of elements: ${routeArrayNode.getElements().length}`);
const parsedRoutes = this.parseRouteArray(routeArrayNode, parentRouteContextFullPath, sourceFile);
console.log(` [ProcessArrayLiteral] FINISHED parseRouteArray. Number of parsed routes from this array: ${parsedRoutes.length}`);
for (const pr of parsedRoutes) {
// console.log(` [ProcessArrayLiteral] Considering parsed route: path='${pr.path}', fp='${pr.fullPath}', comp='${pr.component || 'N/A'}', children#='${pr.children?.length || 0}', redirectTo='${pr.redirectTo || 'N/A'}', loadChildren='${pr.loadChildren || 'N/A'}'`);
this.addRouteToCollection(pr);
}
}
addRouteToCollection(pr) {
if (!pr || (!pr.component && !pr.loadChildren && !pr.redirectTo && (!pr.children || pr.children.length === 0) && pr.path === '')) {
// console.log(` [AddRoute] Skipping route object that is effectively empty or a simple pathless placeholder: ${pr?.fullPath}`);
return;
}
console.log(` [AddRoute] Considering to add route: path='${pr.path}', fp='${pr.fullPath}', comp='${pr.component || 'N/A'}', children#='${pr.children?.length || 0}', redirectTo='${pr.redirectTo || 'N/A'}', loadChildren='${pr.loadChildren || 'N/A'}'`);
// Attempt to find an existing route by fullPath first, as this is the unique identifier for a page.
const existingRouteByPathIndex = this.routes.findIndex(r => r.fullPath === pr.fullPath);
if (existingRouteByPathIndex !== -1) {
const existingRoute = this.routes[existingRouteByPathIndex];
// If an existing route for the same fullPath is found:
// 1. If new route has a component and old one didn't, OR
// 2. If new route has loadChildren and old one didn't, OR
// 3. If new route has redirectTo and old one didn't,
// then the new one is more specific/complete for this path.
let updateReason = '';
if (pr.component && !existingRoute.component)
updateReason = 'new component';
else if (pr.loadChildren && !existingRoute.loadChildren)
updateReason = 'new loadChildren';
else if (pr.redirectTo && !existingRoute.redirectTo)
updateReason = 'new redirectTo';
// 4. Or if new one has children and old one didn't (less common to override this way)
// else if (pr.children && pr.children.length > 0 && (!existingRoute.children || existingRoute.children.length === 0)) updateReason = 'new children';
if (updateReason) {
console.log(` [AddRoute] Updating existing route at ${pr.fullPath} because of ${updateReason}. Old comp: ${existingRoute.component}, New comp: ${pr.component}`);
// Preserve children from the old route if the new one doesn't have them but the old one did (e.g. a grouping route being given a component)
if (!pr.children && existingRoute.children && existingRoute.children.length > 0 && existingRoute.component !== pr.component) {
// Be careful not to wipe out children if we are just adding a component to a path that was a parent.
// This case needs very careful thought. Usually, a path is either a parent OR has a component.
// For now, if a component is added, it might become a terminal route for that path.
console.log(` New route for ${pr.fullPath} has new ${updateReason}, old route had children. Deciding on children preservation...`);
// If the new definition has a component, it usually implies it's the endpoint for that path.
// If the old one was just a grouping path, its children are effectively superseded for this specific path if a component is now defined.
}
this.routes[existingRouteByPathIndex] = pr;
}
else if (pr.component && existingRoute.component && pr.component !== existingRoute.component) {
console.warn(` [AddRoute] Ambiguous route definition for ${pr.fullPath}. Existing component: ${existingRoute.component}, New component: ${pr.component}. Keeping existing.`);
}
else {
// console.log(` [AddRoute] Existing route at ${pr.fullPath} is same or more complete. Skipping new.`);
}
return; // Handled existing path
}
// If no route exists for this fullPath, then check for existing component (for different paths)
// This was the old logic, useful if a component was first defined with a generic path then a more specific one.
const existingRouteByComponentIndex = this.routes.findIndex(r => r.component && pr.component && r.component === pr.component);
if (existingRouteByComponentIndex !== -1) {
const existingRoute = this.routes[existingRouteByComponentIndex];
if (pr.fullPath.length > existingRoute.fullPath.length || (existingRoute.path === '**' && (pr.component || pr.loadChildren))) {
console.log(` [AddRoute] Replacing existing route for component ${pr.component} (old path: ${existingRoute.fullPath}) with new, more specific path: ${pr.fullPath}`);
this.routes[existingRouteByComponentIndex] = pr;
}
else {
// console.log(` [AddRoute] Existing route for component ${pr.component} (path: ${existingRoute.fullPath}) is more specific or same as new route (path: ${pr.fullPath}). Skipping new.`);
}
return; // Handled by component specificity
}
// If neither fullPath nor component matched for replacement, add as new route (final duplication check)
const trulyDuplicate = this.routes.find(r => r.fullPath === pr.fullPath &&
((r.component && pr.component && r.component === pr.component) ||
(r.loadChildren && pr.loadChildren && r.loadChildren === pr.loadChildren) ||
(r.redirectTo && pr.redirectTo && r.redirectTo === pr.redirectTo) ||
(!r.component && !pr.component && !r.loadChildren && !pr.loadChildren && !r.redirectTo && !pr.redirectTo && (r.children && r.children.length > 0) && (pr.children && pr.children.length > 0))));
if (!trulyDuplicate) {
// console.log(` [AddRoute] Adding new route: ${pr.fullPath}`);
this.routes.push(pr);
}
else {
// console.log(` [AddRoute] Skipping truly duplicate route: ${pr.fullPath} (${pr.component || pr.loadChildren || pr.redirectTo || 'path-only with children'})`);
}
}
parseRouteArray(node, parentRouteContextFullPath, sourceFile) {
console.log(` [ParseRouteArray] START. ParentContext: '${parentRouteContextFullPath}', Input node kind: ${node.getKindName()}, SourceFile: ${sourceFile.getFilePath()}`);
const routes = [];
if (Node.isArrayLiteralExpression(node)) {
node.getElements().forEach(element => {
if (Node.isObjectLiteralExpression(element)) {
if (this.processedRouteObjects.has(element)) {
console.log(` [ParseRouteArray] Skipping already processed route object in ${sourceFile.getFilePath()} at line ${element.getStartLineNumber()}`);
return;
}
const route = this.parseRouteObject(element, parentRouteContextFullPath, sourceFile);
if (route) {
console.log(` [ParseRouteArray] Successfully parsed an object. Result: path='${route.path}', fp='${route.fullPath}', comp='${route.component || 'N/A'}'`);
this.processedRouteObjects.add(element);
routes.push(route);
if (route.loadChildren) {
console.log(` [ParseRouteArray] Found loadChildren for route path: '${route.path}', fullPath: '${route.fullPath}'. Target: '${route.loadChildren}'. Scheduling lazy load.`);
this.loadAndParseLazyModule(route.loadChildren, route.fullPath, sourceFile)
.catch(err => {
console.warn(`⚠️ Error processing lazy module ${route.loadChildren} (parent: ${route.fullPath}): ${err.message}`);
});
}
}
else {
console.log(` [ParseRouteArray] parseRouteObject returned null for an element.`);
}
}
});
}
console.log(` [ParseRouteArray] END. Returning ${routes.length} routes from this array literal.`);
return routes;
}
parseRouteObject(node, parentRouteContextFullPath, sourceFile) {
console.log(`[ParseRouteObject] START. ParentContext: '${parentRouteContextFullPath}', SourceFile: ${sourceFile.getFilePath()}, Line: ${node.getStartLineNumber()}`);
const props = {};
let pathSegmentFromProps = undefined;
node.getProperties().forEach(prop => {
if (Node.isPropertyAssignment(prop)) {
const name = prop.getNameNode().getText();
const initializer = prop.getInitializer();
console.log(` [ParseRouteObject] path: '${pathSegmentFromProps || parentRouteContextFullPath}', Found property: '${name}', Initializer Kind: ${initializer?.getKindName()}`);
if (!initializer)
return;
switch (name) {
case 'path':
if (Node.isStringLiteral(initializer))
pathSegmentFromProps = initializer.getLiteralValue();
break;
case 'component':
if (Node.isIdentifier(initializer))
props[name] = initializer.getText();
break;
case 'loadComponent':
if (Node.isArrowFunction(initializer) || Node.isFunctionExpression(initializer)) {
const importCall = initializer.getDescendantsOfKind(SyntaxKind.CallExpression)
.find(call => call.getExpression().getText() === 'import');
if (importCall && importCall.getArguments().length > 0) {
// Attempt to get component name from .then(m => m.ComponentName)
const thenPropertyAccess = importCall.getParentWhile(n => n.getKind() !== SyntaxKind.PropertyAccessExpression && n !== initializer.getBody());
if (thenPropertyAccess && Node.isPropertyAccessExpression(thenPropertyAccess)) {
const standaloneCompName = thenPropertyAccess.getNameNode().getText();
console.log(` [ParseRouteObject] Extracted component name '${standaloneCompName}' from loadComponent for path '${pathSegmentFromProps || parentRouteContextFullPath}'`);
props['component'] = standaloneCompName; // Store it as if it were a direct component property
}
else {
// Fallback: try to derive from the import path if no .then(m => m.Comp) is found
const importPathNode = importCall.getArguments()[0];
if (Node.isStringLiteral(importPathNode)) {
const importedFileName = importPathNode.getLiteralValue().split('/').pop()?.split('.')[0];
if (importedFileName) {
const derivedCompName = this.kebabToPascalCase(importedFileName);
console.log(` [ParseRouteObject] No direct component access in loadComponent's .then(). Derived component name '${derivedCompName}' from import path '${importPathNode.getLiteralValue()}' for path '${pathSegmentFromProps || parentRouteContextFullPath}'`);
props['component'] = derivedCompName;
}
}
}
}
}
break;
case 'loadChildren':
console.log(`[ParseRouteObject] Found 'loadChildren' property for path '${pathSegmentFromProps || parentRouteContextFullPath}'. Initializer Kind: ${initializer.getKindName()}, Text: ${initializer.getText().substring(0, 100)}...`);
if (Node.isArrowFunction(initializer) || Node.isFunctionExpression(initializer)) {
const importCall = initializer.getDescendantsOfKind(SyntaxKind.CallExpression)
.find(call => call.getExpression().getText() === 'import');
if (importCall && importCall.getArguments().length > 0) {
const importPathNode = importCall.getArguments()[0];
if (Node.isStringLiteral(importPathNode)) {
props[name] = importPathNode.getLiteralValue();
console.log(` [ParseRouteObject] Extracted loadChildren target via import() directly in function: '${props[name]}'`);
}
else {
console.log(` [ParseRouteObject] loadChildren function's import() argument is not a StringLiteral: ${importPathNode.getKindName()}`);
}
}
else {
console.log(` [ParseRouteObject] loadChildren function does not contain a direct import() call or import() has no arguments.`);
}
}
else if (Node.isStringLiteral(initializer)) {
props[name] = initializer.getLiteralValue();
console.log(` [ParseRouteObject] Extracted loadChildren target (string literal): '${props[name]}'`);
}
else if (Node.isIdentifier(initializer)) {
console.log(` [ParseRouteObject] loadChildren initializer is an Identifier: '${initializer.getText()}'. Attempting to resolve it.`);
try {
const definitions = initializer.getDefinitionNodes();
for (const def of definitions) {
if (Node.isVariableDeclaration(def) || Node.isFunctionDeclaration(def) || Node.isPropertyAssignment(def)) {
let functionBodyNode = undefined;
if (Node.isVariableDeclaration(def))
functionBodyNode = def.getInitializer();
else if (Node.isFunctionDeclaration(def))
functionBodyNode = def; // FunctionDeclaration node itself
else if (Node.isPropertyAssignment(def))
functionBodyNode = def.getInitializer();
if (functionBodyNode && (Node.isArrowFunction(functionBodyNode) || Node.isFunctionExpression(functionBodyNode) || Node.isFunctionDeclaration(functionBodyNode))) {
console.log(` [ParseRouteObject] Resolved identifier '${initializer.getText()}' to a function-like structure (Kind: ${functionBodyNode.getKindName()}).`);
const importCall = functionBodyNode.getDescendantsOfKind(SyntaxKind.CallExpression)
.find(call => call.getExpression().getText() === 'import');
if (importCall && importCall.getArguments().length > 0) {
const importPathNode = importCall.getArguments()[0];
if (Node.isStringLiteral(importPathNode)) {
props[name] = importPathNode.getLiteralValue();
console.log(` [ParseRouteObject] Extracted loadChildren target via resolved identifier's import(): '${props[name]}'`);
break; // Found it
}
}
}
}
}
if (!props[name]) {
console.log(` [ParseRouteObject] Could not resolve loadChildren identifier '${initializer.getText()}' to a function with an import().`);
}
}
catch (err) {
console.error(` [ParseRouteObject] Error resolving loadChildren identifier '${initializer.getText()}': ${err}`);
}
}
else {
console.log(` [ParseRouteObject] loadChildren initializer is not a recognized type. Kind: ${initializer.getKindName()}`);
}
break;
case 'redirectTo':
if (Node.isStringLiteral(initializer))
props[name] = initializer.getLiteralValue();
break;
case 'children':
if (Node.isArrayLiteralExpression(initializer))
props[name] = initializer; // Store AST node
break;
case 'canActivate':
if (Node.isArrayLiteralExpression(initializer))
props[name] = initializer; // Store AST node
break;
case 'data':
if (Node.isObjectLiteralExpression(initializer))
props[name] = initializer; // Store AST node
break;
}
}
});
if (pathSegmentFromProps === undefined) {
// It's common for a route to be just children or redirectTo, path can be optional if parent provides it or it's empty.
// However, if it has no path, component, children, loadChildren, or redirectTo, it's not a useful route.
if (!props['component'] && !props['children'] && !props['loadChildren'] && !props['redirectTo'] && !props['loadComponent']) {
console.log(`[ParseRouteObject] Skipping object with no path and no other defining properties (comp, children, loadChildren, redirectTo) at line ${node.getStartLineNumber()} in ${sourceFile.getFilePath()}`);
return null;
}
// If path is undefined, but other properties exist, treat path as '' for this segment.
pathSegmentFromProps = '';
}
const route = { path: pathSegmentFromProps, fullPath: '' };
console.log(`[ParseRouteObject] pathSegmentFromProps: '${pathSegmentFromProps}'`);
// Calculate fullPath for the current route object
if (parentRouteContextFullPath === '/' && route.path === '') { // True root of the application
route.fullPath = '/';
if (!this.routes.some(r => r.isRoot)) { // Mark as root only if no other root exists
route.isRoot = true;
}
}
else if (parentRouteContextFullPath === '/') { // Direct child of the application root
route.fullPath = `/${route.path}`;
}
else { // Nested route
route.fullPath = route.path === '' ? parentRouteContextFullPath : `${parentRouteContextFullPath}/${route.path}`;
}
// Normalize fullPath: remove double slashes, then remove trailing slash unless it's just "/"
route.fullPath = route.fullPath.replace(/\/\//g, '/');
if (route.fullPath !== '/' && route.fullPath.endsWith('/')) {
route.fullPath = route.fullPath.slice(0, -1);
}
// Ensure fullPath is at least '/' if it somehow became empty (e.g. parent '/', path '')
if (route.fullPath === '')
route.fullPath = '/';
console.log(`[ParseRouteObject] Calculated fullPath: '${route.fullPath}', componentFromProps: '${props['component'] || 'N/A'}'`);
// Assign other properties
if (props['component'])
route.component = props['component'];
if (props['redirectTo'])
route.redirectTo = props['redirectTo'];
if (props['loadChildren'])
route.loadChildren = props['loadChildren'];
if (props['canActivate'] && Node.isArrayLiteralExpression(props['canActivate'])) {
route.guards = props['canActivate'].getElements().map(el => el.getText());
}
if (props['data'] && Node.isObjectLiteralExpression(props['data'])) {
route.data = {};
props['data'].getProperties().forEach(dataProp => {
if (Node.isPropertyAssignment(dataProp)) {
const dataKey = dataProp.getNameNode().getText();
const dataValueNode = dataProp.getInitializer();
if (dataValueNode) {
route.data[dataKey] = Node.isStringLiteral(dataValueNode) ? dataValueNode.getLiteralValue() : dataValueNode.getText();
}
}
});
}
// IMPORTANT: If there are children, parse them using the *newly calculated and finalized* route.fullPath
// This is critical: children's full paths are relative to their immediate parent's full path.
if (props['children'] && Node.isArrayLiteralExpression(props['children'])) {
// console.log(` Parsing children for ${route.fullPath} (parent context was ${parentRouteContextFullPath}, path segment ${route.path})`);
const childRoutes = this.parseRouteArray(props['children'], route.fullPath, sourceFile);
// Assign to route.children for structural integrity if needed elsewhere, but also add them to the main collection.
route.children = childRoutes;
for (const childRoute of childRoutes) {
this.addRouteToCollection(childRoute);
}
}
console.log(`[ParseRouteObject] END. Returning route object for path: '${route.fullPath}'`);
return route;
}
async loadAndParseLazyModule(modulePathString, parentRouteFullPathForLazyModule, originatingSourceFile) {
let resolvedModulePath = modulePathString;
let exportName = undefined; // For cases like './module#ExportedRoutesArray'
console.log(` [LoadAndParseLazy] For modulePath: '${modulePathString}', OriginatingFile: ${originatingSourceFile.getFilePath()}, Using ParentRoutePathForLazy: '${parentRouteFullPathForLazyModule}'`);
// Avoid re-processing if this exact lazy load (path + parent context) was already triggered
// This is a simple guard, might need more sophisticated caching if complex scenarios arise
const lazyLoadSignature = `${modulePathString}#${parentRouteFullPathForLazyModule}`;
if (this.processedLazyLoads.has(lazyLoadSignature)) {
// console.log(` Skipping already processed lazy load: ${lazyLoadSignature}`);
return;
}
if (resolvedModulePath.includes('#')) {
[resolvedModulePath, exportName] = resolvedModulePath.split('#');
}
// console.log(` Attempting to lazy load: Path='${resolvedModulePath}', Export='${exportName}', ParentRoutePath='${parentRouteFullPathForLazyModule}'`);
// Resolve the module path relative to the originating source file's directory
const baseDir = path.dirname(originatingSourceFile.getFilePath());
let absolutePathToTry = path.resolve(baseDir, resolvedModulePath);
// Check for common extensions or if it's a directory
const extensionsToTry = ['', '.ts', '.module.ts', '.routes.ts'];
let foundPath = undefined;
for (const ext of extensionsToTry) {
let currentTry = absolutePathToTry + ext;
if (fs.existsSync(currentTry) && fs.lstatSync(currentTry).isFile()) {
foundPath = currentTry;
break;
}
}
if (!foundPath && fs.existsSync(absolutePathToTry) && fs.lstatSync(absolutePathToTry).isDirectory()) {
// If it's a directory, look for common routing file patterns inside it
// This is a simplified heuristic. Angular's resolution can be more complex (e.g. package.json main/module fields)
const defaultFiles = ['index.ts', `${path.basename(absolutePathToTry)}.routes.ts`, `${path.basename(absolutePathToTry)}.module.ts`];
for (const defaultFile of defaultFiles) {
let currentTry = path.join(absolutePathToTry, defaultFile);
if (fs.existsSync(currentTry) && fs.lstatSync(currentTry).isFile()) {
foundPath = currentTry;
// console.log(` Lazy path resolved to directory, found default file: ${foundPath}`);
break;
}
}
}
if (!foundPath) {
// Fallback: Try resolving from project 'src' or 'src/app' if not found relative to current file
// This helps with paths like 'app/features/my-feature/my-feature.module'
const fallbackBasePaths = [path.join(this.angularProjectPath, 'src'), path.join(this.angularProjectPath, 'src/app')];
for (const basePath of fallbackBasePaths) {
absolutePathToTry = path.resolve(basePath, resolvedModulePath.startsWith('./') ? resolvedModulePath.substring(2) : resolvedModulePath);
for (const ext of extensionsToTry) {
let currentTry = absolutePathToTry + ext;
if (fs.existsSync(currentTry) && fs.lstatSync(currentTry).isFile()) {
foundPath = currentTry;
break;
}
}
if (foundPath)
break;
if (!foundPath && fs.existsSync(absolutePathToTry) && fs.lstatSync(absolutePathToTry).isDirectory()) {
const defaultFiles = ['index.ts', `${path.basename(absolutePathToTry)}.routes.ts`, `${path.basename(absolutePathToTry)}.module.ts`];