@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/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
- prs: number;
13
- issues: number;
14
- commits: number;
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
  }
@@ -1,6 +1,5 @@
1
- export { resolveColors, themes, type Theme } from "./themes";
1
+ export { resolveColors, themes } from "./themes";
2
2
  export { kFormat, escapeXml, wrapText, measureText } from "./format";
3
- export { getLangColor } from "./languages";
4
- export { icons, icon } from "./icons";
5
- export { FONT_FACE, FONT_FAMILY } from "./font";
6
- export * from "./colors";
3
+ export { icon } from "./icons";
4
+ export { FONT_FAMILY } from "./font";
5
+ export { resolveTw } from "./colors";
@@ -1,2 +1 @@
1
- export declare const langColors: Record<string, string>;
2
1
  export declare function getLangColor(lang: string): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shiinasaku/github-card",
3
- "version": "4.2.0",
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": "oxfmt",
60
- "fmt:check": "oxfmt --check",
61
- "lint": "oxlint",
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": "^1.4.2",
69
- "@elysia/openapi": "^1.4.15",
70
- "@elysia/server-timing": "^1.4.1",
71
- "elysia": "1.4.29"
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
- "@tailwindcss/cli": "^4.3.3",
75
- "@types/bun": "^1.4.0",
76
- "oxfmt": "0.64.0",
77
- "oxlint": "1.79.0",
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.0"
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 m = resolveTw(token, "fill").match(/fill="([^"]+)"/);
62
- return m?.[1] ?? token;
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
- function clamp(s: string, n: number): string {
66
- return s.length <= n ? s : s.slice(0, n - 1).trimEnd() + "\u2026";
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.length > 0 ? visible : langs;
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 hidden = new Set((opts.hide ?? []).map((k) => k.toLowerCase()));
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 nameEsc = escapeXml(clamp(displayName, 28));
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 bioLines = bioRaw ? wrapText(bioRaw, 44, 2) : [];
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 metaStr = pronouns ? `@${loginEsc} \u00b7 ${pronouns}` : `@${loginEsc}`;
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(metaStr, 12.5, 500);
146
- const bioW = bioLines.length ? Math.max(...bioLines.map((l) => measureText(l, 11.5, 400))) : 0;
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 langsW = activeLangsList.length > 0 ? 300 : 0;
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
- const visualLangs = buildLanguageSegments(sorted, totalSize, contentW);
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: { name: string; color: string; pct: string }[] = legendLangs.map((l) => ({
190
- name: clamp(l.name, 14),
191
- color: l.color,
192
- pct: ((l.size / totalSize) * 100).toFixed(1),
193
- }));
194
- if (otherSize > 0 && legendItems.length > 0) {
195
- const existingOther = legendItems.find((item) => item.name === "Other");
196
- if (existingOther) {
197
- existingOther.pct = (
198
- (((Number(existingOther.pct) / 100) * totalSize + otherSize) / totalSize) *
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
- `GitHub profile card for ${escapeXml(displayName)} with ${kFormat(stats.stars)} stars, ${kFormat(stats.commits)} commits, and ${activeLangsList.length} highlighted languages.`,
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
- `* { font-family: ${fontFamily}; text-rendering: geometricPrecision; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }`,
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
- `.header { font-size: 17px; font-weight: 700; fill: ${titleColor}; letter-spacing: -0.015em; }`,
248
+ `${selector} .header { font-size: 17px; font-weight: 700; fill: ${titleColor}; letter-spacing: -0.015em; }`,
247
249
  );
248
- css.push(`.stat { font-size: 14px; font-weight: 600; fill: ${textColor}; }`);
249
- css.push(`.icon { fill: ${iconColor}; }`);
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
- `.stat-label { font-size: 10px; font-weight: 700; fill: ${textColor}; opacity: 0.68; letter-spacing: 0.06em; }`,
253
+ `${selector} .meta { font-size: 12.5px; font-weight: 500; fill: ${textColor}; opacity: 0.72; }`,
256
254
  );
257
255
  css.push(
258
- `.stat-value { font-size: 16px; font-weight: 700; fill: ${textColor}; letter-spacing: -0.015em; font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; }`,
256
+ `${selector} .bio { font-size: 11.5px; font-weight: 400; fill: ${textColor}; opacity: 0.68; }`,
259
257
  );
260
258
  css.push(
261
- `.twitter-text { font-size: 11.5px; font-weight: 500; fill: ${textColor}; opacity: 0.65; }`,
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("@keyframes fadeInAnimation { from { opacity: 0; } to { opacity: 1; } }");
266
- css.push(".stagger { opacity: 0; animation: fadeInAnimation 0.3s ease-in-out forwards; }");
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 as keyof UserStats];
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})`, class: "stat-icon" },
439
+ { transform: `translate(${-iconSize / 2},${iconY})` },
431
440
  icon(d.iconName, iconColor, iconSize),
432
441
  ),
433
442
  );
434
443
  cellParts.push(
435
- el("text", { x: 0, y: valueY, class: "stat-value", "text-anchor": "middle" }, kFormat(val)),
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: `stat${staggerClass}`,
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(el("rect", { x, y: barY, width: w, height: barH, fill: lang.color }, tooltip));
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.join("");
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
+ }