在构建时而非运行时编译 Markdown

本文最后更新于:2025年5月16日 凌晨

背景

最近重构了个人主站,添加了作品集和博客部分,它们都使用 markdown 来编写内容。而直接引入 react-markdown [1] 组件在运行时编译 markdown 不仅成本较高,要添加一系列的 unified 依赖来进行编译 markdown,还要引入相当大的 shikijs [2] 来实现代码高亮。经过一些快速测试,打算尝试使用预编译 markdown 为 html 的方式来解决这个问题。

调研

首先,吾辈尝试了几个现有的工具。

  • mdx-js: 就吾辈的场景而言,完全不需要 markdown 与 react 交互性,而且绑定 react 会导致一些其他问题,例如吾辈后续还希望在 svelte 项目中使用
  • vite-plugin-markdown: 不幸的是,基于 markdown-it 而非 mdast 系列,扩展起来更加困难
  • vite-plugin-md: 仅支持 vue,不支持 react 中使用

而且由于吾辈还需要在编译时就获取 markdown 的一些元数据,例如 frontmatter/toc 等等,所以最终考虑基于 unified.js 自行封装 vite 插件来处理。

实现

基本上,吾辈决定遵循 vite 的惯例 [3],即通过 import query 来支持不同的导入,例如

1
2
3
4
import frontmatter from './README.md?frontmatter' // 导入 frontmatter
import toc from './README.md?toc' // 导入 toc 大纲
import html from './README.md?html' // 导入编译后的 html
import ReactComponent from './README.md?react' // 导入编译后的 react 组件

实现思路

flow.excalidraw.svg

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
import type { Plugin } from 'vite'
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkFm from 'remark-frontmatter'
import rehypeStringify from 'rehype-stringify'
import { toHast } from 'mdast-util-to-hast'
import { select, selectAll } from 'unist-util-select'
import { remove } from 'unist-util-remove'
import rehypeShiki from '@shikijs/rehype'
import rehypeReact from 'rehype-react'
import { readFile } from 'node:fs/promises'
import type { Heading, Yaml } from 'mdast'
import type { Root } from 'hast'
import type { JSX } from 'react/jsx-runtime'
import * as production from 'react/jsx-runtime'

function resolveId(id: string):
| {
type: 'frontmatter' | 'toc' | 'html' | 'react'
path: string
}
| undefined {
if (id.endsWith('.md?frontmatter')) {
return {
type: 'frontmatter',
path: id.slice(0, -'?frontmatter'.length),
}
} else if (id.endsWith('.md?toc')) {
return {
type: 'toc',
path: id.slice(0, -'?toc'.length),
}
} else if (id.endsWith('.md?html')) {
return {
type: 'html',
path: id.slice(0, -'?html'.length),
}
} else if (id.endsWith('.md?react')) {
return {
type: 'react',
path: id.slice(0, -'?react'.length),
}
}
}

type TransformCache = {
frontmatter: string
toc: string
html: string
react: string
}

interface TocItem {
id: string
text: string
level: number
children?: TocItem[]
}

function convertToTocItem(heading: Heading): TocItem {
const text = toString(heading.children[0])
const id = slug(text)
return {
id,
text,
level: heading.depth,
}
}

function markdownToc(md: Root): TocItem[] {
const headings = selectAll('heading', md) as Heading[]
const root: TocItem[] = []
const stack: TocItem[] = []

for (const heading of headings) {
const item = convertToTocItem(heading)
while (stack.length > 0 && stack[stack.length - 1].level >= item.level) {
stack.pop()
}
if (stack.length === 0) {
root.push(item)
} else {
const parent = stack[stack.length - 1]
if (!parent.children) {
parent.children = []
}
parent.children.push(item)
}
stack.push(item)
}

return root
}

