@shiinasaku/github-card 2.0.0 → 4.2.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,111 @@ 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";
20
+ const DEFAULT_AFFILIATIONS = "owner" as const;
18
21
  const appStartedAt = Date.now();
19
22
 
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));
23
+ const CardQuerySchema = t.Object({
24
+ theme: t.Optional(t.String()),
25
+ title_color: t.Optional(t.String()),
26
+ text_color: t.Optional(t.String()),
27
+ icon_color: t.Optional(t.String()),
28
+ bg_color: t.Optional(t.String()),
29
+ border_color: t.Optional(t.String()),
30
+ hide_border: t.Optional(t.BooleanString()),
31
+ compact: t.Optional(t.BooleanString()),
32
+ 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())),
37
+ lang_count: t.Optional(t.Integer({ minimum: 1, maximum: 10 })),
38
+ scope: t.Optional(t.Union([t.Literal("personal"), t.Literal("org"), t.Literal("all")])),
39
+ affiliations: t.Optional(t.Union([t.Literal("owner"), t.Literal("affiliated")])),
40
+ orgs: t.Optional(t.ArrayQuery(t.String())),
41
+ });
42
+
43
+ type CardQuery = typeof CardQuerySchema.static;
44
+ type MutableSet = {
45
+ status?: number | string;
46
+ headers: Record<string, string | undefined>;
47
+ };
48
+
49
+ function normalizeList(values?: string[]): string[] {
50
+ if (!values?.length) return [];
51
+ return values.map((value) => value.trim()).filter(Boolean);
25
52
  }
26
53
 
27
- function parseHiddenStats(value?: string): string[] {
28
- if (!value) return [];
54
+ function parseHiddenStats(values?: string[]): string[] {
29
55
  const allowed = new Set(["stars", "commits", "issues", "repos", "prs"]);
30
56
  return Array.from(
31
57
  new Set(
32
- value
33
- .split(",")
34
- .map((v) => v.trim().toLowerCase())
35
- .filter((v) => allowed.has(v)),
58
+ normalizeList(values)
59
+ .map((value) => value.toLowerCase())
60
+ .filter((value) => allowed.has(value)),
36
61
  ),
37
62
  );
38
63
  }
39
64
 
40
65
  function parseScope(value?: string): "personal" | "org" | "all" {
41
- const normalized = value?.trim().toLowerCase();
42
- if (normalized === "org" || normalized === "all") return normalized;
66
+ if (value === "org" || value === "all") return value;
43
67
  return "personal";
44
68
  }
45
69
 
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
- );
70
+ function parseOrgs(values?: string[]): string[] {
71
+ return Array.from(new Set(normalizeList(values).map((value) => value.toLowerCase())));
72
+ }
73
+
74
+ function parseFieldSet(values?: string[]): Set<string> | null {
75
+ const list = normalizeList(values).map((value) => value.toLowerCase());
76
+ return list.length ? new Set(list) : null;
77
+ }
78
+
79
+ function shouldIncludeLanguages(fields: Set<string> | null): boolean {
80
+ if (!fields) return true;
81
+ return fields.has("all") || fields.has("languages") || fields.has("langs");
82
+ }
83
+
84
+ function buildCardOptions(query: CardQuery, hide: string[]): CardOpts {
85
+ return {
86
+ theme: query.theme,
87
+ title_color: query.title_color,
88
+ text_color: query.text_color,
89
+ icon_color: query.icon_color,
90
+ bg_color: query.bg_color,
91
+ border_color: query.border_color,
92
+ hide_border: query.hide_border ?? false,
93
+ compact: query.compact ?? false,
94
+ hide,
95
+ hide_langs: query.hide_langs,
96
+ show_langs: query.show_langs,
97
+ animate: query.animate ?? false,
98
+ };
99
+ }
100
+
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,
116
+ });
117
+
118
+ return renderCard(data.user, data.stats, data.languages, buildCardOptions(query, hiddenStats));
56
119
  }
57
120
 
58
121
  function renderUserThemesSvg(
@@ -77,14 +140,14 @@ function renderUserThemesSvg(
77
140
  const col = index % cols;
78
141
  const row = Math.floor(index / cols);
79
142
  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");
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
+ );
83
149
 
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>`;
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}`;
88
151
  })
89
152
  .join("");
90
153
 
