Skip to content

Commit 7fcf753

Browse files
committed
refactor(generator): extract renderHeader, renderStyle, renderTowers, renderFooter helpers from generateSVG
1 parent c449e64 commit 7fcf753

1 file changed

Lines changed: 93 additions & 92 deletions

File tree

lib/svg/generator.ts

Lines changed: 93 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -102,52 +102,47 @@ function generateAutoParticles(
102102
return `<g class="heat-particles">${particles}</g>`;
103103
}
104104

105-
// main renderers
106-
export function generateSVG(
107-
stats: StreakStats,
108-
params: BadgeParams,
109-
calendar: ContributionCalendar
110-
): string {
111-
// Dispatch to the auto-theme renderer when the caller requests it.
112-
// This keeps the existing static-theme path completely unchanged.
113-
if (params.autoTheme) {
114-
return generateAutoThemeSVG(stats, params, calendar);
115-
}
116-
const safeUser = escapeXML(params.user || 'GitHub User');
117-
118-
const bg = `#${sanitizeHexColor(params.bg, '0d1117')}`;
119-
const accent = `#${sanitizeHexColor(params.accent, '00ffaa')}`;
120-
const text = `#${sanitizeHexColor(params.text, 'ffffff')}`;
105+
// ── Section helpers for generateSVG ──────────────────────────────────────
121106

122-
const sanitizedFont = sanitizeFont(params.font);
123-
const predefinedFont = sanitizedFont ? FONT_MAP[sanitizedFont.toLowerCase()] : null;
124-
const isPredefinedFont = Boolean(predefinedFont);
125-
const selectedFont = isPredefinedFont
126-
? predefinedFont
127-
: sanitizedFont
128-
? `"${sanitizedFont}", sans-serif`
129-
: null;
107+
function renderHeader(safeUser: string, stats: StreakStats, sf: number): string {
108+
const fs = (n: number) => Math.round(n * sf * 10) / 10;
109+
return `
110+
<title>CommitPulse Stats for ${safeUser}</title>
111+
<desc>
112+
${safeUser} has ${stats.totalContributions} total contributions and a longest streak of ${stats.longestStreak} days.
113+
</desc>
114+
<defs>
115+
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="${fs(5)}" result="blur" /><feComposite in="SourceGraphic" in2="blur" operator="over" /></filter>
116+
</defs>`;
117+
}
130118

131-
const statsFont = selectedFont || '"Space Grotesk", sans-serif';
132-
const sf = getSizeScale(params.size);
133-
const radius = sanitizeRadius(params.radius, 8) * sf;
134-
const labels = getLabels(params.lang);
119+
function renderStyle(
120+
selectedFont: string | null,
121+
statsFont: string,
122+
googleFontsImport: string,
123+
text: string,
124+
accent: string,
125+
sf: number
126+
): string {
127+
const fs = (n: number) => Math.round(n * sf * 10) / 10;
128+
return `
129+
<style>
130+
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&amp;family=JetBrains+Mono&amp;family=Roboto&amp;display=swap');
131+
${googleFontsImport}
132+
${TOWER_ANIMATION_CSS}
133+
.title { font-family: ${selectedFont || '"Syncopate", sans-serif'}; fill: ${text}; font-size: ${fs(18)}px; letter-spacing: ${fs(6)}px; font-weight: 400; opacity: 0.8; }
134+
.stats { font-family: ${statsFont}; fill: ${text}; font-size: ${fs(42)}px; font-weight: 500; letter-spacing: 0; }
135+
.total-val { font-family: ${statsFont}; fill: ${accent}; font-size: ${fs(24)}px; font-weight: 500; }
136+
.label { font-family: "Roboto", sans-serif; fill: ${accent}; font-size: ${fs(11)}px; font-weight: 400; letter-spacing: ${fs(2)}px; opacity: 0.7; }
137+
@media (prefers-reduced-motion: reduce) { .heat-particles { display: none; } }
138+
</style>`;
139+
}
135140

136-
const W = Math.round(600 * sf);
137-
const H = Math.round(420 * sf);
138-
const towerData = scaleTowerData(computeTowers(calendar, params.scale, stats.todayDate), sf);
141+
function renderTowers(towerData: TowerData[], accent: string, text: string, sf: number): string {
139142
let towers = '';
140-
141143
for (const t of towerData) {
142144
const color = t.isGhost ? text : accent;
143-
// Stagger delay creates a diagonal wave across the isometric grid (back-to-front)
144145
const delay = ((t.row + t.col) * 0.015).toFixed(3);
145-
146-
// The outer <g> positions the group at the ground tile (t.x, t.y).
147-
// The inner <g class="cp-tower"> is what CSS animates with scaleY.
148-
// Keeping these two responsibilities in separate elements prevents the
149-
// CSS transform from fighting the SVG translate — they operate independently.
150-
// Geometry paths are drawn offset by -t.h so they extend upward from y=10 (ground).
151146
towers += `
152147
<g transform="translate(${t.x}, ${t.y})">
153148
<g class="cp-tower" style="animation-delay: ${delay}s;">
@@ -162,80 +157,86 @@ export function generateSVG(
162157
if (t.contributionCount >= 10)
163158
towers += generateParticles(t.x, t.y, t.h, accent, t.contributionCount, sf);
164159
}
160+
return towers;
161+
}
165162

166-
// dynamic google fonts import
167-
const googleFontsImport =
168-
sanitizedFont && !isPredefinedFont
169-
? `@import url('https://fonts.googleapis.com/css2?family=${encodeURIComponent(sanitizedFont).replace(/%20/g, '+')}&amp;display=swap');`
170-
: '';
171-
163+
function renderFooter(
164+
stats: StreakStats,
165+
params: BadgeParams,
166+
labels: ReturnType<typeof getLabels>,
167+
safeUser: string,
168+
accent: string,
169+
sf: number
170+
): string {
172171
const s = (n: number) => Math.round(n * sf);
173-
const fs = (n: number) => Math.round(n * sf * 10) / 10;
174-
175172
return `
176-
<svg
177-
xmlns="http://www.w3.org/2000/svg"
178-
width="${W}"
179-
height="${H}"
180-
viewBox="0 0 ${W} ${H}"
181-
fill="none"
182-
role="img"
183-
>
184-
<title>CommitPulse Stats for ${safeUser}</title>
185-
<desc>
186-
${params.user || 'This user'} has ${stats.totalContributions} total contributions and a longest streak of ${stats.longestStreak} days.
187-
</desc>
188-
<defs>
189-
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="${fs(5)}" result="blur" /><feComposite in="SourceGraphic" in2="blur" operator="over" /></filter>
190-
</defs>
191-
192-
<style>
193-
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&amp;family=JetBrains+Mono&amp;family=Roboto&amp;display=swap');
194-
${googleFontsImport}
195-
${TOWER_ANIMATION_CSS}
196-
197-
.title { font-family: ${selectedFont || '"Syncopate", sans-serif'}; fill: ${text}; font-size: ${fs(18)}px; letter-spacing: ${fs(6)}px; font-weight: 400; opacity: 0.8; }
198-
.stats { font-family: ${statsFont}; fill: ${text}; font-size: ${fs(42)}px; font-weight: 500; letter-spacing: 0; }
199-
.total-val { font-family: ${statsFont}; fill: ${accent}; font-size: ${fs(24)}px; font-weight: 500; }
200-
.label { font-family: "Roboto", sans-serif; fill: ${accent}; font-size: ${fs(11)}px; font-weight: 400; letter-spacing: ${fs(2)}px; opacity: 0.7; }
201-
202-
@media (prefers-reduced-motion: reduce) { .heat-particles { display: none; } }
203-
</style>
204-
205-
<rect width="${W}" height="${H}" rx="${radius}" fill="${params.hideBackground ? 'transparent' : bg}" />
206-
207-
<g transform="translate(0, ${s(20)})">${towers}</g>
208173
${
209174
!params.hide_stats
210175
? `
211176
<g transform="translate(${s(40)}, ${s(340)})">
212177
<text class="label">${labels.CURRENT_STREAK}</text>
213178
<text y="${s(40)}" class="stats" filter="url(#glow)">${stats.currentStreak}</text>
214179
</g>
215-
216180
<g transform="translate(${s(300)}, ${s(340)})" text-anchor="middle">
217181
<text class="label">${labels.ANNUAL_SYNC_TOTAL}</text>
218182
<text y="${s(40)}" class="total-val" filter="url(#glow)">${stats.totalContributions}</text>
219183
</g>
220-
221184
<g transform="translate(${s(560)}, ${s(340)})" text-anchor="end">
222185
<text class="label">${labels.PEAK_STREAK}</text>
223186
<text y="${s(40)}" class="stats">${stats.longestStreak}</text>
224-
</g>
225-
`
187+
</g>`
226188
: ''
227189
}
228-
${
229-
!params.hide_title
230-
? `<text x="${s(300)}" y="${s(50)}" text-anchor="middle" class="title">${safeUser.toUpperCase()}</text>`
231-
: ''
232-
}
233-
190+
${!params.hide_title ? `<text x="${s(300)}" y="${s(50)}" text-anchor="middle" class="title">${safeUser.toUpperCase()}</text>` : ''}
234191
<rect x="${s(100)}" y="${s(60)}" width="${s(400)}" height="${sf}" fill="${accent}" fill-opacity="0.3">
235192
<animate attributeName="y" values="${s(80)};${s(320)};${s(80)}" dur="${params.speed || '8s'}" repeatCount="indefinite" />
236-
</rect>
237-
</svg>
238-
`;
193+
</rect>`;
194+
}
195+
196+
// ── Main static-theme renderer ────────────────────────────────────────────
197+
export function generateSVG(
198+
stats: StreakStats,
199+
params: BadgeParams,
200+
calendar: ContributionCalendar
201+
): string {
202+
if (params.autoTheme) return generateAutoThemeSVG(stats, params, calendar);
203+
204+
const safeUser = escapeXML(params.user || 'GitHub User');
205+
const bg = `#${sanitizeHexColor(params.bg, '0d1117')}`;
206+
const accent = `#${sanitizeHexColor(params.accent, '00ffaa')}`;
207+
const text = `#${sanitizeHexColor(params.text, 'ffffff')}`;
208+
209+
const sanitizedFont = sanitizeFont(params.font);
210+
const predefinedFont = sanitizedFont ? FONT_MAP[sanitizedFont.toLowerCase()] : null;
211+
const isPredefinedFont = Boolean(predefinedFont);
212+
const selectedFont = isPredefinedFont
213+
? predefinedFont
214+
: sanitizedFont
215+
? `"${sanitizedFont}", sans-serif`
216+
: null;
217+
const statsFont = selectedFont || '"Space Grotesk", sans-serif';
218+
const googleFontsImport =
219+
sanitizedFont && !isPredefinedFont
220+
? `@import url('https://fonts.googleapis.com/css2?family=${encodeURIComponent(sanitizedFont).replace(/%20/g, '+')}&amp;display=swap');`
221+
: '';
222+
223+
const sf = getSizeScale(params.size);
224+
const radius = sanitizeRadius(params.radius, 8) * sf;
225+
const labels = getLabels(params.lang);
226+
const W = Math.round(600 * sf);
227+
const H = Math.round(420 * sf);
228+
229+
const towerData = scaleTowerData(computeTowers(calendar, params.scale, stats.todayDate), sf);
230+
const towers = renderTowers(towerData, accent, text, sf);
231+
232+
return `
233+
<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" fill="none" role="img">
234+
${renderHeader(safeUser, stats, sf)}
235+
${renderStyle(selectedFont, statsFont, googleFontsImport, text, accent, sf)}
236+
<rect width="${W}" height="${H}" rx="${radius}" fill="${params.hideBackground ? 'transparent' : bg}" />
237+
<g transform="translate(0, ${Math.round(20 * sf)})">${towers}</g>
238+
${renderFooter(stats, params, labels, safeUser, accent, sf)}
239+
</svg>`;
239240
}
240241

241242
//generates an svg for the non existent users

0 commit comments

Comments
 (0)