@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/src/card.ts CHANGED
@@ -1,4 +1,12 @@
1
- import { resolveColors, kFormat, escapeXml, icon, wrapText, FONT_FACE, FONT_FAMILY } from "./utils";
1
+ import {
2
+ resolveColors,
3
+ kFormat,
4
+ escapeXml,
5
+ icon,
6
+ wrapText,
7
+ FONT_FAMILY,
8
+ resolveTw,
9
+ } from "./utils/index";
2
10
  import type { UserProfile, UserStats, LanguageStat } from "./types";
3
11
 
4
12
  export type CardOpts = {
@@ -11,127 +19,640 @@ export type CardOpts = {
11
19
  hide_border?: boolean;
12
20
  compact?: boolean;
13
21
  hide?: string[];
22
+ hide_langs?: string[];
23
+ show_langs?: string[];
24
+ animate?: boolean;
14
25
  };
15
26
 
27
+ const STAT_DEFS = [
28
+ { key: "stars", iconName: "star" as const, label: "Stars" },
29
+ { key: "commits", iconName: "commit" as const, label: "Commits" },
30
+ { key: "issues", iconName: "issue" as const, label: "Issues" },
31
+ { key: "repos", iconName: "repo" as const, label: "Repos" },
32
+ { key: "prs", iconName: "pr" as const, label: "PRs" },
33
+ ] as const;
34
+
35
+ type LangVisual = {
36
+ name: string;
37
+ color: string;
38
+ size: number;
39
+ };
40
+
41
+ /* ── helpers ───────────────────────────────────────────────── */
42
+
43
+ function raw(token: string): string {
44
+ const m = resolveTw(token, "fill").match(/fill="([^"]+)"/);
45
+ return m?.[1] ?? token;
46
+ }
47
+
48
+ function clamp(s: string, n: number): string {
49
+ return s.length <= n ? s : s.slice(0, n - 1).trimEnd() + "\u2026";
50
+ }
51
+
52
+ function buildLanguageSegments(
53
+ langs: LanguageStat[],
54
+ totalSize: number,
55
+ contentW: number,
56
+ ): LangVisual[] {
57
+ const minSegmentW = 6;
58
+ const visible: LangVisual[] = [];
59
+ let tinySize = 0;
60
+
61
+ for (const lang of langs) {
62
+ const segmentW = (lang.size / totalSize) * contentW;
63
+ if (segmentW < minSegmentW) {
64
+ tinySize += lang.size;
65
+ continue;
66
+ }
67
+
68
+ visible.push(lang);
69
+ }
70
+
71
+ if (tinySize > 0) {
72
+ visible.push({ name: "Other", color: "#6b7280", size: tinySize });
73
+ }
74
+
75
+ return visible.length > 0 ? visible : langs;
76
+ }
77
+
78
+ /* ── renderer ──────────────────────────────────────────────── */
79
+
16
80
  export function renderCard(
17
81
  user: UserProfile,
18
82
  stats: UserStats,
19
83
  langs: LanguageStat[],
20
84
  opts: CardOpts = {},
21
85
  ): string {
86
+ const hidden = new Set((opts.hide ?? []).map((k) => k.toLowerCase()));
22
87
  const c = resolveColors(opts);
23
- const hideBorder = opts.hide_border ?? false;
24
88
  const compact = opts.compact ?? false;
89
+ const animate = opts.animate ?? false;
90
+ const hideBorder = opts.hide_border ?? false;
91
+ const hideLangs = new Set((opts.hide_langs ?? []).map((k) => k.toLowerCase().trim()));
92
+ const showLangs = new Set((opts.show_langs ?? []).map((k) => k.toLowerCase().trim()));
93
+
94
+ const bgColor = raw(c.bg);
95
+ const titleColor = raw(c.title);
96
+ const textColor = raw(c.text);
97
+ const iconColor = raw(c.icon);
98
+ const borderColor = raw(c.border);
99
+
100
+ /* ── font (XML-safe) ──────────────────────────────────────── */
101
+ const fontFamily = FONT_FAMILY.replace(/"/g, "&apos;");
102
+
103
+ /* ── profile text setup ───────────────────────────────────── */
104
+ const displayName = user.name || user.login;
105
+ const nameEsc = escapeXml(clamp(displayName, 28));
106
+ const loginEsc = escapeXml(clamp(user.login, 24));
107
+ const pronouns = !compact && user.pronouns ? escapeXml(clamp(user.pronouns, 16)) : "";
108
+ const bioRaw = !compact && user.bio ? user.bio : "";
109
+ const bioLines = bioRaw ? wrapText(bioRaw, 44, 2) : [];
110
+ const twitter = !compact && user.twitter ? escapeXml(clamp(user.twitter, 22)) : "";
111
+ const avatar = (user.avatarUrl || "").replace(/&/g, "&amp;");
112
+
113
+ const visible = STAT_DEFS.filter((d) => !hidden.has(d.key));
114
+ const activeLangsList = langs.filter((l) => {
115
+ const name = l.name.toLowerCase().trim();
116
+ if (showLangs.size > 0 && !showLangs.has(name)) return false;
117
+ if (hideLangs.has(name)) return false;
118
+ return true;
119
+ });
120
+
121
+ /* ── dimensions (dynamic width) ───────────────────────────── */
122
+ const PX = 25; // horizontal padding
123
+ const PY = 25; // top padding
124
+ const avatarSize = 64;
125
+ const avatarR = avatarSize / 2;
126
+
127
+ // Estimate text width
128
+ const metaLen = user.login.length + (pronouns ? pronouns.length + 3 : 0);
129
+ const maxBioLen = bioLines.length ? Math.max(...bioLines.map((l) => l.length)) : 0;
130
+ const profileTextW = Math.max(
131
+ displayName.length * 11, // ~11px per bold char
132
+ metaLen * 7.5, // ~7.5px per medium char
133
+ maxBioLen * 6.5, // ~6.5px per small char
134
+ twitter.length * 7 + 20,
135
+ );
136
+
137
+ const profileW = PX + avatarSize + 16 + profileTextW + PX;
138
+ const statsW = visible.length > 0 ? visible.length * 85 + PX * 2 : 0;
139
+ const langsW = activeLangsList.length > 0 ? 300 : 0; // sensible minimum for languages
140
+
141
+ const W = Math.round(Math.min(540, Math.max(340, profileW, statsW, langsW)));
142
+ const contentW = W - PX * 2;
143
+
144
+ /* ── profile section ──────────────────────────────────────── */
145
+ const infoX = PX + avatarSize + 14;
146
+ const nameY = PY + 22;
147
+ const loginY = nameY + 18;
148
+ const bioStartY = loginY + 16;
149
+ const twitterY = bioStartY + bioLines.length * 14 + (bioLines.length ? 4 : 0);
150
+
151
+ let profileH = loginY + 10 - PY;
152
+ if (bioLines.length) profileH = bioStartY + bioLines.length * 14 - PY;
153
+ if (twitter) profileH = twitterY + 10 - PY;
154
+ profileH = Math.max(profileH, avatarSize + 4);
155
+
156
+ /* ── stats section ────────────────────────────────────────── */
157
+ const statsY = PY + profileH + 14;
158
+ const statW = visible.length > 0 ? contentW / visible.length : 0;
159
+ const statsH = visible.length > 0 ? 50 : 0;
160
+
161
+ /* ── languages section ────────────────────────────────────── */
162
+ const totalSize = activeLangsList.reduce((s, l) => s + l.size, 0) || 1;
163
+ const sorted = [...activeLangsList].sort((a, b) => b.size - a.size);
164
+ const visualLangs = buildLanguageSegments(sorted, totalSize, contentW);
165
+ const barY = statsY + statsH + (statsH ? 16 : 12);
166
+ const barH = 10;
167
+
168
+ const maxLegend = compact ? 0 : 6;
169
+ const legendLangs = visualLangs.slice(0, maxLegend);
170
+ const otherSize = visualLangs.slice(maxLegend).reduce((s, l) => s + l.size, 0);
171
+
172
+ const legendY = barY + barH + 12;
173
+ const legendRowH = 16;
174
+ const legendCols = 3;
175
+ const legendColW = contentW / legendCols;
176
+ const legendItems: { name: string; color: string; pct: string }[] = legendLangs.map((l) => ({
177
+ name: clamp(l.name, 14),
178
+ color: l.color,
179
+ pct: ((l.size / totalSize) * 100).toFixed(1),
180
+ }));
181
+ if (otherSize > 0 && legendItems.length > 0) {
182
+ const existingOther = legendItems.find((item) => item.name === "Other");
183
+ if (existingOther) {
184
+ existingOther.pct = (
185
+ (((Number(existingOther.pct) / 100) * totalSize + otherSize) / totalSize) *
186
+ 100
187
+ ).toFixed(1);
188
+ } else {
189
+ legendItems.push({
190
+ name: "Other",
191
+ color: "#6b7280",
192
+ pct: ((otherSize / totalSize) * 100).toFixed(1),
193
+ });
194
+ }
195
+ }
196
+ const legendRows = compact ? 0 : Math.ceil(legendItems.length / legendCols);
197
+
198
+ const hasLangs = activeLangsList.length > 0;
199
+ const langSectionH = hasLangs ? barH + 12 + legendRows * legendRowH + 8 : 0;
200
+
201
+ /* ── final height ─────────────────────────────────────────── */
202
+ const H = barY + (hasLangs ? langSectionH : 0) + 12 + (hasLangs ? 0 : 4);
203
+
204
+ /* ── build SVG ────────────────────────────────────────────── */
205
+ const parts: string[] = [];
206
+
207
+ // root
208
+ parts.push(
209
+ '<svg width="' +
210
+ W +
211
+ '" height="' +
212
+ H +
213
+ '" viewBox="0 0 ' +
214
+ W +
215
+ " " +
216
+ H +
217
+ '" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="cardTitle cardDesc">',
218
+ );
219
+
220
+ // accessibility
221
+ parts.push('<title id="cardTitle">' + escapeXml(displayName + "'s GitHub Stats") + "</title>");
222
+ parts.push(
223
+ '<desc id="cardDesc">GitHub profile card for ' +
224
+ escapeXml(displayName) +
225
+ " with " +
226
+ kFormat(stats.stars) +
227
+ " stars, " +
228
+ kFormat(stats.commits) +
229
+ " commits, and " +
230
+ activeLangsList.length +
231
+ " highlighted languages.</desc>",
232
+ );
233
+
234
+ // ── <style> block ──────────────────────────────────────────
235
+ const cssLines: string[] = [];
236
+ cssLines.push("* { font-family: " + fontFamily + "; }");
237
+ cssLines.push(".header { font-size: 18px; font-weight: 700; fill: " + titleColor + "; }");
238
+ cssLines.push(".stat { font-size: 14px; font-weight: 600; fill: " + textColor + "; }");
239
+ cssLines.push(".bold { font-weight: 700; }");
240
+ cssLines.push(".icon { fill: " + iconColor + "; }");
241
+ cssLines.push(".lang-name { font-size: 11px; fill: " + textColor + "; opacity: 0.8; }");
242
+ cssLines.push(".lang-progress { fill: " + textColor + "; opacity: 0.08; }");
243
+ cssLines.push(
244
+ ".meta { font-size: 12px; font-weight: 500; fill: " + textColor + "; opacity: 0.7; }",
245
+ );
246
+ cssLines.push(
247
+ ".bio { font-size: 11px; font-weight: 400; fill: " + textColor + "; opacity: 0.68; }",
248
+ );
249
+ cssLines.push(
250
+ ".stat-label { font-size: 9px; font-weight: 600; fill: " +
251
+ textColor +
252
+ "; opacity: 0.5; letter-spacing: 0.08em; }",
253
+ );
254
+ cssLines.push(".stat-value { font-size: 15px; font-weight: 800; fill: " + textColor + "; }");
255
+ cssLines.push(
256
+ ".twitter-text { font-size: 11px; font-weight: 500; fill: " + textColor + "; opacity: 0.65; }",
257
+ );
258
+
259
+ if (animate) {
260
+ cssLines.push("@keyframes fadeInAnimation { from { opacity: 0; } to { opacity: 1; } }");
261
+ cssLines.push(".stagger { opacity: 0; animation: fadeInAnimation 0.3s ease-in-out forwards; }");
262
+ }
263
+
264
+ parts.push("<style>" + cssLines.join(" ") + "</style>");
265
+
266
+ // ── <defs> ─────────────────────────────────────────────────
267
+ parts.push("<defs>");
268
+ parts.push(
269
+ '<clipPath id="av"><circle cx="' +
270
+ (PX + avatarR) +
271
+ '" cy="' +
272
+ (PY + avatarR) +
273
+ '" r="' +
274
+ avatarR +
275
+ '"/></clipPath>',
276
+ );
277
+ parts.push(
278
+ '<radialGradient id="gl" cx="12%" cy="15%" r="65%">' +
279
+ '<stop offset="0%" stop-color="' +
280
+ iconColor +
281
+ '" stop-opacity="0.08"/>' +
282
+ '<stop offset="100%" stop-color="' +
283
+ iconColor +
284
+ '" stop-opacity="0"/>' +
285
+ "</radialGradient>",
286
+ );
287
+ parts.push(
288
+ '<radialGradient id="gl2" cx="92%" cy="95%" r="55%">' +
289
+ '<stop offset="0%" stop-color="' +
290
+ titleColor +
291
+ '" stop-opacity="0.035"/>' +
292
+ '<stop offset="100%" stop-color="' +
293
+ titleColor +
294
+ '" stop-opacity="0"/>' +
295
+ "</radialGradient>",
296
+ );
297
+ parts.push(
298
+ '<linearGradient id="ring" x1="0" y1="0" x2="1" y2="1">' +
299
+ '<stop offset="0%" stop-color="' +
300
+ titleColor +
301
+ '"/>' +
302
+ '<stop offset="100%" stop-color="' +
303
+ iconColor +
304
+ '"/>' +
305
+ "</linearGradient>",
306
+ );
307
+ parts.push(
308
+ '<linearGradient id="card-bg" x1="0" y1="0" x2="' +
309
+ W +
310
+ '" y2="' +
311
+ H +
312
+ '" gradientUnits="userSpaceOnUse">' +
313
+ '<stop offset="0%" stop-color="' +
314
+ bgColor +
315
+ '"/>' +
316
+ '<stop offset="82%" stop-color="' +
317
+ bgColor +
318
+ '"/>' +
319
+ '<stop offset="100%" stop-color="' +
320
+ titleColor +
321
+ '" stop-opacity="0.04"/>' +
322
+ "</linearGradient>",
323
+ );
324
+ parts.push(
325
+ '<linearGradient id="rim" x1="0" y1="0" x2="' +
326
+ W +
327
+ '" y2="0" gradientUnits="userSpaceOnUse">' +
328
+ '<stop offset="0%" stop-color="' +
329
+ iconColor +
330
+ '" stop-opacity="0"/>' +
331
+ '<stop offset="45%" stop-color="' +
332
+ iconColor +
333
+ '" stop-opacity="0.28"/>' +
334
+ '<stop offset="100%" stop-color="' +
335
+ iconColor +
336
+ '" stop-opacity="0"/>' +
337
+ "</linearGradient>",
338
+ );
339
+ parts.push("</defs>");
340
+
341
+ // ── background rect (inset 0.5px for clean border) ─────────
342
+ parts.push(
343
+ '<rect x="0.5" y="0.5" width="' +
344
+ (W - 1) +
345
+ '" height="' +
346
+ (H - 1) +
347
+ '" rx="10" fill="' +
348
+ "url(#card-bg)" +
349
+ '" stroke-opacity="1"/>',
350
+ );
351
+
352
+ // glow overlays (icon tint top-left, title tint bottom-right)
353
+ parts.push(
354
+ '<rect x="0.5" y="0.5" width="' +
355
+ (W - 1) +
356
+ '" height="' +
357
+ (H - 1) +
358
+ '" rx="10" fill="url(#gl)"/>',
359
+ );
360
+ parts.push(
361
+ '<rect x="0.5" y="0.5" width="' +
362
+ (W - 1) +
363
+ '" height="' +
364
+ (H - 1) +
365
+ '" rx="10" fill="url(#gl2)"/>',
366
+ );
367
+ parts.push(
368
+ '<line x1="' +
369
+ PX +
370
+ '" y1="1.5" x2="' +
371
+ (W - PX) +
372
+ '" y2="1.5" stroke="url(#rim)" stroke-width="1"/>',
373
+ );
374
+
375
+ // border
376
+ if (!hideBorder) {
377
+ parts.push(
378
+ '<rect x="0.5" y="0.5" width="' +
379
+ (W - 1) +
380
+ '" height="' +
381
+ (H - 1) +
382
+ '" rx="10" fill="none" stroke="' +
383
+ borderColor +
384
+ '" stroke-opacity="0.5"/>',
385
+ );
386
+ }
387
+
388
+ // ── avatar ─────────────────────────────────────────────────
389
+ parts.push(
390
+ '<g transform="translate(0,0)">' +
391
+ '<circle cx="' +
392
+ (PX + avatarR + 1) +
393
+ '" cy="' +
394
+ (PY + avatarR + 1) +
395
+ '" r="' +
396
+ (avatarR + 3) +
397
+ '" fill="' +
398
+ titleColor +
399
+ '" opacity="0.08"/>' +
400
+ '<image href="' +
401
+ avatar +
402
+ '" x="' +
403
+ PX +
404
+ '" y="' +
405
+ PY +
406
+ '" width="' +
407
+ avatarSize +
408
+ '" height="' +
409
+ avatarSize +
410
+ '" clip-path="url(#av)" preserveAspectRatio="xMidYMid slice"/>' +
411
+ '<circle cx="' +
412
+ (PX + avatarR) +
413
+ '" cy="' +
414
+ (PY + avatarR) +
415
+ '" r="' +
416
+ (avatarR + 1.5) +
417
+ '" fill="none" stroke="url(#ring)" stroke-opacity="0.55" stroke-width="1.5"/>' +
418
+ "</g>",
419
+ );
420
+
421
+ // ── profile text ───────────────────────────────────────────
422
+ parts.push('<g transform="translate(0,0)">');
423
+
424
+ // name
425
+ parts.push('<text x="' + infoX + '" y="' + nameY + '" class="header">' + nameEsc + "</text>");
426
+
427
+ // username + pronouns
428
+ const metaStr = pronouns ? "@" + loginEsc + " \u00b7 " + pronouns : "@" + loginEsc;
429
+ parts.push('<text x="' + infoX + '" y="' + loginY + '" class="meta">' + metaStr + "</text>");
430
+
431
+ // bio
432
+ for (let i = 0; i < bioLines.length; i++) {
433
+ parts.push(
434
+ '<text x="' +
435
+ infoX +
436
+ '" y="' +
437
+ (bioStartY + i * 14) +
438
+ '" class="bio">' +
439
+ escapeXml(bioLines[i]!.trim()) +
440
+ "</text>",
441
+ );
442
+ }
443
+
444
+ // twitter
445
+ if (twitter) {
446
+ parts.push(
447
+ '<g transform="translate(' +
448
+ infoX +
449
+ "," +
450
+ (twitterY - 9) +
451
+ ')">' +
452
+ icon("x", textColor, 11) +
453
+ '<text x="15" y="9" class="twitter-text">@' +
454
+ twitter +
455
+ "</text></g>",
456
+ );
457
+ }
458
+
459
+ parts.push("</g>");
460
+
461
+ // ── stats row ──────────────────────────────────────────────
462
+ if (visible.length > 0) {
463
+ parts.push(
464
+ '<rect x="' +
465
+ PX +
466
+ '" y="' +
467
+ (statsY - 8) +
468
+ '" width="' +
469
+ contentW +
470
+ '" height="' +
471
+ (statsH + 8) +
472
+ '" rx="8" fill="' +
473
+ textColor +
474
+ '" opacity="0.035"/>',
475
+ );
476
+
477
+ parts.push('<g transform="translate(0,0)">');
478
+
479
+ for (let i = 0; i < visible.length; i++) {
480
+ const d = visible[i]!;
481
+ const val = stats[d.key as keyof UserStats];
482
+ const cx = PX + statW * i + statW / 2;
483
+ const cellX = PX + statW * i + 4;
484
+ const iy = statsY + 2;
485
+
486
+ const staggerStyle = animate ? ' style="animation-delay: ' + (i + 3) * 150 + 'ms"' : "";
487
+ const staggerClass = animate ? " stagger" : "";
488
+
489
+ parts.push(
490
+ '<rect x="' +
491
+ cellX +
492
+ '" y="' +
493
+ (statsY - 4) +
494
+ '" width="' +
495
+ Math.max(0, statW - 8) +
496
+ '" height="' +
497
+ (statsH - 2) +
498
+ '" rx="8" fill="' +
499
+ textColor +
500
+ '" opacity="0.028"/>',
501
+ );
502
+
503
+ // each stat wrapped in its own <g>
504
+ parts.push(
505
+ '<g transform="translate(' +
506
+ cx +
507
+ "," +
508
+ iy +
509
+ ')" class="stat' +
510
+ staggerClass +
511
+ '"' +
512
+ staggerStyle +
513
+ ">",
514
+ );
515
+
516
+ // value
517
+ parts.push(
518
+ '<text x="0" y="26" class="stat-value" text-anchor="middle">' + kFormat(val) + "</text>",
519
+ );
520
+
521
+ // label with tiny inline icon, centered as a unit
522
+ const labelText = d.label.toUpperCase();
523
+ const labelW = labelText.length * 6.8;
524
+ const rowW = 10 + 4 + labelW;
525
+ parts.push(
526
+ '<g transform="translate(' +
527
+ -rowW / 2 +
528
+ ',31.5)">' +
529
+ icon(d.iconName, iconColor, 10) +
530
+ "</g>",
531
+ );
532
+ parts.push(
533
+ '<text x="' + (-rowW / 2 + 14) + '" y="40" class="stat-label">' + labelText + "</text>",
534
+ );
535
+
536
+ parts.push("</g>");
537
+ }
538
+
539
+ parts.push("</g>");
540
+ }
541
+
542
+ // ── languages ──────────────────────────────────────────────
543
+ if (hasLangs) {
544
+ // separator
545
+ parts.push(
546
+ '<line x1="' +
547
+ PX +
548
+ '" y1="' +
549
+ (barY - 8) +
550
+ '" x2="' +
551
+ (W - PX) +
552
+ '" y2="' +
553
+ (barY - 8) +
554
+ '" stroke="' +
555
+ textColor +
556
+ '" stroke-opacity="0.08"/>',
557
+ );
558
+
559
+ parts.push('<g transform="translate(0,0)">');
560
+
561
+ // track background & clip path for rounded corners
562
+ parts.push(
563
+ '<clipPath id="lang-clip"><rect x="' +
564
+ PX +
565
+ '" y="' +
566
+ barY +
567
+ '" width="' +
568
+ (animate ? "0" : contentW) +
569
+ '" height="' +
570
+ barH +
571
+ '" rx="5">' +
572
+ (animate
573
+ ? '<animate attributeName="width" from="0" to="' +
574
+ contentW +
575
+ '" dur="0.6s" fill="freeze"/>'
576
+ : "") +
577
+ "</rect></clipPath>",
578
+ );
579
+ parts.push(
580
+ '<rect x="' +
581
+ PX +
582
+ '" y="' +
583
+ barY +
584
+ '" width="' +
585
+ contentW +
586
+ '" height="' +
587
+ barH +
588
+ '" rx="5" class="lang-progress"/>',
589
+ );
590
+
591
+ parts.push('<g clip-path="url(#lang-clip)">');
592
+
593
+ const activeLangs = visualLangs;
594
+ let off = 0;
595
+ for (let i = 0; i < activeLangs.length; i++) {
596
+ const lang = activeLangs[i]!;
597
+ const pct = ((lang.size / totalSize) * 100).toFixed(1);
598
+ let w = i === activeLangs.length - 1 ? contentW - off : (lang.size / totalSize) * contentW;
599
+ if (w < 0) w = 0;
600
+ const x = PX + off;
601
+
602
+ const tooltip = compact ? "" : "<title>" + escapeXml(lang.name) + " " + pct + "%</title>";
603
+
604
+ parts.push(
605
+ '<rect x="' +
606
+ x +
607
+ '" y="' +
608
+ barY +
609
+ '" width="' +
610
+ w +
611
+ '" height="' +
612
+ barH +
613
+ '" fill="' +
614
+ lang.color +
615
+ '">' +
616
+ tooltip +
617
+ "</rect>",
618
+ );
619
+ off += w;
620
+ }
621
+
622
+ parts.push("</g>");
623
+
624
+ // legend (3-column grid)
625
+ for (let i = 0; i < legendItems.length; i++) {
626
+ const item = legendItems[i]!;
627
+ const col = i % legendCols;
628
+ const row = Math.floor(i / legendCols);
629
+ const lx = PX + col * legendColW;
630
+ const ly = legendY + row * legendRowH;
631
+
632
+ const staggerStyle = animate ? ' style="animation-delay: ' + (i + 3) * 150 + 'ms"' : "";
633
+ const staggerClass = animate ? ' class="stagger"' : "";
634
+
635
+ parts.push("<g" + staggerClass + staggerStyle + ">");
636
+ parts.push('<circle cx="' + (lx + 5) + '" cy="' + ly + '" r="4" fill="' + item.color + '"/>');
637
+ parts.push(
638
+ '<text x="' +
639
+ (lx + 14) +
640
+ '" y="' +
641
+ (ly + 4) +
642
+ '" class="lang-name" font-weight="500">' +
643
+ escapeXml(item.name) +
644
+ " " +
645
+ item.pct +
646
+ "%</text>",
647
+ );
648
+ parts.push("</g>");
649
+ }
650
+
651
+ parts.push("</g>");
652
+ }
653
+
654
+ // close
655
+ parts.push("</svg>");
25
656
 
26
- const name = escapeXml(user.name || user.login);
27
- const uname = escapeXml(user.login);
28
- const bioRaw = !compact && user.bio ? escapeXml(user.bio) : "";
29
- const bioLines = bioRaw ? wrapText(bioRaw, 42, 2) : [];
30
- const pronouns = !compact && user.pronouns ? escapeXml(user.pronouns) : "";
31
- const avatar = user.avatarUrl.replace(/&/g, "&amp;");
32
- const twitter = !compact && user.twitter ? escapeXml(user.twitter) : "";
33
- const hiddenStats = new Set((opts.hide || []).map((key) => key.toLowerCase()));
34
- const svgTitle = `${name}'s GitHub Stats`;
35
-
36
- const W = 500;
37
- const H = 200;
38
- const P = 22;
39
- const avatarSize = 72;
40
- const barWidth = W - P * 2;
41
- const barY = H - 40;
42
- const labelY = H - 16;
43
- const infoX = P + avatarSize + 16;
44
-
45
- const totalSize = langs.reduce((sum, l) => sum + l.size, 0) || 1;
46
- let offset = 0;
47
- const langRects = langs
48
- .map((lang) => {
49
- const w = (lang.size / totalSize) * barWidth;
50
- const r = `<rect x="${P + offset}" y="${barY}" width="${w}" height="8" fill="${lang.color}"/>`;
51
- offset += w;
52
- return r;
53
- })
54
- .join("");
55
-
56
- const labelSpacing = compact ? 0 : Math.floor(barWidth / Math.max(langs.length, 1));
57
- const langLabels = compact
58
- ? ""
59
- : langs
60
- .map((lang, i) => {
61
- const pct = ((lang.size / totalSize) * 100).toFixed(0);
62
- return `<circle cx="${P + i * labelSpacing + 5}" cy="${labelY}" r="4" fill="${lang.color}"/><text x="${P + i * labelSpacing + 13}" y="${labelY + 4}" class="lang">${lang.name} ${pct}%</text>`;
63
- })
64
- .join("");
65
-
66
- const nameY = P + 22;
67
- const usernameY = nameY + 18;
68
- const bioY = usernameY + 14;
69
- const twitterY = bioLines.length ? bioY + 28 : usernameY + 16;
70
-
71
- const headerY = P + avatarSize + 12;
72
- const statLabelY = 28;
73
-
74
- const visibleStats = [
75
- { key: "stars", iconName: "star" as const, label: "Stars", value: stats.stars },
76
- { key: "commits", iconName: "commit" as const, label: "Commits", value: stats.commits },
77
- { key: "issues", iconName: "issue" as const, label: "Issues", value: stats.issues },
78
- { key: "repos", iconName: "repo" as const, label: "Repos", value: stats.repos },
79
- { key: "prs", iconName: "pr" as const, label: "PRs", value: stats.prs },
80
- ].filter((stat) => !hiddenStats.has(stat.key));
81
-
82
- const statsMarkup = visibleStats
83
- .map(
84
- (stat, index) => `<g transform="translate(${index * 100},0)">
85
- ${icon(stat.iconName, c.icon, 16)}
86
- <text x="20" y="12" class="stat">${kFormat(stat.value)}</text>
87
- <text x="0" y="${statLabelY}" class="stat-label">${stat.label}</text>
88
- </g>`,
89
- )
90
- .join("");
91
-
92
- return `<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMinYMin meet" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc" shape-rendering="auto" text-rendering="optimizeLegibility" image-rendering="optimizeQuality">
93
- <title id="title">${svgTitle}</title>
94
- <desc id="desc">GitHub stats card for ${name} including stars, commits, issues, repositories, pull requests, and top languages.</desc>
95
- <defs>
96
- <clipPath id="a"><circle cx="${P + avatarSize / 2}" cy="${P + avatarSize / 2}" r="${avatarSize / 2}"/></clipPath>
97
- <clipPath id="b"><rect x="${P}" y="${barY}" width="${barWidth}" height="8" rx="4"/></clipPath>
98
- <filter id="shadow" x="-10%" y="-10%" width="130%" height="140%">
99
- <feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
100
- <feOffset dx="0" dy="1" result="offsetblur"/>
101
- <feComponentTransfer>
102
- <feFuncA type="linear" slope="0.15"/>
103
- </feComponentTransfer>
104
- <feMerge>
105
- <feMergeNode/>
106
- <feMergeNode in="SourceGraphic"/>
107
- </feMerge>
108
- </filter>
109
- </defs>
110
- <style>
111
- ${FONT_FACE}
112
- *{font-family:${FONT_FAMILY}}
113
- .bg{fill:#${c.bg}}
114
- .title{font-size:18px;font-weight:700;fill:#${c.title}}
115
- .user{font-size:12px;fill:#${c.text};opacity:.7}
116
- ${compact ? "" : `.bio{font-size:11px;fill:#${c.text};opacity:.65}.tw{font-size:11px;fill:#${c.text};opacity:.7}.lang{font-size:10px;fill:#${c.text}}`}
117
- .stat{font-size:14px;font-weight:700;fill:#${c.text};text-anchor:start}
118
- .stat-label{font-size:9px;font-weight:600;fill:#${c.text};opacity:.55;text-transform:uppercase;letter-spacing:.6px;text-anchor:start}
119
- .sec{font-size:9px;font-weight:600;fill:#${c.text};opacity:.5;text-transform:uppercase;letter-spacing:.6px}
120
- </style>
121
- <rect class="bg" width="${W}" height="${H}" rx="10" stroke="${hideBorder ? "none" : `#${c.border}`}" stroke-width="1.5" filter="url(#shadow)"/>
122
- <circle cx="${P + avatarSize / 2}" cy="${P + avatarSize / 2}" r="${avatarSize / 2 + 2}" fill="none" stroke="#${c.border}" stroke-width="1" opacity=".6"/>
123
- <image href="${avatar}" x="${P}" y="${P}" width="${avatarSize}" height="${avatarSize}" clip-path="url(#a)" filter="url(#shadow)" preserveAspectRatio="xMidYMid slice"/>
124
- <text x="${infoX}" y="${nameY}" class="title">${name}</text>
125
- <text x="${infoX}" y="${usernameY}" class="user">@${uname}${pronouns ? ` · ${pronouns}` : ""}</text>
126
- ${!compact && bioLines[0] ? `<text x="${infoX}" y="${bioY}" class="bio">${bioLines[0]}</text>` : ""}
127
- ${!compact && bioLines[1] ? `<text x="${infoX}" y="${bioY + 12}" class="bio">${bioLines[1]}</text>` : ""}
128
- ${!compact && twitter ? `<g transform="translate(${infoX},${twitterY - 9})">${icon("x", c.icon, 11)}<text x="14" y="9" class="tw">@${twitter}</text></g>` : ""}
129
- <g transform="translate(${P},${headerY})">
130
- ${statsMarkup}
131
- </g>
132
- <text x="${P}" y="${barY - 8}" class="sec">Top Languages</text>
133
- <rect x="${P}" y="${barY}" width="${barWidth}" height="8" rx="4" fill="#${c.text}" opacity=".1"/>
134
- <g clip-path="url(#b)">${langRects}</g>
135
- ${langLabels}
136
- </svg>`;
657
+ return parts.join("");
137
658
  }