利用Vite Plugin实现从零搭建Naive UI的说明文档

1,069 阅读5分钟

前言

前面两篇我们对Vite插件的开发有了一个大致的了解,这篇我们从零开始,一步步实现Naive UI组件库说明文档的Vite插件。

本文的初始化的代码可以直接克隆这里的代码,从tag 3.0签出分支获取干净的目录结构。(实际上Naive UI的站点更加复杂,因为还包括了主题切换、国际化、编辑器等等),这个标签的代码最大可能的排除了非重点的部分

项目初始化

项目依赖

  • highlight.js 代码高亮
  • vue-router Vue的路由

开发依赖

  • marked markdown解析器
  • naive-ui 组件库
  • unplugin-vue-components vite插件 自动加载naive ui组件
  • eslint
  • prettier
  • @vicons/ionicons5 图标库 纯粹的测试用 可以不安装
  • fs-extra 文件操作库
  • deepmerge 对象合并库
  • codesandbox 代码沙箱
  • typescript
  • eslint-plugin-markdown eslint markdown的规则插件
  • esbuild 借助esbuild的相关api处理代码

依赖安装

pnpm i highlight.js pinia vue-router -S

pnpm i marked naive-ui unplugin-vue-components eslint prettier @vicons/ionicons5 fs-extra deepmerge codesandbox typescript -D

配置eslint和tsconfig

# 初始化tsconfig.json
npx tsc --init 

配置简单的eslint规则后还不够,需要针对每种文件类型的规则进行配置。详细配置如下:

// .eslintrc.cjs
module.exports = {
  extends: ['plugin:markdown/recommended', 'prettier'],
  parserOptions: {
    project: ['./tsconfig.json']
  },
  overrides: [
    {
      files: '*.vue',
      extends: [
        '@vue/typescript/recommended',
        'plugin:vue/vue3-recommended',
        '@vue/typescript'
      ]
    },
    {
      files: ['*.vue', '*.js'],
      extends: ['plugin:vue/vue3-recommended'],
      rules: {
        'vue/multiline-html-element-content-newline': 0,
        'vue/multi-word-component-names': 0,
        'vue/max-attributes-per-line': [
          2,
          {
            singleline: 20,
            multiline: 1
          }
        ],
        'vue/require-default-prop': 0,
        'vue/no-multiple-template-root': 0,
        'vue/no-lone-template': 0,
        'vue/no-v-model-argument': 0,
        'vue/one-component-per-file': 0,
        'import/no-cycle': 1
      }
    },
    {
      files: ['*.ts', '*.tsx'],
      extends: ['standard-with-typescript', 'plugin:import/typescript'],
      parserOptions: {
        project: './tsconfig.json',
        ecmaFeatures: {
          jsx: true
        }
      },
      rules: {
        '@typescript-eslint/strict-boolean-expressions': 0,
        '@typescript-eslint/prefer-nullish-coalescing': 0,
        '@typescript-eslint/explicit-function-return-type': 0,
        '@typescript-eslint/naming-convention': 0,
        'multiline-ternary': 0,
        'no-void': 0,
        'import/no-cycle': 1
      }
    },
    {
      files: ['*.md'],
      processor: 'markdown/markdown',
      rules: {
        'MD025/single-title/single-h1': 0
      }
    },
    {
      files: '*',
      globals: {
        __DEV__: 'readonly'
      }
    }
  ]
}


tsconfig.json配置也需要处理,初始化的tsconfig不易阅读,我们可以全部删除,贴上以下代码:

{
  "compilerOptions": {
    "strict": true,
    "strictNullChecks": true,
    "jsx": "preserve",
    "jsxFactory": "h",
    "jsxFragmentFactory": "Fragment",
    "noImplicitAny": true,
    "noUnusedLocals": false,
    "module": "ES6",
    "moduleResolution": "Node",
    "declaration": true,
    "forceConsistentCasingInFileNames": true,
    "composite": true,
    "target": "ES6",
    "esModuleInterop": true,
    "lib": ["ESNext", "DOM"]
  },
  "exclude": ["node_modules","dist"]
}

至此,eslint和tsconfig配置完成。

文件目录初始化

首先我们先将目录进行设计,主要的目录就是demosrc,demo是文档站点,src用于存放组件的代码。

