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
603 lines (602 loc) • 25.9 kB
JavaScript
import { digraph, toDot, attribute } from "ts-graphviz";
import { execSync } from "child_process";
import * as fs from "fs";
import * as path from "path";
export class DotGenerator {
getFormatName() {
return "dot";
}
getFileExtension() {
return ".dot";
}
getSupportedOptions() {
return [
"outputDirectory",
"filename",
"generateImage",
"layout", // 'LR', 'TB', 'BT', 'RL'
"theme", // 'light', 'dark'
"rankdir",
"splines",
"nodesep",
"ranksep",
];
}
validateOptions(options) {
const errors = [];
if (!options.outputDirectory) {
errors.push("outputDirectory is required");
}
else if (!fs.existsSync(options.outputDirectory)) {
errors.push(`outputDirectory does not exist: ${options.outputDirectory}`);
}
if (options.layout && !["LR", "TB", "BT", "RL"].includes(options.layout)) {
errors.push("layout must be one of: LR, TB, BT, RL");
}
if (options.theme &&
options.theme !== "" &&
!["light", "dark"].includes(options.theme)) {
errors.push("theme must be one of: light, dark");
}
return errors;
}
async generate(analysisResult, options) {
console.log("🎨 Starting DOT graph generation...");
// Set default options
const defaultOptions = {
filename: "user-flows",
generateImage: true,
layout: "LR",
theme: "light",
rankdir: "LR",
splines: "polyline",
nodesep: 1.5,
ranksep: 2.2,
...options,
};
// Create the graph
const graph = this.createGraph(analysisResult, defaultOptions);
// Generate the DOT content
const dotContent = toDot(graph);
// Write DOT file
const dotPath = path.join(defaultOptions.outputDirectory, `${defaultOptions.filename}.dot`);
fs.writeFileSync(dotPath, dotContent);
const result = {
filePath: dotPath,
format: this.getFormatName(),
additionalFiles: [],
};
// Generate image if requested
if (defaultOptions.generateImage) {
try {
const imagePath = await this.generateImage(dotPath, defaultOptions);
result.additionalFiles = [imagePath];
}
catch (error) {
console.warn("⚠️ Could not generate image (Graphviz may not be installed):", error);
}
}
console.log(`✅ DOT file generated: ${dotPath}`);
return result;
}
createGraph(analysisResult, options) {
// Create the main graph with styling optimized for compact layout
const g = digraph("AngularFlows", {
rankdir: options.rankdir || "LR",
splines: "ortho", // Use orthogonal lines for perfectly organized right angles
nodesep: 0.8, // Reduced spacing between nodes
ranksep: 1.5, // Reduced spacing between ranks
overlap: false,
concentrate: true, // Merge edges for cleaner layout
compound: true,
pack: true, // Pack components tightly
packmode: "graph", // Pack the entire graph
});
// Apply theme-based styling
this.applyTheme(g, options.theme || "light");
// Convert analysis results to graph structure
const { routeNodes, flowEdges } = this.convertAnalysisToGraphData(analysisResult);
console.log(`📊 Creating graph with ${routeNodes.size} nodes and ${flowEdges.length} edges`);
// Create nodes with improved styling
for (const [routePath, node] of routeNodes.entries()) {
const color = this.getNodeColor(node.category, node.importance, options.theme);
const fontColor = this.getFontColor(options.theme);
// Special styling for root node
if (routePath === "ROOT") {
g.createNode(node.id, {
label: node.displayName,
fillcolor: "#FF6B35",
fontcolor: fontColor,
style: "filled,rounded",
penwidth: 3,
fontsize: options.theme === "dark" ? 16 : 15, // Larger font for root node
shape: "ellipse", // Different shape for root
});
}
else {
g.createNode(node.id, {
label: `${node.displayName}\\n(${node.originalPath.replace(/"/g, '\\"')})`,
fillcolor: color,
fontcolor: fontColor,
style: "filled,rounded",
penwidth: 2,
fontsize: options.theme === "dark" ? 13 : 12, // Larger font for dark theme
width: 2.0, // Fixed width for consistency
height: 0.8, // Fixed height for consistency
});
}
}
// Create edges with improved styling
const existingEdges = new Set();
for (const edge of flowEdges) {
const sourceNode = routeNodes.get(edge.source);
let targetNode = routeNodes.get(edge.target);
// If target is not found by key, try to find by path (for redirects)
if (!targetNode && edge.type === "redirect") {
for (const [key, node] of routeNodes.entries()) {
if (node.originalPath === edge.target) {
targetNode = node;
edge.target = key; // Update to use the key
break;
}
}
}
// Handle parameterized routes
if (!targetNode && edge.target) {
for (const [key, rn] of routeNodes.entries()) {
const patternText = rn.originalPath.replace(/:[^\\/]+/g, "[^/]+");
const regex = new RegExp(`^${patternText}$`);
if (regex.test(edge.target)) {
targetNode = rn;
edge.target = key; // Update to use the key
break;
}
}
}
if (sourceNode && targetNode) {
const edgeKey = `${sourceNode.id}->${targetNode.id}->${edge.type}${edge.label ? "->" + edge.label : ""}`;
if (!existingEdges.has(edgeKey)) {
const edgeAttrs = this.getEdgeAttributes(edge, options.theme);
g.createEdge([sourceNode.id, targetNode.id], edgeAttrs);
existingEdges.add(edgeKey);
}
}
}
return g;
}
convertAnalysisToGraphData(analysisResult) {
const routeNodes = new Map();
const flowEdges = [];
// Graph data structure to properly model the routing hierarchy
class RouteGraph {
constructor() {
this.nodes = new Map();
}
addRoute(route, parentId) {
if (!route.fullPath || route.fullPath.includes("**"))
return;
const isLayoutComponent = !!(route.component && route.children && route.children.length > 0);
const isTopLevel = !parentId && route.fullPath !== "/";
// Create unique node ID
let nodeId;
if (isLayoutComponent) {
nodeId = `layout_${route.component}_${route.fullPath}`;
}
else {
nodeId = route.fullPath;
}
// Add node to graph
this.nodes.set(nodeId, {
route,
nodeId,
children: new Set(),
parent: parentId,
isLayoutComponent,
isTopLevel
});
// Add to parent's children if parent exists
if (parentId && this.nodes.has(parentId)) {
this.nodes.get(parentId).children.add(nodeId);
}
// Process children recursively
if (route.children && Array.isArray(route.children)) {
for (const childRoute of route.children) {
// Skip redirect-only routes as children of layout components
if (childRoute.redirectTo && !childRoute.component && isLayoutComponent) {
continue;
}
this.addRoute(childRoute, nodeId);
}
}
return nodeId;
}
getNodes() {
return this.nodes;
}
// Get the proper hierarchy edges with root connections
getHierarchyEdges() {
const edges = [];
// Add edges from parent to children
for (const [nodeId, node] of this.nodes) {
if (node.parent) {
edges.push({
source: node.parent,
target: nodeId
});
}
}
// Connect top-level routes to root
const rootNodeId = "ROOT";
for (const [nodeId, node] of this.nodes) {
if (node.isTopLevel || (node.route.fullPath === "/" && node.isLayoutComponent)) {
edges.push({
source: rootNodeId,
target: nodeId
});
}
}
return edges;
}
// Check if we need a root node
needsRootNode() {
return true; // Always create a root for better organization
}
}
// Build the route graph
const routeGraph = new RouteGraph();
// Process all routes and build the graph structure
for (const route of analysisResult.routes) {
routeGraph.addRoute(route);
}
// Add root node if needed
if (routeGraph.needsRootNode()) {
routeNodes.set("ROOT", {
id: "root",
originalPath: "ROOT",
displayName: "Root",
pathDepth: 0,
category: "root",
importance: 0,
});
}
// Convert graph nodes to RouteNode format
for (const [nodeId, graphNode] of routeGraph.getNodes()) {
const route = graphNode.route;
const cleanPath = this.cleanRoutePath(route.fullPath);
const pathDepth = route.fullPath.split("/").filter(Boolean).length;
const category = this.getNodeCategory(route.fullPath);
let displayName = "";
let visualNodeId = cleanPath;
if (graphNode.isLayoutComponent) {
// Layout component
displayName = route.component.replace(/Component$/, "");
displayName = displayName
.replace(/([A-Z])/g, " $1")
.replace(/^./, (str) => str.toUpperCase())
.trim();
visualNodeId = `layout_${route.component}`;
}
else if (route.fullPath === "/" && !route.component) {
displayName = "Root";
}
else if (route.component) {
displayName = route.component.replace(/Component$/, "");
displayName = displayName
.replace(/([A-Z])/g, " $1")
.replace(/^./, (str) => str.toUpperCase())
.trim();
}
else {
const lastSegment = route.fullPath.split("/").filter(Boolean).pop() || "";
displayName = this.deriveDisplayName(lastSegment);
}
routeNodes.set(nodeId, {
id: visualNodeId,
originalPath: route.fullPath,
displayName,
pathDepth,
category,
component: route.component,
importance: 0,
guards: route.guards,
});
// Add redirects to edges
if (route.redirectTo) {
let targetPath = route.redirectTo;
if (!targetPath.startsWith("/")) {
const parentDir = route.fullPath.substring(0, route.fullPath.lastIndexOf("/") + 1) ||
"/";
targetPath = path.posix
.resolve(parentDir, targetPath)
.replace(/\\/g, "/");
}
targetPath = targetPath.replace(/\/\//g, "/");
if (targetPath !== "/" && targetPath.endsWith("/")) {
targetPath = targetPath.slice(0, -1);
}
// Find target node by path
let targetNodeId;
for (const [id, node] of routeNodes.entries()) {
if (node.originalPath === targetPath) {
targetNodeId = id;
break;
}
}
if (targetNodeId) {
flowEdges.push({
source: nodeId,
target: targetNodeId,
type: "redirect",
});
}
}
}
// Add hierarchy edges from the graph structure
const hierarchyEdges = routeGraph.getHierarchyEdges();
for (const edge of hierarchyEdges) {
flowEdges.push({
source: edge.source,
target: edge.target,
type: "hierarchy",
});
}
// Add navigation flows - Enhanced for React support
const componentToNodeId = new Map();
const pathToNodeId = new Map();
// Build comprehensive mapping for both components and paths
for (const [nodeId, node] of routeNodes.entries()) {
// Map by path
pathToNodeId.set(node.originalPath, nodeId);
// Map by component name (for Angular-style)
if (node.component) {
componentToNodeId.set(node.component, nodeId);
const baseName = node.component.replace(/Component$/, "");
if (baseName !== node.component) {
componentToNodeId.set(baseName, nodeId);
}
}
// Map by display name (for React-style)
if (node.displayName && node.displayName !== "Root") {
componentToNodeId.set(node.displayName, nodeId);
// Also try without spaces
const noSpaces = node.displayName.replace(/\s+/g, "");
if (noSpaces !== node.displayName) {
componentToNodeId.set(noSpaces, nodeId);
}
}
}
// Build reverse mapping: component name -> route path (for React)
// This maps React component names to the routes where they are used
for (const route of analysisResult.routes) {
if (route.component && route.fullPath) {
const nodeId = pathToNodeId.get(route.fullPath);
if (nodeId) {
componentToNodeId.set(route.component, nodeId);
// Also map without Component suffix
const baseName = route.component.replace(/Component$/, "");
if (baseName !== route.component) {
componentToNodeId.set(baseName, nodeId);
}
}
}
}
console.log(`📊 Component mappings: ${componentToNodeId.size}, Path mappings: ${pathToNodeId.size}`);
for (const flow of analysisResult.flows) {
if (!flow.from || !flow.to)
continue;
// Find source node - try multiple strategies
let sourceNodeId = componentToNodeId.get(flow.from);
if (!sourceNodeId) {
// Try with Component suffix
const withComponent = flow.from + "Component";
sourceNodeId = componentToNodeId.get(withComponent);
}
if (!sourceNodeId) {
// Try as path
sourceNodeId = pathToNodeId.get(flow.from);
}
if (!sourceNodeId) {
// Try to find by partial match
for (const [component, nodeId] of componentToNodeId.entries()) {
if (component.toLowerCase().includes(flow.from.toLowerCase()) ||
flow.from.toLowerCase().includes(component.toLowerCase())) {
sourceNodeId = nodeId;
break;
}
}
}
// Find target node - normalize target path
let targetPath = flow.to;
// Handle template literals and dynamic paths
if (targetPath.includes("`") || targetPath.includes("${")) {
// Extract the base path from template literals
targetPath = targetPath.replace(/`([^`]*)`/, "$1");
targetPath = targetPath.replace(/\$\{[^}]+\}/g, ":param");
}
if (!targetPath.startsWith("/")) {
if (sourceNodeId) {
const sourceNode = routeNodes.get(sourceNodeId);
if (sourceNode) {
const parentDir = sourceNode.originalPath.substring(0, sourceNode.originalPath.lastIndexOf("/") + 1) || "/";
targetPath = path.posix
.resolve(parentDir, targetPath)
.replace(/\\/g, "/");
}
}
else {
targetPath = "/" + targetPath;
}
}
targetPath = targetPath.replace(/\/\//g, "/");
if (targetPath !== "/" && targetPath.endsWith("/")) {
targetPath = targetPath.slice(0, -1);
}
// Find target node by path
let targetNodeId = pathToNodeId.get(targetPath);
// If not found, try pattern matching for parameterized routes
if (!targetNodeId) {
for (const [nodeId, node] of routeNodes.entries()) {
if (node.originalPath === targetPath) {
targetNodeId = nodeId;
break;
}
// Try pattern matching for routes with parameters
const patternText = node.originalPath.replace(/:[^\\/]+/g, "[^/]+");
const regex = new RegExp(`^${patternText}$`);
if (regex.test(targetPath)) {
targetNodeId = nodeId;
break;
}
}
}
let edgeLabel = undefined;
if (flow.type === "dynamic" && targetNodeId) {
const potentialTargetNode = routeNodes.get(targetNodeId);
if (potentialTargetNode?.guards &&
potentialTargetNode.guards.length > 0) {
edgeLabel = potentialTargetNode.guards.join(", ");
}
}
if (sourceNodeId && targetNodeId) {
flowEdges.push({
source: sourceNodeId,
target: targetNodeId,
type: flow.type,
label: edgeLabel,
});
}
else {
console.log(`⚠️ Could not map flow: ${flow.from} -> ${flow.to} (source: ${!!sourceNodeId}, target: ${!!targetNodeId})`);
}
}
return { routeNodes, flowEdges };
}
applyTheme(graph, theme) {
// Configure node styling
graph.attributes.node.set(attribute.shape, "box");
graph.attributes.node.set(attribute.style, "filled,rounded");
graph.attributes.node.set(attribute.fontname, "Arial");
graph.attributes.node.set(attribute.fontsize, theme === "dark" ? 13 : 12); // Larger font for dark theme
graph.attributes.node.set(attribute.margin, "0.15,0.1");
graph.attributes.node.set(attribute.height, 0.6);
// Configure edge styling
graph.attributes.edge.set(attribute.fontname, "Arial");
graph.attributes.edge.set(attribute.fontsize, theme === "dark" ? 11 : 10); // Larger edge labels for dark theme
// Apply theme-specific styling
switch (theme) {
case "dark":
graph.attributes.graph.set(attribute.bgcolor, "#2d3748");
// Set default edge color for dark theme
graph.attributes.edge.set(attribute.color, "#e2e8f0");
graph.attributes.edge.set(attribute.fontcolor, "#e2e8f0");
break;
case "light":
default:
// Light theme styling (previously colorful)
graph.attributes.edge.set(attribute.color, "#333333");
graph.attributes.edge.set(attribute.fontcolor, "#333333");
break;
}
}
getNodeColor(category, importance, theme) {
// Special color for root - this is universal
if (category === "root")
return "#FF6B35"; // Vibrant orange for root
// Generate vibrant colors based on category hash - completely generic
switch (theme) {
case "dark":
return this.generateVibrantColor(category, true);
case "light":
return this.generateVibrantColor(category, false);
default:
return this.generateVibrantColor(category, false);
}
}
generateVibrantColor(str, dark = false) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
// Generate more vibrant colors using HSL for better control
const hue = Math.abs(hash) % 360;
const saturation = dark ? 80 : 85; // High saturation for vibrancy
const lightness = dark ? 45 : 55; // Darker background for better white text contrast
return this.hslToHex(hue, saturation, lightness);
}
hslToHex(h, s, l) {
l /= 100;
const a = s * Math.min(l, 1 - l) / 100;
const f = (n) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`;
}
getFontColor(theme) {
switch (theme) {
case "dark":
return "#ffffff"; // Pure white for maximum contrast on dark backgrounds
default:
return "#000000"; // Pure black for maximum contrast on light backgrounds
}
}
getEdgeAttributes(edge, theme) {
let edgeAttrs = {
label: edge.label || "",
penwidth: 2, // Make edges thicker for better visibility
};
if (edge.type === "redirect") {
edgeAttrs.style = "dashed";
edgeAttrs.color = theme === "dark" ? "#60A5FA" : "#2563EB"; // Bright blue
edgeAttrs.fontcolor = theme === "dark" ? "#E5E7EB" : "#374151";
edgeAttrs.penwidth = 2.5;
}
else if (edge.type === "dynamic") {
edgeAttrs.color = theme === "dark" ? "#34D399" : "#059669"; // Bright green
edgeAttrs.fontcolor = theme === "dark" ? "#E5E7EB" : "#374151";
edgeAttrs.penwidth = 2.5;
}
else if (edge.type === "static") {
edgeAttrs.color = theme === "dark" ? "#A78BFA" : "#7C3AED"; // Bright purple
edgeAttrs.fontcolor = theme === "dark" ? "#E5E7EB" : "#374151";
edgeAttrs.penwidth = 2.5;
}
else if (edge.type === "hierarchy") {
edgeAttrs.color = theme === "dark" ? "#9CA3AF" : "#6B7280"; // Subtle gray
edgeAttrs.fontcolor = theme === "dark" ? "#E5E7EB" : "#374151";
edgeAttrs.arrowhead = "vee";
edgeAttrs.style = "dotted";
edgeAttrs.penwidth = 1.5; // Thinner for hierarchy
}
return edgeAttrs;
}
cleanRoutePath(path) {
return path.replace(/:[^\/]+/g, (match) => match.substring(1));
}
getNodeCategory(path) {
const segments = path.split("/").filter(Boolean);
if (segments.length === 0)
return "root";
return segments[0].toLowerCase();
}
deriveDisplayName(routePathSegment) {
if (!routePathSegment ||
routePathSegment === "/" ||
routePathSegment === "")
return "Segment";
return routePathSegment
.replace(/^[:*]/, "") // Remove starting : or *
.split(/[-_]/) // Split by dash or underscore
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
async generateImage(dotPath, options) {
const outputDir = path.dirname(dotPath);
const baseName = path.basename(dotPath, ".dot");
const imagePath = path.join(outputDir, `${baseName}.png`);
execSync(`dot -Tpng "${dotPath}" -o "${imagePath}"`);
console.log(`🖼️ PNG image generated: ${imagePath}`);
return imagePath;
}
}