@shiinasaku/github-card 4.1.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +182 -98
- package/dist/card.d.ts +2 -15
- package/dist/lib.d.ts +1 -1
- package/dist/lib.js +3951 -380
- package/dist/types.d.ts +8 -3
- package/dist/utils/font.d.ts +3 -1
- package/dist/utils/format.d.ts +7 -0
- package/dist/utils/icons.d.ts +3 -1
- package/dist/utils/index.d.ts +5 -6
- package/dist/utils/languages.d.ts +0 -1
- package/package.json +16 -19
- package/src/card.ts +392 -467
- package/src/gallery.ts +37 -0
- package/src/github.ts +180 -78
- package/src/index.ts +91 -167
- package/src/lib.ts +1 -1
- package/src/types.ts +8 -3
- package/src/utils/font.ts +4 -2
- package/src/utils/format.ts +62 -22
- package/src/utils/github-client.ts +43 -133
- package/src/utils/icons.ts +31 -10
- package/src/utils/index.ts +5 -6
- package/src/utils/languages.ts +1 -1
- package/fonts/Roboto-subset.woff2 +0 -0
- package/src/env.d.ts +0 -4
- package/src/input.css +0 -1
- package/src/tailwind.css +0 -3837
package/src/gallery.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { renderCard, type CardOpts } from "./card";
|
|
2
|
+
import type { LanguageStat, UserProfile, UserStats } from "./types";
|
|
3
|
+
import { escapeXml, themes } from "./utils";
|
|
4
|
+
|
|
5
|
+
/** Inline SVG cards so the preview also works through GitHub's image proxy. */
|
|
6
|
+
export function renderThemeGallery(
|
|
7
|
+
user: UserProfile,
|
|
8
|
+
stats: UserStats,
|
|
9
|
+
languages: LanguageStat[],
|
|
10
|
+
options: CardOpts = {},
|
|
11
|
+
): string {
|
|
12
|
+
const cards = Object.keys(themes).map((theme) => {
|
|
13
|
+
const svg = renderCard(user, stats, languages, { ...options, theme, idPrefix: theme });
|
|
14
|
+
// Dimensions come from our renderer, not user-supplied SVG.
|
|
15
|
+
const [, width, height] = svg.match(/^<svg\b[^>]*\bviewBox="0 0 (\d+) (\d+)"/) ?? [];
|
|
16
|
+
if (!width || !height) throw new Error("Card renderer returned an invalid viewBox");
|
|
17
|
+
return { theme, svg, width: Number(width), height: Number(height) };
|
|
18
|
+
});
|
|
19
|
+
const columns = 2;
|
|
20
|
+
const gap = 20;
|
|
21
|
+
const labelHeight = 22;
|
|
22
|
+
const cardWidth = Math.max(...cards.map((card) => card.width));
|
|
23
|
+
const cellHeight = Math.max(...cards.map((card) => card.height)) + labelHeight;
|
|
24
|
+
const rows = Math.ceil(cards.length / columns);
|
|
25
|
+
const width = columns * cardWidth + (columns + 1) * gap;
|
|
26
|
+
const height = rows * cellHeight + (rows + 1) * gap;
|
|
27
|
+
|
|
28
|
+
const blocks = cards.map(({ theme, svg }, index) => {
|
|
29
|
+
const x = gap + (index % columns) * (cardWidth + gap);
|
|
30
|
+
const y = gap + Math.floor(index / columns) * (cellHeight + gap) + labelHeight;
|
|
31
|
+
const card = svg.replace(/^<svg\b/, `<svg x="${x}" y="${y}"`);
|
|
32
|
+
const label = `<text x="${x + 4}" y="${y - 8}" fill="#e2e8f0" font-size="13" font-family="system-ui,sans-serif" font-weight="700">${escapeXml(theme)}</text>`;
|
|
33
|
+
return label + card;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="GitHub Card themes for user"><rect width="100%" height="100%" fill="#0b1220"/>${blocks.join("")}</svg>`;
|
|
37
|
+
}
|
package/src/github.ts
CHANGED
|
@@ -11,18 +11,18 @@ import {
|
|
|
11
11
|
export { NotFoundError, RateLimitError, AuthError, UpstreamError };
|
|
12
12
|
|
|
13
13
|
const githubClient = new GitHubClient({
|
|
14
|
-
token: Bun.env.GITHUB_TOKEN
|
|
14
|
+
token: Bun.env.GITHUB_TOKEN ?? "",
|
|
15
15
|
userAgent: "github-card",
|
|
16
16
|
});
|
|
17
17
|
|
|
18
18
|
const CACHE_FRESH_MS = 30 * 60 * 1000;
|
|
19
19
|
const CACHE_STALE_MS = 30 * 60 * 1000;
|
|
20
|
-
const CACHE_VERSION =
|
|
20
|
+
const CACHE_VERSION = 9;
|
|
21
21
|
const REDIS_TTL_SECONDS = Math.ceil((CACHE_FRESH_MS + CACHE_STALE_MS) / 1000);
|
|
22
22
|
const FETCH_TIMEOUT_MS = 6000;
|
|
23
23
|
const AVATAR_TIMEOUT_MS = 4000;
|
|
24
24
|
const MAX_AVATAR_BYTES = 256 * 1024;
|
|
25
|
-
const MAX_PAGES =
|
|
25
|
+
const MAX_PAGES = 100;
|
|
26
26
|
const REPO_PAGE_SIZE = 100;
|
|
27
27
|
const MAX_L1_ENTRIES = 1000;
|
|
28
28
|
const REDIS_PROFILE_PREFIX = "profile:";
|
|
@@ -58,7 +58,7 @@ const refreshInFlight = new Set<string>();
|
|
|
58
58
|
|
|
59
59
|
function evictL1IfNeeded() {
|
|
60
60
|
if (memCache.size <= MAX_L1_ENTRIES) return;
|
|
61
|
-
const oldestKey = memCache.keys().next().value
|
|
61
|
+
const oldestKey = memCache.keys().next().value;
|
|
62
62
|
if (oldestKey) memCache.delete(oldestKey);
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -123,8 +123,8 @@ export function getCacheMetrics() {
|
|
|
123
123
|
fresh,
|
|
124
124
|
stale,
|
|
125
125
|
expired,
|
|
126
|
-
ttlFreshSeconds:
|
|
127
|
-
ttlStaleSeconds:
|
|
126
|
+
ttlFreshSeconds: CACHE_FRESH_MS / 1000,
|
|
127
|
+
ttlStaleSeconds: CACHE_STALE_MS / 1000,
|
|
128
128
|
};
|
|
129
129
|
}
|
|
130
130
|
|
|
@@ -133,18 +133,61 @@ function normalizeUsername(username: string): string {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
function normalizeOrgFilters(orgs?: string[]): string[] {
|
|
136
|
-
return Array.from(
|
|
136
|
+
return Array.from(
|
|
137
|
+
new Set((orgs || []).map((o) => o.trim().toLowerCase()).filter(Boolean)),
|
|
138
|
+
).sort();
|
|
137
139
|
}
|
|
138
140
|
|
|
139
141
|
type FetchOptions = {
|
|
140
142
|
includeLanguages?: boolean;
|
|
141
143
|
langCount?: number;
|
|
144
|
+
hideLangs?: string[];
|
|
145
|
+
showLangs?: string[];
|
|
142
146
|
scope?: "personal" | "org" | "all";
|
|
143
147
|
orgs?: string[];
|
|
144
148
|
affiliations?: "owner" | "affiliated";
|
|
145
149
|
forceRefresh?: boolean;
|
|
146
150
|
};
|
|
147
151
|
|
|
152
|
+
type RepoNode = {
|
|
153
|
+
nameWithOwner: string;
|
|
154
|
+
isFork?: boolean;
|
|
155
|
+
stargazers: { totalCount: number };
|
|
156
|
+
owner?: { login: string; __typename: string } | null;
|
|
157
|
+
languages?: { edges: LangEdge[] | null } | null;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
type PageInfo = { hasNextPage: boolean; endCursor: string | null };
|
|
161
|
+
type RepoConnection = { pageInfo: PageInfo; nodes: RepoNode[] | null };
|
|
162
|
+
|
|
163
|
+
type ProfileUser = {
|
|
164
|
+
login: string;
|
|
165
|
+
name: string | null;
|
|
166
|
+
avatarUrl: string;
|
|
167
|
+
bio: string | null;
|
|
168
|
+
pronouns: string | null;
|
|
169
|
+
twitterUsername: string | null;
|
|
170
|
+
openPRs: { totalCount: number };
|
|
171
|
+
closedPRs: { totalCount: number };
|
|
172
|
+
mergedPRs: { totalCount: number };
|
|
173
|
+
openIssues: { totalCount: number };
|
|
174
|
+
closedIssues: { totalCount: number };
|
|
175
|
+
contributionsCollection: {
|
|
176
|
+
totalCommitContributions: number;
|
|
177
|
+
commitContributionsByRepository?: ContributionEntry[] | null;
|
|
178
|
+
pullRequestContributionsByRepository?: ContributionEntry[] | null;
|
|
179
|
+
issueContributionsByRepository?: ContributionEntry[] | null;
|
|
180
|
+
};
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
type OrgProfile = {
|
|
184
|
+
login: string;
|
|
185
|
+
name: string | null;
|
|
186
|
+
avatarUrl: string;
|
|
187
|
+
description: string | null;
|
|
188
|
+
twitterUsername: string | null;
|
|
189
|
+
};
|
|
190
|
+
|
|
148
191
|
const QUERY_PROFILE = `
|
|
149
192
|
query fullProfile($login: String!, $from: DateTime!, $to: DateTime!, $isOrg: Boolean!) {
|
|
150
193
|
user(login: $login) {
|
|
@@ -186,9 +229,9 @@ query paginateRepos($login: String!, $cursor: String, $pageSize: Int!, $affiliat
|
|
|
186
229
|
const QUERY_PAGINATE_ORG_CONTRIBS = `
|
|
187
230
|
query paginateOrgContribs($login: String!, $cursor: String, $pageSize: Int!, $fetchLangs: Boolean!) {
|
|
188
231
|
user(login: $login) {
|
|
189
|
-
repositoriesContributedTo(first: $pageSize, contributionTypes: [COMMIT, PULL_REQUEST, REPOSITORY], includeUserRepositories: false, after: $cursor) {
|
|
232
|
+
repositoriesContributedTo(first: $pageSize, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY], includeUserRepositories: false, after: $cursor) {
|
|
190
233
|
pageInfo { hasNextPage endCursor }
|
|
191
|
-
nodes { nameWithOwner owner { login __typename } stargazers { totalCount } languages(first: 10, orderBy: {field: SIZE, direction: DESC}) @include(if: $fetchLangs) { edges { size node { color name } } } }
|
|
234
|
+
nodes { nameWithOwner isFork owner { login __typename } stargazers { totalCount } languages(first: 10, orderBy: {field: SIZE, direction: DESC}) @include(if: $fetchLangs) { edges { size node { color name } } } }
|
|
192
235
|
}
|
|
193
236
|
}
|
|
194
237
|
}
|
|
@@ -205,21 +248,23 @@ query paginateOrg($login: String!, $cursor: String, $pageSize: Int!, $fetchLangs
|
|
|
205
248
|
}
|
|
206
249
|
`;
|
|
207
250
|
|
|
208
|
-
async function postGraphQL(query: string, variables: Record<string, unknown>) {
|
|
209
|
-
return githubClient.request(query, {
|
|
251
|
+
async function postGraphQL<T>(query: string, variables: Record<string, unknown>): Promise<T> {
|
|
252
|
+
return githubClient.request<T>(query, {
|
|
210
253
|
variables,
|
|
211
254
|
timeoutMs: FETCH_TIMEOUT_MS,
|
|
212
255
|
});
|
|
213
256
|
}
|
|
214
257
|
|
|
215
258
|
type LangMap = Map<string, { size: number; color: string }>;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
if (
|
|
222
|
-
|
|
259
|
+
type LangEdge = { size: number; node: { name: string; color: string | null } };
|
|
260
|
+
|
|
261
|
+
function accLangs(edges: LangEdge[] | null | undefined, map: LangMap): void {
|
|
262
|
+
if (!edges?.length) return;
|
|
263
|
+
for (const edge of edges) {
|
|
264
|
+
if (!edge?.node?.name || !edge.size) continue;
|
|
265
|
+
const existing = map.get(edge.node.name);
|
|
266
|
+
if (existing) existing.size += edge.size;
|
|
267
|
+
else map.set(edge.node.name, { size: edge.size, color: edge.node.color ?? "#ccc" });
|
|
223
268
|
}
|
|
224
269
|
}
|
|
225
270
|
|
|
@@ -244,23 +289,33 @@ async function fetchAvatarAsBase64(url: string | null | undefined): Promise<stri
|
|
|
244
289
|
}
|
|
245
290
|
}
|
|
246
291
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
292
|
+
type ContributionEntry = {
|
|
293
|
+
repository?: { owner?: { login?: string; __typename?: string } | null } | null;
|
|
294
|
+
contributions?: { totalCount?: number } | null;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
function sumContributions(
|
|
298
|
+
entries: ContributionEntry[] | null | undefined,
|
|
299
|
+
orgsSet: Set<string> | null,
|
|
300
|
+
): number {
|
|
301
|
+
if (!entries?.length) return 0;
|
|
302
|
+
let total = 0;
|
|
303
|
+
for (const entry of entries) {
|
|
304
|
+
const owner = entry?.repository?.owner;
|
|
251
305
|
if (owner?.__typename !== "Organization" || !owner?.login) continue;
|
|
252
306
|
if (orgsSet && !orgsSet.has(owner.login.toLowerCase())) continue;
|
|
253
|
-
|
|
307
|
+
total += entry?.contributions?.totalCount ?? 0;
|
|
254
308
|
}
|
|
255
|
-
return
|
|
309
|
+
return total;
|
|
256
310
|
}
|
|
257
311
|
|
|
258
|
-
|
|
312
|
+
type EffectiveFetchOptions = Required<FetchOptions>;
|
|
313
|
+
|
|
314
|
+
async function directFetch(username: string, opts: EffectiveFetchOptions): Promise<ProfileData> {
|
|
259
315
|
const isPersonal = opts.scope !== "org";
|
|
260
316
|
const isOrg = opts.scope === "org" || opts.scope === "all";
|
|
261
|
-
const fetchLangs = opts.includeLanguages
|
|
262
|
-
const
|
|
263
|
-
const orgsSet = orgsArr.length > 0 ? new Set(orgsArr) : null;
|
|
317
|
+
const fetchLangs = opts.includeLanguages;
|
|
318
|
+
const orgsSet = opts.orgs.length > 0 ? new Set(opts.orgs) : null;
|
|
264
319
|
const affiliations =
|
|
265
320
|
opts.affiliations === "owner" ? ["OWNER"] : ["OWNER", "ORGANIZATION_MEMBER", "COLLABORATOR"];
|
|
266
321
|
|
|
@@ -268,20 +323,20 @@ async function directFetch(username: string, opts: FetchOptions): Promise<Profil
|
|
|
268
323
|
const from = new Date(Date.UTC(now.getUTCFullYear(), 0, 1)).toISOString();
|
|
269
324
|
const to = now.toISOString();
|
|
270
325
|
|
|
271
|
-
let user:
|
|
326
|
+
let user: ProfileUser | OrgProfile | null = null;
|
|
272
327
|
let isOrgAccount = false;
|
|
273
328
|
try {
|
|
274
|
-
const data = await postGraphQL(QUERY_PROFILE, {
|
|
329
|
+
const data = await postGraphQL<{ user: ProfileUser | null }>(QUERY_PROFILE, {
|
|
275
330
|
login: username,
|
|
276
331
|
from,
|
|
277
332
|
to,
|
|
278
|
-
isOrg,
|
|
333
|
+
isOrg: opts.scope === "org",
|
|
279
334
|
});
|
|
280
335
|
user = data.user;
|
|
281
336
|
if (!user) throw new NotFoundError();
|
|
282
337
|
} catch (err) {
|
|
283
338
|
if (err instanceof NotFoundError) {
|
|
284
|
-
const data = await postGraphQL(QUERY_ORG, {
|
|
339
|
+
const data = await postGraphQL<{ organization: OrgProfile | null }>(QUERY_ORG, {
|
|
285
340
|
login: username,
|
|
286
341
|
});
|
|
287
342
|
user = data.organization;
|
|
@@ -295,35 +350,37 @@ async function directFetch(username: string, opts: FetchOptions): Promise<Profil
|
|
|
295
350
|
let stars = 0;
|
|
296
351
|
let reposCount = 0;
|
|
297
352
|
|
|
298
|
-
const processNode = (
|
|
299
|
-
if (!
|
|
300
|
-
if (
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
orgsSet &&
|
|
304
|
-
|
|
305
|
-
)
|
|
306
|
-
|
|
307
|
-
seenRepos.add(n.nameWithOwner);
|
|
308
|
-
stars += n.stargazers?.totalCount || 0;
|
|
353
|
+
const processNode = (node: RepoNode, scopeCheck: boolean): void => {
|
|
354
|
+
if (!node?.nameWithOwner || node.isFork || seenRepos.has(node.nameWithOwner)) return;
|
|
355
|
+
if (scopeCheck) {
|
|
356
|
+
const owner = node.owner;
|
|
357
|
+
if (owner?.__typename !== "Organization" || !owner.login) return;
|
|
358
|
+
if (orgsSet && !orgsSet.has(owner.login.toLowerCase())) return;
|
|
359
|
+
}
|
|
360
|
+
seenRepos.add(node.nameWithOwner);
|
|
361
|
+
stars += node.stargazers?.totalCount ?? 0;
|
|
309
362
|
reposCount++;
|
|
310
|
-
if (fetchLangs) accLangs(
|
|
363
|
+
if (fetchLangs) accLangs(node.languages?.edges ?? null, langMap);
|
|
311
364
|
};
|
|
312
365
|
|
|
313
|
-
|
|
366
|
+
type PaginateData = {
|
|
367
|
+
user?: { repositories?: RepoConnection; repositoriesContributedTo?: RepoConnection } | null;
|
|
368
|
+
organization?: { repositories: RepoConnection } | null;
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const getConnection = (data: PaginateData, query: string): RepoConnection | undefined =>
|
|
314
372
|
data.user
|
|
315
373
|
? query.includes("repositoriesContributedTo")
|
|
316
|
-
? data.user.repositoriesContributedTo
|
|
317
|
-
: data.user.repositories
|
|
374
|
+
? (data.user.repositoriesContributedTo ?? undefined)
|
|
375
|
+
: (data.user.repositories ?? undefined)
|
|
318
376
|
: data.organization?.repositories;
|
|
319
377
|
|
|
320
|
-
const traversePages = async (query: string, scopeCheck: boolean) => {
|
|
378
|
+
const traversePages = async (query: string, scopeCheck: boolean): Promise<void> => {
|
|
321
379
|
let cursor: string | null = null;
|
|
322
|
-
|
|
323
|
-
let hasNext = true;
|
|
380
|
+
const seenCursors = new Set<string>();
|
|
324
381
|
|
|
325
|
-
|
|
326
|
-
const data = await postGraphQL(query, {
|
|
382
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
383
|
+
const data = await postGraphQL<PaginateData>(query, {
|
|
327
384
|
login: username,
|
|
328
385
|
cursor,
|
|
329
386
|
pageSize: REPO_PAGE_SIZE,
|
|
@@ -331,11 +388,23 @@ async function directFetch(username: string, opts: FetchOptions): Promise<Profil
|
|
|
331
388
|
affiliations,
|
|
332
389
|
});
|
|
333
390
|
const nextConn = getConnection(data, query);
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
391
|
+
if (
|
|
392
|
+
!nextConn ||
|
|
393
|
+
!Array.isArray(nextConn.nodes) ||
|
|
394
|
+
typeof nextConn.pageInfo?.hasNextPage !== "boolean"
|
|
395
|
+
) {
|
|
396
|
+
throw new UpstreamError("GitHub repository pagination connection is missing or invalid");
|
|
397
|
+
}
|
|
398
|
+
for (const node of nextConn.nodes) processNode(node, scopeCheck);
|
|
399
|
+
if (!nextConn.pageInfo.hasNextPage) return;
|
|
400
|
+
const nextCursor = nextConn.pageInfo.endCursor;
|
|
401
|
+
if (typeof nextCursor !== "string" || !nextCursor.trim() || seenCursors.has(nextCursor)) {
|
|
402
|
+
throw new UpstreamError("GitHub repository pagination cursor is missing or invalid");
|
|
403
|
+
}
|
|
404
|
+
seenCursors.add(nextCursor);
|
|
405
|
+
cursor = nextCursor;
|
|
338
406
|
}
|
|
407
|
+
throw new UpstreamError(`GitHub repository pagination exceeded ${MAX_PAGES} pages`);
|
|
339
408
|
};
|
|
340
409
|
|
|
341
410
|
const tasks: Promise<void>[] = [];
|
|
@@ -348,39 +417,51 @@ async function directFetch(username: string, opts: FetchOptions): Promise<Profil
|
|
|
348
417
|
|
|
349
418
|
const [avatarDataUrl] = await Promise.all([fetchAvatarAsBase64(user.avatarUrl), ...tasks]);
|
|
350
419
|
|
|
351
|
-
let commits =
|
|
352
|
-
let prs =
|
|
353
|
-
let issues =
|
|
420
|
+
let commits: number | null = null;
|
|
421
|
+
let prs: number | null = null;
|
|
422
|
+
let issues: number | null = null;
|
|
354
423
|
|
|
355
424
|
if (!isOrgAccount) {
|
|
356
|
-
const
|
|
425
|
+
const profile = user as ProfileUser;
|
|
426
|
+
const c = profile.contributionsCollection;
|
|
357
427
|
if (opts.scope === "org") {
|
|
358
428
|
commits = sumContributions(c?.commitContributionsByRepository, orgsSet);
|
|
359
429
|
prs = sumContributions(c?.pullRequestContributionsByRepository, orgsSet);
|
|
360
430
|
issues = sumContributions(c?.issueContributionsByRepository, orgsSet);
|
|
361
431
|
} else {
|
|
362
|
-
commits = c?.totalCommitContributions
|
|
432
|
+
commits = c?.totalCommitContributions ?? 0;
|
|
363
433
|
prs =
|
|
364
|
-
(
|
|
365
|
-
(
|
|
366
|
-
(
|
|
367
|
-
issues = (
|
|
434
|
+
(profile.openPRs?.totalCount ?? 0) +
|
|
435
|
+
(profile.closedPRs?.totalCount ?? 0) +
|
|
436
|
+
(profile.mergedPRs?.totalCount ?? 0);
|
|
437
|
+
issues = (profile.openIssues?.totalCount ?? 0) + (profile.closedIssues?.totalCount ?? 0);
|
|
368
438
|
}
|
|
369
439
|
}
|
|
370
440
|
|
|
441
|
+
const hiddenLanguages = new Set(opts.hideLangs);
|
|
442
|
+
const shownLanguages = new Set(opts.showLangs);
|
|
371
443
|
const languages = Array.from(langMap.entries())
|
|
444
|
+
.filter(([name]) => {
|
|
445
|
+
const normalized = name.trim().toLowerCase();
|
|
446
|
+
return (
|
|
447
|
+
!hiddenLanguages.has(normalized) && (!shownLanguages.size || shownLanguages.has(normalized))
|
|
448
|
+
);
|
|
449
|
+
})
|
|
372
450
|
.map(([name, { size, color }]) => ({ name, size, color }))
|
|
373
451
|
.sort((a, b) => b.size - a.size)
|
|
374
|
-
.slice(0, opts.langCount
|
|
452
|
+
.slice(0, opts.langCount);
|
|
453
|
+
|
|
454
|
+
const profileUser = !isOrgAccount ? (user as ProfileUser) : null;
|
|
455
|
+
const orgUser = isOrgAccount ? (user as OrgProfile) : null;
|
|
375
456
|
|
|
376
457
|
return {
|
|
377
458
|
user: {
|
|
378
459
|
login: user.login,
|
|
379
460
|
name: user.name,
|
|
380
461
|
avatarUrl: avatarDataUrl,
|
|
381
|
-
bio:
|
|
382
|
-
pronouns:
|
|
383
|
-
twitter: user.twitterUsername,
|
|
462
|
+
bio: isOrgAccount ? (orgUser?.description ?? null) : (profileUser?.bio ?? null),
|
|
463
|
+
pronouns: isOrgAccount ? null : (profileUser?.pronouns ?? null),
|
|
464
|
+
twitter: user.twitterUsername ?? null,
|
|
384
465
|
},
|
|
385
466
|
stats: { stars, repos: reposCount, prs, issues, commits },
|
|
386
467
|
languages,
|
|
@@ -391,18 +472,39 @@ export async function getProfileData(
|
|
|
391
472
|
username: string,
|
|
392
473
|
opts: FetchOptions = {},
|
|
393
474
|
): Promise<ProfileData> {
|
|
394
|
-
|
|
395
|
-
const
|
|
475
|
+
username = normalizeUsername(username);
|
|
476
|
+
const effectiveOpts: EffectiveFetchOptions = {
|
|
477
|
+
scope: opts.scope ?? "personal",
|
|
478
|
+
affiliations: opts.affiliations ?? "owner",
|
|
479
|
+
includeLanguages: opts.includeLanguages ?? true,
|
|
480
|
+
langCount: Number.isFinite(opts.langCount)
|
|
481
|
+
? Math.max(1, Math.min(10, Math.floor(opts.langCount!)))
|
|
482
|
+
: 5,
|
|
483
|
+
hideLangs: normalizeOrgFilters(opts.hideLangs),
|
|
484
|
+
showLangs: normalizeOrgFilters(opts.showLangs),
|
|
485
|
+
orgs: normalizeOrgFilters(opts.orgs),
|
|
486
|
+
forceRefresh: opts.forceRefresh ?? false,
|
|
487
|
+
};
|
|
396
488
|
const cacheKey = Bun.hash(
|
|
397
|
-
|
|
489
|
+
JSON.stringify([
|
|
490
|
+
CACHE_VERSION,
|
|
491
|
+
username,
|
|
492
|
+
effectiveOpts.scope,
|
|
493
|
+
effectiveOpts.affiliations,
|
|
494
|
+
effectiveOpts.includeLanguages,
|
|
495
|
+
effectiveOpts.langCount,
|
|
496
|
+
effectiveOpts.orgs,
|
|
497
|
+
effectiveOpts.hideLangs,
|
|
498
|
+
effectiveOpts.showLangs,
|
|
499
|
+
]),
|
|
398
500
|
).toString(36);
|
|
399
501
|
|
|
400
|
-
if (!
|
|
502
|
+
if (!effectiveOpts.forceRefresh) {
|
|
401
503
|
const mem = memCache.get(cacheKey);
|
|
402
504
|
const now = Date.now();
|
|
403
505
|
if (mem && mem.expiresAt > now) {
|
|
404
506
|
if (mem.staleAt <= now && !refreshInFlight.has(cacheKey))
|
|
405
|
-
triggerBackgroundRefresh(username,
|
|
507
|
+
triggerBackgroundRefresh(username, effectiveOpts, cacheKey);
|
|
406
508
|
return mem.data;
|
|
407
509
|
}
|
|
408
510
|
|
|
@@ -410,14 +512,14 @@ export async function getProfileData(
|
|
|
410
512
|
if (cached) {
|
|
411
513
|
setMemoryCache(cacheKey, cached);
|
|
412
514
|
if (cached.staleAt <= now && !refreshInFlight.has(cacheKey))
|
|
413
|
-
triggerBackgroundRefresh(username,
|
|
515
|
+
triggerBackgroundRefresh(username, effectiveOpts, cacheKey);
|
|
414
516
|
return cached.data;
|
|
415
517
|
}
|
|
416
518
|
}
|
|
417
519
|
|
|
418
520
|
if (inFlight.has(cacheKey)) return inFlight.get(cacheKey)!;
|
|
419
521
|
|
|
420
|
-
const promise = directFetch(username,
|
|
522
|
+
const promise = directFetch(username, effectiveOpts)
|
|
421
523
|
.then((data) => saveProfileCache(cacheKey, data))
|
|
422
524
|
.finally(() => {
|
|
423
525
|
inFlight.delete(cacheKey);
|
|
@@ -427,11 +529,11 @@ export async function getProfileData(
|
|
|
427
529
|
return promise;
|
|
428
530
|
}
|
|
429
531
|
|
|
430
|
-
function triggerBackgroundRefresh(username: string, opts:
|
|
532
|
+
function triggerBackgroundRefresh(username: string, opts: EffectiveFetchOptions, cacheKey: string) {
|
|
431
533
|
if (refreshInFlight.has(cacheKey)) return;
|
|
432
534
|
refreshInFlight.add(cacheKey);
|
|
433
535
|
|
|
434
|
-
void directFetch(username,
|
|
536
|
+
void directFetch(username, opts)
|
|
435
537
|
.then((data) => saveProfileCache(cacheKey, data))
|
|
436
538
|
.catch(() => {})
|
|
437
539
|
.finally(() => {
|