显示页面贡献者

This commit is contained in:
Meow
2025-12-21 22:14:50 -05:00
committed by yuhan6665
parent 9a7c6e4e05
commit 173b60cf57
5 changed files with 266 additions and 4 deletions
+1
View File
@@ -4,3 +4,4 @@ node_modules/
# VitePress
.vitepress/dist/
.vitepress/cache/
.vitepress/.generated/contributors.json
@@ -0,0 +1,158 @@
<script setup lang="ts">
import contributorsMap from "../../.generated/contributors.json";
import { useRoute, useData } from "vitepress";
import { computed } from "vue";
type Contributor = { name: string; email?: string; commits: number };
const route = useRoute();
const { lang } = useData();
const list = computed(() => {
const map = contributorsMap as Record<string, Contributor[]>;
const raw = route.path;
const candidates = new Set<string>();
const noHtml = raw.replace(/\.html$/, "");
candidates.add(raw);
candidates.add(noHtml);
// /foo -> /foo/ and /foo/ -> /foo
for (const p of [raw, noHtml]) {
if (p.endsWith("/")) candidates.add(p.slice(0, -1));
else candidates.add(p + "/");
}
for (const key of candidates) {
if (map[key]?.length) return map[key];
}
return [];
});
const t = computed(() => {
const l = (lang.value || "en").toLowerCase();
const dict = {
en: { contributors: "Contributors", commits: "commits" },
zh: { contributors: "贡献者", commits: "次提交" },
ru: { contributors: "Участники", commits: "коммитов" },
} as const;
return (dict as any)[l] || (dict as any)[l.split("-")[0]] || dict.en;
});
</script>
<template>
<details open v-if="list.length" class="vp-contributors">
<summary class="summary">
{{ t.contributors }}
<span class="count">({{ list.length }})</span>
</summary>
<ul class="list">
<li v-for="c in list" :key="c.email || c.name" class="item">
<img class="avatar" :src="c.avatarUrl" :alt="c.name" loading="lazy" />
<div class="main">
<div class="row">
<span class="name">{{ c.name }}</span>
<span class="meta">· {{ c.commits }} {{ t.commits }}</span>
</div>
<div class="sub">
<a v-if="c.email" class="link" :href="`mailto:${c.email}`">{{
c.email
}}</a>
<span v-if="c.github" class="sep">·</span>
<a
v-if="c.github"
class="link"
:href="`https://github.com/${c.github}`"
target="_blank"
rel="noreferrer"
>
@{{ c.github }}
</a>
</div>
</div>
</li>
</ul>
</details>
</template>
<style scoped>
.vp-contributors {
margin-top: 28px;
padding-top: 12px;
border-top: 1px solid var(--vp-c-divider);
font-size: 12px;
opacity: 0.75;
}
.summary {
cursor: pointer;
user-select: none;
font-weight: 500;
list-style: none;
}
.summary::-webkit-details-marker {
display: none;
}
.count {
opacity: 0.7;
margin-left: 4px;
}
.list {
margin: 10px 0 0;
padding-left: 18px;
}
.name {
font-weight: 500;
}
.meta {
opacity: 0.7;
margin-left: 6px;
}
.item {
display: flex;
gap: 10px;
align-items: flex-start;
margin: 6px 0;
}
.avatar {
width: 20px;
height: 20px;
border-radius: 999px;
opacity: 0.9;
}
.main {
min-width: 0;
}
.row {
display: flex;
gap: 6px;
align-items: baseline;
flex-wrap: wrap;
}
.sub {
opacity: 0.75;
font-size: 11px;
line-height: 1.2;
}
.link {
color: var(--vp-c-text-2);
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
.sep {
margin: 0 6px;
opacity: 0.6;
}
</style>
+9
View File
@@ -8,6 +8,9 @@ import mediumZoom from "medium-zoom";
import { onMounted, watch, nextTick } from "vue";
import { useRoute } from "vitepress";
import { h } from "vue";
import PageContributors from "./components/PageContributors.vue";
export default {
extends: DefaultTheme,
@@ -25,4 +28,10 @@ export default {
() => nextTick(() => initZoom())
);
},
Layout() {
return h(DefaultTheme.Layout, null, {
"doc-after": () => h(PageContributors),
});
},
};
+4 -4
View File
@@ -6,8 +6,8 @@
"vitepress-plugin-mermaid": "^2.0.17"
},
"scripts": {
"docs:dev": "vitepress dev",
"docs:build": "vitepress build",
"docs:preview": "vitepress preview"
"docs:dev": "node scripts/gen-contributors.mjs && vitepress dev",
"docs:build": "node scripts/gen-contributors.mjs && vitepress build",
"docs:preview": "node scripts/gen-contributors.mjs && vitepress preview"
}
}
}
+94
View File
@@ -0,0 +1,94 @@
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
const ROOT = process.cwd();
const DOCS_DIR = path.resolve(ROOT, "docs");
const OUT_DIR = path.resolve(ROOT, ".vitepress/.generated");
const OUT_FILE = path.join(OUT_DIR, "contributors.json");
function walk(dir) {
const out = [];
for (const name of fs.readdirSync(dir)) {
const p = path.join(dir, name);
const st = fs.statSync(p);
if (st.isDirectory()) out.push(...walk(p));
else if (p.endsWith(".md")) out.push(p);
}
return out;
}
function md5(s) {
return crypto.createHash("md5").update(s.trim().toLowerCase()).digest("hex");
}
function parseGithubUsernameFromNoreply(email = "") {
// 1) 12345+username@users.noreply.github.com
// 2) username@users.noreply.github.com
const m1 = email.match(/^[^+]+\+([^@]+)@users\.noreply\.github\.com$/i);
if (m1?.[1]) return m1[1];
const m2 = email.match(/^([^@]+)@users\.noreply\.github\.com$/i);
if (m2?.[1]) return m2[1];
return null;
}
function avatarUrlFor(email = "") {
const gh = parseGithubUsernameFromNoreply(email);
if (gh) {
return `https://unavatar.io/github/${encodeURIComponent(gh)}`;
}
if (email) {
return `https://www.gravatar.com/avatar/${md5(email)}?d=identicon&s=64`;
}
return `https://www.gravatar.com/avatar/?d=identicon&s=64`;
}
function gitContributors(fileAbsPath) {
const rel = path.relative(process.cwd(), fileAbsPath).replaceAll("\\", "/");
let raw = "";
try {
raw = execSync(`git log --follow --format="%aN|%aE" -- "${rel}"`, {
encoding: "utf8",
});
} catch {
return [];
}
const map = new Map();
raw
.split("\n")
.map((s) => s.trim())
.filter(Boolean)
.forEach((line) => {
const [name, email] = line.split("|");
const key = (email || name || "").toLowerCase();
if (!map.has(key)) {
const github = parseGithubUsernameFromNoreply(email);
map.set(key, {
name,
email,
github,
avatarUrl: avatarUrlFor(email),
commits: 0,
});
}
map.get(key).commits += 1;
});
return [...map.values()].sort((a, b) => b.commits - a.commits);
}
const files = walk(DOCS_DIR);
const data = {};
for (const abs of files) {
const relToDocs = path.relative(DOCS_DIR, abs).replaceAll("\\", "/");
let route = "/" + relToDocs.replace(/\.md$/, "");
route = route.replace(/\/index$/, "/"); // docs/a/index.md -> /a/
data[route] = gitContributors(abs);
}
fs.mkdirSync(OUT_DIR, { recursive: true });
fs.writeFileSync(OUT_FILE, JSON.stringify(data, null, 2), "utf8");
console.log(`Generated contributors for ${files.length} pages -> ${OUT_FILE}`);