@urmzd/github-insights 2.2.0 → 2.4.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 (49) hide show
  1. package/.githooks/.sr-hooks-hash +1 -0
  2. package/.githooks/commit-msg +0 -1
  3. package/.githooks/pre-commit +0 -1
  4. package/CHANGELOG.md +31 -0
  5. package/assets/insights/index.svg +1 -1
  6. package/assets/insights/metrics-constellation.svg +1 -1
  7. package/assets/insights/metrics-growth.svg +55 -0
  8. package/assets/insights/metrics-heatmap.svg +55 -0
  9. package/assets/insights/metrics-impact.svg +1 -1
  10. package/assets/insights/metrics-rhythm.svg +1 -1
  11. package/assets/insights/metrics-velocity.svg +1 -1
  12. package/examples/classic/README.md +16 -37
  13. package/examples/classic/index.svg +1 -1
  14. package/examples/classic/metrics-constellation.svg +1 -1
  15. package/examples/classic/metrics-growth.svg +55 -0
  16. package/examples/classic/metrics-heatmap.svg +55 -0
  17. package/examples/classic/metrics-impact.svg +1 -1
  18. package/examples/classic/metrics-rhythm.svg +1 -1
  19. package/examples/classic/metrics-velocity.svg +1 -1
  20. package/examples/ecosystem/README.md +51 -43
  21. package/examples/ecosystem/index.svg +1 -1
  22. package/examples/ecosystem/metrics-constellation.svg +1 -1
  23. package/examples/ecosystem/metrics-growth.svg +55 -0
  24. package/examples/ecosystem/metrics-heatmap.svg +55 -0
  25. package/examples/ecosystem/metrics-impact.svg +1 -1
  26. package/examples/ecosystem/metrics-rhythm.svg +1 -1
  27. package/examples/ecosystem/metrics-velocity.svg +1 -1
  28. package/examples/minimal/README.md +16 -37
  29. package/examples/minimal/index.svg +1 -1
  30. package/examples/minimal/metrics-constellation.svg +1 -1
  31. package/examples/minimal/metrics-growth.svg +55 -0
  32. package/examples/minimal/metrics-heatmap.svg +55 -0
  33. package/examples/minimal/metrics-impact.svg +1 -1
  34. package/examples/minimal/metrics-rhythm.svg +1 -1
  35. package/examples/minimal/metrics-velocity.svg +1 -1
  36. package/examples/modern/README.md +50 -74
  37. package/examples/modern/index.svg +1 -1
  38. package/examples/modern/metrics-constellation.svg +1 -1
  39. package/examples/modern/metrics-growth.svg +55 -0
  40. package/examples/modern/metrics-heatmap.svg +55 -0
  41. package/examples/modern/metrics-impact.svg +1 -1
  42. package/examples/modern/metrics-rhythm.svg +1 -1
  43. package/examples/modern/metrics-velocity.svg +1 -1
  44. package/package.json +1 -1
  45. package/src/components/contribution-heatmap.tsx +43 -0
  46. package/src/components/growth-arc.tsx +119 -0
  47. package/src/templates.test.ts +17 -42
  48. package/src/templates.ts +142 -57
  49. package/src/types.ts +6 -0
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
  import { makeContributionData, makeUserProfile } from "./__fixtures__/repos.js";
