Skip to content

Commit 4223186

Browse files
authored
feat: implement dynamic google font support via URL parameter (JhaSourav07#96)
feat: support dynamic google fonts with security sanitization and updated documentation
1 parent aabcb20 commit 4223186

3 files changed

Lines changed: 78 additions & 18 deletions

File tree

README.md

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -76,18 +76,19 @@ URL Parameter > Theme Default > System Fallback
7676

7777
### Parameter Reference
7878

79-
| Parameter | Type | Required | Default | Description |
80-
| --------- | --------- | ---------- | ------------- | ----------------------------------------------------- |
81-
| `user` | `string` |**Yes** || GitHub username to render |
82-
| `theme` | `string` | No | `dark` | Preset theme name (see below) |
83-
| `bg` | `hex` | No | Theme default | Background color — **without** `#` |
84-
| `accent` | `hex` | No | Theme default | Tower & glow color — **without** `#` |
85-
| `text` | `hex` | No | Theme default | Label & stat text color — **without** `#` |
86-
| `radius` | `number` | No | `8` | Border corner radius in pixels |
87-
| `speed` | `string` | No | `8s` | Radar scan animation duration (e.g. `4s`, `12s`) |
88-
| `scale` | `string` | No | `linear` | Tower height scaling: `linear` or `log` (logarithmic) |
89-
| `refresh` | `boolean` | No | `false` | Bypass cache for real-time data |
90-
| `year` | `string` | No || Calendar year to render (e.g. `2023`, `2024`) |
79+
| Parameter | Type | Required | Default | Description |
80+
| --------- | --------- | ---------- | ------------------------------ | ----------------------------------------------------- |
81+
| `user` | `string` |**Yes** || GitHub username to render |
82+
| `theme` | `string` | No | `dark` | Preset theme name (see below) |
83+
| `bg` | `hex` | No | Theme default | Background color — **without** `#` |
84+
| `accent` | `hex` | No | Theme default | Tower & glow color — **without** `#` |
85+
| `text` | `hex` | No | Theme default | Label & stat text color — **without** `#` |
86+
| `radius` | `number` | No | `8` | Border corner radius in pixels |
87+
| `speed` | `string` | No | `8s` | Radar scan animation duration (e.g. `4s`, `12s`) |
88+
| `scale` | `string` | No | `linear` | Tower height scaling: `linear` or `log` (logarithmic) |
89+
| `font` | `string` | No | CommitPulse default typography | Any **Google Font** name (e.g., `Orbitron`, `Inter`) |
90+
| `refresh` | `boolean` | No | `false` | Bypass cache for real-time data |
91+
| `year` | `string` | No || Calendar year to render (e.g. `2023`, `2024`) |
9192

9293
### Theme Presets
9394

@@ -113,6 +114,10 @@ URL Parameter > Theme Default > System Fallback
113114

114115
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&theme=dracula)
115116

117+
<!-- Dynamic Google Fonts — Space-age look with Orbitron -->
118+
119+
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&font=Orbitron)
120+
116121
<!-- Fully custom — hot orange on void black -->
117122

118123
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&bg=080808&accent=ff4500&text=eeeeee&radius=16)

lib/svg/generator.test.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,39 @@ describe('generateSVG', () => {
5757
expect(svg).toContain('class="heat-particles"');
5858
});
5959

