【从 0 到 1 搭建 Vue 组件库框架】6. 建立带有 Demo 示例功能的文档网站 - 上

2,479 阅读18分钟

导航

导航:0. 导论

上一章节: 5. 设计组件库的样式方案 - 下

下一章节:6. 建立带有 Demo 示例功能的文档网站 - 下

本章节示例代码仓:Github

本章节文档展示效果:gkn1234.github.io/openx-ui/

目标

本章,我们来搭建组件库的文档。文档相当于组件库的使用说明书,是用户了解组件用法最直接的渠道,想要做得体验良好,功能强大,是需要花费一些心思的。

element-plus 为例,成熟的组件库文档应当具有以下能力:

1. 能够美观地展示章节与文本,给用户良好的浏览体验,同时方便作者进行开发。

element-plus-content.png

element-plus 的文档一方面样式美观,浏览体验优秀;另一方面它的持续更新无需代码开发,只需不断维护代码仓中的 Markdown 文件,即可生成承载文档与章节内容的静态页面。

element-plus-edit-doc.png

目前市面上常用于生成项目文档的,将 markdown 文件转换为静态网站的工具非常多,例如 GitBookMkDocs,当然在前端领域的 Vue 生态中,我们更加熟悉 VuePressVitePress

2. 能够清晰地展示组件用例。

element-plus-demo.png

用户需要参考组件用例,来快速直观地掌握组件的用法。组件用例一般包含效果渲染部分和源码展示部分。

如果我们能够实现展示用例渲染的热更新,那么文档也能很好地辅助开发者的预览组件效果,大大提高开发调试的效率。

3. 能够提供组件的 API 说明。

element-plus-api1.png

element-plus-api2.png

API 文档是各种库的标配,对于 Vue 组件来说,API 文档需要描述组件对外暴露的通信接口:Props 属性Emits 事件Slots 插槽Exposes 实例 API

如果我们能够实现根据源码自动生成 API 文档,将会大幅减少文档编写的工作量,降低文档维护的成本,同时文档的时效性也得到了很好的保证。

4. 具备代码演练场功能,方便用户在线调试。

element-plus-playground1.png

element-plus-playground2.png

每一个组件用例都能够跳转到对应的代码演练场,使用户可以即刻对该用例进行在线调试。

本章我们会努力以上面归纳出的功能特性为目标,实现一个相对完善的组件库文档。

使用 VitePress 搭建组件库文档

我们知道,使用静态文档生成工具来开发文档网页,是一种开发效率与文档美观度之间的最佳平衡。这里我们选用 VitePress 来实现组件库的文档。

VitePress 是一款静态站点生成器(SSG),专为构建快速、以内容为中心的网站而设计。简而言之,VitePress 获取用 Markdown 编写的源内容,为其添加主题样式,生成可以轻松部署在任何地方的静态 HTML 页面。

GitBookMkDocs 相比,VitePress 的上手难度更高,但是其配置更加灵活,主题样式也更加美观。最重要的是,由于 VitePress 主要基于 Vue 生态,对于 Vue 应用会有非常多的优化与拓展能力,这降低了我们实现复杂展示效果的门槛。

本文不集中讲解 VitePress 的用法和使用技巧,而是会根据我们的搭建进度与需求,将工具使用方面的知识进行穿插讲解。

初始化文档工程

我们在 1. 基于 pnpm 搭建 monorepo 工程目录结构 曾经建立过文档项目 @openxui/docs,但是一直没有为其生成内容。现在我们要开始在先前的基础上继续操作,首先来安装 VitePress

pnpm --filter @openxui/docs i -D vitepress

接下来我们参考 VitePress - 快速开始 中的内容来配置我们的文档工程,但是由于我们的项目是 monorepo 的结构,所以无法完全照搬,需要对 VitePress 的初始化有一定的理解。

我们在 docs 目录下建立这样的文件结构:

📦docs
 ┣ 📂.vitepress
 ┃ ┗ config.mts       # VitePress 主要配置文件
 ┣ 📂public           # 静态资源目录 
 ┃ ┣ 📜favicon.ico
 ┃ ┗ 📜logo.png
 ┣ 📜index.md         # 首页文件
 ┗ 📜package.json

我们来对目录结构以及重要文件进行说明:

  • docs/ 作为根目录 Project Root(参考:VitePress - 根目录),则 VitePress 会固定读取 docs/.vitepress/config.mts(<Project Root>/.vitepress/config.mts) 作为配置文件。