@@ -94,6 +157,27 @@ function renderUserThemesSvg(
94
157
  </svg>`;
95
158
  }
96
159
 
160
+ function applyCacheHeaders(set: MutableSet) {
161
+ set.headers["Cache-Control"] = CACHE_CONTROL_HEADER;
162
+ set.headers["CDN-Cache-Control"] = EDGE_CACHE_CONTROL_HEADER;
163
+ set.headers["Vercel-CDN-Cache-Control"] = EDGE_CACHE_CONTROL_HEADER;
164
+ }
165
+
166
+ function setSvgHeaders(svg: string, ifNoneMatch: string | undefined, set: MutableSet): string {
167
+ const etag = `W/"${Bun.hash(svg).toString(36)}"`;
168
+ if (ifNoneMatch === etag) {
169
+ set.status = 304;
170
+ set.headers.ETag = etag;
171
+ applyCacheHeaders(set);
172
+ return "";
173
+ }
174
+
175
+ set.headers["Content-Type"] = "image/svg+xml";
176
+ set.headers.ETag = etag;
177
+ applyCacheHeaders(set);
178
+ return svg;
179
+ }
180
+
97
181
  const app = new Elysia({ name: "github-card" })
98
182
  .error({
99
183
  NOT_FOUND_ERROR: NotFoundError,
@@ -132,11 +216,12 @@ const app = new Elysia({ name: "github-card" })
132
216
  documentation: {
133
217
  info: {
134
218
  title: "GitHub Profile Card API",
135
- version: "1.3.0",
136
- description: "Generate GitHub profile SVG cards for README and web embeds.",
219
+ version: "4.2.0",
220
+ description:
221
+ "Generate GitHub profile SVG/PNG cards for README, web embeds, and social previews.",
137
222
  },
138
223
  tags: [
139
- { name: "Card", description: "SVG card endpoints" },
224
+ { name: "Card", description: "Card endpoints" },
140
225
  { name: "Meta", description: "Service metadata" },
141
226
  { name: "Ops", description: "Operational endpoints" },
142
227
  ],
@@ -169,8 +254,9 @@ const app = new Elysia({ name: "github-card" })
169
254
  () => ({
170
255
  message: "GitHub Profile Card API",
171
256
  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",
257
+ themes: Object.keys(themes).join(", "),
258
+ affiliations: "owner (default), affiliated",
259
+ organizationSupport: "Use /card/:organization-login",
174
260
  }),
175
261
  {
176
262
  detail: { tags: ["Meta"], summary: "API metadata" },
@@ -179,53 +265,32 @@ const app = new Elysia({ name: "github-card" })
179
265
  .get(
180
266
  "/:username/themes",
181
267
  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 });
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
+
197
281
  const svg = renderUserThemesSvg(data.user, data.stats, data.languages, {
198
- compact: query.compact === "true",
199
- hide_border: query.hide_border === "true",
200
- hide: hiddenStats,
282
+ compact: q.compact ?? false,
283
+ hide_border: q.hide_border ?? false,
284
+ hide: parseHiddenStats(q.hide),
285
+ animate: q.animate ?? false,
201
286
  });
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
287
 
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;
288
+ return setSvgHeaders(svg, headers["if-none-match"], set as MutableSet);
216
289
  },
217
290
  {
218
291
  detail: { tags: ["Card"], summary: "Render all built-in themes for a username" },
219
292
  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
- }),
293
+ query: CardQuerySchema,
229
294
  response: {
230
295
  200: t.String({ description: "SVG markup" }),
231
296
  304: t.String(),
@@ -261,6 +326,7 @@ const app = new Elysia({ name: "github-card" })
261
326
  region: t.Union([t.String(), t.Null()]),
262
327
  cache: t.Object({
263
328
  total: t.Number(),
329
+ maxEntries: t.Number(),
264
330
  fresh: t.Number(),
265
331
  stale: t.Number(),
266
332
  expired: t.Number(),
@@ -275,65 +341,13 @@ const app = new Elysia({ name: "github-card" })
275
341
  .get(
276
342
  "/card/:username",
277
343
  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;
344
+ const svg = await buildCardSvg(username, query as CardQuery);
345
+ return setSvgHeaders(svg, headers["if-none-match"], set as MutableSet);
318
346
  },
319
347
  {
320
348
  detail: { tags: ["Card"], summary: "Render GitHub profile card SVG" },
321
349
  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
- }),
350
+ query: CardQuerySchema,
337
351
  response: {
338
352
  200: t.String({ description: "SVG markup" }),
339
353
  304: t.String(),
package/src/input.css ADDED
@@ -0,0 +1 @@
1
+ @import "tailwindcss";
package/src/lib.ts CHANGED
@@ -15,7 +15,7 @@ export type { UserProfile, UserStats, LanguageStat, ProfileData, CardOptions } f
15
15
  export { themes, resolveColors, type Theme } from "./utils/themes";
16
16
 
17
17
  // Utilities
18
- export { kFormat, escapeXml, wrapText } from "./utils/format";
18
+ export { kFormat, escapeXml, wrapText, measureText } from "./utils/format";
19
19
  export { icons, icon } from "./utils/icons";
20
20
  export { getLangColor } from "./utils/languages";
21
21
  export { FONT_FACE, FONT_FAMILY } from "./utils/font";