Skip to content

Commit 43f3521

Browse files
authored
refactor(types): add a branded hexcolor type to replace plain strings (JhaSourav07#605)
## Description Introduces a `HexColor` branded type (`string & { __brand: 'HexColor' }`) to replace plain `string` for hex color values. This prevents unsanitized color strings from being passed directly into SVG rendering. Fixes JhaSourav07#318 ## Pillar - [ ] 🎨 Pillar 1 — New Theme Design - [ ] 📐 Pillar 2 — Geometric SVG Improvement - [ ] 🕐 Pillar 3 — Timezone Logic Optimization - [x] 🛠️ Other (Bug fix, refactoring, docs) ## Visual Preview ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). - [x] My commits follow the Conventional Commits format (e.g., `feat(themes): ...`, `fix(calculate): ...`). - [ ] I have updated `README.md` if I added a new theme or URL parameter. - [x] I have started the repo. - [x] I have made sure that i have only one commit to merge in this PR. - [x] The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts). - [ ] (Recommended) I joined the CommitPulse Discord community for contributor discussions, mentorship, and faster PR support.
2 parents b5cc44c + 97c0c6c commit 43f3521

5 files changed

Lines changed: 69 additions & 90 deletions

File tree

lib/svg/generator.test.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect } from 'vitest';
22
import { generateSVG, generateMonthlySVG, particleCount, escapeXML } from './generator';
33
import type { BadgeParams, ContributionCalendar, StreakStats, MonthlyStats } from '../../types';
4+
import { hexColor } from './sanitizer';
45