|-- demo
|   |-- App.vue
|   |-- main.js
|   |-- router
|   |   |-- index.js
|   |   |-- routes.js
|   |-- layout
|   |   |-- BasicLayout.vue # 基础布局
|   |   |-- SiteFooter.vue # 底部
|   |   |-- SiteHeader.vue # 头部
|   |-- pages
|   |   |-- Home.vue
|   |-- utils
|   |   |-- codesandbox.js # 代码沙箱配置 如果组件没有发布 可以不需要配置 本文也不做重点
|   |   |-- composables.js # 公共方法
|   |   |-- ComponentDemo.vue # 演示组件
|   |   |-- ComponentDemos.tsx # 包裹所有演示组件的父级组件
|   |   |-- CopyCodeButton.tsx # 复制代码按钮
|   |   |-- EditInCodeSandboxButton.tsx # 代码沙盒编辑按钮
|   |   |-- EditOnGithubButton.tsx # 去github编辑的按钮 直接定位到github的对应demo文件
|   |   |-- EditOnGithubHeader.tsx # 去github编辑文档标题的按钮
|   |   |-- github-url.js # github地址配置
|   |   |-- hljs.js # 代码高亮配置
|   |   |-- route.js # 路由相关的函数,提供生成左侧菜单的工具函数
|-- src
|   |-- index.ts # 组件库入口 也是打包的入口
|   |-- components.ts # 组件库的所有组件
|   |-- button
|   |   |-- index.ts # 组件入口
|   |   |-- src
|   |   |   |-- Button.tsx # 组件源码
|   |   |-- demos
|   |   |   |-- basic.demo.vue # 演示组件
|   |   |   |-- index.demo-entry.md # 演示组件的文档
.... # 还有一些test、style等目录 本文不做关注

插件的逐步编写

其实想了很久应该怎么去写这一块,因为其中引用的东西太多了,又加上自己第一次尝试写文,所以想了想就从入口开始,遇到什么,讲什么,最终再来个概括。

入口文件内容解析

首先,我们需要对.vue.md结尾的文件进行过滤,因为主要的就是对mdxxx.demo.vue文件进行操作,所以先定义好过滤的正则,然后对匹配到的文件进行处理:

// ./plugins/index.js
const fileRegex = /\.(md|vue)$/

const myPlugin = {
  name: "vite-md-parse",
  transform(code, id) {
    if (fileRegex.test(id)) {
      return tragetTransformedVueSrc(code)
    }
  },
};

接着我们创建./plugins/transfrom-vue-src.js,这个文件是用来具体匹配进入的文件是.md 还是demo.xxx文件。 .md文件会被解析成一个合规的vue文件的内容,如下图蓝框部分内容,就是该文件解析的。 demo.xx:用于展示组件的文件,一个文件最终处理为组件的形式加载到当前.md文档内容中,即下图的红色部分,一个红色框代表一个demo文件

image.png

然后我们来看详细代码。很好理解,就是根据路径来区分是进行文档解析还是demo解析。(ps:文件和函数名尽可能的语义化,这样方便阅读)

// ./plugins/transfrom-vue-src.js

import demoLoader from './utils/demo-loader'
import docLoader from './utils/doc-loader'

const targetTransformedVueSrc = async (code, path) => {
  // 解析demo.vue和demo.md
  if (path.endsWith('.demo.md') || path.endsWith('.demo.vue')) {
    const type = path.endsWith('.vue') ? 'vue' : 'md'
    return demoLoader(code, path, type)
  } else if (path.endsWith('.md')) {
    // 解析 入口的md文件
    return docLoader(code, path)
  }
}

export default targetTransformedVueSrc

demoLoader 演示文件的解析

我们首先理清楚demoLoader最终应该产出什么?看我刚刚截图的内容,蓝色的doc文件中包含好多个红色的demo文件,yes~,我们将demo文件产出为一个vue的组件。然后会在docLoader中实现引入。

好了,知道产出是VueComponent,那应该怎么去实现呢? 首先我们先看一下Naive UIButton组件下的icon.demo.vue文件的内容

<markdown>
# 图标v

在按钮上使用图标,可以使用 `render-icon` 属性或 `icon` 插槽。
</markdown>

<template>
  <n-space>
    <n-button secondary strong :render-icon="renderIcon">
      +100 元
    </n-button>
    <n-button icon-placement="right" secondary strong>
      <template #icon>
        <n-icon>
          <cash-icon />
        </n-icon>
      </template>
      +100 元
    </n-button>
  </n-space>