// docs/.vitepress/config.mts
import { defineConfig } from 'vitepress'

// 配置参考:https://vitepress.dev/reference/site-config
export default defineConfig({
  title: "OpenxUI",
  description: "Vue3 组件库",
  themeConfig: {}
})
  • package.json 中配置启动脚本时(参考:VitePress CLI),要注意将 Project Root (docs/,相对路径为 .)指定为 VitePress 工作目录:
// docs/package.json 实战时请清除注释
{
  "name": "@openxui/docs",
  "private": true,
  "scripts": {
    "dev": "vitepress dev . --host",
    "build": "vitepress build .",
    "preview": "vitepress preview . --host"
  },
  "dependencies": {},
  "devDependencies": {
    "vitepress": "1.0.0-rc.22"
  }
}
  • 由于我们没有配置 config.srcDir,markdown 的源目录 Source Directory 默认与根目录一致,因此 docs/index.md(<Source Directory>/index.md) 将作为首页 markdown 文件。
<!-- docs/index.md -->
---
# VitePress 支持在 markdown 中通过 Frontmatter 写 yaml 配置,设置页面的主题样式
# https://vitepress.dev/reference/frontmatter-config#frontmatter-config

# VitePress 默认主题的首页模板:https://vitepress.dev/reference/default-theme-layout#home-layout
layout: home

title: OpenxUI

hero:
  name: OpenxUI
  text: Vue3 组件库
  tagline: 从 0 到 1 搭建 Vue 组件库
  image:
    src: /logo.png
    alt: OpenX
  actions:
    - theme: brand
      text: 指南
    - theme: brand
      text: 组件
    - theme: brand
      text: API 文档
    - theme: brand
      text: 演练场
    - theme: alt
      text: Github
---

vitepress-index.png

之后,运行 pnpm --filter @openxui/docs run dev,我们就能够启动开发服务器,看到上图所展示的首页效果。

值得注意的是,VitePress 运行后会产生许多缓存文件,我们不应该将它们提交到代码仓中,因此需要再 docs 目录下建立 .gitignore 文件忽略缓存目录:

# docs/.gitignore
/.vitepress/cache

规划文档路由

VitePress 可以根据 markdown 文件的目录结构自动生成路由(参考:VitePress - 基于文件路径的路由),我们根据组件库文档的需求,划分了以下目录结构,并建立对应的 markdown 文件:

📦docs
 ┣ 📂...
 ┣ 📂guide                  # 组件使用指南
 ┃ ┣ 📜index.md             # 介绍,路由:/guide/
 ┃ ┣ 📜quick-start.md       # 快速开始。路由:/guide/quick-start.html
 ┃ ┗ 📜...  
 ┣ 📂api                    # API 文档,路由:/api/xxx
 ┃ ┗ 📜... 
 ┣ 📂components             # 组件用例文档,路由:/components/xxx
 ┃ ┣ 📜index.md             # 组件用例首页,路由:/components/
 ┃ ┣ 📜button.md            # 按钮组件用例。路由:/components/button.html
 ┃ ┣ 📜input.md            # 输入组件用例。路由:/components/input.html
 ┃ ┣ 📜config-provider.md   # 配置组件用例。路由:/components/config-provider.html
 ┃ ┗ 📜...
 ┣ 📜index.md               # 首页文件,路由:/
 ┣ 📜playground.md          # 演练场页面,路由:/playground.html
 ┗ 📜package.json

接下来,我们可以给首页的按钮配置跳转链接:

<!-- docs/index.md -->
---
# ... 省略其他内容

hero:
  # ... 省略其他内容

  actions:
    - theme: brand
      text: 指南
+     link: /guide/
    - theme: brand
      text: 组件
+     link: /components/
    - theme: brand
      text: API 文档
+     link: /api/
    - theme: brand
      text: 演练场
+     link: /playground
    - theme: alt
      text: Codehub
+     link: https://open.codehub.huawei.com/innersource/OpenxUI/openx-ui/files?ref=master
---

同时也在 docs/.vitepress/config.mts 中配置标题栏导航到对应的路由:

// docs/.vitepress/config.mts
import { defineConfig } from 'vitepress'

export default defineConfig({
  // ...
  themeConfig: {
    // 新增 themeConfig.nav 头部导航配置
    // 参考:https://vitepress.dev/reference/default-theme-nav#navigation-links
    nav: [
      { text: '指南', link: '/guide/' },
      { text: '组件', link: '/components/' },
      { text: 'API', link: '/api/' },
      { text: '演练场', link: '/playground' },
    ],
  }
})

