@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/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,10 +13,13 @@ 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";
19
21
  const EDGE_CACHE_CONTROL_HEADER = "public, s-maxage=1800, stale-while-revalidate=1800";
22
+ const DEFAULT_AFFILIATIONS = "owner" as const;
20
23
  const appStartedAt = Date.now();
21
24
 
22
25
  const CardQuerySchema = t.Object({
@@ -29,22 +32,30 @@ const CardQuerySchema = t.Object({
29
32
  hide_border: t.Optional(t.BooleanString()),
30
33
  compact: t.Optional(t.BooleanString()),
31
34
  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())),
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())),
36
39
  lang_count: t.Optional(t.Integer({ minimum: 1, maximum: 10 })),
37
40
  scope: t.Optional(t.Union([t.Literal("personal"), t.Literal("org"), t.Literal("all")])),
38
41
  affiliations: t.Optional(t.Union([t.Literal("owner"), t.Literal("affiliated")])),
39
- orgs: t.Optional(t.ArrayQuery(t.String())),
42
+ orgs: t.Optional(t.Array(t.String())),
40
43
  });
41
44
 
42
- type CardQuery = typeof CardQuerySchema.static;
43
- type MutableSet = {
44
- status?: number | string;
45
- 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,
46
54
  };
47
55
 
56
+ type CardQuery = UnwrapSchema<typeof CardQuerySchema>;
57
+ type ResponseSet = Context["set"];
58
+
48
59
  function normalizeList(values?: string[]): string[] {
49
60
  if (!values?.length) return [];
50
61
  return values.map((value) => value.trim()).filter(Boolean);
@@ -61,11 +72,6 @@ function parseHiddenStats(values?: string[]): string[] {
61
72
  );
62
73
  }
63
74
 
64
- function parseScope(value?: string): "personal" | "org" | "all" {
65
- if (value === "org" || value === "all") return value;
66
- return "personal";
67
- }
68
-
69
75
  function parseOrgs(values?: string[]): string[] {
70
76
  return Array.from(new Set(normalizeList(values).map((value) => value.toLowerCase())));
71
77
  }
@@ -97,72 +103,35 @@ function buildCardOptions(query: CardQuery, hide: string[]): CardOpts {
97
103
  };
98
104
  }
99
105
 
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,
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),
115
115
  });
116
-
117
- return renderCard(data.user, data.stats, data.languages, buildCardOptions(query, hiddenStats));
118
116
  }
119
117
 