60-
it('defaults invalid fonts to JetBrains Mono', () => {
60+
it('supports dynamic Google Fonts for non-predefined fonts', () => {
6161
const svg = generateSVG(
6262
mockStats,
63-
{ user: 'avi', font: 'invalidfont' } as unknown as BadgeParams,
63+
{ user: 'avi', font: 'Inter' } as unknown as BadgeParams,
6464
mockCalendar
6565
);
66-
expect(svg).toContain('JetBrains Mono');
66+
67+
expect(svg).toContain(
68+
"@import url('https://fonts.googleapis.com/css2?family=Inter&amp;display=swap');"
69+
);
70+
expect(svg).toContain('font-family: "Inter", sans-serif;');
71+
});
72+
73+
it('replaces spaces with plus sign in dynamic Google Font URLs', () => {
74+
const svg = generateSVG(
75+
mockStats,
76+
{ user: 'avi', font: 'Open Sans' } as unknown as BadgeParams,
77+
mockCalendar
78+
);
79+
80+
expect(svg).toContain('family=Open+Sans');
81+
});
82+
83+
it('sanitizes dangerous characters in font names to prevent CSS injection', () => {
84+
const svg = generateSVG(
85+
mockStats,
86+
{ user: 'avi', font: 'Inter"</style><script>alert(1)</script>' } as unknown as BadgeParams,
87+
mockCalendar
88+
);
89+
90+
expect(svg).toContain('family=Interstylescriptalert1script');
91+
expect(svg).not.toContain('alert(1)');
92+
expect(svg).not.toContain('<script>');
6793
});
6894

6995
it('handles missing params with defaults', () => {
@@ -73,6 +99,18 @@ describe('generateSVG', () => {
7399
expect(svg).toContain('ffffff'); // default text
74100
});
75101

102+
it('falls back to default typography for completely invalid font names', () => {
103+
const svg = generateSVG(
104+
mockStats,
105+
{ user: 'avi', font: '!!!' } as unknown as BadgeParams,
106+
mockCalendar
107+
);
108+
// Should NOT contain a dynamic google fonts import for an empty/invalid family
109+
expect(svg).not.toContain('family=&amp;display=swap');
110+
// Should use default body font
111+
expect(svg).toContain('font-family: "Space Grotesk", sans-serif');
112+
});
113+
76114
// ── Auto-theme (prefers-color-scheme) tests ──────────────────────────────
77115
// These verify that theme=auto produces an SVG that switches between light
78116
// and dark color palettes using CSS custom properties and a media query,

lib/svg/generator.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,17 @@ export function generateSVG(
153153
const accent = `#${(params.accent || '00ffaa').replace('#', '')}`;
154154
const text = `#${(params.text || 'ffffff').replace('#', '')}`;
155155

156-
const selectedFont = params.font
157-
? FONT_MAP[params.font.toLowerCase()] || '"JetBrains Mono", monospace'
158-
: null;
156+
const sanitizeFont = (name: string) => name.replace(/[^a-zA-Z0-9\s-]/g, '').trim();
157+
const sanitizedFont = params.font ? sanitizeFont(params.font) : null;
158+
159+
const predefinedFont = sanitizedFont ? FONT_MAP[sanitizedFont.toLowerCase()] : null;
160+
const isPredefinedFont = Boolean(predefinedFont);
161+
162+
const selectedFont = isPredefinedFont
163+
? predefinedFont
164+
: sanitizedFont
165+
? `"${sanitizedFont}", sans-serif`
166+
: null;
159167

160168
const defaultTitleFont = '"Syncopate", sans-serif';
161169
const defaultBodyFont = '"Space Grotesk", sans-serif';
@@ -199,6 +207,14 @@ export function generateSVG(
199207
}
200208
}
201209

210+
// dynamic google fonts import
211+
const googleFontsImport =
212+
sanitizedFont && !isPredefinedFont
213+
? `@import url('https://fonts.googleapis.com/css2?family=${encodeURIComponent(
214+
sanitizedFont
215+
).replace(/%20/g, '+')}&amp;display=swap');`
216+
: '';
217+
202218
return `
203219
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="420" viewBox="0 0 600 420" fill="none">
204220
<defs>
@@ -210,6 +226,7 @@ export function generateSVG(
210226
211227
<style>
212228
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&amp;family=JetBrains+Mono&amp;family=Roboto&amp;display=swap');
229+
${googleFontsImport}
213230
214231
.title {
215232
font-family: ${selectedFont || defaultTitleFont};

0 commit comments

Comments
 (0)