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
1,204 lines • 51.8 kB
JavaScript
import { Project, SyntaxKind, Node, } from "ts-morph";
import * as fs from "fs";
import * as path from "path";
import glob from "fast-glob";
export class ReactAnalyzer {
constructor() {
this.routes = [];
this.flows = [];
this.menus = [];
this.processedComponents = new Set();
this.componentToFileMap = new Map();
this.fileToComponentMap = new Map();
this.routeComponents = new Set();
}
getFrameworkName() {
return "React";
}
async canAnalyze(projectPath) {
const packageJsonPath = path.join(projectPath, "package.json");
if (fs.existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
const deps = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
// Check for React and common React routing libraries
if (deps["react"] ||
deps["@types/react"] ||
deps["react-router-dom"] ||
deps["@tanstack/react-router"] ||
deps["@reach/router"] ||
deps["next"] ||
deps["gatsby"] ||
deps["@remix-run/react"]) {
return true;
}
}
catch (error) {
// Invalid package.json
}
}
// Check for common React project structures
const commonReactFiles = [
"src/App.js",
"src/App.jsx",
"src/App.ts",
"src/App.tsx",
"src/index.js",
"src/index.jsx",
"src/index.ts",
"src/index.tsx",
"pages/index.js", // Next.js pages
"app/page.tsx", // Next.js app router
"src/routes/index.js", // Common pattern
];
for (const file of commonReactFiles) {
if (fs.existsSync(path.join(projectPath, file))) {
return true;
}
}
return false;
}
getSupportedExtensions() {
return [".js", ".jsx", ".ts", ".tsx", ".mjs"];
}
getConfigFilePatterns() {
return [
"package.json",
"tsconfig.json",
"jsconfig.json",
"next.config.js",
"next.config.mjs",
"gatsby-config.js",
"remix.config.js",
"vite.config.js",
"vite.config.ts",
"webpack.config.js",
];
}
async analyze(options) {
this.projectPath = options.projectPath;
this.routes = [];
this.flows = [];
this.menus = [];
this.processedComponents.clear();
this.componentToFileMap.clear();
this.fileToComponentMap.clear();
this.routeComponents.clear();
// Initialize ts-morph project
const tsConfigPath = path.join(this.projectPath, "tsconfig.json");
const jsConfigPath = path.join(this.projectPath, "jsconfig.json");
if (fs.existsSync(tsConfigPath)) {
this.project = new Project({
tsConfigFilePath: tsConfigPath,
});
}
else if (fs.existsSync(jsConfigPath)) {
this.project = new Project({
compilerOptions: JSON.parse(fs.readFileSync(jsConfigPath, "utf-8"))
.compilerOptions,
});
}
else {
this.project = new Project({
compilerOptions: {
allowJs: true,
jsx: 2, // JsxEmit.React
module: 99, // ModuleKind.ESNext
target: 99, // ScriptTarget.ESNext
moduleResolution: 2, // ModuleResolutionKind.NodeJs
},
});
}
console.log("🔍 Starting React project analysis...");
// Add source files
await this.addSourceFiles();
console.log(`📁 Total source files loaded: ${this.project.getSourceFiles().length}`);
// Build component map
this.buildComponentMap();
// Detect routing library
const routingLibrary = await this.detectRoutingLibrary();
console.log(`📚 Detected routing library: ${routingLibrary || "none"}`);
// Analyze based on detected routing library
if (routingLibrary === "next") {
await this.analyzeNextJsRouting();
}
else if (routingLibrary === "gatsby") {
await this.analyzeGatsbyRouting();
}
else if (routingLibrary === "remix") {
await this.analyzeRemixRouting();
}
else if (routingLibrary === "react-router") {
await this.analyzeReactRouterRouting();
}
else if (routingLibrary === "tanstack-router") {
await this.analyzeTanstackRouting();
}
else if (routingLibrary === "reach-router") {
await this.analyzeReachRouterRouting();
}
else {
// Generic React analysis - look for common patterns
await this.analyzeGenericReactPatterns();
}
// Analyze navigation flows
await this.analyzeNavigationFlows();
// Analyze menu structures
await this.analyzeMenuStructures();
// Add hierarchical relationships
this.addHierarchicalFlows();
return {
routes: this.routes,
flows: this.flows,
menus: this.menus,
};
}
async addSourceFiles() {
const patterns = [
"**/*.{js,jsx,ts,tsx,mjs}",
"!node_modules/**",
"!dist/**",
"!build/**",
"!.next/**",
"!.cache/**",
"!public/**",
"!coverage/**",
];
const files = await glob(patterns, {
cwd: this.projectPath,
absolute: true,
});
this.project.addSourceFilesAtPaths(files);
}
buildComponentMap() {
const sourceFiles = this.project.getSourceFiles();
for (const sourceFile of sourceFiles) {
const filePath = sourceFile.getFilePath();
const components = this.extractComponentsFromFile(sourceFile);
if (components.length > 0) {
this.fileToComponentMap.set(filePath, new Set(components));
for (const component of components) {
this.componentToFileMap.set(component, filePath);
}
}
}
}
extractComponentsFromFile(sourceFile) {
const components = [];
// Function declarations
sourceFile.getFunctions().forEach((func) => {
const name = func.getName();
if (name && this.isComponentName(name)) {
components.push(name);
}
});
// Variable declarations with arrow functions or function expressions
sourceFile.getVariableDeclarations().forEach((varDecl) => {
const name = varDecl.getName();
const initializer = varDecl.getInitializer();
if (initializer &&
this.isComponentName(name) &&
(Node.isArrowFunction(initializer) ||
Node.isFunctionExpression(initializer))) {
components.push(name);
}
});
// Class declarations
sourceFile.getClasses().forEach((classDecl) => {
const name = classDecl.getName();
if (name && this.isComponentName(name)) {
// Check if extends React.Component or similar
const extendsExpr = classDecl.getExtends();
if (extendsExpr) {
const extendsText = extendsExpr.getText();
if (extendsText.includes("Component") ||
extendsText.includes("PureComponent")) {
components.push(name);
}
}
}
});
// Export statements
const defaultExport = sourceFile.getDefaultExportSymbol();
if (defaultExport) {
const name = defaultExport.getName();
if (name && name !== "default" && this.isComponentName(name)) {
components.push(name);
}
}
return components;
}
isComponentName(name) {
// React components typically start with uppercase
return /^[A-Z]/.test(name);
}
async detectRoutingLibrary() {
const packageJsonPath = path.join(this.projectPath, "package.json");
if (fs.existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
const deps = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
if (deps["next"])
return "next";
if (deps["gatsby"])
return "gatsby";
if (deps["@remix-run/react"])
return "remix";
if (deps["react-router-dom"] || deps["react-router"])
return "react-router";
if (deps["@tanstack/react-router"])
return "tanstack-router";
if (deps["@reach/router"])
return "reach-router";
}
catch (error) {
console.warn("Error reading package.json:", error);
}
}
return null;
}
async analyzeReactRouterRouting() {
console.log("🛣️ Analyzing React Router routes...");
const sourceFiles = this.project.getSourceFiles();
for (const sourceFile of sourceFiles) {
// Find Route components and router configurations
this.extractReactRouterRoutes(sourceFile);
// Find Routes defined in objects or arrays
this.extractReactRouterConfigRoutes(sourceFile);
}
}
extractReactRouterRoutes(sourceFile) {
// Find JSX Route elements
const jsxElements = sourceFile.getDescendantsOfKind(SyntaxKind.JsxElement);
const jsxSelfClosingElements = sourceFile.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement);
const allJsxElements = [...jsxElements, ...jsxSelfClosingElements];
for (const element of allJsxElements) {
const tagName = this.getJsxTagName(element);
if (tagName === "Route" || tagName === "PrivateRoute") {
const routeInfo = this.extractRouteInfo(element);
if (routeInfo) {
this.addRoute(routeInfo);
}
}
// Handle Routes component with children
if (tagName === "Routes" || tagName === "Switch") {
this.extractNestedRoutes(element);
}
}
}
extractReactRouterConfigRoutes(sourceFile) {
// Look for route configuration arrays
const arrayLiterals = sourceFile.getDescendantsOfKind(SyntaxKind.ArrayLiteralExpression);
for (const array of arrayLiterals) {
if (this.isRouteConfigArray(array)) {
this.processRouteConfigArray(array);
}
}
// Look for createBrowserRouter, createMemoryRouter, etc.
const callExpressions = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
for (const call of callExpressions) {
const expression = call.getExpression().getText();
if (expression.includes("createBrowserRouter") ||
expression.includes("createMemoryRouter") ||
expression.includes("createHashRouter")) {
const args = call.getArguments();
if (args.length > 0 && Node.isArrayLiteralExpression(args[0])) {
this.processRouteConfigArray(args[0]);
}
}
}
}
getJsxTagName(element) {
if (Node.isJsxElement(element)) {
return element.getOpeningElement().getTagNameNode().getText();
}
else if (Node.isJsxSelfClosingElement(element)) {
return element.getTagNameNode().getText();
}
return null;
}
extractRouteInfo(element) {
const attributes = this.getJsxAttributes(element);
const routeInfo = { path: "" };
// Extract path
const pathAttr = attributes.find((attr) => attr.name === "path");
if (pathAttr) {
routeInfo.path = this.extractAttributeValue(pathAttr.value);
}
// Extract component/element
const componentAttr = attributes.find((attr) => attr.name === "component");
const elementAttr = attributes.find((attr) => attr.name === "element");
if (componentAttr) {
routeInfo.component = this.extractAttributeValue(componentAttr.value);
}
else if (elementAttr) {
routeInfo.element = this.extractJsxElementComponent(elementAttr.value);
}
// Extract index route
const indexAttr = attributes.find((attr) => attr.name === "index");
if (indexAttr) {
routeInfo.index = true;
routeInfo.path = ""; // Index routes don't have paths
}
// Extract guards/middleware
const requireAuthAttr = attributes.find((attr) => attr.name === "requireAuth");
const middlewareAttr = attributes.find((attr) => attr.name === "middleware");
if (requireAuthAttr || middlewareAttr) {
routeInfo.guards = ["AuthGuard"];
}
return routeInfo.path !== "" || routeInfo.index ? routeInfo : null;
}
getJsxAttributes(element) {
const attributes = [];
let jsxAttributes = [];
if (Node.isJsxElement(element)) {
jsxAttributes = element.getOpeningElement().getAttributes();
}
else if (Node.isJsxSelfClosingElement(element)) {
jsxAttributes = element.getAttributes();
}
for (const attr of jsxAttributes) {
if (Node.isJsxAttribute(attr)) {
const name = attr.getNameNode().getText();
const initializer = attr.getInitializer();
attributes.push({ name, value: initializer });
}
}
return attributes;
}
extractAttributeValue(value) {
if (!value)
return "";
if (Node.isStringLiteral(value)) {
return value.getLiteralValue();
}
else if (Node.isJsxExpression(value)) {
const expression = value.getExpression();
if (expression) {
if (Node.isIdentifier(expression)) {
return expression.getText();
}
else if (Node.isStringLiteral(expression)) {
return expression.getLiteralValue();
}
return expression.getText();
}
}
return value.getText ? value.getText() : "";
}
extractJsxElementComponent(value) {
if (!value)
return "";
if (Node.isJsxExpression(value)) {
const expression = value.getExpression();
if (expression) {
const text = expression.getText();
// Extract component name from JSX like <Component />
const match = text.match(/<(\w+)/);
if (match) {
return match[1];
}
// Handle direct component references
if (Node.isIdentifier(expression)) {
return expression.getText();
}
}
}
return "";
}
extractNestedRoutes(element) {
if (Node.isJsxElement(element)) {
const children = element.getJsxChildren();
for (const child of children) {
if (Node.isJsxElement(child) ||
Node.isJsxSelfClosingElement(child)) {
const tagName = this.getJsxTagName(child);
if (tagName === "Route") {
const routeInfo = this.extractRouteInfo(child);
if (routeInfo) {
this.addRoute(routeInfo);
}
}
}
}
}
}
isRouteConfigArray(array) {
const elements = array.getElements();
if (elements.length === 0)
return false;
// Check if array contains route-like objects
return elements.some((element) => {
if (Node.isObjectLiteralExpression(element)) {
const props = element.getProperties();
return props.some((prop) => Node.isPropertyAssignment(prop) &&
(prop.getName() === "path" ||
prop.getName() === "element" ||
prop.getName() === "component" ||
prop.getName() === "children"));
}
return false;
});
}
processRouteConfigArray(array, parentPath = "") {
const elements = array.getElements();
for (const element of elements) {
if (Node.isObjectLiteralExpression(element)) {
this.processRouteConfigObject(element, parentPath);
}
}
}
processRouteConfigObject(obj, parentPath) {
const routeConfig = { path: "" };
let children = null;
for (const prop of obj.getProperties()) {
if (Node.isPropertyAssignment(prop)) {
const name = prop.getName();
const initializer = prop.getInitializer();
if (name === "path" && initializer) {
routeConfig.path = this.extractStringValue(initializer);
}
else if (name === "element" && initializer) {
routeConfig.element = this.extractComponentFromInitializer(initializer);
}
else if (name === "component" && initializer) {
routeConfig.component = this.extractComponentFromInitializer(initializer);
}
else if (name === "index" && initializer) {
routeConfig.index = initializer.getText() === "true";
if (routeConfig.index) {
routeConfig.path = "";
}
}
else if (name === "children" && initializer) {
if (Node.isArrayLiteralExpression(initializer)) {
children = initializer;
}
}
}
}
// Add the route
const fullPath = this.buildFullPath(parentPath, routeConfig.path);
if (routeConfig.path !== "" || routeConfig.index) {
this.addRoute({ ...routeConfig, path: fullPath });
}
// Process children
if (children) {
this.processRouteConfigArray(children, fullPath);
}
}
extractStringValue(node) {
if (Node.isStringLiteral(node)) {
return node.getLiteralValue();
}
else if (Node.isIdentifier(node)) {
// Try to resolve the identifier
const symbol = node.getSymbol();
if (symbol) {
const valueDeclaration = symbol.getValueDeclaration();
if (valueDeclaration && Node.isVariableDeclaration(valueDeclaration)) {
const initializer = valueDeclaration.getInitializer();
if (initializer && Node.isStringLiteral(initializer)) {
return initializer.getLiteralValue();
}
}
}
}
return node.getText().replace(/['"]/g, "");
}
extractComponentFromInitializer(node) {
const text = node.getText();
// Handle JSX elements like <Component />
const jsxMatch = text.match(/<(\w+)/);
if (jsxMatch) {
return jsxMatch[1];
}
// Handle direct component references
if (Node.isIdentifier(node)) {
return node.getText();
}
// Handle lazy loading
if (text.includes("lazy")) {
const lazyMatch = text.match(/lazy\s*\(\s*\(\s*\)\s*=>\s*import\s*\(\s*['"]([^'"]+)['"]\s*\)/);
if (lazyMatch) {
const importPath = lazyMatch[1];
return this.getComponentNameFromPath(importPath);
}
}
return text;
}
getComponentNameFromPath(importPath) {
const fileName = path.basename(importPath, path.extname(importPath));
return this.kebabToPascalCase(fileName);
}
kebabToPascalCase(str) {
return str
.split(/[-_]/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
}
buildFullPath(parentPath, childPath) {
if (childPath.startsWith("/")) {
return childPath;
}
const cleanParent = parentPath.endsWith("/")
? parentPath.slice(0, -1)
: parentPath;
const cleanChild = childPath.startsWith("/")
? childPath.slice(1)
: childPath;
if (cleanParent === "" || cleanParent === "/") {
return "/" + cleanChild;
}
return cleanParent + "/" + cleanChild;
}
addRoute(routeConfig) {
const route = {
path: routeConfig.path,
fullPath: routeConfig.path.startsWith("/")
? routeConfig.path
: "/" + routeConfig.path,
component: routeConfig.component || routeConfig.element,
guards: routeConfig.guards,
};
// Avoid duplicates
const exists = this.routes.some((r) => r.fullPath === route.fullPath && r.component === route.component);
if (!exists) {
this.routes.push(route);
if (route.component) {
this.routeComponents.add(route.component);
}
}
}
async analyzeNextJsRouting() {
console.log("🔷 Analyzing Next.js routing...");
// Check for pages directory (Pages Router)
const pagesDir = path.join(this.projectPath, "pages");
if (fs.existsSync(pagesDir)) {
await this.analyzeNextJsPagesRouter(pagesDir);
}
// Check for src/pages directory
const srcPagesDir = path.join(this.projectPath, "src", "pages");
if (fs.existsSync(srcPagesDir)) {
await this.analyzeNextJsPagesRouter(srcPagesDir);
}
// Check for app directory (App Router)
const appDir = path.join(this.projectPath, "app");
if (fs.existsSync(appDir)) {
await this.analyzeNextJsAppRouter(appDir);
}
// Check for src/app directory
const srcAppDir = path.join(this.projectPath, "src", "app");
if (fs.existsSync(srcAppDir)) {
await this.analyzeNextJsAppRouter(srcAppDir);
}
}
async analyzeNextJsPagesRouter(pagesDir) {
const pageFiles = await glob("**/*.{js,jsx,ts,tsx}", {
cwd: pagesDir,
absolute: true,
ignore: ["_app.*", "_document.*", "api/**"],
});
for (const file of pageFiles) {
const relativePath = path.relative(pagesDir, file);
const routePath = this.nextJsFileToRoute(relativePath);
const componentName = this.getComponentNameFromFile(file);
this.routes.push({
path: routePath,
fullPath: routePath,
component: componentName,
});
this.routeComponents.add(componentName);
}
}
async analyzeNextJsAppRouter(appDir) {
const pageFiles = await glob("**/page.{js,jsx,ts,tsx}", {
cwd: appDir,
absolute: true,
});
for (const file of pageFiles) {
const relativePath = path.relative(appDir, path.dirname(file));
const routePath = this.nextJsAppDirToRoute(relativePath);
const componentName = this.getComponentNameFromFile(file);
this.routes.push({
path: routePath,
fullPath: routePath,
component: componentName,
});
this.routeComponents.add(componentName);
}
// Also check for route.ts/js files (API routes)
const routeFiles = await glob("**/route.{js,ts}", {
cwd: appDir,
absolute: true,
});
for (const file of routeFiles) {
const relativePath = path.relative(appDir, path.dirname(file));
const routePath = this.nextJsAppDirToRoute(relativePath);
this.routes.push({
path: routePath + " (API)",
fullPath: routePath,
component: "API Route",
});
}
}
nextJsFileToRoute(filePath) {
let route = "/" + filePath.replace(/\.(js|jsx|ts|tsx)$/, "");
// Handle index files
route = route.replace(/\/index$/, "");
// Convert [...param] to :param* (catch-all)
route = route.replace(/\[\.\.\.([^\]]+)\]/g, ":$1*");
// Convert [param] to :param
route = route.replace(/\[([^\]]+)\]/g, ":$1");
return route || "/";
}
nextJsAppDirToRoute(dirPath) {
if (dirPath === ".")
return "/";
let route = "/" + dirPath;
// Remove route groups (parentheses)
route = route.replace(/\/\([^)]+\)/g, "");
// Convert [...param] to :param* (catch-all)
route = route.replace(/\[\.\.\.([^\]]+)\]/g, ":$1*");
// Convert [param] to :param
route = route.replace(/\[([^\]]+)\]/g, ":$1");
return route;
}
getComponentNameFromFile(filePath) {
const fileName = path.basename(filePath, path.extname(filePath));
// Handle special Next.js files
if (fileName === "page" || fileName === "layout") {
const dirName = path.basename(path.dirname(filePath));
if (dirName && dirName !== "." && !dirName.startsWith("(")) {
return this.kebabToPascalCase(dirName) + "Page";
}
return "Page";
}
return this.kebabToPascalCase(fileName);
}
async analyzeGatsbyRouting() {
console.log("🟣 Analyzing Gatsby routing...");
// Analyze pages directory
const pagesDir = path.join(this.projectPath, "src", "pages");
if (fs.existsSync(pagesDir)) {
const pageFiles = await glob("**/*.{js,jsx,ts,tsx}", {
cwd: pagesDir,
absolute: true,
});
for (const file of pageFiles) {
const relativePath = path.relative(pagesDir, file);
const routePath = this.gatsbyFileToRoute(relativePath);
const componentName = this.getComponentNameFromFile(file);
this.routes.push({
path: routePath,
fullPath: routePath,
component: componentName,
});
this.routeComponents.add(componentName);
}
}
// Check for gatsby-node.js for programmatically created pages
const gatsbyNodePath = path.join(this.projectPath, "gatsby-node.js");
if (fs.existsSync(gatsbyNodePath)) {
// This would require more complex parsing of createPage calls
console.log(" Found gatsby-node.js - manual inspection may be needed for dynamic routes");
}
}
gatsbyFileToRoute(filePath) {
let route = "/" + filePath.replace(/\.(js|jsx|ts|tsx)$/, "");
// Handle index files
route = route.replace(/\/index$/, "");
// Handle 404 page
if (route === "/404") {
return "/404";
}
// Convert {param} to :param
route = route.replace(/\{([^}]+)\}/g, ":$1");
return route || "/";
}
async analyzeRemixRouting() {
console.log("🎸 Analyzing Remix routing...");
// Analyze routes directory
const routesDir = path.join(this.projectPath, "app", "routes");
if (fs.existsSync(routesDir)) {
const routeFiles = await glob("**/*.{js,jsx,ts,tsx}", {
cwd: routesDir,
absolute: true,
});
for (const file of routeFiles) {
const relativePath = path.relative(routesDir, file);
const routePath = this.remixFileToRoute(relativePath);
const componentName = this.getComponentNameFromFile(file);
this.routes.push({
path: routePath,
fullPath: routePath,
component: componentName,
});
this.routeComponents.add(componentName);
}
}
}
remixFileToRoute(filePath) {
let route = filePath.replace(/\.(js|jsx|ts|tsx)$/, "");
// Handle index routes
if (route.endsWith("/_index") || route === "_index") {
route = route.replace(/\/_index$/, "").replace(/^_index$/, "/");
}
// Convert $ prefixed params to :param
route = route.replace(/\$([^/]+)/g, ":$1");
// Handle dot notation for nested routes
route = route.replace(/\./g, "/");
// Remove underscore prefixes (pathless routes)
route = route.replace(/\/_/g, "/");
return "/" + route;
}
async analyzeTanstackRouting() {
console.log("🔄 Analyzing TanStack Router routes...");
const sourceFiles = this.project.getSourceFiles();
for (const sourceFile of sourceFiles) {
// Look for route definitions
const callExpressions = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
for (const call of callExpressions) {
const expression = call.getExpression().getText();
if (expression.includes("createRoute") || expression.includes("Route")) {
const args = call.getArguments();
if (args.length > 0 && Node.isObjectLiteralExpression(args[0])) {
this.processTanstackRoute(args[0]);
}
}
}
}
}
processTanstackRoute(routeConfig) {
let path = "";
let component = "";
for (const prop of routeConfig.getProperties()) {
if (Node.isPropertyAssignment(prop)) {
const name = prop.getName();
const initializer = prop.getInitializer();
if (name === "path" && initializer) {
path = this.extractStringValue(initializer);
}
else if (name === "component" && initializer) {
component = this.extractComponentFromInitializer(initializer);
}
}
}
if (path) {
this.routes.push({
path,
fullPath: path.startsWith("/") ? path : "/" + path,
component,
});
if (component) {
this.routeComponents.add(component);
}
}
}
async analyzeReachRouterRouting() {
console.log("🎯 Analyzing Reach Router routes...");
const sourceFiles = this.project.getSourceFiles();
for (const sourceFile of sourceFiles) {
const jsxElements = sourceFile.getDescendantsOfKind(SyntaxKind.JsxElement);
const jsxSelfClosingElements = sourceFile.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement);
const allJsxElements = [...jsxElements, ...jsxSelfClosingElements];
for (const element of allJsxElements) {
const tagName = this.getJsxTagName(element);
// Reach Router uses direct component names with path prop
if (tagName && this.isComponentName(tagName)) {
const attributes = this.getJsxAttributes(element);
const pathAttr = attributes.find((attr) => attr.name === "path");
if (pathAttr) {
const path = this.extractAttributeValue(pathAttr.value);
this.routes.push({
path,
fullPath: path.startsWith("/") ? path : "/" + path,
component: tagName,
});
this.routeComponents.add(tagName);
}
}
}
}
}
async analyzeGenericReactPatterns() {
console.log("🔍 Analyzing generic React patterns...");
// Look for common routing patterns even without specific router library
const sourceFiles = this.project.getSourceFiles();
for (const sourceFile of sourceFiles) {
// Look for switch/case statements that might indicate routing
this.analyzeSwitchRouting(sourceFile);
// Look for conditional rendering based on path/route state
this.analyzeConditionalRouting(sourceFile);
// Look for route-like configuration objects
this.analyzeRouteConfigurations(sourceFile);
}
}
analyzeSwitchRouting(sourceFile) {
const switchStatements = sourceFile.getDescendantsOfKind(SyntaxKind.SwitchStatement);
for (const switchStmt of switchStatements) {
const expression = switchStmt.getExpression();
const expressionText = expression.getText();
// Check if switch is on pathname or route-like variable
if (expressionText.includes("pathname") ||
expressionText.includes("route") ||
expressionText.includes("path")) {
const caseBlock = switchStmt.getCaseBlock();
const clauses = caseBlock.getClauses();
for (const clause of clauses) {
if (clause.getKind() === SyntaxKind.CaseClause) {
const caseClause = clause; // Cast to access CaseClause methods
const caseExpression = caseClause.getExpression();
if (caseExpression && Node.isStringLiteral(caseExpression)) {
const path = caseExpression.getLiteralValue();
// Try to find the component being rendered
const statements = caseClause.getStatements();
let component = "UnknownComponent";
for (const stmt of statements) {
const text = stmt.getText();
const componentMatch = text.match(/<(\w+)/);
if (componentMatch) {
component = componentMatch[1];
break;
}
}
this.routes.push({
path,
fullPath: path.startsWith("/") ? path : "/" + path,
component,
});
}
}
}
}
}
}
analyzeConditionalRouting(sourceFile) {
// Look for patterns like: pathname === '/something' && <Component />
const conditionalExpressions = sourceFile.getDescendantsOfKind(SyntaxKind.ConditionalExpression);
for (const conditional of conditionalExpressions) {
const condition = conditional.getCondition();
const conditionText = condition.getText();
// Check if condition involves pathname comparison
const pathMatch = conditionText.match(/['"]([^'"]+)['"]/);
if (pathMatch && conditionText.includes("path")) {
const path = pathMatch[1];
// Try to extract component from the true branch
const whenTrue = conditional.getWhenTrue();
const trueBranchText = whenTrue.getText();
const componentMatch = trueBranchText.match(/<(\w+)/);
if (componentMatch) {
this.routes.push({
path,
fullPath: path.startsWith("/") ? path : "/" + path,
component: componentMatch[1],
});
}
}
}
}
analyzeRouteConfigurations(sourceFile) {
// Look for objects/arrays that might contain route configurations
const objectLiterals = sourceFile.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression);
for (const obj of objectLiterals) {
const properties = obj.getProperties();
let hasPath = false;
let hasComponent = false;
let path = "";
let component = "";
for (const prop of properties) {
if (Node.isPropertyAssignment(prop)) {
const name = prop.getName();
const initializer = prop.getInitializer();
if (name === "path" || name === "route" || name === "url") {
hasPath = true;
if (initializer) {
path = this.extractStringValue(initializer);
}
}
else if (name === "component" ||
name === "element" ||
name === "view" ||
name === "page") {
hasComponent = true;
if (initializer) {
component = this.extractComponentFromInitializer(initializer);
}
}
}
}
if (hasPath && path) {
this.routes.push({
path,
fullPath: path.startsWith("/") ? path : "/" + path,
component: hasComponent ? component : "UnknownComponent",
});
}
}
}
async analyzeNavigationFlows() {
console.log("🧭 Analyzing navigation flows...");
const sourceFiles = this.project.getSourceFiles();
for (const sourceFile of sourceFiles) {
// Extract programmatic navigation
this.extractProgrammaticNavigation(sourceFile);
// Extract Link components
this.extractLinkNavigation(sourceFile);
// Extract anchor tags
this.extractAnchorNavigation(sourceFile);
}
}
extractProgrammaticNavigation(sourceFile) {
const fromComponent = this.getMainComponentFromFile(sourceFile);
if (!fromComponent)
return;
const callExpressions = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
for (const call of callExpressions) {
const expression = call.getExpression();
const expressionText = expression.getText();
// React Router navigation
if (expressionText.includes("navigate") ||
expressionText.includes("push") ||
expressionText.includes("replace") ||
expressionText.includes("redirect")) {
const args = call.getArguments();
if (args.length > 0) {
const firstArg = args[0];
let targetPath = "";
if (Node.isStringLiteral(firstArg)) {
targetPath = firstArg.getLiteralValue();
}
else if (Node.isTemplateExpression(firstArg)) {
// Handle template literals
targetPath = this.extractPathFromTemplate(firstArg);
}
else if (Node.isObjectLiteralExpression(firstArg)) {
// Handle object with pathname
const pathnameProp = firstArg.getProperty("pathname");
if (pathnameProp && Node.isPropertyAssignment(pathnameProp)) {
const initializer = pathnameProp.getInitializer();
if (initializer && Node.isStringLiteral(initializer)) {
targetPath = initializer.getLiteralValue();
}
}
}
if (targetPath) {
this.flows.push({
from: fromComponent,
to: targetPath,
type: "dynamic",
});
}
}
}
// Next.js router
if (expressionText === "router.push" || expressionText === "router.replace") {
const args = call.getArguments();
if (args.length > 0 && Node.isStringLiteral(args[0])) {
const targetPath = args[0].getLiteralValue();
this.flows.push({
from: fromComponent,
to: targetPath,
type: "dynamic",
});
}
}
}
}
extractLinkNavigation(sourceFile) {
const fromComponent = this.getMainComponentFromFile(sourceFile);
if (!fromComponent)
return;
const jsxElements = sourceFile.getDescendantsOfKind(SyntaxKind.JsxElement);
const jsxSelfClosingElements = sourceFile.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement);
const allJsxElements = [...jsxElements, ...jsxSelfClosingElements];
for (const element of allJsxElements) {
const tagName = this.getJsxTagName(element);
if (tagName === "Link" || tagName === "NavLink") {
const attributes = this.getJsxAttributes(element);
// React Router uses 'to' prop
const toAttr = attributes.find((attr) => attr.name === "to");
// Next.js uses 'href' prop
const hrefAttr = attributes.find((attr) => attr.name === "href");
const targetAttr = toAttr || hrefAttr;
if (targetAttr) {
const targetPath = this.extractAttributeValue(targetAttr.value);
if (targetPath) {
this.flows.push({
from: fromComponent,
to: targetPath,
type: "static",
});
}
}
}
}
}
extractAnchorNavigation(sourceFile) {
const fromComponent = this.getMainComponentFromFile(sourceFile);
if (!fromComponent)
return;
const jsxElements = sourceFile.getDescendantsOfKind(SyntaxKind.JsxElement);
const jsxSelfClosingElements = sourceFile.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement);
const allJsxElements = [...jsxElements, ...jsxSelfClosingElements];
for (const element of allJsxElements) {
const tagName = this.getJsxTagName(element);
if (tagName === "a") {
const attributes = this.getJsxAttributes(element);
const hrefAttr = attributes.find((attr) => attr.name === "href");
if (hrefAttr) {
const href = this.extractAttributeValue(hrefAttr.value);
// Only track internal links
if (href && href.startsWith("/") && !href.startsWith("//")) {
this.flows.push({
from: fromComponent,
to: href,
type: "static",
});
}
}
}
}
}
extractPathFromTemplate(template) {
// Simple extraction - in real implementation would need more sophisticated parsing
const text = template.getText();
// Extract static parts and indicate dynamic parts
const cleaned = text.replace(/\$\{[^}]+\}/g, ":param");
return cleaned.replace(/[`'"]/g, "");
}
getMainComponentFromFile(sourceFile) {
const filePath = sourceFile.getFilePath();
const components = this.fileToComponentMap.get(filePath);
if (components && components.size > 0) {
// Prefer default export or route component
for (const component of components) {
if (this.routeComponents.has(component)) {
return component;
}
}
// Return first component if no route component found
return Array.from(components)[0];
}
// Fallback to filename
return this.getComponentNameFromFile(filePath);
}
async analyzeMenuStructures() {
console.log("📋 Analyzing menu structures...");
const sourceFiles = this.project.getSourceFiles();
for (const sourceFile of sourceFiles) {
// Look for navigation/menu related files
const filePath = sourceFile.getFilePath();
if (filePath.includes("nav") ||
filePath.includes("menu") ||
filePath.includes("sidebar") ||
filePath.includes("header")) {
this.extractMenuFromFile(sourceFile);
}
}
}
extractMenuFromFile(sourceFile) {
// Look for menu-like data structures
const arrayLiterals = sourceFile.getDescendantsOfKind(SyntaxKind.ArrayLiteralExpression);
for (const array of arrayLiterals) {
if (this.isMenuArray(array)) {
this.processMenuArray(array);
}
}
// Also look for object literals that might be menus
const objectLiterals = sourceFile.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression);
for (const obj of objectLiterals) {
if (this.isMenuObject(obj)) {
const menu = this.extractMenuFromObject(obj);
if (menu) {
this.menus.push(menu);
}
}
}
}
isMenuArray(array) {
const elements = array.getElements();
if (elements.length === 0)
return false;
// Check if array contains menu-like objects
return elements.some((element) => {
if (Node.isObjectLiteralExpression(element)) {
const props = element.getProperties();
const hasTitle = props.some((prop) => Node.isPropertyAssignment(prop) &&
(prop.getName() === "title" ||
prop.getName() === "label" ||
prop.getName() === "name"));
const hasPath = props.some((prop) => Node.isPropertyAssignment(prop) &&
(prop.getName() === "path" ||
prop.getName() === "href" ||
prop.getName() === "to" ||
prop.getName() === "url"));
return hasTitle && hasPath;
}
return false;
});
}
isMenuObject(obj) {
const props = obj.getProperties();
const hasTitle = props.some((prop) => Node.isPropertyAssignment(prop) &&
(prop.getName() === "title" ||
prop.getName() === "label" ||
prop.getName() === "name"));
const hasPath = props.some((prop) => Node.isPropertyAssignment(prop) &&
(prop.getName() === "path" ||
prop.getName() === "href" ||
prop.getName() === "to" ||
prop.getName() === "url"));
return hasTitle && hasPath;
}
processMenuArray(array) {
const elements = array.getElements();
for (const element of elements) {
if (Node.isObjectLiteralExpression(element)) {
const menu = this.extractMenuFromObject(element);
if (menu) {
this.menus.push(menu);
}
}
}
}
extractMenuFromObject(obj) {
let title = "";
let path = "";
let children = [];
let roles = [];
for (const prop of obj.getProperties()) {
if (Node.isPropertyAssignment(prop)) {
const name = prop.getName();
const initializer = prop.getInitializer();
if ((name === "title" || name === "label" || name === "name") &&
initializer) {
title = this.extractStringValue(initializer);
}
else if ((name === "path" || name === "href" || name === "to" || name === "url") &&
initializer) {
path = this.extractStringValue(initializer);
}
else if ((name === "children" || name === "items" || name === "submenu") &&
initializer &&
Node.isArrayLiteralExpression(initializer)) {
children = this.extractMenuChildren(initializer);
}
else if (name === "roles" &&
initializer &&
Node.isArrayLiteralExpression(initializer)) {
roles = this.extractStringArray(initializer);
}
}
}
if (title && path) {
return {
title,
path,
chil