@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/src/card.ts CHANGED
@@ -1,4 +1,13 @@
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
+ measureText,
8
+ FONT_FAMILY,
9
+ resolveTw,
10
+ } from "./utils/index";
2
11
  import type { UserProfile, UserStats, LanguageStat } from "./types";
3
12
 
4
13
  export type CardOpts = {
@@ -11,127 +20,542 @@ export type CardOpts = {
11
20
  hide_border?: boolean;
12
21
  compact?: boolean;
13
22
  hide?: string[];
23
+ hide_langs?: string[];
24
+ show_langs?: string[];
25
+ animate?: boolean;
14
26
  };
15
27
 
28
+ const STAT_DEFS = [
29
+ { key: "stars", iconName: "star" as const, label: "Stars" },
30
+ { key: "commits", iconName: "commit" as const, label: "Commits" },
31
+ { key: "issues", iconName: "issue" as const, label: "Issues" },
32
+ { key: "repos", iconName: "repo" as const, label: "Repos" },
33
+ { key: "prs", iconName: "pr" as const, label: "PRs" },
34
+ ] as const;
35
+
36
+ type LangVisual = {
37
+ name: string;
38
+ color: string;
39
+ size: number;
40
+ };
41
+
42
+ /* ── tiny SVG builder ──────────────────────────────────────── */
43
+
44
+ function el(
45
+ tag: string,
46
+ attrs: Record<string, string | number | undefined>,
47
+ children = "",
48
+ ): string {
49
+ let a = "";
50
+ for (const [k, v] of Object.entries(attrs)) {
51
+ if (v === undefined || v === null || v === "") continue;
52
+ a += ` ${k}="${escapeXml(String(v))}"`;
53
+ }
54
+ if (!children) return `<${tag}${a} />`;
55
+ return `<${tag}${a}>${children}</${tag}>`;
56
+ }
57
+
58
+ /* ── helpers ───────────────────────────────────────────────── */
59
+
60
+ function raw(token: string): string {
61
+ const m = resolveTw(token, "fill").match(/fill="([^"]+)"/);
62
+ return m?.[1] ?? token;
63
+ }
64
+
65
+ function clamp(s: string, n: number): string {
66
+ return s.length <= n ? s : s.slice(0, n - 1).trimEnd() + "\u2026";
67
+ }
68
+
69
+ function buildLanguageSegments(
70
+ langs: LanguageStat[],
71
+ totalSize: number,
72
+ contentW: number,
73
+ ): LangVisual[] {
74
+ const minSegmentW = 6;
75
+ const visible: LangVisual[] = [];
76
+ let tinySize = 0;
77
+
78
+ for (const lang of langs) {
79
+ const segmentW = (lang.size / totalSize) * contentW;
80
+ if (segmentW < minSegmentW) {
81
+ tinySize += lang.size;
82
+ continue;
83
+ }
84
+ visible.push(lang);
85
+ }
86
+
87
+ if (tinySize > 0) {
88
+ visible.push({ name: "Other", color: "#6b7280", size: tinySize });
89
+ }
90
+
91
+ return visible.length > 0 ? visible : langs;
92
+ }
93
+
94
+ /* ── renderer ──────────────────────────────────────────────── */
95
+
16
96
  export function renderCard(
17
97
  user: UserProfile,
18
98
  stats: UserStats,
19
99
  langs: LanguageStat[],
20
100
  opts: CardOpts = {},
21
101
  ): string {
102
+ const hidden = new Set((opts.hide ?? []).map((k) => k.toLowerCase()));
22
103
  const c = resolveColors(opts);
23
- const hideBorder = opts.hide_border ?? false;
24
104
  const compact = opts.compact ?? false;
105
+ const animate = opts.animate ?? false;
106
+ const hideBorder = opts.hide_border ?? false;
107
+ const hideLangs = new Set((opts.hide_langs ?? []).map((k) => k.toLowerCase().trim()));
108
+ const showLangs = new Set((opts.show_langs ?? []).map((k) => k.toLowerCase().trim()));
109
+
110
+ const bgColor = raw(c.bg);
111
+ const titleColor = raw(c.title);
112
+ const textColor = raw(c.text);
113
+ const iconColor = raw(c.icon);
114
+ const borderColor = raw(c.border);
115
+
116
+ const fontFamily = FONT_FAMILY;
117
+
118
+ /* ── 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)) : "";
123
+ 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)) : "";
126
+ const avatar = user.avatarUrl || "";
127
+ const metaStr = pronouns ? `@${loginEsc} \u00b7 ${pronouns}` : `@${loginEsc}`;
128
+
129
+ const visible = STAT_DEFS.filter((d) => !hidden.has(d.key));
130
+ const activeLangsList = langs.filter((l) => {
131
+ const name = l.name.toLowerCase().trim();
132
+ if (showLangs.size > 0 && !showLangs.has(name)) return false;
133
+ if (hideLangs.has(name)) return false;
134
+ return true;
135
+ });
136
+
137
+ /* ── dimensions (measured, dynamic width) ───────────────── */
138
+ const PX = 24; // horizontal padding
139
+ const PY = 22; // top padding
140
+ const avatarSize = 60;
141
+ const avatarR = avatarSize / 2;
142
+ const textGap = 16;
143
+
144
+ 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;
151
+ 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)));
155
+ const contentW = W - PX * 2;
156
+
157
+ /* ── profile section ─────────────────────────────────────── */
158
+ const infoX = PX + avatarSize + textGap;
159
+ const nameY = PY + 20;
160
+ const loginY = nameY + 17;
161
+ const bioStartY = loginY + 15;
162
+ const twitterY = bioStartY + bioLines.length * 14 + (bioLines.length ? 4 : 0);
163
+
164
+ let profileH = loginY + 8 - PY;
165
+ if (bioLines.length) profileH = bioStartY + bioLines.length * 14 - PY;
166
+ if (twitter) profileH = twitterY + 10 - PY;
167
+ profileH = Math.max(profileH, avatarSize + 6);
168
+
169
+ /* ── stats section ────────────────────────────────────────── */
170
+ const statsY = PY + profileH + 16;
171
+ const statW = visible.length > 0 ? contentW / visible.length : 0;
172
+ const statsH = visible.length > 0 ? 58 : 0;
173
+
174
+ /* ── languages section ─────────────────────────────────── */
175
+ const totalSize = activeLangsList.reduce((s, l) => s + l.size, 0) || 1;
176
+ const sorted = [...activeLangsList].sort((a, b) => b.size - a.size);
177
+ const visualLangs = buildLanguageSegments(sorted, totalSize, contentW);
178
+ const barY = statsY + statsH + (statsH ? 18 : 14);
179
+ 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
+ const legendY = barY + barH + 14;
186
+ const legendRowH = 17;
187
+ const legendCols = 3;
188
+ 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),
206
+ });
207
+ }
208
+ }
209
+ const legendRows = compact ? 0 : Math.ceil(legendItems.length / legendCols);
210
+
211
+ const hasLangs = activeLangsList.length > 0;
212
+ const langSectionH = hasLangs ? barH + 14 + legendRows * legendRowH + 10 : 0;
213
+
214
+ /* ── final height ────────────────────────────────────────── */
215
+ const H = barY + (hasLangs ? langSectionH : 0) + 14 + (hasLangs ? 0 : 6);
216
+
217
+ /* ── accessibility ────────────────────────────────────────── */
218
+ const parts: string[] = [];
219
+ const svgAttrs: Record<string, string | number> = {
220
+ width: W,
221
+ height: H,
222
+ viewBox: `0 0 ${W} ${H}`,
223
+ fill: "none",
224
+ xmlns: "http://www.w3.org/2000/svg",
225
+ role: "img",
226
+ "aria-labelledby": "cardTitle cardDesc",
227
+ };
228
+ let svgOpen = "<svg";
229
+ for (const [k, v] of Object.entries(svgAttrs)) svgOpen += ` ${k}="${escapeXml(String(v))}"`;
230
+ parts.push(svgOpen + ">");
231
+ parts.push(el("title", { id: "cardTitle" }, `${escapeXml(displayName)}'s GitHub Stats`));
232
+ parts.push(
233
+ el(
234
+ "desc",
235
+ { id: "cardDesc" },
236
+ `GitHub profile card for ${escapeXml(displayName)} with ${kFormat(stats.stars)} stars, ${kFormat(stats.commits)} commits, and ${activeLangsList.length} highlighted languages.`,
237
+ ),
238
+ );
239
+
240
+ /* ── styles ──────────────────────────────────────────────── */
241
+ const css: string[] = [];
242
+ css.push(
243
+ `* { font-family: ${fontFamily}; text-rendering: geometricPrecision; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }`,
244
+ );
245
+ css.push(
246
+ `.header { font-size: 17px; font-weight: 700; fill: ${titleColor}; letter-spacing: -0.015em; }`,
247
+ );
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; }`);
254
+ css.push(
255
+ `.stat-label { font-size: 10px; font-weight: 700; fill: ${textColor}; opacity: 0.68; letter-spacing: 0.06em; }`,
256
+ );
257
+ 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"; }`,
259
+ );
260
+ css.push(
261
+ `.twitter-text { font-size: 11.5px; font-weight: 500; fill: ${textColor}; opacity: 0.65; }`,
262
+ );
263
+
264
+ 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; }");
267
+ }
268
+
269
+ parts.push(el("style", {}, css.join(" ")));
270
+
271
+ /* ── defs ────────────────────────────────────────────────── */
272
+ parts.push(
273
+ el(
274
+ "defs",
275
+ {},
276
+ [
277
+ el(
278
+ "clipPath",
279
+ { id: "av" },
280
+ el("circle", { cx: PX + avatarR, cy: PY + avatarR, r: avatarR }),
281
+ ),
282
+ el(
283
+ "radialGradient",
284
+ { id: "gl", cx: "12%", cy: "15%", r: "65%" },
285
+ el("stop", { offset: "0%", "stop-color": iconColor, "stop-opacity": "0.08" }) +
286
+ el("stop", { offset: "100%", "stop-color": iconColor, "stop-opacity": "0" }),
287
+ ),
288
+ el(
289
+ "radialGradient",
290
+ { id: "gl2", cx: "92%", cy: "95%", r: "55%" },
291
+ el("stop", { offset: "0%", "stop-color": titleColor, "stop-opacity": "0.035" }) +
292
+ el("stop", { offset: "100%", "stop-color": titleColor, "stop-opacity": "0" }),
293
+ ),
294
+ el(
295
+ "linearGradient",
296
+ { id: "ring", x1: "0", y1: "0", x2: "1", y2: "1" },
297
+ el("stop", { offset: "0%", "stop-color": titleColor }) +
298
+ el("stop", { offset: "100%", "stop-color": iconColor }),
299
+ ),
300
+ el(
301
+ "linearGradient",
302
+ { id: "card-bg", x1: "0", y1: "0", x2: W, y2: H, gradientUnits: "userSpaceOnUse" },
303
+ el("stop", { offset: "0%", "stop-color": bgColor }) +
304
+ el("stop", { offset: "82%", "stop-color": bgColor }) +
305
+ el("stop", { offset: "100%", "stop-color": titleColor, "stop-opacity": "0.04" }),
306
+ ),
307
+ el(
308
+ "linearGradient",
309
+ { id: "rim", x1: "0", y1: "0", x2: W, y2: "0", gradientUnits: "userSpaceOnUse" },
310
+ el("stop", { offset: "0%", "stop-color": iconColor, "stop-opacity": "0" }) +
311
+ el("stop", { offset: "45%", "stop-color": iconColor, "stop-opacity": "0.28" }) +
312
+ el("stop", { offset: "100%", "stop-color": iconColor, "stop-opacity": "0" }),
313
+ ),
314
+ ].join(""),
315
+ ),
316
+ );
317
+
318
+ /* ── background & glow ───────────────────────────────────── */
319
+ parts.push(
320
+ el("rect", { x: "0.5", y: "0.5", width: W - 1, height: H - 1, rx: 12, fill: "url(#card-bg)" }),
321
+ );
322
+ parts.push(
323
+ el("rect", { x: "0.5", y: "0.5", width: W - 1, height: H - 1, rx: 12, fill: "url(#gl)" }),
324
+ );
325
+ parts.push(
326
+ el("rect", { x: "0.5", y: "0.5", width: W - 1, height: H - 1, rx: 12, fill: "url(#gl2)" }),
327
+ );
328
+ parts.push(
329
+ el("line", {
330
+ x1: PX,
331
+ y1: "1.5",
332
+ x2: W - PX,
333
+ y2: "1.5",
334
+ stroke: "url(#rim)",
335
+ "stroke-width": 1,
336
+ }),
337
+ );
338
+
339
+ if (!hideBorder) {
340
+ parts.push(
341
+ el("rect", {
342
+ x: "0.5",
343
+ y: "0.5",
344
+ width: W - 1,
345
+ height: H - 1,
346
+ rx: 12,
347
+ fill: "none",
348
+ stroke: borderColor,
349
+ "stroke-opacity": "0.5",
350
+ }),
351
+ );
352
+ }
353
+
354
+ /* ── avatar ───────────────────────────────────────────────── */
355
+ parts.push(
356
+ el(
357
+ "g",
358
+ {},
359
+ el("circle", {
360
+ cx: PX + avatarR + 1,
361
+ cy: PY + avatarR + 1,
362
+ r: avatarR + 3,
363
+ fill: titleColor,
364
+ opacity: "0.08",
365
+ }) +
366
+ el("image", {
367
+ href: avatar,
368
+ x: PX,
369
+ y: PY,
370
+ width: avatarSize,
371
+ height: avatarSize,
372
+ "clip-path": "url(#av)",
373
+ preserveAspectRatio: "xMidYMid slice",
374
+ }) +
375
+ el("circle", {
376
+ cx: PX + avatarR,
377
+ cy: PY + avatarR,
378
+ r: avatarR + 1.5,
379
+ fill: "none",
380
+ stroke: "url(#ring)",
381
+ "stroke-opacity": "0.55",
382
+ "stroke-width": "1.5",
383
+ }),
384
+ ),
385
+ );
386
+
387
+ /* ── profile text ─────────────────────────────────────────── */
388
+ const textParts: string[] = [];
389
+ textParts.push(el("text", { x: infoX, y: nameY, class: "header" }, nameEsc));
390
+ textParts.push(el("text", { x: infoX, y: loginY, class: "meta" }, metaStr));
391
+ for (let i = 0; i < bioLines.length; i++) {
392
+ textParts.push(
393
+ el("text", { x: infoX, y: bioStartY + i * 14, class: "bio" }, escapeXml(bioLines[i]!.trim())),
394
+ );
395
+ }
396
+ if (twitter) {
397
+ textParts.push(
398
+ el(
399
+ "g",
400
+ { transform: `translate(${infoX},${twitterY - 9})` },
401
+ icon("x", textColor, 11) +
402
+ el("text", { x: 15, y: 9, class: "twitter-text" }, `@${twitter}`),
403
+ ),
404
+ );
405
+ }
406
+ parts.push(el("g", {}, textParts.join("")));
407
+
408
+ /* ── stats row ───────────────────────────────────────────── */
409
+ if (visible.length > 0) {
410
+ const statParts: string[] = [];
411
+
412
+ for (let i = 0; i < visible.length; i++) {
413
+ const d = visible[i]!;
414
+ const val = stats[d.key as keyof UserStats];
415
+ const cx = PX + statW * i + statW / 2;
416
+ const iy = statsY + 4;
417
+
418
+ const staggerClass = animate ? " stagger" : "";
419
+
420
+ // Vertical stack: icon → value → label, all centered, with airy gaps.
421
+ const iconSize = 15;
422
+ const iconY = 2;
423
+ const valueY = 37;
424
+ const labelY = 52;
425
+
426
+ const cellParts: string[] = [];
427
+ cellParts.push(
428
+ el(
429
+ "g",
430
+ { transform: `translate(${-iconSize / 2},${iconY})`, class: "stat-icon" },
431
+ icon(d.iconName, iconColor, iconSize),
432
+ ),
433
+ );
434
+ cellParts.push(
435
+ el("text", { x: 0, y: valueY, class: "stat-value", "text-anchor": "middle" }, kFormat(val)),
436
+ );
437
+ cellParts.push(
438
+ el(
439
+ "text",
440
+ { x: 0, y: labelY, class: "stat-label", "text-anchor": "middle" },
441
+ d.label.toUpperCase(),
442
+ ),
443
+ );
444
+
445
+ statParts.push(
446
+ el(
447
+ "g",
448
+ {
449
+ transform: `translate(${cx},${iy})`,
450
+ class: `stat${staggerClass}`,
451
+ style: animate ? `animation-delay: ${(i + 3) * 150}ms` : undefined,
452
+ },
453
+ cellParts.join(""),
454
+ ),
455
+ );
456
+
457
+ // Subtle divider between stats (not after the last one).
458
+ if (i < visible.length - 1) {
459
+ const dx = PX + statW * (i + 1);
460
+ statParts.push(
461
+ el("line", {
462
+ x1: dx,
463
+ y1: statsY + 4,
464
+ x2: dx,
465
+ y2: statsY + statsH - 6,
466
+ stroke: textColor,
467
+ "stroke-opacity": "0.12",
468
+ }),
469
+ );
470
+ }
471
+ }
472
+ parts.push(el("g", {}, statParts.join("")));
473
+ }
474
+
475
+ /* ── languages ───────────────────────────────────────────── */
476
+ if (hasLangs) {
477
+ const langParts: string[] = [];
478
+ langParts.push(
479
+ el("line", {
480
+ x1: PX,
481
+ y1: barY - 8,
482
+ x2: W - PX,
483
+ y2: barY - 8,
484
+ stroke: textColor,
485
+ "stroke-opacity": "0.08",
486
+ }),
487
+ );
488
+
489
+ langParts.push(
490
+ el(
491
+ "clipPath",
492
+ { id: "lang-clip" },
493
+ el(
494
+ "rect",
495
+ {
496
+ x: PX,
497
+ y: barY,
498
+ width: animate ? "0" : contentW,
499
+ height: barH,
500
+ rx: 5,
501
+ },
502
+ animate
503
+ ? el("animate", {
504
+ attributeName: "width",
505
+ from: "0",
506
+ to: contentW,
507
+ dur: "0.6s",
508
+ fill: "freeze",
509
+ })
510
+ : "",
511
+ ),
512
+ ),
513
+ );
514
+ langParts.push(
515
+ el("rect", { x: PX, y: barY, width: contentW, height: barH, rx: 5, class: "lang-progress" }),
516
+ );
517
+
518
+ const barParts: string[] = [];
519
+ let off = 0;
520
+ for (let i = 0; i < visualLangs.length; i++) {
521
+ const lang = visualLangs[i]!;
522
+ const pct = ((lang.size / totalSize) * 100).toFixed(1);
523
+ let w = i === visualLangs.length - 1 ? contentW - off : (lang.size / totalSize) * contentW;
524
+ if (w < 0) w = 0;
525
+ const x = PX + off;
526
+ 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));
528
+ off += w;
529
+ }
530
+ langParts.push(el("g", { "clip-path": "url(#lang-clip)" }, barParts.join("")));
531
+
532
+ for (let i = 0; i < legendItems.length; i++) {
533
+ const item = legendItems[i]!;
534
+ const col = i % legendCols;
535
+ const row = Math.floor(i / legendCols);
536
+ const lx = PX + col * legendColW;
537
+ const ly = legendY + row * legendRowH;
538
+
539
+ langParts.push(
540
+ el(
541
+ "g",
542
+ {
543
+ class: animate ? "stagger" : undefined,
544
+ style: animate ? `animation-delay: ${(i + 3) * 150}ms` : undefined,
545
+ },
546
+ el("circle", { cx: lx + 5, cy: ly, r: 4, fill: item.color }) +
547
+ el(
548
+ "text",
549
+ { x: lx + 14, y: ly + 4, class: "lang-name", "font-weight": "500" },
550
+ `${escapeXml(item.name)} ${item.pct}%`,
551
+ ),
552
+ ),
553
+ );
554
+ }
555
+
556
+ parts.push(el("g", {}, langParts.join("")));
557
+ }
25
558
 
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>`;
559
+ parts.push("</svg>");
560
+ return parts.join("");
137
561
  }
package/src/env.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ declare module "*.css" {
2
+ const content: string;
3
+ export default content;
4
+ }