vitePress实现原理(三)
(二)createVitePressPlugin函数
源码位置:src/node/plugins.ts
import path from 'path' // 导入路径模块
import c from 'picocolors' // 导入颜色模块用于控制台输出着色
import {
mergeConfig, // 合并配置函数
searchForWorkspaceRoot, // 查找工作区根目录函数
type ModuleNode, // Vite模块节点类型
type Plugin, // Vite插件类型
type ResolvedConfig, // 解析后的Vite配置类型
type Rollup, // Rollup打包工具类型
type UserConfig // 用户自定义Vite配置类型
} from 'vite'
import {
APP_PATH, // 应用程序路径常量
DIST_CLIENT_PATH, // 客户端分发路径常量
SITE_DATA_REQUEST_PATH, // 站点数据请求路径常量
resolveAliases // 解析别名函数
} from './alias'
import { resolvePages, resolveUserConfig, type SiteConfig } from './config' // 解析页面和用户配置函数及站点配置类型
import { disposeMdItInstance } from './markdown/markdown' // 清理Markdown实例函数
import {
clearCache, // 清除缓存函数
createMarkdownToVueRenderFn, // 创建Markdown转Vue渲染函数
type MarkdownCompileResult // Markdown编译结果类型
} from './markdownToVue'
import { dynamicRoutesPlugin } from './plugins/dynamicRoutesPlugin' // 动态路由插件
import { localSearchPlugin } from './plugins/localSearchPlugin' // 本地搜索插件
import { rewritesPlugin } from './plugins/rewritesPlugin' // 重写插件
import { staticDataPlugin } from './plugins/staticDataPlugin' // 静态数据插件
import { webFontsPlugin } from './plugins/webFontsPlugin' // Web字体插件
import { slash, type PageDataPayload } from './shared' // 斜杠处理函数及页面数据负载类型
import { deserializeFunctions, serializeFunctions } from './utils/fnSerialize' // 函数序列化和反序列化工具
declare module 'vite' {
interface UserConfig {
vitepress?: SiteConfig // 扩展Vite用户配置接口,添加vitepress字段
}
}
const themeRE = /\/\.vitepress\/theme\/index\.(m|c)?(j|t)s$/ // 主题文件正则表达式
const hashRE = /\.([-\w]+)\.js$/ // 哈希值正则表达式
const staticInjectMarkerRE =
/\b(const _hoisted_\d+ = \/\*(?:#|@)__PURE__\*\/\s*createStaticVNode)\("(.*)", (\d+)\)/g // 静态注入标记正则表达式
const staticStripRE = /['"`]__VP_STATIC_START__[^]*?__VP_STATIC_END__['"`]/g // 静态剥离正则表达式
const staticRestoreRE = /__VP_STATIC_(START|END)__/g // 静态恢复正则表达式
// 匹配MPA模式下的客户端JavaScript块。
const scriptClientRE = /<script\b[^>]*client\b[^>]*>([^]*?)<\/script>/
const isPageChunk = (
chunk: Rollup.OutputAsset | Rollup.OutputChunk
): chunk is Rollup.OutputChunk & { facadeModuleId: string } =>
!!(
chunk.type === 'chunk' && // 检查是否为chunk类型
chunk.isEntry && // 检查是否为主入口
chunk.facadeModuleId && // 检查是否有facadeModuleId
chunk.facadeModuleId.endsWith('.md') // 检查facadeModuleId是否以.md结尾
)
const cleanUrl = (url: string): string =>
url.replace(/#.*$/s, '').replace(/\?.*$/s, '') // 移除URL中的哈希和查询参数
export async function createVitePressPlugin(
siteConfig: SiteConfig,
ssr = false,
pageToHashMap?: Record<string, string>,
clientJSMap?: Record<string, string>,
recreateServer?: () => Promise<void>
) {
const {
srcDir,
configPath,
configDeps,
markdown,
site,
vue: userVuePluginOptions,
vite: userViteConfig,
pages,
lastUpdated,
cleanUrls
} = siteConfig
let markdownToVue: Awaited<ReturnType<typeof createMarkdownToVueRenderFn>> // Markdown转Vue渲染函数的结果
const userCustomElementChecker =
userVuePluginOptions?.template?.compilerOptions?.isCustomElement // 用户自定义元素检查器
let isCustomElement = userCustomElementChecker // 初始化自定义元素检查器
if (markdown?.math) {
isCustomElement = (tag) => {
if (tag.startsWith('mjx-')) {
return true
}
return userCustomElementChecker?.(tag) ?? false // 使用用户自定义元素检查器或默认返回false
}
}
// 懒加载导入plugin-vue以尊重NODE_ENV在@vue/compiler-x中的设置
const vuePlugin = await import('@vitejs/plugin-vue').then((r) =>
r.default({
include: [/\.vue$/, /\.md$/], // 包含.vue和.md文件
...userVuePluginOptions, // 合并用户Vue插件选项
template: {
...userVuePluginOptions?.template, // 合并模板选项
compilerOptions: {
...userVuePluginOptions?.template?.compilerOptions, // 合并编译器选项
isCustomElement // 设置自定义元素检查器
}
}
})
)
const processClientJS = (code: string, id: string) => {
return scriptClientRE.test(code)
? code.replace(scriptClientRE, (_, content) => {
if (ssr && clientJSMap) clientJSMap[id] = content // 如果是SSR并且有clientJSMap,则存储内容
return `\n`.repeat(_.split('\n').length - 1) // 替换为相同数量的新行
})
: code // 如果不匹配则返回原始代码
}
let siteData = site // 初始化站点数据
let allDeadLinks: MarkdownCompileResult['deadLinks'] = [] // 初始化所有死链数组
let config: ResolvedConfig // 解析后的Vite配置
let importerMap: Record<string, Set<string> | undefined> = {} // 导入映射
const vitePressPlugin: Plugin = {
name: 'vitepress', // 插件名称
async configResolved(resolvedConfig) {
// 触发时机:当Vite完成配置解析后调用
config = resolvedConfig // 设置解析后的配置
markdownToVue = await createMarkdownToVueRenderFn(
srcDir,
markdown,
pages,
config.command === 'build',
config.base,
lastUpdated,
cleanUrls,
siteConfig
) // 创建Markdown转Vue渲染函数
},
config() {
// 触发时机:在Vite读取配置文件时调用
const baseConfig: UserConfig = {
resolve: {
alias: resolveAliases(siteConfig, ssr) // 解析别名
},
define: {
__VP_LOCAL_SEARCH__: site.themeConfig?.search?.provider === 'local', // 是否启用本地搜索
__ALGOLIA__:
site.themeConfig?.search?.provider === 'algolia' ||
!!site.themeConfig?.algolia, // 是否启用Algolia搜索
__CARBON__: !!site.themeConfig?.carbonAds, // 是否启用Carbon广告
__ASSETS_DIR__: JSON.stringify(siteConfig.assetsDir), // 资源目录
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: !!process.env.DEBUG // Vue生产水合调试信息
},
optimizeDeps: {
// 强制包含vue以避免链接和优化时出现重复副本
include: [
'vue',
'vitepress > @vue/devtools-api',
'vitepress > @vueuse/core'
],
exclude: ['@docsearch/js', 'vitepress'] // 排除某些依赖
},
server: {
fs: {
allow: [
DIST_CLIENT_PATH,
srcDir,
searchForWorkspaceRoot(process.cwd())
] // 允许访问的文件系统路径
}
},
vitepress: siteConfig // VitePress配置
}
return userViteConfig
? mergeConfig(baseConfig, userViteConfig)
: baseConfig // 合并用户配置和基础配置
},
resolveId(id) {
// 触发时机:当Vite需要解析一个模块ID时调用
if (id === SITE_DATA_REQUEST_PATH) {
return SITE_DATA_REQUEST_PATH // 返回站点数据请求路径
}
},
load(id) {
// 触发时机:当Vite需要加载一个模块的内容时调用
if (id === SITE_DATA_REQUEST_PATH) {
let data = siteData
// 生产构建中客户端不需要头部信息
if (config.command === 'build') {
data = { ...siteData, head: [] } // 移除头部信息
// 在生产客户端构建中,数据内联到每个页面以避免配置更改使每个块失效
if (!ssr) {
return `export default window.__VP_SITE_DATA__` // 导出全局站点数据
}
}
data = serializeFunctions(data) // 序列化数据
return `${deserializeFunctions};export default deserializeFunctions(JSON.parse(${JSON.stringify(
JSON.stringify(data)
)}))` // 反序列化数据并导出
}
},
async transform(code, id) {
// 触发时机:当Vite需要转换一个模块的内容时调用
if (id.endsWith('.vue')) {
return processClientJS(code, id) // 处理客户端JavaScript
} else if (id.endsWith('.md')) {
// 将.md文件转换为vueSrc以便plugin-vue处理
const { vueSrc, deadLinks, includes } = await markdownToVue(
code,
id,
config.publicDir
)
allDeadLinks.push(...deadLinks) // 添加死链
if (includes.length) {
includes.forEach((i) => {
; (importerMap[slash(i)] ??= new Set()).add(id) // 更新导入映射
this.addWatchFile(i) // 添加监视文件
})
}
return processClientJS(vueSrc, id) // 处理客户端JavaScript
}
},
renderStart() {
// 触发时机:当Vite开始渲染页面时调用
if (allDeadLinks.length > 0) {
allDeadLinks.forEach(({ url, file }, i) => {
siteConfig.logger.warn(
c.yellow(
`${i === 0 ? '\n\n' : ''}(!) 找到死链 ${c.cyan(
url
)} 在文件 ${c.white(c.dim(file))}`
)
) // 输出警告信息
})
siteConfig.logger.info(
c.cyan(
'\n如果这是预期行为,您可以通过配置禁用此检查。参考:https://vitepress.dev/reference/site-config#ignoredeadlinks\n'
)
) // 提示如何禁用死链检查
throw new Error(`${allDeadLinks.length} 死链(s) 被找到。`) // 抛出错误
}
},
configureServer(server) {
// 触发时机:当Vite启动开发服务器时调用
// 监听文件变化 configPath 项目目录默认doc
if (configPath) {
server.watcher.add(configPath) // 添加配置路径监听
configDeps.forEach((file) => server.watcher.add(file)) // 添加配置依赖监听
}
const onFileAddDelete = async (added: boolean, _file: string) => {
const file = slash(_file)
// 当主题文件被创建或删除时重启服务器
if (themeRE.test(file)) {
siteConfig.logger.info(
c.green(
`${path.relative(process.cwd(), _file)} ${added ? '已创建' : '已删除'
},正在重启服务器...\n`
),
{ clear: true, timestamp: true }
) // 输出重启服务器信息
await recreateServer?.() // 重启服务器
}
// 当Markdown文件被创建或删除时更新页面、动态路由和重写规则
if (file.endsWith('.md')) {
Object.assign(
siteConfig,
await resolvePages(
siteConfig.srcDir,
siteConfig.userConfig,
siteConfig.logger
)
) // 更新页面配置
}
if (!added && importerMap[file]) {
delete importerMap[file] // 删除导入映射
}
}
server.watcher
.on('add', onFileAddDelete.bind(null, true))
.on('unlink', onFileAddDelete.bind(null, false)) // 绑定文件添加和删除事件
// 为服务器中间件添加自定义HTML响应
return () => {
server.middlewares.use(async (req, res, next) => {
const url = req.url && cleanUrl(req.url)
if (url?.endsWith('.html')) {
res.statusCode = 200
res.setHeader('Content-Type', 'text/html')
let html = `<!DOCTYPE html>
<html>
<head>
<title></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="">
</head>
<body>
<div id="app"></div>
<script type="module" src="/@fs/${APP_PATH}/index.js"></script>
</body>
</html>` // 构建基本HTML结构
html = await server.transformIndexHtml(url, html, req.originalUrl) // 转换HTML
res.end(html) // 发送响应
return
}
next() // 调用下一个中间件
})
}
},
renderChunk(code, chunk) {
// 触发时机:当Vite渲染一个代码块时调用
if (!ssr && isPageChunk(chunk as Rollup.OutputChunk)) {
// 对于每个页面块,
// 注入标记以标识静态字符串的开始和结束。
// 我们在这里这样做是因为在generateBundle阶段,
// 块会被压缩,我们无法安全地定位字符串。
// 使用正则表达式依赖于Vue编译器核心的特定输出,
// 这是一个合理的权衡,考虑到相对于完整AST解析的巨大性能提升。
code = code.replace(
staticInjectMarkerRE,
'$1("__VP_STATIC_START__$2__VP_STATIC_END__", $3)'
)
return code
}
return null
},
generateBundle(_options, bundle) {
// 触发时机:当Vite生成最终的构建包时调用
if (ssr) {
this.emitFile({
type: 'asset',
fileName: 'package.json',
source: '{ "private": true, "type": "module" }'
})
} else {
// 客户端构建:
// 对于每个.md入口块,调整其名称为其正确的路径。
for (const name in bundle) {
const chunk = bundle[name]
if (isPageChunk(chunk)) {
// 记录页面 -> 哈希关系
const hash = chunk.fileName.match(hashRE)![1]
pageToHashMap![chunk.name.toLowerCase()] = hash
// 注入另一个块,其中内容已被剥离
bundle[name + '-lean'] = {
...chunk,
fileName: chunk.fileName.replace(/\.js$/, '.lean.js'),
preliminaryFileName: chunk.preliminaryFileName.replace(
/\.js$/,
'.lean.js'
),
code: chunk.code.replace(staticStripRE, `""`)
}
// 从原始代码中移除静态标记
chunk.code = chunk.code.replace(staticRestoreRE, '')
}
}
}
},
async handleHotUpdate(ctx) {
// 触发时机:当Vite检测到文件热更新时调用
const { file, read, server } = ctx
if (file === configPath || configDeps.includes(file)) {
siteConfig.logger.info(
c.green(
`${path.relative(
process.cwd(),
file
)} changed, restarting server...\n`
),
{ clear: true, timestamp: true }
)
try {
await resolveUserConfig(siteConfig.root, 'serve', 'development')
} catch (err: any) {
siteConfig.logger.error(err)
return
}
disposeMdItInstance()
clearCache()
await recreateServer?.()
return
}
// 热重载 .md 文件为 .vue 文件
if (file.endsWith('.md')) {
const content = await read()
const { pageData, vueSrc } = await markdownToVue(
content,
file,
config.publicDir
)
const relativePath = slash(path.relative(srcDir, file))
const payload: PageDataPayload = {
path: `/${siteConfig.rewrites.map[relativePath] || relativePath}`,
pageData
}
// 通知客户端更新页面数据
server.ws.send({
type: 'custom',
event: 'vitepress:pageData',
data: payload
})
// 覆盖src以便Vue插件可以处理HMR
ctx.read = () => vueSrc
}
}
}
const hmrFix: Plugin = {
name: 'vitepress:hmr-fix',
async handleHotUpdate({ file, server, modules }) {
// 触发时机:当Vite检测到文件热更新时调用
const importers = [...(importerMap[slash(file)] || [])]
if (importers.length > 0) {
return [
...modules,
...importers.map((id) => {
clearCache(slash(path.relative(srcDir, id)))
return server.moduleGraph.getModuleById(id)
})
].filter(Boolean) as ModuleNode[]
}
}
}
return [
vitePressPlugin,
rewritesPlugin(siteConfig),
vuePlugin,
hmrFix,
webFontsPlugin(siteConfig.useWebFonts),
...(userViteConfig?.plugins || []),
await localSearchPlugin(siteConfig),
staticDataPlugin,
await dynamicRoutesPlugin(siteConfig)
]
}
- 配置解析和合并:
- 解析用户自定义的VitePress配置。
- 合并基础配置和用户自定义配置,生成最终的Vite配置。
- 主题文件处理:
- 监听主题文件的变化,并在发生变化时重启服务器以应用新的主题。
- Markdown文件处理:
- 将Markdown文件转换为Vue组件以便使用Vue插件进行进一步处理。
- 处理Markdown文件中的死链,并在检测到死链时输出警告信息。
- 静态资源处理:
- 在构建过程中对静态资源进行优化,包括注入标记以标识静态字符串的开始和结束。
- 生成精简版本的JavaScript文件(
.lean.js
),剥离不必要的静态内容。
- 热模块替换 (HMR):
- 支持Markdown文件的热重载,当Markdown文件发生变化时,更新页面数据并通过WebSocket通知客户端重新加载页面。
- 修复热更新问题,确保导入的模块能够正确地被清除和重新加载。
- 服务配置:
- 配置开发服务器,允许访问特定的文件系统路径。
- 添加自定义中间件以处理HTML请求,并返回基本的HTML结构。
- 插件集成:
- 集成多个插件,包括动态路由插件、本地搜索插件、重写插件、静态数据插件、Web字体插件等,以增强VitePress的功能。
- 日志记录:
- 提供详细的日志记录,包括警告信息和操作步骤,便于调试和监控。
- 缓存管理:
- 清除缓存以确保每次构建都是最新的状态。
[!NOTE]
configResolved
: 当Vite完成配置解析后调用,主要用于初始化和设置一些必要的变量和函数。config
: 在Vite读取配置文件时调用,用于合并基础配置和用户自定义配置。resolveId
: 当Vite需要解析一个模块ID时调用,主要用于处理特定路径的模块。load
: 当Vite需要加载一个模块的内容时调用,主要用于加载站点数据。transform
: 当Vite需要转换一个模块的内容时调用,主要用于处理Markdown文件并将其转换为Vue组件。renderStart
: 当Vite开始渲染页面时调用,主要用于检查和报告死链。configureServer
: 当Vite启动开发服务器时调用,主要用于配置开发服务器的行为和监听文件变化。renderChunk
: 当Vite渲染一个代码块时调用,主要用于在代码块中注入静态字符串的标记。generateBundle
: 当Vite生成最终的构建包时调用,主要用于生成精简版本的JavaScript文件。handleHotUpdate
: 当Vite检测到文件热更新时调用,主要用于处理Markdown文件的热重载。
1.createMarkdownToVueRenderFn函数
源码位置:src/node/markdownToVue.ts
import { resolveTitleFromToken } from '@mdit-vue/shared'
import _debug from 'debug'
import fs from 'fs-extra'
import { LRUCache } from 'lru-cache'
import path from 'path'
import type { SiteConfig } from './config'
import {
createMarkdownRenderer,
type MarkdownOptions,
type MarkdownRenderer
} from './markdown/markdown'
import {
EXTERNAL_URL_RE,
getLocaleForPath,
slash,
treatAsHtml,
type HeadConfig,
type MarkdownEnv,
type PageData
} from './shared'
import { getGitTimestamp } from './utils/getGitTimestamp'
import { processIncludes } from './utils/processIncludes'
const debug = _debug('vitepress:md')
const cache = new LRUCache<string, MarkdownCompileResult>({ max: 1024 })
export interface MarkdownCompileResult {
vueSrc: string // 转换后的Vue源代码
pageData: PageData // 页面数据
deadLinks: { url: string; file: string }[] // 死链信息
includes: string[] // 包含的文件路径
}
export function clearCache(file?: string) {
if (!file) {
cache.clear() // 清除所有缓存
return
}
file = JSON.stringify({ file }).slice(1)
cache.find((_, key) => key.endsWith(file!) && cache.delete(key)) // 清除特定文件的缓存
}
export async function createMarkdownToVueRenderFn(
srcDir: string,
options: MarkdownOptions = {},
pages: string[],
isBuild = false,
base = '/',
includeLastUpdatedData = false,
cleanUrls = false,
siteConfig: SiteConfig | null = null
) {
const md = await createMarkdownRenderer(
srcDir,
options,
base,
siteConfig?.logger
) // 创建Markdown渲染器
pages = pages.map((p) => slash(p.replace(/\.md$/, ''))) // 处理页面路径
const dynamicRoutes = new Map(
siteConfig?.dynamicRoutes?.routes.map((r) => [
r.fullPath,
slash(path.join(srcDir, r.route))
]) || []
) // 处理解析动态路由
const rewrites = new Map(
Object.entries(siteConfig?.rewrites.map || {}).map(([key, value]) => [
slash(path.join(srcDir, key)),
slash(path.join(srcDir, value!))
]) || []
) // 处理解析重写规则
return async (
src: string,
file: string,
publicDir: string
): Promise<MarkdownCompileResult> => {
const fileOrig = dynamicRoutes.get(file) || file // 获取原始文件路径
file = rewrites.get(file) || file // 获取重写后的文件路径
const relativePath = slash(path.relative(srcDir, file)) // 获取相对路径
const cacheKey = JSON.stringify({ src, file: relativePath })
if (isBuild || options.cache !== false) {
const cached = cache.get(cacheKey)
if (cached) {
debug(`[cache hit] ${relativePath}`) // 使用缓存
return cached
}
}
const start = Date.now()
// resolve params for dynamic routes
let params
src = src.replace(
/^__VP_PARAMS_START([^]+?)__VP_PARAMS_END__/,
(_, paramsString) => {
params = JSON.parse(paramsString) // 解析动态路由参数
return ''
}
)
// resolve includes
let includes: string[] = []
src = processIncludes(srcDir, src, fileOrig, includes) // 处理包含的文件
const localeIndex = getLocaleForPath(siteConfig?.site, relativePath) // 获取语言索引
// reset env before render
const env: MarkdownEnv = {
path: file,
relativePath,
cleanUrls,
includes,
realPath: fileOrig,
localeIndex
}
const html = md.render(src, env) // 渲染Markdown为HTML
const {
frontmatter = {},
headers = [],
links = [],
sfcBlocks,
title = ''
} = env
// validate data.links
const deadLinks: MarkdownCompileResult['deadLinks'] = []
const recordDeadLink = (url: string) => {
deadLinks.push({ url, file: path.relative(srcDir, fileOrig) }) // 记录死链
}
function shouldIgnoreDeadLink(url: string) {
if (!siteConfig?.ignoreDeadLinks) {
return false
}
if (siteConfig.ignoreDeadLinks === true) {
return true
}
if (siteConfig.ignoreDeadLinks === 'localhostLinks') {
return url.replace(EXTERNAL_URL_RE, '').startsWith('//localhost')
}
return siteConfig.ignoreDeadLinks.some((ignore) => {
if (typeof ignore === 'string') {
return url === ignore
}
if (ignore instanceof RegExp) {
return ignore.test(url)
}
if (typeof ignore === 'function') {
return ignore(url)
}
return false
})
}
if (links) {
const dir = path.dirname(file)
for (let url of links) {
const { pathname } = new URL(url, 'http://a.com')
if (!treatAsHtml(pathname)) continue
url = url.replace(/[?#].*$/, '').replace(/\.(html|md)$/, '')
if (url.endsWith('/')) url += `index`
let resolved = decodeURIComponent(
slash(
url.startsWith('/')
? url.slice(1)
: path.relative(srcDir, path.resolve(dir, url))
)
)
resolved =
siteConfig?.rewrites.inv[resolved + '.md']?.slice(0, -3) || resolved
if (
!pages.includes(resolved) &&
!fs.existsSync(path.resolve(dir, publicDir, `${resolved}.html`)) &&
!shouldIgnoreDeadLink(url)
) {
recordDeadLink(url) // 检查并记录死链
}
}
}
let pageData: PageData = {
title: inferTitle(md, frontmatter, title), // 推断标题
titleTemplate: frontmatter.titleTemplate as any,
description: inferDescription(frontmatter), // 推断描述
frontmatter,
headers,
params,
relativePath,
filePath: slash(path.relative(srcDir, fileOrig))
}
if (includeLastUpdatedData && frontmatter.lastUpdated !== false) {
if (frontmatter.lastUpdated instanceof Date) {
pageData.lastUpdated = +frontmatter.lastUpdated
} else {
pageData.lastUpdated = await getGitTimestamp(fileOrig) // 获取最后更新时间
}
}
if (siteConfig?.transformPageData) {
const dataToMerge = await siteConfig.transformPageData(pageData, {
siteConfig
})
if (dataToMerge) {
pageData = {
...pageData,
...dataToMerge
}
}
}
const vueSrc = [
...injectPageDataCode(
sfcBlocks?.scripts.map((item) => item.content) ?? [],
pageData
),
`<template><div>${html}</div></template>`, // 生成Vue模板
...(sfcBlocks?.styles.map((item) => item.content) ?? []),
...(sfcBlocks?.customBlocks.map((item) => item.content) ?? [])
].join('\n')
debug(`[render] ${file} in ${Date.now() - start}ms.`) // 记录渲染时间
const result = {
vueSrc,
pageData,
deadLinks,
includes
}
if (isBuild || options.cache !== false) {
cache.set(cacheKey, result) // 设置缓存
}
return result
}
}
const scriptRE = /<\/script>/
const scriptLangTsRE = /<\s*script[^>]*\blang=['"]ts['"][^>]*/
const scriptSetupRE = /<\s*script[^>]*\bsetup\b[^>]*/
const scriptClientRE = /<\s*script[^>]*\bclient\b[^>]*/
const defaultExportRE = /((?:^|\n|;)\s*)export(\s*)default/
const namedDefaultExportRE = /((?:^|\n|;)\s*)export(.+)as(\s*)default/
function injectPageDataCode(tags: string[], data: PageData) {
const code = `\nexport const __pageData = JSON.parse(${JSON.stringify(
JSON.stringify(data)
)})`
const existingScriptIndex = tags.findIndex((tag) => {
return (
scriptRE.test(tag) &&
!scriptSetupRE.test(tag) &&
!scriptClientRE.test(tag)
)
})
const isUsingTS = tags.findIndex((tag) => scriptLangTsRE.test(tag)) > -1
if (existingScriptIndex > -1) {
const tagSrc = tags[existingScriptIndex]
// user has <script> tag inside markdown
// if it doesn't have export default it will error out on build
const hasDefaultExport =
defaultExportRE.test(tagSrc) || namedDefaultExportRE.test(tagSrc)
tags[existingScriptIndex] = tagSrc.replace(
scriptRE,
code +
(hasDefaultExport
? ``
: `\nexport default {name:${JSON.stringify(data.relativePath)}}`) +
`</script>`
)
} else {
tags.unshift(
`<script ${
isUsingTS ? 'lang="ts"' : ''
}>${code}\nexport default {name:${JSON.stringify(
data.relativePath
)}}</script>`
)
}
return tags
}
const inferTitle = (
md: MarkdownRenderer,
frontmatter: Record<string, any>,
title: string
) => {
if (typeof frontmatter.title === 'string') {
const titleToken = md.parseInline(frontmatter.title, {})[0]
if (titleToken) {
return resolveTitleFromToken(titleToken, {
shouldAllowHtml: false,
shouldEscapeText: false
})
}
}
return title // 推断标题
}
const inferDescription = (frontmatter: Record<string, any>) => {
const { description, head } = frontmatter
if (description !== undefined) {
return description
}
return (head && getHeadMetaContent(head, 'description')) || '' // 推断描述
}
const getHeadMetaContent = (
head: HeadConfig[],
name: string
): string | undefined => {
if (!head || !head.length) {
return undefined
}
const meta = head.find(([tag, attrs = {}]) => {
return tag === 'meta' && attrs.name === name && attrs.content
})
return meta && meta[1].content // 获取<head>中的<meta>内容
}
1.1createMarkdownRenderer函数
源码位置:src/node/markdown/markdown.ts
import {
componentPlugin,
type ComponentPluginOptions
} from '@mdit-vue/plugin-component'
import {
frontmatterPlugin,
type FrontmatterPluginOptions
} from '@mdit-vue/plugin-frontmatter'
import {
headersPlugin,
type HeadersPluginOptions
} from '@mdit-vue/plugin-headers'
import { sfcPlugin, type SfcPluginOptions } from '@mdit-vue/plugin-sfc'
import { titlePlugin } from '@mdit-vue/plugin-title'
import { tocPlugin, type TocPluginOptions } from '@mdit-vue/plugin-toc'
import { slugify } from '@mdit-vue/shared'
import type { Options } from 'markdown-it'
import MarkdownIt from 'markdown-it'
import anchorPlugin from 'markdown-it-anchor'
import attrsPlugin from 'markdown-it-attrs'
import { full as emojiPlugin } from 'markdown-it-emoji'
import type { BuiltinTheme, Highlighter } from 'shiki'
import type {
LanguageInput,
ShikiTransformer,
ThemeRegistrationAny
} from '@shikijs/types'
import type { Logger } from 'vite'
import { containerPlugin, type ContainerOptions } from './plugins/containers'
import { gitHubAlertsPlugin } from './plugins/githubAlerts'
import { highlight as createHighlighter } from './plugins/highlight'
import { highlightLinePlugin } from './plugins/highlightLines'
import { imagePlugin, type Options as ImageOptions } from './plugins/image'
import { lineNumberPlugin } from './plugins/lineNumbers'
import { linkPlugin } from './plugins/link'
import { preWrapperPlugin } from './plugins/preWrapper'
import { restoreEntities } from './plugins/restoreEntities'
import { snippetPlugin } from './plugins/snippet'
export type { Header } from '../shared'
export type ThemeOptions =
| ThemeRegistrationAny
| BuiltinTheme
| {
light: ThemeRegistrationAny | BuiltinTheme
dark: ThemeRegistrationAny | BuiltinTheme
}
export interface MarkdownOptions extends Options {
/* ==================== General Options ==================== */
/**
* Setup markdown-it instance before applying plugins
*/
preConfig?: (md: MarkdownIt) => void
/**
* Setup markdown-it instance
*/
config?: (md: MarkdownIt) => void
/**
* Disable cache (experimental)
*/
cache?: boolean
externalLinks?: Record<string, string>
/* ==================== Syntax Highlighting ==================== */
/**
* Custom theme for syntax highlighting.
*
* You can also pass an object with `light` and `dark` themes to support dual themes.
*
* @example { theme: 'github-dark' }
* @example { theme: { light: 'github-light', dark: 'github-dark' } }
*
* You can use an existing theme.
* @see https://shiki.style/themes
* Or add your own theme.
* @see https://shiki.style/guide/load-theme
*/
theme?: ThemeOptions
/**
* Languages for syntax highlighting.
* @see https://shiki.style/languages
*/
languages?: LanguageInput[]
/**
* Custom language aliases.
*
* @example { 'my-lang': 'js' }
* @see https://shiki.style/guide/load-lang#custom-language-aliases
*/
languageAlias?: Record<string, string>
/**
* Show line numbers in code blocks
* @default false
*/
lineNumbers?: boolean
/**
* Fallback language when the specified language is not available.
*/
defaultHighlightLang?: string
/**
* Transformers applied to code blocks
* @see https://shiki.style/guide/transformers
*/
codeTransformers?: ShikiTransformer[]
/**
* Setup Shiki instance
*/
shikiSetup?: (shiki: Highlighter) => void | Promise<void>
/**
* The tooltip text for the copy button in code blocks
* @default 'Copy Code'
*/
codeCopyButtonTitle?: string
/* ==================== Markdown It Plugins ==================== */
/**
* Options for `markdown-it-anchor`
* @see https://github.com/valeriangalliat/markdown-it-anchor
*/
anchor?: anchorPlugin.AnchorOptions
/**
* Options for `markdown-it-attrs`
* @see https://github.com/arve0/markdown-it-attrs
*/
attrs?: {
leftDelimiter?: string
rightDelimiter?: string
allowedAttributes?: Array<string | RegExp>
disable?: boolean
}
/**
* Options for `markdown-it-emoji`
* @see https://github.com/markdown-it/markdown-it-emoji
*/
emoji?: {
defs?: Record<string, string>
enabled?: string[]
shortcuts?: Record<string, string | string[]>
}
/**
* Options for `@mdit-vue/plugin-frontmatter`
* @see https://github.com/mdit-vue/mdit-vue/tree/main/packages/plugin-frontmatter
*/
frontmatter?: FrontmatterPluginOptions
/**
* Options for `@mdit-vue/plugin-headers`
* @see https://github.com/mdit-vue/mdit-vue/tree/main/packages/plugin-headers
*/
headers?: HeadersPluginOptions | boolean
/**
* Options for `@mdit-vue/plugin-sfc`
* @see https://github.com/mdit-vue/mdit-vue/tree/main/packages/plugin-sfc
*/
sfc?: SfcPluginOptions
/**
* Options for `@mdit-vue/plugin-toc`
* @see https://github.com/mdit-vue/mdit-vue/tree/main/packages/plugin-toc
*/
toc?: TocPluginOptions
/**
* Options for `@mdit-vue/plugin-component`
* @see https://github.com/mdit-vue/mdit-vue/tree/main/packages/plugin-component
*/
component?: ComponentPluginOptions
/**
* Options for `markdown-it-container`
* @see https://github.com/markdown-it/markdown-it-container
*/
container?: ContainerOptions
/**
* Math support
*
* You need to install `markdown-it-mathjax3` and set `math` to `true` to enable it.
* You can also pass options to `markdown-it-mathjax3` here.
* @default false
* @see https://vitepress.dev/guide/markdown#math-equations
*/
math?: boolean | any
image?: ImageOptions
/**
* Allows disabling the github alerts plugin
* @default true
* @see https://vitepress.dev/guide/markdown#github-flavored-alerts
*/
gfmAlerts?: boolean
}
export type MarkdownRenderer = MarkdownIt
let md: MarkdownRenderer | undefined
let _disposeHighlighter: (() => void) | undefined
export function disposeMdItInstance() {
if (md) {
md = undefined
_disposeHighlighter?.()
}
}
/**
* @experimental
*/
export async function createMarkdownRenderer(
srcDir: string,
options: MarkdownOptions = {},
base = '/',
logger: Pick<Logger, 'warn'> = console
): Promise<MarkdownRenderer> {
if (md) return md // 如果已经存在Markdown实例,则直接返回
const theme = options.theme ?? { light: 'github-light', dark: 'github-dark' } // 设置主题,默认为github-light和github-dark
const codeCopyButtonTitle = options.codeCopyButtonTitle || 'Copy Code' // 设置代码复制按钮的提示文本
const hasSingleTheme = typeof theme === 'string' || 'name' in theme // 判断是否有单一主题
let [highlight, dispose] = options.highlight
? [options.highlight, () => { }]
: await createHighlighter(theme, options, logger) // 创建高亮器
_disposeHighlighter = dispose // 设置高亮器的清理函数
md = MarkdownIt({ html: true, linkify: true, highlight, ...options }) // 创建Markdown解析器实例
md.linkify.set({ fuzzyLink: false }) // 禁用模糊链接识别
md.use(restoreEntities) // 恢复实体字符
if (options.preConfig) {
options.preConfig(md) // 在应用插件之前调用用户配置函数
}
// 自定义插件
md.use(componentPlugin, { ...options.component }) // 使用组件插件
.use(highlightLinePlugin) // 使用高亮行插件
.use(preWrapperPlugin, { codeCopyButtonTitle, hasSingleTheme }) // 使用预处理器包装插件
.use(snippetPlugin, srcDir) // 使用代码片段插件
.use(containerPlugin, { hasSingleTheme }, options.container) // 使用容器插件
.use(imagePlugin, options.image) // 使用图像插件
.use(linkPlugin, { target: '_blank', rel: 'noreferrer', ...options.externalLinks }, base) // 使用链接插件
.use(lineNumberPlugin, options.lineNumbers) // 使用行号插件
md.renderer.rules.table_open = function (tokens, idx, options, env, self) {
return '<table tabindex="0">\n' // 为表格添加tabindex属性
}
if (options.gfmAlerts !== false) {
md.use(gitHubAlertsPlugin) // 使用GitHub风格警告插件
}
// 第三方插件
if (!options.attrs?.disable) {
md.use(attrsPlugin, options.attrs) // 使用属性插件
}
md.use(emojiPlugin, { ...options.emoji }) // 使用表情符号插件
// mdit-vue 插件
md.use(anchorPlugin, {
slugify,
permalink: anchorPlugin.permalink.linkInsideHeader({
symbol: '​',
renderAttrs: (slug, state) => {
// 找到与slug匹配的heading_open令牌
const idx = state.tokens.findIndex((token) => {
const attrs = token.attrs
const id = attrs?.find((attr) => attr[0] === 'id')
return id && slug === id[1]
})
// 获取实际的标题内容
const title = state.tokens[idx + 1].content
return {
'aria-label': `Permalink to "${title}"`
}
}
}),
...options.anchor
} as anchorPlugin.AnchorOptions).use(frontmatterPlugin, {
...options.frontmatter
} as FrontmatterPluginOptions)
if (options.headers) {
md.use(headersPlugin, {
level: [2, 3, 4, 5, 6],
slugify,
...(typeof options.headers === 'boolean' ? undefined : options.headers)
} as HeadersPluginOptions)
}
md.use(sfcPlugin, {
...options.sfc
} as SfcPluginOptions)
.use(titlePlugin)
.use(tocPlugin, {
...options.toc
} as TocPluginOptions)
if (options.math) {
try {
const mathPlugin = await import('markdown-it-mathjax3')
md.use(mathPlugin.default ?? mathPlugin, {
...(typeof options.math === 'boolean' ? {} : options.math)
})
const orig = md.renderer.rules.math_block!
md.renderer.rules.math_block = (tokens, idx, options, env, self) => {
return orig(tokens, idx, options, env, self).replace(
/^<mjx-container /,
'<mjx-container tabindex="0" '
)
}
} catch (error) {
throw new Error(
'You need to install `markdown-it-mathjax3` to use math support.'
)
}
}
// 应用用户配置
if (options.config) {
options.config(md)
}
return md
}
- Markdown 解析器创建:
- 创建并配置一个
MarkdownIt
实例,用于解析 Markdown 文档。 - 支持多种自定义选项,包括语法高亮、插件配置等。
- 创建并配置一个
- 插件集成:
- 集成了多个内置插件,如
componentPlugin
,frontmatterPlugin
,headersPlugin
,sfcPlugin
,tocPlugin
,anchorPlugin
,attrsPlugin
,emojiPlugin
,containerPlugin
,imagePlugin
,linkPlugin
,preWrapperPlugin
,snippetPlugin
,highlightLinePlugin
,lineNumberPlugin
, 和gitHubAlertsPlugin
。 - 支持第三方插件,如
markdown-it-attrs
和markdown-it-emoji
。
- 集成了多个内置插件,如
- 语法高亮:
- 使用
Shiki
进行语法高亮,支持多种主题和语言。 - 提供代码行号显示和代码复制按钮功能。
- 使用
- 扩展性:
- 允许用户通过
preConfig
和config
选项自定义 Markdown 解析器。 - 支持数学公式渲染(需要安装
markdown-it-mathjax3
)。
- 允许用户通过
- 缓存管理:
- 提供
disposeMdItInstance
函数来释放资源,确保每次构建都是最新的状态。
- 提供
- 辅助功能:
- 提供多种辅助函数来处理 Markdown 内容,如
restoreEntities
和highlightLinePlugin
- 提供多种辅助函数来处理 Markdown 内容,如
2.resolveAliases函数
下节揭晓