基于 Next.js 建立文档站点

复杂项目的运营中,文档站点通常不可或缺,比如我正在开发维护的 WonderPen 软件,虽然我一直努力让各个功能都尽可能简明易懂,但当软件的功能复杂到一定程度之后,用户对文档的需求仍然与日俱增,因为总有一些功能难以做到一看就明白,同时也总有一些功能的入口不会直接显示在主界面,如果没有说明,用户可能要花很长时间才能发现。

为了构建文档站点,我尝试过很多方案,最后选择了基于 Next.js 自建,本文记录了一些相关经历。

需求

文档站点主要用于存放产品的使用和帮助说明,内容一般是纯静态的,可以没有用户留言、评论等模块,从功能角度来讲比较简单。

对文档站点,我的需求主要有以下几点:

  1. 最好能使用 Markdown 写文档;
  2. 支持多语言(暂时只有中文、英文的需求);
  3. 能深度定制样式;
  4. 能提前批量修改或新建文档,然后再在指定的时间统一发布更新。

其中第一、二、三点无需多说,关于第四点“批量修改、统一发布”,应该是软件或类似分版本发布的产品共同的需求。举个例子,软件的下一个版本可能有很多变化,文档也需要做对应的变更,比如修改相关的功能说明(也许涉及几个至几十个页面),更新对应的界面截图等等,并且需要在新版发布时同步更新上线。

对这样的需求,使用语雀等在线知识库就不是很合适,虽然它们的功能很强大,但发布粒度是基于单个文档的,很难实现批量修改多个文档,并在指定时间统一发布。目前,要实现这个需求,基本上需要使用专门的文档站点方案,如 VuePress、Docusaurus 等,结合 Git 等工具来管理文档版本,发布时只需将对应的仓库版本推送到站点发布服务即可。当然,也有一些托管的方案,比如 GitBook、Read the Docs 等,但这些托管方案通常只对开源项目免费,同时在样式风格上也有很多限制。

一些之前的尝试

最早的时候,我使用 VuePress 来构建文档站点,因为这个方案对前端开发来说非常简单。后来,发现 Docusaurus 支持更多功能,于是我又将文档站点迁移到了 Docusaurus。几个月前注意到新发布的 VitePress 界面挺好看,也动过迁移到 VitePress 的念头,可惜 VitePress 对多语言的支持迟迟没有完全实现(注:VitePress 于 2023-01-17 发布的 1.0.0-alpha.37 版中已支持多语言),于是暂时放弃。

Docusaurus 等方案虽然很强大,也提供了很多自定义项,但使用了一段时间后,我也遇到了一些问题,主要如下:

  1. 站点的信息结构比较适合呈现技术文档,方便技术人员阅读,但对普通用户不是很友好;
  2. 虽然提供了很多自定义选项,但要让风格和自己的网站足够匹配仍然比较难。

在比较了各大流行的文档站点方案后,我逐渐觉得要让文档站点真正合心,最好的选择应该还是花一点时间基于某个基础框架自建。

自建文档站点

有了自建的想法之后,首先就是方案的选择。一开始,我考察了 Stripe 团队出的 Markdoc 方案,这个方案看起来很强大,上手也不复杂,内容以及样式都可以完全自定义,但是,Markdoc 对 Markdown 的语法做了一些扩展,可以说是一种新的 Markdown 方言了,我比较担心如果用这种方案实现了文档站点,将来万一又想迁移到其他方案,迁移工作量恐怕会很大。

最后,经过综合考虑,我决定放弃 Markdoc 等方案,而是基于 Next.js 直接实现一个文档站点,在这个站点中,文档仍然是以 Markdown 格式编写和保存,用户在访问对应路径时看见的是渲染之后的 HTML 页面。

技术实现

这样一个站点的原理其实非常简单,它就是一个普通的 Next.js 站点项目,目录结构如下所示:

site/
├─ docs/
│  ├─ wonderpen/
│  │  ├─ guides/
│  │  │  ├─ editor.zh.md
│  │  │  ├─ editor.en.md
├─ pages/
│  ├─ index.tsx
│  ├─ [...slug].tsx
├─ ...

省略的部分与常规 Next.js 站点一样,不同之处在于有一个 docs 目录,所有 Markdown 格式的文档都放在这个目录下,每个 .md 文件都将对应一个文档页面。同时,在 pages 目录下有一个 [...slug].tsx 文件,当收到请求时,如果 pages 下没有直接匹配的路径,Next.js 会将请求转给 [...slug].tsx 处理,而 [...slug].tsx 则会去 docs 目录下查找是否有匹配的 Markdown 文件,如果有,则将其渲染为 HTML 页面,否则返回 404 错误。

整个流程如下图所示:

文件组织

上面的例子中所有 Markdown 文件都被放在了根目录下的 docs 目录下,这不是必须的,你也可以放在其他你喜欢的目录,只要后续在 [...slug].tsx 中能根据请求路径读取到对应的文件即可。

