导航
导航:0. 导论
上一章节: 5. 设计组件库的样式方案 - 下
下一章节:6. 建立带有 Demo 示例功能的文档网站 - 下
本章节示例代码仓:Github
本章节文档展示效果:gkn1234.github.io/openx-ui/
目标
本章,我们来搭建组件库的文档。文档相当于组件库的使用说明书,是用户了解组件用法最直接的渠道,想要做得体验良好,功能强大,是需要花费一些心思的。
以 element-plus 为例,成熟的组件库文档应当具有以下能力:
1. 能够美观地展示章节与文本,给用户良好的浏览体验,同时方便作者进行开发。
element-plus 的文档一方面样式美观,浏览体验优秀;另一方面它的持续更新无需代码开发,只需不断维护代码仓中的 Markdown 文件,即可生成承载文档与章节内容的静态页面。
目前市面上常用于生成项目文档的,将 markdown
文件转换为静态网站的工具非常多,例如 GitBook、MkDocs,当然在前端领域的 Vue
生态中,我们更加熟悉 VuePress 与 VitePress。
2. 能够清晰地展示组件用例。
用户需要参考组件用例,来快速直观地掌握组件的用法。组件用例一般包含效果渲染部分和源码展示部分。
如果我们能够实现展示用例渲染的热更新,那么文档也能很好地辅助开发者的预览组件效果,大大提高开发调试的效率。
3. 能够提供组件的 API 说明。
API 文档是各种库的标配,对于 Vue 组件来说,API 文档需要描述组件对外暴露的通信接口:Props 属性、Emits 事件、Slots 插槽、 Exposes 实例 API。
如果我们能够实现根据源码自动生成 API 文档,将会大幅减少文档编写的工作量,降低文档维护的成本,同时文档的时效性也得到了很好的保证。
4. 具备代码演练场功能,方便用户在线调试。
每一个组件用例都能够跳转到对应的代码演练场,使用户可以即刻对该用例进行在线调试。
本章我们会努力以上面归纳出的功能特性为目标,实现一个相对完善的组件库文档。
使用 VitePress 搭建组件库文档
我们知道,使用静态文档生成工具来开发文档网页,是一种开发效率与文档美观度之间的最佳平衡。这里我们选用 VitePress 来实现组件库的文档。
VitePress 是一款静态站点生成器(SSG),专为构建快速、以内容为中心的网站而设计。简而言之,
VitePress
获取用 Markdown 编写的源内容,为其添加主题样式,生成可以轻松部署在任何地方的静态 HTML 页面。
与 GitBook、MkDocs 相比,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
---
之后,运行 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
默认主题中的 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
的默认主题进行更多设置的需要,建议完整阅读文档中的相关配置部分:VitePress - 设置默认主题
到此,我们就搭建起了一个组件库文档的大体框架,可以往其中填写文档内容了。但是,我们还需要做很多努力来提高用户的浏览体验,以及我们自己的开发与维护的体验,这就需要通过下面的章节进行更加深入的实践,完成一些特殊的“定制功能”。
实现组件用例展示功能
在 VitePress
中展示 Vue
组件用例,需要对 VitePress
进行一些拓展。在思考如何实现时我考虑过下面的方案:
- 使用开源的
VitePress
插件 vitepress-theme-demoblock,直接达成开箱即用的集成。 - 参考 element-plus 的实现方案,将相关的代码迁移到自己的项目中。
- 根据自己的实际需求,手写相关的功能拓展代码。
经过我对 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 - 下)中定下的构建思路,我们要设置好 TypeScript
和 Vite
,将 @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
查看运行效果,我们看到无论是用例的渲染还是源码都能够正确展示,并且用例渲染还能响应组件源码的修改做到热更新。如果对展示的体验要求不高的话,其实这种程度就已经可以达到基本需求了。
实现用例展示组件 <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
修改后的展示效果如下:
注意,在上面的例子中,<<< ../demo/button/demo1.vue
上下的空行是必须的,且不能再有额外的缩进,否则根据 markdown 语法的 CommonMark 规范,它将被渲染成纯文本而非 markdown 元素。
根据用例的文件路径渲染出对应的展示内容
element-plus 实现的用例展示功能比起我们目前的效果还要更进一步:它拓展了 markdown 的语法,只需要在成对的 :::demo
& :::
中间写入用例的文件路径,就可以渲染出完整的用例展示效果:
通过文档 VitePress - 更多 Markdown 拓展 我们了解到,VitePress
内置的 markdown 解析模块是 markdown-it,通过给 markdown-it
编写插件,实现了诸多的语法拓展。
markdown-it
这类工具的原理与各种编译器类似,都是通过编译过程解析源码字符串,生成语法树。再读取语法树内容,按照指定的规则输出产物。
flowchart TB
source源码 -->|parse编译| AST语法树 -->|transform转译| 目标内容
当然,markdown-it
对这些概念和数据结构有所简化,它们将 parse 编译
遵循的规则定义为 Rules,将类似 AST 语法树
的数据结构定义为 Token,将渲染的函数称为 Renderer。Rule
、Token
、Renderer
三者有着一一对应的关系,即每一种特定的语法规则都有自己对应的数据结构以及渲染函数。
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-plus
和 vitepress-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 示例功能的文档网站 - 下
本章涉及到的相关资料汇总如下:
官网与文档: