Fumadocs MDX v10

对 Fumadocs MDX 的改进,这是我们内置的内容源。

Back

问题

Fumadocs MDX 在文档方面工作良好。但我们也希望优先考虑灵活性和代码组织。

以前,它是一个简单的 Webpack loader,将 MDX 转换为 JavaScript。 您将 MDX 处理器选项传递给 loader,它会将它们转换为 JavaScript 文件。 然后,会导出一个 .map.ts

export const map = {
  'docs/index.mdx': import('./docs/index.mdx'),
  'docs/guide.mdx': import('./docs/guide.mdx'),
};

您的 Next.js 应用会导入 map 文件,并访问可用的 MDX 文件。

这种模型有效,但我们开始看到一些问题:

  • 没有内置方式定义多个集合:

    例如,我们有一个博客文章目录和一个文档页面目录。

    在 Fumadocs MDX 中,所有这些资源都被转换为 .map.ts 导出的单个对象:

    export const map = {
      'blog/post.mdx': import('./blog/post.mdx'),
      'docs/index.mdx': import('./docs/index.mdx'),
    };

    我们使用 Source API 的 rootDir 选项实现了一个解决方案,但这不是理想的方式。 这也给我们带来了另一个问题:

  • 每个集合的不同 MDX 选项:

    与上面的例子相同,我们有一个 /blog 目录用于博客文章。 如果我们想添加一个 仅适用于博客文章 的 remark 插件,使用 Fumadocs MDX 这是不可能的。

    一旦应用 remark 插件,它就会对所有 MDX 文件生效,包括文档目录中的 MDX 文件。

  • Turbopack 兼容性:

    Turbopack 不允许将非可序列化选项传递给 loader。 然而,整个 MDX、remark 和 rehype 生态系统都使用函数作为插件。 函数不可序列化,除非找到一个无缝支持 Turbopack 的解决方案,否则我们无法将 Fumadocs 迁移到 Turbopack。

  • 编译时验证:

    所有模式验证都无法在构建时进行,因为 MDX loader 实际上 不知道您在 source.ts 中定义的集合

    此外,Zod 模式被传递到 source.ts 中的 source adapter,而不是 loader:

    import { loader } from 'fumadocs-core/source';
    import { createMDXSource } from 'fumadocs-mdx';
    
    export const source = loader({
      source: createMDXSource(map, {
        // schema
      }),
    });

    这会丢失一些可以在打包器级别进行的性能优化。

  • 无类型:

    .map.ts 文件导出的 map 对象类型为 unknown,只有在使用 Source API 的 loader 时才会被类型化。

    这避免了自动生成类型的复杂性,但我希望让它具有类型化,并且无需 Source API 即可使用。

解决方案

参考 Content Collections 和 Velite,我发现为 Fumadocs MDX 有一个配置文件会很棒。

source.config.ts

我们可以使语法类似于 Content Collections 和其他工具,以使采用过程更容易。 要定义一个集合:

import { z } from 'zod';

export const blog = defineCollections({
  dir: './blog',
  schema: z.object({
    // the schema
  }),
  mdxOptions: {
    // remark plugins?
  },
});

MDX loader 读取配置文件,找到文件的对应集合,进行验证,并使用集合中的选项编译它。

这允许我们正常传递 MDX 选项,而不会破坏 Turbopack 的规则。

实现

由于配置文件是用 TypeScript 编写的,我们需要一个打包器来读取它。 我使用了 esbuild,它是一个用 Go 编写的性能出色的打包器。

在打包配置文件后,动态导入将按预期工作。

await import('./source.compiled.mjs');

.map 文件

我们需要一个地方来导入编译后的集合。 以前,我们简单地使用 Webpack 插件生成一个 .map.ts 文件。 它声明了类型,但没有实际数据。

export declare const map: unknown;

一个 loader 将用于将 .map.ts 文件转换为前面提到的输出:

export const map = {
  'docs/index.mdx': import('./docs/index.mdx'),
  'docs/guide.mdx': import('./docs/guide.mdx'),
};

生成的 .map.ts 永不变更,因为它不依赖配置文件。 无论您如何配置,都只会导出一个类型为 unknownmap 对象。

现在,我们需要为每个集合生成类型,并且类型可能会随着我们更改集合而变化。 以前的方法不再适用。

我将 .map.ts 文件重命名为 .source/indexindex.d.tsindex.js 都由 Fumadocs MDX 生成,而不是使用 loader。

实现了一个 map 文件生成器,它读取配置文件并基于导出的集合生成输出。

自动重新加载

我们希望监视更改:

  • 当输入文件添加/删除时,在 .source/index.js 文件中添加或删除相关条目。
  • 当配置文件更改时,重新编译受影响的文件,并更新 .source/index.d.ts 中的生成类型。

我选择了 chokidar 来监视文件更改,它工作良好。 文件监视器位于 next.config.mjs 中,它独立于 MDX loader。

为了在配置文件更改时通知打包器,我们添加了一个 hash

export const collection1 = [import('./docs/index.mdx?hash=hashOfConfigFile')];

当配置 hash 更改时,文件将被重新编译。

为了优化性能,我们还添加了集合名称。

export const collection1 = [
  import('./docs/index.mdx?hash=hashOfConfigFile&collection=collection1'),
];

loader 从 resource query 获取输入文件的集合,而无需额外步骤来检测其关联集合。

结果

将生成一个 .source/index 文件,它完全类型化。 当您修改配置文件时,文件将被重新编译。

  • 支持 Turbopack,我们在仓库中有一个使用 Turbopack 的小示例。
  • 多个集合,每个集合都有自己的 MDX 选项。
  • 运行时 + 构建时验证和转换。

问题

我认为还有改进的空间:

  • 使用 Turbopack 原生功能来打包配置文件?
  • 懒加载/导入 MDX 文件的主要内容?

请给我反馈关于 Fumadocs MDX 重设计的意见 ;)

Written by

Fuma Nama

At

Fri Sep 06 2024