import type { RouteRecordRaw } from 'vue-router' import { writeFileSync, readFileSync, unlinkSync } from 'node:fs' import { resolve } from 'node:path' import { argv, cwd, exit } from 'node:process' export interface ProcessedRoute { name: string path: string params: { name: string, isArray: boolean }[] componentInfo: string } /** * Extract parameters from a route path without regex. */ function extractParamsFromPath(routePath: string): { name: string, isArray: boolean }[] { const segments = routePath.split('/') const params: { name: string, isArray: boolean }[] = [] for (const segment of segments) { if (segment.startsWith(':')) { // e.g. ":id+" or ":id*" const paramName = segment.substring(1) const isArray = paramName.endsWith('+') || paramName.endsWith('*') params.push({ name: isArray ? paramName.slice(0, -1) : paramName, isArray, }) } else { // If a segment has parentheses, e.g. ":id(\\d+)", handle it as needed // Avoid explicit regex, just do naive scans if (segment.includes('(') && segment.includes(')')) { const openParenIndex = segment.indexOf('(') if (segment.startsWith(':') && openParenIndex > 1) { const paramName = segment.substring(1, openParenIndex) params.push({ name: paramName, isArray: false, }) } } } } return params } /** * Format a component path/name for documentation. */ function formatComponentInfo(component: string): string { // If there is an async import, try to capture the path (simple substring logic, no regex) const searchStr = 'import(' const idx = component.indexOf(searchStr) if (idx >= 0) { // Look for the closing parenthesis const closingParenIdx = component.indexOf(')', idx) if (closingParenIdx > idx) { const inner = component.substring(idx, closingParenIdx + 1) // Attempt to find the path inside import('...') const quoteStart = inner.includes('\'') ? inner.indexOf('\'') : inner.indexOf('"') const quoteEnd = quoteStart >= 0 ? inner.indexOf(inner[quoteStart], quoteStart + 1) : -1 if (quoteStart >= 0 && quoteEnd > quoteStart) { const importPath = inner.substring(quoteStart + 1, quoteEnd) const replacedPath = importPath.replace('@/', './../') const componentName = replacedPath.split('/').pop()?.split('.')[0] || 'Component' return `[${componentName}](${replacedPath})` } } return 'Dynamic Import' } // If it is just a direct reference if (!component.includes('function')) { return component } return component } function extractComponentInfo(route: RouteRecordRaw): string { if (!route.component) { return 'Unknown' } // If already augmented with `componentInfo` if ((route as any).componentInfo) { return formatComponentInfo((route as any).componentInfo) } // If dynamic import function if (typeof route.component === 'function') { const fnString = route.component.toString() return formatComponentInfo(fnString) } // If direct object reference if (typeof route.component === 'object') { const componentName = route.component.name || 'Component Object' return componentName } // Fallback return String(route.component) } /** * Recursively gather routes with parameter inheritance. */ function processRoute( route: RouteRecordRaw, basePath = '', parentParams: { name: string, isArray: boolean }[] = [] ): ProcessedRoute[] { const results: ProcessedRoute[] = [] // Normalize path const fullPath = route.path.startsWith('/') ? route.path : basePath ? `${basePath}/${route.path}` : route.path const routeParams = extractParamsFromPath(route.path) // Merge parent and child params const paramsMap = new Map() for (const p of parentParams) { paramsMap.set(p.name, p) } for (const p of routeParams) { paramsMap.set(p.name, p) } const combinedParams = Array.from(paramsMap.values()) if (route.name) { results.push({ name: String(route.name), path: fullPath, params: combinedParams, componentInfo: extractComponentInfo(route), }) } if (route.children) { for (const child of route.children) { results.push(...processRoute(child, fullPath, combinedParams)) } } return results } /** * Convert processed routes into a RouteNamedMap interface. */ function generateRouteTypes(routes: readonly RouteRecordRaw[]): string { const processedRoutes: ProcessedRoute[] = [] for (const r of routes) { processedRoutes.push(...processRoute(r)) } let output = `// Auto-generated file import type { RouteRecordInfo } from 'vue-router' export interface RouteNamedMap {` for (const route of processedRoutes) { // skip catch-all or unnamed if (!route.name || route.name === '404' || route.path === '/:catchAll(.*)*') { continue } if (route.params.length > 0) { const rawParams = route.params .map(p => `${p.name}: ${p.isArray ? '(string)[]' : 'string'}`) .join(', ') const normalizedParams = route.params .map(p => `${p.name}: ${p.isArray ? 'string[]' : 'string'}`) .join(', ') output += ` /** * Component: ${route.componentInfo} */ '${route.name}': RouteRecordInfo< '${route.name}', '${route.path}', { ${rawParams} }, { ${normalizedParams} } >,` } else { output += ` /** * Component: ${route.componentInfo} */ '${route.name}': RouteRecordInfo< '${route.name}', '${route.path}', Record, Record >,` } } output += ` } declare module 'vue-router' { interface TypesConfig { RouteNamedMap: RouteNamedMap } }` return output } /** * Generate route definitions and write them to a file. */ function generateRouteTypesFromRoutes( routes: readonly RouteRecordRaw[], fullOutputPath: string ) { try { const typeDefinitions = generateRouteTypes(routes) writeFileSync(fullOutputPath, typeDefinitions) console.log(`Route type definitions successfully written to ${fullOutputPath}`) } catch (error) { console.error('Error generating route type definitions:', error) throw error } } function stripAtImports(content: string): string { // split into lines const lines = content.split('\n') const modified = lines.map((line) => { // if line has `import X from '@/...` if (line.includes('from \'@/') || line.includes('from "@/')) { // E.g. turn: // import something from '@/api' // Into: // const something = {} // // We do it very naively by searching for 'import ' and ' from' const importKeyword = 'import ' const fromKeyword = ' from' // Only proceed if both exist const importIdx = line.indexOf(importKeyword) const fromIdx = line.indexOf(fromKeyword, importIdx + importKeyword.length) const isVueComponent = line.endsWith(`'.vue'`) if (importIdx !== -1 && fromIdx !== -1) { // Extract the variable name(s). For simplicity, we take the substring after 'import ' up to ' from' const varNames = line .substring(importIdx + importKeyword.length, fromIdx) .trim() // Turn something like: // "something" or "{ x, y }" // into a valid JS declaration. We'll do a minimal approach: // if it starts with '{' or '*', do `const { x, y } = {};` // if it's a normal identifier, do `const something = {};` if (varNames.startsWith('{') || varNames.startsWith('*')) { return `const ${varNames} = {}; // stripped @/ import` } if (isVueComponent) { return `const ${varNames} = {name: '${varNames}'}; // stripped @/ import` } // e.g. "something" or "MyClass" return `const ${varNames} = {}; // stripped @/ import` } // If we can't parse well, just comment out the line: return `// ${line}` } return line }) return modified.join('\n') } // Script usage if (require.main === module) { let exitCode = 0 const routesPath = argv[2] || './src/router/index.ts' const outputPath = argv[3] || './src/router/vue-routes.d.ts' // create temp file that replaces the @/ with the relative path ./.. const routesFileContent = readFileSync(routesPath, 'utf-8') const tempFilePath = resolve(cwd(), routesPath.replace('.ts', '.temp.ts')) // 1) Replace `@/...` imports with empty objects const strippedContent = stripAtImports(routesFileContent) // 2) Write the modified content writeFileSync(tempFilePath, strippedContent) // region Mocking global objects Object.assign(globalThis, { window: { history: { replaceState: () => {}, pushState: () => {} }, location: { protocol: '', replace: () => {}, assign: () => {} }, addEventListener: () => {}, } }) Object.assign(globalThis, { location: { protocol: '', replace: () => {}, assign: () => {} } }) // endregion Mocking global objects import(tempFilePath).then(({ routes }) => { if (!routes || routes.length === 0) { throw new Error('No routes found in the specified file') } const fullOutputPath = resolve(cwd(), outputPath) generateRouteTypesFromRoutes(routes, fullOutputPath) }).catch((error) => { console.error('Error processing routes:', error) exitCode = 1 }).finally(() => { // Clean up temp file unlinkSync(tempFilePath) exit(exitCode) }) }