@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/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 = 8;
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 = 5;
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 as string | undefined;
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(new Set((orgs || []).map((o) => o.trim().toLowerCase()).filter(Boolean)));
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
- async function directFetch(username: string, opts: FetchOptions): Promise<ProfileData> {
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 ?? true;
311
- const orgsArr = normalizeOrgFilters(opts.orgs);
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
- scopeCheck &&
351
- node.owner?.__typename === "Organization" &&
352
- orgsSet &&
353
- !orgsSet.has(node.owner.login.toLowerCase())
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
- let page = 0;
377
- let hasNext = true;
380
+ const seenCursors = new Set<string>();
378
381
 
379
- while (hasNext && page++ < MAX_PAGES) {
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
- for (const node of nextConn?.nodes ?? []) processNode(node, scopeCheck);
389
- hasNext = nextConn?.pageInfo?.hasNextPage ?? false;
390
- cursor = nextConn?.pageInfo?.endCursor ?? null;
391
- if (hasNext && !cursor) break;
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 = 0;
406
- let prs = 0;
407
- let issues = 0;
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 || 5);
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!.login,
437
- name: user!.name,
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!.twitterUsername ?? null,
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
- const norm = normalizeUsername(username);
453
- const orgs = normalizeOrgFilters(opts.orgs);
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
- `v${CACHE_VERSION}:${norm}:${opts.scope || "personal"}:${opts.affiliations || "owner"}:${opts.includeLanguages !== false}:${opts.langCount || 5}:${orgs.join("|")}`,
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 (!opts.forceRefresh) {
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, opts, cacheKey);
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, opts, cacheKey);
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, opts)
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: FetchOptions, cacheKey: string) {
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, { ...opts, forceRefresh: true })
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.ArrayQuery(t.String())),
34
- hide: t.Optional(t.ArrayQuery(t.String())),
35
- hide_langs: t.Optional(t.ArrayQuery(t.String())),
36
- show_langs: t.Optional(t.ArrayQuery(t.String())),
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.ArrayQuery(t.String())),
42
+ orgs: t.Optional(t.Array(t.String())),
41
43
  });
42
44
 
43
- type CardQuery = typeof CardQuerySchema.static;
44
- type MutableSet = {
45
- status?: number | string;
46
- headers: Record<string, string | undefined>;
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
- async function buildCardSvg(username: string, query: CardQuery): Promise<string> {
102
- const langCount = query.lang_count ?? 5;
103
- const hiddenStats = parseHiddenStats(query.hide);
104
- const scope = parseScope(query.scope);
105
- const orgs = parseOrgs(query.orgs);
106
- const fields = parseFieldSet(query.fields);
107
- const includeLanguages = shouldIncludeLanguages(fields);
108
- const affiliations = (query.affiliations ?? DEFAULT_AFFILIATIONS) as "owner" | "affiliated";
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 renderUserThemesSvg(
122
- user: Parameters<typeof renderCard>[0],
123
- stats: Parameters<typeof renderCard>[1],
124
- langs: Parameters<typeof renderCard>[2],
125
- cardOpts: Parameters<typeof renderCard>[3],
126
- ) {
127
- const entries = Object.keys(themes);
128
- const cols = 2;
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: MutableSet) {
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: MutableSet): string {
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
- NOT_FOUND_ERROR: NotFoundError,
184
- RATE_LIMIT_ERROR: RateLimitError,
185
- AUTH_ERROR: AuthError,
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: "4.2.0",
169
+ version,
220
170
  description:
221
- "Generate GitHub profile SVG/PNG cards for README, web embeds, and social previews.",
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: t.Object({ username: t.String({ pattern: USERNAME_PATTERN }) }),
219
+ params: CardParamsSchema,
293
220
  query: CardQuerySchema,
294
- response: {
295
- 200: t.String({ description: "SVG markup" }),
296
- 304: t.String(),
297
- 401: t.Object({ error: t.String() }),
298
- 404: t.Object({ error: t.String() }),
299
- 429: t.Object({ error: t.String() }),
300
- 500: t.Object({ error: t.String() }),
301
- 502: t.Object({ error: t.String() }),
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: t.Object({ username: t.String({ pattern: USERNAME_PATTERN }) }),
275
+ params: CardParamsSchema,
350
276
  query: CardQuerySchema,
351
- response: {
352
- 200: t.String({ description: "SVG markup" }),
353
- 304: t.String(),
354
- 401: t.Object({ error: t.String() }),
355
- 404: t.Object({ error: t.String() }),
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
- prs: number;
14
- issues: number;
15
- commits: number;
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
  }