import { readFileSync, writeFileSync, existsSync, unlinkSync, readdirSync, } from "node:fs"; import { join, dirname, resolve, sep, basename as pathBasename, } from "node:path"; import ts from "typescript"; import { generateRouteTypesSource } from "./codegen.js"; import type { ScanFilter } from "./scan-filter.js"; import { resolveImportedVariable, resolveImportPath, buildCombinedRouteMapWithSearch, type UnresolvableInclude, } from "./include-resolution.js"; import { findUrlsVariableNames } from "./per-module-writer.js"; import { isAutoGeneratedRouteName } from "../../route-name.js"; function countPublicRouteEntries(source: string): number { const matches = source.matchAll(/^\s+(?:"([^"]+)"|([a-zA-Z_$][^:]*)):\s*["{]/gm) ?? []; let count = 0; for (const match of matches) { const routeName = match[1] || match[2]; if (routeName && !isAutoGeneratedRouteName(routeName.trim())) { count++; } } return count; } const ROUTER_CALL_PATTERN = /\bcreateRouter\s*[<(]/; function isRoutableSourceFile(name: string): boolean { return ( (name.endsWith(".ts") || name.endsWith(".tsx") || name.endsWith(".js") || name.endsWith(".jsx")) && !name.includes(".gen.") && !name.includes(".test.") && !name.includes(".spec.") ); } function findRouterFilesRecursive( dir: string, filter: ScanFilter | undefined, results: string[], ): void { let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch (err) { console.warn( `[rango] Failed to scan directory ${dir}: ${(err as Error).message}`, ); return; } const childDirs: string[] = []; const routerFilesInDir: string[] = []; for (const entry of entries) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { if ( entry.name === "node_modules" || entry.name === "dist" || entry.name === "coverage" || entry.name === "__tests__" || entry.name === "__mocks__" || entry.name.startsWith(".") ) continue; childDirs.push(fullPath); continue; } if (!isRoutableSourceFile(entry.name)) continue; if (filter && !filter(fullPath)) continue; try { const source = readFileSync(fullPath, "utf-8"); if (ROUTER_CALL_PATTERN.test(source)) { routerFilesInDir.push(fullPath); } } catch { continue; } } // A directory that contains a router file is treated as a router root. // Once found, deeper directories are skipped to avoid redundant scans. if (routerFilesInDir.length > 0) { results.push(...routerFilesInDir); return; } for (const childDir of childDirs) { findRouterFilesRecursive(childDir, filter, results); } } export function findNestedRouterConflict( routerFiles: string[], ): { ancestor: string; nested: string } | null { const routerDirs = [ ...new Set(routerFiles.map((filePath) => dirname(resolve(filePath)))), ].sort((a, b) => a.length - b.length); for (let i = 0; i < routerDirs.length; i++) { const ancestorDir = routerDirs[i]; const prefix = ancestorDir.endsWith(sep) ? ancestorDir : `${ancestorDir}${sep}`; for (let j = i + 1; j < routerDirs.length; j++) { const nestedDir = routerDirs[j]; if (!nestedDir.startsWith(prefix)) continue; const ancestorFile = routerFiles.find( (filePath) => dirname(resolve(filePath)) === ancestorDir, ); const nestedFile = routerFiles.find( (filePath) => dirname(resolve(filePath)) === nestedDir, ); if (ancestorFile && nestedFile) { return { ancestor: ancestorFile, nested: nestedFile }; } } } return null; } export function formatNestedRouterConflictError( conflict: { ancestor: string; nested: string }, prefix = "[rango]", ): string { return ( `${prefix} Nested router roots are not supported.\n` + `Router root: ${conflict.ancestor}\n` + `Nested router: ${conflict.nested}\n` + `Move the nested router into a sibling directory or configure it as a separate app root.` ); } // --------------------------------------------------------------------------- // Router file URL extraction // --------------------------------------------------------------------------- /** * Result of extracting URL patterns from a router file. * - "variable": a named variable reference (e.g., `.routes(patterns)` or `urls: patterns`) * - "inline": an inline builder function (e.g., `.routes(({ path }) => [...])` or `urls: ({ path }) => [...]`) */ export type UrlsExtractionResult = | { kind: "variable"; name: string } | { kind: "inline"; block: string }; /** * Extract the url patterns from a router file using AST. * Detects four patterns: * 1. createRouter(...).routes(variableName) * 2. createRouter({ urls: variableName, ... }) * 3. createRouter(...).routes(({ path, ... }) => [...]) * 4. createRouter({ urls: ({ path, ... }) => [...], ... }) * Returns either a variable name or an inline code block. */ export function extractUrlsFromRouter( code: string, ): UrlsExtractionResult | null { const sourceFile = ts.createSourceFile( "router.tsx", code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX, ); let result: UrlsExtractionResult | null = null; function isCreateRouterCall(node: ts.Node): boolean { if (!ts.isCallExpression(node)) return false; const callee = node.expression; return ts.isIdentifier(callee) && callee.text === "createRouter"; } /** Check if a node is an arrow/function expression (inline builder). */ function isInlineBuilder(node: ts.Node): boolean { return ts.isArrowFunction(node) || ts.isFunctionExpression(node); } /** Check if a .routes() call chains from createRouter(). */ function isRoutesOnCreateRouter(node: ts.CallExpression): boolean { if ( !ts.isPropertyAccessExpression(node.expression) || node.expression.name.text !== "routes" ) return false; let inner: ts.Expression = node.expression.expression; while ( ts.isCallExpression(inner) && ts.isPropertyAccessExpression(inner.expression) ) { inner = inner.expression.expression; } return isCreateRouterCall(inner); } function visit(node: ts.Node) { if (result) return; // Pattern 1 & 3: createRouter(...).routes(variableName | builder) if ( ts.isCallExpression(node) && node.arguments.length >= 1 && isRoutesOnCreateRouter(node) ) { const arg = node.arguments[0]; if (ts.isIdentifier(arg)) { result = { kind: "variable", name: arg.text }; } else if (isInlineBuilder(arg)) { result = { kind: "inline", block: arg.getText(sourceFile) }; } return; } // Pattern 2 & 4: createRouter({ urls: variableName | builder, ... }) if (isCreateRouterCall(node)) { const callExpr = node as ts.CallExpression; for (const callArg of callExpr.arguments) { if (ts.isObjectLiteralExpression(callArg)) { for (const prop of callArg.properties) { if ( ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name) && prop.name.text === "urls" ) { if (ts.isIdentifier(prop.initializer)) { result = { kind: "variable", name: prop.initializer.text }; } else if (isInlineBuilder(prop.initializer)) { result = { kind: "inline", block: prop.initializer.getText(sourceFile), }; } return; } } } } } ts.forEachChild(node, visit); } visit(sourceFile); return result; } /** * Extract the `basename` string literal from createRouter({ basename: "..." }). * Returns the basename value or undefined if not present. */ export function extractBasenameFromRouter(code: string): string | undefined { const sourceFile = ts.createSourceFile( "router.tsx", code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX, ); let result: string | undefined; function visit(node: ts.Node) { if (result !== undefined) return; if ( ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "createRouter" ) { for (const arg of node.arguments) { if (ts.isObjectLiteralExpression(arg)) { for (const prop of arg.properties) { if ( ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name) && prop.name.text === "basename" && ts.isStringLiteral(prop.initializer) ) { result = prop.initializer.text; return; } } } } } ts.forEachChild(node, visit); } visit(sourceFile); return result; } /** @deprecated Use extractUrlsFromRouter instead */ export function extractUrlsVariableFromRouter(code: string): string | null { const result = extractUrlsFromRouter(code); return result?.kind === "variable" ? result.name : null; } /** Apply a basename prefix to all route patterns in a result set. */ function applyBasenameToRoutes( result: { routes: Record; searchSchemas: Record>; }, basename: string, ): { routes: Record; searchSchemas: Record>; } { const prefixed: Record = {}; for (const [name, pattern] of Object.entries(result.routes)) { if (pattern === "/") { prefixed[name] = basename; } else if (basename.endsWith("/") && pattern.startsWith("/")) { prefixed[name] = basename + pattern.slice(1); } else { prefixed[name] = basename + pattern; } } return { routes: prefixed, searchSchemas: result.searchSchemas }; } // Filesystem path of the generated route-types file for a router source file. // Native separators — matches the self-gen-tracking Map key the watcher compares. export function genFileTsPath(sourceFile: string): string { const base = pathBasename(sourceFile).replace(/\.(tsx?|jsx?)$/, ""); return join(dirname(sourceFile), `${base}.named-routes.gen.ts`); } // Search schemas for the gen file: prefer the runtime manifest's; when it omits // them (some module-runner flows) fall back to static parsing filtered to the // public route-name set. Returns the runtime value unchanged otherwise. export function resolveSearchSchemas( publicRouteNames: string[], runtimeSchemas: Record> | undefined, sourceFile: string, ): Record> | undefined { if (runtimeSchemas && Object.keys(runtimeSchemas).length > 0) { return runtimeSchemas; } const staticParsed = buildCombinedRouteMapForRouterFile(sourceFile); if (Object.keys(staticParsed.searchSchemas).length === 0) { return runtimeSchemas; } const filtered: Record> = {}; for (const name of publicRouteNames) { const schema = staticParsed.searchSchemas[name]; if (schema) filtered[name] = schema; } return Object.keys(filtered).length > 0 ? filtered : runtimeSchemas; } /** * Resolve routes and search schemas from a router source file by following the * variable passed to `.routes(...)` or `urls: ...` in createRouter options, * or by parsing an inline builder function directly. */ export function buildCombinedRouteMapForRouterFile(routerFilePath: string): { routes: Record; searchSchemas: Record>; } { let routerSource: string; try { routerSource = readFileSync(routerFilePath, "utf-8"); } catch { return { routes: {}, searchSchemas: {} }; } const extraction = extractUrlsFromRouter(routerSource); if (!extraction) { return { routes: {}, searchSchemas: {} }; } // Detect basename from createRouter({ basename: "..." }) const rawBasename = extractBasenameFromRouter(routerSource); const basename = rawBasename ? ("/" + rawBasename.replace(/^\/+|\/+$/g, "")).replace(/^\/$/, "") : undefined; let result: { routes: Record; searchSchemas: Record>; }; // Inline builder: extract routes directly from the function body if (extraction.kind === "inline") { result = buildCombinedRouteMapWithSearch( routerFilePath, undefined, undefined, undefined, extraction.block, ); } else { // Variable reference: follow imports or same-file declaration const imported = resolveImportedVariable(routerSource, extraction.name); if (imported) { const targetFile = resolveImportPath(imported.specifier, routerFilePath); if (!targetFile) { return { routes: {}, searchSchemas: {} }; } result = buildCombinedRouteMapWithSearch( targetFile, imported.exportedName, ); } else { result = buildCombinedRouteMapWithSearch(routerFilePath, extraction.name); } } // Apply basename prefix to all extracted route patterns if (basename) { result = applyBasenameToRoutes(result, basename); } return result; } // --------------------------------------------------------------------------- // Unresolvable include detection (full include tree walk) // --------------------------------------------------------------------------- /** * Walk the full include tree starting from a router file and detect * all includes that the static parser cannot resolve. * Returns an array of diagnostics; empty means fully resolvable. */ export function detectUnresolvableIncludes( routerFilePath: string, ): UnresolvableInclude[] { const realPath = resolve(routerFilePath); let source: string; try { source = readFileSync(realPath, "utf-8"); } catch { return []; } // Extract the urls source from the router file const extraction = extractUrlsFromRouter(source); if (!extraction) return []; const diagnostics: UnresolvableInclude[] = []; if (extraction.kind === "inline") { // Inline builder: parse directly buildCombinedRouteMapWithSearch( realPath, undefined, new Set(), diagnostics, extraction.block, ); return diagnostics; } // Variable reference: resolve where it comes from const imported = resolveImportedVariable(source, extraction.name); let targetFile: string; let exportedName: string | undefined; if (imported) { const resolved = resolveImportPath(imported.specifier, realPath); if (!resolved) { return [ { pathPrefix: "/", namePrefix: null, reason: "file-not-found", sourceFile: realPath, detail: `import "${imported.specifier}" resolved to no file`, }, ]; } targetFile = resolved; exportedName = imported.exportedName; } else { // Same-file urls() definition targetFile = realPath; exportedName = extraction.name; } buildCombinedRouteMapWithSearch( targetFile, exportedName, new Set(), diagnostics, ); return diagnostics; } /** * Walk the include tree for a standalone urls() module file and detect * all unresolvable includes. Mirrors detectUnresolvableIncludes() but * operates on urls() variable declarations instead of going through * createRouter(). */ export function detectUnresolvableIncludesForUrlsFile( filePath: string, ): UnresolvableInclude[] { const realPath = resolve(filePath); let source: string; try { source = readFileSync(realPath, "utf-8"); } catch { return []; } const varNames = findUrlsVariableNames(source); if (varNames.length === 0) return []; const diagnostics: UnresolvableInclude[] = []; for (const varName of varNames) { buildCombinedRouteMapWithSearch(realPath, varName, new Set(), diagnostics); } return diagnostics; } // --------------------------------------------------------------------------- // Per-router named-routes.gen.ts writer // --------------------------------------------------------------------------- /** * Scan for files containing createRouter() and return their paths. * Call once at startup; the result can be reused on subsequent watcher triggers. */ export function findRouterFiles(root: string, filter?: ScanFilter): string[] { const result: string[] = []; findRouterFilesRecursive(root, filter, result); return result; } /** * Write named-routes.gen.ts files from static source parsing. * Dev-only: provides initial .gen.ts files for IDE types before runtime * discovery runs. Must NOT be called during production builds -- runtime * discovery in buildStart produces the definitive file. */ export function writeCombinedRouteTypes( root: string, knownRouterFiles?: string[], opts?: { preserveIfLarger?: boolean; onWrite?: (outPath: string, content: string) => void; }, ): void { // Delete old combined named-routes.gen.ts if it exists (stale from older versions) try { const oldCombinedPath = join(root, "src", "named-routes.gen.ts"); if (existsSync(oldCombinedPath)) { unlinkSync(oldCombinedPath); console.log( `[rango] Removed stale combined route types: ${oldCombinedPath}`, ); } } catch {} const routerFilePaths = knownRouterFiles ?? findRouterFiles(root); if (routerFilePaths.length === 0) return; const nestedRouterConflict = findNestedRouterConflict(routerFilePaths); if (nestedRouterConflict) { throw new Error(formatNestedRouterConflictError(nestedRouterConflict)); } for (const routerFilePath of routerFilePaths) { const result = buildCombinedRouteMapForRouterFile(routerFilePath); if ( Object.keys(result.routes).length === 0 && Object.keys(result.searchSchemas).length === 0 ) { // Check if the file even has a createRouter call — if not, skip entirely. // If it does, fall through to write an empty placeholder below. let routerSource: string; try { routerSource = readFileSync(routerFilePath, "utf-8"); } catch { continue; } if (!extractUrlsFromRouter(routerSource)) continue; } const outPath = genFileTsPath(routerFilePath); const existing = existsSync(outPath) ? readFileSync(outPath, "utf-8") : null; // When the static parser can't extract routes (e.g. callback-style urls()), // write an empty placeholder so the build-time transform's injected import // resolves. Runtime discovery will overwrite this with the real routes. if (Object.keys(result.routes).length === 0) { if (!existing) { const emptySource = generateRouteTypesSource({}); opts?.onWrite?.(outPath, emptySource); writeFileSync(outPath, emptySource); } continue; } const hasSearchSchemas = Object.keys(result.searchSchemas).length > 0; const source = generateRouteTypesSource( result.routes, hasSearchSchemas ? result.searchSchemas : undefined, ); if (existing !== source) { // On initial dev startup, don't overwrite a file from runtime discovery // (which has all dynamic routes) with a smaller set from the static // parser. The static parser can't see routes generated by Array.from() // or other dynamic code. During HMR (file watcher), always write so // newly added routes appear immediately. if (opts?.preserveIfLarger && existing) { const existingCount = countPublicRouteEntries(existing); const newCount = Object.keys(result.routes).filter( (name) => !isAutoGeneratedRouteName(name), ).length; if (existingCount > newCount) { continue; } } opts?.onWrite?.(outPath, source); writeFileSync(outPath, source); console.log( `[rango] Generated route types (${Object.keys(result.routes).length} routes) -> ${outPath}`, ); } } }