Skip to content

Commit d5d58c6

Browse files
committed
feat: 用正则表达式的方式方向解析出文档的链接
1 parent d600824 commit d5d58c6

3 files changed

Lines changed: 245 additions & 54 deletions

File tree

app/components/ContributorRow.tsx

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"use client";
2+
3+
import * as Dialog from "@radix-ui/react-dialog";
4+
import { X, ExternalLink } from "lucide-react";
5+
import Link from "next/link";
6+
import { cn } from "@/lib/utils";
7+
8+
type Contributor = {
9+
id: string;
10+
name: string;
11+
points: number;
12+
commits: number;
13+
avatarUrl: string;
14+
contributedDocs?: { id: string; title: string; url: string }[];
15+
};
16+
17+
export function ContributorRow({
18+
user,
19+
idx,
20+
maxPoints,
21+
}: {
22+
user: Contributor;
23+
idx: number;
24+
maxPoints: number;
25+
}) {
26+
return (
27+
<Dialog.Root>
28+
<Dialog.Trigger asChild>
29+
<button className="w-full text-left group flex flex-col md:flex-row md:items-center gap-4 border border-[var(--foreground)] p-4 bg-[var(--background)] hard-shadow-hover transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 cursor-pointer">
30+
<div className="font-mono text-2xl font-bold w-12 text-center text-[var(--foreground)] shrink-0">
31+
#{idx + 1}
32+
</div>
33+
<div className="w-12 h-12 bg-neutral-200 dark:bg-neutral-800 border border-[var(--foreground)] transition-transform group-hover:scale-110 overflow-hidden shrink-0">
34+
<img
35+
src={user.avatarUrl}
36+
alt={user.name}
37+
className="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-300"
38+
/>
39+
</div>
40+
<div className="flex-1 min-w-[150px] overflow-hidden">
41+
<div className="font-serif text-xl font-bold text-[var(--foreground)] truncate">
42+
{user.name}
43+
</div>
44+
<div className="font-mono text-xs uppercase text-neutral-500 mt-1">
45+
{user.points.toLocaleString()} PTS
46+
</div>
47+
</div>
48+
49+
{/* 柱状图可视化积分比例 */}
50+
<div className="w-full md:w-64 lg:w-96 h-6 border border-[var(--foreground)] bg-neutral-100 dark:bg-neutral-900 overflow-hidden relative shrink-0">
51+
<div
52+
className="absolute top-0 left-0 h-full bg-[var(--foreground)] transition-all duration-1000 origin-left"
53+
style={{ width: `${(user.points / maxPoints) * 100}%` }}
54+
/>
55+
<div className="absolute inset-0 flex items-center px-2 font-mono text-[10px] text-[var(--background)] mix-blend-difference uppercase tracking-widest">
56+
POWER LEVEL
57+
</div>
58+
</div>
59+
</button>
60+
</Dialog.Trigger>
61+
62+
<Dialog.Portal>
63+
<Dialog.Overlay className="fixed inset-0 bg-black/60 z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
64+
<Dialog.Content className="fixed left-[50%] outline-none top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border-2 border-[#111111] bg-[#F9F9F7] p-6 sm:p-8 shadow-[8px_8px_0px_0px_#111111] duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] max-h-[85vh] flex col dark:bg-neutral-950 dark:border-neutral-200 dark:shadow-[8px_8px_0px_0px_#e5e5e5]">
65+
<div className="flex justify-between items-start border-b-4 border-[#111111] dark:border-neutral-200 pb-4 mb-2 shrink-0">
66+
<div className="min-w-0 pr-4">
67+
<Dialog.Title className="font-serif text-3xl font-black uppercase text-[#111111] dark:text-neutral-100 leading-none truncate">
68+
{user.name}
69+
</Dialog.Title>
70+
<Dialog.Description className="font-mono text-xs uppercase tracking-widest mt-2 text-neutral-600 dark:text-neutral-400">
71+
Contributions Dossier
72+
</Dialog.Description>
73+
</div>
74+
<Dialog.Close className="h-8 w-8 flex items-center justify-center border border-[#111111] dark:border-neutral-200 hover:bg-[#111111] hover:text-[#F9F9F7] dark:hover:bg-neutral-200 dark:hover:text-neutral-950 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#CC0000] shrink-0">
75+
<X className="h-4 w-4" />
76+
<span className="sr-only">Close</span>
77+
</Dialog.Close>
78+
</div>
79+
80+
<div className="overflow-y-auto pr-2 flex-grow min-h-[150px] relative">
81+
<h4 className="font-serif text-lg font-bold mb-4 text-[#111111] dark:text-neutral-100 flex items-center gap-2">
82+
Document Commits
83+
<span className="font-mono text-xs bg-[#111111] text-[#F9F9F7] dark:bg-neutral-200 dark:text-neutral-950 px-2 py-0.5">
84+
{user.commits}
85+
</span>
86+
</h4>
87+
<div className="flex flex-col gap-0 border-t border-[#111111]/20 dark:border-neutral-200/20">
88+
{user.contributedDocs && user.contributedDocs.length > 0 ? (
89+
user.contributedDocs.map((doc) => (
90+
<Link
91+
key={doc.id}
92+
href={doc.url}
93+
className="flex w-full items-center justify-between group/link border-b border-[#111111]/20 dark:border-neutral-200/20 py-3 hover:bg-[#111111]/5 dark:hover:bg-white/5 transition-colors px-2"
94+
>
95+
<span className="font-mono text-sm text-[#111111] dark:text-neutral-200 group-hover/link:underline decoration-2 decoration-[#CC0000] underline-offset-4 truncate pr-4">
96+
{doc.title}
97+
</span>
98+
<ExternalLink className="h-4 w-4 text-[#111111]/50 dark:text-neutral-400 group-hover/link:text-[#CC0000] transition-colors shrink-0" />
99+
</Link>
100+
))
101+
) : (
102+
<div className="text-sm font-body italic text-neutral-500 pt-4">
103+
No explicit document history found.
104+
</div>
105+
)}
106+
</div>
107+
</div>
108+
109+
<div className="pt-4 border-t-2 border-[#111111] dark:border-neutral-200 mt-2 shrink-0 bg-[#F9F9F7] dark:bg-neutral-950">
110+
<div className="flex justify-between items-center">
111+
<span className="font-mono text-xs uppercase tracking-widest text-[#111111] dark:text-neutral-200">
112+
Total Score
113+
</span>
114+
<span className="font-serif font-black text-2xl text-[#CC0000]">
115+
{user.points.toLocaleString()}{" "}
116+
<span className="text-sm font-mono text-[#111111] dark:text-neutral-200 tracking-normal">
117+
PTS
118+
</span>
119+
</span>
120+
</div>
121+
</div>
122+
</Dialog.Content>
123+
</Dialog.Portal>
124+
</Dialog.Root>
125+
);
126+
}

