@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/README.md +159 -261
- package/dist/card.d.ts +16 -0
- package/dist/lib.d.ts +13 -0
- package/dist/lib.js +1347 -0
- package/dist/types.d.ts +37 -0
- package/dist/utils/colors.d.ts +2 -0
- package/dist/utils/font.d.ts +4 -0
- package/dist/utils/format.d.ts +10 -0
- package/dist/utils/icons.d.ts +11 -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 +30 -20
- package/src/card.ts +537 -113
- package/src/env.d.ts +4 -0
- package/src/github.ts +406 -480
- package/src/index.ts +148 -134
- package/src/input.css +1 -0
- package/src/lib.ts +1 -1
- package/src/tailwind.css +3837 -0
- package/src/types.ts +1 -0
- package/src/utils/colors.ts +85 -0
- package/src/utils/font.ts +5 -19
- package/src/utils/format.ts +54 -16
- package/src/utils/github-client.ts +198 -0
- package/src/utils/icons.ts +27 -9
- package/src/utils/index.ts +2 -1
- package/src/utils/themes.ts +154 -19
- package/fonts/Roboto-subset.woff2 +0 -0
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,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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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(
|
|
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
|
-
|
|
33
|
-
.
|
|
34
|
-
.
|
|
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
|
-
|
|
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(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
82
|
-
const
|
|
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 `<
|
|
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: "
|
|
136
|
-
description:
|
|
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: "
|
|
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
|
-
|
|
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
|
|
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 });
|
|
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:
|
|
199
|
-
hide_border:
|
|
200
|
-
hide:
|
|
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
|
-
|
|
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:
|
|
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
|
|
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;
|
|
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:
|
|
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";
|