From 173b60cf57d001c6ac0ffd73120df0e23bc1f72c Mon Sep 17 00:00:00 2001 From: Meow <197331664+Meo597@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:44:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=98=BE=E7=A4=BA=E9=A1=B5=E9=9D=A2=E8=B4=A1?= =?UTF-8?q?=E7=8C=AE=E8=80=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../theme/components/PageContributors.vue | 158 ++++++++++++++++++ .vitepress/theme/index.mts | 9 + package.json | 8 +- scripts/gen-contributors.mjs | 94 +++++++++++ 5 files changed, 266 insertions(+), 4 deletions(-) create mode 100644 .vitepress/theme/components/PageContributors.vue create mode 100644 scripts/gen-contributors.mjs diff --git a/.gitignore b/.gitignore index 6f178ceb..dd18b2a5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules/ # VitePress .vitepress/dist/ .vitepress/cache/ +.vitepress/.generated/contributors.json diff --git a/.vitepress/theme/components/PageContributors.vue b/.vitepress/theme/components/PageContributors.vue new file mode 100644 index 00000000..dea75a78 --- /dev/null +++ b/.vitepress/theme/components/PageContributors.vue @@ -0,0 +1,158 @@ + + + + + + {{ t.contributors }} + ({{ list.length }}) + + + + + + + + {{ c.name }} + · {{ c.commits }} {{ t.commits }} + + + + {{ + c.email + }} + · + + @{{ c.github }} + + + + + + + + + diff --git a/.vitepress/theme/index.mts b/.vitepress/theme/index.mts index a57bda33..5ec6b655 100644 --- a/.vitepress/theme/index.mts +++ b/.vitepress/theme/index.mts @@ -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), + }); + }, }; diff --git a/package.json b/package.json index 8123a347..105b3a36 100644 --- a/package.json +++ b/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/scripts/gen-contributors.mjs b/scripts/gen-contributors.mjs new file mode 100644 index 00000000..f946f927 --- /dev/null +++ b/scripts/gen-contributors.mjs @@ -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}`);