开发提效之一键将vue文件转换为markdown文档

433 阅读3分钟

背景

笔者所在公司去年快速发展,前端从10人发展到接近30人,且大部分都在开发一个超大项目,于是笔者便搭建了以vuepress2为基础的文档库。

效果如下所示 image.png

文档库的实现方式有很多,但是如果要发动大家都参与进来,那么写文档就一定要是一件简单的事

于是便写了一个脚本来根据vue文件生成对应得markdown文档,减少大家时间成本

效果

假设我们要为某一个组件生成文档,只需执行一行命令即可

动画.gif

接着复制生成的文件名称到浏览器url进行替换,就能快速看到效果

动画.gif

实现

该脚本的实现其实很简单,主要依赖了几个包vue-docgen-api(vue代码转换为json),json2md(将json格式转换为markdown格式)

步骤如下

  1. 读取目标文件内容,再通过vue-docgen-api将内容转换成json格式
  2. 提取json里面的有效内容进行数据处理,在通过json2md将这些数据生成markdwon格式
  3. 写入到指定的目录文件里
// 自动生成组件文档目录

/**
 * 为什么不用 https://vue-styleguidist.github.io/docs/docgen-cli.html#install 而自己造轮子?
 * 1.如果目标MD文件已存在,没有配置决定是否覆盖生成还是增量生成
 * 2.没有md内容提取策略 (contentStrategy)
 * 3.单文件提取不友好
 * */

const path = require('node:path')
const os = require('node:os')
const fs = require('fs-extra')
const { parse } = require('vue-docgen-api') // 引入资源包
const json2md = require('json2md')
const glob = require('glob')
const { program } = require('commander')

program.option('-t --target [p]', '目标文件绝对路径')
program.option('-r --readDir [p]', '默认读取的组件路径')
program.option('-m --mdname [p]', '生成的md文件名')
program.option('-o --override', '如有同名文件,是否覆盖')
program.option('-d --outDir [p]', '默认生成md文件所放目录')
program.option('-e --exampleDir [p]', 'vue demo文件路径')

// 去除换行符
function text2rn(text) {
  if (text && text.replace) {
    return text.replace(/\r\n/g, '')
  }
  return text === undefined ? '' : text
}

function transformFirstChart(str, flag = true) {
  const fn = flag ? 'toUpperCase' : 'toLowerCase'
  return str.slice(0, 1)[fn]() + str.slice(1)
}

// 获取vue文件数据
async function getData(name, paths) {
  const arr = []
  for (const index in paths) {
    const result = await parse(paths[index]) // 异步加载需要解析的vue的文件
    arr.push(result)
  }
  return json2Md(name, arr)
}

// 创建MD内容
function json2Md(name, jsons) {
  const flag = jsons.length > 1
  const arr = [
    { h1: name },
    {
      p: `:::demo
${Config.getDocFileName(name)}/basic
:::`,
    },
  ]
  jsons.forEach((data) => {
    const prefix = flag ? `${data.displayName} ` : ''
    data.description && arr.push({ blockquote: data.description })
    data.props
      && arr.push(
        ...[
          { h2: `${prefix}Props` },
          {
            table: {
              headers: ['参数', '说明', '类型', '可选值', '默认值'],
              rows: data.props?.map((prop) => {
                return [
                  prop.name,
                  prop.description || '',
                  prop.type?.name || '',
                  '',
                  JSON.stringify(text2rn(prop.defaultValue && prop.defaultValue.value)),
                ]
              }) || [],
            },
          },
        ],
      )
    data.events
      && arr.push(
        ...[
          {
            h2: `${prefix}Events`,
          },
          {
            table: {
              headers: ['事件名', '说明', '参数'],
              rows: data.events.map((event) => {
                return [event.name, '', JSON.stringify(text2rn(event.properties?.map(item => item).join('')))]
              }),
            },
          },
        ],
      )
    data.methods
      && arr.push(
        ...[
          {
            h2: `${prefix}Methods`,
          },
          {
            table: {
              headers: ['方法名', '说明', '参数'],
              rows: data.methods.map((method) => {
                return [method.name, method.description || '', '']
              }),
            },
          },
        ],
      )
    data.slots
      && arr.push(
        ...[
          {
            h2: `${prefix}Slots`,
          },
          {
            table: {
              headers: ['name', '说明', '参数'],
              rows: data.slots.map((slot) => {
                return [
                  slot.name,
                  slot.description || '',
                  JSON.stringify(
                    (slot.bindings
                      && slot.bindings.map((binding) => {
                        return text2rn(binding.name)
                      }))
                      || '',
                  ),
                ]
              }),
            },
          },
        ],
      )
    data.expose && arr.push(
      ...[
        {
          h2: `${prefix}Expose`,
        },
        {
          table: {
            headers: ['方法名', '说明', '参数'],
            rows: data.expose.map((expose) => {
              return [expose.name, expose.description || '', '']
            }),
          },
        },
      ],
    )
  })
  return json2md(arr)
}

