你的组件库文档怎么自动获取【贡献者】

679 阅读6分钟

各位大佬早上好、中午好、晚上好!

在进行组件库维护的时候,需要进行组件库文档的编写,需要获取到贡献者或者维护者。vitePress的方式是通过在文档的最上面初始化页面的frontmatter

--- 
author:xxx,
avatar: xxx,
---

然后通过useData获取到这个数据,然后通过一个Contributor.vue的组件渲染贡献者列表。

<script setup>
import { useData } from 'vitepress'

const { theme } = useData()
</script>

<template>
  <h1>{{ theme.footer.copyright }}</h1>
</template>

本文将通过另一种方式简化此步骤,让文档维护者无感的更新贡献者列表

以下示例环境:

  • 代码托管:私有化的gitlab
  • 组件库搭建框架:vitePress

第一步:获取gitlab秘钥

秘钥的作用是用于通过http接口请求gitlab服务。

以下是如何在GitLab中生成一个新的Personal Access Token的步骤:

  1. 登录到你的GitLab实例。
  2. 在右上角点击你的头像,然后选择 Settings。
  3. 在左侧菜单中,找到并点击 Access Tokens。
  4. 在 New Access Token 部分,填写以下信息:
    • Name: 给你的Token一个描述性的名称,例如“API Access”。
    • Expires at: 选择一个过期时间,或者让Token不过期。如果你担心安全问题,建议设置一个合理的过期时间。
    • Scopes: 选择Token的作用域。为了获取用户信息,我们选择read_user scope。如果你打算进行更复杂的操作,可以选择勾选其他scopes。
  5. 点击 Create personal access token。
  6. 在这个页面的下边,你会看到你的新Token。请确保你立即复制这个Token并将其保存在一个安全的地方。GitLab不会再次显示这个Token,所以如果你丢失了它,你将需要生成一个新的Token。
image.png

第二步:获取用户信息

gitlab提供了api用于操作数据,我们可以利用用户api来获取所有用户的详细信息。

gitlab:http://xxx/api/v4/users?per_page=${pageSize}&page=${page} 。如果代码部署到其他平台,如gitHub也有相应的开放api

import { join, resolve } from 'node:path'
import fs from 'fs-extra'
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DIR_ROOT = resolve(__dirname, '..')

async function getUsers(page, pageSize) {
  return new Promise((resolve, reject) => {
      // 私有化部署的域名
    fetch(`http:/私有化部署的域名/api/v4/users?per_page=${pageSize}&page=${page}`, {
      headers: {
        'PRIVATE-TOKEN': 'xxx', // 这里填上你刚刚获取的Access Tokens
      }
    })
      .then(response => response.json())
      .then(data => {
        // console.log("🚀 ~ data:", data)
        if (data) {
          const users = data.reduce((users, item) => {
            users.push({
              id: item.id,
              name: item.name,
              username: item.username,
              avatar_url: item.avatar_url,
              web_url: item.web_url,
            })
            return users;
          }, [])
          console.log('users.length >>:', users.length);
          resolve(users)
        }
      })
      .catch(error => {
        console.error('Error:', error)
        reject([])
      });
  })
}

async function updateUsers() {
  const users = await getUsers(1, 1000)
  if (users.length > 0) {
    await fs.writeFile(join(DIR_ROOT, './users.json'), `${JSON.stringify(users, null, 2)}\n`, 'utf8')
  }
}

updateUsers()

这段代码的目的是从GitLab API获取用户列表,并将这些用户信息保存到本地的一个JSON文件中:

  1. 定义了一个异步函数getUsers,它通过发送HTTP请求到一个私有化部署的GitLab API端点来获取用户列表。这个函数接收两个参数:pagepageSize,分别代表请求的页码和每页的用户数量。
  2. getUsers函数中,使用fetch函数发送请求,并设置请求头中的PRIVATE-TOKEN用于身份验证。
  3. 请求成功后,解析JSON响应,并将用户信息提取到一个新的数组中。
  4. 定义了一个异步函数updateUsers,它调用getUsers函数获取用户数据,如果获取到的用户数量大于0,则将这些用户信息写入到项目根目录下的users.json文件中。
  5. 最后,调用updateUsers函数执行整个过程。

然后在根目录新建一个users.json文件用于保存请求回来的数据

写个sh用于执行此脚本文件

"update-users": "cd ./scripts && node users.js",

第三步:获取每个文件的提交信息

这里我们使用了node模块child_process用于动态执行git来获取提交信息;

child_process是Node.js的一个内置模块,它提供了一种方法来创建子进程,允许你执行操作系统上的命令,并与这些子进程进行交互。这个模块在需要对操作系统进行交互,或者执行需要访问系统资源的任务时非常有用。在此脚本中我们主要使用 exec。它启动一个shell,然后在该shell中执行命令,并且缓存命令的输出,当命令执行完成时,则将输出以回调函数参数的形式返回。

