Skip to content

MDX 支持

Velite 开箱即用地支持 MDX。您可以使用 MDX 编写内容,Velite 会自动为您渲染。

以下示例可能会对您有所帮助:

开始使用

例如,假设您有以下内容结构:

diff
项目根目录
├── content
│   └── posts
│       └── hello-world.mdx
├── public
├── package.json
└── velite.config.js

./content/posts/hello-world.mdx 是一个 MDX 文档,其内容如下:

mdx
---
title: Hello world
---

export const year = 2023

# 去年的降雪量

{year} 年,降雪量高于平均水平。
随后是温暖的春天,导致
许多附近河流出现洪水泛滥的情况。

<Chart year={year} color="#fcb32c" />

使用 s.mdx() 模式可将编译后的 MDX 代码添加到您的内容集合中。

js
import { defineConfig, s } from 'velite'

export default defineConfig({
  collections: {
    posts: {
      name: 'Post',
      pattern: 'posts/*.mdx',
      schema: s.object({
        title: s.string(),
        code: s.mdx()
      })
    }
  }
})

运行 velite build,您将获得以下数据结构:

json
{
  "posts": [
    {
      "title": "Hello world",
      "code": "const{Fragment:n,jsx:e,jsxs:t}=arguments[0],o=2023;function _createMdxContent(r){const a={h1:\"h1\",p:\"p\",...r.components},{Chart:c}=a;return c||function(n,e){throw new Error(\"Expected \"+(e?\"component\":\"object\")+\" `\"+n+\"` to be defined: you likely forgot to import, pass, or provide it.\")}(\"Chart\",!0),t(n,{children:[e(a.h1,{children:\"Last year’s snowfall\"}),\"\\n\",t(a.p,{children:[\"In \",o,\", the snowfall was above average.\\nIt was followed by a warm spring which caused\\nflood conditions in many of the nearby rivers.\"]}),\"\\n\",e(c,{year:o,color:\"#fcb32c\"})]})}return{year:o,default:function(n={}){const{wrapper:t}=n.components||{};return t?e(t,{...n,children:e(_createMdxContent,{...n})}):_createMdxContent(n)}};"
    }
  ]
}

默认情况下,Velite 会将 MDX 内容编译为函数体字符串,可在您的应用程序中用于渲染内容。

渲染 MDX 内容

首先,您可以创建一个通用组件来渲染编译后的 mdx 代码。它应接受该代码和一个在 MDX 内容中使用的组件列表。

./components/mdx-content.tsx:

tsx
import * as runtime from 'react/jsx-runtime'

const sharedComponents = {
  // 在此处添加您的全局组件
}

// 将 Velite 生成的 MDX 代码解析为一个 React 组件函数
const useMDXComponent = (code: string) => {
  const fn = new Function(code)
  return fn({ ...runtime }).default
}

interface MDXProps {
  code: string
  components?: Record<string, React.ComponentType>
}

// MDXContent 组件
export const MDXContent = ({ code, components }: MDXProps) => {
  const Component = useMDXComponent(code)
  return <Component components={{ ...sharedComponents, ...components }} />
}

然后,您可以使用 MDXContent 组件来渲染 MDX 内容:

./pages/posts/[slug].tsx:

tsx
import { posts } from '@/.velite'
import { Chart } from '@/components/chart' // 导入您的自定义组件
import { MDXContent } from '@/components/mdx-content'

export default function Post({ params: { slug } }) {
  const post = posts.find(i => i.slug === slug)
  return (
    <article>
      <h1>{post.title}</h1>
      <MDXContent code={post.code} components={{ Chart }} />
    </article>
  )
}

常见问题解答

如何在 MDX 中导入组件?

您不需要这样做,因为 Velite 的 s.mdx() 模式不会在构建时打包这些组件。无需构建导入树。这有助于减小您内容的输出体积。

例如,假设您为多个 MDX 文件提取了一个公共组件,并在这些 MDX 中导入了该组件。

tsx
export const Callout = ({ children }: { children: React.ReactNode }) => {
  // 您的公共组件
  return <div style={{ border: '1px solid #ddd', padding: '1rem' }}>{children}</div>
}
mdx
---
title: Foo
---

import { Callout } from '../components/callout'

# Foo

<Callout>This is foo callout.</Callout>
mdx
---
title: Bar
---

import { Callout } from '../components/callout'

# Bar

<Callout>This is bar callout.</Callout>

::>

如果 Velite 使用捆绑器来编译您的 MDX,Callout 组件将被捆绑到每个 MDX 文件中,这会导致输出代码中存在大量冗余。

相反,只需在您的 MDX 文件中使用您想要的任何组件,而无需导入。

mdx
---
title: Foo
---

# Foo

<Callout>This is foo callout.</Callout>
mdx
---
title: Bar
---

# Bar

<Callout>This is bar callout.</Callout>

::>

然后,将组件注入到 MDXContent 组件中:

tsx
import { Callout } from '@/components/callout'
import { MDXContent } from '@/components/mdx-content'

export default function Post({ params: { slug } }) {
  const post = posts.find(i => i.slug === slug)
  return (
    <article>
      <h1>{post.title}</h1>
      <MDXContent code={post.code} components={{ Callout }} />
    </article>
  )
}