120
- function renderUserThemesSvg(
121
- user: Parameters<typeof renderCard>[0],
122
- stats: Parameters<typeof renderCard>[1],
123
- langs: Parameters<typeof renderCard>[2],
124
- cardOpts: Parameters<typeof renderCard>[3],
125
- ) {
126
- const entries = Object.keys(themes);
127
- const cols = 2;
128
- const gap = 20;
129
- const cardW = 500;
130
- const cardH = 200;
131
- const labelH = 22;
132
- const cellH = cardH + labelH;
133
- const rows = Math.ceil(entries.length / cols);
134
- const width = cols * cardW + (cols + 1) * gap;
135
- const height = rows * cellH + (rows + 1) * gap;
136
-
137
- const blocks = entries
138
- .map((name, index) => {
139
- const col = index % cols;
140
- const row = Math.floor(index / cols);
141
- const x = gap + col * (cardW + gap);
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
- );
148
-
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}`;
150
- })
151
- .join("");
152
-
153
- 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">
154
- <rect width="100%" height="100%" fill="#0b1220"/>
155
- ${blocks}
156
- </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
+ );
157
126
  }
158
127
 
159
- function applyCacheHeaders(set: MutableSet) {
128
+ function applyCacheHeaders(set: ResponseSet) {
160
129
  set.headers["Cache-Control"] = CACHE_CONTROL_HEADER;
161
130
  set.headers["CDN-Cache-Control"] = EDGE_CACHE_CONTROL_HEADER;
162
131
  set.headers["Vercel-CDN-Cache-Control"] = EDGE_CACHE_CONTROL_HEADER;
163
132
  }
164
133
 
165
- function setSvgHeaders(svg: string, ifNoneMatch: string | undefined, set: MutableSet): string {
134
+ function setSvgHeaders(svg: string, ifNoneMatch: string | undefined, set: ResponseSet): string {
166
135
  const etag = `W/"${Bun.hash(svg).toString(36)}"`;
167
136
  if (ifNoneMatch === etag) {
168
137
  set.status = 304;
@@ -177,29 +146,11 @@ function setSvgHeaders(svg: string, ifNoneMatch: string | undefined, set: Mutabl
177
146
  return svg;
178
147
  }
179
148
 
180
- const app = new Elysia({ name: "github-card" })
181
- .error({
182
- NOT_FOUND_ERROR: NotFoundError,
183
- RATE_LIMIT_ERROR: RateLimitError,
184
- AUTH_ERROR: AuthError,
185
- UPSTREAM_ERROR: UpstreamError,
186
- })
187
- .onError(({ code, error, set }) => {
188
- switch (code) {
189
- case "NOT_FOUND_ERROR":
190
- set.status = 404;
191
- return { error: error.message };
192
- case "RATE_LIMIT_ERROR":
193
- set.status = 429;
194
- return { error: error.message };
195
- case "AUTH_ERROR":
196
- set.status = 401;
197
- return { error: error.message };
198
- case "UPSTREAM_ERROR":
199
- set.status = 502;
200
- return { error: error.message };
201
- }
202
- })
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 }))
203
154
  .use(
204
155
  cors({
205
156
  origin: true,
@@ -215,9 +166,9 @@ const app = new Elysia({ name: "github-card" })
215
166
  documentation: {
216
167
  info: {
217
168
  title: "GitHub Profile Card API",
218
- version: "4.0.0",
169
+ version,
219
170
  description:
220
- "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.",
221
172
  },
222
173
  tags: [
223
174
  { name: "Card", description: "Card endpoints" },
@@ -230,12 +181,6 @@ const app = new Elysia({ name: "github-card" })
230
181
  .use(serverTiming())
231
182
  .get(
232
183
  "/",
233
- () => ({
234
- message: "GitHub Profile Card API",
235
- usage: "GET /card/:username",
236
- themes: "GET /:username/themes",
237
- docs: "/openapi",
238
- }),
239
184
  {
240
185
  detail: { tags: ["Meta"], summary: "API metadata" },
241
186
  response: {
@@ -247,74 +192,48 @@ const app = new Elysia({ name: "github-card" })
247
192
  }),
248
193
  },
249
194
  },
195
+ () => ({
196
+ message: "GitHub Profile Card API",
197
+ usage: "GET /card/:username",
198
+ themes: "GET /:username/themes",
199
+ docs: "/openapi",
200
+ }),
250
201
  )
251
202
  .get(
252
203
  "/meta",
204
+ {
205
+ detail: { tags: ["Meta"], summary: "API metadata" },
206
+ },
253
207
  () => ({
254
208
  message: "GitHub Profile Card API",
255
209
  usage: "GET /card/:username",
256
210
  themes: Object.keys(themes).join(", "),
257
- affiliations: "affiliated (default), owner",
211
+ affiliations: "owner (default), affiliated",
258
212
  organizationSupport: "Use /card/:organization-login",
259
213
  }),
260
- {
261
- detail: { tags: ["Meta"], summary: "API metadata" },
262
- },
263
214
  )
264
215
  .get(
265
216
  "/:username/themes",
266
- async ({ params: { username }, query, headers, set }) => {
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
-
280
- const svg = renderUserThemesSvg(data.user, data.stats, data.languages, {
281
- compact: q.compact ?? false,
282
- hide_border: q.hide_border ?? false,
283
- hide: parseHiddenStats(q.hide),
284
- animate: q.animate ?? false,
285
- });
286
-
287
- return setSvgHeaders(svg, headers["if-none-match"], set as MutableSet);
288
- },
289
217
  {
290
218
  detail: { tags: ["Card"], summary: "Render all built-in themes for a username" },
291
- params: t.Object({ username: t.String({ pattern: USERNAME_PATTERN }) }),
219
+ params: CardParamsSchema,
292
220
  query: CardQuerySchema,
293
- response: {
294
- 200: t.String({ description: "SVG markup" }),
295
- 304: t.String(),
296
- 401: t.Object({ error: t.String() }),
297
- 404: t.Object({ error: t.String() }),
298
- 429: t.Object({ error: t.String() }),
299
- 500: t.Object({ error: t.String() }),
300
- 502: t.Object({ error: t.String() }),
301
- },
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);
302
233
  },
303
234
  )
304
235
  .get(
305
236
  "/health",
306
- async () => {
307
- const cache = getCacheMetrics();
308
- const redisReachable = await isRedisReachable();
309
- return {
310
- status: "ok",
311
- uptimeSeconds: Math.floor((Date.now() - appStartedAt) / 1000),
312
- runtime: "vercel-function",
313
- region: Bun.env.VERCEL_REGION || null,
314
- cache,
315
- redisReachable,
316
- };
317
- },
318
237
  {
319
238
  detail: { tags: ["Ops"], summary: "Service health and cache telemetry" },
320
239
  response: {
@@ -336,33 +255,38 @@ const app = new Elysia({ name: "github-card" })
336
255
  }),
337
256
  },
338
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
+ },
339
270
  )
340
271
  .get(
341
272
  "/card/:username",
342
- async ({ params: { username }, query, headers, set }) => {
343
- const svg = await buildCardSvg(username, query as CardQuery);
344
- return setSvgHeaders(svg, headers["if-none-match"], set as MutableSet);
345
- },
346
273
  {
347
274
  detail: { tags: ["Card"], summary: "Render GitHub profile card SVG" },
348
- params: t.Object({ username: t.String({ pattern: USERNAME_PATTERN }) }),
275
+ params: CardParamsSchema,
349
276
  query: CardQuerySchema,
350
- response: {
351
- 200: t.String({ description: "SVG markup" }),
352
- 304: t.String(),
353
- 401: t.Object({ error: t.String() }),
354
- 404: t.Object({ error: t.String() }),
355
- 429: t.Object({ error: t.String() }),
356
- 500: t.Object({ error: t.String() }),
357
- 502: t.Object({ error: t.String() }),
358
- },
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);
359
282
  },
360
283
  );
361
284
 
285
+ export type App = typeof app;
286
+ export default app;
287
+
362
288
  if (import.meta.main) {
363
289
  const port = Number(Bun.env.PORT || 3000);
364
290
  app.listen(port);
365
291
  console.log(`Dev server running on http://localhost:${port}`);
366
292
  }