获取你写的md文档的所有地址:

function getMarkdownPath(dir) {
  let files = [];
  const items = fs.readdirSync(dir);

  items.forEach(item => {
    const itemPath = path.join(dir, item);
    const stats = fs.statSync(itemPath);

    if (stats.isDirectory()) {
      files = files.concat(getMarkdownPath(itemPath)); // 递归获取子文件夹中的文件
    } else if (path.extname(item) === '.md') {
      files.push(itemPath.replace(/\\/g, '/'));
    }
  });

  return files;
}

通过文件地址获取提交记录

async function getCommitByPath(path) {
  return new Promise((resolve, reject) => {
    exec(`git log --pretty=format:"%h %an %ad" -- ${path}`, (error, stdout, stderr) => {
      if (error) {
        console.error(`Error: ${error.message}`);
        reject(error.message)
        return;
      }
      if (stderr) {
        reject(stderr)
        console.error(`Stderr: ${stderr}`);
        return;
      }
    
      // 解析 Git log 输出
      const gitLogLines = stdout.split('\n');
      const contributors = new Set();
    
      gitLogLines.forEach(line => {
        const [hash, author] = line.split(' ')
        contributors.add(author);
      });
    
      // 输出贡献者列表
      resolve(Array.from(contributors));
    });
  })
}

将获取到的提交记录写入到文件中

async function main() {
  const markdownFiles = getMarkdownPath('../docs')

  let pageCommit = {};
  if (markdownFiles.length > 0) {
    for (let path of markdownFiles) {
      const users = await getCommitByPath(path)
      pageCommit[path] = users
    }
  }

  console.log('pageCommit >>:', pageCommit);

  if (!isEmpty(pageCommit)) {
    await fs.writeFile(join(DIR_ROOT, './page-commit.json'), `${JSON.stringify(pageCommit, null, 2)}\n`, 'utf8')
  }
}

这是全部的获取文件提交记录的代码:

import { exec } from "child_process";
import { join, resolve } from 'node:path'
import fs from 'fs-extra'
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DIR_ROOT = resolve(__dirname, '..')

function getMarkdownPath(dir) {
  let files = [];
  const items = fs.readdirSync(dir);

  items.forEach(item => {
    const itemPath = path.join(dir, item);
    const stats = fs.statSync(itemPath);

    if (stats.isDirectory()) {
      files = files.concat(getMarkdownPath(itemPath)); // 递归获取子文件夹中的文件
    } else if (path.extname(item) === '.md') {
      files.push(itemPath.replace(/\\/g, '/'));
    }
  });

  return files;
}

async function getCommitByPath(path) {
  return new Promise((resolve, reject) => {
    exec(`git log --pretty=format:"%h %an %ad" -- ${path}`, (error, stdout, stderr) => {
      if (error) {
        console.error(`Error: ${error.message}`);
        reject(error.message)
        return;
      }
      if (stderr) {
        reject(stderr)
        console.error(`Stderr: ${stderr}`);
        return;
      }
    
      // 解析 Git log 输出
      const gitLogLines = stdout.split('\n');
      const contributors = new Set();
    
      gitLogLines.forEach(line => {
        const [hash, author] = line.split(' ')
        contributors.add(author);
      });
    
      // 输出贡献者列表
      resolve(Array.from(contributors));
    });
  })
}

 function isEmpty(obj) {
  for (const key of Object.keys(obj)) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      return false;
    }
  }
  return true;
}

async function main() {
  const markdownFiles = getMarkdownPath('../docs')

  let pageCommit = {};
  if (markdownFiles.length > 0) {
    for (let path of markdownFiles) {
      const users = await getCommitByPath(path)
      pageCommit[path] = users
    }
  }

  console.log('pageCommit >>:', pageCommit);

  if (!isEmpty(pageCommit)) {
    await fs.writeFile(join(DIR_ROOT, './page-commit.json'), `${JSON.stringify(pageCommit, null, 2)}\n`, 'utf8')
  }
}

main()
  1. 定义了一个函数getMarkdownPath,它递归地搜索指定目录及其子目录,收集所有Markdown文件的路径,并将这些路径返回。

  2. 定义了一个异步函数getCommitByPath,它通过执行git log命令获取指定文件的提交历史,并解析输出以提取贡献者列表(即作者),然后返回这些作者的名字。

  3. 定义了一个辅助函数isEmpty,用于检查一个对象是否为空。

  4. 定义了一个异步函数main,它是整个脚本的入口点。

    • main函数首先调用getMarkdownPath来获取所有Markdown文件的路径列表。
    • 然后,对于每个Markdown文件,调用getCommitByPath来获取其贡献者列表,并将这些信息存储在一个对象pageCommit中,该对象的键是文件路径,值是贡献者数组。
    • 最后,如果pageCommit不为空,将其内容写入到项目根目录下的page-commit.json文件中。

