Skip to content

Commit 688dd93

Browse files
authored
fix(generator): replace & with & in CSS @import font URLs inside SVG style blocks (JhaSourav07#1215)
## Description Fixes JhaSourav07#1209 Inside an SVG `<style>` block, content is parsed as raw CSS text — not XML. The `renderStyle` function in `lib/svg/generator.ts` was using `&amp;` as a separator in the Google Fonts `@import` URL, which CSS parsers treat as a literal string, silently breaking the font request and falling back to `sans-serif`. Replaced all `&amp;` occurrences with raw `&` inside CSS `@import` declarations — both in the static font string and the dynamic Google Font parameter path. ## Pillar - [ ] 🎨 Pillar 1 — New Theme Design - [ ] 📐 Pillar 2 — Geometric SVG Improvement - [ ] 🕐 Pillar 3 — Timezone Logic Optimization - [x] 🛠️ Other (Bug fix) ## Visual Preview No visual diff needed — this is a CSS parsing fix. Fonts now load correctly in server-side renderers (`resvg`, `sharp`), Inkscape, VS Code SVG preview, and older mobile browsers where `&amp;` was causing silent fallback to `sans-serif`. ## Changes Made - `lib/svg/generator.ts` — replaced `&amp;` with `&` in all `@import` URL strings - `lib/svg/generator.test.ts` — updated assertions to expect raw `&` - `app/api/streak/route.test.ts` — updated assertions to expect raw `&` ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally. - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors. - [x] My commits follow the Conventional Commits format (`fix(generator): ...`). - [x] I have starred 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.
2 parents 97d2cd4 + 7200527 commit 688dd93

3 files changed

Lines changed: 14 additions & 14 deletions

File tree

app/api/streak/route.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -910,7 +910,7 @@ describe('GET /api/streak', () => {
910910
expect(response.status).toBe(200);
911911
expect(body).toContain('Space Grotesk');
912912
// Whitespace-only should not produce a Google Fonts import with an empty family
913-
expect(body).not.toContain('family=+&amp;display=swap');
913+
expect(body).not.toContain('family=+&display=swap');
914914
});
915915

916916
it('passes a valid predefined font name through to the SVG', async () => {
@@ -945,7 +945,7 @@ describe('GET /api/streak', () => {
945945
expect(response.status).toBe(200);
946946
expect(body).toContain('Space Grotesk');
947947
// No empty Google Fonts import should be emitted
948-
expect(body).not.toContain('family=&amp;display=swap');
948+
expect(body).not.toContain('family=&display=swap');
949949
});
950950

951951
it('strips dangerous characters from a font name containing a double-quote', async () => {
@@ -1003,7 +1003,7 @@ describe('GET /api/streak', () => {
10031003
expect(response.status).toBe(200);
10041004
expect(body).toContain('Fira Code');
10051005
// Should NOT emit a second dynamic import for the same font
1006-
expect(body).not.toContain('family=fira&amp;display=swap');
1006+
expect(body).not.toContain('family=fira&display=swap');
10071007
});
10081008

10091009
it('returns 200 and a valid SVG even when an extreme font value is supplied', async () => {

lib/svg/generator.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ describe('generateSVG', () => {
141141
);
142142

143143
expect(svg).toContain(
144-
"@import url('https://fonts.googleapis.com/css2?family=Inter&amp;display=swap');"
144+
"@import url('https://fonts.googleapis.com/css2?family=Inter&display=swap');"
145145
);
146146
expect(svg).toContain('font-family: "Inter", sans-serif;');
147147
});
@@ -182,7 +182,7 @@ describe('generateSVG', () => {
182182
mockCalendar
183183
);
184184
// Should NOT contain a dynamic google fonts import for an empty/invalid family
185-
expect(svg).not.toContain('family=&amp;display=swap');
185+
expect(svg).not.toContain('family=&display=swap');
186186
// Should use default body font
187187
expect(svg).toContain('font-family: "Space Grotesk", sans-serif');
188188
});
@@ -194,7 +194,7 @@ describe('generateSVG', () => {
194194
mockCalendar
195195
);
196196
expect(svg).toContain('Space Grotesk');
197-
expect(svg).not.toContain('family=&amp;display=swap');
197+
expect(svg).not.toContain('family=&display=swap');
198198
});
199199

200200
it('uses default font when font param is whitespace only', () => {
@@ -204,7 +204,7 @@ describe('generateSVG', () => {
204204
mockCalendar
205205
);
206206
expect(svg).toContain('Space Grotesk');
207-
expect(svg).not.toContain('family=+&amp;display=swap');
207+
expect(svg).not.toContain('family=+&display=swap');
208208
});
209209

210210
it('allows apostrophes in font names like Times New Roman', () => {

lib/svg/generator.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ function renderStyle(
153153
const fs = (n: number) => Math.round(n * sf * 10) / 10;
154154
return `
155155
<style>
156-
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&amp;family=JetBrains+Mono&amp;family=Roboto&amp;family=Syncopate:wght@400;700&amp;family=Space+Grotesk:wght@400;500;600;700&amp;display=swap');
156+
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&family=JetBrains+Mono&family=Roboto&family=Syncopate:wght@400;700&family=Space+Grotesk:wght@400;500;600;700&display=swap');
157157
${googleFontsImport}
158158
${TOWER_ANIMATION_CSS}
159159
.scan-line {
@@ -338,7 +338,7 @@ export function generateSVG(
338338
const googleFontUrlPart =
339339
sanitizedFont && !isPredefinedFont ? sanitizeGoogleFontUrl(sanitizedFont) : null;
340340
const googleFontsImport = googleFontUrlPart
341-
? `@import url('https://fonts.googleapis.com/css2?family=${googleFontUrlPart}&amp;display=swap');`
341+
? `@import url('https://fonts.googleapis.com/css2?family=${googleFontUrlPart}&display=swap');`
342342
: '';
343343

344344
const sf = getSizeScale(params.size);
@@ -424,7 +424,7 @@ function generateAutoThemeSVG(
424424
${renderHeader(safeUser, stats, sf, params)}
425425
426426
<style>
427-
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&amp;family=JetBrains+Mono&amp;family=Roboto&amp;family=Syncopate:wght@400;700&amp;family=Space+Grotesk:wght@400;500;600;700&amp;display=swap');
427+
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&family=JetBrains+Mono&family=Roboto&family=Syncopate:wght@400;700&family=Space+Grotesk:wght@400;500;600;700&display=swap');
428428
:root { --cp-bg: #${light.bg}; --cp-text: #${light.text}; --cp-accent: #${light.accent}; }
429429
@media (prefers-color-scheme: dark) { :root { --cp-bg: #${dark.bg}; --cp-text: #${dark.text}; --cp-accent: #${dark.accent}; } }
430430
.cp-bg-fill { fill: var(--cp-bg); } .cp-text-fill { fill: var(--cp-text); color: var(--cp-text); } .cp-accent-fill { fill: var(--cp-accent); color: var(--cp-accent); }
@@ -511,7 +511,7 @@ export function generateMonthlySVG(stats: MonthlyStats, params: BadgeParams): st
511511
const googleFontUrlPart =
512512
sanitizedFont && !isPredefinedFont ? sanitizeGoogleFontUrl(sanitizedFont) : null;
513513
const googleFontsImport = googleFontUrlPart
514-
? `@import url('https://fonts.googleapis.com/css2?family=${googleFontUrlPart}&amp;display=swap');`
514+
? `@import url('https://fonts.googleapis.com/css2?family=${googleFontUrlPart}&display=swap');`
515515
: '';
516516

517517
const commitsLabel = params.mode === 'loc' ? 'LINES THIS MONTH' : labels.COMMITS_THIS_MONTH;
@@ -557,7 +557,7 @@ export function generateMonthlySVG(stats: MonthlyStats, params: BadgeParams): st
557557
>
558558
<title>Monthly Stats for ${safeUser}</title>
559559
<style>
560-
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&amp;family=JetBrains+Mono&amp;family=Roboto&amp;family=Syncopate:wght@400;700&amp;family=Space+Grotesk:wght@400;500;600;700&amp;display=swap');
560+
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&family=JetBrains+Mono&family=Roboto&family=Syncopate:wght@400;700&family=Space+Grotesk:wght@400;500;600;700&display=swap');
561561
${googleFontsImport}
562562
563563
.title { font-family: ${selectedFont || '"Syncopate", sans-serif'}; fill: ${text}; font-size: 14px; letter-spacing: 2px; font-weight: 400; opacity: 0.8; }
@@ -647,7 +647,7 @@ function generateAutoThemeMonthlySVG(stats: MonthlyStats, params: BadgeParams):
647647
>
648648
<title>Monthly Stats for ${safeUser}</title>
649649
<style>
650-
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&amp;family=JetBrains+Mono&amp;family=Roboto&amp;family=Syncopate:wght@400;700&amp;family=Space+Grotesk:wght@400;500;600;700&amp;display=swap');
650+
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&family=JetBrains+Mono&family=Roboto&family=Syncopate:wght@400;700&family=Space+Grotesk:wght@400;500;600;700&display=swap');
651651
:root { --cp-bg: #${light.bg}; --cp-text: #${light.text}; --cp-accent: #${light.accent}; --cp-negative: #ff4444; }
652652
@media (prefers-color-scheme: dark) { :root { --cp-bg: #${dark.bg}; --cp-text: #${dark.text}; --cp-accent: #${dark.accent}; --cp-negative: #ff6666; } }
653653
.cp-bg-fill { fill: var(--cp-bg); }
@@ -814,7 +814,7 @@ export function generateNotFoundSVG(
814814
</defs>
815815
816816
<style>
817-
@import url('https://fonts.googleapis.com/css2?family=Syncopate:wght@400;700&amp;family=Space+Grotesk:wght@400;500;600&amp;display=swap');
817+
@import url('https://fonts.googleapis.com/css2?family=Syncopate:wght@400;700&family=Space+Grotesk:wght@400;500;600&display=swap');
818818
.title { font-family: "Syncopate", sans-serif; fill: ${text}; font-size: 18px; letter-spacing: 6px; font-weight: 400; opacity: 0.5; }
819819
.label { font-family: "Roboto", sans-serif; fill: ${accent}; font-size: 11px; letter-spacing: 2px; opacity: 0.4; }
820820
.stats { font-family: "Space Grotesk", sans-serif; fill: ${text}; font-size: 42px; font-weight: 500; opacity: 0.2; }

0 commit comments

Comments
 (0)