同时,不同语言的 Markdown 文件也被放在了一起,只在文件名后缀中加以区分,比如 editor.zh.md 是中文文档,editor.en.md 是对应的英文文档。

在 Docusaurus 等方案中,不同语言的翻译文件需要放在专门的 i18n 目录下,比如:i18n/en/docusaurus-plugin-content-docs/current/guides/editor.mdx 。这种方式将多语言文件当作类似插件或游戏中的 MOD 来处理,每种语言的文件都在自己单独的文件夹中,方便管理和多人协作,各语言的翻译者只要处理自己目录下的文件即可,但在实践中,这种组织方式有时也会带来不便。

其中一个不便是,这个文档库的各种语言都是由同一个人(我)维护的,多数情况下,各个语言的文档需要同步更新,当我修改了某个文档的中文版时,它对应的英文版常常也需要同时修改。在 Docusaurus 中,不同语言的文件位于不同的文件夹中,改完一个语言后,需要在复杂的目录树中寻找其他语言对应的文件,也许对不同语言文件由不同人员维护的场景来说这样比较方便,但如果是由同一个人负责维护更新,那么这个寻找操作就有点麻烦了。所以,为什么不直接将同一个文档不同语言的版本放在一起,只是用不同的后缀来区分呢?

文件格式

参考 Docusaurus 等方案,我在每个 Markdown 文件的头部以 YAML 的格式放置了一些元信息,如下所示:

---
title: 编辑器
---

Markdown 正文……

文件头部分还可以根据需要添加更多自定义信息,比如文档图标等等。解析文件时,先读取使用 --- 标记出的文件头,用 YAML 库解析,然后再读取后面的 Markdown 正文,使用 markdown-it 等库渲染为 HTML。

文件读取

这个方案最关键的部分,是获取请求 URL 中的路径,读取对应的 Markdown 文件并渲染。

这部分的工作,可以在 [...slug].tsxgetServerSideProps 中处理。主要代码如下:

export async function getServerSideProps(
  ctx: GetServerSidePropsContext,
): Promise<GetServerSidePropsResult<IProps>> {
  const { query, locale } = ctx
  let slug = query.slug

  let key = Array.isArray(slug) ? path.join(...slug) : slug
  if (!key) {
    return { notFound: true }
  }

  // parseDocFile 负责读取 Markdown 文件并解析
  let data = await parseDocFile(key, locale || 'en')
  if (!data) {
    return { notFound: true }
  }

  return {
    props: {
      title: data.metadata.title,
      content: data.html,
    },
  }
}

其中 parseDocFile 的主要代码如下:

export default async function parseDocFile(
  key: string,
  locale: string = 'en',
): Promise<IDocFile | null> {
  let fn = `${key}.${locale}.md`
  if (fn.startsWith('/')) {
    fn = fn.slice(1)
  }

  fn = path.join(base_dir, 'docs', fn)
  let data = cached[fn] || ''
  if (data) {
    return data
  }

  if (!isFile(fn)) {
    return null
  }

  try {
    let source = await fs.promises.readFile(fn, 'utf-8')
    let data = parseSource(source)
    if (!is_dev) {
      // 缓存数据,提升速度
      cached[fn] = data
    }
    return data
  } catch (e) {
    console.error(e)
    return null
  }
}

一些关键点如下:

  1. base_dir 需要使用 process.cwd() 获取,而不能用 __dirname 等路径再计算相对路径;
  2. 由于站点正式发布之后 Markdown 内容不会变化,因此可以将解析及渲染结果缓存起来,让每个路径只在首次访问时读取并渲染一次,以提升运行效率。

具体的解析和渲染工作是在 parseSource() 方法中实现,这部分就是简单地调用相关库(比如 markdown-it),此处就不再赘述了。

至此,构建一个支持 Markdown 的文档站点的关键部分就完成了,当然,要让这个站点更易用,还有不少细节工作需要完善,比如实现导航索引等功能。

这个方案原理很简单,不过对小型文档站点而言已经足够,能完全满足我的需求,由于站点页面完全由自己控制,因此页面样式可以根据需要深度定制,确保与主站的整体风格保持一致。

唯一的不足,是本地开发时,修改 Markdown 文件后页面不能自动刷新,不过这个小缺点对我而言影响不大。

小结

在研究和使用了多种文档站点方案之后,我开始尝试基于 Next.js 自建文档站点方案。

自建方案的技术原理很简单,取得用户请求的路径,寻找对应的 Markdown 文件,解析并渲染为 HTML 。

最后,这个自建的文档站点已经上线,欢迎访问 WonderPen 帮助中心 查看效果。

分类:编程标签:JavaScriptNext.js

相关文章:

评论:

Zihan

astro.build

monkeytao

+10086

monkeytao

astro.build +10086

发表评论: