VitePress 在文章底部显示贡献者列表
目标效果
在每篇文章的结尾处显示该文章对应的 .md 文件的历史贡献者名单,包括 commit 主作者和通过 Co-authored-by trailer 标记的联合作者。名单上显示每个贡献者的昵称、用户名和头像,并提供按钮跳转到其 GitHub 主页。
大致实现思路是在构建阶段,将仓库所有贡献者的头像下载,再通过 Vite 插件在每个 .md 文件的 Frontmatter 中插入该文件贡献者的信息,在页面上的 Vue 组件通过读取 Frontmatter 展示贡献者信息。
构建阶段:获取贡献者信息并写入
基本逻辑
首先要意识到一个问题:Git 和 GitHub 中「提交者」或者说「用户」的概念是有一定区别的。Git log 中记录一个贡献者,记录的是其名称和 Email,Email 是唯一标识符(主键
此外,由于用户的隐私设置,通过 Email 地址也不一定能通过 API 查询到其对应的 GitHub 账户。那如何将 Git 中的邮件信息和 GitHub 用户关联起来?
可以通过对 commit 的查询实现。GitHub GraphQL API 提供的 commit authors 字段会同时返回 Git 主作者和 commit message 中 Co-authored-by trailer 标记的联合作者;其中每个作者的 user.login 是对应的 GitHub 用户名。有了用户名,就可以通过 REST API 查询其昵称和头像 URL 了。
查询到所有的作者信息之后缓存下来,可以得到这样的数据:
type FullContributorData = {
username: string;
nickname: string;
avatar: string;
emails: string[];
};接下来在 Vite 插件中对所有 .md 文件进行处理,通过 Git 查询该文件所有作者的 Email,从缓存中查找对应的用户信息,并添加到 Frontmatter 中即可。
所以大概的流程是:
代码实现
和 Git 交互这里使用 simple-git 实现。首先查询全仓库所有 commit 的 SHA1、作者 Email,以及 commit message 中的 Co-authored-by trailer,然后进行一些简单的字符串处理即可。%(trailers:...) 是 Git 的 pretty format 占位符;代码使用 NUL 和记录分隔符划分字段,以免提交信息中的空格影响解析。
type EmailWithSha1 = { email: string; sha1: string };
async function getRepoContributors(): Promise<EmailWithSha1[]> {
const format =
"--format=%H%x00%ae%x00%(trailers:key=Co-authored-by,valueonly,separator=%x00)%x1e";
return parseContributors(await git.raw(["log", format]));
}然后是从 GitHub API 请求数据。使用官方提供的 @octokit/rest 包(其中的 Octokit 同时提供 GraphQL 客户端
import { Octokit } from "@octokit/rest";
const octokit = new Octokit({
auth: process.env.GITHUB_TOKEN,
});
// octokit.graphql( ... )NOTE
如果不提供 token,API 请求会被限制在 60 次 / 小时。Token 可在 GitHub 设置中生成。
Token 是隐私信息,不要把 token 写死在代码里!可采用 dotenv 等模块将其分离出来保存在 .env 文件中并添加到 .gitignore 中。
如果在顶层产生 Octokit 实例,则只要引入模块就会产生网络请求,并不方便。因此下面与 GitHub API 相关的函数,Octokit 实例全部从参数传入。
查询给定 EmailWithSha1 的用户名时,必须从 GraphQL authors 中按 Email 找到相应作者,不能继续读取 REST commit 的单数 author 字段,否则查询联合作者时仍会得到主作者:
type EmailWithSha1 = { email: string; sha1: string };
type EmailWithUsername = { email: string; username: string };
async function queryUsername(
{ email, sha1 }: EmailWithSha1,
octokit: Octokit,
): Promise<EmailWithUsername> {
const result = await octokit.graphql(
`query ($owner: String!, $repo: String!, $oid: GitObjectID!) {
repository(owner: $owner, name: $repo) {
object(oid: $oid) {
... on Commit {
authors(first: 100) { nodes { email user { login } } }
}
}
}
}`,
{ owner, repo, oid: sha1 },
);
const author = result.repository.object?.authors.nodes?.find(
(candidate) => candidate?.email?.toLowerCase() === email,
);
if (!author?.user) throw new Error("Author not found");
return { email, username: author.user.login };
}归并同属一个用户的 Email 并查询完整用户信息的函数如下:
function queryFullDataList(
emailTuples: EmailWithUsername[],
octokit: Octokit,
): Promise<FullContributorData[]> {
const user2emails = new Map<string, string[]>(); // username -> emails
emailTuples.forEach(({ email, username }) => {
if (user2emails.has(username)) user2emails.get(username)!.push(email);
else user2emails.set(username, [email]);
});
return Promise.all(
Array.from(user2emails).map(([username, emails]) =>
octokit.rest.users.getByUsername({ username }).then(({ data }) => ({
username,
nickname: data.name ?? username,
avatar: data.avatar_url,
emails,
})),
),
);
}至此,已经获取到了所有贡献者的完整信息。接下来就是通过 Vite 插件,在读入 .md 文件时,查询其所有作者的 Email,从缓存中查找对应的用户信息,并添加到 Frontmatter 中。
获取给定文件的所有贡献者 Email 时使用相同的 parseContributors,因此主作者和联合作者会被一致处理:
async function getEmailList(filePath: string): Promise<string[]> {
return parseContributors(
await git.raw([
"log",
gitLogFormat,
"--follow",
"--no-merges",
"--",
filePath,
]),
).map(({ email }) => email);
}Vite 插件是对象的形式。官方提供了详细的文档。其类型信息可以这样引入:
import type { Plugin } from "vite";那么插件的主体部分大概像这样:
// queryFullDataList 的结果保存在 fullUsrData 中
({
name: "add-contributors",
enforce: "pre",
async transform(code, path) {
if (!path.endsWith(".md") || code.trim().match(/^---\r?\n/) !== null)
return; // 若 Frontmatter 存在则跳过
const nameTuples = (await getEmailList(path))
.map((e) => fullUsrData.find(({ emails }) => emails.includes(e))!)
.map(({ nickname, username }) => `${nickname},${username}`);
const finalList = Array.from(new Set(nameTuples)).join(";");
return `---\ncontributorList: ${finalList}\n---\n\n` + code;
},
}) as Plugin;完整代码实现详见 addContributors.ts。
此外还需要在 config.mts 中引入插件(也可以使用传统的 Vite 项目配置方式,在项目根目录下建立 vite.config.mts 来配置
export default {
vite: {
plugins: [addContributor],
},
// ...
} as UserConfig<DefaultTheme.Config>;客户端:读取 Frontmatter 并展示
扩展默认主题,并通过 VitePress 内置主题提供的 doc-footer-before 布局插槽,把贡献者列表 Vue 组件塞进去。此外,VitePress 提供了读取 Frontmatter 的 API useData().frontmatter,配合 v-for 即可渲染贡献者列表。
此处提供部分组件代码供参考:
<template>
<div id="contributors">
<div v-for="person in contributorList" class="contributor">
<img :src="person.avatar" class="avatar" />
<span class="nickname">{{ person.nickname }}</span>
<span class="username">{{ person.username }}</span>
<a
class="github-link vpi-social-github"
:href="`https://github.com/${person.username}/`"
target="_blank"
></a>
</div>
</div>
</template>
<script lang="ts" setup>
import { watchEffect, ref } from "vue";
import { useData } from "vitepress";
type Contributor = {
username: string;
nickname: string;
avatar: string;
};
const { frontmatter } = useData();
const contributorList = ref<Contributor[]>([]);
const link = ref("");
watchEffect(() => {
contributorList.value = frontmatter.value.contributorList
.split(";")
.map((raw) => raw.split(","))
.map(([nickname, username]) => ({
nickname,
username,
avatar: `/avatars/${username}.png`,
}));
});
</script>完整代码实现详见 contributors.vue。