大家可以在创建出来的 markdown 文档中填写一些内容,之后在本地环境中测试一下路由跳转效果:

vitepress-nav-test.gif

然而,我们的文档虽然有了正确的路由,但是内容区域的左侧似乎显得有些空白,缺少章节索引。组件用例文档的左侧边栏,应该列出所有的组件索引,能够方便我们跳转到任意一个组件的用例章节。这就需要配置 VitePress 默认主题中的 Sidebar

// docs/.vitepress/config.mts
import { defineConfig } from 'vitepress'

export default defineConfig({
  // ...
  themeConfig: {
    // 新增 themeConfig.sidebar 文档章节导航配置
    // 参考:https://vitepress.dev/reference/default-theme-sidebar#multiple-sidebars
    sidebar: {
      // 指南部分的章节导航
      '/guide/': [
        {
          text: '指引',
          items: [
            { text: '组件库介绍', link: '/guide/' },
            { text: '快速开始', link: '/guide/quick-start' },
          ],
        },
      ],
      // 组件部分的章节导航
      '/components/': [
        {
          text: '组件',
          items: [
            { text: 'Button 按钮', link: '/components/button' },
          ],
        },
      ]
    }
  }
})

vitepress-sidebar-test.gif

如果还有对 VitePress 的默认主题进行更多设置的需要,建议完整阅读文档中的相关配置部分:VitePress - 设置默认主题

到此,我们就搭建起了一个组件库文档的大体框架,可以往其中填写文档内容了。但是,我们还需要做很多努力来提高用户的浏览体验,以及我们自己的开发与维护的体验,这就需要通过下面的章节进行更加深入的实践,完成一些特殊的“定制功能”。

实现组件用例展示功能

VitePress 中展示 Vue 组件用例,需要对 VitePress 进行一些拓展。在思考如何实现时我考虑过下面的方案:

  1. 使用开源的 VitePress 插件 vitepress-theme-demoblock,直接达成开箱即用的集成。
  2. 参考 element-plus 的实现方案,将相关的代码迁移到自己的项目中。
  3. 根据自己的实际需求,手写相关的功能拓展代码。

经过我对 vitepress-theme-demoblock 的体验,并且参考了 element-plus 的实现代码,我发现这个功能的实现原理并不复杂,正好利用了 VitePress 的能力。

下面,我们将手写代码,渐进地展示这个功能的实现过程:

  • 先纯粹用 VitePress 的能力实现用例展示;
  • 再实现 <Demo> 组件为用例的展示增加更多细节;
  • 最后再进入到 markdown 编译的环节,探索如何拓展语法,将用例文件的路径转译为 VitePress 支持的内容。

一旦我们了解了其中的原理,我们就有能力按照自己的需要,自由地对 VitePress 做更多的魔改定制。

用例展示准备

在上一章(5. 设计组件库的样式方案 - 下),我们在 demo/src/App.vue 中书写了 Button 按钮的一个用例,我们试着将它迁移到文档工程中。

我们建立 docs/demo 目录来归纳组件样例的实现,并将上一章的用例写入 docs/demo/demo1.vue 中:

📦demo
 ┣ 📂button         # Button 组件用例
 ┃ ┣ 📜demo1.vue 
 ┃ ┗ 📜...
 ┣ 📂input          # Input 组件用例
 ┃ ┗ 📜...
 ┗ 📂...            # 其他组件用例

<!-- docs/demo/button/demo1.vue -->
<script setup lang="ts">
import { ref, reactive } from 'vue';
import {
  Button,
  Input,
  ConfigProvider,
  useTheme,
  tinyThemeVars,
  themeVars,
  OpenxuiCssVarsConfig,
} from '@openxui/ui';

const { setTheme } = useTheme();

const currentGlobalTheme = ref<'default' | 'tiny'>('default');

function switchGlobalTheme() {
  if (currentGlobalTheme.value === 'tiny') {
    currentGlobalTheme.value = 'default';
    setTheme(themeVars);
  } else {
    currentGlobalTheme.value = 'tiny';
    setTheme(tinyThemeVars);
  }
}

const currentSecondLineTheme = ref<'default' | 'tiny'>('default');
const secondLineThemeVars: OpenxuiCssVarsConfig = reactive({});
function switchSecondLineTheme() {
  if (currentSecondLineTheme.value === 'tiny') {
    currentSecondLineTheme.value = 'default';
    Object.assign(secondLineThemeVars, themeVars);
  } else {
    currentSecondLineTheme.value = 'tiny';
    Object.assign(secondLineThemeVars, tinyThemeVars);
  }
}
</script>

