基于Vite我开发了一个文档工具,真香
写在前面
想必大家都听过或者用过下面的文档工具
- VuePress
- VitePress
- dumi
- docz
但是使用这些工具生成的文档都千篇一律,作为一个有追求的前端,必须自己撸一个
看完这篇文章你将学会
- 🎨 将md文件渲染成html
- 📋 在md中集成react组件
- 📦 封装成npm工具,开箱即用
顺带还能体验下vite,了解下主题切换的方案
感兴趣的可以访问 vvmodal 体验
搭建项目
抱着开源的态度,首先我们需要给我们的工具取一个响当当的名字,为了避免名字被占用,可以使用
npm info xxx
去测试名称是否被占用,出现404那么恭喜你,这个名字属于你了
先用 Vite
快速生成一个项目
> yarn create vite vvdoc --template react-ts
Markdown生成页面
Markdown
生成页面的方案有很多,这里我使用 mdx
去实现,点击了解MDX
> yarn add @mdx-js/mdx @mdx-js/react @mdx-js/rollup
在 vite
中配置 mdx
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig(async () => {
const mdx = await import('@mdx-js/rollup')
return {
plugins: [
react({
jsxRuntime: 'automatic',
}),
mdx.default({
jsxRuntime: 'automatic',
providerImportSource: '@mdx-js/react'
})
],
optimizeDeps: {
include: [
'react/jsx-runtime' // 因为这个文件不会显示引入,所以需要让vite提前预编译
]
},
}
})
在 src
下新建 docs
文件夹,创建 index.mdx
,开始编写第一个文档
## 第一篇文章
我是一个简单的md文档
1. 我用mdx生成
2. 我能直接写react组件
<button>点击我</button>
import { useState } from 'react'
import Home from './docs/index.mdx'
function App() {
return (
<div className="App">
<Home />
</div>
)
}
export default App
访问 http://localhost:3000
查看效果
到此 Markdown
生成页面的功能就完成了,是不是 so easy
美化文档
仅仅只是生成 html
是不够的,毕竟默认的样式也太丑了,我们需要加点样式来美化
美化HTML
这里我使用 theme-ui
来丰富文档,点击了解 theme-ui
> yarn add theme-ui @emotion/react
配置 theme
主题,这里的 theme-ui
主要是动态的生成 style
,如果不使用第三方插件,直接写一份 css
也可以实现
import { useState } from 'react'
import Home from './docs/index.mdx'
import { ThemeProvider } from 'theme-ui'
import { makeTheme } from '@theme-ui/css/utils'
const theme = makeTheme({
colors: {
text: '#383838',
primary: '#a862ea'
},
styles: {
body: {
color: 'text',
fontSize: 15,
lineHeight: '30px',
wordBreak: 'break-word'
},
h2: {
fontSize: '1.2em',
margin: '24px 0 12px',
color: 'primary'
},
ul: {
pl: '2em',
},
li: {
pl: '0.2em',
'::marker': {
color: 'primary'
}
}
}
})
function App() {
return (
<ThemeProvider theme={theme}>
<div className="App">
<Home />
</div>
</ThemeProvider>
)
}
export default App
然后我们看下效果
这样我们样式也处理好了,可以按照自己的喜好编写自己的主题
美化代码
搞定了基础的html,现在我们来处理代码预览
首先我们要知道 Markdown
中的代码预览会被转成
<pre>
<code></code>
</pre>
代码高亮用的最多的就是 prism
我们安装下
> yarn add @theme-ui/prism
美化下 pre
标签,然后将 code
标签 转换成 Prism
组件,最后引入 Prism
组件的 style
import React from 'react'
import Home from './docs/index.mdx'
import { ThemeProvider } from 'theme-ui'
import { makeTheme } from '@theme-ui/css/utils'
+ import Prism from '@theme-ui/prism'
+ import pre from '@theme-ui/prism/presets/dracula.json'
const theme = makeTheme({
...
+ code: {
+ fontFamily: 'monospace',
+ fontSize: 1,
+ },
+ pre: {
+ p: 3,
+ fontSize: 3,
+ lineHeight: 'body',
+ ...pre
+ }
})
+ const components = {
+ pre: ({ children }: { children: React.ReactNode }) => <>{children}</>,
+ code: Prism,
+ }
function App() {
return (
<ThemeProvider theme={theme} components={components}>
<div className="App">
<Home />
</div>
</ThemeProvider>
)
}
export default App
然后我们看下效果
组件预览
实现了基本的文档渲染,现在我们再来实现下组件预览
明确下组件预览需要的功能
- 组件被正常渲染且能交互
- 能查看组件源代码
相信大家都看过 antd
等组件库的文档,都有代码预览的功能,大部分都是基于 codesandbox
去实现,由于我们基于 mdx
所以可以很简单的实现这个功能
-
第一步先实现组件渲染
首先新建
playground
文件夹存放组件,新建一个Demo.jsx
import { Button } from "theme-ui"; import { useState } from "react"; export const Demo = () => { const [count, setCount] = useState(0) return ( <> <p>{count}</p> <Button onClick={() => setCount(val => ++val)}>+</Button> </> ) }
-
mdx
中导入组件## 第一篇文章 我是一个简单的md文档 - 我用mdx生成 - 我能直接写react组件 import { Demo } from '../playground/Demo'; <Demo />
这里需要注意 `import` 语句下面必须留一个**空行**
看下效果
显示组件源码
-
先获取源代码
## 第一篇文章 我是一个简单的md文档 - 我用mdx生成 - 我能直接写react组件 import { Demo } from '../playground/Demo'; + import text from '../playground/Demo?raw'; <Demo /> + <p>{text}</p>
使用
raw
loader
拿到文件的文本内容 直接渲染到html
上 -
美化下源代码
## 第一篇文章 我是一个简单的md文档 - 我用mdx生成 - 我能直接写react组件 import { Demo } from '../playground/Demo'; import text from '../playground/Demo?raw'; + import Prism from '@theme-ui/prism' <Demo /> + <Prism className="tsx">{text}</Prism>
沿用上面用到的
Prism
组件来代替p
标签
封装成 Playground 组件
虽然实现了组件预览,但是每次都这么写也太繁琐了,所以我们来封装成一个组件
预览组件包含下面几个功能
- 自动获取组件源码
- 自动获取组件的语言用来高亮语法
- 美化下样式,左边展示代码,右边展示预览
创建 Playground 组件
.
└── Playground
├── Code.tsx
├── Preview.tsx
└── index.tsx
// index.tsx
import { Flex } from "theme-ui";
import { Preview } from "./Preview";
import { Code } from './Code'
export const Playground = (props: { url: string }) => {
return (
<Flex>
<Code url={props.url}/>
<Preview url={props.url} />
</Flex>
)
}
// Code.tsx
import Prism from '@theme-ui/prism'
import { useEffect, useState } from "react";
import { Box } from "theme-ui";
const alias: { [key: string]: string } = {
'js': 'javascript',
'sh': 'bash'
}
export const Code = (props: { url: string }) => {
const { url } = props
const [code, setCode] = useState<string>('');
const extname = url.split('.').pop()
if (!extname) {
throw new Error(`${url}格式错误,没有后缀名`)
}
useEffect(() => {
import(`../../${url}?raw`).then(module => {
setCode(module.default)
})
}, [])
return (
<Box sx={{ flex: 1 }}>
<Prism className={alias[extname] || extname}>
{code}
</Prism>
</Box>
)
}
// Preview.tsx
import { Box } from "theme-ui";
import React, { Suspense, lazy } from "react";
export const Preview = (props: { url: string }) => {
const { url } = props
const Comp = lazy(() => import(`../../${url}`))
return (
<Box
p={2}
sx={{
bg: 'muted',
overflow: 'auto',
flex: 1,
my: 3
}}
>
<Suspense fallback={<div>loading</div>}>
<Comp/>
</Suspense>
</Box>
)
}
使用预览组件
我是一个简单的md文档
- 我用mdx生成
- 我能直接写react组件
<Playground url="/playground/Demo.tsx"/>
最后看下效果
最后可以发挥你们自己的才能,美化这个预览组件
渲染文档的部分就到此结束,下面我们来实现路由,头部,页脚等公共组件
文档网站构建
路由
写过 umi
或者 next.js
的都知道约定式路由,现在我们就按照这个方法来实现文档的路由
首先需要满足如下需求
- 自动生成 navbar
- 自动生成 sidebar
- 自动生成路由配置
然后我们约定好文件夹的命名和结构所对应的路由
- index.mdx 对应 /
- xxx.mdx 对应 /xxx
- xxx/index.mdx 对应 /xxx/
- xxx/xxx.mdx 对应 /xxx/xxx
约定所有文档都写在 docs
目录下
接下来开始撸代码
其实整体思路非常简单,我们不采用 umi
这种在编译阶段生成路由配置的做法,而是默认所有路由存在,等进入页面的时候获取 path
按照 path
去找对应规则的 mdx
文件,找到了直接渲染,没找到则展示 404
import React, { useMemo } from 'react'
import { Route, Routes, useLocation } from 'react-router-dom'
const pages = import.meta.globEager('/src/docs/**/*.mdx')
const NotFount: React.FC = () => <div>404</div>
function pathToFile(path: string): React.ComponentType {
let module
if (path.endsWith('/')) {
module = findFile(path + 'index') || findFile(path.substring(0, path.lastIndexOf('/')))
} else {
module = findFile(path) || findFile(path + '/index')
}
if (module) {
return module.default
}
return NotFount
}
function findFile(path: string): any {
return pages['/src/docs' + path + '.mdx']
}
export default () => {
const location = useLocation()
const Element = useMemo(() => {
return pathToFile(location.pathname)
}, [location.pathname])
return (
<Routes>
<Route path={location.pathname} element={<Element/>}/>
</Routes>
)
}
+ import { BrowserRouter } from 'react-router-dom'
+ import DocsRoute from './routes'
function App() {
return (
<ThemeProvider theme={theme} components={components}>
<div className="App">
+ <BrowserRouter>
+ <DocsRoute/>
+ </BrowserRouter>
</div>
</ThemeProvider>
)
}
下面我们测试下,创建如下目录的文件
.
├── apis
│ └── index.mdx
├── about.mdx
└── index.mdx
效果如下
配置头部和侧边栏
其实这里就没啥好说的,就写组件,前端在行,唯一值得说的是,navbar 和 sidebar 需要在配置文件中配置好,怎么去加载这个配置文件
配置文件
配置文件我们见过很多,基本格式如下
- vvdoc.config.json
- vvdoc.config.js
- vvdoc.config.ts
- .vvdocrc
加载 js 和 json 很简单,直接 import 即可,如果想加载 ts 类型的配置文件,则可以在先使用 fs 获取到配置文件源码,编译以后引入,这里我推荐两个库
本次教程里面我们就简单点,直接使用 json 格式的配置文件
新建 vvdoc.config.json
{
"title": "vvModal",
"logo": "",
"repository": "https://github.com/zwmmm/vvModal",
"menus": [
{
"text": "首页",
"active": "^/",
"path": "/"
},
{
"text": "API",
"active": "^/apis",
"path": "/apis/"
}
],
"chapters": {
"/apis/": [
{
"name": "Apis",
"children": [
{
"name": "create",
"path": "/apis/"
},
{
"name": "show",
"path": "/apis/show"
},
{
"name": "antdModal",
"path": "/apis/antdModal"
},
{
"name": "antdDrawer",
"path": "/apis/antdDrawer"
}
]
},
{
"name": "Hooks",
"children": [
{
"name": "useModal",
"path": "/apis/useModal"
},
{
"name": "useShow",
"path": "/apis/useShow"
},
{
"name": "useHide",
"path": "/apis/useHide"
}
]
}
]
}
}
修改 vite 配置,读取配置文件内容,并且合并默认配置
import { resolve } from 'path';
import * as fs from 'fs';
const root = process.cwd()
const configName = 'vvdoc.config.json'
const configPath = resolve(root, configName)
const config = {
title: "vvDoc",
logo: "",
repository: "zwmmm/vvDoc",
menus: {},
chapters: {},
htmlTags: []
}
if (fs.existsSync(configPath)) {
Object.assign(config, JSON.parse(fs.readFileSync(configPath, 'utf-8')))
}
接下来是关键
如何在前端拿到配置文件的内容?
基于强大的 vite
这都不是问题,自定义一个插件实现
export default defineConfig(async () => {
const mdx = await import('@mdx-js/rollup')
return {
plugins: [
...,
+ {
+ name: 'vvdoc',
+ load(id) {
+ if (id === '/@config') {
+ return `export default ${JSON.stringify(config)}`
+ }
+ }
+ },
],
resolve: {
alias: {
'config': '/@config'
}
}
}
})
增加 ts
类型
declare module 'config' {
interface ChapterType {
name: string
path?: string
children?: ChapterType[]
}
const config: {
title: string
logo: string
repository: string
menus: {
text: string,
active: string,
path: string
}[]
chapters: Record<string, ChapterType[]>,
base: string
}
export default config
}
别忘记修改 tsconfig.json
{
"paths": {
"config": "/@config"
}
}
最后我们写前端代码
增加一个 config.ts
// 这里的config其实只是个别名,最终访问的是 /@config 这个路径前面已经被我们拦截
import { default as _config } from 'config';
export let config = _config
创建一个 Header 组件看下是否能拿到
import { config } from "../../config";
export const Header = () => {
console.log(config)
return <div></div>
}
控制台输出
最后基于这个配置 开发自己的UI,这里我就不多说了,感兴趣的直接看 vvdoc 的源代码
配置文件热更新
每次修改配置文件都需要重新启动,这能忍,必须安排成热更新
首先修改 vite 配置
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path';
import * as fs from 'fs';
const root = process.cwd()
const configName = 'vvdoc.config.json'
const configPath = resolve(root, configName)
const config = {
title: "vvDoc",
logo: "",
repository: "zwmmm/vvDoc",
menus: {},
chapters: {}
}
function mergeConfig() {
if (fs.existsSync(configPath)) {
Object.assign(config, JSON.parse(fs.readFileSync(configPath, 'utf-8')))
}
}
mergeConfig()
const configPathName = '/@config';
export default defineConfig(async () => {
const mdx = await import('@mdx-js/rollup')
return {
plugins: [
react({
jsxImportSource: 'theme-ui',
jsxRuntime: 'automatic',
}),
mdx.default({
jsxImportSource: 'theme-ui',
jsxRuntime: 'automatic',
providerImportSource: '@mdx-js/react'
}),
{
name: 'vvdoc',
load(id) {
if (id === configPathName) {
return `export default ${JSON.stringify(config)}`
}
},
+ configureServer(server) {
+ // 监听配置文件变更
+ if (configPath) {
+ server.watcher.add(configPath)
+ }
+ },
+ async handleHotUpdate(ctx) {
+ const { file, server } = ctx
+ if (file === configPath) {
+ mergeConfig()
+ return [server.moduleGraph.getModuleById(configPathName)!]
+ }
}
},
],
optimizeDeps: {
include: [
'react/jsx-runtime'
]
},
resolve: {
alias: {
'config': configPathName
}
}
}
})
修改 config.ts
import { default as _config } from 'config';
export let config = _config
if (import.meta.hot) {
import.meta.hot!.accept('/@siteData', (m) => {
config = m.default
})
}
好了,配置文件热更新也加好了
发布到NPM
为了做到开箱即用,现在吧上面的代码整理下发布到 npm
最终的使用模式如下
-
创建项目
> npm create vvdoc my-doc # 生成文档项目
生成的目录结构
. ├── docs │ └── index.mdx ├── playground │ └── Demo.tsx ├── index.html ├── package.json └── vvdoc.config.json
-
dev & build
> vvdoc dev > vvdoc build
现将刚才开发的项目改造成终端启动
增加 bin/vvdoc.js
文件,修改 package.json
"bin": {
"vvdoc": "./bin/vvdoc.js"
},
#!/usr/bin/env node
// vvdoc.js
const { createServer, build } = require('vite')
const path = require('path')
const mode = process.argv[2] || 'dev';
;(async () => {
if (mode === 'dev') {
process.env.NODE_ENV = 'development'
const server = await createServer({
configFile: path.resolve(__dirname, '../vite.config.ts'),
})
await server.listen()
server.printUrls()
} else {
process.env.NODE_ENV = 'production'
await build({
configFile: path.resolve(__dirname, '../vite.config.ts')
})
}
})()
修改 vite.config.ts
,因为我们的代码会被安装到 node_modules
下面,所以需要吧 root
设置成当前项目的根目录,其实本质上只是吧当前的 docs
playground
目录单独拿出去给用户修改,其他文件隐藏起来。
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path';
import * as fs from 'fs';
const root = process.cwd()
const configName = 'vvdoc.config.json'
const configPath = resolve(root, configName)
const config = {
title: "vvDoc",
logo: "",
repository: "zwmmm/vvDoc",
menus: {},
chapters: {}
}
function mergeConfig() {
if (fs.existsSync(configPath)) {
Object.assign(config, JSON.parse(fs.readFileSync(configPath, 'utf-8')))
}
}
mergeConfig()
const configPathName = '/@config';
export default defineConfig(async () => {
const mdx = await import('@mdx-js/rollup')
return {
+ root,
+ server: {
+ fs: {
+ allow: [
+ __dirname,
+ root,
+ ]
+ }
+ },
+ build: {
+ outDir: resolve(root, 'dist'),
+ },
+ publicDir: resolve(root, 'public'),
plugins: [
react({
jsxImportSource: 'theme-ui',
jsxRuntime: 'automatic',
}),
mdx.default({
jsxImportSource: 'theme-ui',
jsxRuntime: 'automatic',
providerImportSource: '@mdx-js/react'
}),
{
name: 'vvdoc',
load(id) {
if (id === configPathName) {
return `export default ${JSON.stringify(config)}`
}
},
configureServer(server) {
// 监听配置文件变更
if (configPath) {
server.watcher.add(configPath)
}
},
async handleHotUpdate(ctx) {
const { file, server } = ctx
if (file === configPath) {
mergeConfig()
return [server.moduleGraph.getModuleById(configPathName)!]
}
}
},
],
optimizeDeps: {
include: [
'react/jsx-runtime'
]
},
resolve: {
alias: {
'config': configPathName
}
}
}
})
最后吧项目发布到 npm ,这个就没啥好说的了
至于如何使用 npm create vvdoc xxx
来创建项目,这个就简单了,只需要发布一个 create-vvdoc
的 npm
包,具体的内容点这里
最后的最后,这里没有公众号引流,单纯的分享技术,码字不易,如果有学到内容和干货,给个点赞就是对我最大的鼓励,我还会继续出类似的教程,对于文中的很多方案 我都是从易用和易学的角度来思考的,就比如组件预览的方案,虽然使用mdx可以简单的实现,但是没办法做沙箱隔离,真实的场景还需要思考很多。
转载自:https://juejin.cn/post/7100749960668250143