vitepress主题开发--站点配置config.mts导入主题配置ts文件报错Unknown file extension

446 阅读3分钟

发现问题

最近开发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: {
    ...
  },
});