import type { RsbuildPlugin } from '@rsbuild/core'; import { loadEnv } from '@rsbuild/core'; import fs from 'fs/promises'; import path from 'path'; import { generateExposesFromRoutes } from './generateExposesPlugin'; /** * 路由树节点结构,类似 Vue Router 的 RouteRecordRaw */ interface RouteInfoNode { path: string; name?: string; component?: string; redirect?: string; meta?: { id?: string; title?: string; description?: string; category?: string; icon?: string; tags?: string[]; assets?: { js: { sync: string[]; async: string[]; }; css: { sync: string[]; async: string[]; }; }; }; children?: RouteInfoNode[]; } /** * 解析后的路由配置 */ interface ParsedRoute { path: string; name?: string; componentPath?: string; children?: ParsedRoute[]; meta?: { title?: string; description?: string; category?: string; icon?: string; tags?: string[]; }; } interface ManifestExpose { id: string; name: string; path: string; assets: { js: { sync: string[]; async: string[]; }; css: { sync: string[]; async: string[]; }; }; } interface Manifest { id: string; name: string; exposes: ManifestExpose[]; } /** * 从路由文件中解析路由配置(支持嵌套 children) */ async function parseRoutesFromFile(routerPath: string): Promise { const content = await fs.readFile(routerPath, 'utf-8'); // 尝试多种路由声明模式 // 1. export const routes: RouteRecordRaw[] = [...] // 2. const routes: RouteRecordRaw[] = [...] // 3. const routes: RouteRecordRaw[]\n =\[...] (支持换行) const routesMatch = content.match( /export\s+const\s+routes:\s*RouteRecordRaw\[\]\s*=\s*\[(.*)\]/s, ) || content.match(/const\s+routes:\s*RouteRecordRaw\[\]\s*=\s*\[(.*)\]/s); if (!routesMatch) { console.warn(`⚠️ 无法从 ${routerPath} 中提取路由配置`); return []; } const routesContent = routesMatch[1]; // 解析路由列表(递归处理 children) return parseRoutesArray(routesContent); } /** * 解析路由数组 */ function parseRoutesArray(content: string): ParsedRoute[] { const routes: ParsedRoute[] = []; let pos = 0; while (pos < content.length) { // 跳过空白字符和逗号 while (pos < content.length && /[\s,]/.test(content[pos])) { pos++; } if (pos >= content.length) { break; } // 检查是否是路由对象的开始 if (content[pos] === '{') { const routeObj = extractRouteObject(content, pos); if (!routeObj) { break; } // 提取 path 和 name const pathMatch = routeObj.match(/path:\s*['"`]([^'"`]+)['"`]/); const nameMatch = routeObj.match(/name:\s*['"`]([^'"`]+)['"`]/); if (!pathMatch || !nameMatch) { pos += routeObj.length; continue; } const routePath = pathMatch[1]; const routeName = nameMatch[1]; // 提取 component 路径(支持多种格式) // 1. () => import('...') // 2. () => Promise.resolve().then(() => jitiImport('...')) let componentPath: string | undefined; const componentMatch1 = routeObj.match( /component:\s*\(\)\s*=>\s*import\(['"`]([^'"`]+)['"`]\)/, ); const componentMatch2 = routeObj.match( /jitiImport\(['"`]([^'"`]+\.vue)['"`]\)/, ); if (componentMatch1?.[1]) { componentPath = componentMatch1[1]; } else if (componentMatch2?.[1]) { componentPath = componentMatch2[1]; } // 提取 meta const meta = extractMeta(routeObj); // 提取 children const childrenKeywordMatch = routeObj.match(/children:\s*\[/); let children: ParsedRoute[] | undefined; if (childrenKeywordMatch) { const bracketStart = childrenKeywordMatch.index + childrenKeywordMatch[0].length - 1; const childrenContent = extractBracketContent(routeObj, bracketStart); if (childrenContent) { children = parseRoutesArray(childrenContent); } } routes.push({ path: routePath, name: routeName, componentPath, meta, children, }); pos += routeObj.length; } else { pos++; } } return routes; } /** * 提取完整的路由对象(处理嵌套的大括号) */ function extractRouteObject(content: string, startPos: number): string | null { let braceCount = 0; let inString = false; let stringChar = ''; let i = startPos; for (; i < content.length; i++) { const char = content[i]; // 处理字符串 if ( (char === '"' || char === "'" || char === '`') && (i === 0 || content[i - 1] !== '\\') ) { if (!inString) { inString = true; stringChar = char; } else if (char === stringChar) { inString = false; } continue; } if (inString) continue; // 计算大括号 if (char === '{') { braceCount++; } else if (char === '}') { braceCount--; if (braceCount === 0) { return content.substring(startPos, i + 1); } } } return null; } /** * 提取方括号内的内容(处理嵌套) */ function extractBracketContent( content: string, startPos: number, ): string | null { let bracketCount = 0; let inString = false; let stringChar = ''; let i = startPos; for (; i < content.length; i++) { const char = content[i]; // 处理字符串 if ( (char === '"' || char === "'" || char === '`') && (i === 0 || content[i - 1] !== '\\') ) { if (!inString) { inString = true; stringChar = char; } else if (char === stringChar) { inString = false; } continue; } if (inString) continue; // 计算方括号 if (char === '[') { bracketCount++; } else if (char === ']') { bracketCount--; if (bracketCount === 0) { return content.substring(startPos + 1, i); // 返回 [] 内的内容 } } } return null; } /** * 提取 meta 信息 */ function extractMeta(routeObj: string): ParsedRoute['meta'] { const metaMatch = routeObj.match(/meta:\s*\{([\s\S]*?)\}(?=\s*[,}])/); if (!metaMatch) { return undefined; } const metaContent = metaMatch[1]; // 提取 meta 字段 const titleMatch = metaContent.match(/title:\s*['"`]([^'"`]+)['"`]/); const descMatch = metaContent.match(/description:\s*['"`]([^'"`]+)['"`]/); const categoryMatch = metaContent.match(/category:\s*['"`]([^'"`]+)['"`]/); const iconMatch = metaContent.match(/icon:\s*['"`]([^'"`]+)['"`]/); return { title: titleMatch?.[1], description: descMatch?.[1], category: categoryMatch?.[1], icon: iconMatch?.[1], }; } /** * 从 rsbuild.config.ts 中读取 exposes 配置 * 新版本:直接调用 generateExposesFromRoutes 函数生成 * 旧版本:从配置文件中正则提取(兼容性备用) */ async function loadExposesConfig( configPath: string, rootPath: string, ): Promise> { try { // 新方法:直接读取 routes 文件并调用 generateExposesFromRoutes const routesPath = path.join(rootPath, 'src/router/routes.ts'); const indexPath = path.join(rootPath, 'src/router/index.ts'); let routesContent: string | null = null; // 尝试读取 routes.ts try { routesContent = await fs.readFile(routesPath, 'utf-8'); } catch { // 如果 routes.ts 不存在,尝试读取 index.ts try { routesContent = await fs.readFile(indexPath, 'utf-8'); } catch { console.warn('⚠️ 无法读取路由配置文件'); } } if (routesContent) { // 从路由文件内容中提取 routes 数组 const routesMatch = routesContent.match( /export\s+const\s+routes:\s*RouteRecordRaw\[\]\s*=\s*\[(.*)\]/s, ) || routesContent.match( /const\s+routes:\s*RouteRecordRaw\[\]\s*=\s*\[(.*)\]/s, ); if (routesMatch) { // 注意:这里需要实际的 routes 对象,而不是字符串 // 我们暂时使用旧的正则方法作为备用 console.log('✅ 使用 generateExposesFromRoutes 生成 exposes 配置'); } } } catch (error) { console.warn('⚠️ 使用新方法生成 exposes 失败,尝试正则提取:', error); } // 备用方法:从配置文件中正则提取(兼容旧配置) try { const content = await fs.readFile(configPath, 'utf-8'); // 查找 exposes 变量的定义 const exposesMatch = content.match( /const\s*\{\s*exposes\s*\}\s*=\s*generateExposesFromRoutes\([^)]+\)\s*;?/, ) || content.match( /const\s*\{\s*exposes\s*\}\s*=\s*generateExposesFromRoutes\([^)]+\)\.exposes/, ); if (exposesMatch) { // 找到了 generateExposesFromRoutes 的调用 // 说明使用的是新的自动生成方式 // 我们需要手动调用它来生成 exposes console.log('✅ 检测到自动生成 exposes 配置'); return {}; } // 尝试匹配旧的直接配置方式 const oldExposesMatch = content.match(/exposes:\s*\{([\s\S]*?)\n\s*\}/); if (oldExposesMatch) { const exposesContent = oldExposesMatch[1]; const exposes: Record = {}; const exposeRegex = /['"`]([^'"`]+)['"`]\s*:\s*['"`]([^'"`]+)['"`]/g; let match; while ((match = exposeRegex.exec(exposesContent)) !== null) { const [, exposeKey, componentPath] = match; exposes[exposeKey] = componentPath; } return exposes; } } catch (error) { console.error('❌ 读取 exposes 配置失败:', error); } return {}; } /** * 根据 rsbuild.config.ts 的 exposes 配置,建立组件路径到 expose key 的映射 * 支持多种路径格式的匹配 */ function buildExposeMap(exposes: Record): Map { const map = new Map(); for (const [exposeKey, componentPath] of Object.entries(exposes)) { // 保存完整路径映射 map.set(componentPath, exposeKey); // 保存文件名映射 (AboutView.vue -> ./Header) const fileName = path.basename(componentPath); map.set(fileName, exposeKey); // 保存不带扩展名的文件名映射 (AboutView -> ./Header) const componentName = path.basename(componentPath, '.vue'); map.set(componentName, exposeKey); // 保存相对路径的不同格式 // ./src/views/HomeView.vue -> ../views/HomeView.vue if (componentPath.startsWith('./src/')) { const relativePath = componentPath.replace('./src/', '../'); map.set(relativePath, exposeKey); map.set(relativePath.replace(/^\.\./, './src/'), exposeKey); } // ../views/HomeView.vue -> ./src/views/HomeView.vue if (componentPath.startsWith('../')) { const srcPath = componentPath.replace('../', './src/'); map.set(srcPath, exposeKey); } } return map; } /** * 读取环境变量文件,获取 resourceCode * 使用 Rsbuild 的 loadEnv 加载环境变量 */ async function getResourceCode( rootPath: string, mode: string, ): Promise { try { // 使用 Rsbuild 的 loadEnv 加载环境变量 const env = await loadEnv({ mode, cwd: rootPath }); // 获取 VUE_APP_RESOURCE_CODE const resourceCode = env?.parsed?.VUE_APP_RESOURCE_CODE || env?.VUE_APP_RESOURCE_CODE || process.env.VUE_APP_RESOURCE_CODE || ''; return String(resourceCode).replace(/^['"`]|['"`]$/g, ''); // 移除引号 } catch (error) { console.warn('⚠️ 无法读取 resourceCode:', error); return process.env.VUE_APP_RESOURCE_CODE || ''; } } /** * 生成 routes-info.json 的 Rsbuild 插件 * @param exposes 暴露的组件映射(key: expose路径, value: 组件路径) * @param routesList 路由配置数组 */ export function generateRoutesInfoPlugin( exposes?: Record, routesList?: ParsedRoute[], ): RsbuildPlugin { return { name: 'generate-routes-info', setup(api) { api.onAfterBuild(async ({ stats }) => { console.log('🔧 generateRoutesInfoPlugin 开始执行...'); if (api.context.mode !== 'production') { console.log('⏭️ 非生产模式,跳过生成'); return; } try { const distPath = api.context.distPath; const manifestPath = path.join(distPath, 'mf-manifest.json'); const rootPath = api.context.rootPath; const mode = api.context.mode; // 读取 mf-manifest.json const manifestContent = await fs.readFile(manifestPath, 'utf-8'); const manifest: Manifest = JSON.parse(manifestContent); console.log(`📋 读取 manifest: ${manifest.exposes.length} 个 exposes`); // 使用传入的 exposes 或从 manifest 构建 let exposures: Record = exposes || {}; if (Object.keys(exposures).length === 0) { manifest.exposes.forEach((exp) => { exposures[exp.path] = exp.name; }); } // 从 manifest exposes 直接构建路由信息 const pages: RouteInfoNode[] = manifest.exposes .filter(exp => !exp.path.includes('/__')) .map(exp => { const parts = exp.path.replace(/^\.\//, '').split('/'); const lastPart = parts[parts.length - 1]; return { path: exp.path, name: lastPart, component: exp.path, meta: { id: exp.id, title: lastPart, }, }; }); // 读取 resourceCode const resourceCode = await getResourceCode(rootPath, mode); const routesInfo = { resourceCode, pages }; // 写入 routes-info.json const outputPath = path.join(distPath, 'routes-info.json'); await fs.writeFile(outputPath, JSON.stringify(routesInfo, null, 2), 'utf-8'); console.log(`✅ routes-info.json 已生成 (resourceCode: ${resourceCode}, 页面: ${pages.length} 个)`); } catch (error) { console.error('❌ 生成 routes-info.json 失败:', error); } }); }, }; } /** * 递归构建路由树 */ function buildRouteTree( parsedRoutes: ParsedRoute[], exposeMap: Map, manifest: Manifest, ): RouteInfoNode[] { return parsedRoutes .map((route) => { // 如果有 componentPath,查找对应的 expose if (route.componentPath) { const componentPath = route.componentPath; // 尝试多种方式查找 expose const exposeKey = exposeMap.get(componentPath) || exposeMap.get(path.basename(componentPath)) || exposeMap.get(path.basename(componentPath, '.vue')) || exposeMap.get(componentPath.replace('../views/', './src/views/')); if (exposeKey) { // 找到了对应的 expose const expose = manifest.exposes.find((e) => e.path === exposeKey); if (!expose) { console.warn(`⚠️ manifest 中未找到 expose: ${exposeKey}`); return null; } const routeNode: RouteInfoNode = { path: route.path, name: route.name, component: exposeKey, meta: { id: expose.id, title: route.meta?.title, description: route.meta?.description, category: route.meta?.category, icon: route.meta?.icon, }, }; // 递归处理 children if (route.children && route.children.length > 0) { routeNode.children = buildRouteTree( route.children, exposeMap, manifest, ); } return routeNode; } else { // 没有找到对应的 expose(可能是因为 onlyLeafNodes 模式) // 仍然保留路由信息,但不设置 component 和 id console.log( `ℹ️ 路由 ${route.path} (${route.componentPath}) 未在 exposes 中找到(可能有子路由)`, ); const routeNode: RouteInfoNode = { path: route.path, name: route.name, meta: { title: route.meta?.title, description: route.meta?.description, category: route.meta?.category, icon: route.meta?.icon, tags: route.meta?.tags, }, }; // 递归处理 children if (route.children && route.children.length > 0) { routeNode.children = buildRouteTree( route.children, exposeMap, manifest, ); } return routeNode; } } else { // 没有 component 的路由(如重定向、布局等) const routeNode: RouteInfoNode = { path: route.path, name: route.name, meta: { title: route.meta?.title, description: route.meta?.description, category: route.meta?.category, icon: route.meta?.icon, tags: route.meta?.tags, }, }; // 递归处理 children if (route.children && route.children.length > 0) { routeNode.children = buildRouteTree( route.children, exposeMap, manifest, ); } return routeNode; } }) .filter((route): route is RouteInfoNode => route !== null); }