unplugin-convention-routes
Version:
File system base router plugin for unplugin
507 lines (492 loc) • 17.4 kB
JavaScript
import { createUnplugin } from "unplugin";
import process from "node:process";
import { slash, toArray } from "@antfu/utils";
//#region src/core/options.ts
/**
* 各框架默认支持的文件扩展名
*/
const DEFAULT_EXTENSIONS = {
vue: [
"vue",
"ts",
"js"
],
react: [
"tsx",
"jsx",
"ts",
"js"
]
};
/**
* 解析用户配置选项
* 将用户提供的配置转换为内部使用的完整配置对象
* @param userOptions - 用户配置选项
* @returns 解析后的完整配置对象
*/
function resolveOptions(userOptions) {
const { resolver, dirs = "src/pages", extensions = DEFAULT_EXTENSIONS[resolver], exclude = [
"node_modules",
".git",
"**/__*__/**",
"**/components/**",
"**/components.*"
], importPath = "relative", caseSensitive = false, routeNameSeparator = "-", extendRoute, onRoutesGenerated, onClientGenerated } = userOptions;
return {
root: slash(process.cwd()),
resolver,
dirs: toArray(typeof dirs === "string" ? {
dir: dirs,
baseRoute: ""
} : dirs).map((dir) => {
if (typeof dir === "string") return {
dir: slash(dir),
baseRoute: ""
};
return {
...dir,
dir: slash(dir.dir)
};
}),
extensions,
exclude,
importPath,
caseSensitive,
routeNameSeparator,
extendRoute,
onRoutesGenerated,
onClientGenerated
};
}
//#endregion
//#region src/core/path.ts
/**
* 生成 Vite 的 glob 匹配模式
* @param dir - 页面目录配置
* @param extensions - 文件扩展名列表
* @returns glob 匹配模式字符串
*/
function getGlobPattern(dir, extensions) {
const ext = extensions.length > 1 ? `{${extensions.join(",")}}` : extensions[0];
return `${dir.dir.startsWith("/") ? dir.dir : `/${dir.dir}`}/**/*.${ext}`;
}
/**
* 生成 Rspack/Webpack 的 context 匹配模式
* 注意:Rspack 虚拟模块中的相对路径会有问题,所以使用绝对路径
* @param dir - 页面目录配置
* @param extensions - 文件扩展名列表
* @param root - 项目根目录
* @returns context 配置对象
*/
function getWebpackContextPattern(dir, extensions, root) {
const extPattern = extensions.join("|");
let dirPath = dir.dir;
if (!dirPath.startsWith("/")) dirPath = `/${dirPath}`;
return {
path: root + dirPath,
recursive: true,
regExp: `\\.(${extPattern})$`
};
}
//#endregion
//#region src/resolvers/utils.ts
/**
* Resolver 公共工具函数模块
* 提供 glob 模式转换、正则表达式转义等功能
*/
/**
* 正则表达式特殊字符匹配模式
* 移到模块作用域以避免每次调用重新编译
*/
const ESCAPE_REGEXP = /[.*+?^${}()|[\]\\/:]/g;
/**
* glob 模式转换用的正则表达式
* 移到模块作用域以避免每次调用重新编译
*/
const GLOB_DOUBLE_STAR_RE = /\*\*/g;
const GLOB_SINGLE_STAR_RE = /\*/g;
const GLOB_QUESTION_RE = /\?/g;
/**
* 正则元字符匹配模式(不包含 / 和 :,因为它们在 new RegExp() 中不是特殊字符)
* 用于 glob 模式转换时转义字面字符
* 移到模块作用域以避免每次调用重新编译
*/
const REGEX_META_CHARS_RE = /[.+^${}()|[\]\\]/g;
/**
* 占位符替换用的正则表达式
* 移到模块作用域以避免每次调用重新编译
*/
const PLACEHOLDER_DOUBLE_STAR_RE = /<<<DOUBLE_STAR>>>/g;
const PLACEHOLDER_SINGLE_STAR_RE = /<<<SINGLE_STAR>>>/g;
const PLACEHOLDER_QUESTION_RE = /<<<QUESTION>>>/g;
/**
* 反斜杠匹配正则表达式
* 用于转义正则表达式字符串中的反斜杠
*/
const BACKSLASH_RE = /\\/g;
/**
* 扩展名点前缀匹配正则表达式
* 移到模块作用域以避免每次调用重新编译
*/
const EXTENSION_DOT_PREFIX_RE = /^\./;
/**
* 转义正则表达式中的特殊字符
* 包括 Windows 路径中的特殊字符(如 / 和 :)
* @param str - 待转义的字符串
* @returns 转义后的字符串
*/
function escapeRegExp$1(str) {
return str.replace(ESCAPE_REGEXP, "\\$&");
}
/**
* 将 glob 模式转换为正则表达式字符串
* 支持 **、*、? 等基本 glob 语法
*
* 转换顺序很重要:
* 1. 先用占位符替换 glob 通配符,避免后续转义影响
* 2. 转义所有正则元字符(. + ^ $ { } ( ) | [ ] \),让它们作为字面量
* 3. 将占位符替换为正则表达式
*
* 注意:/ 和 : 不需要转义,因为在 new RegExp() 中它们不是特殊字符
*
* @param pattern - glob 模式
* @returns 正则表达式字符串
*/
function globToRegExp(pattern) {
return pattern.replace(GLOB_DOUBLE_STAR_RE, "<<<DOUBLE_STAR>>>").replace(GLOB_SINGLE_STAR_RE, "<<<SINGLE_STAR>>>").replace(GLOB_QUESTION_RE, "<<<QUESTION>>>").replace(REGEX_META_CHARS_RE, "\\$&").replace(PLACEHOLDER_DOUBLE_STAR_RE, ".*").replace(PLACEHOLDER_SINGLE_STAR_RE, "[^/]*").replace(PLACEHOLDER_QUESTION_RE, "[^/]");
}
/**
* 转义正则表达式字符串中的反斜杠
* 用于生成 new RegExp() 的字符串参数
* 因为在字符串字面量中,\ 需要双重转义才能表示正则中的 \
* @param str - 正则表达式字符串
* @returns 转义后的字符串
*/
function escapeRegexString(str) {
return str.replace(BACKSLASH_RE, "\\\\");
}
/**
* 创建排除模式的正则表达式数组代码
* 在循环外部调用,只执行一次,避免每次迭代重复创建正则表达式
* @param exclude - 排除模式数组
* @param varName - 正则数组变量名
* @returns 创建正则数组的代码字符串,如果 exclude 为空则返回空字符串
*/
function createExcludePatterns(exclude, varName = "excludePatterns") {
if (!exclude || exclude.length === 0) return "";
return `const ${varName} = [${exclude.map((p) => escapeRegexString(globToRegExp(p))).map((p) => `new RegExp('${p}')`).join(", ")}]`;
}
/**
* 生成排除模式的检查代码
* 在循环内部调用,用于检查当前路径是否匹配排除模式
* @param pathVarName - 路径变量名,Vite 用 'path',Rspack 用 'key'
* @param patternsVarName - 正则数组变量名,需要与 createExcludePatterns 中的一致
* @returns 检查代码字符串
*/
function generateExcludeCheck(pathVarName = "path", patternsVarName = "excludePatterns") {
return `if (${patternsVarName}.some(pattern => pattern.test(${pathVarName}))) return`;
}
/**
* 生成路由路径转换代码
* 用于将文件路径转换为路由路径
* @param pathVar - 路径变量名(如 'path' 或 'key')
* @param escapedDir - 转义后的目录路径
* @param basePath - 基础路径
* @param extensions - 文件扩展名列表
* @param catchAllFormat - catch-all 格式,Vue 用 '(.*)*',React 用 '/*'
* @param caseSensitive - 是否区分大小写
* @returns 路径转换代码字符串
*/
function generateRoutePathTransformCode(pathVar, escapedDir, basePath, extensions, catchAllFormat, caseSensitive) {
return `let routePath = ${pathVar}
.replace(/${escapedDir}/, '')
.replace(/^\\.\\//, '')
.replace(/^\\//, '')
.replace(/\\.(${extensions.map((e) => e.replace(EXTENSION_DOT_PREFIX_RE, "")).join("|")})$/, '')
.replace(/\\/index$/, '')
.replace(/^index$/, '')
.replace(/\\[(\\.\\.\\.)?(.+?)\\]/g, (_, isCatchAll, name) =>
isCatchAll ? \`:\${name}${catchAllFormat}\` : \`:\${name}\`
)
.replace(/\\$(.+)/g, ':$1')
.replace(/\\$$$/, '${catchAllFormat}')
${caseSensitive ? "" : ".toLowerCase()"}
routePath = '${basePath}' + (routePath ? '/' + routePath : '') || '/'`;
}
/**
* 生成路由名称转换代码
* 用于将文件路径转换为路由名称
* @param pathVar - 路径变量名(如 'path' 或 'key')
* @param escapedDir - 转义后的目录路径
* @param extensions - 文件扩展名列表
* @param routeNameSeparator - 路由名称分隔符
* @returns 路由名称转换代码字符串
*/
function generateRouteNameTransformCode(pathVar, escapedDir, extensions, routeNameSeparator) {
return `const name = ${pathVar}
.replace(/${escapedDir}/, '')
.replace(/^\\.\\//, '')
.replace(/^\\//, '')
.replace(/\\.(${extensions.map((e) => e.replace(EXTENSION_DOT_PREFIX_RE, "")).join("|")})$/, '')
.replace(/\\/index$/, '')
.replace(/^index$/, '')
.replace(/\\//g, '${routeNameSeparator}')
.replace(/\\[(\\.\\.\\.)?(.+?)\\]/g, '$2')
.replace(/\\$(.+)/g, '$1')
.replace(/^${routeNameSeparator}/, '') || 'index'`;
}
//#endregion
//#region src/resolvers/react.ts
/**
* 生成 React + Vite 的路由代码
* 使用 import.meta.glob 自动扫描页面文件
* @param options - 解析后的配置选项
* @returns 生成的路由代码字符串
*/
function generateReactViteCode(options) {
const contextBlocks = [];
const excludePatternsCode = createExcludePatterns(options.exclude);
const excludeCheckCode = excludePatternsCode ? generateExcludeCheck("path") : "";
for (const dir of options.dirs) {
const globPattern = getGlobPattern(dir, options.extensions);
const contextVar = `__pages_${contextBlocks.length}__`;
const basePath = dir.baseRoute ? `/${dir.baseRoute}` : "";
const routePathCode = generateRoutePathTransformCode("path", escapeRegExp$1(dir.dir), basePath, options.extensions, "/*", options.caseSensitive);
contextBlocks.push(`
const ${contextVar} = import.meta.glob('${globPattern}')
Object.entries(${contextVar}).forEach(([path, moduleFn]) => {
const pathSegments = path.split('/')
const shouldIgnore = pathSegments.some(seg => /^__.*__$/.test(seg))
if (shouldIgnore) return
${excludeCheckCode ? ` ${excludeCheckCode}\n` : ""} ${routePathCode}
routes.push({
path: routePath,
element: React.createElement(React.lazy(moduleFn))
})
})`);
}
return `import React from 'react'
const routes = []
${excludePatternsCode ? `${excludePatternsCode}\n` : ""}${contextBlocks.join("\n")}
export default routes
`;
}
/**
* 生成 React + Rspack 的路由代码
* 使用 import.meta.webpackContext 自动扫描页面文件
* @param options - 解析后的配置选项
* @returns 生成的路由代码字符串
*/
function generateReactRspackCode(options) {
const contextBlocks = [];
const excludePatternsCode = createExcludePatterns(options.exclude);
const excludeCheckCode = excludePatternsCode ? generateExcludeCheck("key") : "";
for (const dir of options.dirs) {
const pattern = getWebpackContextPattern(dir, options.extensions, options.root);
const contextVar = `__pages_${contextBlocks.length}__`;
const basePath = dir.baseRoute ? `/${dir.baseRoute}` : "";
const routePathCode = generateRoutePathTransformCode("key", escapeRegExp$1(pattern.path), basePath, options.extensions, "/*", options.caseSensitive);
contextBlocks.push(`
const ${contextVar} = import.meta.webpackContext('${pattern.path}', {
recursive: ${pattern.recursive},
regExp: /${pattern.regExp}$/
})
${contextVar}.keys().forEach((key) => {
const pathSegments = key.split('/')
const shouldIgnore = pathSegments.some(seg => /^__.*__$/.test(seg))
if (shouldIgnore) return
${excludeCheckCode ? ` ${excludeCheckCode}\n` : ""} ${routePathCode}
const loadModule = async () => {
const module = await ${contextVar}(key)
return { default: module.default || module }
}
routes.push({
path: routePath,
element: React.createElement(React.lazy(loadModule))
})
})`);
}
return `import React from 'react'
const routes = []
${excludePatternsCode ? `${excludePatternsCode}\n` : ""}${contextBlocks.join("\n")}
export default routes
`;
}
/**
* 根据构建工具生成对应的 React 路由代码
* @param options - 解析后的配置选项
* @param buildTool - 构建工具类型
* @returns 生成的路由代码字符串
*/
function generateReactRoutes(options, buildTool) {
return buildTool === "vite" ? generateReactViteCode(options) : generateReactRspackCode(options);
}
//#endregion
//#region src/resolvers/vue.ts
/**
* 生成 Vue + Vite 的路由代码
* 使用 import.meta.glob 自动扫描页面文件
* @param options - 解析后的配置选项
* @returns 生成的路由代码字符串
*/
function generateVueViteCode(options) {
const contextBlocks = [];
const excludePatternsCode = createExcludePatterns(options.exclude);
const excludeCheckCode = excludePatternsCode ? generateExcludeCheck("path") : "";
for (const dir of options.dirs) {
const globPattern = getGlobPattern(dir, options.extensions);
const contextVar = `__pages_${contextBlocks.length}__`;
const basePath = dir.baseRoute ? `/${dir.baseRoute}` : "";
const escapedDir = escapeRegExp$1(dir.dir);
const routePathCode = generateRoutePathTransformCode("path", escapedDir, basePath, options.extensions, "(.*)*", options.caseSensitive);
const routeNameCode = generateRouteNameTransformCode("path", escapedDir, options.extensions, options.routeNameSeparator);
contextBlocks.push(`
const ${contextVar} = import.meta.glob('${globPattern}')
Object.entries(${contextVar}).forEach(([path, module]) => {
const pathSegments = path.split('/')
const shouldIgnore = pathSegments.some(seg => /^__.*__$/.test(seg))
if (shouldIgnore) return
${excludeCheckCode ? ` ${excludeCheckCode}\n` : ""} ${routePathCode}
${routeNameCode}
routes.push({
path: routePath,
name,
component: module
})
})`);
}
return `const routes = []
${excludePatternsCode ? `${excludePatternsCode}\n` : ""}${contextBlocks.join("\n")}
export default routes
`;
}
/**
* 生成 Vue + Rspack 的路由代码
* 使用 import.meta.webpackContext 自动扫描页面文件
* @param options - 解析后的配置选项
* @returns 生成的路由代码字符串
*/
function generateVueRspackCode(options) {
const contextBlocks = [];
const excludePatternsCode = createExcludePatterns(options.exclude);
const excludeCheckCode = excludePatternsCode ? generateExcludeCheck("key") : "";
for (const dir of options.dirs) {
const pattern = getWebpackContextPattern(dir, options.extensions, options.root);
const contextVar = `__pages_${contextBlocks.length}__`;
const basePath = dir.baseRoute ? `/${dir.baseRoute}` : "";
const escapedDir = escapeRegExp$1(pattern.path);
const routePathCode = generateRoutePathTransformCode("key", escapedDir, basePath, options.extensions, "(.*)*", options.caseSensitive);
const routeNameCode = generateRouteNameTransformCode("key", escapedDir, options.extensions, options.routeNameSeparator);
contextBlocks.push(`
const ${contextVar} = import.meta.webpackContext('${pattern.path}', {
recursive: ${pattern.recursive},
regExp: /${pattern.regExp}$/
})
${contextVar}.keys().forEach((key) => {
const pathSegments = key.split('/')
const shouldIgnore = pathSegments.some(seg => /^__.*__$/.test(seg))
if (shouldIgnore) return
${excludeCheckCode ? ` ${excludeCheckCode}\n` : ""} ${routePathCode}
${routeNameCode}
routes.push({
path: routePath,
name,
component: () => ${contextVar}(key)
})
})`);
}
return `const routes = []
${excludePatternsCode ? `${excludePatternsCode}\n` : ""}${contextBlocks.join("\n")}
export default routes
`;
}
/**
* 根据构建工具生成对应的 Vue 路由代码
* @param options - 解析后的配置选项
* @param buildTool - 构建工具类型
* @returns 生成的路由代码字符串
*/
function generateVueRoutes(options, buildTool) {
return buildTool === "vite" ? generateVueViteCode(options) : generateVueRspackCode(options);
}
//#endregion
//#region src/resolvers/index.ts
/**
* 路由生成器映射表
* 将 resolver 类型映射到对应的路由生成函数
*/
const generators = {
vue: generateVueRoutes,
react: generateReactRoutes
};
/**
* 生成路由代码
* 根据配置的 resolver 类型选择对应的生成器
* @param options - 解析后的配置选项
* @param buildTool - 构建工具类型
* @returns 生成的路由代码字符串
*/
function generateRoutes(options, buildTool) {
return generators[options.resolver](options, buildTool);
}
//#endregion
//#region src/index.ts
/**
* 虚拟模块 ID 映射表
* 根据不同的 resolver 类型提供不同的虚拟模块 ID
*/
const VIRTUAL_MODULE_IDS = {
vue: ["virtual:unplugin-convention-routes/vue", "~pages"],
react: ["virtual:unplugin-convention-routes/react", "~react-pages"]
};
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const createExactIdFilter = (ids) => new RegExp(`^(?:${ids.map(escapeRegExp).join("|")})$`);
/**
* unplugin 工厂函数
* 创建约定式路由插件的核心实现
* @param userOptions - 用户配置选项
* @param meta - unplugin 提供的元信息
* @param meta.framework - 构建工具框架类型
* @returns unplugin 插件对象
*/
const unpluginFactory = (userOptions, { framework }) => {
const options = resolveOptions(userOptions);
const buildTool = framework === "vite" ? "vite" : "rspack";
const virtualIds = VIRTUAL_MODULE_IDS[options.resolver];
const virtualIdFilter = createExactIdFilter(virtualIds);
return {
name: "unplugin-convention-routes",
/**
* 解析模块 ID
* 将虚拟模块 ID 转换为内部 ID
* @param id - 模块 ID
* @returns 解析后的模块 ID 或 null
*/
resolveId: {
filter: { id: virtualIdFilter },
handler(id) {
if (virtualIds.includes(id)) return virtualIds[0];
return null;
}
},
/**
* 加载模块内容
* 为虚拟模块生成路由代码
* @param id - 模块 ID
* @returns 生成的代码或 null
*/
load: {
filter: { id: virtualIdFilter },
handler(id) {
if (id === virtualIds[0]) return generateRoutes(options, buildTool);
return null;
}
}
};
};
/**
* 创建 unplugin 实例
*/
const unplugin = /* @__PURE__ */ createUnplugin(unpluginFactory);
//#endregion
export { unpluginFactory as n, unplugin as t };