您还可以添加全局组件,以便它们对所有 MDX 文件可用。

tsx
import * as runtime from 'react/jsx-runtime'

import { Callout } from '@/components/callout'

const sharedComponents = {
  // 在此处添加您的全局组件
  Callout
}

const useMDXComponent = (code: string) => {
  const fn = new Function(code)
  return fn({ ...runtime }).default
}

interface MDXProps {
  code: string
  components?: Record<string, React.ComponentType>
}

export const MDXContent = ({ code, components }: MDXProps) => {
  const Component = useMDXComponent(code)
  return <Component components={{ ...sharedComponents, ...components }} />
}

如果想捆绑 MDX 怎么办?

如果您可以接受输出体积的增加,那么捆绑 MDX 可能是获得更好可移植性的一个不错的选择。

您可以安装以下包来捆绑 MDX:

bash
npm i esbuild @fal-works/esbuild-plugin-global-externals @mdx-js/esbuild --save-dev

然后,为 MDX 捆绑创建一个自定义模式:

CAUTION

以下代码仅是一个简单的示例。您可能需要根据实际情况进行调整。

ts
import { dirname, join } from 'node:path'
import { globalExternals } from '@fal-works/esbuild-plugin-global-externals'
import mdxPlugin from '@mdx-js/esbuild'
import { build } from 'esbuild'

import type { Plugin } from 'esbuild'

const compileMdx = async (source: string, path: string, options: CompileOptions): Promise<string> => {
  const virtualSourse: Plugin = {
    name: 'virtual-source',
    setup: build => {
      build.onResolve({ filter: /^__faker_entry/ }, args => {
        return {
          path: join(args.resolveDir, args.path),
          pluginData: { contents: source } // for mdxPlugin
        }
      })
    }
  }

  const bundled = await build({
    entryPoints: [`__faker_entry.mdx`],
    absWorkingDir: dirname(path),
    write: false,
    bundle: true,
    target: 'node18',
    platform: 'neutral',
    format: 'esm',
    globalName: 'VELITE_MDX_COMPONENT',
    treeShaking: true,
    jsx: 'automatic',
    minify: true,
    plugins: [
      virtualSourse,
      mdxPlugin({}),
      globalExternals({
        react: {
          varName: 'React',
          type: 'cjs'
        },
        'react-dom': {
          varName: 'ReactDOM',
          type: 'cjs'
        },
        'react/jsx-runtime': {
          varName: '_jsx_runtime',
          type: 'cjs'
        }
      })
    ]
  })

  return bundled.outputFiles[0].text.replace('var VELITE_MDX_COMPONENT=', 'return ')
}

export const mdxBundle = (options: MdxOptions = {}) =>
  custom<string>().transform<string>(async (value, { meta: { path, content, config }, addIssue }) => {
    value = value ?? content
    if (value == null) {
      addIssue({ fatal: true, code: 'custom', message: 'The content is empty' })
      return null as never
    }

    const enableGfm = options.gfm ?? config.mdx?.gfm ?? true
    const enableMinify = options.minify ?? config.mdx?.minify ?? true
    const removeComments = options.removeComments ?? config.mdx?.removeComments ?? true
    const copyLinkedFiles = options.copyLinkedFiles ?? config.mdx?.copyLinkedFiles ?? true
    const outputFormat = options.outputFormat ?? config.mdx?.outputFormat ?? 'function-body'

    const remarkPlugins = [] as PluggableList
    const rehypePlugins = [] as PluggableList

    if (enableGfm) remarkPlugins.push(remarkGfm) // 支持 gfm (自动链接字面量、脚注、删除线、表格、任务列表)。
    if (removeComments) remarkPlugins.push(remarkRemoveComments) // 移除 HTML 注释
    if (copyLinkedFiles) remarkPlugins.push([remarkCopyLinkedFiles, config.output]) // 将链接的文件复制到公共路径,并将其 URL 替换为公共 URL
    if (options.remarkPlugins != null) remarkPlugins.push(...options.remarkPlugins) // 应用 remark 插件
    if (options.rehypePlugins != null) rehypePlugins.push(...options.rehypePlugins) // 应用 rehype 插件
    if (config.mdx?.remarkPlugins != null) remarkPlugins.push(...config.mdx.remarkPlugins) // 应用全局 remark 插件
    if (config.mdx?.rehypePlugins != null) rehypePlugins.push(...config.mdx.rehypePlugins) // 应用全局 rehype 插件

    const compilerOptions = { ...config.mdx, ...options, outputFormat, remarkPlugins, rehypePlugins }

    try {
      return await compileMdx(value, path, compilerOptions)
    } catch (err: any) {
      addIssue({ fatal: true, code: 'custom', message: err.message })
      return null as never
    }
  })

然后,您可以在 velite.config.js 中使用自定义模式:

js
import { defineConfig, s } from 'velite'

import { mdxBundle } from './mdx'

export default defineConfig({
  collections: {
    posts: {
      name: 'Post',
      pattern: 'posts/*.mdx',
      schema: s.object({
        title: s.string(),
        code: mdxBundle()
      })
    }
  }
})

Distributed under the MIT License.