Read OSS

内容插件与 MDX 管道:从 Markdown 到 React

高级

前置知识

  • 了解插件生命周期与 actions 系统(第 2 篇)
  • 了解构建管道与 webpack loader(第 3 篇)
  • 具备 MDX、remark 和 rehype 生态的基础知识

内容插件与 MDX 管道:从 Markdown 到 React

插件生命周期负责创建路由,构建管道负责编译产物。但磁盘上的一个 .md 文件,究竟是如何变成能在浏览器中渲染的 React 组件的?这正是 MDX 管道的职责所在——一条由 webpack loader 编排、remark 与 rehype 插件共同组成的精密处理链,以及三个负责读取文件并将其映射到路由的内容插件。

本文将完整追踪内容处理管道的全过程:从原始 Markdown 文件到最终渲染的页面。我们先从 MDX loader 入手,逐一梳理每个 remark 插件的执行顺序,再以 docs 插件为案例,深入分析内容插件如何协调文件读取、版本管理、侧边栏生成和路由创建。

MDX Webpack Loader

Markdown 处理的入口是位于 packages/docusaurus-mdx-loader/src/loader.ts 的 webpack loader。当 webpack 遇到 .md.mdx 文件时,该 loader 会依次执行以下步骤:

  1. 解析 front matter — 使用可配置的 parseFrontMatter 函数(第 44-48 行)
  2. 将 MDX 编译为 JSX — 通过 compileToJSX() 运行完整的 remark/rehype 处理链
  3. 提取内容标题 — 从第一个标题节点中获取
  4. 检测 MDX 局部文件 — 以 _ 为前缀的文件被视为局部文件(partials),若其中包含 front matter 则会触发警告
  5. 生成资源引用 — Markdown 中引用的图片和链接会被转换为 webpack 的 require() 调用
flowchart TD
    MD["document.md"] --> LOADER["MDX Loader"]
    LOADER --> FM["Parse front matter"]
    FM --> COMPILE["compileToJSX()"]
    COMPILE --> REMARK["Remark plugins"]
    REMARK --> REHYPE["Rehype plugins"]
    REHYPE --> JSX["JSX output"]
    JSX --> ASSETS["Asset require() emission"]
    ASSETS --> MODULE["Webpack module"]

loader 会区分两种 webpack compiler 名称(clientserver)。当启用 mdxCrossCompilerCache 时,同一个 MDX 文件的编译结果可以在两者之间复用——这是一项重要的性能优化,因为构建过程中每个 MDX 文件会被编译两次(一次用于客户端 bundle,一次用于 SSG)。

Remark/Rehype 插件链

位于 processor.ts 的处理器工厂函数负责组装完整的转换链。插件的执行顺序至关重要——每个插件对 AST 的处理结果都会传递给下一个插件。

remark 插件的执行顺序如下:

flowchart TD
    A["beforeDefaultRemarkPlugins (user)"] --> B["remark-frontmatter"]
    B --> C["remark-directive"]
    C --> D["contentTitle (extract h1)"]
    D --> E["admonitions (:::note, :::tip)"]
    E --> F["headings (extract + generate IDs)"]
    F --> G["remark-emoji"]
    G --> H["toc (table of contents)"]
    H --> I["details (HTML details/summary)"]
    I --> J["head (HTML head injection)"]
    J --> K["mermaid (if enabled)"]
    K --> L["transformImage (require assets)"]
    L --> M["resolveMarkdownLinks"]
    M --> N["transformLinks (require assets)"]
    N --> O["remark-gfm"]
    O --> P["remark-comment (if mdx1Compat)"]
    P --> Q["remarkPlugins (user)"]
    Q --> R["unusedDirectivesWarning"]
    R --> S["codeCompatPlugin (MDX1 code blocks)"]

每个插件都有其特定用途,以下几个值得重点关注:

headingsremark/headings)负责提取所有标题并生成稳定的锚点 ID。anchorsMaintainCase 选项控制 ID 是否转为小写。

