@urmzd/github-insights 2.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.
Files changed (102) hide show
  1. package/.gitattributes +28 -0
  2. package/.github/dependabot.yml +6 -0
  3. package/.github/pull_request_template.md +14 -0
  4. package/.github/workflows/ci.yml +93 -0
  5. package/.github/workflows/release.yml +59 -0
  6. package/.nvmrc +1 -0
  7. package/.pre-commit-config.yaml +5 -0
  8. package/AGENTS.md +69 -0
  9. package/CHANGELOG.md +260 -0
  10. package/CONTRIBUTING.md +87 -0
  11. package/LICENSE +190 -0
  12. package/README.md +188 -0
  13. package/action.yml +45 -0
  14. package/biome.json +40 -0
  15. package/examples/classic/README.md +9 -0
  16. package/examples/classic/index.svg +14 -0
  17. package/examples/classic/metrics-calendar.svg +14 -0
  18. package/examples/classic/metrics-complexity.svg +14 -0
  19. package/examples/classic/metrics-contributions.svg +14 -0
  20. package/examples/classic/metrics-expertise.svg +14 -0
  21. package/examples/classic/metrics-languages.svg +14 -0
  22. package/examples/classic/metrics-pulse.svg +14 -0
  23. package/examples/ecosystem/README.md +59 -0
  24. package/examples/ecosystem/index.svg +14 -0
  25. package/examples/ecosystem/metrics-calendar.svg +14 -0
  26. package/examples/ecosystem/metrics-complexity.svg +14 -0
  27. package/examples/ecosystem/metrics-contributions.svg +14 -0
  28. package/examples/ecosystem/metrics-expertise.svg +14 -0
  29. package/examples/ecosystem/metrics-languages.svg +14 -0
  30. package/examples/ecosystem/metrics-pulse.svg +14 -0
  31. package/examples/minimal/README.md +9 -0
  32. package/examples/minimal/index.svg +14 -0
  33. package/examples/minimal/metrics-calendar.svg +14 -0
  34. package/examples/minimal/metrics-complexity.svg +14 -0
  35. package/examples/minimal/metrics-contributions.svg +14 -0
  36. package/examples/minimal/metrics-expertise.svg +14 -0
  37. package/examples/minimal/metrics-languages.svg +14 -0
  38. package/examples/minimal/metrics-pulse.svg +14 -0
  39. package/examples/modern/README.md +111 -0
  40. package/examples/modern/index.svg +14 -0
  41. package/examples/modern/metrics-calendar.svg +14 -0
  42. package/examples/modern/metrics-complexity.svg +14 -0
  43. package/examples/modern/metrics-contributions.svg +14 -0
  44. package/examples/modern/metrics-expertise.svg +14 -0
  45. package/examples/modern/metrics-languages.svg +14 -0
  46. package/examples/modern/metrics-pulse.svg +14 -0
  47. package/llms.txt +24 -0
  48. package/metrics/index.svg +14 -0
  49. package/metrics/metrics-calendar.svg +14 -0
  50. package/metrics/metrics-complexity.svg +14 -0
  51. package/metrics/metrics-contributions.svg +14 -0
  52. package/metrics/metrics-domains.svg +14 -0
  53. package/metrics/metrics-expertise.svg +14 -0
  54. package/metrics/metrics-languages.svg +14 -0
  55. package/metrics/metrics-pulse.svg +14 -0
  56. package/metrics/metrics-tech-stack.svg +14 -0
  57. package/package.json +35 -0
  58. package/skills/github-insights/SKILL.md +237 -0
  59. package/sr.yaml +16 -0
  60. package/src/__fixtures__/repos.ts +84 -0
  61. package/src/api.ts +729 -0
  62. package/src/components/bar-chart.test.tsx +38 -0
  63. package/src/components/bar-chart.tsx +54 -0
  64. package/src/components/contribution-calendar.test.tsx +44 -0
  65. package/src/components/contribution-calendar.tsx +94 -0
  66. package/src/components/contribution-cards.test.tsx +36 -0
  67. package/src/components/contribution-cards.tsx +58 -0
  68. package/src/components/donut-chart.test.tsx +36 -0
  69. package/src/components/donut-chart.tsx +102 -0
  70. package/src/components/full-svg.test.tsx +54 -0
  71. package/src/components/full-svg.tsx +59 -0
  72. package/src/components/project-cards.test.tsx +46 -0
  73. package/src/components/project-cards.tsx +66 -0
  74. package/src/components/section.test.tsx +69 -0
  75. package/src/components/section.tsx +79 -0
  76. package/src/components/stat-cards.test.tsx +32 -0
  77. package/src/components/stat-cards.tsx +57 -0
  78. package/src/components/style-defs.test.tsx +26 -0
  79. package/src/components/style-defs.tsx +27 -0
  80. package/src/components/tech-highlights.test.tsx +63 -0
  81. package/src/components/tech-highlights.tsx +109 -0
  82. package/src/config.test.ts +127 -0
  83. package/src/config.ts +103 -0
  84. package/src/index.ts +363 -0
  85. package/src/jsx-factory.test.tsx +86 -0
  86. package/src/jsx-factory.ts +46 -0
  87. package/src/jsx.d.ts +6 -0
  88. package/src/metrics.test.ts +669 -0
  89. package/src/metrics.ts +365 -0
  90. package/src/parsers.test.ts +247 -0
  91. package/src/parsers.ts +146 -0
  92. package/src/readme.test.ts +189 -0
  93. package/src/readme.ts +70 -0
  94. package/src/svg-utils.test.ts +66 -0
  95. package/src/svg-utils.ts +33 -0
  96. package/src/templates.test.ts +412 -0
  97. package/src/templates.ts +296 -0
  98. package/src/theme.ts +33 -0
  99. package/src/types.ts +235 -0
  100. package/teasr.toml +14 -0
  101. package/tsconfig.json +21 -0
  102. package/vitest.config.ts +12 -0
