@shiinasaku/github-card 2.0.0 → 4.1.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/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Elysia, t } from "elysia";
2
- import { cors } from "@elysiajs/cors";
3
- import { openapi } from "@elysiajs/openapi";
4
- import { serverTiming } from "@elysiajs/server-timing";
2
+ import { cors } from "@elysia/cors";
3
+ import { openapi } from "@elysia/openapi";
4
+ import { serverTiming } from "@elysia/server-timing";
5
5
  import {
6
6
  AuthError,
7
7
  getCacheMetrics,
@@ -11,48 +11,110 @@ import {
11
11
  RateLimitError,
12
12
  UpstreamError,
13
13
  } from "./github";
14
- import { renderCard } from "./card";
14
+ import { renderCard, type CardOpts } from "./card";
15
15
  import { themes } from "./utils";
16
16
 
17
17
  const USERNAME_PATTERN = "^[a-zA-Z0-9-]{1,39}$";
18
+ const CACHE_CONTROL_HEADER = "public, max-age=0, s-maxage=1800, stale-while-revalidate=1800";
19
+ const EDGE_CACHE_CONTROL_HEADER = "public, s-maxage=1800, stale-while-revalidate=1800";
18
20
  const appStartedAt = Date.now();
19
21
 
20
- function parseLangCount(value?: string): number {
21
- if (!value) return 5;
22
- const parsed = Number.parseInt(value, 10);
23
- if (Number.isNaN(parsed)) return 5;
24
- return Math.min(10, Math.max(1, parsed));
22
+ const CardQuerySchema = t.Object({
23
+ theme: t.Optional(t.String()),
24
+ title_color: t.Optional(t.String()),
25
+ text_color: t.Optional(t.String()),
26
+ icon_color: t.Optional(t.String()),
27
+ bg_color: t.Optional(t.String()),
28
+ border_color: t.Optional(t.String()),
29
+ hide_border: t.Optional(t.BooleanString()),
30
+ compact: t.Optional(t.BooleanString()),
31
+ animate: t.Optional(t.BooleanString()),
32
+ fields: t.Optional(t.ArrayQuery(t.String())),
33
+ hide: t.Optional(t.ArrayQuery(t.String())),
34
+ hide_langs: t.Optional(t.ArrayQuery(t.String())),
35
+ show_langs: t.Optional(t.ArrayQuery(t.String())),
36
+ lang_count: t.Optional(t.Integer({ minimum: 1, maximum: 10 })),
37
+ scope: t.Optional(t.Union([t.Literal("personal"), t.Literal("org"), t.Literal("all")])),
38
+ affiliations: t.Optional(t.Union([t.Literal("owner"), t.Literal("affiliated")])),
39
+ orgs: t.Optional(t.ArrayQuery(t.String())),
40
+ });
41
+
42
+ type CardQuery = typeof CardQuerySchema.static;
43
+ type MutableSet = {
44
+ status?: number | string;
45
+ headers: Record<string, string | undefined>;
46
+ };
47
+
48
+ function normalizeList(values?: string[]): string[] {
49
+ if (!values?.length) return [];
50
+ return values.map((value) => value.trim()).filter(Boolean);
25
51
  }
26
52
 
27
- function parseHiddenStats(value?: string): string[] {
28
- if (!value) return [];
53
+ function parseHiddenStats(values?: string[]): string[] {
29
54
  const allowed = new Set(["stars", "commits", "issues", "repos", "prs"]);
30
55
  return Array.from(
31
56
  new Set(
32
- value
33
- .split(",")
34
- .map((v) => v.trim().toLowerCase())
35
- .filter((v) => allowed.has(v)),
57
+ normalizeList(values)
58
+ .map((value) => value.toLowerCase())
59
+ .filter((value) => allowed.has(value)),
36
60
  ),
37
61
  );
38
62
  }
39
63
 
40
64
  function parseScope(value?: string): "personal" | "org" | "all" {
41
- const normalized = value?.trim().toLowerCase();
42
- if (normalized === "org" || normalized === "all") return normalized;
65
+ if (value === "org" || value === "all") return value;
43
66
  return "personal";
44
67
  }
45
68
 
46
- function parseOrgs(value?: string): string[] {
47
- if (!value) return [];
48
- return Array.from(
49
- new Set(
50
- value
51
- .split(",")
52
- .map((v) => v.trim())
53
- .filter(Boolean),
54
- ),
55
- );
69
+ function parseOrgs(values?: string[]): string[] {
70
+ return Array.from(new Set(normalizeList(values).map((value) => value.toLowerCase())));
71
+ }
72
+
73
+ function parseFieldSet(values?: string[]): Set<string> | null {
74
+ const list = normalizeList(values).map((value) => value.toLowerCase());
75
+ return list.length ? new Set(list) : null;
76
+ }
77
+
78
+ function shouldIncludeLanguages(fields: Set<string> | null): boolean {
79
+ if (!fields) return true;
80
+ return fields.has("all") || fields.has("languages") || fields.has("langs");
81
+ }
82
+
83
+ function buildCardOptions(query: CardQuery, hide: string[]): CardOpts {
84
+ return {
85
+ theme: query.theme,
86
+ title_color: query.title_color,
87
+ text_color: query.text_color,
88
+ icon_color: query.icon_color,
89
+ bg_color: query.bg_color,
90
+ border_color: query.border_color,
91
+ hide_border: query.hide_border ?? false,
92
+ compact: query.compact ?? false,
93
+ hide,
94
+ hide_langs: query.hide_langs,
95
+ show_langs: query.show_langs,
96
+ animate: query.animate ?? false,
97
+ };
98
+ }
99
+
100
+ async function buildCardSvg(username: string, query: CardQuery): Promise<string> {
101
+ const langCount = query.lang_count ?? 5;
102
+ const hiddenStats = parseHiddenStats(query.hide);
103
+ const scope = parseScope(query.scope);
104
+ const orgs = parseOrgs(query.orgs);
105
+ const fields = parseFieldSet(query.fields);
106
+ const includeLanguages = shouldIncludeLanguages(fields);
107
+ const affiliations = query.affiliations ?? "affiliated";
108
+
109
+ const data = await getProfileData(username, {
110
+ includeLanguages,
111
+ langCount,
112
+ scope,
113
+ affiliations,
114
+ orgs,
115
+ });
116
+
117
+ return renderCard(data.user, data.stats, data.languages, buildCardOptions(query, hiddenStats));
56
118
  }
57
119
 
58
120
  function renderUserThemesSvg(
@@ -77,14 +139,14 @@ function renderUserThemesSvg(
77
139
  const col = index % cols;
78
140
  const row = Math.floor(index / cols);
79
141
  const x = gap + col * (cardW + gap);
80
- const y = gap + row * (cellH + gap);
81
- const cardSvg = renderCard(user, stats, langs, { ...cardOpts, theme: name });
82
- const encoded = encodeURIComponent(cardSvg).replace(/'/g, "%27").replace(/"/g, "%22");
142
+ const y = gap + row * (cellH + gap) + labelH;
143
+ // Inline the card as a nested <svg> element (GitHub camo-safe, unlike data-URI <image>)
144
+ const cardSvg = renderCard(user, stats, langs, { ...cardOpts, theme: name }).replace(
145
+ "<svg ",
146
+ `<svg x="${x}" y="${y}" `,
147
+ );
83
148
 
84
- return `<g transform="translate(${x},${y})">
85
- <text x="4" y="14" fill="#e2e8f0" font-size="13" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Arial" font-weight="700">${name}</text>
86
- <image href="data:image/svg+xml;utf8,${encoded}" x="0" y="${labelH}" width="${cardW}" height="${cardH}"/>
87
- </g>`;
149
+ 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}`;
88
150
  })
89
151
  .join("");
90
152
 
@@ -94,6 +156,27 @@ function renderUserThemesSvg(
94
156
  </svg>`;
95
157
  }
96
158
 
159
+ function applyCacheHeaders(set: MutableSet) {
160
+ set.headers["Cache-Control"] = CACHE_CONTROL_HEADER;
161
+ set.headers["CDN-Cache-Control"] = EDGE_CACHE_CONTROL_HEADER;
162
+ set.headers["Vercel-CDN-Cache-Control"] = EDGE_CACHE_CONTROL_HEADER;
163
+ }
164
+
165
+ function setSvgHeaders(svg: string, ifNoneMatch: string | undefined, set: MutableSet): string {
166
+ const etag = `W/"${Bun.hash(svg).toString(36)}"`;
167
+ if (ifNoneMatch === etag) {
168
+ set.status = 304;
169
+ set.headers.ETag = etag;
170
+ applyCacheHeaders(set);
171
+ return "";
172
+ }
173
+
174
+ set.headers["Content-Type"] = "image/svg+xml";
175
+ set.headers.ETag = etag;
176
+ applyCacheHeaders(set);
177
+ return svg;
178
+ }
179
+
97
180
  const app = new Elysia({ name: "github-card" })
98
181
  .error({
99
182
  NOT_FOUND_ERROR: NotFoundError,
@@ -132,11 +215,12 @@ const app = new Elysia({ name: "github-card" })
132
215
  documentation: {
133
216
  info: {
134
217
  title: "GitHub Profile Card API",
135
- version: "1.3.0",
136
- description: "Generate GitHub profile SVG cards for README and web embeds.",
218
+ version: "4.0.0",
219
+ description:
220
+ "Generate GitHub profile SVG/PNG cards for README, web embeds, and social previews.",
137
221
  },
138
222
  tags: [
139
- { name: "Card", description: "SVG card endpoints" },
223
+ { name: "Card", description: "Card endpoints" },
140
224
  { name: "Meta", description: "Service metadata" },
141
225
  { name: "Ops", description: "Operational endpoints" },
142
226
  ],
@@ -169,8 +253,9 @@ const app = new Elysia({ name: "github-card" })
169
253
  () => ({
170
254
  message: "GitHub Profile Card API",
171
255
  usage: "GET /card/:username",
172
- themes:
173
- "default, dark, radical, merko, gruvbox, tokyonight, onedark, cobalt, synthwave, highcontrast, dracula, monokai, nord, github_dark, pearl, slate, forest, rose, sand",
256
+ themes: Object.keys(themes).join(", "),
257
+ affiliations: "affiliated (default), owner",
258
+ organizationSupport: "Use /card/:organization-login",
174
259
  }),
175
260
  {
176
261
  detail: { tags: ["Meta"], summary: "API metadata" },
@@ -179,53 +264,32 @@ const app = new Elysia({ name: "github-card" })
179
264
  .get(
180
265
  "/:username/themes",
181
266
  async ({ params: { username }, query, headers, set }) => {
182
- const langCount = parseLangCount(query.lang_count);
183
- const hiddenStats = parseHiddenStats(query.hide);
184
- const scope = parseScope(query.scope);
185
- const orgs = parseOrgs(query.orgs);
186
- const fields = query.fields
187
- ? new Set(
188
- query.fields
189
- .split(",")
190
- .map((v) => v.trim().toLowerCase())
191
- .filter(Boolean),
192
- )
193
- : null;
194
- const includeLanguages =
195
- !fields || fields.has("all") || fields.has("languages") || fields.has("langs");
196
- const data = await getProfileData(username, { includeLanguages, langCount, scope, orgs });
267
+ const q = query as CardQuery;
268
+ const scope = parseScope(q.scope);
269
+ const orgs = parseOrgs(q.orgs);
270
+ const fields = parseFieldSet(q.fields);
271
+ const includeLanguages = shouldIncludeLanguages(fields);
272
+ const data = await getProfileData(username, {
273
+ includeLanguages,
274
+ langCount: q.lang_count ?? 5,
275
+ scope,
276
+ affiliations: q.affiliations ?? "affiliated",
277
+ orgs,
278
+ });
279
+
197
280
  const svg = renderUserThemesSvg(data.user, data.stats, data.languages, {
198
- compact: query.compact === "true",
199
- hide_border: query.hide_border === "true",
200
- hide: hiddenStats,
281
+ compact: q.compact ?? false,
282
+ hide_border: q.hide_border ?? false,
283
+ hide: parseHiddenStats(q.hide),
284
+ animate: q.animate ?? false,
201
285
  });
202
- const etag = `W/"${Bun.hash(svg).toString(36)}"`;
203
- if (headers["if-none-match"] === etag) {
204
- set.status = 304;
205
- set.headers["ETag"] = etag;
206
- set.headers["Cache-Control"] =
207
- "public, max-age=0, s-maxage=1800, stale-while-revalidate=1800";
208
- return "";
209
- }
210
286
 
211
- set.headers["Content-Type"] = "image/svg+xml";
212
- set.headers["ETag"] = etag;
213
- set.headers["Cache-Control"] =
214
- "public, max-age=0, s-maxage=1800, stale-while-revalidate=1800";
215
- return svg;
287
+ return setSvgHeaders(svg, headers["if-none-match"], set as MutableSet);
216
288
  },
217
289
  {
218
290
  detail: { tags: ["Card"], summary: "Render all built-in themes for a username" },
219
291
  params: t.Object({ username: t.String({ pattern: USERNAME_PATTERN }) }),
220
- query: t.Object({
221
- compact: t.Optional(t.String()),
222
- hide_border: t.Optional(t.String()),
223
- fields: t.Optional(t.String()),
224
- hide: t.Optional(t.String()),
225
- lang_count: t.Optional(t.String()),
226
- scope: t.Optional(t.Union([t.Literal("personal"), t.Literal("org"), t.Literal("all")])),
227
- orgs: t.Optional(t.String()),
228
- }),
292
+ query: CardQuerySchema,
229
293
  response: {
230
294
  200: t.String({ description: "SVG markup" }),
231
295
  304: t.String(),
@@ -261,6 +325,7 @@ const app = new Elysia({ name: "github-card" })
261
325
  region: t.Union([t.String(), t.Null()]),
262
326
  cache: t.Object({
263
327
  total: t.Number(),
328
+ maxEntries: t.Number(),
264
329
  fresh: t.Number(),
265
330
  stale: t.Number(),
266
331
  expired: t.Number(),
@@ -275,65 +340,13 @@ const app = new Elysia({ name: "github-card" })
275
340
  .get(
276
341
  "/card/:username",
277
342
  async ({ params: { username }, query, headers, set }) => {
278
- const langCount = parseLangCount(query.lang_count);
279
- const hiddenStats = parseHiddenStats(query.hide);
280
- const scope = parseScope(query.scope);
281
- const orgs = parseOrgs(query.orgs);
282
- const fields = query.fields
283
- ? new Set(
284
- query.fields
285
- .split(",")
286
- .map((v) => v.trim().toLowerCase())
287
- .filter(Boolean),
288
- )
289
- : null;
290
- const includeLanguages =
291
- !fields || fields.has("all") || fields.has("languages") || fields.has("langs");
292
- const data = await getProfileData(username, { includeLanguages, langCount, scope, orgs });
293
- const svg = renderCard(data.user, data.stats, data.languages, {
294
- theme: query.theme,
295
- title_color: query.title_color,
296
- text_color: query.text_color,
297
- icon_color: query.icon_color,
298
- bg_color: query.bg_color,
299
- border_color: query.border_color,
300
- hide_border: query.hide_border === "true",
301
- compact: query.compact === "true",
302
- hide: hiddenStats,
303
- });
304
- const etag = `W/"${Bun.hash(svg).toString(36)}"`;
305
- if (headers["if-none-match"] === etag) {
306
- set.status = 304;
307
- set.headers["ETag"] = etag;
308
- set.headers["Cache-Control"] =
309
- "public, max-age=0, s-maxage=1800, stale-while-revalidate=1800";
310
- return "";
311
- }
312
-
313
- set.headers["Content-Type"] = "image/svg+xml";
314
- set.headers["ETag"] = etag;
315
- set.headers["Cache-Control"] =
316
- "public, max-age=0, s-maxage=1800, stale-while-revalidate=1800";
317
- return svg;
343
+ const svg = await buildCardSvg(username, query as CardQuery);
344
+ return setSvgHeaders(svg, headers["if-none-match"], set as MutableSet);
318
345
  },
319
346
  {
320
347
  detail: { tags: ["Card"], summary: "Render GitHub profile card SVG" },
321
348
  params: t.Object({ username: t.String({ pattern: USERNAME_PATTERN }) }),
322
- query: t.Object({
323
- theme: t.Optional(t.String()),
324
- title_color: t.Optional(t.String()),
325
- text_color: t.Optional(t.String()),
326
- icon_color: t.Optional(t.String()),
327
- bg_color: t.Optional(t.String()),
328
- border_color: t.Optional(t.String()),
329
- hide_border: t.Optional(t.String()),
330
- compact: t.Optional(t.String()),
331
- fields: t.Optional(t.String()),
332
- hide: t.Optional(t.String()),
333
- lang_count: t.Optional(t.String()),
334
- scope: t.Optional(t.Union([t.Literal("personal"), t.Literal("org"), t.Literal("all")])),
335
- orgs: t.Optional(t.String()),
336
- }),
349
+ query: CardQuerySchema,
337
350
  response: {
338
351
  200: t.String({ description: "SVG markup" }),
339
352
  304: t.String(),
package/src/input.css ADDED
@@ -0,0 +1 @@
1
+ @import "tailwindcss";