app/rank/page.tsx

Lines changed: 7 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Header } from "@/app/components/Header";
22
import { Footer } from "@/app/components/Footer";
33
import { AnimatedBar } from "@/app/components/AnimatedBar";
44
import { cn } from "@/lib/utils";
5+
import { ContributorRow } from "@/app/components/ContributorRow";
56

67
import leaderboardData from "@/generated/site-leaderboard.json";
78

@@ -12,6 +13,7 @@ const mockRanks = leaderboardData as {
1213
points: number;
1314
commits: number;
1415
avatarUrl: string;
16+
contributedDocs?: { id: string; title: string; url: string }[];
1517
}[];
1618

1719
export default function RankPage() {
@@ -33,40 +35,12 @@ export default function RankPage() {
3335

3436
<div className="flex flex-col gap-4">
3537
{mockRanks.map((user, idx) => (
36-
<div
38+
<ContributorRow
3739
key={user.id}
38-
className="group flex flex-col md:flex-row md:items-center gap-4 border border-[var(--foreground)] p-4 bg-[var(--background)] hard-shadow-hover transition-all"
39-
>
40-
<div className="font-mono text-2xl font-bold w-12 text-center text-[var(--foreground)]">
41-
#{idx + 1}
42-
</div>
43-
<div className="w-12 h-12 bg-neutral-200 dark:bg-neutral-800 border border-[var(--foreground)] transition-transform group-hover:scale-110 overflow-hidden">
44-
<img
45-
src={user.avatarUrl}
46-
alt={user.name}
47-
className="w-full h-full object-cover"
48-
/>
49-
</div>
50-
<div className="flex-1 min-w-[150px]">
51-
<div className="font-serif text-xl font-bold text-[var(--foreground)]">
52-
{user.name}
53-
</div>
54-
<div className="font-mono text-xs uppercase text-neutral-500 mt-1">
55-
{user.points.toLocaleString()} PTS
56-
</div>
57-
</div>
58-
59-
{/* Bar chart visualization */}
60-
<div className="w-full md:w-64 lg:w-96 h-6 border border-[var(--foreground)] bg-neutral-100 dark:bg-neutral-900 overflow-hidden relative">
61-
<div
62-
className="absolute top-0 left-0 h-full bg-[var(--foreground)] transition-all duration-1000 origin-left"
63-
style={{ width: `${(user.points / maxPoints) * 100}%` }}
64-
/>
65-
<div className="absolute inset-0 flex items-center px-2 font-mono text-[10px] text-[var(--background)] mix-blend-difference">
66-
POWER LEVEL
67-
</div>
68-
</div>
69-
</div>
40+
user={user}
41+
idx={idx}
42+
maxPoints={maxPoints}
43+
/>
7044
))}
7145
</div>
7246
</div>

scripts/generate-leaderboard.mjs

Lines changed: 112 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ dotenv.config({ path: [".env.local", ".env"] });
1818
import * as PrismaModule from "../generated/prisma/client.ts";
1919
const PrismaClient =
2020
PrismaModule.default?.PrismaClient || PrismaModule.PrismaClient;
21-
import pg from "pg";
22-
const { Pool } = pg;
2321
import { PrismaPg } from "@prisma/adapter-pg";
22+
import pg from "pg";
23+
// 我们使用 node:fs/promises 的 fs 即可
2424

2525
const __filename = fileURLToPath(import.meta.url);
2626
const __dirname = path.dirname(__filename);
@@ -35,49 +35,140 @@ async function ensureParentDir(filePath) {
3535

3636
async function main() {
3737
if (!process.env.DATABASE_URL) {
38-
console.error("[generate-leaderboard] No DATABASE_URL found. Skipping.");
38+
console.error(
39+
"[generate-leaderboard] 未找到 DATABASE_URL,跳过生成排行榜。 | No DATABASE_URL found. Skipping.",
40+
);
3941
process.exit(0);
4042
}
4143

44+
const rawData = await fs.readFile(
45+
path.join(__dirname, "../.source/index.ts"),
46+
"utf-8",
47+
);
48+
const docsMap = {};
49+
50+
// 正则提取所有类似 { info: {"path":"...","absolutePath":"..."}, data: docs_0 } 的节点数据。
51+
// 在 .source/index.ts 文件底部有一行类似于:
52+
// export const docs = _runtime.docs<typeof _source.docs>([{ info: ... }, { info: ... }])
53+
//
54+
// 正则解析:
55+
56+
// - `/s`: 允许 `.` 匹配换行符,防备代码被格式化成多行
57+
58+
// - `export const docs.*=\s*.*?docs>\(\[`: 匹配由 Fumadocs 自动生成的固定代码开头,直到方括号 `[`
59+
const pagesInfoMatch = rawData.match(
60+
/export const docs.*=\s*.*?docs>\(\[(.*?)\]\)/s,
61+
);
62+
63+
// pagesInfoMatch[1] 代表我们在上方的正则中用括号 (.*?) 提取出来的具体内容(也就是那一大串 {info: ...} 数组字符串)
64+
if (pagesInfoMatch && pagesInfoMatch[1]) {
65+
const pagesRaw = pagesInfoMatch[1];
66+
// 我们利用简单解析获取所有的 path 和大致提取对应的导入行
67+
const pageItems = pagesRaw.split("}, {");
68+
for (const item of pageItems) {
69+
// - `(.*?)`: 捕获方括号内部的所有的对象内容(非贪婪匹配),这一部分就是所有文章的配置数组
70+
const pathMatch = item.match(/"path":"(.*?)"/);
71+
if (pathMatch && pathMatch[1]) {
72+
const docPath = pathMatch[1];
73+
let title = docPath.replace(/\.mdx?$/, "");
74+
const url = `/docs/${title}`;
75+
76+
let docIdFromFm = null;
77+
// 为了获取确切的 title 和 docId,我们需要打开实际的文件获取 frontmatter,
78+
try {
79+
const absolutePathMatch = item.match(/"absolutePath":"(.*?)"/);
80+
if (absolutePathMatch && absolutePathMatch[1]) {
81+
const content = await fs.readFile(absolutePathMatch[1], "utf-8");
82+
83+
// 提取 title
84+
const titleMatch =
85+
content.match(/^title:\s*(?:'|")?(.*?)(?:'|")?$/m) ||
86+
content.match(/^#\s+(.*)$/m);
87+
if (titleMatch && titleMatch[1]) {
88+
title = titleMatch[1].trim();
89+
}
90+
91+
// 提取 docId
92+
const docIdMatch = content.match(
93+
/^docId:\s*(?:'|")?(.*?)(?:'|")?$/m,
94+
);
95+
if (docIdMatch && docIdMatch[1]) {
96+
docIdFromFm = docIdMatch[1].trim();
97+
}
98+
}
99+
} catch (e) {
100+
console.error(e);
101+
}
102+
103+
// 优先使用 frontmatter 中的 docId 作为键(与数据库中存储的 CUID 对应)
104+
// 否则回退使用文件路径作为键
105+
const key = docIdFromFm || docPath.replace(/\.mdx?$/, "");
106+
docsMap[key] = { title, url };
107+
}
108+
}
109+
}
110+
42111
const connectionString = `${process.env.DATABASE_URL}`;
43-
const pool = new Pool({ connectionString });
112+
const pool = new pg.Pool({ connectionString });
44113
const adapter = new PrismaPg(pool);
45114
const prisma = new PrismaClient({ adapter });
46115

47116
console.log(
48-
"[generate-leaderboard] Connecting to database to aggregate contributions...",
117+
"[generate-leaderboard] 连接数据库以聚合贡献数据... | Connecting to database to aggregate contributions...",
49118
);
50119

51120
try {
52-
// 聚合每一个 github_id 的总贡献量
53-
const aggregations = await prisma.doc_contributors.groupBy({
54-
by: ["github_id"],
55-
_sum: {
56-
contributions: true,
57-
},
58-
});
121+
// 聚合每一个 github_id 的总贡献量和所有贡献的文章
122+
const allRecords = await prisma.doc_contributors.findMany();
123+
124+
const grouped = {};
125+
for (const record of allRecords) {
126+
const gid = record.github_id.toString();
127+
if (!grouped[gid]) {
128+
grouped[gid] = {
129+
contributions: 0,
130+
docs: new Set(),
131+
};
132+
}
133+
grouped[gid].contributions += record.contributions;
134+
grouped[gid].docs.add(record.doc_id);
135+
}
59136

60137
// 格式化输出榜单
61-
const leaderboard = aggregations
62-
.filter((item) => item._sum.contributions && item._sum.contributions > 0)
63-
.map((item) => {
64-
const points = Number(item._sum.contributions) * 10; // 每个 commit 暂定 10 分
65-
const githubId = item.github_id.toString();
138+
const leaderboard = Object.entries(grouped)
139+
.filter(([id, data]) => data.contributions > 0)
140+
.map(([id, data]) => {
141+
const points = data.contributions * 10; // 每个 commit 暂定 10 分
142+
const githubId = id;
143+
144+
const contributedDocsInfo = Array.from(data.docs).map((dbDocId) => {
145+
// dbDocId 对应数据库里的 CUID (如 psc0xf6oa1m7g8s9wfwiojkf)
146+
// 或之前的路径 (如 path/to/doc.mdx 需要去除后缀匹配)
147+
const key = dbDocId.replace(/\.mdx?$/, "");
148+
const mappedInfo = docsMap[key];
149+
150+
return {
151+
id: dbDocId,
152+
title: mappedInfo ? mappedInfo.title : dbDocId, // 若没有匹配到页面,回退显示 docId
153+
url: mappedInfo ? mappedInfo.url : `/docs/${key}`,
154+
};
155+
});
66156

67157
return {
68158
id: githubId,
69159
// 暂时没有办法直接从表中获取 login_name,我们就以此格式保留并前端展示默认占位符或者使用 github username API 换取 (如果需要完全离线,则只展示ID)
70160
name: `GitHub User ${githubId}`,
71161
points: points,
72-
commits: item._sum.contributions,
162+
commits: data.contributions,
73163
avatarUrl: `https://avatars.githubusercontent.com/u/${githubId}`,
164+
contributedDocs: contributedDocsInfo,
74165
};
75166
})
76167
.sort((a, b) => b.points - a.points);
77168

78169
// 为前排用户附带一下 Github API 拉取详细昵称信息,由于只是少数人(例如前100),可以接受构建时拉取。
79170
console.log(
80-
`[generate-leaderboard] Aggregated ${leaderboard.length} users. Annotating top 100 with Github API...`,
171+
`[generate-leaderboard] 已聚合 ${leaderboard.length} 名用户,正在通过 GitHub API 获取前 100 名的详细信息... | Aggregated ${leaderboard.length} users. Annotating top 100 with Github API...`,
81172
);
82173

83174
// 我们在此取前 100 名抓取 Github Name 防止 Rate Limit
@@ -104,11 +195,11 @@ async function main() {
104195
await fs.writeFile(outputAbs, JSON.stringify(leaderboard, null, 2), "utf8");
105196

106197
console.log(
107-
`[generate-leaderboard] Successfully wrote leaderboard to ${OUTPUT}`,
198+
`[generate-leaderboard] 排行榜数据已成功写入至 ${OUTPUT} | Successfully wrote leaderboard to ${OUTPUT}`,
108199
);
109200
} catch (error) {
110201
console.error(
111-
"[generate-leaderboard] Failed to generate leaderboard:",
202+
"[generate-leaderboard] 生成排行榜失败: | Failed to generate leaderboard:",
112203
error,
113204
);
114205
process.exit(1);

0 commit comments

Comments
 (0)