| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668 |
- 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<ParsedRoute[]> {
- 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<Record<string, string>> {
- 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\([^)]+\)\.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<string, string> = {};
- 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<string, string>): Map<string, string> {
- const map = new Map<string, string>();
- 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<string> {
- 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 插件
- * 自动从路由配置中读取元数据(支持嵌套 children)
- */
- export function generateRoutesInfoPlugin(): RsbuildPlugin {
- return {
- name: 'generate-routes-info',
- setup(api) {
- api.onAfterBuild(async ({ stats }) => {
- if (api.context.mode !== 'production') {
- 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);
- // 读取 rsbuild.config.ts 的 exposes 配置
- const configPath = path.join(rootPath, 'rsbuild.config.ts');
- const exposesConfig = await loadExposesConfig(configPath, rootPath);
- // 如果从配置文件读取失败(因为使用了自动生成),则直接生成
- if (Object.keys(exposesConfig).length === 0) {
- console.log('🔄 直接从路由生成 exposes 配置');
- // 需要动态导入 routes 模块
- try {
- const routesModule = await import(
- path.join(rootPath, 'src/router/routes.ts')
- );
- const { exposes: autoExposes } = generateExposesFromRoutes(
- routesModule.routes,
- { onlyLeafNodes: true },
- );
- Object.assign(exposesConfig, autoExposes);
- } catch (error) {
- console.error('❌ 自动生成 exposes 失败:', error);
- }
- }
- // 建立组件路径到 expose key 的映射
- const exposeMap = buildExposeMap(exposesConfig);
- // 读取路由配置(优先从 routes.ts 读取,如果不存在则从 index.ts 读取)
- const routesPath = path.join(rootPath, 'src/router/routes.ts');
- const indexPath = path.join(rootPath, 'src/router/index.ts');
- let parsedRoutes: ParsedRoute[] = [];
- // 先尝试读取 routes.ts
- try {
- await fs.access(routesPath);
- parsedRoutes = await parseRoutesFromFile(routesPath);
- console.log('✅ 从 routes.ts 读取路由配置');
- } catch {
- // 如果 routes.ts 不存在,则尝试从 index.ts 读取
- try {
- parsedRoutes = await parseRoutesFromFile(indexPath);
- console.log('✅ 从 index.ts 读取路由配置');
- } catch (error) {
- console.error('❌ 无法读取路由配置文件:', error);
- }
- }
- // 递归构建路由树
- const pages = buildRouteTree(parsedRoutes, exposeMap, manifest);
- // 读取 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',
- );
- // 统计路由节点数
- const countRoutes = (routes: RouteInfoNode[]): number => {
- let count = 0;
- routes.forEach((route) => {
- count++;
- if (route.children) {
- count += countRoutes(route.children);
- }
- });
- return count;
- };
- } catch (error) {
- console.error('❌ 生成 routes-info.json 失败:', error);
- }
- });
- },
- };
- }
- /**
- * 递归构建路由树
- */
- function buildRouteTree(
- parsedRoutes: ParsedRoute[],
- exposeMap: Map<string, string>,
- 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);
- }
|