<template>
  <div>
    <div class="btns">
      <Button>Button</Button>
      <Button type="primary">
        Button
      </Button>
      <Button type="success">
        Button
      </Button>
      <Button type="danger">
        Button
      </Button>
      <Button type="warning">
        Button
      </Button>
      <Button type="info">
        Button
      </Button>
    </div>
    <ConfigProvider class="btns" :theme-vars="secondLineThemeVars">
      <Button plain>
        Button
      </Button>
      <Button type="primary" plain>
        Button
      </Button>
      <Button type="success" plain>
        Button
      </Button>
      <Button type="danger" plain>
        Button
      </Button>
      <Button type="warning" plain>
        Button
      </Button>
      <Button type="info" plain>
        Button
      </Button>
    </ConfigProvider>
    <div class="btns">
      <Button disabled>
        Button
      </Button>
      <Button type="primary" disabled>
        Button
      </Button>
      <Button type="success" disabled>
        Button
      </Button>
      <Button type="danger" disabled>
        Button
      </Button>
      <Button type="warning" disabled>
        Button
      </Button>
      <Button type="info" disabled>
        Button
      </Button>
    </div>
    <div class="btns">
      <Button @click="switchGlobalTheme">
        切换全局主题,当前:{{ currentGlobalTheme }}
      </Button>
      <Button @click="switchSecondLineTheme">
        切换第二行主题1,当前:{{ currentSecondLineTheme }}
      </Button>
    </div>
    <div>
      <i class="i-op-alert inline-block text-100px c-primary" />
      <i class="i-op-alert-marked inline-block text-60px c-success" />
    </div>
    <Input />
  </div>
</template>

<style lang="scss" scoped>
.btns {
  :deep(.op-button) {
    margin-bottom: 10px;

    &:not(:first-child) {
      margin-left: 10px;
    }
  }
}
</style>

为了在文档工程中正确展示我们的用例,我们还需要提前做一些其他准备:

我们要在文档项目中引入组件库主包的依赖:

pnpm --filter @openxui/docs i -S @openxui/ui

根据本系列第二章(2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下)中定下的构建思路,我们要设置好 TypeScriptVite,将 @openxui/xxx 的内部依赖引用定位到对应的源码,从而实现组件展示的热更新。

// docs/tsconfig.json
{
  // 集成基础配置
  "extends": "../tsconfig.base.json",
  "compilerOptions": {
    "baseUrl": ".",
    "lib": ["ESNext", "DOM", "DOM.Iterable"],
    "types": [],
    // 因为 baseUrl 改变了,基础配置中的 paths 也需要一并重写
    "paths": {
      // 将 @openxui/xxx 内部依赖定位到源码路径
      "@openxui/*": ["../packages/*/src"]
    }
  },
  "include": [
    // 文档应用会引用其他子模块的源码,因此都要包含到 include 中
    "../packages/*/src",
    ".vitepress/**/*",
    ".vitepress/**/*.md",
    // demo 目录存放用例代码
    "demo/**/*",
    // 脚本目录,之后会涉及
    "scripts/**/*"
  ],
  "exclude": ["**/dist", "**/cache"]
}

// docs/vite.config.mts
import { defineConfig } from 'vite';
import { join } from 'node:path';
import unocss from 'unocss/vite';

export default defineConfig({
  plugins: [
    // 应用组件库的 unocss 预设,配置文件在根目录的 uno.config.ts
    // 集成 UnoCss 方便我们编写组件用例,或者实现 VitePress 专用组件
    unocss(),
  ],
  resolve: {
    alias: [
      {
        // 将 @openxui/xxx 内部依赖定位到源码路径
        find: /^@openxui\/(.+)$/,
        replacement: join(__dirname, '..', 'packages', '$1', 'src'),
      },
    ],
  },
});

之后,我们要按照文档 VitePress - 拓展默认主题 中的说明,建立 docs/.vitepress/theme/index.ts 文件,对 VitePress 主题进行一些扩展:

  • CSS 方面,由于组件样式已经在源码入口中导出,我们只需要应用组件库的 UnoCSS 预设即可(UnoCSS 相关可以参考 5. 设计组件库的样式方案 - 下)。
  • 我们还要在这里设置好 Vue 的 App 实例,这里我们注册了组件库提供的主题插件,以后还可能在这里注册其他的插件或者全局组件。
