基于Vite我开发了一个文档工具,真香

·  阅读 2144
基于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 查看效果

image.png

到此 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
复制代码

然后我们看下效果

image.png

这样我们样式也处理好了,可以按照自己的喜好编写自己的主题

美化代码

搞定了基础的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
复制代码

然后我们看下效果

image.png

组件预览

实现了基本的文档渲染,现在我们再来实现下组件预览

明确下组件预览需要的功能

  • 组件被正常渲染且能交互
  • 能查看组件源代码

相信大家都看过 antd 等组件库的文档,都有代码预览的功能,大部分都是基于 codesandbox 去实现,由于我们基于 mdx 所以可以很简单的实现这个功能

  1. 第一步先实现组件渲染

    首先新建 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>
        </>
      )
    }
    复制代码
  2. mdx 中导入组件

    ## 第一篇文章
    
    我是一个简单的md文档
    
    - 我用mdx生成
    - 我能直接写react组件
    
    import { Demo } from '../playground/Demo';
    
    <Demo />
    复制代码
    这里需要注意 `import` 语句下面必须留一个**空行**
    复制代码

看下效果

chrome-capture-2022-4-21.gif

显示组件源码

  1. 先获取源代码

    ## 第一篇文章
    
    我是一个简单的md文档
    
    - 我用mdx生成
    - 我能直接写react组件
    
    import { Demo } from '../playground/Demo';
    + import text from '../playground/Demo?raw';
    
    <Demo />
    + <p>{text}</p>
    复制代码

    使用 raw loader 拿到文件的文本内容 直接渲染到 html

    image.png

  2. 美化下源代码

    ## 第一篇文章
    
    我是一个简单的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 标签

    image.png

封装成 Playground 组件

虽然实现了组件预览,但是每次都这么写也太繁琐了,所以我们来封装成一个组件

预览组件包含下面几个功能

  1. 自动获取组件源码
  2. 自动获取组件的语言用来高亮语法
  3. 美化下样式,左边展示代码,右边展示预览

创建 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"/>
复制代码

最后看下效果

chrome-capture-2022-4-21.gif

最后可以发挥你们自己的才能,美化这个预览组件

渲染文档的部分就到此结束,下面我们来实现路由,头部,页脚等公共组件

文档网站构建

路由

写过 umi 或者 next.js 的都知道约定式路由,现在我们就按照这个方法来实现文档的路由

首先需要满足如下需求

  1. 自动生成 navbar
  2. 自动生成 sidebar
  3. 自动生成路由配置

然后我们约定好文件夹的命名和结构所对应的路由

  1. index.mdx 对应 /
  2. xxx.mdx 对应 /xxx
  3. xxx/index.mdx 对应 /xxx/
  4. 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
复制代码

效果如下

chrome-capture-2022-4-21 (1).gif

配置头部和侧边栏

其实这里就没啥好说的,就写组件,前端在行,唯一值得说的是,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>
}
复制代码

控制台输出

image.png

最后基于这个配置 开发自己的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

最终的使用模式如下

  1. 创建项目

    > npm create vvdoc my-doc # 生成文档项目
    复制代码

    生成的目录结构

    .
     ├── docs
     │   └── index.mdx
     ├── playground
     │   └── Demo.tsx
     ├── index.html
     ├── package.json
     └── vvdoc.config.json
    复制代码
  2. 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-vvdocnpm 包,具体的内容点这里

最后的最后,这里没有公众号引流,单纯的分享技术,码字不易,如果有学到内容和干货,给个点赞就是对我最大的鼓励,我还会继续出类似的教程,对于文中的很多方案 我都是从易用和易学的角度来思考的,就比如组件预览的方案,虽然使用mdx可以简单的实现,但是没办法做沙箱隔离,真实的场景还需要思考很多。

分类:
前端
标签:
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改