// 创建MD文件
function createMd(name, content, outDir) {
  return new Promise((resolve, reject) => {
    const filePath = path.resolve(outDir, `${name}.md`)
    fs.writeFile(path.resolve(filePath), content, {}, (error) => {
      if (error) {
        return reject(new Error(`[生成${name}.md失败]: ${error}`))
      }
      return resolve(name)
    })
  })
}

// 创建实例vue文件
function createExample(name, outDir, fileName) {
  const newName = transformFirstChart(name)
  const content = `<script lang="tsx" setup>
import ${newName} from '@/components/${newName}/${newName}.vue'
</script>

<template>
    <div>
       <${newName} />
    </div>
</template>`
  return new Promise(async (resolve, reject) => {
    const filePath = path.resolve(outDir, `${fileName}/basic.vue`)
    try {
      await fs.outputFileSync(path.resolve(filePath), content)
      return resolve(name)
    }
    catch (error) {
      reject(new Error(`[生成${name}.vue失败]: ${error}`))
    }
  })
}

function isUndefined(value) {
  return value === undefined
}
// 初始化
async function init(userConfig = {}) {
  console.log('开始生成MD文档, 请稍等片刻')
  const options = program.opts()
  Object.assign(Config, userConfig)
  Config.components = isUndefined(options.target) ? Config.components : path.resolve(process.cwd(), options.target).replace(/\\/g, '/')
  Config.componentsRoot = isUndefined(options.readDir) ? Config.componentsRoot : path.resolve(process.cwd(), options.readDir)
  Config.override = isUndefined(options.override) ? Config.override : options.override
  Config.iExample = isUndefined(options.iExample) ? Config.iExample : options.iExample
  Config.outDir = isUndefined(options.outDir) ? Config.outDir : path.resolve(process.cwd(), options.outDir)

  const globConfig = {
    cwd: Config.componentsRoot,
    ignore: Config.ignore,
  }
  if (os.type() == 'Windows_NT') {
    // windows平台
    globConfig.root = Config.componentsRoot
  }
  glob(
    Config.components,
    globConfig,
    async (err, files) => {
      if (err) {
        console.log('[componentsRoot]: 查找组件失败', err)
        return
      }
      files = files.map((item) => {
        if (path.isAbsolute(item)) {
          return path.relative(Config.componentsRoot, item)
        }
        return item
      })
      const filePaths = await getFileNoExists(getFileToMd(files), Config.outDir)
      const res = await Promise.allSettled(
        filePaths.map(async ([name, contentPaths]) => {
          try {
            const content = await getData(
              name,
              contentPaths.map(dir => path.resolve(Config.componentsRoot, dir)),
            )
            return createMd(Config.getDocFileName(name), content, Config.outDir).then(async () => {
              if (Config.iExample) {
                await createExample(name, Config.exampleDir, Config.getDocFileName(name))
              }
              return Config.getDocFileName(name)
            })
          }
          catch (error) {
            console.log(error)
          }
        }),
      )
      const allfile = res.filter(item => item.status === 'fulfilled').map(item => item.value)
      console.log(
        '[成功生成以下MD文件]:',
        allfile.map((item) => { return { title: item, path: `/components/${item}.md` } }),
      )
    },
  )
}

// 策略方法
const swichContentStrategy = {
  index(key, value) {
    const flag = value.find(item => item.includes('index.vue'))
    return flag ? [flag] : null
  },
  component(key, value) {
    const flag = value.find(item => item.includes(`${key}.vue`))
    return flag ? [flag] : null
  },
  all(key, value) {
    return value
  },
}

// 判断该文件是否存在
async function getFileNoExists(files, outDir) {
  if (Config.override) return files
  const arr = []
  await Promise.all(
    files.map(([name, contentPath]) => {
      return new Promise((resolve, reject) => {
        const filePath = path.resolve(outDir, `${Config.getDocFileName(name)}.md`)
        fs.access(filePath, async (err) => {
          if (err) {
            arr.push([name, contentPath])
          }
          resolve()
        })
      })
    }),
  )
  return arr
}