tocremark/toc)根据标题构建目录数据结构,并将其作为 toc 元数据注入,供主题组件使用。

transformImagetransformLinks![](./img.png)[link](./doc.md) 中的相对路径转换为 webpack 的 require() 调用,使 bundler 能够处理并为这些资源生成哈希指纹。

admonitionsremark/admonitions)将 :::note / :::tip / :::warning 指令块转换为 <Admonition> JSX 组件。

unusedDirectivesWarning 负责捕获未被任何插件处理的类指令语法(如 :::something),并向用户发出警告,提示可能存在拼写错误。

codeCompatPlugin 处理 MDX v1 代码块的兼容性问题——它始终在最后执行,确保在用户插件(如 npm2yarn)之后运行。

remark 插件执行完毕后,rehype 插件开始运行。对于 md 格式(区别于 mdx),会在开头插入 rehype-raw 以处理原始 HTML 块,同时对 MDX 表达式节点进行透传。

提示: 你可以在 docusaurus.config.js 中通过内容插件选项(如 docs: {remarkPlugins: [...]})添加自定义 remark/rehype 插件。自定义插件会被插入到默认 remark 插件与 unusedDirectivesWarning 插件之间,此时 AST 已经过充分处理,结构清晰。

Docs 插件:内容加载与版本管理

位于 plugin-content-docs/src/index.ts#L82-L108 的 docs 插件是最复杂的内容插件。其构造函数会立即解析侧边栏路径并读取版本元数据:

const versionsMetadata = await readVersionsMetadata({context, options});

版本管理系统支持同时维护多个文档版本。版本元数据包含版本名称、内容路径、侧边栏路径和 URL 前缀。"当前"版本默认指向 docs/ 目录,而已发布的历史版本则存储在 versioned_docs/v<X>/ 中,并通过 versions.json 进行追踪。

flowchart TD
    VM["readVersionsMetadata()"] --> CURRENT["Current version<br/>docs/"]
    VM --> V2["Version 2.0<br/>versioned_docs/version-2.0/"]
    VM --> V1["Version 1.0<br/>versioned_docs/version-1.0/"]
    
    CURRENT --> LOAD["loadVersion()"]
    V2 --> LOAD
    V1 --> LOAD
    LOAD --> DOCS["Per-version docs array"]
    LOAD --> SIDEBARS["Per-version sidebars"]

loadContent() 阶段,插件会对每个版本调用 loadVersion(),扫描内容目录中的 Markdown 文件,解析其 front matter,提取元数据(标题、描述、标签、侧边栏位置),并构建完整的文档数据结构。每个版本都有独立的侧边栏配置和内容路径。

通过 id 选项,插件还支持多实例运行——你可以使用 docs 作为主文档实例,同时创建一个名为 community 的第二实例,指向不同的内容路径。每个实例都拥有独立的版本管理、侧边栏和路由。

侧边栏生成

sidebars/index.ts 中的侧边栏系统支持两种模式:

自动生成侧边栏 — 默认模式。当 sidebarPathundefined 时,Docusaurus 根据文件系统结构自动生成侧边栏。第 22-29 行的 DefaultSidebars 配置会创建一个 type: 'autogenerated' 类型的侧边栏,指向根目录。

自动生成功能支持通过 _category_.json_category_.yml 文件自定义分类的标签、排序和描述。readCategoriesMetadata() 函数(第 43-68 行)使用 glob 模式扫描这些文件。

手动侧边栏 — 通过导出侧边栏配置的 sidebars.js 文件定义,使用 loadFreshModule() 加载,支持 TypeScript 和 ESM 格式。

两种模式都经过相同的处理管道:规范化 → 处理 → 后处理。处理阶段会通过扫描文件系统将 autogenerated 条目解析为实际的文档引用,后处理阶段则负责验证链接目标并应用排序规则。