</template>

<script lang="ts">
import { CashOutline as CashIcon } from '@vicons/ionicons5'
import { defineComponent, h } from 'vue'

export default defineComponent({
  components: {
    CashIcon
  },
  setup () {
    return {
      renderIcon () {
        return h(CashIcon)
      }
    }
  }
})
</script>

文件内容除了正常的vue的语法模板外,还包括了一个<markdown></markdown>标签,所以我们需要将文件内容处理成一个vue文件,并且还要处理成一个个的卡片,附上codesandboxgithub所需要的解析文本,以确保可以直接跳转到githubcodesandbox网站去进行修改或者演示,并且可以一键复制代码、展开折叠等功能,不过这些所有的功能,都已经实现,可以直接查看这两个文件./demo/utils/ComponentDemos.tsx./demo/utils/ComponentDemo.vue

./demo/utils/ComponentDemo.vueprops就是我们需要解析的数据,最终产物应该是这样:

<template>
  <component-demo
    demo-file-name="<!--DEMO_FILE_NAME-->"
    relative-url="<!--URL-->"
    title="<!--TITLE_SLOT-->"
    ts-code="<!--TS_CODE_SLOT-->"
    js-code="<!--JS_CODE_SLOT-->"
    language-type="<!--LANGUAGE_TYPE_SLOT-->"
  >
    <template #title>
      <!--TITLE_SLOT-->
    </template>
    <template #content>
      <!--CONTENT_SLOT-->
    </template>
    <template #demo>
      <div class="demo-card__view">
        <!--DEMO_SLOT-->
      </div>
    </template>
  </component-demo>
</template>

<!--SCRIPT_SLOT-->

<!--STYLE_SLOT-->

我们可以将它提前定义好,为了阅读方便,我们将它定义成一个文件./plugins/utils/ComponentDemoTemplate.vue,然后在需要的时候读取。

话不多说,咱们...继续肝着~

image.png

// ./plugins/utils/demo-loader.js
import { vueToDemo, mdToDemo } from './demo-render'
import projectPath from './project-path' // 获取项目根目录

// demo文件解析
const demoLoader = (code, id, type) => {
  // 获取相对路径 用于生成codesandbox、github链接等 输出为:src/button/demo/xxx.demo.vue
  const relativeUrl = id.replace(projectPath + '/', '')

  if (type === 'vue') {
    return vueToDemo(code, {
      relativeUrl,
      resourcePath: id,
      isVue: true
    })
  }
  return mdToDemo(code, {
    relativeUrl,
    resourcePath: id,
    isVue: false
  })
}

export default demoLoader

我们创建一个demo-render.js,用来处理细节:

  • 我们需要先把文件生成一个数据明确的对象,满足ComponentDemo组件的props
  • 然后生成jsCodetsCode,供codesandbox复制、展示代码
  • 获取文件名
  • 生成组件代码文本
代码文本层级处理
// ./plugins/utils/demo-render.js
import { marked } from 'marked'
import createRender from './md-render'

const mdRenderer = createRender()

// 解析demo文件的文本,生成ComponentDemo组件的props对象
function getPartsOfDemo (text) {
  // 获取template、script、style、markdown等数据
  const template = text.match(/<template>([\s\S]*?)<\/template>/)?.[1]
  const script = text.match(/<script.*?>([\s\S]*?)<\/script>/)?.[1]
  const style = text.match(/<style>([\s\S]*?)<\/style>/)?.[1]
  const markdownText = text.match(/<markdown>([\s\S]*?)<\/markdown>/)?.[1]
  // 获取markdown文本中的h1标题作为demo标题 将其他内容作为content进行解析
  const tokens = marked.lexer(markdownText)
  const contentTokens = []
  let title = ''
  for (const token of tokens) {
    if (token.type === 'heading' && token.depth === 1) {
      title = token.text
    } else {
      contentTokens.push(token)
    }
  }
  const languageType = text.includes('lang="ts"') ? 'ts' : 'js'
  return {
    template,
    script,
    style,
    title,
    content: marked.parser(contentTokens, {
      renderer: mdRenderer
    }),
    language: languageType
  }
}
生成jsCode和tsCode

我们在上一步的结果中,插入jsCodetsCode

