likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 7

导航

本章节示例代码仓:Github

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

目标

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

使用 VitePress 搭建组件库文档

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

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

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

本文不集中讲解 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
---

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

之后,运行 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 文档中填写一些内容,之后在本地环境中测试一下路由跳转效果:

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

然而,我们的文档虽然有了正确的路由,但是内容区域的左侧似乎显得有些空白,缺少章节索引。组件用例文档的左侧边栏,应该列出所有的组件索引,能够方便我们跳转到任意一个组件的用例章节。这就需要配置 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' },
          ],
        },
      ]
    }
  }
})

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

如果还有对 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 做更多的魔改定制。

用例展示准备

我们建立 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
// 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 主题进行一些扩展:

// 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

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

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

实现用例展示组件 <Demo>

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

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

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

因为 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

修改后的展示效果如下:

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

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

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

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

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

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

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

parse编译
transform转译
source源码
AST语法树
目标内容

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

Rules
Renderer
markdown文本
Token
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 说明代码演练场的功能,我们将在下半篇实现。

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

官网与文档:

element-plus 组件库

VitePress

VuePress

GitBook

MkDocs

Markdown 指南

Vue 官方文档

vitepress-theme-demoblock 用例展示插件

markdown-it

markdown-it-container

CommonMark 规范