#!/usr/bin/env bun import fs from "fs"; import path from "path"; import * as parser from "@babel/parser"; import traverse from "@babel/traverse"; import * as t from "@babel/types"; import { readdirSync, statSync, writeFileSync } from "fs"; import _ from "lodash"; import { basename, extname, join, relative } from "path"; const PAGES_DIR = join(process.cwd(), "src/pages"); const OUTPUT_FILE = join(process.cwd(), "src/AppRoutes.tsx"); /** * ✅ Ubah nama file menjadi PascalCase * - Support: snake_case, kebab-case, camelCase, PascalCase */ const toComponentName = (fileName: string): string => { return fileName .replace(/\.[^/.]+$/, "") // hilangkan ekstensi file .replace(/[_-]+/g, " ") // snake_case & kebab-case → spasi .replace(/([a-z])([A-Z])/g, "$1 $2") // camelCase → spasi .replace(/\b\w/g, (c) => c.toUpperCase()) // kapital tiap kata .replace(/\s+/g, ""); // gabung semua → PascalCase }; /** * ✅ Normalisasi nama menjadi path route (kebab-case) */ function toRoutePath(name: string): string { name = name.replace(/\.[^/.]+$/, ""); // hapus ekstensi if (name.toLowerCase() === "home") return "/"; if (name.toLowerCase() === "login") return "/login"; if (name.toLowerCase() === "notfound") return "/*"; // Hapus prefix/suffix umum name = name.replace(/_page$/i, "").replace(/^form_/i, ""); // ✅ Normalisasi ke kebab-case return _.kebabCase(name); } // 🧭 Scan folder pages secara rekursif function scan(dir: string): any[] { const items = readdirSync(dir); const routes: any[] = []; for (const item of items) { const full = join(dir, item); const stat = statSync(full); if (stat.isDirectory()) { routes.push({ name: item, path: _.kebabCase(item), children: scan(full), }); } else if (extname(item) === ".tsx") { routes.push({ name: basename(item, ".tsx"), filePath: relative(join(process.cwd(), "src"), full).replace(/\\/g, "/"), }); } } return routes; } // 🏗️ Generate JSX dari struktur folder function generateJSX(routes: any[], parentPath = ""): string { let jsx = ""; for (const route of routes) { if (route.children) { const layout = route.children.find((r: any) => r.name.endsWith("_layout")); if (layout) { const LayoutComponent = toComponentName(layout.name.replace("_layout", "Layout")); const nested = route.children.filter((r: any) => r !== layout); const nestedRoutes = generateJSX(nested, `${parentPath}/${route.path}`); const homeFile = route.children.find((r: any) => r.name.toLowerCase().endsWith("_home") ); const indexRoute = homeFile ? `} />\n` : ""; jsx += ` }> ${indexRoute} ${nestedRoutes} `; } else { jsx += generateJSX(route.children, `${parentPath}/${route.path}`); } } else { const Component = toComponentName(route.name); const routePath = toRoutePath(route.name); const fullPath = routePath.startsWith("/") ? routePath : `${parentPath}/${routePath}`.replace(/\/+/g, "/"); jsx += `} />\n`; } } return jsx; } // 🧾 Generate import otomatis function generateImports(routes: any[]): string { const imports = new Set(); function collect(rs: any[]) { for (const r of rs) { if (r.children) collect(r.children); else { const Comp = toComponentName(r.name); imports.add(`import ${Comp} from "./${r.filePath.replace(/\.tsx$/, "")}";`); } } } collect(routes); return Array.from(imports).join("\n"); } function generateRoutes() { const allRoutes = scan(PAGES_DIR); const imports = generateImports(allRoutes); const jsxRoutes = generateJSX(allRoutes); const finalCode = ` // ⚡ Auto-generated by generateRoutes.ts — DO NOT EDIT MANUALLY import { BrowserRouter, Routes, Route } from "react-router-dom"; ${imports} export default function AppRoutes() { return ( ${jsxRoutes} ); } `; writeFileSync(OUTPUT_FILE, finalCode); console.log(`✅ Routes generated → ${OUTPUT_FILE}`); Bun.spawnSync(["bunx", "prettier", "--write", "src/**/*.tsx"]); } // --- Extract untuk clientRoutes.ts --- const SRC_DIR = path.resolve(process.cwd(), "src"); const APP_ROUTES_FILE = path.join(SRC_DIR, "AppRoutes.tsx"); interface RouteNode { path: string; children: RouteNode[]; } function getAttributePath(attrs: (t.JSXAttribute | t.JSXSpreadAttribute)[]) { const pathAttr = attrs.find( (attr) => t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: "path" }) ) as t.JSXAttribute | undefined; if (pathAttr && t.isStringLiteral(pathAttr.value)) return pathAttr.value.value; return ""; } function extractRouteNodes(node: t.JSXElement): RouteNode | null { const opening = node.openingElement; if (!t.isJSXIdentifier(opening.name) || opening.name.name !== "Route") return null; const currentPath = getAttributePath(opening.attributes); const children: RouteNode[] = []; for (const child of node.children) { if (t.isJSXElement(child)) { const childNode = extractRouteNodes(child); if (childNode) children.push(childNode); } } return { path: currentPath, children }; } function flattenRoutes(node: RouteNode, parentPath = ""): Record { const record: Record = {}; let fullPath = node.path; if (fullPath) { if (!fullPath.startsWith("/")) { if (parentPath) { if (fullPath === "/") fullPath = parentPath; else fullPath = `${parentPath.replace(/\/$/, "")}/${fullPath}`; } if (!fullPath.startsWith("/")) fullPath = `/${fullPath}`; } fullPath = fullPath.replace(/\/+/g, "/"); record[fullPath] = fullPath; } for (const child of node.children) { Object.assign(record, flattenRoutes(child, fullPath || parentPath)); } return record; } function extractRoutes(code: string): Record { const ast = parser.parse(code, { sourceType: "module", plugins: ["typescript", "jsx"], }); const routes: Record = {}; traverse(ast, { JSXElement(path) { const opening = path.node.openingElement; if (t.isJSXIdentifier(opening.name) && opening.name.name === "Routes") { for (const child of path.node.children) { if (t.isJSXElement(child)) { const node = extractRouteNodes(child); if (node) Object.assign(routes, flattenRoutes(node)); } } } }, }); return routes; } export default function route() { generateRoutes(); if (!fs.existsSync(APP_ROUTES_FILE)) { console.error("❌ AppRoutes.tsx not found in src/"); process.exit(1); } const code = fs.readFileSync(APP_ROUTES_FILE, "utf-8"); const routes = extractRoutes(code); console.log("✅ Generated Routes:"); console.log(routes); const outPath = path.join(SRC_DIR, "clientRoutes.ts"); fs.writeFileSync( outPath, `// AUTO-GENERATED FILE\nconst clientRoutes = ${JSON.stringify(routes, null, 2)} as const;\n\nexport default clientRoutes;` ); console.log(`📄 clientRoutes.ts saved → ${outPath}`); } route()