const mergeParts = ({ parts, isVue }) => {
  const mergedParts = { ...parts }
  mergedParts.tsCode = ''
  mergedParts.jsCode = ''
  // 因为需要手动加上template script style等标签
  // 还需要将ts->js,单独把函数提取出来
  handleMergeCode({ parts, mergedParts, isVue })
  // encodeURIComponent 函数可把字符串作为 URI 组件进行编码。
  mergedParts.tsCode = encodeURIComponent(mergedParts.tsCode.trim())
  mergedParts.jsCode = encodeURIComponent(mergedParts.jsCode.trim())
  return mergedParts
}

// ./plugins/utils/ts-to-js.js 
const { transformSync } = require('esbuild')

const tsToJs = (content) => {
  if (!content) {
    return ''
  }
 // 注意这里,因为esbuild会把空行给去掉,所以这里先把空行替换成__blankline,然后再替换回来
  const beforeTransformContent = content.replace(
    /\n(\s)*\n/g,
    '\n__blankline\n'
  )
  const { code } = transformSync(beforeTransformContent, {
    loader: 'ts',
    minify: false,
    minifyWhitespace: false,
    charset: 'utf8'
  })
  return code.trim().replace(/__blankline;/g, '')
}

export default tsToJs

// ./plugins/utils/handle-merge-code.js
import tsToJs from './ts-to-js'

// 将解析后的demo文件,处理成标准的vue语法,手动拼接
// 这个文件只是单纯的手动加上标签,无需额外关注,直接复制就好
function handleMergeCode ({ parts, mergedParts, isVue }) {
  // 如果是vue文件并且是ts语法
  if (isVue && parts.language === 'ts') {
    if (parts.template) {
      mergedParts.tsCode += `<template>${parts.template}</template>\n\n`
      mergedParts.jsCode += `<template>${parts.template}</template>\n\n`
    }
    if (parts.script) {
      mergedParts.tsCode += `<script lang="ts">
${parts.script}
</script>\n\n`
      mergedParts.jsCode += `<script>
${tsToJs(parts.script)}
</script>\n\n`
    }
    if (parts.style) {
      const style = `<style scoped>${parts.style}</style>`
      mergedParts.tsCode += style
      mergedParts.jsCode += style
    }
  } else {
    // 如果是vue文件并且是js语法
    if (parts.template) {
      mergedParts.jsCode += isVue
        ? `<template>${parts.template}</template>`
        : `<template>\n${parts.template
            .split('\n')
            .map((line) => (line.length ? '  ' + line : line))
            .join('\n')}\n</template>\n\n`
    }
    if (parts.script) {
      mergedParts.jsCode += `<script>
${parts.script}
</script>\n\n`
    }
    if (parts.style) {
      const style = isVue
        ? `<style scoped>${parts.style}</style>`
        : `<style scoped>
${parts.style}
</style>`
      mergedParts.jsCode += style
    }
  }
}

export default handleMergeCode
获取组件名称

直接贴代码:

// 获取文件名
function getFileName (resourcePath) {
  const dirs = resourcePath.split('/')
  const fileNameWithExtension = dirs[dirs.length - 1]
  return [fileNameWithExtension.split('.')[0], fileNameWithExtension]
}
组装成vue文件文本

此时我们创建的ComponentDemoTemplate.vue文件就派上用场了:

import fs from 'fs'
const __HTTP__ = process.env.NODE_ENV !== 'production' ? 'http' : 'https'
const demoBlock = fs
  .readFileSync(path.resolve(__dirname, 'ComponentDemoTemplate.vue'))
  .toString()