graph TD
    INPUT["sidebarPath option"] -->|undefined| AUTO["DefaultSidebars<br/>autogenerated from filesystem"]
    INPUT -->|false| DISABLED["DisabledSidebars<br/>no sidebar"]
    INPUT -->|path| MANUAL["Load sidebars.js"]
    AUTO --> NORM["normalizeSidebars()"]
    MANUAL --> NORM
    NORM --> PROC["processSidebars()"]
    PROC --> POST["postProcessSidebars()"]
    POST --> FINAL["Final sidebar data"]

路由创建:将内容与主题组件连接

内容插件中最直观的部分莫过于 contentLoaded() 的实现。docs 插件在 routes.ts 中的路由创建逻辑,清晰展示了内容是如何转化为路由的。

对于每篇文档,插件会:

  1. 调用 actions.createData() 将文档元数据写入一个 JSON 模块
  2. 创建路由,component 设为 options.docItemComponent(解析为 @theme/DocItem
  3. 设置 modules: {content: doc.source} — 指示路由将 MDX 文件作为模块加载
  4. sidebar 作为路由属性附加,供父组件渲染对应的侧边栏
sequenceDiagram
    participant DOC as docs plugin
    participant ACT as actions
    participant GEN as .docusaurus/
    participant THEME as @theme/DocItem

    DOC->>ACT: createData('abc123.json', docMetadata)
    ACT->>GEN: Write JSON to .docusaurus/docs/default/abc123.json
    ACT-->>DOC: modulePath
    DOC->>ACT: addRoute({path, component: '@theme/DocItem', modules: {content: doc.source}})
    Note over DOC,THEME: At runtime, DocItem receives doc content and metadata as props

路由结构是层级化的:每个版本创建一个以 docRootComponent(解析为 @theme/DocRoot)为组件的父路由,各个文档页面作为嵌套子路由挂载其下。这种设计使侧边栏组件在 DocRoot 层级渲染,在文档间导航时无需卸载重载,从而保持持久化状态。

提示: addRoute() 中的 component 字段始终是字符串,如 '@theme/DocItem',而非实际的 import 语句。主题别名系统(将在第 5 篇中介绍)会在 webpack 编译阶段将该字符串解析为真正的 React 组件。

Blog 插件与 Pages 插件

docs 插件虽然最为复杂,但 blog 和 pages 插件遵循相同的设计模式:

plugin-content-blog 扫描博客文章(遵循日期命名约定的 Markdown 文件),为单篇文章、博客列表、标签页和归档页分别生成路由,使用 @theme/BlogPostPage@theme/BlogListPage 等主题组件。

plugin-content-pages 是最简单的一个——它扫描 src/pages/ 目录下的 React 组件和 MDX 文件,为 MDX 内容创建使用 @theme/MDXPage 的路由。没有版本管理,没有侧边栏,只是直接的文件到路由映射。

三个插件共享一些通用模式:通过 getPathsToWatch() 告知开发服务器哪些文件变更需要触发重新构建;通过 configureWebpack() 为各自的内容目录注册 MDX loader 规则;通过 getTranslationFiles() 支持 i18n。

MDX loader 规则是内容插件与 MDX 管道之间的关键纽带。每个插件都会注册一条 webpack 规则,匹配其内容目录下的 .md/.mdx 文件,并使用 MDX loader 及其特定配置(remark 插件、admonition 设置等)进行处理。这也正是我们在第 2 篇中看到的 MDX fallback 插件需要排除这些路径的原因——它只处理其余的文件。

下一步

我们已经完整追踪了内容从 Markdown 文件经过 MDX 管道到路由创建的全过程。但路由中引用的是 @theme/DocItem 这样的主题组件字符串——它是如何变成真正的 React 组件的?下一篇文章将深入探讨主题系统的别名解析机制、theme-classic 中 100 多个组件的组织方式,以及让 Docusaurus 独具特色的 swizzle 机制。