// docs/.vitepress/theme/index.ts
import DefaultTheme from 'vitepress/theme';
import { EnhanceAppContext } from 'vitepress';
import { Theme } from '@openxui/ui';

// 应用组件库的 unocss 预设,配置文件在根目录的 uno.config.ts
import 'virtual:uno.css';

export default {
  ...DefaultTheme,
  enhanceApp(ctx: EnhanceAppContext) {
    DefaultTheme.enhanceApp(ctx);

    const { app } = ctx;
    // 应用组件库提供的主题插件
    app.use(Theme);

    // 注册其他插件、全局组件、provide...
  },
};

仅用 VitePress 的能力展示用例

VitePress 针对对 Vue 做了特殊的支持和优化,能够在 markdown 中直接渲染 Vue 组件(参考:VitePress - 使用 Vue 组件)。

同时,VitePress 拓展了 markdown 语法,支持通过 <<< filePath 的写法,将 filePath 路径对应的文件中的内容以代码块的形式渲染出来(参考:VitePress - 引入代码片段)。

于是,我们就可以在按钮的用例展示文档 docs/components/button.md 中,用 VitePress 的原生能力实现用例展示:

<!-- docs/components/button.md -->
<script setup>
import demo1 from '../demo/button/demo1.vue'
</script>

# Button 按钮

常用的操作按钮。

## 基础用法

基础的按钮用法。

<!-- 展示组件 -->
<demo1></demo1>

<!-- 展示源码 -->
<<< ../demo/button/demo1.vue

## Button API

查看运行效果,我们看到无论是用例的渲染还是源码都能够正确展示,并且用例渲染还能响应组件源码的修改做到热更新。如果对展示的体验要求不高的话,其实这种程度就已经可以达到基本需求了。

button-demo-hmr.gif

实现用例展示组件 <Demo>

为了让文档的体验朝着我们的榜样靠拢,我们需要对用例的展示进行优化:

  • 给整个展示区域增加样式,例如边框;
  • 源码展示部分支持显示和隐藏;
  • 为用例相关的更多操作提供拓展空间。

button-demo-element.png

因为 Vue 的组件具有 插槽 Slots 的特性,能够支持我们将自定义的片段渲染到组件的指定位置,我们可以考虑将整个用例展示功能封装为组件 <Demo>

我们建立 docs/.vitepress/components 目录来存放 VitePress 文档功能相关的组件:

📦.vitepress
 ┣ 📂components
 ┃ ┣ 📜Demo.vue
 ┃ ┗ 📜index.ts
 ┣ 📂theme
 ┃ ┗ 📜index.ts
 ┗ 📜config.mts

我们可能会发现,在 .vitepress 目录内部的 .ts.vue 文件都不再有 ESLint 提示,这是因为 ESLint 会默认忽略 . 开头的目录。我们建立 docs/.eslintignore 文件来处理这个问题:

# docs/.eslintignore
# eslint 会自动忽略 . 开头的路径或文件,必须强行指定放开
!.vitepress

# 产物和缓存依然要忽略
.vitepress/dist
.vitepress/cache

接下来,我们编写代码实现组件功能:

<!-- docs/.vitepress/components/Demo.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue';

function toPlayground() {
}

const isCodeShow = ref(false);
</script>

<template>
  <div class="demo">
    <div class="demo-render">
      <!-- 用例渲染插槽 -->
      <slot name="demo" />
    </div>
    <!-- 各种功能操作,如展开源码,跳转到 Playground 等 -->
    <div class="demo-operators">
      <i class="i-op-code-screen" title="在 Playground 中编辑" @click="toPlayground" />
      <i class="i-op-code" title="查看源代码" @click="isCodeShow = !isCodeShow" />
    </div>
    <div v-if="isCodeShow" class="demo-code">
      <!-- 用例源码插槽 -->
      <slot name="code" />

      <div class="pb-16px text-center" @click="isCodeShow = false">
        <a href="javascript:;" class="cursor-pointer c-info! no-underline!">隐藏源代码</a>
      </div>
    </div>
  </div>
</template>

<style scoped lang="scss">
.demo {
  border: 1px solid rgb(var(--op-color-bd_light));
  border-radius: 4px;
}

.demo-render {
  padding: 20px;
}

.demo-operators {
  display: flex;
  justify-content: flex-end;
  padding: 16px;
  font-size: 18px;
  color: rgb(var(--op-color-secondary));
  border-top: 1px solid rgb(var(--op-color-bd_light));

  i {
    cursor: pointer;

    + i {
      margin-left: 16px;
    }
  }
}
</style>