// 单纯的将parts中的内容和模板占位符进行替换,不用重点关注
const genVueComponent = (parts, fileName, relativeUrl) => {
  const demoFileNameReg = /<!--DEMO_FILE_NAME-->/g
  const relativeUrlReg = /<!--URL-->/g
  const titleReg = /<!--TITLE_SLOT-->/g
  const contentReg = /<!--CONTENT_SLOT-->/
  const tsCodeReg = /<!--TS_CODE_SLOT-->/
  const jsCodeReg = /<!--JS_CODE_SLOT-->/
  const scriptReg = /<!--SCRIPT_SLOT-->/
  const styleReg = /<!--STYLE_SLOT-->/
  const demoReg = /<!--DEMO_SLOT-->/
  const languageTypeReg = /<!--LANGUAGE_TYPE_SLOT-->/
  let src = demoBlock
  src = src.replace(demoFileNameReg, fileName)
  src = src.replace(relativeUrlReg, relativeUrl)
  if (parts.content) {
    src = src.replace(contentReg, parts.content)
  }
  if (parts.title) {
    src = src.replace(titleReg, parts.title)
  }
  if (parts.tsCode) {
    src = src.replace(tsCodeReg, parts.tsCode)
  }
  if (parts.jsCode) {
    src = src.replace(jsCodeReg, parts.jsCode)
  }
  if (parts.script) {
    const startScriptTag =
      parts.language === 'ts' ? '<script lang="ts">\n' : '<script>\n'
    src = src.replace(scriptReg, startScriptTag + parts.script + '\n</script>')
  }
  if (parts.language) {
    src = src.replace(languageTypeReg, parts.language)
  }
  if (parts.style) {
    const style = genStyle(parts.style)
    if (style !== null) {
      src = src.replace(styleReg, style)
    }
  }
  if (parts.template) {
    src = src.replace(demoReg, parts.template)
  }
  if (/__HTTP__/.test(src)) {
    src = src.replace(/__HTTP__/g, __HTTP__)
  }
  return src.trim()
}
合体

我来组成template~,我来组成script.... 我们是.....啥也不是

好了,我们现在需要做的就是将这些函数整合起来,代码如下:

export const vueToDemo = (
  code,
  { resourcePath, relativeUrl, isVue = true }
) => {
  const parts = getPartsOfDemo(code) // 将文本处理成组件props结构
  const mergedParts = mergeParts({ parts, isVue }) // 处理js/tsCode
  const [fileName] = getFileName(resourcePath)
  return genVueComponent(mergedParts, fileName, relativeUrl)
}

实际上还有一个mdToVue的函数,原理基本一致,这里就不做过多废话了~

docLoader 文档解析

目标产物

我们需要设定好目标,才不会迷茫,不是吗?

我们先来看下Naive UI的页面,如下图

image.png 它有以下几个部分:

  • content 主内容区
    • 大标题 并支持去github编辑
    • 描述
    • 演示组件
    • API相关
  • anchor 右侧锚点

所以我们的文档解析需要做的工作还是蛮多的,包括:生成引入组件的script命令、生成anchor列表、将demo组件加载到内容去,并根据配置决定一行显示几个

嗯?好像。。。工作也不是很多😳

然后我们计划好如何去实现:

  • 解析文档获取是否有API每栏组件数是否开启锚点等数据
  • 获取demo中的信息,根据展示个数来处理demo组件
  • 获取大标题信息,添加按钮,提供去github编辑的功能
  • 生成锚点数据和标签
  • 生成主体template
  • 生成最终的script
  • 合体

我们先将其他的东西拆分来讲,最后再分析主函数

处理demo组件

通过marked.lexer,我们可以获取到 ```demo ``` 中的数据,然后得到组件名、id、标题、标签等信息,具体代码如下(其中的resolveDemoTitlecameLCase不用关注,意如其名):

// 处理每一个demo.vue文件  将其转换成组件 比如 basic.demo.vue  转换成 <BasicDemo />
const resolveDemoInfos = async (demoInfos, relativeDir, env) => {
  // demoInfos 就是```demo ```中的文本,我们处理成数组
  const demoStr = demoInfos
    .split('\n')
    .map((item) => item.trim())
    .filter((id) => id.length)
  const demos = []
  // 处理md文件中的demo文件名 保持和真实文件名一致
  for (const demo of demoStr) {
    const debug = demo.includes('debug') || demo.includes('Debug')
    // 生产环境就不显示debug的demo
    if (env === 'production' && debug) continue
    let fileName
    if (demo.includes('.vue')) {
      fileName = demo.slice(0, -4) + '.demo.vue'
    } else {
      fileName = demo + '.demo.vue'
    }
    const componentName = `${cameLCase(demo)}Demo`
    demos.push({
      id: demo.replace(/\.demo|\.vue/g, ''),
      variable: componentName,
      fileName,
      title: await resolveDemoTitle(fileName, relativeDir),
      tag: `<${componentName} />`,
      debug
    })
  }
  return demos
}