367
-
368
- export default app;
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";
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
  }
package/src/utils/font.ts CHANGED
@@ -1,3 +1,5 @@
1
+ /** System font stack — no shipped font, respects the user's OS. */
1
2
  export const FONT_FAMILY =
2
- 'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"';
3
- export const FONT_FACE = ""; // Kept for API backwards compatibility
3
+ 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"';
4
+ /** @deprecated No shipped font — kept for API backwards compatibility. */
5
+ export const FONT_FACE = "";
@@ -1,12 +1,51 @@
1
1
  export function kFormat(num: number): string {
2
+ const sign = num < 0 ? "-" : "";
3
+ const abs = Math.abs(num);
2
4
  const trim = (v: number) => v.toFixed(1).replace(/\.0$/, "");
3
- if (num >= 999_950) return trim(num / 1_000_000) + "M";
4
- if (num >= 999.95) return trim(num / 1_000) + "k";
5
- return String(Math.round(num));
5
+ if (abs >= 999_950_000) return sign + trim(abs / 1_000_000_000) + "B";
6
+ if (abs >= 999_950) return sign + trim(abs / 1_000_000) + "M";
7
+ if (abs >= 999.95) return sign + trim(abs / 1_000) + "k";
8
+ return sign + String(Math.round(abs));
9
+ }
10
+
11
+ /**
12
+ * Approximate rendered width of a string in SVG user units for the default
13
+ * sans-serif stack. Widths are per-character averages tuned to the card's
14
+ * font sizes and weights, so layout can reserve enough space without relying
15
+ * on browser text measurement (which is unavailable server-side).
16
+ */
17
+ export function measureText(text: string, size: number, weight = 400): number {
18
+ if (!text) return 0;
19
+ const factor = size;
20
+ const weightFactor = weight >= 700 ? 1.08 : weight >= 600 ? 1.04 : 1;
21
+ let width = 0;
22
+ for (const ch of text) {
23
+ const code = ch.codePointAt(0)!;
24
+ let w: number;
25
+ if (ch === " ") w = 0.28;
26
+ else if (ch === "." || ch === "," || ch === ":" || ch === ";") w = 0.3;
27
+ else if (ch === "i" || ch === "l" || ch === "I" || ch === "t" || ch === "j" || ch === "|")
28
+ w = 0.3;
29
+ else if (ch === "m" || ch === "w" || ch === "W" || ch === "M" || ch === "@") w = 0.95;
30
+ else if (code >= 0x1100 && code <= 0x11ff)
31
+ w = 1.0; // Hangul
32
+ else if (code >= 0x4e00 && code <= 0x9fff)
33
+ w = 1.0; // CJK
34
+ else if (code >= 0x3040 && code <= 0x30ff)
35
+ w = 1.0; // Kana
36
+ else if (code >= 0xac00 && code <= 0xd7af)
37
+ w = 1.0; // Hangul syllables
38
+ else if (code > 0x7f)
39
+ w = 0.72; // other non-ASCII (emoji, accents)
40
+ else w = 0.58; // default latin
41
+ width += w;
42
+ }
43
+ return width * factor * weightFactor;
6
44
  }
