导读

本文所述依赖如下的库包及其版本

包名 版本号
next 14.2.15
react 18.2.0
react-dom 18.2.0
@mdx-js/lodaer 3.1.0
@mdx-js/react 3.1.0
@next/mdx 15.0.2
@types/mdx 2.0.13
remark-gfm 4
remark-frontmatter 5.0.0
rehype-highlight 7.0.1
next-mdx-remote 5.0.0

本文的开发环境****基于 Macbook Pro M1 MacOS 14.6.1。

本地渲染支持

由于我们的文档除了从packages/**加载的动态文档,还有next.js内部固定的文档。让我们先实现next.js内部的markdown解析和mdx的支持。

安装依赖与本地配置

参考官方网站的配置

pnpm add @next/mdx @mdx-js/loader @mdx-js/react @types/mdx

配置文件参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import createMDX from '@next/mdx'

/** @type {import('next').NextConfig} */
const nextConfig = {
   reactStrictMode:true,
   output:'standalone',
   images:{
       //github pages 无法对图像优化
       unoptimized:true
  },
   //都是对应仓库名<reposity-name>
   // basePath:"/react-components",
   // assetPrefix:"/react-components",
   //支持这些后缀作为文件名
   pageExtensions:["js","jsx","ts","tsx","md","mdx"]
};

const withMDX = createMDX({
   extension: /\.mdx?$/,
   // Add markdown plugins here, as desired
})

export default withMDX(nextConfig);

注意如上的配置中的extension: /\.mdx?$/ ,其表示以.md.mdx为后缀的页面会被next.js解析。

再****在项目的根目录下添加文件: mdx-components.tsx

1
2
3
4
5
6
7
import type { MDXComponents } from 'mdx/types'

export function useMDXComponents(components: MDXComponents): MDXComponents {
 return {
   ...components,
}
}

**接着,添加 **app/docs/page.md页面,输入一些内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 导读

本文基于以下包和版本配置:

|             包名               | 版本号 |
| :-----------------------------: | :-----: |
|             next               | 14.2.15 |
|             react             | 18.2.0 |
|           react-dom           | 18.2.0 |
|           tailwindcss           | 3.4.1 |
|         @changesets/cli         | 2.27.9 |
|         @commitlint/cli         | 19.5.0 |
| @commitlint/config-conventional | 19.5.0 |
|             husky             | 9.1.6 |
|           typescript           | 5.4.4 |


本文介绍的开发环境是**Macbook Pro M1 MacOS 14.6.1**。

# 项目启动与打包验证

## 创建项目

创建项目,使用next 14.2.15

```bash
npx [email protected]

使用app router的模式

本地运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

于是,可在`http://lcoalhost:3000/docs`路径下看到

