@shiinasaku/github-card 4.2.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/README.md +40 -13
- package/dist/card.d.ts +2 -15
- package/dist/lib.js +4363 -930
- package/dist/types.d.ts +8 -3
- package/dist/utils/index.d.ts +4 -5
- package/dist/utils/languages.d.ts +0 -1
- package/package.json +14 -16
- package/src/card.ts +114 -92
- package/src/gallery.ts +37 -0
- package/src/github.ts +86 -42
- package/src/index.ts +89 -166
- package/src/types.ts +8 -3
- package/src/utils/format.ts +22 -20
- package/src/utils/github-client.ts +43 -138
- package/src/utils/icons.ts +5 -1
- package/src/utils/index.ts +4 -5
- package/src/utils/languages.ts +1 -1
- package/src/env.d.ts +0 -4
- package/src/input.css +0 -1
- package/src/tailwind.css +0 -3837
package/dist/types.d.ts
CHANGED
|
@@ -9,9 +9,10 @@ export interface UserProfile {
|
|
|
9
9
|
export interface UserStats {
|
|
10
10
|
stars: number;
|
|
11
11
|
repos: number;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
/** null means unavailable, not zero (for example, organization activity totals). */
|
|
13
|
+
prs: number | null;
|
|
14
|
+
issues: number | null;
|
|
15
|
+
commits: number | null;
|
|
15
16
|
}
|
|
16
17
|
export interface LanguageStat {
|
|
17
18
|
name: string;
|
|
@@ -33,5 +34,9 @@ export interface CardOptions {
|
|
|
33
34
|
hide_border?: boolean;
|
|
34
35
|
compact?: boolean;
|
|
35
36
|
hide?: string[];
|
|
37
|
+
hide_langs?: string[];
|
|
38
|
+
show_langs?: string[];
|
|
36
39
|
animate?: boolean;
|
|
40
|
+
/** Stable namespace for multiple cards embedded in the same SVG document. */
|
|
41
|
+
idPrefix?: string;
|
|
37
42
|
}
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
export { resolveColors, themes
|
|
1
|
+
export { resolveColors, themes } from "./themes";
|
|
2
2
|
export { kFormat, escapeXml, wrapText, measureText } from "./format";
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
5
|
-
export {
|
|
6
|
-
export * from "./colors";
|
|
3
|
+
export { icon } from "./icons";
|
|
4
|
+
export { FONT_FAMILY } from "./font";
|
|
5
|
+
export { resolveTw } from "./colors";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shiinasaku/github-card",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"description": "Beautiful GitHub stats SVG cards. Embed in README, use as API, or render programmatically.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"badge",
|
|
@@ -56,27 +56,25 @@
|
|
|
56
56
|
"dev": "bun run --watch src/index.ts",
|
|
57
57
|
"build": "bun build src/lib.ts --outfile dist/lib.js --format esm --target node && tsc -p tsconfig.build.json",
|
|
58
58
|
"prepublishOnly": "bun run build",
|
|
59
|
-
"fmt": "
|
|
60
|
-
"fmt:check": "
|
|
61
|
-
"
|
|
62
|
-
"lint:fix": "oxlint --fix",
|
|
63
|
-
"typecheck": "tsc --noEmit --skipLibCheck",
|
|
59
|
+
"fmt": "bun fmt",
|
|
60
|
+
"fmt:check": "bun fmt --check",
|
|
61
|
+
"typecheck": "bun check",
|
|
64
62
|
"test": "bun test",
|
|
65
63
|
"test:coverage": "bun test --coverage"
|
|
66
64
|
},
|
|
67
65
|
"dependencies": {
|
|
68
|
-
"@elysia/cors": "
|
|
69
|
-
"@elysia/openapi": "
|
|
70
|
-
"@elysia/server-timing": "
|
|
71
|
-
"
|
|
66
|
+
"@elysia/cors": "2.0.0-rc.1",
|
|
67
|
+
"@elysia/openapi": "2.0.0-rc.1",
|
|
68
|
+
"@elysia/server-timing": "2.0.0-rc.0",
|
|
69
|
+
"culori": "^4.0.2",
|
|
70
|
+
"elysia": "2.0.0-rc.3"
|
|
72
71
|
},
|
|
73
72
|
"devDependencies": {
|
|
74
|
-
"@
|
|
75
|
-
"@types/bun": "^1.4.
|
|
76
|
-
"
|
|
77
|
-
"
|
|
78
|
-
"tailwindcss": "^4.3.3",
|
|
73
|
+
"@resvg/resvg-js": "^2.6.2",
|
|
74
|
+
"@types/bun": "^1.4.4",
|
|
75
|
+
"@types/culori": "^4.0.1",
|
|
76
|
+
"@xmldom/xmldom": "^0.9.12",
|
|
79
77
|
"typescript": "^6.0.3"
|
|
80
78
|
},
|
|
81
|
-
"packageManager": "bun@1.4.
|
|
79
|
+
"packageManager": "bun@1.4.4"
|
|
82
80
|
}
|
package/src/card.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parse, formatHex, formatHex8 } from "culori";
|
|
1
2
|
import {
|
|
2
3
|
resolveColors,
|
|
3
4
|
kFormat,
|
|
@@ -8,22 +9,9 @@ import {
|
|
|
8
9
|
FONT_FAMILY,
|
|
9
10
|
resolveTw,
|
|
10
11
|
} from "./utils/index";
|
|
11
|
-
import type { UserProfile, UserStats, LanguageStat } from "./types";
|
|
12
|
-
|
|
13
|
-
export type CardOpts =
|
|
14
|
-
theme?: string;
|
|
15
|
-
title_color?: string;
|
|
16
|
-
text_color?: string;
|
|
17
|
-
icon_color?: string;
|
|
18
|
-
bg_color?: string;
|
|
19
|
-
border_color?: string;
|
|
20
|
-
hide_border?: boolean;
|
|
21
|
-
compact?: boolean;
|
|
22
|
-
hide?: string[];
|
|
23
|
-
hide_langs?: string[];
|
|
24
|
-
show_langs?: string[];
|
|
25
|
-
animate?: boolean;
|
|
26
|
-
};
|
|
12
|
+
import type { UserProfile, UserStats, LanguageStat, CardOptions } from "./types";
|
|
13
|
+
|
|
14
|
+
export type CardOpts = CardOptions;
|
|
27
15
|
|
|
28
16
|
const STAT_DEFS = [
|
|
29
17
|
{ key: "stars", iconName: "star" as const, label: "Stars" },
|
|
@@ -58,12 +46,27 @@ function el(
|
|
|
58
46
|
/* ── helpers ───────────────────────────────────────────────── */
|
|
59
47
|
|
|
60
48
|
function raw(token: string): string {
|
|
61
|
-
const
|
|
62
|
-
|
|
49
|
+
const value = resolveTw(token, "fill").match(/fill="([^"]+)"/)?.[1] ?? token;
|
|
50
|
+
const color = parse(value);
|
|
51
|
+
return color
|
|
52
|
+
? color.alpha === undefined || color.alpha === 1
|
|
53
|
+
? formatHex(color)
|
|
54
|
+
: formatHex8(color)
|
|
55
|
+
: "#6b7280";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fitText(text: string, width: number, size: number, weight = 400): string {
|
|
59
|
+
if (measureText(text, size, weight) <= width) return text;
|
|
60
|
+
const chars = Array.from(text);
|
|
61
|
+
while (chars.length && measureText(chars.join("") + "…", size, weight) > width) chars.pop();
|
|
62
|
+
return chars.join("").trimEnd() + "…";
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
// Stable output keeps ETags useful; the gallery supplies a distinct prefix per theme.
|
|
66
|
+
function cardId(value: string): string {
|
|
67
|
+
let hash = 2166136261;
|
|
68
|
+
for (const char of value) hash = Math.imul(hash ^ char.codePointAt(0)!, 16777619);
|
|
69
|
+
return `card-${(hash >>> 0).toString(36)}`;
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
function buildLanguageSegments(
|
|
@@ -88,7 +91,7 @@ function buildLanguageSegments(
|
|
|
88
91
|
visible.push({ name: "Other", color: "#6b7280", size: tinySize });
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
return visible
|
|
94
|
+
return visible;
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
/* ── renderer ──────────────────────────────────────────────── */
|
|
@@ -99,7 +102,9 @@ export function renderCard(
|
|
|
99
102
|
langs: LanguageStat[],
|
|
100
103
|
opts: CardOpts = {},
|
|
101
104
|
): string {
|
|
102
|
-
const
|
|
105
|
+
const namespace = cardId(JSON.stringify([user, stats, langs, opts]));
|
|
106
|
+
const selector = `#${namespace}-root`;
|
|
107
|
+
const hidden = new Set((opts.hide ?? []).map((k) => k.trim().toLowerCase()));
|
|
103
108
|
const c = resolveColors(opts);
|
|
104
109
|
const compact = opts.compact ?? false;
|
|
105
110
|
const animate = opts.animate ?? false;
|
|
@@ -113,21 +118,17 @@ export function renderCard(
|
|
|
113
118
|
const iconColor = raw(c.icon);
|
|
114
119
|
const borderColor = raw(c.border);
|
|
115
120
|
|
|
116
|
-
const fontFamily = FONT_FAMILY;
|
|
117
|
-
|
|
118
121
|
/* ── profile text ─────────────────────────────────────────── */
|
|
119
|
-
const displayName = user.name || user.login;
|
|
120
|
-
const
|
|
121
|
-
const loginEsc = escapeXml(clamp(user.login, 24));
|
|
122
|
-
const pronouns = !compact && user.pronouns ? escapeXml(clamp(user.pronouns, 16)) : "";
|
|
122
|
+
const displayName = user.name?.trim() || user.login;
|
|
123
|
+
const pronouns = !compact && user.pronouns ? user.pronouns : "";
|
|
123
124
|
const bioRaw = !compact && user.bio ? user.bio : "";
|
|
124
|
-
const
|
|
125
|
-
const twitter = !compact && user.twitter ? escapeXml(clamp(user.twitter, 22)) : "";
|
|
125
|
+
const twitterRaw = !compact && user.twitter ? user.twitter.replace(/^@/, "") : "";
|
|
126
126
|
const avatar = user.avatarUrl || "";
|
|
127
|
-
const
|
|
127
|
+
const metaRaw = `@${user.login}${pronouns ? ` · ${pronouns}` : ""}`;
|
|
128
128
|
|
|
129
129
|
const visible = STAT_DEFS.filter((d) => !hidden.has(d.key));
|
|
130
130
|
const activeLangsList = langs.filter((l) => {
|
|
131
|
+
if (!Number.isFinite(l.size) || l.size <= 0) return false;
|
|
131
132
|
const name = l.name.toLowerCase().trim();
|
|
132
133
|
if (showLangs.size > 0 && !showLangs.has(name)) return false;
|
|
133
134
|
if (hideLangs.has(name)) return false;
|
|
@@ -142,17 +143,18 @@ export function renderCard(
|
|
|
142
143
|
const textGap = 16;
|
|
143
144
|
|
|
144
145
|
const nameW = measureText(displayName, 17, 700);
|
|
145
|
-
const metaW = measureText(
|
|
146
|
-
const
|
|
147
|
-
const twitterW = twitter ? measureText(`@${twitter}`, 11.5, 500) : 0;
|
|
148
|
-
const profileTextW = Math.max(nameW, metaW, bioW, twitterW);
|
|
149
|
-
|
|
150
|
-
const profileW = PX + avatarSize + textGap + profileTextW + PX;
|
|
146
|
+
const metaW = measureText(metaRaw, 12.5, 500);
|
|
147
|
+
const profileW = PX + avatarSize + textGap + Math.max(nameW, metaW) + PX;
|
|
151
148
|
const statsW = visible.length > 0 ? visible.length * 84 + PX * 2 : 0;
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
const W = Math.round(Math.min(540, Math.max(340, profileW, statsW, langsW)));
|
|
149
|
+
const W = Math.round(Math.min(540, Math.max(340, profileW, statsW)));
|
|
155
150
|
const contentW = W - PX * 2;
|
|
151
|
+
const profileTextW = contentW - avatarSize - textGap;
|
|
152
|
+
const nameEsc = escapeXml(fitText(displayName, profileTextW, 17, 700));
|
|
153
|
+
const metaStr = escapeXml(fitText(metaRaw, profileTextW, 12.5, 500));
|
|
154
|
+
const bioLines = wrapText(bioRaw, 44, 2).map((line) => fitText(line, profileTextW, 11.5));
|
|
155
|
+
const twitter = escapeXml(
|
|
156
|
+
fitText(twitterRaw ? `@${twitterRaw}` : "", profileTextW - 15, 11.5, 500),
|
|
157
|
+
);
|
|
156
158
|
|
|
157
159
|
/* ── profile section ─────────────────────────────────────── */
|
|
158
160
|
const infoX = PX + avatarSize + textGap;
|
|
@@ -174,38 +176,30 @@ export function renderCard(
|
|
|
174
176
|
/* ── languages section ─────────────────────────────────── */
|
|
175
177
|
const totalSize = activeLangsList.reduce((s, l) => s + l.size, 0) || 1;
|
|
176
178
|
const sorted = [...activeLangsList].sort((a, b) => b.size - a.size);
|
|
177
|
-
|
|
179
|
+
let visualLangs = buildLanguageSegments(sorted, totalSize, contentW);
|
|
180
|
+
// The legend and bar must describe the same groups and colors.
|
|
181
|
+
if (!compact && visualLangs.length > 6) {
|
|
182
|
+
const named = visualLangs.filter((lang) => lang.name !== "Other").slice(0, 5);
|
|
183
|
+
const otherSize = totalSize - named.reduce((sum, lang) => sum + lang.size, 0);
|
|
184
|
+
visualLangs = [...named, { name: "Other", color: "#6b7280", size: otherSize }];
|
|
185
|
+
}
|
|
178
186
|
const barY = statsY + statsH + (statsH ? 18 : 14);
|
|
179
187
|
const barH = 10;
|
|
180
|
-
|
|
181
|
-
const maxLegend = compact ? 0 : 6;
|
|
182
|
-
const legendLangs = visualLangs.slice(0, maxLegend);
|
|
183
|
-
const otherSize = visualLangs.slice(maxLegend).reduce((s, l) => s + l.size, 0);
|
|
184
|
-
|
|
185
188
|
const legendY = barY + barH + 14;
|
|
186
189
|
const legendRowH = 17;
|
|
187
|
-
const legendCols = 3;
|
|
190
|
+
const legendCols = Math.min(3, Math.max(1, Math.floor(contentW / 150)));
|
|
188
191
|
const legendColW = contentW / legendCols;
|
|
189
|
-
const legendItems
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
100
|
|
200
|
-
).toFixed(1);
|
|
201
|
-
} else {
|
|
202
|
-
legendItems.push({
|
|
203
|
-
name: "Other",
|
|
204
|
-
color: "#6b7280",
|
|
205
|
-
pct: ((otherSize / totalSize) * 100).toFixed(1),
|
|
192
|
+
const legendItems = compact
|
|
193
|
+
? []
|
|
194
|
+
: visualLangs.map((lang) => {
|
|
195
|
+
const pct = ((lang.size / totalSize) * 100).toFixed(1);
|
|
196
|
+
const suffixW = measureText(` ${pct}%`, 11, 500);
|
|
197
|
+
return {
|
|
198
|
+
name: fitText(lang.name, legendColW - 20 - suffixW, 11, 500),
|
|
199
|
+
color: raw(lang.color),
|
|
200
|
+
pct,
|
|
201
|
+
};
|
|
206
202
|
});
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
203
|
const legendRows = compact ? 0 : Math.ceil(legendItems.length / legendCols);
|
|
210
204
|
|
|
211
205
|
const hasLangs = activeLangsList.length > 0;
|
|
@@ -217,6 +211,7 @@ export function renderCard(
|
|
|
217
211
|
/* ── accessibility ────────────────────────────────────────── */
|
|
218
212
|
const parts: string[] = [];
|
|
219
213
|
const svgAttrs: Record<string, string | number> = {
|
|
214
|
+
id: "root",
|
|
220
215
|
width: W,
|
|
221
216
|
height: H,
|
|
222
217
|
viewBox: `0 0 ${W} ${H}`,
|
|
@@ -233,40 +228,51 @@ export function renderCard(
|
|
|
233
228
|
el(
|
|
234
229
|
"desc",
|
|
235
230
|
{ id: "cardDesc" },
|
|
236
|
-
|
|
231
|
+
escapeXml(
|
|
232
|
+
`GitHub profile card for ${displayName}. ${visible
|
|
233
|
+
.map(({ key, label }) => {
|
|
234
|
+
const value = stats[key];
|
|
235
|
+
return value === null ? `${label}: unavailable` : `${label}: ${kFormat(value)}`;
|
|
236
|
+
})
|
|
237
|
+
.join(", ")}. ${activeLangsList.length} highlighted languages.`,
|
|
238
|
+
),
|
|
237
239
|
),
|
|
238
240
|
);
|
|
239
241
|
|
|
240
242
|
/* ── styles ──────────────────────────────────────────────── */
|
|
241
243
|
const css: string[] = [];
|
|
242
244
|
css.push(
|
|
243
|
-
|
|
245
|
+
`${selector} { font-family: ${FONT_FAMILY}; text-rendering: geometricPrecision; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }`,
|
|
244
246
|
);
|
|
245
247
|
css.push(
|
|
246
|
-
|
|
248
|
+
`${selector} .header { font-size: 17px; font-weight: 700; fill: ${titleColor}; letter-spacing: -0.015em; }`,
|
|
247
249
|
);
|
|
248
|
-
css.push(
|
|
249
|
-
css.push(
|
|
250
|
-
css.push(`.lang-name { font-size: 11px; fill: ${textColor}; opacity: 0.82; }`);
|
|
251
|
-
css.push(`.lang-progress { fill: ${textColor}; opacity: 0.07; }`);
|
|
252
|
-
css.push(`.meta { font-size: 12.5px; font-weight: 500; fill: ${textColor}; opacity: 0.72; }`);
|
|
253
|
-
css.push(`.bio { font-size: 11.5px; font-weight: 400; fill: ${textColor}; opacity: 0.68; }`);
|
|
250
|
+
css.push(`${selector} .lang-name { font-size: 11px; fill: ${textColor}; opacity: 0.82; }`);
|
|
251
|
+
css.push(`${selector} .lang-progress { fill: ${textColor}; opacity: 0.07; }`);
|
|
254
252
|
css.push(
|
|
255
|
-
|
|
253
|
+
`${selector} .meta { font-size: 12.5px; font-weight: 500; fill: ${textColor}; opacity: 0.72; }`,
|
|
256
254
|
);
|
|
257
255
|
css.push(
|
|
258
|
-
|
|
256
|
+
`${selector} .bio { font-size: 11.5px; font-weight: 400; fill: ${textColor}; opacity: 0.68; }`,
|
|
259
257
|
);
|
|
260
258
|
css.push(
|
|
261
|
-
|
|
259
|
+
`${selector} .stat-label { font-size: 10px; font-weight: 700; fill: ${textColor}; opacity: 0.68; letter-spacing: 0.06em; }`,
|
|
260
|
+
);
|
|
261
|
+
css.push(
|
|
262
|
+
`${selector} .stat-value { font-size: 16px; font-weight: 700; fill: ${textColor}; letter-spacing: -0.015em; font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; }`,
|
|
263
|
+
);
|
|
264
|
+
css.push(
|
|
265
|
+
`${selector} .twitter-text { font-size: 11.5px; font-weight: 500; fill: ${textColor}; opacity: 0.65; }`,
|
|
262
266
|
);
|
|
263
267
|
|
|
264
268
|
if (animate) {
|
|
265
|
-
css.push(
|
|
266
|
-
css.push(
|
|
269
|
+
css.push(`@keyframes ${namespace}-fade { from { opacity: 0; } to { opacity: 1; } }`);
|
|
270
|
+
css.push(
|
|
271
|
+
`${selector} .stagger { opacity: 0; animation: ${namespace}-fade 0.3s ease-in-out forwards; }`,
|
|
272
|
+
);
|
|
267
273
|
}
|
|
268
274
|
|
|
269
|
-
parts.push(el("style", {}, css.join(" ")));
|
|
275
|
+
parts.push(el("style", {}, escapeXml(css.join(" "))));
|
|
270
276
|
|
|
271
277
|
/* ── defs ────────────────────────────────────────────────── */
|
|
272
278
|
parts.push(
|
|
@@ -398,8 +404,7 @@ export function renderCard(
|
|
|
398
404
|
el(
|
|
399
405
|
"g",
|
|
400
406
|
{ transform: `translate(${infoX},${twitterY - 9})` },
|
|
401
|
-
icon("x", textColor, 11) +
|
|
402
|
-
el("text", { x: 15, y: 9, class: "twitter-text" }, `@${twitter}`),
|
|
407
|
+
icon("x", textColor, 11) + el("text", { x: 15, y: 9, class: "twitter-text" }, twitter),
|
|
403
408
|
),
|
|
404
409
|
);
|
|
405
410
|
}
|
|
@@ -411,28 +416,36 @@ export function renderCard(
|
|
|
411
416
|
|
|
412
417
|
for (let i = 0; i < visible.length; i++) {
|
|
413
418
|
const d = visible[i]!;
|
|
414
|
-
const val = stats[d.key
|
|
419
|
+
const val = stats[d.key];
|
|
415
420
|
const cx = PX + statW * i + statW / 2;
|
|
416
421
|
const iy = statsY + 4;
|
|
417
422
|
|
|
418
|
-
const staggerClass = animate ? " stagger" : "";
|
|
419
|
-
|
|
420
423
|
// Vertical stack: icon → value → label, all centered, with airy gaps.
|
|
421
424
|
const iconSize = 15;
|
|
422
425
|
const iconY = 2;
|
|
423
426
|
const valueY = 37;
|
|
424
427
|
const labelY = 52;
|
|
425
428
|
|
|
426
|
-
const cellParts: string[] = [
|
|
429
|
+
const cellParts: string[] = [
|
|
430
|
+
el(
|
|
431
|
+
"title",
|
|
432
|
+
{},
|
|
433
|
+
val === null ? `${d.label}: unavailable` : `${d.label}: ${val.toLocaleString("en-US")}`,
|
|
434
|
+
),
|
|
435
|
+
];
|
|
427
436
|
cellParts.push(
|
|
428
437
|
el(
|
|
429
438
|
"g",
|
|
430
|
-
{ transform: `translate(${-iconSize / 2},${iconY})
|
|
439
|
+
{ transform: `translate(${-iconSize / 2},${iconY})` },
|
|
431
440
|
icon(d.iconName, iconColor, iconSize),
|
|
432
441
|
),
|
|
433
442
|
);
|
|
434
443
|
cellParts.push(
|
|
435
|
-
el(
|
|
444
|
+
el(
|
|
445
|
+
"text",
|
|
446
|
+
{ x: 0, y: valueY, class: "stat-value", "text-anchor": "middle" },
|
|
447
|
+
val === null ? "—" : kFormat(val),
|
|
448
|
+
),
|
|
436
449
|
);
|
|
437
450
|
cellParts.push(
|
|
438
451
|
el(
|
|
@@ -447,7 +460,7 @@ export function renderCard(
|
|
|
447
460
|
"g",
|
|
448
461
|
{
|
|
449
462
|
transform: `translate(${cx},${iy})`,
|
|
450
|
-
class:
|
|
463
|
+
class: animate ? "stagger" : undefined,
|
|
451
464
|
style: animate ? `animation-delay: ${(i + 3) * 150}ms` : undefined,
|
|
452
465
|
},
|
|
453
466
|
cellParts.join(""),
|
|
@@ -524,7 +537,9 @@ export function renderCard(
|
|
|
524
537
|
if (w < 0) w = 0;
|
|
525
538
|
const x = PX + off;
|
|
526
539
|
const tooltip = compact ? "" : el("title", {}, `${escapeXml(lang.name)} ${pct}%`);
|
|
527
|
-
barParts.push(
|
|
540
|
+
barParts.push(
|
|
541
|
+
el("rect", { x, y: barY, width: w, height: barH, fill: raw(lang.color) }, tooltip),
|
|
542
|
+
);
|
|
528
543
|
off += w;
|
|
529
544
|
}
|
|
530
545
|
langParts.push(el("g", { "clip-path": "url(#lang-clip)" }, barParts.join("")));
|
|
@@ -557,5 +572,12 @@ export function renderCard(
|
|
|
557
572
|
}
|
|
558
573
|
|
|
559
574
|
parts.push("</svg>");
|
|
560
|
-
return parts
|
|
575
|
+
return parts
|
|
576
|
+
.join("")
|
|
577
|
+
.replace(/\bid="([^"]+)"/g, (_, id: string) => `id="${namespace}-${id}"`)
|
|
578
|
+
.replace(/="url\(#([^)]+)\)"/g, (_, id: string) => `="url(#${namespace}-${id})"`)
|
|
579
|
+
.replace(
|
|
580
|
+
'aria-labelledby="cardTitle cardDesc"',
|
|
581
|
+
`aria-labelledby="${namespace}-cardTitle ${namespace}-cardDesc"`,
|
|
582
|
+
);
|
|
561
583
|
}
|
package/src/gallery.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { renderCard, type CardOpts } from "./card";
|
|
2
|
+
import type { LanguageStat, UserProfile, UserStats } from "./types";
|
|
3
|
+
import { escapeXml, themes } from "./utils";
|
|
4
|
+
|
|
5
|
+
/** Inline SVG cards so the preview also works through GitHub's image proxy. */
|
|
6
|
+
export function renderThemeGallery(
|
|
7
|
+
user: UserProfile,
|
|
8
|
+
stats: UserStats,
|
|
9
|
+
languages: LanguageStat[],
|
|
10
|
+
options: CardOpts = {},
|
|
11
|
+
): string {
|
|
12
|
+
const cards = Object.keys(themes).map((theme) => {
|
|
13
|
+
const svg = renderCard(user, stats, languages, { ...options, theme, idPrefix: theme });
|
|
14
|
+
// Dimensions come from our renderer, not user-supplied SVG.
|
|
15
|
+
const [, width, height] = svg.match(/^<svg\b[^>]*\bviewBox="0 0 (\d+) (\d+)"/) ?? [];
|
|
16
|
+
if (!width || !height) throw new Error("Card renderer returned an invalid viewBox");
|
|
17
|
+
return { theme, svg, width: Number(width), height: Number(height) };
|
|
18
|
+
});
|
|
19
|
+
const columns = 2;
|
|
20
|
+
const gap = 20;
|
|
21
|
+
const labelHeight = 22;
|
|
22
|
+
const cardWidth = Math.max(...cards.map((card) => card.width));
|
|
23
|
+
const cellHeight = Math.max(...cards.map((card) => card.height)) + labelHeight;
|
|
24
|
+
const rows = Math.ceil(cards.length / columns);
|
|
25
|
+
const width = columns * cardWidth + (columns + 1) * gap;
|
|
26
|
+
const height = rows * cellHeight + (rows + 1) * gap;
|
|
27
|
+
|
|
28
|
+
const blocks = cards.map(({ theme, svg }, index) => {
|
|
29
|
+
const x = gap + (index % columns) * (cardWidth + gap);
|
|
30
|
+
const y = gap + Math.floor(index / columns) * (cellHeight + gap) + labelHeight;
|
|
31
|
+
const card = svg.replace(/^<svg\b/, `<svg x="${x}" y="${y}"`);
|
|
32
|
+
const label = `<text x="${x + 4}" y="${y - 8}" fill="#e2e8f0" font-size="13" font-family="system-ui,sans-serif" font-weight="700">${escapeXml(theme)}</text>`;
|
|
33
|
+
return label + card;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
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"><rect width="100%" height="100%" fill="#0b1220"/>${blocks.join("")}</svg>`;
|
|
37
|
+
}
|