最终我们产出的是一个数组结构,来供我们主函数中使用,在主函数中,在渲染templatescript的内容时,需要用到它来生成标签和import xxx from xxx的命令。

生成template的函数也很简单:

const genDemosTemplate = (demosInfos, colSpan) => {
  return `<component-demos :span="${colSpan}">${demosInfos
    .map(({ tag }) => tag)
    .join('\n')}</component-demos>`
}
锚点数据处理

这里也区分了是组件的演示文档还是项目的说明文档,这里只用演示文档来举例子,我们在上面将组件的列表获取到了,并且拿到了id信息,我们要做的就生成n-anchor-link标签就可以了。

// 生成锚点的父级组件  将子组件包裹起来
function genAnchorTemplate (
  children,
  options = {
    ignoreGap: false
  }
) {
  return `
    <n-anchor
      internal-scrollable
      :bound="16"
      type="block"
      style="width: 192px; position: sticky; top: 32px; max-height: calc(100vh - 32px - 64px); height: auto;"
      offset-target="#doc-layout"
      :ignore-gap="${options.ignoreGap}"
    >
      ${children}
    </n-anchor>
  `
}

// 生成文档页的锚点,快速定位到对应的位置 详情看naive的anchor文档: https://www.naiveui.com/zh-CN/os-theme/components/anchor
const genDemosAnchorTemplate = (demoInfos, hasApi, mdLayer) => {
  // 如果有api,就将api的锚点放在最后 否则就放在第一个
  const links = // 将demos的锚点和md文档中的h3标签的锚点合并
    (
      hasApi ? demoInfos.concat(genDemosApiAnchorTemplate(mdLayer)) : demoInfos
    ).map(
      ({ id, title }) => `<n-anchor-link
      title="${title}"
      href="#${id}"
    />`
    )
  // 注意 原文中有个v-if的判断displayMode,这里删除不作处理
  // 将锚点放在一个n-anchor组件中
  return genAnchorTemplate(links.join('\n'), {
    ignoreGap: hasApi
  })
}
script代码生成

这一块的代码,一直不知道该怎么去分析。最终想了下,如果给我从零去设计,我可能是先用vue3+naive去搭建一个中规中矩的站点,然后将其中的scripttemplate中不变的地方抽取出来。实际上这一部分没什么可讲的,就是一堆定义好的变量、样式,我们只需要把引入组件的命令动态的生成,根据md中是否开启锚点配置,控制变量即可,代码如下:

/**
 * 生成vue3的script部分
 * @param {Array} demosInfos demo.vue的集合
 * @param {*} components 转换后的组件
 * @param {*} relativeDir 所在的相对路径
 * @param {*} forceShowAnchor 是否显示锚点
 */
function genDocScript (demoInfos, components = [], url, forceShowAnchor) {
  const showAnchor = !!(demoInfos.length || forceShowAnchor)
  const importStmts = demoInfos
    .map(({ variable, fileName }) => `import ${variable} from './${fileName}'`)
    .concat(components.map(({ importStmt }) => importStmt))
    .join('\n')
  const componentStmts = demoInfos
    .map(({ variable }) => variable)
    .concat(components.map(({ ids }) => ids).flat())
    .join(',\n')
  const script = `<script>
${importStmts}
import { computed } from 'vue'
import { useMemo } from 'vooks'
import { useIsMobile } from '/demo/utils/composables'

export default {
  components: {
    ${componentStmts}
  },
  setup () {
    const isMobileRef = useIsMobile()
    const showAnchorRef = useMemo(() => {
      if (isMobileRef.value) return false
      return ${showAnchor}
    })
    const useSmallPaddingRef = isMobileRef
    return {
      showAnchor: showAnchorRef,
      wrapperStyle: computed(() => {
        return !useSmallPaddingRef.value
          ? 'display: flex; flex-wrap: nowrap; padding: 32px 24px 56px 56px;'
          : 'padding: 16px 16px 24px 16px;'
      }),
      contentStyle: computed(() => {
        return showAnchorRef.value
          ? 'width: calc(100% - 228px); margin-right: 36px;'
          : 'width: 100%; padding-right: 12px;'; 
      }),
      url: ${JSON.stringify(url)}
    }
  }
}
</script>`
  return script
}

生成template基本一致,就不做额外介绍了。大家可以在我代码仓库tag - 4.0中查看。

文档解析,获取关键信息

