diff --git a/docs/customization.md b/docs/customization.md
index c3298984e..fcbf197fe 100644
--- a/docs/customization.md
+++ b/docs/customization.md
@@ -59,44 +59,44 @@ URL Parameter > Theme Default > System Fallback
> All parameters below are optional except `user`. Append them to the base URL as query string key-value pairs (e.g. `?user=YOUR_USERNAME&theme=neon&size=large`). Boolean parameters accept `true` or `false`. Hex color values are provided **without** the `#` prefix.
-| Parameter | Description | Default | Allowed Values / Constraints | Example |
-| ----------------- | ----------------------------------------------------------------------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
-| `user` | GitHub username to render (Required) | — | Any valid GitHub username | `?user=jhasourav07` |
-| `theme` | Preset theme name | `dark` | `auto`, `dark`, `neon`, `dracula`, `github`, `light`, `gruvbox`, `random`, etc. | `?theme=dracula` |
-| `bg` | Background color | Theme default | Hex color code (without `#`) | `?bg=0d1117` |
-| `accent` | Tower & glow color | Theme default | Hex color code (without `#`) | `?accent=58a6ff` |
-| `text` | Label & stat text color | Theme default | Hex color code (without `#`) | `?text=c9d1d9` |
-| `radius` | Border corner radius in pixels | `8` | Numeric value | `?radius=16` |
-| `border` | Custom stroke color around the SVG container | — | Hex color code (without `#`) | `?border=ff0000` |
-| `speed` | Radar scan duration | `8s` | `2s`–`20s` | `?speed=4s` |
-| `scale` | Tower height scaling | `linear` | `linear`, `log`, `sqrt` | `?scale=sqrt` |
-| `size` | Badge dimensions | `medium` | `small`, `medium`, `large` | `?size=large` |
-| `font` | Custom font for text | Default typography | Any valid Google Font name | `?font=Orbitron` |
-| `refresh` | Bypass cache for real-time data | `false` | `true`, `false` | `?refresh=true` |
-| `year` | Calendar year to render | Current year | `2023`, `2024`, etc. | `?year=2023` |
-| `hide_title` | Hide GitHub username/title | `false` | `true`, `false` | `?hide_title=true` |
-| `custom_title` | Custom header title text | — | Any text string (XML-escaped) | `?custom_title=My%20Pulse` |
-| `custom_subtitle` | Custom subtitle text right below title | — | Any text string (XML-escaped) | `?custom_subtitle=Dev` |
-| `hide_background` | Remove the background rect | `false` | `true`, `false` | `?hide_background=true` |
-| `hide_stats` | Hide bottom row displaying stats | `false` | `true`, `false` | `?hide_stats=true` |
-| `tz` | IANA timezone | `UTC` | Valid IANA timezone | `?tz=Asia/Kolkata` |
-| `lang` | Language code for labels | `en` | `en`, `es`, `hi`, `fr`, `pt`, `ko`, `ja`, `de`, `zh` | `?lang=hi` |
-| `view` | Rendering mode | `default` | `default`, `monthly`, `heatmap`, `pulse`, `skyline`, `languages`, `constellation`, `weekday`, `radar`, `doughnut`, `pie`, `activity_graph`, `commit_clock` | `?view=commit_clock` | | `entrance` | Entrance animation for towers | `rise` | `rise`, `fade`, `slide`, `none` | `?entrance=fade` |
-| `delta_format` | Month-over-month delta format (`view=monthly`) | `percent` | `percent`, `absolute`, `both` | `?delta_format=absolute` |
-| `width` | Custom width for SVG canvas (`view=monthly`, `pulse`, `skyline`) | `300` | Numeric value | `?width=400` |
-| `height` | Custom height for SVG canvas (`view=monthly`, `pulse`, `skyline`) | `120` | Numeric value | `?height=150` |
-| `grace` | Grace period in days before streak resets (see [Grace Period Examples](#grace-period-examples)) | `1` | `0`–`7` | `?grace=2` |
-| `mode` | Base data rendering mode | `commits` | `commits`, `loc` | `?mode=loc` |
-| `repo` | Render monolith for a specific repository | — | `owner/repo` | `?repo=vercel/next.js` |
-| `org` | Organization name to generate a Mega-City for | — | Valid GitHub organization name | `?org=vercel` |
-| `labels` | Render optional isometric month/weekday labels | `false` | `true`, `false` | `?labels=true` |
-| `labelColor` | Custom text color for isometric labels | — | Hex color code (without `#`) | `?labelColor=ffffff` |
-| `versus` | Compare against an opponent side-by-side | — | Any valid GitHub username | `?versus=octocat` |
-| `shading` | Apply intensity-based opacity shading to tower faces | `false` | `true`, `false` | `?shading=true` |
-| `dim_weekends` | Dim weekend towers (Saturdays and Sundays) | `false` | `true`, `false` | `?dim_weekends=true` |
-| `opacity` | Global opacity scalar for tower fill | `1.0` | `0.1`–`1.0` | `?opacity=0.8` |
-| `gradient` | Show volumetric gradients on the floor | `false` | `true`, `false` | `?gradient=true` |
-| `minify` | Enable SVG minification and payload optimization | `true` | `true`, `false` | `?minify=false` |
+| Parameter | Description | Default | Allowed Values / Constraints | Example |
+| ----------------- | ----------------------------------------------------------------------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | --- | ---------- | ----------------------------- | ------ | ------------------------------- | ---------------- |
+| `user` | GitHub username to render (Required) | — | Any valid GitHub username | `?user=jhasourav07` |
+| `theme` | Preset theme name | `dark` | `auto`, `dark`, `neon`, `dracula`, `github`, `light`, `gruvbox`, `random`, etc. | `?theme=dracula` |
+| `bg` | Background color | Theme default | Hex color code (without `#`) | `?bg=0d1117` |
+| `accent` | Tower & glow color | Theme default | Hex color code (without `#`) | `?accent=58a6ff` |
+| `text` | Label & stat text color | Theme default | Hex color code (without `#`) | `?text=c9d1d9` |
+| `radius` | Border corner radius in pixels | `8` | Numeric value | `?radius=16` |
+| `border` | Custom stroke color around the SVG container | — | Hex color code (without `#`) | `?border=ff0000` |
+| `speed` | Radar scan duration | `8s` | `2s`–`20s` | `?speed=4s` |
+| `scale` | Tower height scaling | `linear` | `linear`, `log`, `sqrt` | `?scale=sqrt` |
+| `size` | Badge dimensions | `medium` | `small`, `medium`, `large` | `?size=large` |
+| `font` | Custom font for text | Default typography | Any valid Google Font name | `?font=Orbitron` |
+| `refresh` | Bypass cache for real-time data | `false` | `true`, `false` | `?refresh=true` |
+| `year` | Calendar year to render | Current year | `2023`, `2024`, etc. | `?year=2023` |
+| `hide_title` | Hide GitHub username/title | `false` | `true`, `false` | `?hide_title=true` |
+| `custom_title` | Custom header title text | — | Any text string (XML-escaped) | `?custom_title=My%20Pulse` |
+| `custom_subtitle` | Custom subtitle text right below title | — | Any text string (XML-escaped) | `?custom_subtitle=Dev` |
+| `hide_background` | Remove the background rect | `false` | `true`, `false` | `?hide_background=true` |
+| `hide_stats` | Hide bottom row displaying stats | `false` | `true`, `false` | `?hide_stats=true` |
+| `tz` | IANA timezone | `UTC` | Valid IANA timezone | `?tz=Asia/Kolkata` |
+| `lang` | Language code for labels | `en` | `en`, `es`, `hi`, `fr`, `pt`, `ko`, `ja`, `de`, `zh` | `?lang=hi` |
+| `view` | Rendering mode | `default` | `default`, `monthly`, `heatmap`, `pulse`, `skyline`, `languages`, `constellation`, `weekday`, `radar`, `doughnut`, `pie`, `activity_graph`, `commit_clock` | `?view=commit_clock` | | `entrance` | Entrance animation for towers | `rise` | `rise`, `fade`, `slide`, `none` | `?entrance=fade` |
+| `delta_format` | Month-over-month delta format (`view=monthly`) | `percent` | `percent`, `absolute`, `both` | `?delta_format=absolute` |
+| `width` | Custom width for SVG canvas (`view=monthly`, `pulse`, `skyline`) | `300` | Numeric value | `?width=400` |
+| `height` | Custom height for SVG canvas (`view=monthly`, `pulse`, `skyline`) | `120` | Numeric value | `?height=150` |
+| `grace` | Grace period in days before streak resets (see [Grace Period Examples](#grace-period-examples)) | `1` | `0`–`7` | `?grace=2` |
+| `mode` | Base data rendering mode | `commits` | `commits`, `loc` | `?mode=loc` |
+| `repo` | Render monolith for a specific repository | — | `owner/repo` | `?repo=vercel/next.js` |
+| `org` | Organization name to generate a Mega-City for | — | Valid GitHub organization name | `?org=vercel` |
+| `labels` | Render optional isometric month/weekday labels | `false` | `true`, `false` | `?labels=true` |
+| `labelColor` | Custom text color for isometric labels | — | Hex color code (without `#`) | `?labelColor=ffffff` |
+| `versus` | Compare against an opponent side-by-side | — | Any valid GitHub username | `?versus=octocat` |
+| `shading` | Apply intensity-based opacity shading to tower faces | `false` | `true`, `false` | `?shading=true` |
+| `dim_weekends` | Dim weekend towers (Saturdays and Sundays) | `false` | `true`, `false` | `?dim_weekends=true` |
+| `opacity` | Global opacity scalar for tower fill | `1.0` | `0.1`–`1.0` | `?opacity=0.8` |
+| `gradient` | Show volumetric gradients on the floor | `false` | `true`, `false` | `?gradient=true` |
+| `minify` | Enable SVG minification and payload optimization | `true` | `true`, `false` | `?minify=false` |
---
diff --git a/lib/svg/generator.ts b/lib/svg/generator.ts
index 3c3a1a2f4..6c6991bb8 100644
--- a/lib/svg/generator.ts
+++ b/lib/svg/generator.ts
@@ -27,6 +27,11 @@ import {
sanitizeSpeed,
sanitizeCustomText,
} from './sanitizer';
+import {
+ aggregateTechStack,
+ buildLanguageColorPalette,
+ type TechStackSummary,
+} from './techStackAnalytics';
import {
GRID_ORIGIN_X,
@@ -4046,3 +4051,232 @@ export function buildInlineErrorSVG(text: string, options?: ErrorSVGOptions): st
}
`;
}
+
+// ─── Tech Stack View ──────────────────────────────────────────────────────────
+
+/**
+ * Renders the SVG legend for the tech stack view.
+ * Shows top languages as colored swatches with name + percentage labels.
+ */
+function renderTechStackLegend(
+ summary: TechStackSummary,
+ sf: number,
+ text: string,
+ bg: string,
+ statsFont: string
+): string {
+ if (summary.topLanguages.length === 0) return '';
+
+ const fs = (n: number): number => Math.round(n * sf * 10) / 10;
+ const s = createScaler(sf);
+
+ const LEGEND_X = s(430);
+ const LEGEND_Y = s(90);
+ const ROW_H = s(20);
+ const SWATCH_SIZE = s(10);
+ const CORNER_R = s(2);
+ const PANEL_W = s(155);
+ const PANEL_H = s(22 + summary.topLanguages.length * 20 + 10);
+
+ const isLightBg = getLuminance(bg) > 0.5;
+ const panelFill = isLightBg ? '#00000015' : '#ffffff10';
+ const panelStroke = isLightBg ? '#00000030' : '#ffffff20';
+
+ let rows = '';
+ summary.topLanguages.forEach((lang, i) => {
+ const y = LEGEND_Y + s(22) + i * ROW_H;
+ const hexColor = lang.color.startsWith('#') ? lang.color : `#${lang.color}`;
+ rows += `
+
+ ${escapeXML(lang.language)}
+ ${lang.percentage}%`;
+ });
+
+ return `
+
+
+ TECH STACK
+ ${rows}
+ `;
+}
+
+/**
+ * Renders an archetype badge below the legend.
+ */
+function renderArchetypeBadge(
+ archetype: string,
+ sf: number,
+ text: string,
+ accentColor: string,
+ statsFont: string,
+ legendYOffset: number
+): string {
+ const fs = (n: number): number => Math.round(n * sf * 10) / 10;
+ const s = createScaler(sf);
+
+ const BADGE_X = s(432);
+ const BADGE_Y = s(legendYOffset);
+ const BADGE_W = s(150);
+ const BADGE_H = s(18);
+ const CORNER_R = s(9);
+
+ const hexAccent = accentColor.startsWith('#') ? accentColor : `#${accentColor}`;
+
+ return `
+
+
+ ${escapeXML(archetype)}
+ `;
+}
+
+/**
+ * Generates the language-aware isometric monolith SVG — `view=techstack`.
+ *
+ * Tower colors are driven by the contributor's dominant programming language.
+ * A legend panel on the right side lists the top languages with color swatches.
+ * A developer archetype badge is displayed below the legend.
+ *
+ * @param stats - Streak stats for the user
+ * @param params - Badge rendering params
+ * @param calendar - Contribution calendar data
+ * @param repoContributions - Repository contributions with language data
+ */
+export function generateTechStackSVG(
+ stats: import('../../types').StreakStats,
+ params: BadgeParams,
+ calendar: ContributionCalendar,
+ repoContributions: import('../../types').RepoContribution[]
+): string {
+ if (params.autoTheme) {
+ // Delegate to auto-theme variant with techstack accent colors injected
+ const summary = aggregateTechStack(repoContributions);
+ const palette = buildLanguageColorPalette(summary);
+ const techParams: BadgeParams = {
+ ...params,
+ accent: palette as import('../../types').HexColor[],
+ };
+ return generateAutoThemeSVG(stats, techParams, calendar);
+ }
+
+ const rawBorderWidth = String(params.border || '').trim();
+ let safeBorderWidth: string | number = 0;
+ if (/^\d+$/.test(rawBorderWidth)) {
+ safeBorderWidth = parseInt(rawBorderWidth, 10);
+ } else if (
+ /^#[0-9a-fA-F]{3,6}$/.test(rawBorderWidth) ||
+ /^[0-9a-fA-F]{3,6}$/.test(rawBorderWidth)
+ ) {
+ safeBorderWidth = 2;
+ } else if (['none', 'thin', 'medium', 'thick'].includes(rawBorderWidth.toLowerCase())) {
+ safeBorderWidth = rawBorderWidth.toLowerCase();
+ }
+
+ const animate = params.animate ?? true;
+ const safeUser = escapeXML(params.user || 'GitHub User');
+ const bg = `#${sanitizeHexColor(params.bg, '0d1117')}`;
+ const bgFill =
+ params.bgType === 'linear' || params.bgType === 'radial' ? 'url(#canvas-gradient)' : bg;
+
+ // ── Build tech stack data ──────────────────────────────────────────────────
+ const techSummary = aggregateTechStack(repoContributions);
+ const langPalette = buildLanguageColorPalette(techSummary, 5);
+
+ // Use tech stack colors as multi-accent if available, otherwise fall back to params accent
+ const accent: string | string[] =
+ langPalette.length > 0
+ ? langPalette
+ : Array.isArray(params.accent)
+ ? params.accent.map((c) => sanitizeHexColor(c, '00ffaa'))
+ : sanitizeHexColor(params.accent, '00ffaa');
+
+ const text = `#${sanitizeHexColor(params.text, 'ffffff')}`;
+
+ const borderAttr = safeBorderWidth
+ ? `stroke="#${sanitizeHexColor(params.border, '000000')}" stroke-width="${safeBorderWidth}"`
+ : '';
+
+ const sanitizedFont = sanitizeFont(params.font);
+ const selectedFont = resolveFont(sanitizedFont);
+ const isPredefinedFont = isBundledFont(sanitizedFont);
+ const statsFont = selectedFont || '"Space Grotesk", sans-serif';
+ const googleFontUrlPart =
+ sanitizedFont && !isPredefinedFont ? sanitizeGoogleFontUrl(sanitizedFont) : null;
+ const googleFontsImport = googleFontUrlPart
+ ? `@import url('https://fonts.googleapis.com/css2?family=${googleFontUrlPart}&display=swap');`
+ : '';
+
+ const sf = getSizeScale(params.size);
+ const radius = sanitizeRadius(params.radius, 8) * sf;
+ const labels = getLabels(params.lang);
+ const labelVisible = params.label !== false;
+ const W = Math.round(SVG_WIDTH * sf);
+ const H = Math.round((labelVisible ? SVG_HEIGHT : SVG_HEIGHT - 40) * sf);
+ const yOffset = params.label === false ? -40 : 0;
+
+ const towerData = scaleTowerData(
+ computeTowers(calendar, params.scale, stats.todayDate, params.mode),
+ sf
+ );
+
+ if (params.gradient) {
+ generateCustomGradients(params);
+ }
+
+ // Dominant accent for particles/glow
+ const mainAccentHex =
+ langPalette.length > 0
+ ? (langPalette[langPalette.length - 1] ?? '#00ffaa')
+ : Array.isArray(accent)
+ ? (accent[accent.length - 1] ?? '#00ffaa')
+ : typeof accent === 'string'
+ ? accent.startsWith('#')
+ ? accent
+ : `#${accent}`
+ : '#00ffaa';
+
+ const towers = renderTowers(
+ towerData,
+ params,
+ accent,
+ text,
+ sf,
+ false,
+ params.opacity ?? 1.0,
+ animate,
+ false
+ );
+
+ const safeId = safeUser.replace(/[^a-zA-Z0-9-]/g, '_').toLowerCase();
+
+ // ── Legend & badge Y positioning ──────────────────────────────────────────
+ const legendTopY = 90;
+ const legendBottomY = legendTopY + 22 + techSummary.topLanguages.length * 20 + 18;
+
+ const legend = renderTechStackLegend(techSummary, sf, text, bg, statsFont);
+ const archetypeBadge =
+ techSummary.dominantLanguage !== null
+ ? renderArchetypeBadge(
+ techSummary.archetype,
+ sf,
+ text,
+ mainAccentHex,
+ statsFont,
+ legendBottomY
+ )
+ : '';
+
+ return `
+`;
+}
diff --git a/lib/svg/languageColors.ts b/lib/svg/languageColors.ts
index 6214c99f0..0d26bc9be 100644
--- a/lib/svg/languageColors.ts
+++ b/lib/svg/languageColors.ts
@@ -1,26 +1,88 @@
+// lib/svg/languageColors.ts
+// Comprehensive language color map sourced from github-linguist / github/linguist
+// Used for tech stack visualization and tower color-coding.
export const LANGUAGE_COLORS: Record = {
+ // Web / Frontend
TypeScript: '#3178c6',
JavaScript: '#f1e05a',
+ HTML: '#e34c26',
+ CSS: '#563d7c',
+ SCSS: '#c6538c',
+ Sass: '#a53b70',
+ Less: '#1d365d',
+ Vue: '#41b883',
+ Svelte: '#ff3e00',
+ CoffeeScript: '#244776',
+ Handlebars: '#f7931e',
+ Liquid: '#67b8de',
+ Pug: '#a86454',
+ EJS: '#a91e50',
+ Astro: '#ff5a03',
+ WebAssembly: '#04133b',
+
+ // Backend / Systems
Python: '#3572A5',
Java: '#b07219',
'C++': '#f34b7d',
- HTML: '#e34c26',
- CSS: '#563d7c',
- Go: '#00ADD8',
- Rust: '#dea584',
C: '#555555',
'C#': '#178600',
- PHP: '#4F5D95',
+ Go: '#00ADD8',
+ Rust: '#dea584',
Ruby: '#701516',
+ PHP: '#4F5D95',
Swift: '#F05138',
Kotlin: '#A97BFF',
Dart: '#00B4AB',
- Lua: '#000080',
- R: '#198CE7',
Scala: '#c22d40',
- Perl: '#0298c3',
+ Clojure: '#db5855',
Haskell: '#5e5086',
Elixir: '#6e4a7e',
- Vue: '#41b883',
- Svelte: '#ff3e00',
+ Erlang: '#B83998',
+ Ocaml: '#ef7a08',
+ 'F#': '#b845fc',
+ Crystal: '#000100',
+ Nim: '#ffc200',
+ Zig: '#ec915c',
+ V: '#4f87c4',
+ Odin: '#3882D0',
+
+ // Scripting / Shell
+ Shell: '#89e051',
+ Bash: '#89e051',
+ PowerShell: '#012456',
+ Lua: '#000080',
+ Perl: '#0298c3',
+ Awk: '#c30e9b',
+ Tcl: '#e4cc98',
+ Groovy: '#e69f56',
+
+ // Data / ML / Scientific
+ R: '#198CE7',
+ Julia: '#a270ba',
+ MATLAB: '#e16737',
+ Jupyter: '#DA5B0B',
+ Fortran: '#4d41b1',
+ COBOL: '#0101ff',
+ 'Common Lisp': '#3fb68b',
+ Scheme: '#1e4aec',
+ Prolog: '#74283c',
+
+ // DevOps / Config / Markup
+ Dockerfile: '#384d54',
+ HCL: '#844fba',
+ Nix: '#7e7eff',
+ CMake: '#DA3434',
+ Makefile: '#427819',
+ YAML: '#cb171e',
+ TOML: '#9c4121',
+ Jsonnet: '#0064bd',
+
+ // Mobile
+ 'Objective-C': '#438eff',
+ 'Objective-C++': '#6866fb',
+
+ // Database / Query
+ PLpgSQL: '#336791',
+ PLSQL: '#dad8d8',
+ TSQL: '#e9e8e8',
};
diff --git a/lib/svg/techStackAnalytics.test.ts b/lib/svg/techStackAnalytics.test.ts
new file mode 100644
index 000000000..b8a102124
--- /dev/null
+++ b/lib/svg/techStackAnalytics.test.ts
@@ -0,0 +1,210 @@
+// lib/svg/techStackAnalytics.test.ts
+import { describe, it, expect } from 'vitest';
+import {
+ aggregateTechStack,
+ detectDeveloperArchetype,
+ getDominantLanguageColor,
+ buildLanguageColorPalette,
+ type TechStackEntry,
+} from './techStackAnalytics';
+import type { RepoContribution } from '../../types';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+
+function makeRepo(name: string, language: string | null, count: number): RepoContribution {
+ return {
+ repository: {
+ name,
+ primaryLanguage: language ? { name: language } : null,
+ },
+ contributions: { totalCount: count },
+ };
+}
+
+// ── aggregateTechStack ────────────────────────────────────────────────────────
+
+describe('aggregateTechStack', () => {
+ it('returns empty summary for empty input', () => {
+ const summary = aggregateTechStack([]);
+ expect(summary.topLanguages).toEqual([]);
+ expect(summary.allLanguages).toEqual([]);
+ expect(summary.dominantLanguage).toBeNull();
+ expect(summary.archetype).toBe('GitHub Developer');
+ expect(summary.totalCommits).toBe(0);
+ });
+
+ it('returns empty summary when all repos have no language', () => {
+ const repos = [makeRepo('repo1', null, 50), makeRepo('repo2', null, 30)];
+ const summary = aggregateTechStack(repos);
+ expect(summary.topLanguages).toEqual([]);
+ expect(summary.dominantLanguage).toBeNull();
+ });
+
+ it('correctly aggregates single language', () => {
+ const repos = [makeRepo('a', 'TypeScript', 100), makeRepo('b', 'TypeScript', 50)];
+ const summary = aggregateTechStack(repos);
+ expect(summary.topLanguages).toHaveLength(1);
+ expect(summary.topLanguages[0]!.language).toBe('TypeScript');
+ expect(summary.topLanguages[0]!.commits).toBe(150);
+ expect(summary.topLanguages[0]!.percentage).toBe(100);
+ expect(summary.dominantLanguage).toBe('TypeScript');
+ });
+
+ it('sorts languages by commit count descending', () => {
+ const repos = [
+ makeRepo('a', 'Go', 10),
+ makeRepo('b', 'Python', 200),
+ makeRepo('c', 'TypeScript', 100),
+ ];
+ const summary = aggregateTechStack(repos);
+ expect(summary.topLanguages[0]!.language).toBe('Python');
+ expect(summary.topLanguages[1]!.language).toBe('TypeScript');
+ expect(summary.topLanguages[2]!.language).toBe('Go');
+ });
+
+ it('limits topLanguages to 5', () => {
+ const repos = [
+ makeRepo('a', 'TypeScript', 100),
+ makeRepo('b', 'Python', 90),
+ makeRepo('c', 'Go', 80),
+ makeRepo('d', 'Rust', 70),
+ makeRepo('e', 'Java', 60),
+ makeRepo('f', 'Ruby', 50),
+ ];
+ const summary = aggregateTechStack(repos);
+ expect(summary.topLanguages).toHaveLength(5);
+ expect(summary.allLanguages).toHaveLength(6);
+ // Ruby is #6, excluded from top 5 but in allLanguages
+ expect(summary.allLanguages.at(-1)!.language).toBe('Ruby');
+ });
+
+ it('computes percentages correctly', () => {
+ const repos = [makeRepo('a', 'TypeScript', 75), makeRepo('b', 'Python', 25)];
+ const summary = aggregateTechStack(repos);
+ const ts = summary.topLanguages.find((l) => l.language === 'TypeScript');
+ const py = summary.topLanguages.find((l) => l.language === 'Python');
+ expect(ts!.percentage).toBe(75);
+ expect(py!.percentage).toBe(25);
+ });
+
+ it('assigns known LANGUAGE_COLORS', () => {
+ const repos = [makeRepo('a', 'TypeScript', 100)];
+ const summary = aggregateTechStack(repos);
+ expect(summary.topLanguages[0]!.color).toBe('#3178c6');
+ });
+
+ it('assigns fallback color for unknown languages', () => {
+ const repos = [makeRepo('a', 'BrainFuck', 100)];
+ const summary = aggregateTechStack(repos);
+ expect(summary.topLanguages[0]!.color).toBe('#8B949E');
+ });
+
+ it('computes totalCommits correctly', () => {
+ const repos = [makeRepo('a', 'Go', 40), makeRepo('b', 'Rust', 60)];
+ const summary = aggregateTechStack(repos);
+ expect(summary.totalCommits).toBe(100);
+ });
+});
+
+// ── detectDeveloperArchetype ──────────────────────────────────────────────────
+
+describe('detectDeveloperArchetype', () => {
+ it('returns GitHub Developer for empty input', () => {
+ expect(detectDeveloperArchetype([])).toBe('GitHub Developer');
+ });
+
+ it('detects AI / ML Engineer', () => {
+ const stack: TechStackEntry[] = [
+ { language: 'Python', commits: 300, percentage: 75, color: '#3572A5' },
+ { language: 'Julia', commits: 100, percentage: 25, color: '#a270ba' },
+ ];
+ expect(detectDeveloperArchetype(stack)).toBe('AI / ML Engineer');
+ });
+
+ it('detects Systems Programmer', () => {
+ const stack: TechStackEntry[] = [
+ { language: 'Rust', commits: 300, percentage: 70, color: '#dea584' },
+ { language: 'C', commits: 100, percentage: 30, color: '#555555' },
+ ];
+ expect(detectDeveloperArchetype(stack)).toBe('Systems Programmer');
+ });
+
+ it('detects Frontend Developer', () => {
+ const stack: TechStackEntry[] = [
+ { language: 'TypeScript', commits: 400, percentage: 80, color: '#3178c6' },
+ { language: 'CSS', commits: 100, percentage: 20, color: '#563d7c' },
+ ];
+ expect(detectDeveloperArchetype(stack)).toBe('Frontend Developer');
+ });
+
+ it('detects Mobile Developer', () => {
+ const stack: TechStackEntry[] = [
+ { language: 'Swift', commits: 300, percentage: 60, color: '#F05138' },
+ { language: 'Kotlin', commits: 200, percentage: 40, color: '#A97BFF' },
+ ];
+ expect(detectDeveloperArchetype(stack)).toBe('Mobile Developer');
+ });
+
+ it('detects a plausible archetype for mixed web+backend stack', () => {
+ const stack: TechStackEntry[] = [
+ { language: 'TypeScript', commits: 200, percentage: 40, color: '#3178c6' },
+ { language: 'Python', commits: 200, percentage: 40, color: '#3572A5' },
+ { language: 'CSS', commits: 100, percentage: 20, color: '#563d7c' },
+ ];
+ const result = detectDeveloperArchetype(stack);
+ // TypeScript (40%) qualifies for Frontend; Python (40%) qualifies for AI/ML.
+ // All three are legitimate archetypes for this mixed profile.
+ expect(['Frontend Developer', 'Full-Stack Developer', 'AI / ML Engineer']).toContain(result);
+ });
+
+ it('falls back to Polyglot Developer for unusual stacks', () => {
+ const stack: TechStackEntry[] = [
+ { language: 'Prolog', commits: 50, percentage: 25, color: '#74283c' },
+ { language: 'COBOL', commits: 50, percentage: 25, color: '#0101ff' },
+ { language: 'Fortran', commits: 50, percentage: 25, color: '#4d41b1' },
+ { language: 'Awk', commits: 50, percentage: 25, color: '#c30e9b' },
+ ];
+ expect(detectDeveloperArchetype(stack)).toBe('Polyglot Developer');
+ });
+});
+
+// ── getDominantLanguageColor ──────────────────────────────────────────────────
+
+describe('getDominantLanguageColor', () => {
+ it('returns fallback color for empty summary', () => {
+ const summary = aggregateTechStack([]);
+ expect(getDominantLanguageColor(summary, '#aabbcc')).toBe('#aabbcc');
+ });
+
+ it('returns dominant language color', () => {
+ const repos = [makeRepo('a', 'TypeScript', 100), makeRepo('b', 'Go', 20)];
+ const summary = aggregateTechStack(repos);
+ expect(getDominantLanguageColor(summary)).toBe('#3178c6');
+ });
+});
+
+// ── buildLanguageColorPalette ─────────────────────────────────────────────────
+
+describe('buildLanguageColorPalette', () => {
+ it('returns fallback for empty summary', () => {
+ const summary = aggregateTechStack([]);
+ expect(buildLanguageColorPalette(summary, 5, '#00ffaa')).toEqual(['#00ffaa']);
+ });
+
+ it('returns up to N colors', () => {
+ const repos = [
+ makeRepo('a', 'TypeScript', 100),
+ makeRepo('b', 'Python', 90),
+ makeRepo('c', 'Go', 80),
+ makeRepo('d', 'Rust', 70),
+ makeRepo('e', 'Java', 60),
+ makeRepo('f', 'Ruby', 50),
+ ];
+ const summary = aggregateTechStack(repos);
+ const palette = buildLanguageColorPalette(summary, 3);
+ expect(palette).toHaveLength(3);
+ expect(palette[0]).toBe('#3178c6'); // TypeScript
+ expect(palette[1]).toBe('#3572A5'); // Python
+ expect(palette[2]).toBe('#00ADD8'); // Go
+ });
+});
diff --git a/lib/svg/techStackAnalytics.ts b/lib/svg/techStackAnalytics.ts
new file mode 100644
index 000000000..f5ade37de
--- /dev/null
+++ b/lib/svg/techStackAnalytics.ts
@@ -0,0 +1,202 @@
+// lib/svg/techStackAnalytics.ts
+// Pure utility for aggregating tech stack data from repository contributions.
+// This module has NO server-side imports and is safe to use in both client and server contexts.
+
+import type { RepoContribution } from '../../types';
+import { LANGUAGE_COLORS } from './languageColors';
+
+export interface TechStackEntry {
+ /** Programming language name */
+ language: string;
+ /** Total commit contributions to repos using this language */
+ commits: number;
+ /** Percentage of total contributions (0–100) */
+ percentage: number;
+ /** Hex color for this language (includes '#' prefix) */
+ color: string;
+}
+
+export interface TechStackSummary {
+ /** Top-5 languages by contribution count */
+ topLanguages: TechStackEntry[];
+ /** Full list of all detected languages */
+ allLanguages: TechStackEntry[];
+ /** Single dominant language name, or null if no data */
+ dominantLanguage: string | null;
+ /** Developer archetype string derived from the tech stack */
+ archetype: string;
+ /** Total contribution count across all repos */
+ totalCommits: number;
+}
+
+// ─── Archetype definitions ────────────────────────────────────────────────────
+
+const ARCHETYPES: Array<{
+ name: string;
+ languages: string[];
+ minShare: number; // minimum % of one matching language to qualify
+}> = [
+ {
+ name: 'AI / ML Engineer',
+ languages: ['Python', 'Julia', 'R', 'MATLAB', 'Jupyter', 'Fortran'],
+ minShare: 30,
+ },
+ {
+ name: 'Systems Programmer',
+ languages: ['Rust', 'C', 'C++', 'Zig', 'Go', 'Assembly'],
+ minShare: 30,
+ },
+ {
+ name: 'Mobile Developer',
+ languages: ['Swift', 'Kotlin', 'Dart', 'Objective-C', 'Objective-C++', 'Java'],
+ minShare: 30,
+ },
+ {
+ name: 'Backend Developer',
+ languages: ['Go', 'Java', 'Kotlin', 'Rust', 'Python', 'Ruby', 'PHP', 'Scala', 'Elixir', 'C#'],
+ minShare: 40,
+ },
+ {
+ name: 'Frontend Developer',
+ languages: ['TypeScript', 'JavaScript', 'HTML', 'CSS', 'Vue', 'Svelte', 'Astro'],
+ minShare: 40,
+ },
+ {
+ name: 'Full-Stack Developer',
+ languages: ['TypeScript', 'JavaScript', 'Python', 'Go', 'Ruby', 'PHP', 'Java', 'Kotlin', 'C#'],
+ minShare: 15,
+ },
+ {
+ name: 'Data Engineer',
+ languages: ['Python', 'Scala', 'SQL', 'R', 'Julia', 'MATLAB'],
+ minShare: 30,
+ },
+ {
+ name: 'DevOps Engineer',
+ languages: ['Shell', 'Bash', 'Python', 'Go', 'HCL', 'Dockerfile', 'PowerShell', 'Makefile'],
+ minShare: 25,
+ },
+ {
+ name: 'Functional Programmer',
+ languages: ['Haskell', 'Elixir', 'Clojure', 'F#', 'Ocaml', 'Erlang', 'Scheme', 'Common Lisp'],
+ minShare: 20,
+ },
+];
+
+// ─── Core aggregation ─────────────────────────────────────────────────────────
+
+/**
+ * Aggregate repository contributions into a sorted tech stack summary.
+ *
+ * @param repoContributions - Raw repository contribution data from the GitHub API
+ * @returns Full TechStackSummary with percentages, colors, and archetype
+ */
+export function aggregateTechStack(repoContributions: RepoContribution[]): TechStackSummary {
+ const langCounts: Record = {};
+
+ for (const contrib of repoContributions) {
+ const lang = contrib.repository.primaryLanguage?.name;
+ if (lang && lang.trim()) {
+ langCounts[lang] = (langCounts[lang] ?? 0) + contrib.contributions.totalCount;
+ }
+ }
+
+ const totalCommits = Object.values(langCounts).reduce((sum, n) => sum + n, 0);
+
+ if (totalCommits === 0) {
+ return {
+ topLanguages: [],
+ allLanguages: [],
+ dominantLanguage: null,
+ archetype: 'GitHub Developer',
+ totalCommits: 0,
+ };
+ }
+
+ const allLanguages: TechStackEntry[] = Object.entries(langCounts)
+ .map(([language, commits]) => ({
+ language,
+ commits,
+ percentage: Math.round((commits / totalCommits) * 100),
+ color: (LANGUAGE_COLORS as Record)[language] ?? '#8B949E',
+ }))
+ .sort((a, b) => b.commits - a.commits);
+
+ const topLanguages = allLanguages.slice(0, 5);
+ const dominantLanguage = topLanguages[0]?.language ?? null;
+ const archetype = detectDeveloperArchetype(allLanguages);
+
+ return {
+ topLanguages,
+ allLanguages,
+ dominantLanguage,
+ archetype,
+ totalCommits,
+ };
+}
+
+/**
+ * Determine a developer archetype label based on the language distribution.
+ * Scans archetype definitions in priority order and returns the first match.
+ * Falls back to 'Polyglot Developer' if nothing matches, or 'GitHub Developer'
+ * when there is no language data at all.
+ *
+ * @param allLanguages - Sorted (desc) list of TechStackEntry objects
+ */
+export function detectDeveloperArchetype(allLanguages: TechStackEntry[]): string {
+ if (allLanguages.length === 0) return 'GitHub Developer';
+
+ for (const archetype of ARCHETYPES) {
+ const matchingLangs = allLanguages.filter((l) => archetype.languages.includes(l.language));
+ const matchingShare = matchingLangs.reduce((sum, l) => sum + l.percentage, 0);
+
+ // At least one language from this archetype must meet the threshold
+ const topMatchShare = matchingLangs[0]?.percentage ?? 0;
+ if (topMatchShare >= archetype.minShare || matchingShare >= archetype.minShare * 1.5) {
+ return archetype.name;
+ }
+ }
+
+ // Fallback: Full-Stack if multiple language families represented
+ const hasWeb = allLanguages.some((l) =>
+ ['TypeScript', 'JavaScript', 'HTML', 'CSS'].includes(l.language)
+ );
+ const hasBackend = allLanguages.some((l) =>
+ ['Python', 'Go', 'Java', 'Ruby', 'PHP', 'Rust', 'C#', 'Kotlin'].includes(l.language)
+ );
+ if (hasWeb && hasBackend) return 'Full-Stack Developer';
+
+ return 'Polyglot Developer';
+}
+
+/**
+ * Get the dominant language color for a tech stack.
+ * Returns the hex color (with '#' prefix) for the most-used language,
+ * or the fallback if no language data is present.
+ *
+ * @param summary - Aggregated tech stack summary
+ * @param fallbackColor - Hex color string (with '#' prefix) used when no language found
+ */
+export function getDominantLanguageColor(
+ summary: TechStackSummary,
+ fallbackColor: string = '#00ffaa'
+): string {
+ return summary.topLanguages[0]?.color ?? fallbackColor;
+}
+
+/**
+ * Build a color palette of the top N languages for use in multi-accent tower rendering.
+ * Returns an array of hex color strings (with '#' prefix).
+ *
+ * @param summary - Aggregated tech stack summary
+ * @param count - Maximum number of colors to return (default 5)
+ * @param fallbackColor - Accent fallback when fewer languages exist
+ */
+export function buildLanguageColorPalette(
+ summary: TechStackSummary,
+ count: number = 5,
+ fallbackColor: string = '#00ffaa'
+): string[] {
+ if (summary.topLanguages.length === 0) return [fallbackColor];
+ return summary.topLanguages.slice(0, count).map((l) => l.color);
+}
diff --git a/lib/validations.ts b/lib/validations.ts
index a30dbea37..0034e0aa3 100644
--- a/lib/validations.ts
+++ b/lib/validations.ts
@@ -503,6 +503,7 @@ const baseStreakParamsSchema = z.object({
'commit_clock',
'weekday',
'punchcard',
+ 'techstack',
])
.catch('default')
.default('default'),
diff --git a/middleware.ts b/middleware.ts
index 900253fd3..7b1343355 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -60,6 +60,8 @@ const ROUTES_WITH_OWN_RATE_LIMITING = [
'/api/learning-curve',
'/api/org',
'/api/spotify', // Added here in case it has its own rate limiter
+ '/api/languages',
+ '/api/tech-stack',
];
function addSecurityHeaders(response: NextResponse): NextResponse {
@@ -205,5 +207,7 @@ export const config = {
'/api/user-repos/:path*',
'/api/webhook/:path*',
'/api/webhooks/:path*',
+ '/api/languages/:path*',
+ '/api/tech-stack/:path*',
],
};
diff --git a/models/TechStackAnalytics.ts b/models/TechStackAnalytics.ts
new file mode 100644
index 000000000..fd13d6e99
--- /dev/null
+++ b/models/TechStackAnalytics.ts
@@ -0,0 +1,83 @@
+// models/TechStackAnalytics.ts
+// MongoDB/Mongoose schema for caching processed tech stack analytics.
+// Reduces repeated GitHub API calls and computation costs.
+import mongoose, { Document, Model, Schema } from 'mongoose';
+
+export interface ITechStackEntry {
+ language: string;
+ commits: number;
+ percentage: number;
+ color: string;
+}
+
+export interface ITechStackAnalytics extends Document {
+ /** GitHub username (lowercased) */
+ username: string;
+ /** Year the analytics cover, e.g. "2024". "all" for full-history. */
+ year: string;
+ /** Top languages sorted by contribution count (desc) */
+ techStack: ITechStackEntry[];
+ /** Full language list (not just top-5) */
+ allLanguages: ITechStackEntry[];
+ /** Dominant language name */
+ dominantLanguage: string | null;
+ /** Developer archetype label */
+ archetype: string;
+ /** Total commits counted when the analytics were computed */
+ totalCommits: number;
+ /** ISO timestamp when the analytics were computed */
+ computedAt: Date;
+ /** When this cache entry expires — used for TTL index */
+ ttlExpiry: Date;
+}
+
+const TechStackEntrySchema = new Schema(
+ {
+ language: { type: String, required: true },
+ commits: { type: Number, required: true },
+ percentage: { type: Number, required: true },
+ color: { type: String, required: true },
+ },
+ { _id: false }
+);
+
+const TechStackAnalyticsSchema = new Schema(
+ {
+ username: { type: String, required: true, lowercase: true, trim: true },
+ year: { type: String, required: true },
+ techStack: { type: [TechStackEntrySchema], default: [] },
+ allLanguages: { type: [TechStackEntrySchema], default: [] },
+ dominantLanguage: { type: String, default: null },
+ archetype: { type: String, required: true, default: 'GitHub Developer' },
+ totalCommits: { type: Number, required: true, default: 0 },
+ computedAt: { type: Date, required: true, default: Date.now },
+ ttlExpiry: { type: Date, required: true },
+ },
+ {
+ timestamps: false,
+ collection: 'techstack_analytics',
+ }
+);
+
+// Unique compound index: one document per (username, year) pair
+TechStackAnalyticsSchema.index({ username: 1, year: 1 }, { unique: true });
+
+// MongoDB TTL index: automatically delete expired documents
+TechStackAnalyticsSchema.index({ ttlExpiry: 1 }, { expireAfterSeconds: 0 });
+
+/** 24-hour cache TTL in milliseconds */
+export const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
+
+/**
+ * Build the TTL expiry date for a new cache entry.
+ * @param ttlMs - Time-to-live in milliseconds (defaults to CACHE_TTL_MS = 24h)
+ */
+export function buildTtlExpiry(ttlMs: number = CACHE_TTL_MS): Date {
+ return new Date(Date.now() + ttlMs);
+}
+
+const TechStackAnalytics: Model =
+ mongoose.models.TechStackAnalytics ||
+ mongoose.model('TechStackAnalytics', TechStackAnalyticsSchema);
+
+export default TechStackAnalytics;
diff --git a/types/index.ts b/types/index.ts
index 2e3406eaf..b3233d3d1 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -284,7 +284,7 @@ export interface BadgeParams {
/** Language/locale code for stat labels (e.g. 'en', 'fr', 'ja'). Defaults to 'en'. */
lang?: string;
- /** Badge layout variant. 'default' shows the isometric monolith; 'monthly' shows month-over-month stats; 'heatmap' shows a flat 2D contribution heatmap; 'pulse' shows a heartbeat sparkline; 'skyline' shows a city skyline; 'languages' shows a 3D isometric city of top programming languages; 'constellation' shows a celestial star-map SVG visualization; 'radar' shows a radar chart of contribution metrics. */
+ /** Badge layout variant. 'default' shows the isometric monolith; 'monthly' shows month-over-month stats; 'heatmap' shows a flat 2D contribution heatmap; 'pulse' shows a heartbeat sparkline; 'skyline' shows a city skyline; 'languages' shows a 3D isometric city of top programming languages; 'constellation' shows a celestial star-map SVG visualization; 'radar' shows a radar chart of contribution metrics; 'techstack' shows a language-aware isometric monolith with a tech stack legend. */
view?:
| 'default'
| 'monthly'
@@ -299,7 +299,8 @@ export interface BadgeParams {
| 'activity_graph'
| 'commit_clock'
| 'weekday'
- | 'punchcard';
+ | 'punchcard'
+ | 'techstack';
/** Format for the monthly delta indicator. 'percent' shows %, 'absolute' shows raw count, 'both' shows both. */
delta_format?: 'percent' | 'absolute' | 'both';