56
describe('generateSVG', () => {
67
const mockStats: StreakStats = {
@@ -186,9 +187,9 @@ describe('generateSVG', () => {
186187
describe('autoTheme', () => {
187188
const autoParams: BadgeParams = {
188189
user: 'avi',
189-
bg: 'ffffff',
190-
text: '24292f',
191-
accent: '0969da',
190+
bg: hexColor('ffffff'),
191+
text: hexColor('24292f'),
192+
accent: hexColor('0969da'),
192193
speed: '8s',
193194
scale: 'linear',
194195
autoTheme: true,
@@ -247,9 +248,9 @@ describe('generateSVG', () => {
247248
it('does NOT inject a media query for non-auto themes', () => {
248249
const staticParams: BadgeParams = {
249250
user: 'avi',
250-
bg: '0d1117',
251-
text: 'c9d1d9',
252-
accent: '58a6ff',
251+
bg: hexColor('0d1117'),
252+
text: hexColor('c9d1d9'),
253+
accent: hexColor('58a6ff'),
253254
speed: '8s',
254255
scale: 'linear',
255256
autoTheme: false,

lib/svg/sanitizer.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* Prevents attribute injection and malformed SVG generation.
44
*/
55

6+
import type { HexColor } from '../../types/index';
7+
68
const HEX_COLOR_REGEX = /^([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
79

810
/**
@@ -15,20 +17,36 @@ export function isValidHex(color?: string): boolean {
1517
return HEX_COLOR_REGEX.test(cleanColor);
1618
}
1719

20+
/**
21+
* Converts a known-safe hex color literal to the `HexColor` branded type.
22+
* Intended for hardcoded values in theme definitions, tests, and fixtures
23+
* where the color is authored by a developer rather than supplied by user input.
24+
*
25+
* If the value is invalid, falls back to `fallback` (defaults to `'000000'`).
26+
* For user-supplied input, use `sanitizeHexColor` instead.
27+
*/
28+
export function hexColor(value: string, fallback = '000000'): HexColor {
29+
const cleaned = value.replace('#', '');
30+
if (HEX_COLOR_REGEX.test(cleaned)) {
31+
return cleaned as HexColor;
32+
}
33+
return fallback.replace('#', '') as HexColor;
34+
}
35+
1836
/**
1937
* Sanitizes a color input, ensuring it's a valid hex or falls back to a safe value.
2038
* Always returns a hex string WITHOUT the leading #.
2139
*/
22-
export function sanitizeHexColor(input: string | undefined | null, fallback: string): string {
23-
if (!input) return fallback.replace('#', '');
40+
export function sanitizeHexColor(input: string | undefined | null, fallback: string): HexColor {
41+
if (!input) return fallback.replace('#', '') as HexColor;
2442

2543
const cleanInput = input.trim().replace('#', '');
2644

2745
if (HEX_COLOR_REGEX.test(cleanInput)) {
28-
return cleanInput;
46+
return cleanInput as HexColor;
2947
}
3048

31-
return fallback.replace('#', '');
49+
return fallback.replace('#', '') as HexColor;
3250
}
3351

3452
/**

lib/svg/themes.ts

Lines changed: 22 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,29 @@
11
// lib/svg/themes.ts
22
import { BadgeTheme } from '../../types';
3+
import { hexColor } from './sanitizer';
4+
5+
function makeTheme(bg: string, text: string, accent: string): BadgeTheme {
6+
return {
7+
bg: hexColor(bg),
8+
text: hexColor(text),
9+
accent: hexColor(accent),
10+
};
11+
}
312

413
export const themes: Record<string, BadgeTheme> = {
5-
dark: {
6-
bg: '0d1117',
7-
text: 'c9d1d9',
8-
accent: '58a6ff',
9-
},
10-
light: {
11-
bg: 'ffffff',
12-
text: '24292f',
13-
accent: '0969da',
14-
},
15-
neon: {
16-
bg: '000000',
17-
text: '00ffcc',
18-
accent: 'ff00ff',
19-
},
20-
github: {
21-
bg: '0d1117',
22-
text: 'ffffff',
23-
accent: '238636', // The classic green
24-
},
25-
dracula: {
26-
bg: '282a36',
27-
text: 'f8f8f2',
28-
accent: 'bd93f9',
29-
},
30-
ocean: {
31-
bg: '0a192f',
32-
text: 'ccd6f6',
33-
accent: '64ffda',
34-
},
35-
sunset: {
36-
bg: '1a0a0a',
37-
text: 'ffd6c0',
38-
accent: 'ff6b35',
39-
},
40-
forest: {
41-
bg: '0d1f0d',
42-
text: 'c8f0c8',
43-
accent: '39d353',
44-
},
45-
rose: {
46-
bg: '1f0d14',
47-
text: 'f0c8d4',
48-
accent: 'ff6b9d',
49-
},
50-
nord: {
51-
bg: '2e3440',
52-
text: 'd8dee9',
53-
accent: '88c0d0',
54-
},
55-
synthwave: {
56-
bg: '0d0221',
57-
text: 'f8f8f2',
58-
accent: 'ff2d78',
59-
},
60-
gruvbox: {
61-
bg: '282828',
62-
text: 'ebdbb2',
63-
accent: 'fe8019',
64-
},
65-
highcontrast: {
66-
bg: '0a0a0a', // High-contrast theme: vivid red-orange accent on near-black background.
67-
text: '888888',
68-
accent: 'ff4500',
69-
},
14+
dark: makeTheme('0d1117', 'c9d1d9', '58a6ff'),
15+
light: makeTheme('ffffff', '24292f', '0969da'),
16+
neon: makeTheme('000000', '00ffcc', 'ff00ff'),
17+
github: makeTheme('0d1117', 'ffffff', '238636'),
18+
dracula: makeTheme('282a36', 'f8f8f2', 'bd93f9'),
19+
ocean: makeTheme('0a192f', 'ccd6f6', '64ffda'),
20+
sunset: makeTheme('1a0a0a', 'ffd6c0', 'ff6b35'),
21+
forest: makeTheme('0d1f0d', 'c8f0c8', '39d353'),
22+
rose: makeTheme('1f0d14', 'f0c8d4', 'ff6b9d'),
23+
nord: makeTheme('2e3440', 'd8dee9', '88c0d0'),
24+
synthwave: makeTheme('0d0221', 'f8f8f2', 'ff2d78'),
25+
gruvbox: makeTheme('282828', 'ebdbb2', 'fe8019'),
26+
highcontrast: makeTheme('0a0a0a', '888888', 'ff4500'),
7027
};
7128

7229
// Auto-theme pairs: the SVG switches between these two palettes

scripts/benchmark-svg.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { performance } from 'perf_hooks';
22
import { generateSVG } from '../lib/svg/generator';
3+
import { hexColor } from '../lib/svg/sanitizer';
34

45
const stats = {
56
currentStreak: 12,
@@ -30,21 +31,21 @@ const calendar = {
3031
const themes = [
3132
{
3233
name: 'dark',
33-
bg: '0d1117',
34-
accent: '00ffaa',
35-
text: 'ffffff',
34+
bg: hexColor('0d1117'),
35+
accent: hexColor('00ffaa'),
36+
text: hexColor('ffffff'),
3637
},
3738
{
3839
name: 'light',
39-
bg: 'ffffff',
40-
accent: 'ff00aa',
41-
text: '111111',
40+
bg: hexColor('ffffff'),
41+
accent: hexColor('ff00aa'),
42+
text: hexColor('111111'),
4243
},
4344
{
4445
name: 'purple',
45-
bg: '1a1025',
46-
accent: '9b5cff',
47-
text: 'f5f5f5',
46+
bg: hexColor('1a1025'),
47+
accent: hexColor('9b5cff'),
48+
text: hexColor('f5f5f5'),
4849
},
4950
];
5051

types/index.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
export type HexColor = string & { __brand: 'HexColor' };
2+
13
export interface StreakStats {
24
currentStreak: number;
35
longestStreak: number;
@@ -6,9 +8,9 @@ export interface StreakStats {
68
}
79

810
export interface BadgeTheme {
9-
bg: string;
10-
text: string;
11-
accent: string;
11+
bg: HexColor;
12+
text: HexColor;
13+
accent: HexColor;
1214
}
1315

1416
export interface ContributionDay {
@@ -35,9 +37,9 @@ export interface MonthlyStats {
3537

3638
export interface BadgeParams {
3739
user: string;
38-
bg: string;
39-
text: string;
40-
accent: string;
40+
bg: HexColor;
41+
text: HexColor;
42+
accent: HexColor;
4143
speed: string;
4244
scale: 'linear' | 'log';
4345
font?: string;

0 commit comments

Comments
 (0)