@@ -0,0 +1,412 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { makeContributionData, makeUserProfile } from "./__fixtures__/repos.js";
3
+ import {
4
+ buildSocialBadges,
5
+ extractFirstName,
6
+ getTemplate,
7
+ shieldsBadgeLabel,
8
+ } from "./templates.js";
9
+ import type { TemplateContext } from "./types.js";
10
+
11
+ const makeContext = (
12
+ overrides: Partial<TemplateContext> = {},
13
+ ): TemplateContext => ({
14
+ username: "urmzd",
15
+ name: "Urmzd Maharramoff",
16
+ firstName: "Urmzd",
17
+ pronunciation: undefined,
18
+ title: "Software Engineer",
19
+ bio: "Building tools",
20
+ preamble: "A software developer in Austin, TX.",
21
+ svgs: [{ label: "GitHub Metrics", path: "metrics/index.svg" }],
22
+ sectionSvgs: {
23
+ pulse: "metrics/metrics-pulse.svg",
24
+ calendar: "metrics/metrics-calendar.svg",
25
+ expertise: "metrics/metrics-expertise.svg",
26
+ },
27
+ profile: makeUserProfile(),
28
+ activeProjects: [
29
+ {
30
+ name: "resume-generator",
31
+ url: "https://github.com/urmzd/resume-generator",
32
+ description: "CLI tool for professional resumes",
33
+ stars: 42,
34
+ category: "Applications",
35
+ },
36
+ ],
37
+ maintainedProjects: [
38
+ {
39
+ name: "flappy-bird",
40
+ url: "https://github.com/urmzd/flappy-bird",
41
+ description: "JavaFX game with design patterns",
42
+ stars: 8,
43
+ category: "Research & Experiments",
44
+ },
45
+ ],
46
+ inactiveProjects: [],
47
+ allProjects: [],
48
+ categorizedProjects: {
49
+ Applications: [
50
+ {
51
+ name: "resume-generator",
52
+ url: "https://github.com/urmzd/resume-generator",
53
+ description: "CLI tool for professional resumes",
54
+ stars: 42,
55
+ category: "Applications",
56
+ },
57
+ ],
58
+ "Research & Experiments": [
59
+ {
60
+ name: "flappy-bird",
61
+ url: "https://github.com/urmzd/flappy-bird",
62
+ description: "JavaFX game with design patterns",
63
+ stars: 8,
64
+ category: "Research & Experiments",
65
+ },
66
+ ],
67
+ },
68
+ languages: [
69
+ { name: "TypeScript", value: 100, percent: "60.0", color: "#3178c6" },
70
+ { name: "Rust", value: 50, percent: "30.0", color: "#dea584" },
71
+ ],
72
+ techHighlights: [],
73
+ contributionData: makeContributionData(),
74
+ socialBadges:
75
+ "[![urmzd.dev](https://img.shields.io/badge/urmzd.dev-4285F4?style=flat&logo=google-chrome&logoColor=white)](https://urmzd.dev)",
76
+ svgDir: "metrics",
77
+ ...overrides,
78
+ });
79
+
80
+ // ── extractFirstName ───────────────────────────────────────────────────────
81
+
82
+ describe("extractFirstName", () => {
83
+ it("returns first word of full name", () => {
84
+ expect(extractFirstName("Urmzd Maharramoff")).toBe("Urmzd");
85
+ });
86
+
87
+ it("returns whole name if single word", () => {
88
+ expect(extractFirstName("Urmzd")).toBe("Urmzd");
89
+ });
90
+
91
+ it("handles extra whitespace", () => {
92
+ expect(extractFirstName(" Urmzd Maharramoff ")).toBe("Urmzd");
93
+ });
94
+ });
95
+
96
+ // ── buildSocialBadges ──────────────────────────────────────────────────────
97
+
98
+ describe("buildSocialBadges", () => {
99
+ it("builds website badge with hostname", () => {
100
+ const profile = makeUserProfile({
101
+ twitterUsername: null,
102
+ socialAccounts: [],
103
+ });
104
+ const badges = buildSocialBadges(profile);
105
+ expect(badges).toContain("urmzd.dev");
106
+ expect(badges).toContain("https://urmzd.dev");
107
+ expect(badges).not.toContain("Website");
108
+ });
109
+
110
+ it("builds twitter badge with @username", () => {
111
+ const profile = makeUserProfile({ websiteUrl: null, socialAccounts: [] });
112
+ const badges = buildSocialBadges(profile);
113
+ expect(badges).toContain("@urmzd");
114
+ expect(badges).toContain("https://x.com/urmzd");
115
+ });
116
+
117
+ it("builds LinkedIn badge with handle from URL", () => {
118
+ const profile = makeUserProfile({
119
+ websiteUrl: null,
120
+ twitterUsername: null,
121
+ });
122
+ const badges = buildSocialBadges(profile);
123
+ expect(badges).toContain("[![urmzd]");
124
+ expect(badges).toContain("https://linkedin.com/in/urmzd");
125
+ });
126
+
127
+ it("builds Mastodon badge with @user from URL", () => {
128
+ const profile = makeUserProfile({
129
+ websiteUrl: null,
130
+ twitterUsername: null,
131
+ socialAccounts: [
132
+ { provider: "MASTODON", url: "https://mastodon.social/@urmzd" },
133
+ ],
134
+ });
135
+ const badges = buildSocialBadges(profile);
136
+ expect(badges).toContain("@urmzd");
137
+ expect(badges).toContain("mastodon.social/@urmzd");
138
+ });
139
+
140
+ it("builds YouTube badge with channel name from URL", () => {
141
+ const profile = makeUserProfile({
142
+ websiteUrl: null,
143
+ twitterUsername: null,
144
+ socialAccounts: [
145
+ { provider: "YOUTUBE", url: "https://youtube.com/@mychannel" },
146
+ ],
147
+ });
148
+ const badges = buildSocialBadges(profile);
149
+ expect(badges).toContain("mychannel");
150
+ expect(badges).toContain("youtube.com/@mychannel");
151
+ });
152
+
153
+ it("returns empty string when no social info", () => {
154
+ const profile = makeUserProfile({
155
+ websiteUrl: null,
156
+ twitterUsername: null,
157
+ socialAccounts: [],
158
+ });
159
+ expect(buildSocialBadges(profile)).toBe("");
160
+ });
161
+ });
162
+
163
+ // ── shieldsBadgeLabel ────────────────────────────────────────────────────────
164
+
165
+ describe("shieldsBadgeLabel", () => {
166
+ it("escapes hyphens", () => {
167
+ expect(shieldsBadgeLabel("my-site")).toBe("my--site");
168
+ });
169
+
170
+ it("escapes underscores", () => {
171
+ expect(shieldsBadgeLabel("my_name")).toBe("my__name");
172
+ });
173
+
174
+ it("leaves plain text unchanged", () => {
175
+ expect(shieldsBadgeLabel("urmzd.dev")).toBe("urmzd.dev");
176
+ });
177
+ });
178
+
179
+ // ── getTemplate ────────────────────────────────────────────────────────────
180
+
181
+ describe("getTemplate", () => {
182
+ it("returns a function for classic", () => {
183
+ expect(typeof getTemplate("classic")).toBe("function");
184
+ });
185
+
186
+ it("returns a function for modern", () => {
187
+ expect(typeof getTemplate("modern")).toBe("function");
188
+ });
189
+
190
+ it("returns a function for minimal", () => {
191
+ expect(typeof getTemplate("minimal")).toBe("function");
192
+ });
193
+
194
+ it("returns a function for ecosystem", () => {
195
+ expect(typeof getTemplate("ecosystem")).toBe("function");
196
+ });
197
+ });
198
+
199
+ // ── Classic template ───────────────────────────────────────────────────────
200
+
201
+ describe("classicTemplate", () => {
202
+ it("includes name heading", () => {
203
+ const output = getTemplate("classic")(makeContext());
204
+ expect(output).toContain("# Urmzd Maharramoff");
205
+ });
206
+
207
+ it("includes title blockquote", () => {
208
+ const output = getTemplate("classic")(makeContext());
209
+ expect(output).toContain("> Software Engineer");
210
+ });
211
+
212
+ it("includes preamble content", () => {
213
+ const output = getTemplate("classic")(makeContext());
214
+ expect(output).toContain("A software developer in Austin, TX.");
215
+ });
216
+
217
+ it("includes SVG embeds", () => {
218
+ const output = getTemplate("classic")(makeContext());
219
+ expect(output).toContain("![GitHub Metrics](metrics/index.svg)");
220
+ });
221
+
222
+ it("includes social badges", () => {
223
+ const output = getTemplate("classic")(makeContext());
224
+ expect(output).toContain("img.shields.io");
225
+ });
226
+
227
+ it("includes bio footer", () => {
228
+ const output = getTemplate("classic")(makeContext());
229
+ expect(output).toContain("<sub>Building tools</sub>");
230
+ });
231
+
232
+ it("includes attribution", () => {
233
+ const output = getTemplate("classic")(makeContext());
234
+ expect(output).toContain("@urmzd/github-insights");
235
+ });
236
+
237
+ it("includes pronunciation when provided", () => {
238
+ const output = getTemplate("classic")(
239
+ makeContext({ pronunciation: "/ˈʊrm.zəd/" }),
240
+ );
241
+ expect(output).toContain("/ˈʊrm.zəd/");
242
+ });
243
+
244
+ it("ends with trailing newline", () => {
245
+ const output = getTemplate("classic")(makeContext());
246
+ expect(output.endsWith("\n")).toBe(true);
247
+ });
248
+ });
249
+
250
+ // ── Modern template ────────────────────────────────────────────────────────
251
+
252
+ describe("modernTemplate", () => {
253
+ it("includes wave greeting with first name", () => {
254
+ const output = getTemplate("modern")(makeContext());
255
+ expect(output).toContain("# Hi, I'm Urmzd");
256
+ });
257
+
258
+ it("includes preamble", () => {
259
+ const output = getTemplate("modern")(makeContext());
260
+ expect(output).toContain("A software developer in Austin, TX.");
261
+ });
262
+
263
+ it("includes active projects section with h3 headings", () => {
264
+ const output = getTemplate("modern")(makeContext());
265
+ expect(output).toContain("## Active Projects");
266
+ expect(output).toContain(
267
+ "### [resume-generator](https://github.com/urmzd/resume-generator)",
268
+ );
269
+ expect(output).toContain("\u2605 42");
270
+ });
271
+
272
+ it("includes maintained projects section with h3 headings", () => {
273
+ const output = getTemplate("modern")(makeContext());
274
+ expect(output).toContain("## Maintained Projects");
275
+ expect(output).toContain(
276
+ "### [flappy-bird](https://github.com/urmzd/flappy-bird)",
277
+ );
278
+ });
279
+
280
+ it("includes AI summary in project section when available", () => {
281
+ const output = getTemplate("modern")(
282
+ makeContext({
283
+ activeProjects: [
284
+ {
285
+ name: "my-tool",
286
+ url: "https://github.com/urmzd/my-tool",
287
+ description: "A CLI tool",
288
+ stars: 5,
289
+ summary: "AI-generated summary of the project.",
290
+ },
291
+ ],
292
+ }),
293
+ );
294
+ expect(output).toContain("AI-generated summary of the project.");
295
+ });
296
+
297
+ it("includes GitHub Stats section with pulse and calendar", () => {
298
+ const output = getTemplate("modern")(makeContext());
299
+ expect(output).toContain("## GitHub Stats");
300
+ expect(output).toContain("metrics/metrics-pulse.svg");
301
+ expect(output).toContain("metrics/metrics-calendar.svg");
302
+ });
303
+
304
+ it("includes expertise section", () => {
305
+ const output = getTemplate("modern")(makeContext());
306
+ expect(output).toContain("## Other Areas of Interest");
307
+ expect(output).toContain("metrics/metrics-expertise.svg");
308
+ });
309
+
310
+ it("includes social badges", () => {
311
+ const output = getTemplate("modern")(makeContext());
312
+ expect(output).toContain("img.shields.io");
313
+ });
314
+
315
+ it("ends with trailing newline", () => {
316
+ const output = getTemplate("modern")(makeContext());
317
+ expect(output.endsWith("\n")).toBe(true);
318
+ });
319
+ });
320
+
321
+ // ── Minimal template ───────────────────────────────────────────────────────
322
+
323
+ describe("minimalTemplate", () => {
324
+ it("uses first name as heading", () => {
325
+ const output = getTemplate("minimal")(makeContext());
326
+ expect(output).toContain("# Urmzd");
327
+ });
328
+
329
+ it("includes preamble", () => {
330
+ const output = getTemplate("minimal")(makeContext());
331
+ expect(output).toContain("A software developer in Austin, TX.");
332
+ });
333
+
334
+ it("includes SVG embeds", () => {
335
+ const output = getTemplate("minimal")(makeContext());
336
+ expect(output).toContain("![GitHub Metrics](metrics/index.svg)");
337
+ });
338
+
339
+ it("includes social badges", () => {
340
+ const output = getTemplate("minimal")(makeContext());
341
+ expect(output).toContain("img.shields.io");
342
+ });
343
+
344
+ it("includes attribution", () => {
345
+ const output = getTemplate("minimal")(makeContext());
346
+ expect(output).toContain("@urmzd/github-insights");
347
+ });
348
+
349
+ it("ends with trailing newline", () => {
350
+ const output = getTemplate("minimal")(makeContext());
351
+ expect(output.endsWith("\n")).toBe(true);
352
+ });
353
+ });
354
+
355
+ // ── Ecosystem template ──────────────────────────────────────────────────
356
+
357
+ describe("ecosystemTemplate", () => {
358
+ it("includes wave greeting with first name", () => {
359
+ const output = getTemplate("ecosystem")(makeContext());
360
+ expect(output).toContain("# Hi, I'm Urmzd");
361
+ });
362
+
363
+ it("includes preamble", () => {
364
+ const output = getTemplate("ecosystem")(makeContext());
365
+ expect(output).toContain("A software developer in Austin, TX.");
366
+ });
367
+
368
+ it("renders projects as categorized tables", () => {
369
+ const output = getTemplate("ecosystem")(makeContext());
370
+ expect(output).toContain("### Applications");
371
+ expect(output).toContain("| Project | Description |");
372
+ expect(output).toContain(
373
+ "| [resume-generator](https://github.com/urmzd/resume-generator) |",
374
+ );
375
+ });
376
+
377
+ it("renders Research & Experiments category", () => {
378
+ const output = getTemplate("ecosystem")(makeContext());
379
+ expect(output).toContain("### Research & Experiments");
380
+ expect(output).toContain(
381
+ "| [flappy-bird](https://github.com/urmzd/flappy-bird) |",
382
+ );
383
+ });
384
+
385
+ it("includes GitHub Stats section", () => {
386
+ const output = getTemplate("ecosystem")(makeContext());
387
+ expect(output).toContain("## GitHub Stats");
388
+ expect(output).toContain("metrics/metrics-pulse.svg");
389
+ expect(output).toContain("metrics/metrics-calendar.svg");
390
+ });
391
+
392
+ it("includes expertise section", () => {
393
+ const output = getTemplate("ecosystem")(makeContext());
394
+ expect(output).toContain("## Other Areas of Interest");
395
+ expect(output).toContain("metrics/metrics-expertise.svg");
396
+ });
397
+
398
+ it("includes social badges", () => {
399
+ const output = getTemplate("ecosystem")(makeContext());
400
+ expect(output).toContain("img.shields.io");
401
+ });
402
+
403
+ it("includes attribution", () => {
404
+ const output = getTemplate("ecosystem")(makeContext());
405
+ expect(output).toContain("@urmzd/github-insights");
406
+ });
407
+
408
+ it("ends with trailing newline", () => {
409
+ const output = getTemplate("ecosystem")(makeContext());
410
+ expect(output.endsWith("\n")).toBe(true);
411
+ });
412
+ });
@@ -0,0 +1,296 @@
1
+ import type {
2
+ ProjectItem,
3
+ TemplateContext,
4
+ TemplateFunction,
5
+ TemplateName,
6
+ UserProfile,
7
+ } from "./types.js";
8
+
9
+ // ── Helpers ────────────────────────────────────────────────────────────────
10
+
11
+ function attribution(): string {
12
+ const now = new Date().toISOString().split("T")[0];
13
+ return `<sub>Last generated on ${now} using [@urmzd/github-insights](https://github.com/urmzd/github-insights)</sub>`;
14
+ }
15
+
16
+ export function extractFirstName(fullName: string): string {
17
+ return fullName.trim().split(/\s+/)[0] || fullName;
18
+ }
19
+
20
+ /** Escape special characters for shields.io badge labels (`-` → `--`, `_` → `__`). */
21
+ export function shieldsBadgeLabel(text: string): string {
22
+ return text.replace(/-/g, "--").replace(/_/g, "__");
23
+ }
24
+
25
+ export function buildSocialBadges(profile: UserProfile): string {
26
+ const badges: string[] = [];
27
+
28
+ if (profile.websiteUrl) {
29
+ let label: string;
30
+ try {
31
+ label = new URL(profile.websiteUrl).hostname;
32
+ } catch {
33
+ label = "Website";
34
+ }
35
+ badges.push(
36
+ `[![${label}](https://img.shields.io/badge/${shieldsBadgeLabel(label)}-4285F4?style=flat&logo=google-chrome&logoColor=white)](${profile.websiteUrl})`,
37
+ );
38
+ }
39
+ if (profile.twitterUsername) {
40
+ const label = `@${profile.twitterUsername}`;
41
+ badges.push(
42
+ `[![${label}](https://img.shields.io/badge/${shieldsBadgeLabel(label)}-000000?style=flat&logo=x&logoColor=white)](https://x.com/${profile.twitterUsername})`,
43
+ );
44
+ }
45
+ for (const account of profile.socialAccounts) {
46
+ const provider = account.provider.toLowerCase();
47
+ if (provider === "linkedin") {
48
+ const match = account.url.match(/\/in\/([^/?#]+)/);
49
+ const label = match?.[1] || "LinkedIn";
50
+ badges.push(
51
+ `[![${label}](https://img.shields.io/badge/${shieldsBadgeLabel(label)}-0A66C2?style=flat&logo=linkedin&logoColor=white)](${account.url})`,
52
+ );
53
+ } else if (provider === "mastodon") {
54
+ const match = account.url.match(/\/@([^/?#]+)/);
55
+ const label = match ? `@${match[1]}` : "Mastodon";
56
+ badges.push(
57
+ `[![${label}](https://img.shields.io/badge/${shieldsBadgeLabel(label)}-6364FF?style=flat&logo=mastodon&logoColor=white)](${account.url})`,
58
+ );
59
+ } else if (provider === "youtube") {
60
+ const match = account.url.match(/\/(?:@|c(?:hannel)?\/|user\/)([^/?#]+)/);
61
+ const label = match?.[1] || "YouTube";
62
+ badges.push(
63
+ `[![${label}](https://img.shields.io/badge/${shieldsBadgeLabel(label)}-FF0000?style=flat&logo=youtube&logoColor=white)](${account.url})`,
64
+ );
65
+ }
66
+ }
67
+
68
+ return badges.join(" ");
69
+ }
70
+
71
+ // ── Project section helper (modern template) ─────────────────────────────
72
+
73
+ function renderProjectSection(title: string, projects: ProjectItem[]): string {
74
+ if (projects.length === 0) return "";
75
+
76
+ const items = projects
77
+ .map((p) => {
78
+ const desc = p.summary || p.description || "No description";
79
+ const meta: string[] = [];
80
+ if (p.stars > 0) meta.push(`\u2605 ${p.stars}`);
81
+ if (p.languages?.length) meta.push(p.languages.slice(0, 3).join(", "));
82
+ const metaLine = meta.length > 0 ? `${meta.join(" \u00b7 ")}` : "";
83
+ return `### [${p.name}](${p.url})\n${desc}${metaLine ? `\n${metaLine}` : ""}`;
84
+ })
85
+ .join("\n\n");
86
+
87
+ return `## ${title}\n\n${items}`;
88
+ }
89
+
90
+ // ── Project table helper (ecosystem template) ────────────────────────────
91
+
92
+ function renderProjectTable(title: string, projects: ProjectItem[]): string {
93
+ if (projects.length === 0) return "";
94
+
95
+ const header = `| Project | Description |\n|---------|-------------|`;
96
+ const rows = projects
97
+ .map((p) => {
98
+ const desc = p.summary || p.description || "No description";
99
+ const safeDesc = desc.replace(/\|/g, "\\|").replace(/\n/g, " ");
100
+ return `| [${p.name}](${p.url}) | ${safeDesc} |`;
101
+ })
102
+ .join("\n");
103
+
104
+ return `### ${title}\n\n${header}\n${rows}`;
105
+ }
106
+
107
+ // ── Classic template ───────────────────────────────────────────────────────
108
+
109
+ function classicTemplate(ctx: TemplateContext): string {
110
+ const parts: string[] = [];
111
+
112
+ if (ctx.pronunciation) {
113
+ parts.push(`# ${ctx.name} <sub><i>(${ctx.pronunciation})</i></sub>`);
114
+ } else {
115
+ parts.push(`# ${ctx.name}`);
116
+ }
117
+
118
+ if (ctx.title) {
119
+ parts.push(`> ${ctx.title}`);
120
+ }
121
+
122
+ if (ctx.preamble) {
123
+ parts.push(ctx.preamble);
124
+ }
125
+
126
+ if (ctx.socialBadges) {
127
+ parts.push(ctx.socialBadges);
128
+ }
129
+
130
+ for (const svg of ctx.svgs) {
131
+ parts.push(`![${svg.label}](${svg.path})`);
132
+ }
133
+
134
+ if (ctx.bio) {
135
+ parts.push(`---\n\n<sub>${ctx.bio}</sub>`);
136
+ }
137
+
138
+ parts.push(attribution());
139
+
140
+ return `${parts.join("\n\n")}\n`;
141
+ }
142
+
143
+ // ── Modern template ────────────────────────────────────────────────────────
144
+
145
+ function modernTemplate(ctx: TemplateContext): string {
146
+ const parts: string[] = [];
147
+
148
+ parts.push(`# Hi, I'm ${ctx.firstName} 👋`);
149
+
150
+ if (ctx.preamble) {
151
+ parts.push(ctx.preamble);
152
+ }
153
+
154
+ if (ctx.socialBadges) {
155
+ parts.push(ctx.socialBadges);
156
+ }
157
+
158
+ const activeSection = renderProjectSection(
159
+ "Active Projects",
160
+ ctx.activeProjects,
161
+ );
162
+ if (activeSection) parts.push(activeSection);
163
+
164
+ const maintainedSection = renderProjectSection(
165
+ "Maintained Projects",
166
+ ctx.maintainedProjects,
167
+ );
168
+ if (maintainedSection) parts.push(maintainedSection);
169
+
170
+ const inactiveSection = renderProjectSection(
171
+ "Inactive Projects",
172
+ ctx.inactiveProjects,
173
+ );
174
+ if (inactiveSection) parts.push(inactiveSection);
175
+
176
+ // GitHub Stats section: pulse + calendar
177
+ const statsImages: string[] = [];
178
+ if (ctx.sectionSvgs.pulse) {
179
+ statsImages.push(`![At a Glance](${ctx.sectionSvgs.pulse})`);
180
+ }
181
+ if (ctx.sectionSvgs.calendar) {
182
+ statsImages.push(`![Contributions](${ctx.sectionSvgs.calendar})`);
183
+ }
184
+ if (statsImages.length > 0) {
185
+ parts.push(`## GitHub Stats\n\n${statsImages.join("\n")}`);
186
+ }
187
+
188
+ // Other areas of interest: expertise
189
+ if (ctx.sectionSvgs.expertise) {
190
+ parts.push(
191
+ `## Other Areas of Interest\n\n![Expertise](${ctx.sectionSvgs.expertise})`,
192
+ );
193
+ }
194
+
195
+ parts.push(attribution());
196
+
197
+ return `${parts.join("\n\n")}\n`;
198
+ }
199
+
200
+ // ── Minimal template ───────────────────────────────────────────────────────
201
+
202
+ function minimalTemplate(ctx: TemplateContext): string {
203
+ const parts: string[] = [];
204
+
205
+ parts.push(`# ${ctx.firstName}`);
206
+
207
+ if (ctx.preamble) {
208
+ parts.push(ctx.preamble);
209
+ }
210
+
211
+ if (ctx.socialBadges) {
212
+ parts.push(ctx.socialBadges);
213
+ }
214
+
215
+ for (const svg of ctx.svgs) {
216
+ parts.push(`![${svg.label}](${svg.path})`);
217
+ }
218
+
219
+ parts.push(attribution());
220
+
221
+ return `${parts.join("\n\n")}\n`;
222
+ }
223
+
224
+ // ── Ecosystem template ────────────────────────────────────────────────────
225
+
226
+ const CATEGORY_ORDER = [
227
+ "Developer Tools",
228
+ "SDKs",
229
+ "Applications",
230
+ "Research & Experiments",
231
+ ];
232
+
233
+ function ecosystemTemplate(ctx: TemplateContext): string {
234
+ const parts: string[] = [];
235
+
236
+ parts.push(`# Hi, I'm ${ctx.firstName} 👋`);
237
+
238
+ if (ctx.preamble) {
239
+ parts.push(ctx.preamble);
240
+ }
241
+
242
+ if (ctx.socialBadges) {
243
+ parts.push(ctx.socialBadges);
244
+ }
245
+
246
+ // Render project tables grouped by category
247
+ for (const category of CATEGORY_ORDER) {
248
+ const projects = ctx.categorizedProjects[category];
249
+ if (projects && projects.length > 0) {
250
+ parts.push(renderProjectTable(category, projects));
251
+ }
252
+ }
253
+
254
+ // Render any uncategorized projects that don't match known categories
255
+ for (const [category, projects] of Object.entries(ctx.categorizedProjects)) {
256
+ if (!CATEGORY_ORDER.includes(category) && projects.length > 0) {
257
+ parts.push(renderProjectTable(category, projects));
258
+ }
259
+ }
260
+
261
+ // GitHub Stats section: pulse + calendar
262
+ const statsImages: string[] = [];
263
+ if (ctx.sectionSvgs.pulse) {
264
+ statsImages.push(`![At a Glance](${ctx.sectionSvgs.pulse})`);
265
+ }
266
+ if (ctx.sectionSvgs.calendar) {
267
+ statsImages.push(`![Contributions](${ctx.sectionSvgs.calendar})`);
268
+ }
269
+ if (statsImages.length > 0) {
270
+ parts.push(`## GitHub Stats\n\n${statsImages.join("\n")}`);
271
+ }
272
+
273
+ // Other areas of interest: expertise
274
+ if (ctx.sectionSvgs.expertise) {
275
+ parts.push(
276
+ `## Other Areas of Interest\n\n![Expertise](${ctx.sectionSvgs.expertise})`,
277
+ );
278
+ }
279
+
280
+ parts.push(attribution());
281
+
282
+ return `${parts.join("\n\n")}\n`;
283
+ }
284
+
285
+ // ── Registry ───────────────────────────────────────────────────────────────
286
+
287
+ const TEMPLATES: Record<TemplateName, TemplateFunction> = {
288
+ classic: classicTemplate,
289
+ modern: modernTemplate,
290
+ minimal: minimalTemplate,
291
+ ecosystem: ecosystemTemplate,
292
+ };
293
+
294
+ export function getTemplate(name: TemplateName): TemplateFunction {
295
+ return TEMPLATES[name] || TEMPLATES.classic;
296
+ }