总的来说就是:遍历一个目录及其子目录中的所有Markdown文件,获取每个文件的Git提交历史和贡献者列表,并将这些信息保存到一个JSON文件中。

将提交信息拼接到对应文件的末尾

我们已经拿到了用户信息,和每个文件的提交信息。vitePress提供了布局插槽用于将一些信息显示在页面中:

<!--.vitepress/theme/MyLayout.vue-->
<script setup>
import DefaultTheme from 'vitepress/theme'

const { Layout } = DefaultTheme
</script>

<template>
  <Layout>
    <template #doc-footer-before`> // 这里使用了doc-footer-before插槽
      My custom sidebar top content
    </template>
  </Layout>
</template>

不过这种方式不太好用,它是静态的,无法根据每个页面的不同元数据动态设置,而且提交者不能显示在右侧的锚点导航中。

image.png

不用怕,vitePress灵活性非常高,我们可以利用插件的形式来动态修改文章的字符串,拼接贡献者到文章的末尾,具体怎么做呢?

  1. .vitepress下新建plugins文件夹用于放置自定义的插件
  2. 新建MDPreprocessor.js文件,代码逻辑很简单,code就是文件的源码,在code的尾部添加了<Contributors/>组件,然后返回。
export function MDPreprocessor() {
  return {
    name: 'md-preprocessor',
    transform(code, id) {
      if (!id.endsWith('.md')) return null
      const [filename, i] = id.split('/').slice(-2)
      // 是首页的 index.md直接跳过,这里可以根据项目定义其他的判断条件
      if (filename === 'xxx' && i === 'index.md') return code

      // 在正文末尾插入“Contributors”标题以及贡献者组件
      code += '\n\n## 贡献者\n<Contributors/>'
      return code
    }
  }
}

  1. 新建一个vite.config.js配置文件
import { defineConfig } from 'vite'
import { MDPreprocessor } from './.vitepress/plugins/MDPreprocessor'

export default defineConfig(async () => {
  return {
    plugins: [
      MDPreprocessor()
    ]
  }
})

完成以上步骤,每个你定义的md格式的文件都会在文件的末尾添加Contributors组件了,下一步就是要实现这个Contributors组件。

实现Contributors组件

  1. .vitepress\theme\components\新建Contributors.vue组件并写入以下内容:
<script setup>
import { useData } from 'vitepress'
import users from '../../../users.json'
import pageCommit from '../../../page-commit.json'

const { page } = useData();
const { relativePath } = page.value;

const pageContributors = pageCommit['../' + relativePath]
let pageContributorInfos = [];
if (pageContributors) {
  pageContributorInfos = users.filter(item => {
    return pageContributors.includes(item.username) || pageContributors.includes(item.name)
  })
}

</script>
<template>
  <div class="contributor-wraper" v-for="user of pageContributorInfos" :key="user.id">
    <div class="contributor">
      <img :src="user.avatar_url" :alt="user.name" :title="user.name">
      <a :href="user.web_url" target="_blank">{{ user.name }}</a>
    </div>
  </div>
</template>
<style scoped>
.contributor-wraper {
  display: flex;
  flex-wrap: wrap;
}
.contributor {
  display: flex;
  font-size: 16px;
  line-height: 30px;
  margin-right: 10px;
}
img {
  width: 30px;
  height: 30px;
  line-height: 30px;
  vertical-align: middle;
  margin-right: 5px;
  border-radius: 50%;
  border: 1px solid rgba(0, 0, 0, 0.3);
}
</style>

.vitepress\theme\index.js里注册这个组件为全局组件

export default {
  extends: DefaultTheme,
  Layout: () => {
    return h(DefaultTheme.Layout, null, {
      // https://vitepress.dev/guide/extending-default-theme#layout-slots
    })
  },
  enhanceApp({ app, router, siteData }) {
    app.component('Contributors', Contributors)
    // ...
  },
}

完成以上代码的编写,不出意外的话你的贡献者将会显示到每个文件的末尾

image.png

并且文件的提交者都会显示到此列表里。nice!!

最后我们在package.json里:

  "scripts": {
    "dev": "vitepress dev",
    "build": "pnpm update-users && pnpm update-page-commit && vitepress build",
    "preview": "vitepress preview",
    "preinstall": "npx only-allow pnpm",
    "update-users": "cd ./scripts && node users.js",
    "update-page-commit": "cd ./scripts && node page-commit.js"
  }

当我们执行pnpm build的时候先更新用户列表然后更新每个文件的提交信息,最后打包。done!!