文件解析,肯定还是marked.lexer,根据Naive UI的约定,我们对是否有<!--single-column--><!--anchor:on-->## API来当作参数,

const docLoader = async (code, relativeDir, env = 'development') => {
  const colSpan = ~code.search('<!--single-column-->') ? 1 : 2
  const forceShowAnchor = !!~code.search('<!--anchor:on-->')
  const hasApi = !!~code.search('## API')

  const mdLayer = marked.lexer(code)

  // 获取组件的代码  比如md文件中引入了一些第三方的组件  就使用这里的代码
  const componentsIndex = mdLayer.findIndex(
    (item) => item.type === 'code' && item.lang === 'component'
  )
  let components = []
  if (~componentsIndex) {
    // mdLayer[componentsIndex].text : NButton: import { Button } from 'naive-ui'
    components = mdLayer[componentsIndex].text
      .split('\n')
      .map((component) => {
        const [compName, importCode] = component.split(':')
        if (!compName.trim()) throw new Error('没有组件名')
        if (!importCode.trim()) throw new Error('没有组件资源地址')
        return {
          compName: compName.split(',').map((item) => item.trim()),
          importCode
        }
      })
      .filter(({ compName, importCode }) => compName && importCode) // 过滤掉空的
  }

  // 处理标题  并添加在github中编辑的功能
  const titleIndex = mdLayer.findIndex(
    (item) => item.type === 'heading' && item.depth === 1
  )
  if (~titleIndex) {
    const title = mdLayer[titleIndex].text
    const btnTemplate =
      `<edit-on-github-header :relative-url="url" text=${title}></edit-on-github-header>`
    mdLayer.splice(titleIndex, 1, {
      type: 'html',
      pre: false,
      text: btnTemplate
    })
  }

  // 处理demo 并移除在生产中的打包构建
  const demoIndex = mdLayer.findIndex(
    (item) => item.type === 'code' && item.lang === 'demo'
  )
  let demoInfos = []
  if (~demoIndex) {
    demoInfos = await resolveDemoInfos(
      mdLayer[demoIndex].text,
      relativeDir,
      env,
    )
    mdLayer.splice(demoIndex, 1, {
      type: 'html',
      pre: false,
      text: genDemosTemplate(demoInfos, colSpan)
    })
  }

  const docMainTemplate = marked.parser(mdLayer, {
    renderer: mdRenderer,
    gfm: true
  })
  // 生成文档的模板
  const docTemplate = `
    <template>
      <div
        class="doc"
        :style="wrapperStyle"
      >
        <div :style="contentStyle">
          ${docMainTemplate}
        </div>
        <div style="width: 192px;" v-if="showAnchor">
          ${
            demoInfos.length
              ? genDemosAnchorTemplate(demoInfos, hasApi, mdLayer)
              : genPageAnchorTemplate(mdLayer)
          }
        </div>
      </div>
    </template>`
  
  const docScript = genDocScript(
    demoInfos,
    components,
    relativeDir,
    forceShowAnchor,
  )
  return `${docTemplate}\n\n${docScript}`
}

最终,我们就完成了整体的文档解析,当然了,这个时候还有很多的问题,导致无法启动项目,比如:

  • 站点项目中引入自己开发的组件问题,解决方式: dev: 配置别名引入,prod: 生成本地pack移动到node_modules中,注意名称保持一致。
  • 组件的到处和install函数暴露。 等等等等的问题,不过这些都已经处理好,并上传到了仓库,大家可以在最新版本查看。

附上一个最终的效果图:

QQ20230209-173718-HD.gif

总结

看过一遍文章以后,至少已经模糊的知道了怎么去实现。简单点,其实就是在vite plugin中,获取文件文本文件路径,然后对内容进行一个约定、解析、拆分,最终组合成一个vue文本,其实看这类的源码,最复杂的地方是在于,代码中有很多很多的非重点的内容,比如i18nthemestore等等,这些实际上并不需要跟着他们的标准来。所以我做的就是排除了很多不重要的东西,进行重点的讲解。另外,学会了,也需要进一步的加深巩固和拓展。你说,我拓展的就比原来的好吗?并不是,重要的是,我进行了进一步的改造,表示我掌握了这一块的知识。有兴趣的也可以看看这个仓库。一定一定要尝试打开自己的思维,无论思路对或错,好或坏,只要尝试,总会有更多收获。