@shiinasaku/github-card 4.2.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 +40 -13
- package/dist/card.d.ts +2 -15
- package/dist/lib.js +4363 -930
- package/dist/types.d.ts +8 -3
- package/dist/utils/index.d.ts +4 -5
- package/dist/utils/languages.d.ts +0 -1
- package/package.json +14 -16
- package/src/card.ts +114 -92
- package/src/gallery.ts +37 -0
- package/src/github.ts +86 -42
- package/src/index.ts +89 -166
- package/src/types.ts +8 -3
- package/src/utils/format.ts +22 -20
- package/src/utils/github-client.ts +43 -138
- package/src/utils/icons.ts +5 -1
- package/src/utils/index.ts +4 -5
- package/src/utils/languages.ts +1 -1
- package/src/env.d.ts +0 -4
- package/src/input.css +0 -1
- package/src/tailwind.css +0 -3837
package/src/github.ts
CHANGED
|
@@ -17,12 +17,12 @@ const githubClient = new GitHubClient({
|
|
|
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
|
|
|
@@ -133,12 +133,16 @@ 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";
|
|
@@ -147,6 +151,7 @@ type FetchOptions = {
|
|
|
147
151
|
|
|
148
152
|
type RepoNode = {
|
|
149
153
|
nameWithOwner: string;
|
|
154
|
+
isFork?: boolean;
|
|
150
155
|
stargazers: { totalCount: number };
|
|
151
156
|
owner?: { login: string; __typename: string } | null;
|
|
152
157
|
languages?: { edges: LangEdge[] | null } | null;
|
|
@@ -224,9 +229,9 @@ query paginateRepos($login: String!, $cursor: String, $pageSize: Int!, $affiliat
|
|
|
224
229
|
const QUERY_PAGINATE_ORG_CONTRIBS = `
|
|
225
230
|
query paginateOrgContribs($login: String!, $cursor: String, $pageSize: Int!, $fetchLangs: Boolean!) {
|
|
226
231
|
user(login: $login) {
|
|
227
|
-
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) {
|
|
228
233
|
pageInfo { hasNextPage endCursor }
|
|
229
|
-
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 } } } }
|
|
230
235
|
}
|
|
231
236
|
}
|
|
232
237
|
}
|
|
@@ -304,12 +309,13 @@ function sumContributions(
|
|
|
304
309
|
return total;
|
|
305
310
|
}
|
|
306
311
|
|
|
307
|
-
|
|
312
|
+
type EffectiveFetchOptions = Required<FetchOptions>;
|
|
313
|
+
|
|
314
|
+
async function directFetch(username: string, opts: EffectiveFetchOptions): Promise<ProfileData> {
|
|
308
315
|
const isPersonal = opts.scope !== "org";
|
|
309
316
|
const isOrg = opts.scope === "org" || opts.scope === "all";
|
|
310
|
-
const fetchLangs = opts.includeLanguages
|
|
311
|
-
const
|
|
312
|
-
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;
|
|
313
319
|
const affiliations =
|
|
314
320
|
opts.affiliations === "owner" ? ["OWNER"] : ["OWNER", "ORGANIZATION_MEMBER", "COLLABORATOR"];
|
|
315
321
|
|
|
@@ -324,7 +330,7 @@ async function directFetch(username: string, opts: FetchOptions): Promise<Profil
|
|
|
324
330
|
login: username,
|
|
325
331
|
from,
|
|
326
332
|
to,
|
|
327
|
-
isOrg,
|
|
333
|
+
isOrg: opts.scope === "org",
|
|
328
334
|
});
|
|
329
335
|
user = data.user;
|
|
330
336
|
if (!user) throw new NotFoundError();
|
|
@@ -345,14 +351,12 @@ async function directFetch(username: string, opts: FetchOptions): Promise<Profil
|
|
|
345
351
|
let reposCount = 0;
|
|
346
352
|
|
|
347
353
|
const processNode = (node: RepoNode, scopeCheck: boolean): void => {
|
|
348
|
-
if (!node?.nameWithOwner || seenRepos.has(node.nameWithOwner)) return;
|
|
349
|
-
if (
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
orgsSet &&
|
|
353
|
-
|
|
354
|
-
)
|
|
355
|
-
return;
|
|
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
|
+
}
|
|
356
360
|
seenRepos.add(node.nameWithOwner);
|
|
357
361
|
stars += node.stargazers?.totalCount ?? 0;
|
|
358
362
|
reposCount++;
|
|
@@ -373,10 +377,9 @@ async function directFetch(username: string, opts: FetchOptions): Promise<Profil
|
|
|
373
377
|
|
|
374
378
|
const traversePages = async (query: string, scopeCheck: boolean): Promise<void> => {
|
|
375
379
|
let cursor: string | null = null;
|
|
376
|
-
|
|
377
|
-
let hasNext = true;
|
|
380
|
+
const seenCursors = new Set<string>();
|
|
378
381
|
|
|
379
|
-
|
|
382
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
380
383
|
const data = await postGraphQL<PaginateData>(query, {
|
|
381
384
|
login: username,
|
|
382
385
|
cursor,
|
|
@@ -385,11 +388,23 @@ async function directFetch(username: string, opts: FetchOptions): Promise<Profil
|
|
|
385
388
|
affiliations,
|
|
386
389
|
});
|
|
387
390
|
const nextConn = getConnection(data, query);
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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;
|
|
392
406
|
}
|
|
407
|
+
throw new UpstreamError(`GitHub repository pagination exceeded ${MAX_PAGES} pages`);
|
|
393
408
|
};
|
|
394
409
|
|
|
395
410
|
const tasks: Promise<void>[] = [];
|
|
@@ -402,9 +417,9 @@ async function directFetch(username: string, opts: FetchOptions): Promise<Profil
|
|
|
402
417
|
|
|
403
418
|
const [avatarDataUrl] = await Promise.all([fetchAvatarAsBase64(user.avatarUrl), ...tasks]);
|
|
404
419
|
|
|
405
|
-
let commits =
|
|
406
|
-
let prs =
|
|
407
|
-
let issues =
|
|
420
|
+
let commits: number | null = null;
|
|
421
|
+
let prs: number | null = null;
|
|
422
|
+
let issues: number | null = null;
|
|
408
423
|
|
|
409
424
|
if (!isOrgAccount) {
|
|
410
425
|
const profile = user as ProfileUser;
|
|
@@ -423,22 +438,30 @@ async function directFetch(username: string, opts: FetchOptions): Promise<Profil
|
|
|
423
438
|
}
|
|
424
439
|
}
|
|
425
440
|
|
|
441
|
+
const hiddenLanguages = new Set(opts.hideLangs);
|
|
442
|
+
const shownLanguages = new Set(opts.showLangs);
|
|
426
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
|
+
})
|
|
427
450
|
.map(([name, { size, color }]) => ({ name, size, color }))
|
|
428
451
|
.sort((a, b) => b.size - a.size)
|
|
429
|
-
.slice(0, opts.langCount
|
|
452
|
+
.slice(0, opts.langCount);
|
|
430
453
|
|
|
431
454
|
const profileUser = !isOrgAccount ? (user as ProfileUser) : null;
|
|
432
455
|
const orgUser = isOrgAccount ? (user as OrgProfile) : null;
|
|
433
456
|
|
|
434
457
|
return {
|
|
435
458
|
user: {
|
|
436
|
-
login: user
|
|
437
|
-
name: user
|
|
459
|
+
login: user.login,
|
|
460
|
+
name: user.name,
|
|
438
461
|
avatarUrl: avatarDataUrl,
|
|
439
462
|
bio: isOrgAccount ? (orgUser?.description ?? null) : (profileUser?.bio ?? null),
|
|
440
463
|
pronouns: isOrgAccount ? null : (profileUser?.pronouns ?? null),
|
|
441
|
-
twitter: user
|
|
464
|
+
twitter: user.twitterUsername ?? null,
|
|
442
465
|
},
|
|
443
466
|
stats: { stars, repos: reposCount, prs, issues, commits },
|
|
444
467
|
languages,
|
|
@@ -449,18 +472,39 @@ export async function getProfileData(
|
|
|
449
472
|
username: string,
|
|
450
473
|
opts: FetchOptions = {},
|
|
451
474
|
): Promise<ProfileData> {
|
|
452
|
-
|
|
453
|
-
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
|
+
};
|
|
454
488
|
const cacheKey = Bun.hash(
|
|
455
|
-
|
|
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
|
+
]),
|
|
456
500
|
).toString(36);
|
|
457
501
|
|
|
458
|
-
if (!
|
|
502
|
+
if (!effectiveOpts.forceRefresh) {
|
|
459
503
|
const mem = memCache.get(cacheKey);
|
|
460
504
|
const now = Date.now();
|
|
461
505
|
if (mem && mem.expiresAt > now) {
|
|
462
506
|
if (mem.staleAt <= now && !refreshInFlight.has(cacheKey))
|
|
463
|
-
triggerBackgroundRefresh(username,
|
|
507
|
+
triggerBackgroundRefresh(username, effectiveOpts, cacheKey);
|
|
464
508
|
return mem.data;
|
|
465
509
|
}
|
|
466
510
|
|
|
@@ -468,14 +512,14 @@ export async function getProfileData(
|
|
|
468
512
|
if (cached) {
|
|
469
513
|
setMemoryCache(cacheKey, cached);
|
|
470
514
|
if (cached.staleAt <= now && !refreshInFlight.has(cacheKey))
|
|
471
|
-
triggerBackgroundRefresh(username,
|
|
515
|
+
triggerBackgroundRefresh(username, effectiveOpts, cacheKey);
|
|
472
516
|
return cached.data;
|
|
473
517
|
}
|
|
474
518
|
}
|
|
475
519
|
|
|
476
520
|
if (inFlight.has(cacheKey)) return inFlight.get(cacheKey)!;
|
|
477
521
|
|
|
478
|
-
const promise = directFetch(username,
|
|
522
|
+
const promise = directFetch(username, effectiveOpts)
|
|
479
523
|
.then((data) => saveProfileCache(cacheKey, data))
|
|
480
524
|
.finally(() => {
|
|
481
525
|
inFlight.delete(cacheKey);
|
|
@@ -485,11 +529,11 @@ export async function getProfileData(
|
|
|
485
529
|
return promise;
|
|
486
530
|
}
|
|
487
531
|
|
|
488
|
-
function triggerBackgroundRefresh(username: string, opts:
|
|
532
|
+
function triggerBackgroundRefresh(username: string, opts: EffectiveFetchOptions, cacheKey: string) {
|
|
489
533
|
if (refreshInFlight.has(cacheKey)) return;
|
|
490
534
|
refreshInFlight.add(cacheKey);
|
|
491
535
|
|
|
492
|
-
void directFetch(username,
|
|
536
|
+
void directFetch(username, opts)
|
|
493
537
|
.then((data) => saveProfileCache(cacheKey, data))
|
|
494
538
|
.catch(() => {})
|
|
495
539
|
.finally(() => {
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Elysia, t } from "elysia";
|
|
1
|
+
import { Elysia, t, type Context, type UnwrapSchema } from "elysia";
|
|
2
2
|
import { cors } from "@elysia/cors";
|
|
3
3
|
import { openapi } from "@elysia/openapi";
|
|
4
4
|
import { serverTiming } from "@elysia/server-timing";
|
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
} from "./github";
|
|
14
14
|
import { renderCard, type CardOpts } from "./card";
|
|
15
15
|
import { themes } from "./utils";
|
|
16
|
+
import { renderThemeGallery } from "./gallery";
|
|
17
|
+
import { version } from "../package.json";
|
|
16
18
|
|
|
17
19
|
const USERNAME_PATTERN = "^[a-zA-Z0-9-]{1,39}$";
|
|
18
20
|
const CACHE_CONTROL_HEADER = "public, max-age=0, s-maxage=1800, stale-while-revalidate=1800";
|
|
@@ -30,22 +32,30 @@ const CardQuerySchema = t.Object({
|
|
|
30
32
|
hide_border: t.Optional(t.BooleanString()),
|
|
31
33
|
compact: t.Optional(t.BooleanString()),
|
|
32
34
|
animate: t.Optional(t.BooleanString()),
|
|
33
|
-
fields: t.Optional(t.
|
|
34
|
-
hide: t.Optional(t.
|
|
35
|
-
hide_langs: t.Optional(t.
|
|
36
|
-
show_langs: t.Optional(t.
|
|
35
|
+
fields: t.Optional(t.Array(t.String())),
|
|
36
|
+
hide: t.Optional(t.Array(t.String())),
|
|
37
|
+
hide_langs: t.Optional(t.Array(t.String())),
|
|
38
|
+
show_langs: t.Optional(t.Array(t.String())),
|
|
37
39
|
lang_count: t.Optional(t.Integer({ minimum: 1, maximum: 10 })),
|
|
38
40
|
scope: t.Optional(t.Union([t.Literal("personal"), t.Literal("org"), t.Literal("all")])),
|
|
39
41
|
affiliations: t.Optional(t.Union([t.Literal("owner"), t.Literal("affiliated")])),
|
|
40
|
-
orgs: t.Optional(t.
|
|
42
|
+
orgs: t.Optional(t.Array(t.String())),
|
|
41
43
|
});
|
|
42
44
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
const CardParamsSchema = t.Object({ username: t.String({ pattern: USERNAME_PATTERN }) });
|
|
46
|
+
const ErrorSchema = t.Object({ error: t.String() });
|
|
47
|
+
const CardResponseSchema = {
|
|
48
|
+
200: t.String({ description: "SVG markup" }),
|
|
49
|
+
304: t.String(),
|
|
50
|
+
401: ErrorSchema,
|
|
51
|
+
404: ErrorSchema,
|
|
52
|
+
429: ErrorSchema,
|
|
53
|
+
502: ErrorSchema,
|
|
47
54
|
};
|
|
48
55
|
|
|
56
|
+
type CardQuery = UnwrapSchema<typeof CardQuerySchema>;
|
|
57
|
+
type ResponseSet = Context["set"];
|
|
58
|
+
|
|
49
59
|
function normalizeList(values?: string[]): string[] {
|
|
50
60
|
if (!values?.length) return [];
|
|
51
61
|
return values.map((value) => value.trim()).filter(Boolean);
|
|
@@ -62,11 +72,6 @@ function parseHiddenStats(values?: string[]): string[] {
|
|
|
62
72
|
);
|
|
63
73
|
}
|
|
64
74
|
|
|
65
|
-
function parseScope(value?: string): "personal" | "org" | "all" {
|
|
66
|
-
if (value === "org" || value === "all") return value;
|
|
67
|
-
return "personal";
|
|
68
|
-
}
|
|
69
|
-
|
|
70
75
|
function parseOrgs(values?: string[]): string[] {
|
|
71
76
|
return Array.from(new Set(normalizeList(values).map((value) => value.toLowerCase())));
|
|
72
77
|
}
|
|
@@ -98,72 +103,35 @@ function buildCardOptions(query: CardQuery, hide: string[]): CardOpts {
|
|
|
98
103
|
};
|
|
99
104
|
}
|
|
100
105
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const data = await getProfileData(username, {
|
|
111
|
-
includeLanguages,
|
|
112
|
-
langCount,
|
|
113
|
-
scope,
|
|
114
|
-
affiliations,
|
|
115
|
-
orgs,
|
|
106
|
+
function fetchProfile(username: string, query: CardQuery) {
|
|
107
|
+
return getProfileData(username, {
|
|
108
|
+
includeLanguages: shouldIncludeLanguages(parseFieldSet(query.fields)),
|
|
109
|
+
langCount: query.lang_count ?? 5,
|
|
110
|
+
hideLangs: query.hide_langs,
|
|
111
|
+
showLangs: query.show_langs,
|
|
112
|
+
scope: query.scope ?? "personal",
|
|
113
|
+
affiliations: query.affiliations ?? DEFAULT_AFFILIATIONS,
|
|
114
|
+
orgs: parseOrgs(query.orgs),
|
|
116
115
|
});
|
|
117
|
-
|
|
118
|
-
return renderCard(data.user, data.stats, data.languages, buildCardOptions(query, hiddenStats));
|
|
119
116
|
}
|
|
120
117
|
|
|
121
|
-
function
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const gap = 20;
|
|
130
|
-
const cardW = 500;
|
|
131
|
-
const cardH = 200;
|
|
132
|
-
const labelH = 22;
|
|
133
|
-
const cellH = cardH + labelH;
|
|
134
|
-
const rows = Math.ceil(entries.length / cols);
|
|
135
|
-
const width = cols * cardW + (cols + 1) * gap;
|
|
136
|
-
const height = rows * cellH + (rows + 1) * gap;
|
|
137
|
-
|
|
138
|
-
const blocks = entries
|
|
139
|
-
.map((name, index) => {
|
|
140
|
-
const col = index % cols;
|
|
141
|
-
const row = Math.floor(index / cols);
|
|
142
|
-
const x = gap + col * (cardW + gap);
|
|
143
|
-
const y = gap + row * (cellH + gap) + labelH;
|
|
144
|
-
// Inline the card as a nested <svg> element (GitHub camo-safe, unlike data-URI <image>)
|
|
145
|
-
const cardSvg = renderCard(user, stats, langs, { ...cardOpts, theme: name }).replace(
|
|
146
|
-
/^<svg\b/,
|
|
147
|
-
`<svg x="${x}" y="${y}"`,
|
|
148
|
-
);
|
|
149
|
-
|
|
150
|
-
return `<text x="${x + 4}" y="${y - 8}" fill="#e2e8f0" font-size="13" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Arial" font-weight="700">${name}</text>${cardSvg}`;
|
|
151
|
-
})
|
|
152
|
-
.join("");
|
|
153
|
-
|
|
154
|
-
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">
|
|
155
|
-
<rect width="100%" height="100%" fill="#0b1220"/>
|
|
156
|
-
${blocks}
|
|
157
|
-
</svg>`;
|
|
118
|
+
async function buildCardSvg(username: string, query: CardQuery): Promise<string> {
|
|
119
|
+
const data = await fetchProfile(username, query);
|
|
120
|
+
return renderCard(
|
|
121
|
+
data.user,
|
|
122
|
+
data.stats,
|
|
123
|
+
data.languages,
|
|
124
|
+
buildCardOptions(query, parseHiddenStats(query.hide)),
|
|
125
|
+
);
|
|
158
126
|
}
|
|
159
127
|
|
|
160
|
-
function applyCacheHeaders(set:
|
|
128
|
+
function applyCacheHeaders(set: ResponseSet) {
|
|
161
129
|
set.headers["Cache-Control"] = CACHE_CONTROL_HEADER;
|
|
162
130
|
set.headers["CDN-Cache-Control"] = EDGE_CACHE_CONTROL_HEADER;
|
|
163
131
|
set.headers["Vercel-CDN-Cache-Control"] = EDGE_CACHE_CONTROL_HEADER;
|
|
164
132
|
}
|
|
165
133
|
|
|
166
|
-
function setSvgHeaders(svg: string, ifNoneMatch: string | undefined, set:
|
|
134
|
+
function setSvgHeaders(svg: string, ifNoneMatch: string | undefined, set: ResponseSet): string {
|
|
167
135
|
const etag = `W/"${Bun.hash(svg).toString(36)}"`;
|
|
168
136
|
if (ifNoneMatch === etag) {
|
|
169
137
|
set.status = 304;
|
|
@@ -178,29 +146,11 @@ function setSvgHeaders(svg: string, ifNoneMatch: string | undefined, set: Mutabl
|
|
|
178
146
|
return svg;
|
|
179
147
|
}
|
|
180
148
|
|
|
181
|
-
const app = new Elysia({ name: "github-card" })
|
|
182
|
-
.error({
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
UPSTREAM_ERROR: UpstreamError,
|
|
187
|
-
})
|
|
188
|
-
.onError(({ code, error, set }) => {
|
|
189
|
-
switch (code) {
|
|
190
|
-
case "NOT_FOUND_ERROR":
|
|
191
|
-
set.status = 404;
|
|
192
|
-
return { error: error.message };
|
|
193
|
-
case "RATE_LIMIT_ERROR":
|
|
194
|
-
set.status = 429;
|
|
195
|
-
return { error: error.message };
|
|
196
|
-
case "AUTH_ERROR":
|
|
197
|
-
set.status = 401;
|
|
198
|
-
return { error: error.message };
|
|
199
|
-
case "UPSTREAM_ERROR":
|
|
200
|
-
set.status = 502;
|
|
201
|
-
return { error: error.message };
|
|
202
|
-
}
|
|
203
|
-
})
|
|
149
|
+
export const app = new Elysia({ name: "github-card" })
|
|
150
|
+
.onError(NotFoundError, ({ error, status }) => status(404, { error: error.message }))
|
|
151
|
+
.onError(RateLimitError, ({ error, status }) => status(429, { error: error.message }))
|
|
152
|
+
.onError(AuthError, ({ error, status }) => status(401, { error: error.message }))
|
|
153
|
+
.onError(UpstreamError, ({ error, status }) => status(502, { error: error.message }))
|
|
204
154
|
.use(
|
|
205
155
|
cors({
|
|
206
156
|
origin: true,
|
|
@@ -216,9 +166,9 @@ const app = new Elysia({ name: "github-card" })
|
|
|
216
166
|
documentation: {
|
|
217
167
|
info: {
|
|
218
168
|
title: "GitHub Profile Card API",
|
|
219
|
-
version
|
|
169
|
+
version,
|
|
220
170
|
description:
|
|
221
|
-
"Generate GitHub profile SVG
|
|
171
|
+
"Generate GitHub profile SVG cards for README, web embeds, and social previews.",
|
|
222
172
|
},
|
|
223
173
|
tags: [
|
|
224
174
|
{ name: "Card", description: "Card endpoints" },
|
|
@@ -231,12 +181,6 @@ const app = new Elysia({ name: "github-card" })
|
|
|
231
181
|
.use(serverTiming())
|
|
232
182
|
.get(
|
|
233
183
|
"/",
|
|
234
|
-
() => ({
|
|
235
|
-
message: "GitHub Profile Card API",
|
|
236
|
-
usage: "GET /card/:username",
|
|
237
|
-
themes: "GET /:username/themes",
|
|
238
|
-
docs: "/openapi",
|
|
239
|
-
}),
|
|
240
184
|
{
|
|
241
185
|
detail: { tags: ["Meta"], summary: "API metadata" },
|
|
242
186
|
response: {
|
|
@@ -248,9 +192,18 @@ const app = new Elysia({ name: "github-card" })
|
|
|
248
192
|
}),
|
|
249
193
|
},
|
|
250
194
|
},
|
|
195
|
+
() => ({
|
|
196
|
+
message: "GitHub Profile Card API",
|
|
197
|
+
usage: "GET /card/:username",
|
|
198
|
+
themes: "GET /:username/themes",
|
|
199
|
+
docs: "/openapi",
|
|
200
|
+
}),
|
|
251
201
|
)
|
|
252
202
|
.get(
|
|
253
203
|
"/meta",
|
|
204
|
+
{
|
|
205
|
+
detail: { tags: ["Meta"], summary: "API metadata" },
|
|
206
|
+
},
|
|
254
207
|
() => ({
|
|
255
208
|
message: "GitHub Profile Card API",
|
|
256
209
|
usage: "GET /card/:username",
|
|
@@ -258,64 +211,29 @@ const app = new Elysia({ name: "github-card" })
|
|
|
258
211
|
affiliations: "owner (default), affiliated",
|
|
259
212
|
organizationSupport: "Use /card/:organization-login",
|
|
260
213
|
}),
|
|
261
|
-
{
|
|
262
|
-
detail: { tags: ["Meta"], summary: "API metadata" },
|
|
263
|
-
},
|
|
264
214
|
)
|
|
265
215
|
.get(
|
|
266
216
|
"/:username/themes",
|
|
267
|
-
async ({ params: { username }, query, headers, set }) => {
|
|
268
|
-
const q = query as CardQuery;
|
|
269
|
-
const scope = parseScope(q.scope);
|
|
270
|
-
const orgs = parseOrgs(q.orgs);
|
|
271
|
-
const fields = parseFieldSet(q.fields);
|
|
272
|
-
const includeLanguages = shouldIncludeLanguages(fields);
|
|
273
|
-
const data = await getProfileData(username, {
|
|
274
|
-
includeLanguages,
|
|
275
|
-
langCount: q.lang_count ?? 5,
|
|
276
|
-
scope,
|
|
277
|
-
affiliations: (q.affiliations ?? DEFAULT_AFFILIATIONS) as "owner" | "affiliated",
|
|
278
|
-
orgs,
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
const svg = renderUserThemesSvg(data.user, data.stats, data.languages, {
|
|
282
|
-
compact: q.compact ?? false,
|
|
283
|
-
hide_border: q.hide_border ?? false,
|
|
284
|
-
hide: parseHiddenStats(q.hide),
|
|
285
|
-
animate: q.animate ?? false,
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
return setSvgHeaders(svg, headers["if-none-match"], set as MutableSet);
|
|
289
|
-
},
|
|
290
217
|
{
|
|
291
218
|
detail: { tags: ["Card"], summary: "Render all built-in themes for a username" },
|
|
292
|
-
params:
|
|
219
|
+
params: CardParamsSchema,
|
|
293
220
|
query: CardQuerySchema,
|
|
294
|
-
response:
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
221
|
+
response: CardResponseSchema,
|
|
222
|
+
},
|
|
223
|
+
async ({ params: { username }, query, headers, set }) => {
|
|
224
|
+
const data = await fetchProfile(username, query);
|
|
225
|
+
const svg = renderThemeGallery(
|
|
226
|
+
data.user,
|
|
227
|
+
data.stats,
|
|
228
|
+
data.languages,
|
|
229
|
+
buildCardOptions(query, parseHiddenStats(query.hide)),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
return setSvgHeaders(svg, headers["if-none-match"], set);
|
|
303
233
|
},
|
|
304
234
|
)
|
|
305
235
|
.get(
|
|
306
236
|
"/health",
|
|
307
|
-
async () => {
|
|
308
|
-
const cache = getCacheMetrics();
|
|
309
|
-
const redisReachable = await isRedisReachable();
|
|
310
|
-
return {
|
|
311
|
-
status: "ok",
|
|
312
|
-
uptimeSeconds: Math.floor((Date.now() - appStartedAt) / 1000),
|
|
313
|
-
runtime: "vercel-function",
|
|
314
|
-
region: Bun.env.VERCEL_REGION || null,
|
|
315
|
-
cache,
|
|
316
|
-
redisReachable,
|
|
317
|
-
};
|
|
318
|
-
},
|
|
319
237
|
{
|
|
320
238
|
detail: { tags: ["Ops"], summary: "Service health and cache telemetry" },
|
|
321
239
|
response: {
|
|
@@ -337,33 +255,38 @@ const app = new Elysia({ name: "github-card" })
|
|
|
337
255
|
}),
|
|
338
256
|
},
|
|
339
257
|
},
|
|
258
|
+
async () => {
|
|
259
|
+
const cache = getCacheMetrics();
|
|
260
|
+
const redisReachable = await isRedisReachable();
|
|
261
|
+
return {
|
|
262
|
+
status: "ok",
|
|
263
|
+
uptimeSeconds: Math.floor((Date.now() - appStartedAt) / 1000),
|
|
264
|
+
runtime: Bun.env.VERCEL ? "vercel-function" : "bun",
|
|
265
|
+
region: Bun.env.VERCEL_REGION || null,
|
|
266
|
+
cache,
|
|
267
|
+
redisReachable,
|
|
268
|
+
};
|
|
269
|
+
},
|
|
340
270
|
)
|
|
341
271
|
.get(
|
|
342
272
|
"/card/:username",
|
|
343
|
-
async ({ params: { username }, query, headers, set }) => {
|
|
344
|
-
const svg = await buildCardSvg(username, query as CardQuery);
|
|
345
|
-
return setSvgHeaders(svg, headers["if-none-match"], set as MutableSet);
|
|
346
|
-
},
|
|
347
273
|
{
|
|
348
274
|
detail: { tags: ["Card"], summary: "Render GitHub profile card SVG" },
|
|
349
|
-
params:
|
|
275
|
+
params: CardParamsSchema,
|
|
350
276
|
query: CardQuerySchema,
|
|
351
|
-
response:
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
429: t.Object({ error: t.String() }),
|
|
357
|
-
500: t.Object({ error: t.String() }),
|
|
358
|
-
502: t.Object({ error: t.String() }),
|
|
359
|
-
},
|
|
277
|
+
response: CardResponseSchema,
|
|
278
|
+
},
|
|
279
|
+
async ({ params: { username }, query, headers, set }) => {
|
|
280
|
+
const svg = await buildCardSvg(username, query);
|
|
281
|
+
return setSvgHeaders(svg, headers["if-none-match"], set);
|
|
360
282
|
},
|
|
361
283
|
);
|
|
362
284
|
|
|
285
|
+
export type App = typeof app;
|
|
286
|
+
export default app;
|
|
287
|
+
|
|
363
288
|
if (import.meta.main) {
|
|
364
289
|
const port = Number(Bun.env.PORT || 3000);
|
|
365
290
|
app.listen(port);
|
|
366
291
|
console.log(`Dev server running on http://localhost:${port}`);
|
|
367
292
|
}
|
|
368
|
-
|
|
369
|
-
export default app;
|
package/src/types.ts
CHANGED
|
@@ -10,9 +10,10 @@ export interface UserProfile {
|
|
|
10
10
|
export interface UserStats {
|
|
11
11
|
stars: number;
|
|
12
12
|
repos: number;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
/** null means unavailable, not zero (for example, organization activity totals). */
|
|
14
|
+
prs: number | null;
|
|
15
|
+
issues: number | null;
|
|
16
|
+
commits: number | null;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export interface LanguageStat {
|
|
@@ -37,5 +38,9 @@ export interface CardOptions {
|
|
|
37
38
|
hide_border?: boolean;
|
|
38
39
|
compact?: boolean;
|
|
39
40
|
hide?: string[];
|
|
41
|
+
hide_langs?: string[];
|
|
42
|
+
show_langs?: string[];
|
|
40
43
|
animate?: boolean;
|
|
44
|
+
/** Stable namespace for multiple cards embedded in the same SVG document. */
|
|
45
|
+
idPrefix?: string;
|
|
41
46
|
}
|