UNPKG

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

833 lines (832 loc) 40.2 kB
import { Project, SyntaxKind, Node, } from "ts-morph"; import { parse as parseHTML } from "node-html-parser"; import * as fs from "fs"; import * as path from "path"; import glob from "fast-glob"; export class AngularAnalyzer { constructor() { this.routes = []; this.flows = []; this.menus = []; this.processedRouteObjects = new Set(); this.processedLazyLoads = new Set(); } getFrameworkName() { return "Angular"; } async canAnalyze(projectPath) { // Check for Angular-specific files const angularFiles = ["angular.json", "package.json"]; for (const file of angularFiles) { const filePath = path.join(projectPath, file); if (fs.existsSync(filePath)) { if (file === "package.json") { try { const packageJson = JSON.parse(fs.readFileSync(filePath, "utf-8")); const deps = { ...packageJson.dependencies, ...packageJson.devDependencies, }; if (deps["@angular/core"] || deps["@angular/cli"]) { return true; } } catch (error) { // Invalid package.json, continue checking other files } } else { return true; // angular.json exists } } } // Check for TypeScript config and Angular-specific source structure const tsConfigPath = path.join(projectPath, "tsconfig.json"); const srcAppPath = path.join(projectPath, "src", "app"); return fs.existsSync(tsConfigPath) && fs.existsSync(srcAppPath); } getSupportedExtensions() { return [".ts", ".html"]; } getConfigFilePatterns() { return ["angular.json", "tsconfig.json", "package.json"]; } async analyze(options) { this.angularProjectPath = options.projectPath; this.routes = []; this.flows = []; this.menus = []; this.processedRouteObjects.clear(); this.processedLazyLoads.clear(); // Initialize ts-morph project this.project = new Project({ tsConfigFilePath: path.join(this.angularProjectPath, "tsconfig.json"), }); console.log("🔍 Starting Angular project analysis..."); await this.addSourceFiles(); console.log(`📁 Total TypeScript source files loaded: ${this.project.getSourceFiles().length}`); console.log("📍 Analyzing routing modules (initial pass)..."); await this.analyzeRoutingModules("/"); console.log("🔎 Analyzing template files and TS for navigation..."); await this.analyzeSourceFilesForNavigation(); return { routes: this.routes, flows: this.flows, menus: this.menus, }; } // Helper to convert kebab-case to PascalCase for component names kebabToPascalCase(filename) { let name = path.basename(filename, path.extname(filename)); 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)$/, ""); return name .split("-") .filter((part) => part.length > 0) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(""); } async addSourceFiles() { const tsFiles = await glob("**/*.ts", { 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 { console.log(` [AnalyzeRoutes] Initial scan for top-level routing configuration. Parent context for this scan: '${parentPathForThisContext}' (this should be '/')`); const appConfigTs = this.project.getSourceFile((sf) => /app\.config\.ts$/.test(sf.getFilePath())); const appModuleTs = this.project.getSourceFile((sf) => /app\.module\.ts$/.test(sf.getFilePath())); 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 (appModuleTs && filesToProcess.length === 0) { console.log(` Found app.module.ts: ${appModuleTs.getFilePath()}`); filesToProcess.push(appModuleTs); } if (filesToProcess.length === 0) { console.warn(" ⚠️ No clear top-level routing configuration file found for initial route scan."); } } for (const sourceFile of filesToProcess) { const filePath = sourceFile.getFilePath(); 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(); // 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)) { this.processRouteArrayLiteral(firstArg, parentPathForThisFileContext, sourceFile); processedArrayLiterals.add(firstArg); } else if (Node.isIdentifier(firstArg)) { this.resolveAndProcessIdentifier(firstArg, parentPathForThisFileContext, sourceFile, processedArrayLiterals); } } } // NgModule imports analysis if (sourceFile.getFilePath().endsWith(".module.ts")) { this.analyzeNgModuleImports(sourceFile, parentPathForThisFileContext); } // General scan for route arrays this.performGeneralRouteScan(sourceFile, parentPathForThisFileContext, processedArrayLiterals); } resolveAndProcessIdentifier(identifier, parentPath, sourceFile, processedArrays) { const identifierName = identifier.getText(); try { const definitions = identifier.getDefinitionNodes(); for (const def of definitions) { if (Node.isVariableDeclaration(def)) { const initializer = def.getInitializer(); if (initializer && Node.isArrayLiteralExpression(initializer)) { this.processRouteArrayLiteral(initializer, parentPath, sourceFile); processedArrays.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 '${parentPath}'.`); this.analyzeRoutingModules(parentPath, [importSourceFile]); } } } } catch (error) { console.error(` Error resolving identifier '${identifierName}' in ${sourceFile.getFilePath()}: ${error}`); } } analyzeNgModuleImports(sourceFile, parentPath) { 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)) { this.processNgModuleImportsArray(importsInitializer, parentPath, sourceFile); } } } } } processNgModuleImportsArray(importsArray, parentPath, sourceFile) { for (const imp of importsArray.getElements()) { if (Node.isIdentifier(imp)) { const importName = imp.getText(); try { const definitionNodes = imp.getDefinitionNodes(); for (const defNode of definitionNodes) { const definitionSourceFile = defNode.getSourceFile(); if (definitionSourceFile && definitionSourceFile.getFilePath() !== sourceFile.getFilePath()) { if (Node.isClassDeclaration(defNode) || Node.isFunctionDeclaration(defNode)) { console.log(` [NgModuleScan] '${importName}' definition found in ${definitionSourceFile.getFilePath()}. Scheduling analysis.`); this.analyzeRoutingModules(parentPath, [definitionSourceFile]); break; } } else if (Node.isImportSpecifier(defNode)) { const importDeclaration = defNode.getImportDeclaration(); const importedSourceFile = importDeclaration.getModuleSpecifierSourceFile(); if (importedSourceFile && importedSourceFile.getFilePath() !== sourceFile.getFilePath()) { this.analyzeRoutingModules(parentPath, [importedSourceFile]); break; } } } } catch (err) { console.warn(` [NgModuleScan] Error resolving identifier ${importName}: ${err}`); } } } } performGeneralRouteScan(sourceFile, parentPath, processedArrays) { const allArrayLiterals = sourceFile.getDescendantsOfKind(SyntaxKind.ArrayLiteralExpression); for (const arr of allArrayLiterals) { if (processedArrays.has(arr)) continue; 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) { this.processRouteArrayLiteral(arr, parentPath, sourceFile); } } } 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) { this.addRouteToCollection(pr); } } addRouteToCollection(pr) { if (!pr || (!pr.component && !pr.loadChildren && !pr.redirectTo && (!pr.children || pr.children.length === 0) && pr.path === "")) { 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"}'`); const existingRouteByPathIndex = this.routes.findIndex((r) => r.fullPath === pr.fullPath); if (existingRouteByPathIndex !== -1) { const existingRoute = this.routes[existingRouteByPathIndex]; 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"; if (updateReason) { console.log(` [AddRoute] Updating existing route at ${pr.fullPath} because of ${updateReason}.`); this.routes[existingRouteByPathIndex] = pr; } return; } 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} with more specific path: ${pr.fullPath}`); this.routes[existingRouteByComponentIndex] = pr; } return; } 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) { this.routes.push(pr); } } parseRouteArray(node, parentRouteContextFullPath, sourceFile) { const routes = []; if (Node.isArrayLiteralExpression(node)) { node.getElements().forEach((element) => { if (Node.isObjectLiteralExpression(element)) { if (this.processedRouteObjects.has(element)) { return; } const route = this.parseRouteObject(element, parentRouteContextFullPath, sourceFile); if (route) { this.processedRouteObjects.add(element); routes.push(route); if (route.loadChildren) { this.loadAndParseLazyModule(route.loadChildren, route.fullPath, sourceFile).catch((err) => { console.warn(`⚠️ Error processing lazy module ${route.loadChildren}: ${err.message}`); }); } } } }); } return routes; } parseRouteObject(node, parentRouteContextFullPath, sourceFile) { const props = {}; let pathSegmentFromProps = undefined; node.getProperties().forEach((prop) => { if (Node.isPropertyAssignment(prop)) { const name = prop.getNameNode().getText(); const initializer = prop.getInitializer(); 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": this.parseLoadComponent(initializer, props); break; case "loadChildren": this.parseLoadChildren(initializer, props, name); break; case "redirectTo": if (Node.isStringLiteral(initializer)) props[name] = initializer.getLiteralValue(); break; case "children": if (Node.isArrayLiteralExpression(initializer)) props[name] = initializer; break; case "canActivate": if (Node.isArrayLiteralExpression(initializer)) props[name] = initializer; break; case "data": if (Node.isObjectLiteralExpression(initializer)) props[name] = initializer; break; } } }); if (pathSegmentFromProps === undefined) { if (!props["component"] && !props["children"] && !props["loadChildren"] && !props["redirectTo"] && !props["loadComponent"]) { return null; } pathSegmentFromProps = ""; } const route = { path: pathSegmentFromProps, fullPath: "" }; // Calculate fullPath if (parentRouteContextFullPath === "/" && route.path === "") { route.fullPath = "/"; if (!this.routes.some((r) => r.isRoot)) { route.isRoot = true; } } else if (parentRouteContextFullPath === "/") { route.fullPath = `/${route.path}`; } else { route.fullPath = route.path === "" ? parentRouteContextFullPath : `${parentRouteContextFullPath}/${route.path}`; } // Normalize fullPath route.fullPath = route.fullPath.replace(/\/\//g, "/"); if (route.fullPath !== "/" && route.fullPath.endsWith("/")) { route.fullPath = route.fullPath.slice(0, -1); } if (route.fullPath === "") route.fullPath = "/"; // 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(); } } }); } // Parse children if (props["children"] && Node.isArrayLiteralExpression(props["children"])) { const childRoutes = this.parseRouteArray(props["children"], route.fullPath, sourceFile); route.children = childRoutes; // Important: If this route has a component (like MainLayoutComponent), // we need to ensure it appears as a node in the graph // Only add child routes to the collection if this route has no component // Otherwise, the hierarchy will be preserved through the parent-child relationship if (!route.component && !props["loadComponent"]) { // This is a grouping route without its own component, so flatten the children for (const childRoute of childRoutes) { this.addRouteToCollection(childRoute); } } } return route; } parseLoadComponent(initializer, props) { 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 thenPropertyAccess = importCall.getParentWhile((n) => n.getKind() !== SyntaxKind.PropertyAccessExpression && n !== initializer.getBody()); if (thenPropertyAccess && Node.isPropertyAccessExpression(thenPropertyAccess)) { const standaloneCompName = thenPropertyAccess.getNameNode().getText(); props["component"] = standaloneCompName; } else { const importPathNode = importCall.getArguments()[0]; if (Node.isStringLiteral(importPathNode)) { const importedFileName = importPathNode .getLiteralValue() .split("/") .pop() ?.split(".")[0]; if (importedFileName) { const derivedCompName = this.kebabToPascalCase(importedFileName); props["component"] = derivedCompName; } } } } } } parseLoadChildren(initializer, props, name) { 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(); } } } else if (Node.isStringLiteral(initializer)) { props[name] = initializer.getLiteralValue(); } else if (Node.isIdentifier(initializer)) { this.resolveLoadChildrenIdentifier(initializer, props, name); } } resolveLoadChildrenIdentifier(initializer, props, name) { 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; else if (Node.isPropertyAssignment(def)) functionBodyNode = def.getInitializer(); if (functionBodyNode && (Node.isArrowFunction(functionBodyNode) || Node.isFunctionExpression(functionBodyNode) || Node.isFunctionDeclaration(functionBodyNode))) { 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(); break; } } } } } } catch (err) { console.error(`Error resolving loadChildren identifier: ${err}`); } } async loadAndParseLazyModule(modulePathString, parentRouteFullPathForLazyModule, originatingSourceFile) { let resolvedModulePath = modulePathString; let exportName = undefined; const lazyLoadSignature = `${modulePathString}#${parentRouteFullPathForLazyModule}`; if (this.processedLazyLoads.has(lazyLoadSignature)) { return; } if (resolvedModulePath.includes("#")) { [resolvedModulePath, exportName] = resolvedModulePath.split("#"); } const baseDir = path.dirname(originatingSourceFile.getFilePath()); let absolutePathToTry = path.resolve(baseDir, resolvedModulePath); 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()) { 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; break; } } } if (!foundPath) { 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) { console.warn(`⚠️ Could not resolve lazy-loaded file path: '${modulePathString}'. Parent: ${parentRouteFullPathForLazyModule}.`); return; } const lazyLoadedSourceFile = this.project.addSourceFileAtPathIfExists(foundPath) || this.project.getSourceFile(foundPath); if (lazyLoadedSourceFile) { this.project.resolveSourceFileDependencies(); if (foundPath.endsWith(".module.ts")) { const moduleDir = path.dirname(foundPath); const baseModuleName = path.basename(foundPath, ".module.ts"); const patterns = [ `${baseModuleName}-routing.module.ts`, `${baseModuleName}.routing.module.ts`, `${baseModuleName}.routes.ts`, ]; let routingFileFound = false; for (const pattern of patterns) { const routingFilePath = path.join(moduleDir, pattern); if (fs.existsSync(routingFilePath)) { const routingSourceFile = this.project.addSourceFileAtPathIfExists(routingFilePath) || this.project.getSourceFile(routingFilePath); if (routingSourceFile) { this.project.resolveSourceFileDependencies(); await this.analyzeRoutingModules(parentRouteFullPathForLazyModule, [routingSourceFile]); routingFileFound = true; break; } } } if (!routingFileFound) { await this.analyzeRoutingModules(parentRouteFullPathForLazyModule, [ lazyLoadedSourceFile, ]); } } else { await this.analyzeRoutingModules(parentRouteFullPathForLazyModule, [ lazyLoadedSourceFile, ]); } } this.processedLazyLoads.add(lazyLoadSignature); } async analyzeSourceFilesForNavigation() { const sourceFiles = this.project.getSourceFiles(); for (const sourceFile of sourceFiles) { this.extractProgrammaticNavigation(sourceFile); const filePath = sourceFile.getFilePath(); if (filePath.endsWith(".component.ts")) { await this.analyzeComponentNavigation(sourceFile); } } } async analyzeComponentNavigation(sourceFile) { const componentClass = sourceFile.getClasses()[0]; if (!componentClass) return; const filePath = sourceFile.getFilePath(); let templateContent = null; let templatePath = null; const decorator = componentClass.getDecorator("Component"); if (decorator) { const decoratorArgs = decorator.getArguments(); if (decoratorArgs.length > 0 && Node.isObjectLiteralExpression(decoratorArgs[0])) { const metadata = decoratorArgs[0]; // Check for templateUrl const templateUrlProp = metadata.getProperty("templateUrl"); if (templateUrlProp && Node.isPropertyAssignment(templateUrlProp)) { const initializer = templateUrlProp.getInitializer(); if (initializer && Node.isStringLiteral(initializer)) { const relativePath = initializer.getLiteralValue(); templatePath = path.resolve(path.dirname(filePath), relativePath); } } // Check for inline template if no templateUrl if (!templatePath) { const templateProp = metadata.getProperty("template"); if (templateProp && Node.isPropertyAssignment(templateProp)) { const initializer = templateProp.getInitializer(); if (initializer && (Node.isStringLiteral(initializer) || Node.isNoSubstitutionTemplateLiteral(initializer))) { templateContent = initializer.getLiteralText(); } } } } } if (templatePath && fs.existsSync(templatePath)) { templateContent = fs.readFileSync(templatePath, "utf-8"); } if (templateContent) { this.extractTemplateNavigation(templateContent, templatePath || filePath, this.kebabToPascalCase(path.basename(filePath))); } } extractProgrammaticNavigation(sourceFile) { const navigateCalls = sourceFile .getDescendantsOfKind(SyntaxKind.CallExpression) .filter((call) => { const expr = call.getExpression(); const exprText = expr.getText(); return !!exprText.match(/router\.(navigate|navigateByUrl)/); }); for (const call of navigateCalls) { const flow = this.parseNavigationCall(call, sourceFile.getFilePath()); if (flow) { this.flows.push(flow); } } } parseNavigationCall(callNode, filePath) { const expression = callNode.getExpression().getText(); const isNavigateCall = expression.endsWith("router.navigate") || expression.endsWith("router.navigateByUrl"); if (!isNavigateCall) return null; const navArgs = callNode.getArguments(); if (navArgs.length === 0) return null; const targetPathNode = navArgs[0]; let targetPath; if (Node.isArrayLiteralExpression(targetPathNode)) { let segments = []; targetPathNode.getElements().forEach((elNode) => { if (Node.isStringLiteral(elNode)) { segments.push(elNode.getLiteralValue()); } else { const elText = elNode.getText(); if (elText.toLowerCase().includes("id")) { segments.push(":id"); } else { segments.push(`:${elText.replace(/[^a-zA-Z0-9_]/g, "")}`); } } }); if (segments.length > 0) { let builtPath = ""; if (segments[0].startsWith("/")) { builtPath = segments[0]; for (let i = 1; i < segments.length; i++) { if (!builtPath.endsWith("/")) { builtPath += "/"; } builtPath += segments[i].startsWith("/") ? segments[i].substring(1) : segments[i]; } } else { builtPath = segments .map((s) => s.replace(/^\/+|\/+$/g, "")) .filter((s) => s) .join("/"); } targetPath = builtPath.replace(/\/\//g, "/"); } } else if (Node.isStringLiteral(targetPathNode)) { targetPath = targetPathNode.getLiteralValue(); } if (targetPath === undefined) return null; let fromContextIdentifier; const containingClass = callNode.getFirstAncestorByKind(SyntaxKind.ClassDeclaration); if (containingClass && containingClass.getNameNode()) { fromContextIdentifier = containingClass.getName(); } else { const baseName = path.basename(filePath, path.extname(filePath)); const strippedName = baseName.replace(/\.(component|service|guard)$/, ""); fromContextIdentifier = this.kebabToPascalCase(strippedName); } if (!targetPath.startsWith("/")) { const currentRoute = this.routes.find((r) => r.component === fromContextIdentifier); if (currentRoute && currentRoute.fullPath) { try { const ensuredBasePath = currentRoute.fullPath.startsWith("/") ? currentRoute.fullPath : "/" + currentRoute.fullPath; const baseUrlString = "http://dummy.com" + ensuredBasePath; const baseUrl = new URL(baseUrlString); targetPath = new URL(targetPath, baseUrl).pathname; } catch (e) { console.warn(`Failed to resolve relative path "${targetPath}" against base "${currentRoute.fullPath}". Error: ${e.message}`); } } } const from = containingClass && containingClass.getNameNode() ? containingClass.getName() : fromContextIdentifier; return { from, to: targetPath, type: "dynamic" }; } extractTemplateNavigation(content, templateFilePath, fromComponentName) { const root = parseHTML(content); const routerLinks = root.querySelectorAll("[routerLink]"); for (const link of routerLinks) { const routePath = link.getAttribute("routerLink"); if (routePath) { let normalizedToPath = routePath.trim(); if (normalizedToPath !== "/" && normalizedToPath.endsWith("/")) { normalizedToPath = normalizedToPath.slice(0, -1); } this.flows.push({ from: fromComponentName, to: normalizedToPath, type: "static", }); } } } }