3
3
  import {
4
4
  buildSocialBadges,
5
+ descriptiveAlt,
5
6
  extractFirstName,
6
7
  getTemplate,
7
8
  shieldsBadgeLabel,
@@ -219,9 +220,11 @@ describe("classicTemplate", () => {
219
220
  expect(output).toContain("A software developer in Austin, TX.");
220
221
  });
221
222
 
222
- it("includes SVG embeds", () => {
223
+ it("includes SVG embeds with descriptive alt text", () => {
223
224
  const output = getTemplate("classic")(makeContext());
224
- expect(output).toContain("![GitHub Metrics](assets/insights/index.svg)");
225
+ expect(output).toContain(
226
+ `![${descriptiveAlt("GitHub Metrics", "Urmzd Maharramoff")}](assets/insights/index.svg)`,
227
+ );
225
228
  });
226
229
 
227
230
  it("includes social badges", () => {
@@ -246,7 +249,7 @@ describe("classicTemplate", () => {
246
249
  expect(output).toContain("/ˈʊrm.zəd/");
247
250
  });
248
251
 
249
- it("includes archived section for archived projects", () => {
252
+ it("omits archived section", () => {
250
253
  const output = getTemplate("classic")(
251
254
  makeContext({
252
255
  archivedProjects: [
@@ -259,12 +262,6 @@ describe("classicTemplate", () => {
259
262
  ],
260
263
  }),
261
264
  );
262
- expect(output).toContain("## Archived");
263
- expect(output).toContain("[old-project]");
264
- });
265
-
266
- it("omits archived section when no archived projects", () => {
267
- const output = getTemplate("classic")(makeContext());
268
265
  expect(output).not.toContain("## Archived");
269
266
  });
270
267
 
@@ -293,7 +290,7 @@ describe("modernTemplate", () => {
293
290
  expect(output).toContain(
294
291
  "### [resume-generator](https://github.com/urmzd/resume-generator)",
295
292
  );
296
- expect(output).toContain("\u2605 42");
293
+ expect(output).toContain("Stars: 42");
297
294
  });
298
295
 
299
296
  it("includes maintained projects section with h3 headings", () => {
@@ -334,7 +331,7 @@ describe("modernTemplate", () => {
334
331
  expect(output).toContain("assets/insights/metrics-constellation.svg");
335
332
  });
336
333
 
337
- it("includes archived section separate from active/maintained", () => {
334
+ it("omits archived section", () => {
338
335
  const output = getTemplate("modern")(
339
336
  makeContext({
340
337
  archivedProjects: [
@@ -347,8 +344,7 @@ describe("modernTemplate", () => {
347
344
  ],
348
345
  }),
349
346
  );
350
- expect(output).toContain("## Archived");
351
- expect(output).toContain("[legacy-lib]");
347
+ expect(output).not.toContain("## Archived");
352
348
  });
353
349
 
354
350
  it("includes social badges", () => {
@@ -375,9 +371,11 @@ describe("minimalTemplate", () => {
375
371
  expect(output).toContain("A software developer in Austin, TX.");
376
372
  });
377
373
 
378
- it("includes SVG embeds", () => {
374
+ it("includes SVG embeds with descriptive alt text", () => {
379
375
  const output = getTemplate("minimal")(makeContext());
380
- expect(output).toContain("![GitHub Metrics](assets/insights/index.svg)");
376
+ expect(output).toContain(
377
+ `![${descriptiveAlt("GitHub Metrics", "Urmzd Maharramoff")}](assets/insights/index.svg)`,
378
+ );
381
379
  });
382
380
 
383
381
  it("includes social badges", () => {
@@ -390,7 +388,7 @@ describe("minimalTemplate", () => {
390
388
  expect(output).toContain("@urmzd/github-insights");
391
389
  });
392
390
 
393
- it("includes archived section for archived projects", () => {
391
+ it("omits archived section", () => {
394
392
  const output = getTemplate("minimal")(
395
393
  makeContext({
396
394
  archivedProjects: [
@@ -403,8 +401,7 @@ describe("minimalTemplate", () => {
403
401
  ],
404
402
  }),
405
403
  );
406
- expect(output).toContain("## Archived");
407
- expect(output).toContain("[old-util]");
404
+ expect(output).not.toContain("## Archived");
408
405
  });
409
406
 
410
407
  it("ends with trailing newline", () => {
@@ -466,7 +463,7 @@ describe("ecosystemTemplate", () => {
466
463
  expect(output).toContain("@urmzd/github-insights");
467
464
  });
468
465
 
469
- it("separates archived projects from category tables", () => {
466
+ it("omits archived section", () => {
470
467
  const output = getTemplate("ecosystem")(
471
468
  makeContext({
472
469
  archivedProjects: [
@@ -478,31 +475,9 @@ describe("ecosystemTemplate", () => {
478
475
  category: "Applications",
479
476
  },
480
477
  ],
481
- categorizedProjects: {
482
- Applications: [
483
- {
484
- name: "resume-generator",
485
- url: "https://github.com/urmzd/resume-generator",
486
- description: "CLI tool for professional resumes",
487
- stars: 42,
488
- category: "Applications",
489
- },
490
- {
491
- name: "old-app",
492
- url: "https://github.com/urmzd/old-app",
493
- description: "A retired application",
494
- stars: 3,
495
- category: "Applications",
496
- },
497
- ],
498
- },
499
478
  }),
500
479
  );
501
- expect(output).toContain("### Archived");
502
- expect(output).toContain("[old-app]");
503
- // old-app should NOT appear in the Applications table
504
- const appSection = output.split("### Applications")[1].split("###")[0];
505
- expect(appSection).not.toContain("old-app");
480
+ expect(output).not.toContain("### Archived");
506
481
  });
507
482
 
508
483
  it("ends with trailing newline", () => {
package/src/templates.ts CHANGED
@@ -10,7 +10,51 @@ import type {
10
10
 
11
11
  function attribution(templateName: string): string {
12
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) · Template: \`${templateName}\`</sub>`;
13
+ return `<!-- section: footer -->\n<sub>Last generated on ${now} using [@urmzd/github-insights](https://github.com/urmzd/github-insights) · Template: \`${templateName}\`</sub>`;
14
+ }
15
+
16
+ function frontmatter(ctx: TemplateContext): string {
17
+ const langs = ctx.languages.slice(0, 10).map((l) => l.name);
18
+ const lines = [
19
+ "<!-- ai-metadata",
20
+ `type: github-profile`,
21
+ `name: ${ctx.name}`,
22
+ `username: ${ctx.username}`,
23
+ ...(ctx.title ? [`title: ${ctx.title}`] : []),
24
+ `languages: [${langs.join(", ")}]`,
25
+ `profile: https://github.com/${ctx.username}`,
26
+ "-->",
27
+ ];
28
+ return lines.join("\n");
29
+ }
30
+
31
+ const ALT_TEXT_MAP: Record<string, string> = {
32
+ "GitHub Metrics":
33
+ "Combined visualization of language velocity, contribution rhythm, project constellation, and open source impact for {name}",
34
+ "Language Velocity":
35
+ "Streamgraph of {name}'s programming language usage over the past year",
36
+ "Contribution Rhythm":
37
+ "Radar chart of {name}'s contribution patterns by day of week",
38
+ "Project Constellation":
39
+ "Map of {name}'s projects positioned by language ecosystem and complexity",
40
+ "Impact Trail":
41
+ "Bar chart of {name}'s open source contributions by repository star count",
42
+ };
43
+
44
+ export function descriptiveAlt(label: string, name: string): string {
45
+ const template = ALT_TEXT_MAP[label];
46
+ if (template) return template.replace(/\{name\}/g, name);
47
+ return label;
48
+ }
49
+
50
+ function inlineMetadata(ctx: TemplateContext): string {
51
+ const parts: string[] = [];
52
+ if (ctx.title) parts.push(`**Role:** ${ctx.title}`);
53
+ const topLangs = ctx.languages.slice(0, 5).map((l) => l.name);
54
+ if (topLangs.length > 0)
55
+ parts.push(`**Top Languages:** ${topLangs.join(", ")}`);
56
+ if (parts.length === 0) return "";
57
+ return parts.join(" | ");
14
58
  }
15
59
 
16
60
  export function extractFirstName(fullName: string): string {
@@ -77,8 +121,9 @@ function renderProjectSection(title: string, projects: ProjectItem[]): string {
77
121
  .map((p) => {
78
122
  const desc = p.summary || p.description || "No description";
79
123
  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(", "));
124
+ if (p.stars > 0) meta.push(`Stars: ${p.stars}`);
125
+ if (p.languages?.length)
126
+ meta.push(`Languages: ${p.languages.slice(0, 3).join(", ")}`);
82
127
  const metaLine = meta.length > 0 ? `${meta.join(" \u00b7 ")}` : "";
83
128
  return `### [${p.name}](${p.url})\n${desc}${metaLine ? `\n${metaLine}` : ""}`;
84
129
  })
@@ -92,12 +137,16 @@ function renderProjectSection(title: string, projects: ProjectItem[]): string {
92
137
  function renderProjectTable(title: string, projects: ProjectItem[]): string {
93
138
  if (projects.length === 0) return "";
94
139
 
95
- const header = `| Project | Description |\n|---------|-------------|`;
140
+ const header = `| Project | Description | Stars | Languages |\n|---------|-------------|-------|-----------|`;
96
141
  const rows = projects
97
142
  .map((p) => {
98
143
  const desc = p.summary || p.description || "No description";
99
144
  const safeDesc = desc.replace(/\|/g, "\\|").replace(/\n/g, " ");
100
- return `| [${p.name}](${p.url}) | ${safeDesc} |`;
145
+ const stars = p.stars > 0 ? String(p.stars) : "-";
146
+ const langs = p.languages?.length
147
+ ? p.languages.slice(0, 3).join(", ")
148
+ : "-";
149
+ return `| [${p.name}](${p.url}) | ${safeDesc} | ${stars} | ${langs} |`;
101
150
  })
102
151
  .join("\n");
103
152
 
@@ -109,6 +158,8 @@ function renderProjectTable(title: string, projects: ProjectItem[]): string {
109
158
  function classicTemplate(ctx: TemplateContext): string {
110
159
  const parts: string[] = [];
111
160
 
161
+ parts.push(frontmatter(ctx));
162
+
112
163
  if (ctx.pronunciation) {
113
164
  parts.push(`# ${ctx.name} <sub><i>(${ctx.pronunciation})</i></sub>`);
114
165
  } else {
@@ -123,16 +174,18 @@ function classicTemplate(ctx: TemplateContext): string {
123
174
  parts.push(ctx.preamble);
124
175
  }
125
176
 
126
- if (ctx.socialBadges) {
127
- parts.push(ctx.socialBadges);
128
- }
177
+ const meta = inlineMetadata(ctx);
178
+ if (meta) parts.push(meta);
129
179
 
130
- for (const svg of ctx.svgs) {
131
- parts.push(`![${svg.label}](${svg.path})`);
180
+ if (ctx.socialBadges) {
181
+ parts.push(`<!-- section: social -->\n${ctx.socialBadges}`);
132
182
  }
133
183
 
134
- if (ctx.archivedProjects.length > 0) {
135
- parts.push(renderProjectSection("Archived", ctx.archivedProjects));
184
+ if (ctx.svgs.length > 0) {
185
+ const svgLines = ctx.svgs
186
+ .map((svg) => `![${descriptiveAlt(svg.label, ctx.name)}](${svg.path})`)
187
+ .join("\n");
188
+ parts.push(`<!-- section: visualizations -->\n${svgLines}`);
136
189
  }
137
190
 
138
191
  if (ctx.bio) {
@@ -149,66 +202,82 @@ function classicTemplate(ctx: TemplateContext): string {
149
202
  function modernTemplate(ctx: TemplateContext): string {
150
203
  const parts: string[] = [];
151
204
 
205
+ parts.push(frontmatter(ctx));
206
+
152
207
  parts.push(`# Hi, I'm ${ctx.firstName} 👋`);
153
208
 
154
209
  if (ctx.preamble) {
155
210
  parts.push(ctx.preamble);
156
211
  }
157
212
 
213
+ const meta = inlineMetadata(ctx);
214
+ if (meta) parts.push(meta);
215
+
158
216
  if (ctx.socialBadges) {
159
- parts.push(ctx.socialBadges);
217
+ parts.push(`<!-- section: social -->\n${ctx.socialBadges}`);
160
218
  }
161
219
 
220
+ // Projects
221
+ const projectSections: string[] = [];
162
222
  const activeSection = renderProjectSection(
163
223
  "Active Projects",
164
224
  ctx.activeProjects,
165
225
  );
166
- if (activeSection) parts.push(activeSection);
226
+ if (activeSection) projectSections.push(activeSection);
167
227
 
168
228
  const maintainedSection = renderProjectSection(
169
229
  "Maintained Projects",
170
230
  ctx.maintainedProjects,
171
231
  );
172
- if (maintainedSection) parts.push(maintainedSection);
232
+ if (maintainedSection) projectSections.push(maintainedSection);
173
233
 
174
234
  const inactiveSection = renderProjectSection(
175
235
  "Inactive Projects",
176
236
  ctx.inactiveProjects,
177
237
  );
178
- if (inactiveSection) parts.push(inactiveSection);
238
+ if (inactiveSection) projectSections.push(inactiveSection);
179
239
 
180
- const archivedSection = renderProjectSection(
181
- "Archived",
182
- ctx.archivedProjects,
183
- );
184
- if (archivedSection) parts.push(archivedSection);
240
+ if (projectSections.length > 0) {
241
+ parts.push(`<!-- section: projects -->\n${projectSections.join("\n\n")}`);
242
+ }
243
+
244
+ // Visualizations
245
+ const vizParts: string[] = [];
185
246
 
186
247
  // Constellation
187
248
  if (ctx.sectionSvgs.constellation) {
188
- parts.push(
189
- `## Project Map\n\n![Project Constellation](${ctx.sectionSvgs.constellation})`,
249
+ vizParts.push(
250
+ `## Project Map\n\n![${descriptiveAlt("Project Constellation", ctx.name)}](${ctx.sectionSvgs.constellation})`,
190
251
  );
191
252
  }
192
253
 
193
254
  // GitHub Stats section: rhythm + velocity
194
255
  const statsImages: string[] = [];
195
256
  if (ctx.sectionSvgs.velocity) {
196
- statsImages.push(`![Language Velocity](${ctx.sectionSvgs.velocity})`);
257
+ statsImages.push(
258
+ `![${descriptiveAlt("Language Velocity", ctx.name)}](${ctx.sectionSvgs.velocity})`,
259
+ );
197
260
  }
198
261
  if (ctx.sectionSvgs.rhythm) {
199
- statsImages.push(`![Contribution Rhythm](${ctx.sectionSvgs.rhythm})`);
262
+ statsImages.push(
263
+ `![${descriptiveAlt("Contribution Rhythm", ctx.name)}](${ctx.sectionSvgs.rhythm})`,
264
+ );
200
265
  }
201
266
  if (statsImages.length > 0) {
202
- parts.push(`## GitHub Stats\n\n${statsImages.join("\n")}`);
267
+ vizParts.push(`## GitHub Stats\n\n${statsImages.join("\n")}`);
203
268
  }
204
269
 
205
270
  // Impact
206
271
  if (ctx.sectionSvgs.impact) {
207
- parts.push(
208
- `## Open Source Impact\n\n![Impact Trail](${ctx.sectionSvgs.impact})`,
272
+ vizParts.push(
273
+ `## Open Source Impact\n\n![${descriptiveAlt("Impact Trail", ctx.name)}](${ctx.sectionSvgs.impact})`,
209
274
  );
210
275
  }
211
276
 
277
+ if (vizParts.length > 0) {
278
+ parts.push(`<!-- section: visualizations -->\n${vizParts.join("\n\n")}`);
279
+ }
280
+
212
281
  parts.push(attribution(ctx.templateName));
213
282
 
214
283
  return `${parts.join("\n\n")}\n`;
@@ -219,22 +288,26 @@ function modernTemplate(ctx: TemplateContext): string {
219
288
  function minimalTemplate(ctx: TemplateContext): string {
220
289
  const parts: string[] = [];
221
290
 
291
+ parts.push(frontmatter(ctx));
292
+
222
293
  parts.push(`# ${ctx.firstName}`);
223
294
 
224
295
  if (ctx.preamble) {
225
296
  parts.push(ctx.preamble);
226
297
  }
227
298
 
228
- if (ctx.socialBadges) {
229
- parts.push(ctx.socialBadges);
230
- }
299
+ const meta = inlineMetadata(ctx);
300
+ if (meta) parts.push(meta);
231
301
 
232
- for (const svg of ctx.svgs) {
233
- parts.push(`![${svg.label}](${svg.path})`);
302
+ if (ctx.socialBadges) {
303
+ parts.push(`<!-- section: social -->\n${ctx.socialBadges}`);
234
304
  }
235
305
 
236
- if (ctx.archivedProjects.length > 0) {
237
- parts.push(renderProjectSection("Archived", ctx.archivedProjects));
306
+ if (ctx.svgs.length > 0) {
307
+ const svgLines = ctx.svgs
308
+ .map((svg) => `![${descriptiveAlt(svg.label, ctx.name)}](${svg.path})`)
309
+ .join("\n");
310
+ parts.push(`<!-- section: visualizations -->\n${svgLines}`);
238
311
  }
239
312
 
240
313
  parts.push(attribution(ctx.templateName));
@@ -254,70 +327,82 @@ const CATEGORY_ORDER = [
254
327
  function ecosystemTemplate(ctx: TemplateContext): string {
255
328
  const parts: string[] = [];
256
329
 
330
+ parts.push(frontmatter(ctx));
331
+
257
332
  parts.push(`# Hi, I'm ${ctx.firstName} 👋`);
258
333
 
259
334
  if (ctx.preamble) {
260
335
  parts.push(ctx.preamble);
261
336
  }
262
337
 
338
+ const meta = inlineMetadata(ctx);
339
+ if (meta) parts.push(meta);
340
+
263
341
  if (ctx.socialBadges) {
264
- parts.push(ctx.socialBadges);
342
+ parts.push(`<!-- section: social -->\n${ctx.socialBadges}`);
265
343
  }
266
344
 
267
- // Build a set of archived project names to filter them out of category tables
268
- const archivedNames = new Set(ctx.archivedProjects.map((p) => p.name));
345
+ // Projects
346
+ const projectParts: string[] = [];
269
347
 
270
- // Render project tables grouped by category (excluding archived)
348
+ // Render project tables grouped by category
271
349
  for (const category of CATEGORY_ORDER) {
272
- const projects = ctx.categorizedProjects[category]?.filter(
273
- (p) => !archivedNames.has(p.name),
274
- );
350
+ const projects = ctx.categorizedProjects[category];
275
351
  if (projects && projects.length > 0) {
276
- parts.push(renderProjectTable(category, projects));
352
+ projectParts.push(renderProjectTable(category, projects));
277
353
  }
278
354
  }
279
355
 
280
- // Render any uncategorized projects that don't match known categories (excluding archived)
356
+ // Render any uncategorized projects that don't match known categories
281
357
  for (const [category, projects] of Object.entries(ctx.categorizedProjects)) {
282
358
  if (!CATEGORY_ORDER.includes(category)) {
283
- const nonArchived = projects.filter((p) => !archivedNames.has(p.name));
284
- if (nonArchived.length > 0) {
285
- parts.push(renderProjectTable(category, nonArchived));
359
+ if (projects.length > 0) {
360
+ projectParts.push(renderProjectTable(category, projects));
286
361
  }
287
362
  }
288
363
  }
289
364
 
290
- // Render all archived projects in one consolidated section
291
- if (ctx.archivedProjects.length > 0) {
292
- parts.push(renderProjectTable("Archived", ctx.archivedProjects));
365
+ if (projectParts.length > 0) {
366
+ parts.push(`<!-- section: projects -->\n${projectParts.join("\n\n")}`);
293
367
  }
294
368
 
369
+ // Visualizations
370
+ const vizParts: string[] = [];
371
+
295
372
  // Constellation
296
373
  if (ctx.sectionSvgs.constellation) {
297
- parts.push(
298
- `## Project Map\n\n![Project Constellation](${ctx.sectionSvgs.constellation})`,
374
+ vizParts.push(
375
+ `## Project Map\n\n![${descriptiveAlt("Project Constellation", ctx.name)}](${ctx.sectionSvgs.constellation})`,
299
376
  );
300
377
  }
301
378
 
302
379
  // GitHub Stats section: velocity + rhythm
303
380
  const statsImages: string[] = [];
304
381
  if (ctx.sectionSvgs.velocity) {
305
- statsImages.push(`![Language Velocity](${ctx.sectionSvgs.velocity})`);
382
+ statsImages.push(
383
+ `![${descriptiveAlt("Language Velocity", ctx.name)}](${ctx.sectionSvgs.velocity})`,
384
+ );
306
385
  }
307
386
  if (ctx.sectionSvgs.rhythm) {
308
- statsImages.push(`![Contribution Rhythm](${ctx.sectionSvgs.rhythm})`);
387
+ statsImages.push(
388
+ `![${descriptiveAlt("Contribution Rhythm", ctx.name)}](${ctx.sectionSvgs.rhythm})`,
389
+ );
309
390
  }
310
391
  if (statsImages.length > 0) {
311
- parts.push(`## GitHub Stats\n\n${statsImages.join("\n")}`);
392
+ vizParts.push(`## GitHub Stats\n\n${statsImages.join("\n")}`);
312
393
  }
313
394
 
314
395
  // Impact
315
396
  if (ctx.sectionSvgs.impact) {
316
- parts.push(
317
- `## Open Source Impact\n\n![Impact Trail](${ctx.sectionSvgs.impact})`,
397
+ vizParts.push(
398
+ `## Open Source Impact\n\n![${descriptiveAlt("Impact Trail", ctx.name)}](${ctx.sectionSvgs.impact})`,
318
399
  );
319
400
  }
320
401
 
402
+ if (vizParts.length > 0) {
403
+ parts.push(`<!-- section: visualizations -->\n${vizParts.join("\n\n")}`);
404
+ }
405
+
321
406
  parts.push(attribution(ctx.templateName));
322
407
 
323
408
  return `${parts.join("\n\n")}\n`;
package/src/types.ts CHANGED
@@ -166,6 +166,12 @@ export interface ContributionRhythm {
166
166
 
167
167
  // ── Project constellation ─────────────────────────────────────────────────
168
168
 
169
+ export interface GrowthArcPoint {
170
+ label: string;
171
+ avgComplexity: number;
172
+ repoCount: number;
173
+ }
174
+
169
175
  export interface ConstellationNode {
170
176
  name: string;
171
177
  url: string;