![](https://cdn.nlark.com/yuque/0/2024/png/25532991/1730298694262-ae364fe9-2859-455b-b57d-3103000ef5a5.png)

如果把上面`next.config.js`配置文件中的`extension: /\.mdx?$/`干掉,你就会得到一个next.js提示的编译错误:

![](https://cdn.nlark.com/yuque/0/2024/png/25532991/1730298940780-6f108254-fdc0-443c-ac47-643fa39126ef.png)

## 插件的使用
注意到:上方的文档样式着实太丑了,且断行不对、代码没有格式化🤢;因此我们可以选择使用一些插件,本部分[参考官方文档](https://nextjs.org/docs/pages/building-your-application/configuring/mdx#remark-and-rehype-plugins)。

remark用来处理markdown文档,用来进行ast解析等,[可以在github中找到有趣的插件](https://github.com/remarkjs/remark/blob/main/doc/plugins.md#list-of-plugins)。

rehype用来处理html,[可在github中找到有趣的插件](https://github.com/rehypejs/rehype/blob/main/doc/plugins.md#list-of-plugins)。

本节使用的插件配置如下:

```tsx
import createMDX from '@next/mdx'
import remarkGfm from 'remark-gfm'
import remarkFrontmatter from 'remark-frontmatter'
import rehypeHighlight from 'rehype-highlight'
...
const withMDX = createMDX({
  extension: /\.mdx?$/,
  options:{
      //处理md象github那样,出来formatter语法
      remarkPlugins:[remarkGfm,remarkFrontmatter],
      rehypePlugins:[rehypeHighlight]
  }
  // Add markdown plugins here, as desired
})

再****在**src/layout.tsx**文件中新增highlight的样式文件

1
import "highlight.js/styles/lightfair.css"

**重新访问 **http://lcoalhost:3000/docs

看起来确实美观得多了🎆。

按照官网的教程,这里的page.md还可以写成page.mdx,这里就不再赘述,请自行查阅官方文档。

加载其它库包下的文档

前置知识串讲

上文的内容几乎都是官方文档中的内容,而我们真正要做的,是****加载来自**packages/**/docs/index.md**这个路径下的文档。以项目为例,需要加载packages/image-gallery/docs/index.md,并显示在页面上,

目标是访问/docs/image-gallery时能加载这个markdown文件,也就是packages/image-gallery/docs/index.md

这里要重点说明:由于组件库包都是已知的,对应的文档就是已知的,加上我们的页面还是部署在github pages上,且应该是个静态的页面,每次更新文档或者组件库都会重新构建。这类构建方式是SSG,而非SSR服务端渲染。

如果你不了解什么是SSR,什么是SSG,傻傻分不清楚,请看看这篇文章《一文搞懂:什么是SSR、SSG、CSR?前端渲染技术全解析》

为了将我们的应用以SSG的方式构建,我们需要在next.config.js将output设置为 export

接着,要构建SSG,我们肯定要告知next.js 当前存在哪些页面,也就是明确哪些组件库是有文档的。

由于我们最终通过/docs/[slug]路径访问,这个[slug]可能是image-gallery也可能是color-pciker,因此[slug]是个动态路径,于是我们的next.js也需要使用动态路由的方式构建我们的文档(PS:如果你对nextjs的路由不够了解,可以参考@神说要有光 大佬《Next.js 的路由为什么这么奇怪?》)。

于是新建一个src/app/docs/[slug]/page.tsx页面文件,其除了页面渲染函数外,还有一个用于SSG指定构建路径的函数 generateStaticParams(),该函数在next.js以SSG构建时执行。详情参考官方文档

一开始,如果没有指定generateStaticParams()函数,启动页面就会报错:

加上这个generateParams()函数,但是只返回空数组:

又是不同的报错信息:

给定一个默认的对象,并在页面正常时显示 slug的匹配值:

此时页面正常显示:

但是!访问/docs/image-gallery 还是不行的。

除非我们也把它加上我们的generateStaticParams()函数的返回值:

这下页面也正常了!

到了这一步,你可能对generateStaticParams()和动态路由[slug]有了一定的了解,其底层其实是一种**results.include([slug])**匹配机制。而且,假若我们需要对packages/**下的每个组件库更新文档,每次都要来到这个src/app/docs/[slug]/page.tsx文件下,修改一下generateStaticParams()函数的返回值,你为了简化操作可能会有:

但是这样操作显然不太友好,这样的命令式更新实在是蛋疼(看着就头疼)!

而大多数组件库,如果你更新文档都是去仓库编辑对应的.md文件就好了,并不会要求开发者做复杂的额外配置。因此,我们需要约定一种方式,实现声明式地更新文档。

动态读取可用路径并加载.md文档的内容

**其实,上文中,已经对这个声明式做了说明 **packages/[slug]/docs/index.md,我们构建时自动识别这样的路径,取出[slug]的值并以合法的形式作为generateStaticParams()的返回值。

首先,读取packages路径下所有的路径,如果子路径存在/docs/index.md 这样的文件,我们就过滤为一个数组,并以合法的格式返回。

详细的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import fs from "fs"
import path from "path"

export async function generateStaticParams(props){
   const files =fs.readdirSync(path.join(process.cwd(),'packages'))
      .filter(file=>fs.existsSync(path.join(process.cwd(),'packages',file,'docs','index.md')))
   return files.map(file => ({ slug: file } ))
}


export default function Page(props){
   const {params} = props
   return <div>slug:{params.slug}</div>
}

接着我们的页面就是能正常访问的了。

接着,在Page()页面渲染函数,我们确保了已经拿到合法的路径,到这里,我们可以直接读取这份.md文件。

Page()函数代码如下:

1
2
3
4
5
6
7
8
9
export default async function Page({params}:{params:{slug:string}}){
   const slug = params.slug;
   const content = await fs.promises.readFile(path.join(process.cwd(),'packages',slug,'docs','index.md'), 'utf-8');

   return <>
       <div>slug:{params.slug}</div>
       <div>{content}</div>
   </>
}

到这里,我们已经打通了只要每新增组件,并按照要求放置.md文档(packages/**/docs/index.md),就可以实现动态访问了。

但是目前,我们加载的是原生的markdown文件内容,并没有处理为正确的html并支持组件化渲染,要实现这一点,这是下一节中的内容。

安装next-mdx-remote

上一节实现了加载packages/**/docs/index.md这个路径下的文档,本文将继续探索完成html部分的渲染。

第一步,参考官方的教程,我们使用next-mdx-remote

安装下载它:

pnpm add next-mdx-remote -w

之后在src/app/docs/[slug]/page.tsx页面中使用:

1
2
3
4
5
6
7
8
9
10
11
12
import {MDXRemote} from "next-mdx-remote/rsc"
...
export default async function Page({params}:{params:{slug:string}}){
   const slug = params.slug;
   const content = await fs.promises.readFile(path.join(process.cwd(),'packages',slug,'docs','index.md'), 'utf-8');

   return <>
       <div>slug:{params.slug}</div>
      {/*<div>{content}</div>*/}
       <MDXRemote source={content} />
   </>
}

打开浏览器,访问/docs/color-picker,看到渲染成功了

必须要强调的是:import 是从next-mdx-remote/rsc导入的MDXRemote,而不是直接从next-mdx-remote导入。虽然next-mdx-remote也可以导出MDXRemote组件,但是用法完全不同。具体异同笔者也不是很清楚,可以参考官方文档自行研究

远程加载后的文档美化

接着,笔者又发现一新的问题,在上述部分(本地渲染支持)中,笔者在next.config.js中配置的remarkPluginsrehypePlugins没有生效。因为它们只处理本地加载的.md或者.mdx文档,不处理“远程”加载的文件内容,于是,该项目也需要配置这些插件。

参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import fs from "fs"
import path from "path"
import {MDXRemote} from "next-mdx-remote/rsc"
import remarkGfm from "remark-gfm";
import remarkFrontmatter from "remark-frontmatter";
import rehypeHighlight from "rehype-highlight";

export async function generateStaticParams(props){
   const files =fs.readdirSync(path.join(process.cwd(),'packages'))
      .filter(file=>fs.existsSync(path.join(process.cwd(),'packages',file,'docs','index.md')))
   return files.map(file => ({ slug: file } ))
}


export default async function Page({params}:{params:{slug:string}}){
   const slug = params.slug;
   const content = await fs.promises.readFile(path.join(process.cwd(),'packages',slug,'docs','index.md'), 'utf-8');

   return <>
       <div>slug:{params.slug}</div>
      {/*<div>{content}</div>*/}
       <MDXRemote source={content} options={{
           mdxOptions:{
               remarkPlugins:[remarkGfm,remarkFrontmatter],
               rehypePlugins:[rehypeHighlight]
          }
      }}/>
   </>
}

可以看到,MDXRemote里的配置和next.config.js完全一致,继续刷新浏览器,可以看到上线了正确的断行还有highlight代码块解析。

为了后续方便,笔者将MDXRemote的使用封装为组件RemoteConntent,并放置到/src/components/marddown/RemoteContent.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import remarkGfm from "remark-gfm";
import remarkFrontmatter from "remark-frontmatter";
import rehypeHighlight from "rehype-highlight";
import {MDXRemote} from "next-mdx-remote/rsc";

const RemoteContent:React.FC<{source:string}>=({source})=>{
   return <MDXRemote source={source} options={{
       mdxOptions:{
           remarkPlugins:[remarkGfm,remarkFrontmatter],
           rehypePlugins:[rehypeHighlight]
      }
  }}/>
}

export default RemoteContent

具体使用,在/src/app/docs/[slug].page.tsx中,使用RemoteContent替换MDXRemote

1
2
3
4
5
6
7
8
9
10
11
12
13
import RemoteContent from "@/components/markdown/RemoteContent"
...
export default async function Page({params}:{params:{slug:string}}){
   const slug = params.slug;
   const content = await fs.promises
      .readFile(path.join(process.cwd(),'packages',slug,'docs','index.md'), 'utf-8');

   return <>
       <div>slug:{params.slug}</div>
      {/*<div>{content}</div>*/}
       <RemoteContent source={content} />
   </>
}

至此,markdown解析的集成算是搞定了,按上一篇的打包说明,尝试看看打包后是正常的不。

**执行 **pnpm build命令后,得到了一个报错:

这是我们的项目中ts不允许使用any,在项目根目录下的tsconfig.json

**文件中新增 **noImplicitAny:false即可

可以看到,打包是成功了的。

接着,我们cd 到打包生成的out目录,它就是我们使用SSG模式打包后输出的所有静态资源。

允许serve命令,可以通过npm i -g serve安装,它将帮助我们在此目录下生成一个web服务器,就好像配置了nginx一样,接着就可以在浏览器中验证我们的功能了!

**在浏览器中访问 **http://localhost:3000/docs/image-gallery,显示是正常的。

说明引入的markdown功能开发和部署下都是没问题的,就可以放心地把代码推送到仓库了。

可通过github查看相关的代码,可回退到本次提交记录。(git reset --hard 3c0361a)

本文小结

本文介绍了next.js 如何加载next.js项目中的.md的markdown文档和从外部加载markdown字符串并解析。

接着,声明式指定加载packages/**/docs/index.md路径的markdown字符串,并使用remark.js和rehype.js插件来实现markdown的美化和代码高亮。

参考文档

  1. 一文搞懂:什么是SSR、SSG、CSR?前端渲染技术全解析
  2. Next.js 的路由为什么这么奇怪?
  3. 使用 Next.js 搭建 Monorepo 组件库文档