发现问题
最近开发vitepress-pure-theme-zyco主题时,主题侧的site-config文件是用ts写的,用户使用主题时,需要引入相应的配置文件。
主题包在package.json中申明exports和typesVersions字段
{
"exports": {
"./base": "./src/site-config/config.ts"
},
"typesVersions": {
"*": {
"base": [
"./src/site-config/config.ts"
]
}
}
}
用户站点配置文件.vitepress/config.mts引入主题配置
import { defineConfigWithTheme } from "vitepress";
import type { PureThemeConfig } from "vitepress-pure-theme-zyco/config";
import { baseConfig } from "vitepress-pure-theme-zyco/base";
export default defineConfigWithTheme<PureThemeConfig>({
extends: baseConfig,
themeConfig: {
...
},
});
用户对站点进行打包(vitepress build doc),产生Unknown file extension ".ts"的报错
研究原理
用户侧配置文件能使用ts,主题包的配置文件使用ts却报错,下面来研究一下为何会出现这种情况。
入口文件
使用vitepress build打包时,首先执行的是bin/vitepress.js
#!/usr/bin/env node
import('../dist/node/cli.js')
这里执行的是打包后的cli.js,所以我们直接看cli的源(ts)文件
vitepress/src/node/cli.ts
// 关注重点代码片段,忽略次要逻辑
if (command === 'build') {
// 调用了build方法
build(root, argv).catch((err) => {
...
})
}
vitepress/src/node/build/build.ts
export async function build(
root?: string,
buildOptions: BuildOptions & { base?: string; mpa?: string } = {}
) {
...
process.env.NODE_ENV = 'production'
// 开始执行解析配置逻辑
const siteConfig = await resolveConfig(root, 'build', 'production')
...
}
vitepress/src/node/config.ts
export async function resolveConfig(
root: string = process.cwd(),
command: 'serve' | 'build' = 'serve',
mode = 'development'
): Promise<SiteConfig> {
// normalize root into absolute path
root = normalizePath(path.resolve(root))
// 进一步交给resolveUserConfig处理
const [userConfig, configPath, configDeps] = await resolveUserConfig(
root,
command,
mode
)
...
}
export async function resolveUserConfig(
root: string,
command: 'serve' | 'build',
mode: string
): Promise<[UserConfig, string | undefined, string[]]> {
// 这里在加载用户站点自己的config
// 这里的supportedConfigExtensions是一个数组 ['js', 'ts', 'mjs', 'mts']
// 这也解释了为什么用户侧的配置文件可以支持多种文件后缀
const configPath = supportedConfigExtensions
.flatMap((ext) => [
resolve(root, `config/index.${ext}`),
resolve(root, `config.${ext}`)
])
.find(fs.pathExistsSync)
let userConfig: RawConfigExports = {}
let configDeps: string[] = []
if (!configPath) {
debug(`no config file found.`)
} else {
// loadConfigFromFile来自vite
const configExports = await loadConfigFromFile(
{ command, mode },
configPath,
root
)
...
}
...
}
因为关键的loadConfigFromFile方法来自vite,因此就需要再去研究vite的源码了。
vite
vite/packages/vite/src/node/config.ts
export async function loadConfigFromFile(
configEnv: ConfigEnv,
configFile?: string,
configRoot: string = process.cwd(),
logLevel?: LogLevel,
customLogger?: Logger,
): Promise<{
path: string
config: UserConfig
dependencies: string[]
} | null> {
...
try {
const bundled = await bundleConfigFile(resolvedPath, isESM)
const userConfig = await loadConfigFromBundledFile(
resolvedPath,
bundled.code,
isESM,
)
debug?.(`bundled config file loaded in ${getTime()}`)
}catch(e){
// 这里就是上面Unknown file extension错误抛出的地方
// 那么产生错误的原因就要关注上面的两个方法了
createLogger(logLevel, { customLogger }).error(
colors.red(`failed to load config from ${resolvedPath}`),
{
error: e,
},
)
throw e
}
...
}
bundleConfigFile
async function bundleConfigFile(
fileName: string,
isESM: boolean,
): Promise<{ code: string; dependencies: string[] }> {
...
// 这里的build方法来自esbuild
// fileName是用户侧自定义的配置文件config.mts
// 也就是说,vite在依靠esbuild打包配置文件,将ts写的配置文件编译成js
const result = await build({
absWorkingDir: process.cwd(),
entryPoints: [fileName],
write: false,
target: [`node${process.versions.node}`],
platform: 'node',
bundle: true,
format: isESM ? 'esm' : 'cjs',
mainFields: ['main'],
sourcemap: 'inline',
metafile: true,
define: {
...
},
plugins: [
// vite自定义了一些esbuild插件
// 重点关注该插件
{
name: 'externalize-deps',
setup(build) {
...
build.onResolve(
{ filter: /^[^.].*/ },
async ({ path: id, importer, kind
}) => {
// 在解析文件import的依赖时,这里的回调函数执行,重点关注返回值
// idFsPath已经被处理成“file://xxx”格式的文件路径
// external为true意味着用户配置文件导入的第三方包被标记成了外部依赖
// 那么在用户配置文件中,
// import { baseConfig } from "vitepress-pure-theme-zyco/base" 实际指向的文件并不会被打包进来
// 而是保留成 import { baseConfig } from “file://xxx”
return {
path: idFsPath,
external: true,
}
})
}
},
...
]
})
const { text } = result.outputFiles[0]
return {
// 把打包后的文件内容返回
code: text,
dependencies: result.metafile ? Object.keys(result.metafile.inputs) : [],
}
}
loadConfigFromBundledFile 处理打包后的代码
async function loadConfigFromBundledFile(
fileName: string,
bundledCode: string,
isESM: boolean,
): Promise<UserConfigExport> {
...
// 这里就是把打包后的用户配置文件内容写入临时文件中
if (isESM) {
const fileBase = `${fileName}.timestamp-${Date.now()}-${Math.random()
.toString(16)
.slice(2)}`
const fileNameTmp = `${fileBase}.mjs`
const fileUrl = `${pathToFileURL(fileBase)}.mjs`
await fsp.writeFile(fileNameTmp, bundledCode)
try {
// 动态导入包含打包内容的临时文件
return (await import(fileUrl)).default
} finally {
fs.unlink(fileNameTmp, () => {})
}
}
...
}
到这里,应该明白为何会报错了吧?
因为bundleConfigFile方法,通过esbuild打包用户的配置文件,但是并未把第三方包的文件打包进来,而是保留了文件路径,交给NodeJS运行时解析执行
而NodeJS(v18.19.0)并未原生支持ts,所以在执行vitepress build命令行逻辑时,发现用户配置文件import了一个ts文件,导致打包失败
解决方案
既然知道了报错原因,那么解决方法就好想到了。
将主题包的ts配置文件拆分成 js + d.ts,保留类型定义,用户使用你开发的包时,代码提示会更加友好。
修改主题包的package.json
{
"exports": {
"./base": "./src/site-config/config.js"
},
"typesVersions": {
"*": {
"base": [
"./src/site-config/config.d.ts"
]
}
}
}
用户配置文件.vitepress/config.mts引入主题配置
...
// vitepress build打包该文件后,这里的第三方包名将替换成文件路径“file://xx/xx.js”,因为是js文件,node执行自然就不会报错了
import { baseConfig } from "vitepress-pure-theme-zyco/base";
export default defineConfigWithTheme<PureThemeConfig>({
extends: baseConfig,
themeConfig: {
...
},
});