惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Engineering at Meta
Engineering at Meta
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
小众软件
小众软件
博客园_首页
T
Tailwind CSS Blog
美团技术团队
博客园 - 叶小钗
Microsoft Security Blog
Microsoft Security Blog
有赞技术团队
有赞技术团队
Apple Machine Learning Research
Apple Machine Learning Research
大猫的无限游戏
大猫的无限游戏
Microsoft Azure Blog
Microsoft Azure Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
I
InfoQ
MongoDB | Blog
MongoDB | Blog
The Cloudflare Blog
J
Java Code Geeks
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园 - 聂微东
酷 壳 – CoolShell
酷 壳 – CoolShell
Blog — PlanetScale
Blog — PlanetScale
IT之家
IT之家
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Y
Y Combinator Blog

rxliuli blog

App Store Connect 发布事故回顾 Safari 扩展 Popup 中的滚动迟缓问题 Safari 云签名在 GitHub Actions 中的两个陷阱 使用 GitHub Actions 全自动发布 Safari 扩展 旅行 - 东福山岛 旅行 - 新加坡 - 越南 旅行 - 马来西亚 - 下 旅行 - 马来西亚 - 上 Browser Extension Dev - 08. 发布 Chrome Web Store Browser Extension Dev - 07. Popup UI Browser Extension Dev - 06. 按需注入脚本 Browser Extension Dev - 05. 存储和配置 Browser Extension Dev Extra - User Script 介绍 Browser Extension Dev - 04. Background Script Browser Extension Dev Extra - 如何找到锁定滚动的元素 Browser Extension Dev - 03. 注入 UI Browser Extension Dev - 02. 使用 WXT Browser Extension Dev - 01. 介绍基本概念 Chrome => Firefox 扩展移植的那些坑 发布 Safari 扩展到 iOS 应用商店 再游新疆 -- 自驾 实践: 使用 Hono 开发全栈应用 Web 流式写入文件 在 Web 中解压大型 ZIP 并保持目录结构
在构建时而非运行时编译 Markdown
rxliuli · 2025-05-06 · via rxliuli blog

背景

最近重构了个人主站,添加了作品集和博客部分,它们都使用 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,吾辈只是意外一个看似常见的需求居然没有很好的现成解决方案,即便已经有人做过的事情,只要有所改进仍然可以再次创建。