Project Icon

mdx-bundler

高效编译打包MDX文件的开源工具

mdx-bundler是一款高效的MDX文件编译打包工具,采用MDX v3和esbuild技术,具有卓越性能。它支持处理多种来源的文件,包括本地、GitHub仓库和CMS系统。该工具支持按需打包,适用于Remix和Next.js等服务端渲染框架。相比传统MDX插件,mdx-bundler突破了构建时的限制,为内容管理提供了更灵活的解决方案。

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 v3esbuild,因此速度非常快,并支持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 返回。

这个功能最好与 mdxOptionsesbuildOptions 的调整一起使用。在下面的例子中,.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,解析为具有以下属性的对象。

类型

mdx-bundler 在其自身的包中提供完整的类型定义。

bundleMDX 有一个单一的类型参数,它是你的 frontmatter 的类型。它默认为 {[key: string]: any} 并且必须是一个对象。这然后用于为返回的 frontmatter 以及传递给 esbuildOptionsmdxOptions 的 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 在那个环境中工作:

  1. Workers 不能运行二进制文件。bundleMDX 使用 esbuild(一个二进制文件)来打包你的 MDX 代码。
  2. 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]):

本项目遵循 [all-contributors] 规范。 欢迎任何形式的贡献!

许可证

MIT

项目侧边栏1项目侧边栏2
推荐项目
Project Cover

豆包MarsCode

豆包 MarsCode 是一款革命性的编程助手,通过AI技术提供代码补全、单测生成、代码解释和智能问答等功能,支持100+编程语言,与主流编辑器无缝集成,显著提升开发效率和代码质量。

Project Cover

AI写歌

Suno AI是一个革命性的AI音乐创作平台,能在短短30秒内帮助用户创作出一首完整的歌曲。无论是寻找创作灵感还是需要快速制作音乐,Suno AI都是音乐爱好者和专业人士的理想选择。

Project Cover

有言AI

有言平台提供一站式AIGC视频创作解决方案,通过智能技术简化视频制作流程。无论是企业宣传还是个人分享,有言都能帮助用户快速、轻松地制作出专业级别的视频内容。

Project Cover

Kimi

Kimi AI助手提供多语言对话支持,能够阅读和理解用户上传的文件内容,解析网页信息,并结合搜索结果为用户提供详尽的答案。无论是日常咨询还是专业问题,Kimi都能以友好、专业的方式提供帮助。

Project Cover

阿里绘蛙

绘蛙是阿里巴巴集团推出的革命性AI电商营销平台。利用尖端人工智能技术,为商家提供一键生成商品图和营销文案的服务,显著提升内容创作效率和营销效果。适用于淘宝、天猫等电商平台,让商品第一时间被种草。

Project Cover

吐司

探索Tensor.Art平台的独特AI模型,免费访问各种图像生成与AI训练工具,从Stable Diffusion等基础模型开始,轻松实现创新图像生成。体验前沿的AI技术,推动个人和企业的创新发展。

Project Cover

SubCat字幕猫

SubCat字幕猫APP是一款创新的视频播放器,它将改变您观看视频的方式!SubCat结合了先进的人工智能技术,为您提供即时视频字幕翻译,无论是本地视频还是网络流媒体,让您轻松享受各种语言的内容。

Project Cover

美间AI

美间AI创意设计平台,利用前沿AI技术,为设计师和营销人员提供一站式设计解决方案。从智能海报到3D效果图,再到文案生成,美间让创意设计更简单、更高效。

Project Cover

稿定AI

稿定设计 是一个多功能的在线设计和创意平台,提供广泛的设计工具和资源,以满足不同用户的需求。从专业的图形设计师到普通用户,无论是进行图片处理、智能抠图、H5页面制作还是视频剪辑,稿定设计都能提供简单、高效的解决方案。该平台以其用户友好的界面和强大的功能集合,帮助用户轻松实现创意设计。

投诉举报邮箱: service@vectorlightyear.com
@2024 懂AI·鲁ICP备2024100362号-6·鲁公网安备37021002001498号