// docs/.vitepress/components/index.ts
import Demo from './Demo.vue';

export {
  Demo,
};

我们可以将 <Demo> 注册为全局组件,方便在每一篇文档中都可以直接使用:

// docs/.vitepress/theme/index.ts
// ...
+ import { Demo } from '../components';

export default {
  // ...
  enhanceApp(ctx: EnhanceAppContext) {
    // ...

+   app.component('Demo', Demo);
  },
};

完成全局注册后,我们将 docs/components/button.md 中的用例改为使用 <Demo> 组件展示。

<!-- docs/components/button.md -->
<script setup>
import demo1 from '../demo/button/demo1.vue'
</script>

# Button 按钮

常用的操作按钮。

## 基础用法

基础的按钮用法。

<Demo>
  <template #demo>
    <demo1></demo1>
  </template>
  <template #code>

  <<< ../demo/button/demo1.vue

  </template>
</Demo>

## Button API

修改后的展示效果如下:

vitepress-demo-test.gif

注意,在上面的例子中,<<< ../demo/button/demo1.vue 上下的空行是必须的,且不能再有额外的缩进,否则根据 markdown 语法的 CommonMark 规范,它将被渲染成纯文本而非 markdown 元素。

根据用例的文件路径渲染出对应的展示内容

element-plus 实现的用例展示功能比起我们目前的效果还要更进一步:它拓展了 markdown 的语法,只需要在成对的 :::demo & ::: 中间写入用例的文件路径,就可以渲染出完整的用例展示效果:

element-plus-demo-md.png

通过文档 VitePress - 更多 Markdown 拓展 我们了解到,VitePress 内置的 markdown 解析模块是 markdown-it,通过给 markdown-it 编写插件,实现了诸多的语法拓展。

markdown-it 这类工具的原理与各种编译器类似,都是通过编译过程解析源码字符串,生成语法树。再读取语法树内容,按照指定的规则输出产物。

flowchart TB
  source源码 -->|parse编译| AST语法树 -->|transform转译| 目标内容

当然,markdown-it 对这些概念和数据结构有所简化,它们将 parse 编译 遵循的规则定义为 Rules,将类似 AST 语法树 的数据结构定义为 Token,将渲染的函数称为 RendererRuleTokenRenderer 三者有着一一对应的关系,即每一种特定的语法规则都有自己对应的数据结构以及渲染函数。

flowchart TB
  markdown文本 -->|Rules| Token -->|Renderer| HTML内容

回顾我们的需求,我们应当先拓展编译规则 Rules,使 markdown-it 能够识别下面的语法,并从中解析出用例组件的路径:

:::demo
../demo/button/demo1.vue
:::

接着我们要将识别出的组件路径,通过对应的 Renderer 转译为 VitePress 本身支持的内容:

  • 一部分是用例组件引入的 import 语句。
  • 另一部分是 <Demo> 组件的使用,在两个插槽中分别填充用例渲染与源码展示。
<script setup>
import demo1 from '../demo/button/demo1.vue'
</script>

<Demo>
  <template #demo>
    <demo1></demo1>
  </template>
  <template #code>

  <<< ../demo/button/demo1.vue

  </template>
</Demo>

确定了基础的思路后,我们参考学习他人的实现过程,发现通过 markdown-it-container 可以便捷地实现对 markdown-it 的拓展,实现自定义块级容器的解析与渲染规则:

// markdown-it-container 使用参考
import md from 'markdown-it';
import mdContainer from 'markdown-it-container';

md.use(mdContainer, 'demo', {
  validate(params) {
    // Rules 拓展,自定义识别规则。
    // 匹配 ::: 后的内容,返回 true 代表正确识别
    return params.trim().match(/^demo\s+(.*)$/);
  },
  render(tokens, idx) {
    // Renderer 拓展
    // 当遍历 Token 列表,访问到 ::: 的开始与闭合的 Token 时,触发此函数。
    if (tokens[idx].nesting === 1) {
      // opening tag
      return '<Demo>';

    }
    // closing tag
    return '</Demo>';
  }
});

下面,我们正式开始实现完整的功能。第一步,安装我们所需要的工具:

pnpm --filter @openxui/docs i -S markdown-it markdown-it-container
pnpm --filter @openxui/docs i -D @types/markdown-it

第二步,建立 docs/.vitepress/plugins 目录,存放我们的 markdown-it 拓展插件:

