generateRoutesInfoPlugin.ts 19 KB

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