7
45
 
8
46
  export function escapeXml(str: string): string {
9
47
  return str
48
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff\ud800-\udfff]/gu, "")
10
49
  .replace(/&/g, "&amp;")
11
50
  .replace(/</g, "&lt;")
12
51
  .replace(/>/g, "&gt;")
@@ -15,28 +54,29 @@ export function escapeXml(str: string): string {
15
54
  }
16
55
 
17
56
  export function wrapText(input: string, maxLen: number, maxLines: number): string[] {
18
- const words = input.split(/\s+/).filter(Boolean);
57
+ if (maxLen < 1 || maxLines < 1) return [];
58
+ let remaining = Array.from(input.trim().replace(/\s+/g, " "));
19
59
  const lines: string[] = [];
20
- let current = "";
21
60
 
22
- for (const word of words) {
23
- const next = current ? `${current} ${word}` : word;
24
- if (next.length <= maxLen) {
25
- current = next;
26
- continue;
61
+ while (remaining.length && lines.length < maxLines) {
62
+ if (remaining.length <= maxLen) {
63
+ lines.push(remaining.join(""));
64
+ break;
27
65
  }
28
- if (current) lines.push(current);
29
- if (lines.length === maxLines) break;
30
- current = word.length > maxLen ? word.slice(0, maxLen) : word;
31
- }
32
- if (current && lines.length < maxLines) lines.push(current);
33
-
34
- // Truncated? Mark the last line with an ellipsis.
35
- const consumed = lines.join(" ").length;
36
- if (lines.length > 0 && consumed < input.trim().length) {
37
- const last = lines[lines.length - 1]!;
38
- lines[lines.length - 1] =
39
- (last.length >= maxLen ? last.slice(0, Math.max(0, maxLen - 1)) : last) + "…";
66
+ if (lines.length === maxLines - 1) {
67
+ lines.push(
68
+ remaining
69
+ .slice(0, maxLen - 1)
70
+ .join("")
71
+ .trimEnd() + "…",
72
+ );
73
+ break;
74
+ }
75
+ const space = remaining.lastIndexOf(" ", maxLen);
76
+ const end = space > 0 ? space : maxLen;
77
+ lines.push(remaining.slice(0, end).join(""));
78
+ remaining = remaining.slice(end);
79
+ if (remaining[0] === " ") remaining.shift();
40
80
  }
41
81
  return lines;
42
82
  }