// 根据策略收集需要转换的VUE文件,并分组
function getFileToMd(files) {
  const mapC = new Map()
  files.forEach((file) => {
    const key = getRootDir(file)
    const arr = mapC.get(key) || []
    arr.push(file)
    mapC.set(key, arr)
  });
  [...mapC.entries()].forEach(([key, value]) => {
    Config.contentStrategy.some((contentStrategy) => {
      const result = swichContentStrategy[contentStrategy](key, value)
      if (result) {
        mapC.set(key, result)
        return true
      }
    })
  })
  return [...mapC.entries()]
}

// 获取根文件夹 例如传参 '/a/b/c' , 返回 'a'
function getRootDir(componentPath) {
  let str = componentPath
  while (path.dirname(str) !== '.') {
    str = path.dirname(str)
  }
  return str
}
/**
 * md内容提取策略
 *
 * 组件文档结构存在下面的场景
 *
 * -packages
 *   -table
 *      -src
 *         -table.vue
 *         -filter-panel.vue
 *  -addBtn
 *      -index.vue
 *  -edit
 *      -src
 *         -InlineEdit.vue
 *         -SwichEdit.vue
 *  -fileList
 *      -file.vue
 *      -index.vue
 *
 * 即一个组件文件夹下可能多个vue文件,或者只有一个vue文件
 * 对于一个vue文件,我们直接提取里面内容, 或者不提取
 * 对于多个vue文件,我们需要制定策略,需要提取index.vue还是{component}.vue,又或者是提取全部vue文件内容到一个md中
 *
 * 默认策略如下
 * index: index.vue
 * compenent: 同名component.vue
 * all: 在此如果都没有,则提取所有vue文件
 */
const contentStrategy = ['index', 'component', 'all']

// 配置,暴露给用户使用
const Config = {
  contentStrategy,
  componentsRoot: path.resolve('src/components'),
  components: '**/*.vue',
  outDir: path.resolve('docs/component'),
  exampleDir: path.resolve('docs/examples'),
  ignore: [''],
  override: false, // 是否覆盖已有的md文件
  iExample: true, // 是否生成vue demo文件
  getDocFileName(componentPath) {
    const str = getRootDir(componentPath)
    return str.replace(/[A-Z]/g, (march, g1, g2) => {
      if (g1 === 0) {
        return march.toLowerCase()
      }
      return `-${march.toLowerCase()}`
    })
  },
}

module.exports = init

具体代码可见此仓库 github.com/wangziweng7…

注意

如果需要一键生成文档内容质量更好,可参照此格式写我们的vue文件vue-styleguidist/packages/vue-docgen-api at dev · vue-styleguidist/vue-styleguidist · GitHub

<template>
  <div>
    <!-- @slot Use this slot header -->
    <slot name="header"></slot>

    <table class="grid">
      <!-- -->
    </table>

    <!--
      @slot Modal footer
      @binding item an item passed to the footer
		-->
    <slot name="footer" :item="item" />
  </div>
</template>

<script>
import { text } from './utils'

/**
 * This is an example of creating a reusable grid component and using it with external data.
 * @version 1.0.5
 * @author [Rafael](https://github.com/rafaesc92)
 * @since Version 1.0.1
 */
export default {
  name: 'grid',
  props: {
    /**
     * object/array defaults should be returned from a factory function
     * @version 1.0.5
     * @since Version 1.0.1
     * @see See [Wikipedia](https://en.wikipedia.org/wiki/Web_colors#HTML_color_names) for a list of color names
     * @link See [Wikipedia](https://en.wikipedia.org/wiki/Web_colors#HTML_color_names) for a list of color names
     */
    msg: {
      type: [String, Number],
      default: text
    },
    /**
     * Model example
     * @model
     */
    value: {
      type: String
    },
    /**
     * describe data
     * @version 1.0.5
     */
    data: [Array],
    /**
     * get columns list
     */
    columns: [Array],
    /**
     * filter key
     * @ignore
     */
    filterKey: {
      type: String,
      default: 'example'
    }
  },
  methods: {
    /**
     * Sets the order
     *
     * @public
     * @version 1.0.5
     * @since Version 1.0.1
     * @param {string} key Key to order
     * @returns {string} Test
     */
    sortBy: function (key) {
      this.sortKey = key
      this.sortOrders[key] = this.sortOrders[key] * -1

      /**
       * Success event.
       *
       * @event success
       * @type {object}
       */
      this.$emit('success', {
        demo: 'example'
      })
    },
  }
}
</script>