generateRoutesInfoPlugin.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  1. import type { RsbuildPlugin } from '@rsbuild/core';
  2. import fs from 'fs/promises';
  3. import path from 'path';
  4. import { loadEnv } from '@rsbuild/core';
  5. import { generateExposesFromRoutes } from './generateExposesPlugin';
  6. /**
  7. * 路由树节点结构,类似 Vue Router 的 RouteRecordRaw
  8. */
  9. interface RouteInfoNode {
  10. path: string;
  11. name?: string;
  12. component?: string;
  13. redirect?: string;
  14. meta?: {
  15. id?: string;
  16. title?: string;
  17. description?: string;
  18. category?: string;
  19. icon?: string;
  20. tags?: string[];
  21. assets?: {
  22. js: {
  23. sync: string[];
  24. async: string[];
  25. };
  26. css: {
  27. sync: string[];
  28. async: string[];
  29. };
  30. };
  31. };
  32. children?: RouteInfoNode[];
  33. }
  34. /**
  35. * 解析后的路由配置
  36. */
  37. interface ParsedRoute {
  38. path: string;
  39. name?: string;
  40. componentPath?: string;
  41. children?: ParsedRoute[];
  42. meta?: {
  43. title?: string;
  44. description?: string;
  45. category?: string;
  46. icon?: string;
  47. tags?: string[];
  48. };
  49. }
  50. interface ManifestExpose {
  51. id: string;
  52. name: string;
  53. path: string;
  54. assets: {
  55. js: {
  56. sync: string[];
  57. async: string[];
  58. };
  59. css: {
  60. sync: string[];
  61. async: string[];
  62. };
  63. };
  64. }
  65. interface Manifest {
  66. id: string;
  67. name: string;
  68. exposes: ManifestExpose[];
  69. }
  70. /**
  71. * 从路由文件中解析路由配置(支持嵌套 children)
  72. */
  73. async function parseRoutesFromFile(routerPath: string): Promise<ParsedRoute[]> {
  74. const content = await fs.readFile(routerPath, 'utf-8');
  75. // 尝试多种路由声明模式
  76. // 1. export const routes: RouteRecordRaw[] = [...]
  77. // 2. const routes: RouteRecordRaw[] = [...]
  78. // 3. const routes: RouteRecordRaw[]\n =\[...] (支持换行)
  79. const routesMatch = content.match(/export\s+const\s+routes:\s*RouteRecordRaw\[\]\s*=\s*\[(.*)\]/s) ||
  80. content.match(/const\s+routes:\s*RouteRecordRaw\[\]\s*=\s*\[(.*)\]/s);
  81. if (!routesMatch) {
  82. console.warn(`⚠️ 无法从 ${routerPath} 中提取路由配置`);
  83. return [];
  84. }
  85. const routesContent = routesMatch[1];
  86. // 解析路由列表(递归处理 children)
  87. return parseRoutesArray(routesContent);
  88. }
  89. /**
  90. * 解析路由数组
  91. */
  92. function parseRoutesArray(content: string): ParsedRoute[] {
  93. const routes: ParsedRoute[] = [];
  94. let pos = 0;
  95. while (pos < content.length) {
  96. // 跳过空白字符和逗号
  97. while (pos < content.length && /[\s,]/.test(content[pos])) {
  98. pos++;
  99. }
  100. if (pos >= content.length) {
  101. break;
  102. }
  103. // 检查是否是路由对象的开始
  104. if (content[pos] === '{') {
  105. const routeObj = extractRouteObject(content, pos);
  106. if (!routeObj) {
  107. break;
  108. }
  109. // 提取 path 和 name
  110. const pathMatch = routeObj.match(/path:\s*['"`]([^'"`]+)['"`]/);
  111. const nameMatch = routeObj.match(/name:\s*['"`]([^'"`]+)['"`]/);
  112. if (!pathMatch || !nameMatch) {
  113. pos += routeObj.length;
  114. continue;
  115. }
  116. const routePath = pathMatch[1];
  117. const routeName = nameMatch[1];
  118. // 提取 component 路径(支持多种格式)
  119. // 1. () => import('...')
  120. // 2. () => Promise.resolve().then(() => jitiImport('...'))
  121. let componentPath: string | undefined;
  122. const componentMatch1 = routeObj.match(/component:\s*\(\)\s*=>\s*import\(['"`]([^'"`]+)['"`]\)/);
  123. const componentMatch2 = routeObj.match(/jitiImport\(['"`]([^'"`]+\.vue)['"`]\)/);
  124. if (componentMatch1?.[1]) {
  125. componentPath = componentMatch1[1];
  126. } else if (componentMatch2?.[1]) {
  127. componentPath = componentMatch2[1];
  128. }
  129. // 提取 meta
  130. const meta = extractMeta(routeObj);
  131. // 提取 children
  132. const childrenKeywordMatch = routeObj.match(/children:\s*\[/);
  133. let children: ParsedRoute[] | undefined;
  134. if (childrenKeywordMatch) {
  135. const bracketStart = childrenKeywordMatch.index + childrenKeywordMatch[0].length - 1;
  136. const childrenContent = extractBracketContent(routeObj, bracketStart);
  137. if (childrenContent) {
  138. children = parseRoutesArray(childrenContent);
  139. }
  140. }
  141. routes.push({
  142. path: routePath,
  143. name: routeName,
  144. componentPath,
  145. meta,
  146. children,
  147. });
  148. pos += routeObj.length;
  149. } else {
  150. pos++;
  151. }
  152. }
  153. return routes;
  154. }
  155. /**
  156. * 提取完整的路由对象(处理嵌套的大括号)
  157. */
  158. function extractRouteObject(content: string, startPos: number): string | null {
  159. let braceCount = 0;
  160. let inString = false;
  161. let stringChar = '';
  162. let i = startPos;
  163. for (; i < content.length; i++) {
  164. const char = content[i];
  165. // 处理字符串
  166. if ((char === '"' || char === "'" || char === '`') && (i === 0 || content[i - 1] !== '\\')) {
  167. if (!inString) {
  168. inString = true;
  169. stringChar = char;
  170. } else if (char === stringChar) {
  171. inString = false;
  172. }
  173. continue;
  174. }
  175. if (inString) continue;
  176. // 计算大括号
  177. if (char === '{') {
  178. braceCount++;
  179. } else if (char === '}') {
  180. braceCount--;
  181. if (braceCount === 0) {
  182. return content.substring(startPos, i + 1);
  183. }
  184. }
  185. }
  186. return null;
  187. }
  188. /**
  189. * 提取方括号内的内容(处理嵌套)
  190. */
  191. function extractBracketContent(content: string, startPos: number): string | null {
  192. let bracketCount = 0;
  193. let inString = false;
  194. let stringChar = '';
  195. let i = startPos;
  196. for (; i < content.length; i++) {
  197. const char = content[i];
  198. // 处理字符串
  199. if ((char === '"' || char === "'" || char === '`') && (i === 0 || content[i - 1] !== '\\')) {
  200. if (!inString) {
  201. inString = true;
  202. stringChar = char;
  203. } else if (char === stringChar) {
  204. inString = false;
  205. }
  206. continue;
  207. }
  208. if (inString) continue;
  209. // 计算方括号
  210. if (char === '[') {
  211. bracketCount++;
  212. } else if (char === ']') {
  213. bracketCount--;
  214. if (bracketCount === 0) {
  215. return content.substring(startPos + 1, i); // 返回 [] 内的内容
  216. }
  217. }
  218. }
  219. return null;
  220. }
  221. /**
  222. * 提取 meta 信息
  223. */
  224. function extractMeta(routeObj: string): ParsedRoute['meta'] {
  225. const metaMatch = routeObj.match(/meta:\s*\{([\s\S]*?)\}(?=\s*[,}])/);
  226. if (!metaMatch) {
  227. return undefined;
  228. }
  229. const metaContent = metaMatch[1];
  230. // 提取 meta 字段
  231. const titleMatch = metaContent.match(/title:\s*['"`]([^'"`]+)['"`]/);
  232. const descMatch = metaContent.match(/description:\s*['"`]([^'"`]+)['"`]/);
  233. const categoryMatch = metaContent.match(/category:\s*['"`]([^'"`]+)['"`]/);
  234. const iconMatch = metaContent.match(/icon:\s*['"`]([^'"`]+)['"`]/);
  235. return {
  236. title: titleMatch?.[1],
  237. description: descMatch?.[1],
  238. category: categoryMatch?.[1],
  239. icon: iconMatch?.[1],
  240. };
  241. }
  242. /**
  243. * 从 rsbuild.config.ts 中读取 exposes 配置
  244. * 新版本:直接调用 generateExposesFromRoutes 函数生成
  245. * 旧版本:从配置文件中正则提取(兼容性备用)
  246. */
  247. async function loadExposesConfig(
  248. configPath: string,
  249. rootPath: string
  250. ): Promise<Record<string, string>> {
  251. try {
  252. // 新方法:直接读取 routes 文件并调用 generateExposesFromRoutes
  253. const routesPath = path.join(rootPath, 'src/router/routes.ts');
  254. const indexPath = path.join(rootPath, 'src/router/index.ts');
  255. let routesContent: string | null = null;
  256. // 尝试读取 routes.ts
  257. try {
  258. routesContent = await fs.readFile(routesPath, 'utf-8');
  259. } catch {
  260. // 如果 routes.ts 不存在,尝试读取 index.ts
  261. try {
  262. routesContent = await fs.readFile(indexPath, 'utf-8');
  263. } catch {
  264. console.warn('⚠️ 无法读取路由配置文件');
  265. }
  266. }
  267. if (routesContent) {
  268. // 从路由文件内容中提取 routes 数组
  269. const routesMatch = routesContent.match(/export\s+const\s+routes:\s*RouteRecordRaw\[\]\s*=\s*\[(.*)\]/s) ||
  270. routesContent.match(/const\s+routes:\s*RouteRecordRaw\[\]\s*=\s*\[(.*)\]/s);
  271. if (routesMatch) {
  272. // 注意:这里需要实际的 routes 对象,而不是字符串
  273. // 我们暂时使用旧的正则方法作为备用
  274. console.log('✅ 使用 generateExposesFromRoutes 生成 exposes 配置');
  275. }
  276. }
  277. } catch (error) {
  278. console.warn('⚠️ 使用新方法生成 exposes 失败,尝试正则提取:', error);
  279. }
  280. // 备用方法:从配置文件中正则提取(兼容旧配置)
  281. try {
  282. const content = await fs.readFile(configPath, 'utf-8');
  283. // 查找 exposes 变量的定义
  284. const exposesMatch = content.match(/const\s*\{\s*exposes\s*\}\s*=\s*generateExposesFromRoutes\([^)]+\)\.exposes/);
  285. if (exposesMatch) {
  286. // 找到了 generateExposesFromRoutes 的调用
  287. // 说明使用的是新的自动生成方式
  288. // 我们需要手动调用它来生成 exposes
  289. console.log('✅ 检测到自动生成 exposes 配置');
  290. return {};
  291. }
  292. // 尝试匹配旧的直接配置方式
  293. const oldExposesMatch = content.match(/exposes:\s*\{([\s\S]*?)\n\s*\}/);
  294. if (oldExposesMatch) {
  295. const exposesContent = oldExposesMatch[1];
  296. const exposes: Record<string, string> = {};
  297. const exposeRegex = /['"`]([^'"`]+)['"`]\s*:\s*['"`]([^'"`]+)['"`]/g;
  298. let match;
  299. while ((match = exposeRegex.exec(exposesContent)) !== null) {
  300. const [, exposeKey, componentPath] = match;
  301. exposes[exposeKey] = componentPath;
  302. }
  303. return exposes;
  304. }
  305. } catch (error) {
  306. console.error('❌ 读取 exposes 配置失败:', error);
  307. }
  308. return {};
  309. }
  310. /**
  311. * 根据 rsbuild.config.ts 的 exposes 配置,建立组件路径到 expose key 的映射
  312. * 支持多种路径格式的匹配
  313. */
  314. function buildExposeMap(exposes: Record<string, string>): Map<string, string> {
  315. const map = new Map<string, string>();
  316. for (const [exposeKey, componentPath] of Object.entries(exposes)) {
  317. // 保存完整路径映射
  318. map.set(componentPath, exposeKey);
  319. // 保存文件名映射 (AboutView.vue -> ./Header)
  320. const fileName = path.basename(componentPath);
  321. map.set(fileName, exposeKey);
  322. // 保存不带扩展名的文件名映射 (AboutView -> ./Header)
  323. const componentName = path.basename(componentPath, '.vue');
  324. map.set(componentName, exposeKey);
  325. // 保存相对路径的不同格式
  326. // ./src/views/HomeView.vue -> ../views/HomeView.vue
  327. if (componentPath.startsWith('./src/')) {
  328. const relativePath = componentPath.replace('./src/', '../');
  329. map.set(relativePath, exposeKey);
  330. map.set(relativePath.replace(/^\.\./, './src/'), exposeKey);
  331. }
  332. // ../views/HomeView.vue -> ./src/views/HomeView.vue
  333. if (componentPath.startsWith('../')) {
  334. const srcPath = componentPath.replace('../', './src/');
  335. map.set(srcPath, exposeKey);
  336. }
  337. }
  338. return map;
  339. }
  340. /**
  341. * 读取环境变量文件,获取 resourceCode
  342. * 使用 Rsbuild 的 loadEnv 加载环境变量
  343. */
  344. async function getResourceCode(rootPath: string, mode: string): Promise<string> {
  345. try {
  346. // 使用 Rsbuild 的 loadEnv 加载环境变量
  347. const env = await loadEnv({ mode, cwd: rootPath });
  348. // 获取 VUE_APP_RESOURCE_CODE
  349. const resourceCode = env?.parsed?.VUE_APP_RESOURCE_CODE ||
  350. env?.VUE_APP_RESOURCE_CODE ||
  351. process.env.VUE_APP_RESOURCE_CODE ||
  352. '';
  353. return String(resourceCode).replace(/^['"`]|['"`]$/g, ''); // 移除引号
  354. } catch (error) {
  355. console.warn('⚠️ 无法读取 resourceCode:', error);
  356. return process.env.VUE_APP_RESOURCE_CODE || '';
  357. }
  358. }
  359. /**
  360. * 生成 routes-info.json 的 Rsbuild 插件
  361. * 自动从路由配置中读取元数据(支持嵌套 children)
  362. */
  363. export function generateRoutesInfoPlugin(): RsbuildPlugin {
  364. return {
  365. name: 'generate-routes-info',
  366. setup(api) {
  367. api.onAfterBuild(async ({ stats }) => {
  368. try {
  369. const distPath = api.context.distPath;
  370. const manifestPath = path.join(distPath, 'mf-manifest.json');
  371. const rootPath = api.context.rootPath;
  372. // 获取当前构建模式
  373. const mode = api.context.mode;
  374. // 读取 mf-manifest.json
  375. const manifestContent = await fs.readFile(manifestPath, 'utf-8');
  376. const manifest: Manifest = JSON.parse(manifestContent);
  377. // 读取 rsbuild.config.ts 的 exposes 配置
  378. const configPath = path.join(rootPath, 'rsbuild.config.ts');
  379. const exposesConfig = await loadExposesConfig(configPath, rootPath);
  380. // 如果从配置文件读取失败(因为使用了自动生成),则直接生成
  381. if (Object.keys(exposesConfig).length === 0) {
  382. console.log('🔄 直接从路由生成 exposes 配置');
  383. // 需要动态导入 routes 模块
  384. try {
  385. const routesModule = await import(path.join(rootPath, 'src/router/routes.ts'));
  386. const { exposes: autoExposes } = generateExposesFromRoutes(routesModule.routes, { onlyLeafNodes: true });
  387. Object.assign(exposesConfig, autoExposes);
  388. } catch (error) {
  389. console.error('❌ 自动生成 exposes 失败:', error);
  390. }
  391. }
  392. // 建立组件路径到 expose key 的映射
  393. const exposeMap = buildExposeMap(exposesConfig);
  394. // 读取路由配置(优先从 routes.ts 读取,如果不存在则从 index.ts 读取)
  395. const routesPath = path.join(rootPath, 'src/router/routes.ts');
  396. const indexPath = path.join(rootPath, 'src/router/index.ts');
  397. let parsedRoutes: ParsedRoute[] = [];
  398. // 先尝试读取 routes.ts
  399. try {
  400. await fs.access(routesPath);
  401. parsedRoutes = await parseRoutesFromFile(routesPath);
  402. console.log('✅ 从 routes.ts 读取路由配置');
  403. } catch {
  404. // 如果 routes.ts 不存在,则尝试从 index.ts 读取
  405. try {
  406. parsedRoutes = await parseRoutesFromFile(indexPath);
  407. console.log('✅ 从 index.ts 读取路由配置');
  408. } catch (error) {
  409. console.error('❌ 无法读取路由配置文件:', error);
  410. }
  411. }
  412. // 递归构建路由树
  413. const pages = buildRouteTree(parsedRoutes, exposeMap, manifest);
  414. // 读取 resourceCode
  415. const resourceCode = await getResourceCode(rootPath, mode);
  416. // 包装成新的格式
  417. const routesInfo = {
  418. resourceCode,
  419. pages,
  420. };
  421. // 写入 routes-info.json
  422. const outputPath = path.join(distPath, 'routes-info.json');
  423. await fs.writeFile(
  424. outputPath,
  425. JSON.stringify(routesInfo, null, 2),
  426. 'utf-8'
  427. );
  428. // 统计路由节点数
  429. const countRoutes = (routes: RouteInfoNode[]): number => {
  430. let count = 0;
  431. routes.forEach(route => {
  432. count++;
  433. if (route.children) {
  434. count += countRoutes(route.children);
  435. }
  436. });
  437. return count;
  438. };
  439. } catch (error) {
  440. console.error('❌ 生成 routes-info.json 失败:', error);
  441. }
  442. });
  443. },
  444. };
  445. }
  446. /**
  447. * 递归构建路由树
  448. */
  449. function buildRouteTree(
  450. parsedRoutes: ParsedRoute[],
  451. exposeMap: Map<string, string>,
  452. manifest: Manifest
  453. ): RouteInfoNode[] {
  454. return parsedRoutes
  455. .map((route) => {
  456. // 如果有 componentPath,查找对应的 expose
  457. if (route.componentPath) {
  458. const componentPath = route.componentPath;
  459. // 尝试多种方式查找 expose
  460. const exposeKey = exposeMap.get(componentPath) ||
  461. exposeMap.get(path.basename(componentPath)) ||
  462. exposeMap.get(path.basename(componentPath, '.vue')) ||
  463. exposeMap.get(componentPath.replace('../views/', './src/views/'));
  464. if (exposeKey) {
  465. // 找到了对应的 expose
  466. const expose = manifest.exposes.find((e) => e.path === exposeKey);
  467. if (!expose) {
  468. console.warn(`⚠️ manifest 中未找到 expose: ${exposeKey}`);
  469. return null;
  470. }
  471. const routeNode: RouteInfoNode = {
  472. path: route.path,
  473. name: route.name,
  474. component: exposeKey,
  475. meta: {
  476. id: expose.id,
  477. title: route.meta?.title,
  478. description: route.meta?.description,
  479. category: route.meta?.category,
  480. icon: route.meta?.icon,
  481. },
  482. };
  483. // 递归处理 children
  484. if (route.children && route.children.length > 0) {
  485. routeNode.children = buildRouteTree(route.children, exposeMap, manifest);
  486. }
  487. return routeNode;
  488. } else {
  489. // 没有找到对应的 expose(可能是因为 onlyLeafNodes 模式)
  490. // 仍然保留路由信息,但不设置 component 和 id
  491. console.log(`ℹ️ 路由 ${route.path} (${route.componentPath}) 未在 exposes 中找到(可能有子路由)`);
  492. const routeNode: RouteInfoNode = {
  493. path: route.path,
  494. name: route.name,
  495. meta: {
  496. title: route.meta?.title,
  497. description: route.meta?.description,
  498. category: route.meta?.category,
  499. icon: route.meta?.icon,
  500. tags: route.meta?.tags,
  501. },
  502. };
  503. // 递归处理 children
  504. if (route.children && route.children.length > 0) {
  505. routeNode.children = buildRouteTree(route.children, exposeMap, manifest);
  506. }
  507. return routeNode;
  508. }
  509. } else {
  510. // 没有 component 的路由(如重定向、布局等)
  511. const routeNode: RouteInfoNode = {
  512. path: route.path,
  513. name: route.name,
  514. meta: {
  515. title: route.meta?.title,
  516. description: route.meta?.description,
  517. category: route.meta?.category,
  518. icon: route.meta?.icon,
  519. tags: route.meta?.tags,
  520. },
  521. };
  522. // 递归处理 children
  523. if (route.children && route.children.length > 0) {
  524. routeNode.children = buildRouteTree(route.children, exposeMap, manifest);
  525. }
  526. return routeNode;
  527. }
  528. })
  529. .filter((route): route is RouteInfoNode => route !== null);
  530. }