mdx-bundler 🦤
编译并打包你的MDX文件及其依赖项。速度飞快。
[![构建状态][build-badge]][build] [![代码覆盖率][coverage-badge]][coverage] [![版本][version-badge]][package] [![下载量][downloads-badge]][npmtrends] [![MIT许可证][license-badge]][license] ![所有贡献者][all-contributors-badge] [![欢迎PR][prs-badge]][prs] [![行为准则][coc-badge]][coc]
问题
你有一串MDX文本和它使用的各种TS/JS文件,你想获得这些文件的打包版本以在浏览器中执行。
解决方案
这是一个异步函数,可以编译和打包你的MDX文件及其依赖项。它使用MDX v3和esbuild,因此速度非常快,并支持TypeScript文件(作为MDX文件的依赖项)。
你的源文件可以是本地的、远程GitHub仓库中的、CMS中的,或者其他任何地方,这都无关紧要。mdx-bundler
只关心你传递给它所有必要的文件和源代码,它会负责为你打包所有内容。
常见问题:
"MDX有什么特别之处?"
MDX允许你将简洁的markdown语法与React组件的强大功能结合起来,用于编写内容。对于内容丰富的网站,直接用HTML编写内容可能会非常繁琐。人们经常使用所见即所得编辑器来解决这个问题,但这些编辑器在将作者的意图映射到HTML时往往不尽如人意。许多人更喜欢使用markdown来表达他们的内容源,然后将其解析成HTML进行渲染。
使用Markdown编写内容的问题在于,如果你想在内容中嵌入一些交互性,你的选择会非常有限。你要么需要插入一个JavaScript目标的元素(这种方式很间接),要么可以使用iframe
之类的东西。
如前所述,MDX允许你将简洁的markdown语法与React组件的强大功能结合起来。因此,你可以导入React组件并在markdown中直接渲染它。这是两全其美的解决方案。
"这与next-mdx-remote
有什么不同?"
mdx-bundler
实际上会打包MDX文件的依赖项。例如,以下内容无法在next-mdx-remote
中工作,但在mdx-bundler
中可以:
---
title: 示例文章
published: 2021-02-13
description: 这是一些描述
---
# 哇哦
import Demo from './demo'
这里有一个**精彩的**演示:
<Demo />
next-mdx-remote
在处理该导入时会出错,因为它只是一个编译器,而不是打包器。mdx-bundler
既是MDX编译器又是打包器。这就是区别所在。
"这与webpack或rollup的mdx插件有什么不同?"
那些工具旨在"构建时"运行,然后你部署文件的构建版本。这意味着如果你的MDX内容中有一个拼写错误需要修改,你必须重新构建并重新部署整个网站。这也意味着你向网站添加的每个MDX页面都会增加构建时间,所以它的扩展性并不是很好。
mdx-bundler
当然可以在构建时使用,但它更强大的用途是作为运行时打包器。一个常见的用例是为你的MDX内容设置一个路由,当请求到达时,你加载MDX内容并将其交给mdx-bundler
进行打包。这意味着mdx-bundler
具有无限的可扩展性。无论你有多少MDX内容,你的构建时间都不会变长。此外,mdx-bundler
速度相当快,但为了使这种按需打包更快,你可以使用适当的缓存头来避免不必要的重新打包。
Webpack/rollup等还要求所有MDX文件都在本地文件系统上才能工作。如果你想将MDX内容存储在单独的仓库或CMS中,你就会遇到困难,或者需要在构建时进行一些复杂操作来放置文件。
使用mdx-bundler
,不管你的MDX内容来自哪里,你都可以打包任何地方的文件,你只需负责将内容加载到内存中,然后将其交给mdx-bundler
进行打包。
"这能与Remix/Gatsby/Next/CRA等一起使用吗?"
完全可以。它可以与任何这些工具一起使用。根据你的元框架是否支持服务器端渲染,你的实现方式会有所不同。你可能决定采用构建时方法(对于Gatsby/CRA),但如前所述,mdx-bundler
的真正威力体现在按需打包方面。因此,它最适合Remix/Next等SSR框架。
"为什么用渡渡鸟emoji?🦤"
为什么不呢?
"为什么esbuild是一个对等依赖?"
esbuild提供了一个用GO编写的服务,它与之交互。同一时间只能运行一个这样的服务实例,并且它必须与npm包的版本相同。如果它是一个硬依赖,你将只能使用mdx-bundler使用的esbuild版本。
目录
安装
这个模块通过npm分发,它与node捆绑在一起,应该作为你项目的dependencies
之一安装:
npm install --save mdx-bundler esbuild
mdx-bundler的一个依赖项需要一个可用的node-gyp设置才能正确安装。
使用
import {bundleMDX} from 'mdx-bundler'
const mdxSource = `
---
title: 示例文章
published: 2021-02-13
description: 这是一些描述
---
# 哇哦
import Demo from './demo'
这里有一个**精彩的**演示:
<Demo />
`.trim()
const result = await bundleMDX({
source: mdxSource,
files: {
'./demo.tsx': `
import * as React from 'react'
function Demo() {
return <div>精彩的演示!</div>
}
export default Demo
`,
},
})
const {code, frontmatter} = result
之后,你将code
发送到客户端,然后:
import * as React from 'react'
import {getMDXComponent} from 'mdx-bundler/client'
function Post({code, frontmatter}) {
// 通常最好将这个函数调用记忆化
// 以避免每次渲染时重新创建组件。
const Component = React.useMemo(() => getMDXComponent(code), [code])
return (
<>
<header>
<h1>{frontmatter.title}</h1>
<p>{frontmatter.description}</p>
</header>
<main>
<Component />
</main>
</>
)
}
最终,这将被渲染为(基本上):
<header>
<h1>这是标题</h1>
<p>这是一些描述</p>
</header>
<main>
<div>
<h1>哇哦</h1>
<p>这里有一个<strong>精彩的</strong>演示:</p>
<div>精彩的演示!</div>
</div>
</main>
选项
source
MDX的string
源。
如果设置了file
则不能设置此项
file
磁盘上包含MDX的文件路径。你可能还需要设置cwd。
如果设置了source
则不能设置此项
files
files
配置是一个包含所有要打包的文件的对象。键是文件的路径(相对于 MDX 源文件),值是文件源代码的字符串。你可以从文件系统或远程数据库获取这些内容。如果你的 MDX 不引用其他文件(或只从 node_modules
导入),则可以完全省略这个配置。
mdxOptions
这允许你修改内置的 MDX 配置(传递给 @mdx-js/esbuild
)。这对于指定自己的 remarkPlugins/rehypePlugins 很有帮助。
该函数接收默认的 mdxOptions 和 frontmatter 作为参数。
bundleMDX({
source: mdxSource,
mdxOptions(options, frontmatter) {
// 这是添加自定义 remark/rehype 插件的推荐方式:
// 语法可能看起来有点奇怪,但它可以在我们将来添加/删除插件时保护你。
options.remarkPlugins = [...(options.remarkPlugins ?? []), myRemarkPlugin]
options.rehypePlugins = [...(options.rehypePlugins ?? []), myRehypePlugin]
return options
},
})
esbuildOptions
你可以使用 esbuildOptions
选项自定义任何 esbuild 选项。这需要一个函数,该函数接收默认的 esbuild 选项和 frontmatter,并期望返回一个选项对象。
bundleMDX({
source: mdxSource,
esbuildOptions(options, frontmatter) {
options.minify = false
options.target = [
'es2020',
'chrome58',
'firefox57',
'safari11',
'edge16',
'node12',
]
return options
},
})
有关可用选项的更多信息,可以在 esbuild 文档 中找到。
建议使用此功能来配置 target
以达到你想要的输出,否则 esbuild 默认为 esnext
,这意味着它不会编译任何标准化的特性,因此旧版浏览器的用户可能会遇到错误。
globals
这告诉 esbuild 某个模块是外部可用的。例如,如果你的 MDX 文件使用了 d3 库,而你的应用中已经在使用 d3 库,那么你最终会向用户发送两次 d3
(一次用于你的应用,一次用于这个 MDX 组件)。这是浪费的,你最好告诉 esbuild 不要 打包 d3
,然后在调用 getMDXComponent
时自己传递给组件。
全局外部配置选项: https://www.npmjs.com/package/@fal-works/esbuild-plugin-global-externals
这里有一个例子:
// 在 Node 中运行的服务器端或构建时代码:
import {bundleMDX} from 'mdx-bundler'
const mdxSource = `
# 这是标题
import leftPad from 'left-pad'
<div>{leftPad("很棒的演示!", 12, '!')}</div>
`.trim()
const result = await bundleMDX({
source: mdxSource,
// 注意:这*仅*在你想在 MDX 文件包和主应用之间共享依赖时才需要。
// 否则,所有依赖都会被打包。
// 所以无论哪种方式都可以工作,这只是一个优化,以避免向用户发送
// 同一库的多个副本。
globals: {'left-pad': 'myLeftPad'},
})
// 可以在浏览器或 Node 中运行的服务器渲染和/或客户端代码:
import * as React from 'react'
import leftPad from 'left-pad'
import {getMDXComponent} from 'mdx-bundler/client'
function MDXPage({code}: {code: string}) {
const Component = React.useMemo(
() => getMDXComponent(result.code, {myLeftPad: leftPad}),
[result.code, leftPad],
)
return (
<main>
<Component />
</main>
)
}
cwd
将 cwd
(当前工作目录)设置为一个目录将允许 esbuild 解析导入。这个目录可以是读取 mdx 内容的目录,或者是应该在其中_运行_非磁盘 mdx 的目录。
content/pages/demo.tsx
import * as React from 'react'
function Demo() {
return <div>很棒的演示!</div>
}
export default Demo
src/build.ts
import {bundleMDX} from 'mdx-bundler'
const mdxSource = `
---
title: 示例文章
published: 2021-02-13
description: 这是一些描述
---
# 哇哦
import Demo from './demo'
这是一个**很棒的**演示:
<Demo />
`.trim()
const result = await bundleMDX({
source: mdxSource,
cwd: '/users/you/site/_content/pages',
})
const {code, frontmatter} = result
grayMatterOptions
这允许你配置 gray-matter 选项。
你的函数会接收当前的 gray-matter 配置,供你修改。返回你修改后的 gray matter 配置对象。
bundleMDX({
grayMatterOptions: options => {
options.excerpt = true
return options
},
})
bundleDirectory & bundlePath
这允许你设置打包输出的目录和该目录的公共 URL。如果设置了其中一个选项,另一个也必须设置。
JavaScript 包不会被写入这个目录,仍然会以字符串形式从 bundleMDX
返回。
这个功能最好与 mdxOptions
和 esbuildOptions
的调整一起使用。在下面的例子中,.png
文件被写入磁盘,然后从 /file/
提供服务。
这允许你将资产与你的 MDX 一起存储,然后让 esbuild 像处理其他内容一样处理它们。
建议每个包都有自己的 bundleDirectory
,这样多个包就不会覆盖彼此的资产。
const {code} = await bundleMDX({
file: '/path/to/site/content/file.mdx',
cwd: '/path/to/site/content',
bundleDirectory: '/path/to/site/public/file',
bundlePath: '/file/',
mdxOptions: options => {
options.remarkPlugins = [remarkMdxImages]
return options
},
esbuildOptions: options => {
options.loader = {
...options.loader,
'.png': 'file',
}
return options
},
})
返回值
bundleMDX
返回一个 Promise,解析为具有以下属性的对象。
code
- 你的 mdx 的打包结果,以string
形式。frontmatter
- 从 gray-matter 获取的 frontmatterobject
。matter
- gray-matter 返回的整个对象
类型
mdx-bundler
在其自身的包中提供完整的类型定义。
bundleMDX
有一个单一的类型参数,它是你的 frontmatter 的类型。它默认为 {[key: string]: any}
并且必须是一个对象。这然后用于为返回的 frontmatter
以及传递给 esbuildOptions
和 mdxOptions
的 frontmatter 提供类型。
const {frontmatter} = bundleMDX<{title: string}>({source})
frontmatter.title // 类型为 string
组件替换
MDX Bundler 通过 getMDXComponent
返回的组件的 components
属性传递 MDX 替换组件的能力。
这里有一个例子,它移除了图片周围的 p 标签。
import * as React from 'react'
import {getMDXComponent} from 'mdx-bundler/client'
const Paragraph: React.FC = props => {
if (typeof props.children !== 'string' && props.children.type === 'img') {
return <>{props.children}</>
}
return <p {...props} />
}
function MDXPage({code}: {code: string}) {
const Component = React.useMemo(() => getMDXComponent(code), [code])
return (
<main>
<Component components={{p: Paragraph}} />
</main>
)
}
Frontmatter 和常量
你可以在 mdx 内容中引用 frontmatter 元数据或常量。
---
title: 示例文章
---
export const exampleImage = 'https://example.com/image.jpg'
# {frontmatter.title}
<img src={exampleImage} alt="图片替代文本" />
访问命名导出
你可以使用 getMDXExport
而不是 getMDXComponent
来将 mdx 文件视为模块而不仅仅是一个组件。它接受与 getMDXComponent
相同的参数。
---
title: 示例文章
---
export const toc = [{depth: 1, value: '标题'}]
# 标题
import * as React from 'react'
import {getMDXExport} from 'mdx-bundler/client'
function MDXPage({code}: {code: string}) {
const mdxExport = getMDXExport(code)
console.log(mdxExport.toc) // [ { depth: 1, value: '标题' } ]
const Component = React.useMemo(() => mdxExport.default, [code])
return <Component />
}
图片打包
通过 cwd 和 remark 插件 remark-mdx-images,你可以在你的 mdx 中打包图片!
esbuild 中有两个可以用于此目的的加载器。最简单的是 dataurl
,它将图片作为内联数据 URL 输出到返回的代码中。
import {remarkMdxImages} from 'remark-mdx-images'
const {code} = await bundleMDX({
source: mdxSource,
cwd: '/users/you/site/_content/pages',
mdxOptions: options => {
options.remarkPlugins = [...(options.remarkPlugins ?? []), remarkMdxImages]
```js
return options
},
esbuildOptions: options => {
options.loader = {
...options.loader,
'.png': 'dataurl',
}
return options
},
})
file
加载器需要更多的配置才能工作。使用 file
加载器时,你的图片会被复制到输出目录,因此 esbuild 需要设置为写入文件,并且需要知道把它们放在哪里,以及在图片源中使用的文件夹的 URL。
每次调用
bundleMDX
都是相互独立的。如果你为所有内容设置相同的目录,bundleMDX
会在不警告的情况下覆盖图片。因此,每个 bundle 需要自己的输出目录。
// 对于文件 `_content/pages/about.mdx`
const {code} = await bundleMDX({
source: mdxSource,
cwd: '/users/you/site/_content/pages',
mdxOptions: options => {
options.remarkPlugins = [...(options.remarkPlugins ?? []), remarkMdxImages]
return options
},
esbuildOptions: options => {
// 为这个 bundle 设置 `outdir` 到一个公共位置
options.outdir = '/users/you/site/public/img/about'
options.loader = {
...options.loader,
// 告诉 esbuild 对 png 使用 `file` 加载器
'.png': 'file',
}
// 将公共路径设置为 /img/about
options.publicPath = '/img/about'
// 将 write 设置为 true,这样 esbuild 就会输出文件
options.write = true
return options
},
})
打包一个文件
如果你的 MDX 文件在磁盘上,你可以通过让 mdx-bundler
为你读取文件来节省一些时间和代码。不需要提供 source
字符串,你可以将 file
设置为磁盘上 MDX 的路径。将 cwd
设置为它的文件夹,这样相对导入就能正常工作。
import {bundleMDX} from 'mdx-bundler'
const {code, frontmatter} = await bundleMDX({
file: '/users/you/site/content/file.mdx',
cwd: '/users/you/site/content/',
})
下游文件中的自定义组件
为了确保自定义组件在下游 MDX 文件中可访问,你可以使用 @mdx-js/react
中的 MDXProvider
将自定义组件传递给嵌套导入。
npm install --save @mdx-js/react
const globals = {
'@mdx-js/react': {
varName: 'MdxJsReact',
namedExports: ['useMDXComponents'],
defaultExport: false,
},
};
const { code } = bundleMDX({
source,
globals,
mdxOptions(options: Record<string, any>) {
return {
...options,
providerImportSource: '@mdx-js/react',
};
}
});
从那里,你将 code
发送到你的客户端,然后:
import { MDXProvider, useMDXComponents } from '@mdx-js/react';
const MDX_GLOBAL_CONFIG = {
MdxJsReact: {
useMDXComponents,
},
};
export const MDXComponent: React.FC<{
code: string;
frontmatter: Record<string, any>;
}> = ({ code }) => {
const Component = useMemo(
() => getMDXComponent(code, MDX_GLOBAL_CONFIG),
[code],
);
return (
<MDXProvider components={{ Text: ({ children }) => <p>{children}</p> }}>
<Component />
</MDXProvider>
);
};
已知问题
Cloudflare Workers
我们非常希望这能在 cloudflare workers 中工作。不幸的是,cloudflare 有两个限制阻止了 mdx-bundler
在那个环境中工作:
- Workers 不能运行二进制文件。
bundleMDX
使用esbuild
(一个二进制文件)来打包你的 MDX 代码。 - Workers 不能运行
eval
或类似的东西。getMDXComponent
使用new Function
评估打包的代码。
一个解决方法是将你的 mdx-bundler 相关代码放在不同的环境中,并从 Cloudflare worker 内部调用那个环境。在我看来,这违背了使用 Cloudflare workers 的目的。另一个潜在的解决方法是在 worker 内部使用 WASM。有 esbuild-wasm
,但该包存在一些问题,这些问题在该链接中有解释。然后是 wasm-jseval
,但我无法让它运行 mdx-bundler
输出的代码而不出错。
如果有人愿意深入研究这个问题,那将是非常棒的,但不幸的是,我可能永远不会去研究它。
Next.JS esbuild ENOENT
esbuild 依赖 __dirname
来确定其可执行文件的位置,Next.JS 和 Webpack 有时会破坏这一点,需要手动告诉 esbuild 去哪里查找。
在 bundleMDX
之前添加以下代码将直接指向你平台的正确可执行文件。
import path from 'path'
if (process.platform === 'win32') {
process.env.ESBUILD_BINARY_PATH = path.join(
process.cwd(),
'node_modules',
'esbuild',
'esbuild.exe',
)
} else {
process.env.ESBUILD_BINARY_PATH = path.join(
process.cwd(),
'node_modules',
'esbuild',
'bin',
'esbuild',
)
}
关于这个问题的更多信息可以在这篇文章中找到。
灵感
当我正在将 kentcdodds.com 重写为 remix 时,我决定想要保留我的博客文章为 MDX 格式,但我不想在构建时编译它们全部,或者每次修复一个拼写错误就必须重新部署。所以我制作了这个,它允许我的服务器按需编译。
其他解决方案
有 next-mdx-remote,但它更像是一个 mdx 编译器而不是打包器(不能为依赖项打包你的 mdx)。此外,它专注于 Next.js,而这个是与元框架无关的。
问题
想要贡献? 寻找 [Good First Issue][good-first-issue] 标签。
🐛 Bugs
请为错误、缺失的文档或意外行为提交问题。
[查看 Bugs][bugs]
💡 功能请求
请提交问题来建议新功能。通过添加 👍 来为功能请求投票。这有助于维护者确定优先处理的内容。
[查看功能请求][requests]
贡献者 ✨
感谢这些人([emoji key][emojis]):
许可证
MIT