📦docs
 ┣ 📂.vitepress
 ┃ ┣ 📂plugins
 ┃ ┃ ┣ 📜index.ts
 ┃ ┃ ┣ 📜mdDemoPlugin.ts          # 处理将新语法转换为 <Demo> 组件
 ┃ ┃ ┣ 📜mdPlugin.ts              # markdown-it 插件,集合了 mdDemoPlugin 和 mdScriptSetupPlugin
 ┃ ┃ ┗ 📜mdScriptSetupPlugin.ts   # 处理用例组件通过 <script setup> 引入文档。
 ┃ ┗ 📂...
 ┗ 📜...

第三步,利用 markdown-it-container,实现 :::demo 语法的识别,并正确转换成 <Demo> 组件的使用:

// docs/.vitepress/plugins/mdDemoPlugin.ts
import type MarkdownIt from 'markdown-it';
import mdContainer from 'markdown-it-container';
import type Token from 'markdown-it/lib/token';
import type Renderer from 'markdown-it/lib/renderer';
import { basename, dirname, resolve } from 'node:path';
import { readFileSync } from 'node:fs';

export interface ContainerOpts {
  marker?: string | undefined
  validate?(params: string): boolean
  render?(
    tokens: Token[],
    index: number,
    options: any,
    env: any,
    self: Renderer
  ): string
}

export function mdDemoPlugin(md: MarkdownIt) {
  md.use(mdContainer, 'demo', <ContainerOpts>{
    validate(params) {
      return Boolean(params.trim().match(/^demo\s*(.*)$/));
    },

    render(tokens, idx, options, env, self) {
      const token = tokens[idx];

      // 不考虑 :::demo 的嵌套情况,碰到深层嵌套直接放弃渲染
      if (token.level > 0) return '';

      // :::demo 开启标签时触发
      if (token.nesting === 1) {
        // 获取到 :::demo 内部的路径
        let sourceFilePath = getInnerPathFromContainerToken(tokens, idx);
        // @ 开头代表以 docs 目录为基准定位
        if (sourceFilePath.startsWith('@')) {
          sourceFilePath = sourceFilePath.replace('@', process.cwd());
        }
        // 转换为绝对路径
        sourceFilePath = resolve(dirname(env.path), sourceFilePath);

        // 根据文件路径获取组件名称
        const [componentName, ext = 'vue'] = basename(sourceFilePath).split('.', 2);

        // 读取用例组件源码
        const sourceCode = readFileSync(sourceFilePath, 'utf-8');

        // 将用例组件源码渲染成对应的 Html,准备插入 <Demo> 组件的 code 插槽
        const sourceCodeHtml = self.rules.fence?.(
          // 将源码拼接成 markdown 的代码块形式
          // 调用 md.parse() 将代码块转换成对应的 Token
          // 调用代码块渲染的 Renderer —— renderer.rules.fence(),生成源码展示 Html
          md.parse(`\`\`\`${ext}\n${sourceCode}\n\`\`\``, env),
          0,
          options,
          env,
          self,
        );

        // 拼接 <Demo> 组件的使用代码
        const txt = `<Demo>
          <template #demo><${componentName} /></template>
          <template #code>${sourceCodeHtml}</template>
        `;
        return txt;
      }
      // 读取到 :::demo 闭合的 Token 时,输出闭合 </Demo> 标签
      return '</Demo>';
    },
  });
}

/** 当读取到 :::demo 开启的 Token 时,解析出内部的用例组件文件路径 */
export function getInnerPathFromContainerToken(
  tokens: Token[],
  idx: number,
) {
  const innerPathToken = tokens[idx + 2];
  return innerPathToken.content.trim();
}

由于 markdown-it-container 这个库没有类型文件,我们简单为它创建一个类型声明 docs/.vitepress/env.d.ts,避免 IDE 的 TS 报错。

// docs/.vitepress/env.d.ts
declare module 'markdown-it-container' {
  import type { PluginWithParams } from 'markdown-it';

  const container: PluginWithParams;
  export = container;
}

第四步,我们要处理如何生成 <script setup>,并在其中填入用例组件的 import 语句。element-plusvitepress-theme-demoblock 都采用 Vite 插件在编译过程中完成这个操作,我们可以采用 markdown-it 插件统一实现:

// docs/.vitepress/plugins/mdScriptSetupPlugin.ts
import type MarkdownIt from 'markdown-it';
import { basename } from 'node:path';
import { getInnerPathFromContainerToken } from './mdDemoPlugin';

