generateRoutesInfoPlugin.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  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\([^)]+\)\s*;?/,
  307. ) || content.match(
  308. /const\s*\{\s*exposes\s*\}\s*=\s*generateExposesFromRoutes\([^)]+\)\.exposes/,
  309. );
  310. if (exposesMatch) {
  311. // 找到了 generateExposesFromRoutes 的调用
  312. // 说明使用的是新的自动生成方式
  313. // 我们需要手动调用它来生成 exposes
  314. console.log('✅ 检测到自动生成 exposes 配置');
  315. return {};
  316. }
  317. // 尝试匹配旧的直接配置方式
  318. const oldExposesMatch = content.match(/exposes:\s*\{([\s\S]*?)\n\s*\}/);
  319. if (oldExposesMatch) {
  320. const exposesContent = oldExposesMatch[1];
  321. const exposes: Record<string, string> = {};
  322. const exposeRegex = /['"`]([^'"`]+)['"`]\s*:\s*['"`]([^'"`]+)['"`]/g;
  323. let match;
  324. while ((match = exposeRegex.exec(exposesContent)) !== null) {
  325. const [, exposeKey, componentPath] = match;
  326. exposes[exposeKey] = componentPath;
  327. }
  328. return exposes;
  329. }
  330. } catch (error) {
  331. console.error('❌ 读取 exposes 配置失败:', error);
  332. }
  333. return {};
  334. }
  335. /**
  336. * 根据 rsbuild.config.ts 的 exposes 配置,建立组件路径到 expose key 的映射
  337. * 支持多种路径格式的匹配
  338. */
  339. function buildExposeMap(exposes: Record<string, string>): Map<string, string> {
  340. const map = new Map<string, string>();
  341. for (const [exposeKey, componentPath] of Object.entries(exposes)) {
  342. // 保存完整路径映射
  343. map.set(componentPath, exposeKey);
  344. // 保存文件名映射 (AboutView.vue -> ./Header)
  345. const fileName = path.basename(componentPath);
  346. map.set(fileName, exposeKey);
  347. // 保存不带扩展名的文件名映射 (AboutView -> ./Header)
  348. const componentName = path.basename(componentPath, '.vue');
  349. map.set(componentName, exposeKey);
  350. // 保存相对路径的不同格式
  351. // ./src/views/HomeView.vue -> ../views/HomeView.vue
  352. if (componentPath.startsWith('./src/')) {
  353. const relativePath = componentPath.replace('./src/', '../');
  354. map.set(relativePath, exposeKey);
  355. map.set(relativePath.replace(/^\.\./, './src/'), exposeKey);
  356. }
  357. // ../views/HomeView.vue -> ./src/views/HomeView.vue
  358. if (componentPath.startsWith('../')) {
  359. const srcPath = componentPath.replace('../', './src/');
  360. map.set(srcPath, exposeKey);
  361. }
  362. }
  363. return map;
  364. }
  365. /**
  366. * 读取环境变量文件,获取 resourceCode
  367. * 使用 Rsbuild 的 loadEnv 加载环境变量
  368. */
  369. async function getResourceCode(
  370. rootPath: string,
  371. mode: string,
  372. ): Promise<string> {
  373. try {
  374. // 使用 Rsbuild 的 loadEnv 加载环境变量
  375. const env = await loadEnv({ mode, cwd: rootPath });
  376. // 获取 VUE_APP_RESOURCE_CODE
  377. const resourceCode =
  378. env?.parsed?.VUE_APP_RESOURCE_CODE ||
  379. env?.VUE_APP_RESOURCE_CODE ||
  380. process.env.VUE_APP_RESOURCE_CODE ||
  381. '';
  382. return String(resourceCode).replace(/^['"`]|['"`]$/g, ''); // 移除引号
  383. } catch (error) {
  384. console.warn('⚠️ 无法读取 resourceCode:', error);
  385. return process.env.VUE_APP_RESOURCE_CODE || '';
  386. }
  387. }
  388. /**
  389. * 生成 routes-info.json 的 Rsbuild 插件
  390. * @param exposes 暴露的组件映射(key: expose路径, value: 组件路径)
  391. * @param routesList 路由配置数组
  392. */
  393. export function generateRoutesInfoPlugin(
  394. exposes?: Record<string, string>,
  395. routesList?: ParsedRoute[],
  396. ): RsbuildPlugin {
  397. return {
  398. name: 'generate-routes-info',
  399. setup(api) {
  400. api.onAfterBuild(async ({ stats }) => {
  401. console.log('🔧 generateRoutesInfoPlugin 开始执行...');
  402. if (api.context.mode !== 'production') {
  403. console.log('⏭️ 非生产模式,跳过生成');
  404. return;
  405. }
  406. try {
  407. const distPath = api.context.distPath;
  408. const manifestPath = path.join(distPath, 'mf-manifest.json');
  409. const rootPath = api.context.rootPath;
  410. const mode = api.context.mode;
  411. // 读取 mf-manifest.json
  412. const manifestContent = await fs.readFile(manifestPath, 'utf-8');
  413. const manifest: Manifest = JSON.parse(manifestContent);
  414. console.log(`📋 读取 manifest: ${manifest.exposes.length} 个 exposes`);
  415. // 使用传入的 exposes 或从 manifest 构建
  416. let exposures: Record<string, string> = exposes || {};
  417. if (Object.keys(exposures).length === 0) {
  418. manifest.exposes.forEach((exp) => {
  419. exposures[exp.path] = exp.name;
  420. });
  421. }
  422. // 从 manifest exposes 直接构建路由信息
  423. const pages: RouteInfoNode[] = manifest.exposes
  424. .filter(exp => !exp.path.includes('/__'))
  425. .map(exp => {
  426. const parts = exp.path.replace(/^\.\//, '').split('/');
  427. const lastPart = parts[parts.length - 1];
  428. return {
  429. path: exp.path,
  430. name: lastPart,
  431. component: exp.path,
  432. meta: {
  433. id: exp.id,
  434. title: lastPart,
  435. },
  436. };
  437. });
  438. // 读取 resourceCode
  439. const resourceCode = await getResourceCode(rootPath, mode);
  440. const routesInfo = { resourceCode, pages };
  441. // 写入 routes-info.json
  442. const outputPath = path.join(distPath, 'routes-info.json');
  443. await fs.writeFile(outputPath, JSON.stringify(routesInfo, null, 2), 'utf-8');
  444. console.log(`✅ routes-info.json 已生成 (resourceCode: ${resourceCode}, 页面: ${pages.length} 个)`);
  445. } catch (error) {
  446. console.error('❌ 生成 routes-info.json 失败:', error);
  447. }
  448. });
  449. },
  450. };
  451. }
  452. /**
  453. * 递归构建路由树
  454. */
  455. function buildRouteTree(
  456. parsedRoutes: ParsedRoute[],
  457. exposeMap: Map<string, string>,
  458. manifest: Manifest,
  459. ): RouteInfoNode[] {
  460. return parsedRoutes
  461. .map((route) => {
  462. // 如果有 componentPath,查找对应的 expose
  463. if (route.componentPath) {
  464. const componentPath = route.componentPath;
  465. // 尝试多种方式查找 expose
  466. const exposeKey =
  467. exposeMap.get(componentPath) ||
  468. exposeMap.get(path.basename(componentPath)) ||
  469. exposeMap.get(path.basename(componentPath, '.vue')) ||
  470. exposeMap.get(componentPath.replace('../views/', './src/views/'));
  471. if (exposeKey) {
  472. // 找到了对应的 expose
  473. const expose = manifest.exposes.find((e) => e.path === exposeKey);
  474. if (!expose) {
  475. console.warn(`⚠️ manifest 中未找到 expose: ${exposeKey}`);
  476. return null;
  477. }
  478. const routeNode: RouteInfoNode = {
  479. path: route.path,
  480. name: route.name,
  481. component: exposeKey,
  482. meta: {
  483. id: expose.id,
  484. title: route.meta?.title,
  485. description: route.meta?.description,
  486. category: route.meta?.category,
  487. icon: route.meta?.icon,
  488. },
  489. };
  490. // 递归处理 children
  491. if (route.children && route.children.length > 0) {
  492. routeNode.children = buildRouteTree(
  493. route.children,
  494. exposeMap,
  495. manifest,
  496. );
  497. }
  498. return routeNode;
  499. } else {
  500. // 没有找到对应的 expose(可能是因为 onlyLeafNodes 模式)
  501. // 仍然保留路由信息,但不设置 component 和 id
  502. console.log(
  503. `ℹ️ 路由 ${route.path} (${route.componentPath}) 未在 exposes 中找到(可能有子路由)`,
  504. );
  505. const routeNode: RouteInfoNode = {
  506. path: route.path,
  507. name: route.name,
  508. meta: {
  509. title: route.meta?.title,
  510. description: route.meta?.description,
  511. category: route.meta?.category,
  512. icon: route.meta?.icon,
  513. tags: route.meta?.tags,
  514. },
  515. };
  516. // 递归处理 children
  517. if (route.children && route.children.length > 0) {
  518. routeNode.children = buildRouteTree(
  519. route.children,
  520. exposeMap,
  521. manifest,
  522. );
  523. }
  524. return routeNode;
  525. }
  526. } else {
  527. // 没有 component 的路由(如重定向、布局等)
  528. const routeNode: RouteInfoNode = {
  529. path: route.path,
  530. name: route.name,
  531. meta: {
  532. title: route.meta?.title,
  533. description: route.meta?.description,
  534. category: route.meta?.category,
  535. icon: route.meta?.icon,
  536. tags: route.meta?.tags,
  537. },
  538. };
  539. // 递归处理 children
  540. if (route.children && route.children.length > 0) {
  541. routeNode.children = buildRouteTree(
  542. route.children,
  543. exposeMap,
  544. manifest,
  545. );
  546. }
  547. return routeNode;
  548. }
  549. })
  550. .filter((route): route is RouteInfoNode => route !== null);
  551. }