169 lines
4.9 KiB
TypeScript
169 lines
4.9 KiB
TypeScript
|
|
import * as fs from "node:fs";
|
||
|
|
import * as path from "node:path";
|
||
|
|
|
||
|
|
interface FlowStep {
|
||
|
|
id: string;
|
||
|
|
label: string;
|
||
|
|
major: number;
|
||
|
|
level: number;
|
||
|
|
file: string; // New: Track file path
|
||
|
|
line: number; // New: Track line number
|
||
|
|
}
|
||
|
|
|
||
|
|
interface FlowMap {
|
||
|
|
[flowId: string]: FlowStep[];
|
||
|
|
}
|
||
|
|
|
||
|
|
/** CONFIGURATION **/
|
||
|
|
const SRC_DIR = "./src";
|
||
|
|
const OUTPUT_DIR = "./docs/flows";
|
||
|
|
const FILE_EXTENSIONS = [".ts", ".js", ".tsx", ".jsx"];
|
||
|
|
|
||
|
|
// Updated Regex: Handles //FLOWS and // FLOWS
|
||
|
|
const FLOW_REGEX = /\/\/\s*FLOWS:\s*([^|]+)\s*\|\s*([\w.]+)\s*\|\s*(.*)/g;
|
||
|
|
|
||
|
|
/** UTILS **/
|
||
|
|
function getFiles(dir: string, allFiles: string[] = []): string[] {
|
||
|
|
if (!fs.existsSync(dir)) return allFiles;
|
||
|
|
const files = fs.readdirSync(dir);
|
||
|
|
for (const file of files) {
|
||
|
|
const name = path.join(dir, file);
|
||
|
|
if (fs.statSync(name).isDirectory()) getFiles(name, allFiles);
|
||
|
|
else if (FILE_EXTENSIONS.includes(path.extname(name))) allFiles.push(name);
|
||
|
|
}
|
||
|
|
return allFiles;
|
||
|
|
}
|
||
|
|
|
||
|
|
function safeId(id: string): string {
|
||
|
|
// Mermaid IDs cannot contain dots
|
||
|
|
return `step_${id.replace(/\./g, "_")}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** CORE LOGIC **/
|
||
|
|
function main() {
|
||
|
|
const flowData: FlowMap = {};
|
||
|
|
const files = getFiles(SRC_DIR);
|
||
|
|
|
||
|
|
console.log(`🔍 Scanning ${files.length} files for // FLOWS comments...`);
|
||
|
|
|
||
|
|
// 1. Extraction
|
||
|
|
files.forEach((file) => {
|
||
|
|
const content = fs.readFileSync(file, "utf-8");
|
||
|
|
const lines = content.split("\n");
|
||
|
|
lines.forEach((lineText, index) => {
|
||
|
|
let match: RegExpExecArray | null;
|
||
|
|
FLOW_REGEX.lastIndex = 0;
|
||
|
|
match = FLOW_REGEX.exec(lineText);
|
||
|
|
while (match !== null) {
|
||
|
|
const [_, flowId, id, label] = match.map((s) => s.trim());
|
||
|
|
if (!flowId || !id || !label) {
|
||
|
|
console.warn(`⚠️ Invalid FLOWS comment in ${file}: ${match[0]}`);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (!flowData[flowId]) flowData[flowId] = [];
|
||
|
|
|
||
|
|
flowData[flowId].push({
|
||
|
|
id,
|
||
|
|
label,
|
||
|
|
major: parseInt(id.split(".")?.[0] || "0"),
|
||
|
|
level: id.split(".").length,
|
||
|
|
file: path.relative(process.cwd(), file), // Store relative path
|
||
|
|
line: index + 1, // Store line number (1-based)
|
||
|
|
});
|
||
|
|
match = FLOW_REGEX.exec(lineText);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
if (Object.keys(flowData).length === 0) {
|
||
|
|
console.log(
|
||
|
|
"⚠️ No flow comments found. Try adding: // FLOWS: myflow | 1 | Start",
|
||
|
|
);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. Generate Output
|
||
|
|
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||
|
|
|
||
|
|
for (const [flowId, steps] of Object.entries(flowData)) {
|
||
|
|
// Sort logically (1, 1.1, 1.2, 2)
|
||
|
|
const sorted = steps.sort((a, b) =>
|
||
|
|
a.id.localeCompare(b.id, undefined, { numeric: true }),
|
||
|
|
);
|
||
|
|
const mermaid = ["graph TD"];
|
||
|
|
const connections = new Set<string>();
|
||
|
|
|
||
|
|
sorted.forEach((current) => {
|
||
|
|
const nextSteps = sorted.filter((candidate) => {
|
||
|
|
const currentIdPrefix = `${current.id}.`;
|
||
|
|
|
||
|
|
// 1. DESCEND: Link to immediate children (3 -> 3.1, 3 -> 3.2)
|
||
|
|
// Check if candidate starts with current ID and is exactly one level deeper
|
||
|
|
if (
|
||
|
|
candidate.id.startsWith(currentIdPrefix) &&
|
||
|
|
candidate.level === current.level + 1
|
||
|
|
) {
|
||
|
|
// Ensure it's a direct child (e.g., 3 -> 3.1, not 3 -> 3.1.1)
|
||
|
|
const remaining = candidate.id.replace(currentIdPrefix, "");
|
||
|
|
return !remaining.includes(".");
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. MERGE: Link leaf nodes to the next major integer (3.1.2 -> 4, 3.2.1 -> 4)
|
||
|
|
const isLeaf = !sorted.some(
|
||
|
|
(s) => s.id.startsWith(currentIdPrefix) && s.id !== current.id,
|
||
|
|
);
|
||
|
|
if (
|
||
|
|
isLeaf &&
|
||
|
|
candidate.major === current.major + 1 &&
|
||
|
|
candidate.level === 1
|
||
|
|
) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 3. SEQUENCE (Optional): If you want 3.1.1 -> 3.1.2
|
||
|
|
// If they share the exact same parent and are consecutive
|
||
|
|
const currentParent = current.id.includes(".")
|
||
|
|
? current.id.substring(0, current.id.lastIndexOf("."))
|
||
|
|
: null;
|
||
|
|
const candParent = candidate.id.includes(".")
|
||
|
|
? candidate.id.substring(0, candidate.id.lastIndexOf("."))
|
||
|
|
: null;
|
||
|
|
|
||
|
|
if (
|
||
|
|
currentParent !== null &&
|
||
|
|
currentParent === candParent &&
|
||
|
|
candidate.level === current.level
|
||
|
|
) {
|
||
|
|
// Only link if you want branches to be sequential.
|
||
|
|
// In your case, you likely want 3.1.1 and 3.1.2 to both go to 4 independently.
|
||
|
|
// So we leave this FALSE to keep them as parallel paths.
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
return false;
|
||
|
|
});
|
||
|
|
|
||
|
|
nextSteps.forEach((next) => {
|
||
|
|
const link = ` ${safeId(current.id)}["${current.label}"] --> ${safeId(next.id)}["${next.label}"]`;
|
||
|
|
connections.add(link);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// Handle single-node flows (start node with no connections)
|
||
|
|
if (connections.size === 0 && sorted.length > 0 && sorted[0]) {
|
||
|
|
mermaid.push(` ${safeId(sorted[0].id)}["${sorted[0].label}"]`);
|
||
|
|
} else {
|
||
|
|
connections.forEach((c) => mermaid.push(c));
|
||
|
|
}
|
||
|
|
|
||
|
|
const outPath = path.join(OUTPUT_DIR, `${flowId}.md`);
|
||
|
|
|
||
|
|
const header = "```mermaid\n\n";
|
||
|
|
const footer = "\n```";
|
||
|
|
fs.writeFileSync(outPath, header + mermaid.join("\n") + footer);
|
||
|
|
console.log(`✅ Generated: ${outPath}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
main();
|