export function mdScriptSetupPlugin(md: MarkdownIt) {
  // markdown-it 插件一般都是通过重写 render 函数实现,这里先暂存原本的 render 方法。
  const defaultRender = md.renderer.render;
  const defaultHtmlBlockRender = md.renderer.rules.html_block;

  // <script setup> 检验正则
  const reg = /^<script\s(.+\s)?setup(\s.*)?>([\s\S]*)<\/script>/;

  md.renderer.render = (tokens, options, env, ...rests: any[]) => {
    const [
      renderScriptSetup = true,
    ] = rests;

    if (!renderScriptSetup) {
      return defaultRender(tokens, options, env);
    }

    let match: RegExpMatchArray | null = null;

    const demoImports: string[] = [];
    // 遍历所有 Token
    for (let i = 0; i < tokens.length; i++) {
      const token = tokens[i];
      // 匹配 md 文件中原有的 <script setup>
      const scriptSetupMatch = token.content.trim().match(reg);
      if (scriptSetupMatch && !match) {
        match = scriptSetupMatch;
      }

      if (
        token.type !== 'container_demo_open' ||
        token.nesting !== 1 ||
        token.level > 0
      ) continue;

      // 对于 :::demo 块,读取文件路径,生成 import 语句。
      const sourceFilePath = getInnerPathFromContainerToken(tokens, i);
      const componentName = basename(sourceFilePath).split('.')[0];
      demoImports.push(`import ${componentName} from '${sourceFilePath}';`);
    }

    const [
      ,
      setupPre = '',
      setupPost = '',
      code = '',
    ] = match || [];

    // 拼接生成 <script setup> 代码
    const scriptSetupCode = `<script ${setupPre}setup${setupPost}>
    ${demoImports.join('\n')}
    ${code}
    </script>`;

    return defaultRender(
      // 将 <script setup> 模块提到 Token 列表的头部进行渲染
      [...md.parse(scriptSetupCode, env), ...tokens],
      options,
      env,
    );
  };

  md.renderer.rules.html_block = (tokens, idx, options, env, self) => {
    // 因为经处理后的 <script setup> 已被提到队首,所以其他 <script setup> 将不被渲染
    if (reg.test(tokens[idx].content) && idx !== 0) {
      return '';
    }
    return defaultHtmlBlockRender?.(tokens, idx, options, env, self) || '';
  };
}

实现了主要逻辑后,我们在出口对插件进行整合与导出。

// docs/.vitepress/plugins/mdPlugin.ts
import type MarkdownIt from 'markdown-it';
import { mdDemoPlugin } from './mdDemoPlugin';
import { mdScriptSetupPlugin } from './mdScriptSetupPlugin';

export const mdPlugin = (md: MarkdownIt) => {
  md.use(mdDemoPlugin);
  md.use(mdScriptSetupPlugin);
};

// docs/.vitepress/plugins/index.ts
export * from './mdPlugin';

最后,我们在 docs/.vitepress/config.mts 中应用我们的 markdown-it 插件。

// docs/.vitepress/config.mts
import { defineConfig } from 'vitepress';
+import { mdPlugin } from './plugins';

export default defineConfig({
  // ...
+ markdown: {
+   config: (md) => {
+     md.use(mdPlugin);
+   },
+ },
  // ...
});

用例展示效果

我们将 docs/components/button.md 中的用例展示部分修改为最新的形式:引用用例组件的文件路径。

<!-- docs/components/button.md -->
# Button 按钮

常用的操作按钮。

## 基础用法

基础的按钮用法。

:::demo

../demo/button/demo1.vue

:::

## Button API

待补充

展示效果和之前是一样的,这里就不再重复贴图了,渲染出来的组件依然可以响应源码的修改进行热更新。

但美中不足的是,用例源码内容的展示无法热更新,需要重启 VitePress 的开发服务器才能刷新,不过我们对用例源码热更新的需求并不大。

结尾与资料汇总

上篇我们初步用 VitePress 搭建了组件库文档,并实现了关键的组件用例展示的功能,基本接近了主流开源组件库文档的效果。由于篇幅有限,对文档而言同样非常重要的API 说明代码演练场的功能,我们将在下半篇实现。

下一章节:6. 建立带有 Demo 示例功能的文档网站 - 下

本章涉及到的相关资料汇总如下:

官网与文档:

element-plus 组件库

VitePress

VuePress

GitBook

MkDocs

Markdown 指南

Vue 官方文档

vitepress-theme-demoblock 用例展示插件

markdown-it

markdown-it-container

CommonMark 规范