async function transform(raw: string): Promise<TransformCache> {
const root = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkFm)
.parse(raw)
const yaml = select('yaml', root) as Yaml
const frontmatter = yaml?.data ?? {}
remove(root, 'yaml')
const toc = markdownToc(root)
const hast = toHast(root) as Root
const html = unified()
.use(rehypeShiki, {
theme: 'github-dark',
} satisfies Parameters<typeof rehypeShiki>[0])
.use(rehypeStringify)
.stringify(hast)
const file = await unified()
.use(rehypeShiki, {
theme: 'github-dark',
} satisfies Parameters<typeof rehypeShiki>[0])
.use(rehypeReact, production)
.stringify(hast)
const jsx = stringifyJsx(file)
return {
frontmatter: `export default ${JSON.stringify(frontmatter)}`,
toc: `export default ${JSON.stringify(toc)}`,
html: `export default ${JSON.stringify(html)}`,
react: `import React from "react"\nconst ReactComponent = () => ${jsx};\nexport default ReactComponent`,
}
}

export function markdown(): Plugin {
const map: Record<string, TransformCache> = {}

return {
name: 'vite-plugin-markdown',
async transform(_code, id) {
const resolved = resolveId(id)
if (!resolved) {
return
}
const { type, path } = resolved
if (map[path]) {
return map[path][type]
}
const raw = await readFile(path, 'utf-8')
const cache = await transform(raw)
map[path] = cache
return cache[type]
},
}
}

类型定义

要在 TypeScript 中使用,还需要在 vite-env.d.ts 中添加一些额外的类型定义,让 TypeScript 能正确识别特定文件名及后缀。[4]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
declare module '*.md?frontmatter' {
const frontmatter: Record<string, any>
export default frontmatter
}

declare module '*.md?toc' {
interface TocItem {
id: string
text: string
level: number
children?: TocItem[]
}

const toc: TocItem[]
export default toc
}

declare module '*.md?html' {
const html: string
export default html
}

declare module '*.md?react' {
import { ComponentType } from 'react'
const Component: ComponentType
export default Component
}

问题

这里碰到了一个问题,如何将转换 markdown 为编译后的 jsx。例如

1
2
3
# title

content

希望得到的是

1
2
3
4
5
6
7
8
9
10
11
import React from 'react'

const ReactComponent = () =>
React.createElement(
React.Fragment,
null,
React.createElement('h1', { id: 'title' }, 'title'),
React.createElement('p', null, 'content'),
)

export default ReactComponent

是的,吾辈尝试先将 markdown 转换为 html,然后使用 esbuild 编译 jsx。不幸的是,html 与 jsx 不完全兼容。即便解决了 html/jsx 兼容问题,再将 jsx 编译为 js 时仍然可能存在问题,例如 react-element-to-jsx-string [5] 是一个常见的包,但它也存在一些问题,例如处理 code block 中的 ‘\n’ 时会自动忽略,导致编译后的代码不正确。

最终,吾辈决定直接转换 react element 为 js 字符串,本质上它也只是一个字符串拼接罢了,远没有想象中那么复杂。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
function stringifyJsx(jsx: JSX.Element): string {
if (
typeof jsx === 'string' ||
typeof jsx === 'number' ||
typeof jsx === 'boolean'
) {
return JSON.stringify(jsx)
}
const { children, ...props } = jsx.props ?? {}
if (jsx.key !== undefined && jsx.key !== null) {
props.key = jsx.key
}
function parseType(jsx: JSX.Element) {
if (typeof jsx.type === 'string') {
return `"${jsx.type}"`
}
if (
typeof jsx.type === 'symbol' &&
jsx.type === Symbol.for('react.fragment')
) {
return 'React.Fragment'
}
throw new Error(`Unknown type: ${jsx.type}`)
}
const _props = Object.keys(props).length === 0 ? null : JSON.stringify(props)
const _children =
children === undefined
? undefined
: Array.isArray(children)
? children.map(stringifyJsx)
: stringifyJsx(children)
if (_children === undefined) {
if (_props === null) {
return `React.createElement(${parseType(jsx)})`
}
return `React.createElement(${parseType(jsx)},${_props})`
}
return `React.createElement(${parseType(jsx)},${_props},${_children})`
}

总结

目前,完整功能在 unplugin-markdown [6] 实现并发布至 npm,吾辈只是意外一个看似常见的需求居然没有很好的现成解决方案,即便已经有人做过的事情,只要有所改进仍然可以再次创建。


在构建时而非运行时编译 Markdown
https://blog.rxliuli.com/p/777c31e33d1e4e54803c785787e2e085/
作者
rxliuli
发布于
2025年5月6日
许可协议