@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/README.md +134 -293
- package/dist/card.d.ts +16 -0
- package/dist/lib.d.ts +13 -0
- package/dist/lib.js +1209 -0
- package/dist/types.d.ts +37 -0
- package/dist/utils/colors.d.ts +2 -0
- package/dist/utils/font.d.ts +2 -0
- package/dist/utils/format.d.ts +3 -0
- package/dist/utils/icons.d.ts +9 -0
- package/dist/utils/index.d.ts +6 -0
- package/dist/utils/languages.d.ts +2 -0
- package/dist/utils/themes.d.ts +16 -0
- package/package.json +29 -18
- package/src/card.ts +634 -113
- package/src/env.d.ts +4 -0
- package/src/github.ts +351 -483
- package/src/index.ts +147 -134
- package/src/input.css +1 -0
- package/src/tailwind.css +3837 -0
- package/src/types.ts +1 -0
- package/src/utils/colors.ts +85 -0
- package/src/utils/font.ts +3 -19
- package/src/utils/format.ts +16 -16
- package/src/utils/github-client.ts +193 -0
- package/src/utils/icons.ts +2 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/themes.ts +154 -19
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Elysia, t } from "elysia";
|
|
2
|
-
import { cors } from "@
|
|
3
|
-
import { openapi } from "@
|
|
4
|
-
import { serverTiming } from "@
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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(
|
|
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
|
-
|
|
33
|
-
.
|
|
34
|
-
.
|
|
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
|
-
|
|
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(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
82
|
-
const
|
|
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 `<
|
|
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: "
|
|
136
|
-
description:
|
|
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: "
|
|
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
|
-
|
|
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
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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:
|
|
199
|
-
hide_border:
|
|
200
|
-
hide:
|
|
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
|
-
|
|
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:
|
|
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
|
|
279
|
-
|
|
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:
|
|
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";
|