Skip to content

Commit b18e2b9

Browse files
authored
merge: feat(themes): Add themed SVG fallbacks for API error responses (#8253)
## Description Fixes #7796 ## Pillar - [x] 🎨 Pillar 1 — New Theme Design - [ ] 📐 Pillar 2 — Geometric SVG Improvement - [ ] 🕐 Pillar 3 — Timezone Logic Optimization - [ ] 🛠️ Other (Bug fix, refactoring, docs) ## Visual Preview Previously, when hitting API rate limits or facing validation errors, CommitPulse returned a generic `#2d0000` (red) SVG error box which looked out of place on beautifully themed GitHub Profile READMEs. With this PR: - Error responses now parse the incoming query params (`theme`, custom `bg`, `accent`, `text`, etc.) using `resolveErrorTheme`. - A matching visually-themed SVG fallback is generated via `buildInlineErrorSVG` containing the appropriate border radius, background, text, and accent borders. - Supported in `/api/spotify`, `/api/spotlight`, `/api/streak`, `/api/wakatime`, and `/api/wrapped`. *Example theme response when requesting `theme=dracula`:* ```xml <svg xmlns="http://www.w3.org/2000/svg" width="400" height="150" viewBox="0 0 400 150"> <rect width="400" height="150" fill="#282a36" rx="8" stroke="#bd93f9" stroke-opacity="0.25" stroke-width="1"/> <text x="200" y="75" text-anchor="middle" dominant-baseline="central" fill="#f8f8f2" font-family="sans-serif" font-size="13">Rate Limit Exceeded</text> </svg> ``` ## 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). - [x] (Recommended) I joined the CommitPulse Discord community for contributor discussions, mentorship, and faster PR support.
2 parents 43fc865 + 7ad2df0 commit b18e2b9

10 files changed

Lines changed: 358 additions & 124 deletions

File tree

app/api/spotify/route.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import { NextResponse } from 'next/server';
44
import { getCurrentlyPlaying } from '@/services/spotify/api';
55
import { generateSpotifySVG } from '@/lib/svg/spotify';
6+
import { buildInlineErrorSVG } from '@/lib/svg/generator';
7+
import { resolveErrorTheme } from '@/lib/svg/themes';
68
import { spotifyParamsSchema, coerceQueryParams } from '@/lib/validations';
79
import { optimizeSVG } from '@/lib/svg/optimizer';
810
import crypto from 'crypto';
@@ -39,10 +41,13 @@ export async function GET(request: Request) {
3941
fieldErrors.formErrors[0] ??
4042
'Invalid parameters';
4143

42-
const errorSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="150" viewBox="0 0 400 150">
43-
<rect width="400" height="150" fill="#2d0000" rx="8"/>
44-
<text x="200" y="75" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${firstError}</text>
45-
</svg>`;
44+
const errTheme = resolveErrorTheme(searchParams);
45+
const errorSvg = buildInlineErrorSVG(firstError, {
46+
bg: errTheme.bg,
47+
accent: errTheme.accent,
48+
text: errTheme.text,
49+
radius: errTheme.radius,
50+
});
4651

4752
return new NextResponse(errorSvg, {
4853
status: 400,

app/api/spotlight/route.ts

Lines changed: 76 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import crypto from 'crypto';
22
import { NextResponse } from 'next/server';
33
import { fetchRepoDetails } from '@/lib/github';
44
import { generateRepoSpotlightSVG } from '@/lib/svg/repoSpotlight';
5-
import { getNormalizedThemeKey, themes } from '@/lib/svg/themes';
5+
import { getNormalizedThemeKey, themes, resolveErrorTheme } from '@/lib/svg/themes';
6+
import { buildInlineErrorSVG } from '@/lib/svg/generator';
67
import { streakParamsSchema, coerceQueryParams } from '@/lib/validations';
78
import { getClientIp } from '@/utils/getClientIp';
89
import { RateLimiter, getRateLimitHeaders } from '@/lib/rate-limit';
@@ -13,63 +14,78 @@ const spotlightLimiter = new RateLimiter(50, 60_000, 1);
1314
const SVG_CSP_HEADER =
1415
"default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src https://fonts.gstatic.com;";
1516

16-
function buildInlineErrorSVG(text: string): string {
17-
const MAX_LINE = 48;
18-
const truncated = text.length > MAX_LINE * 2 ? text.slice(0, MAX_LINE * 2 - 1) + '…' : text;
19-
const line1 = escapeXML(truncated.slice(0, MAX_LINE));
20-
const line2 = truncated.length > MAX_LINE ? escapeXML(truncated.slice(MAX_LINE)) : null;
21-
const textY = line2 ? '62' : '75';
22-
return `<svg xmlns="http://www.w3.org/2000/svg" width="450" height="160" viewBox="0 0 450 160">
23-
<rect width="450" height="160" fill="#2d0000" rx="8"/>
24-
<text x="225" y="${textY}" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${line1}</text>${
25-
line2
26-
? `\n <text x="225" y="91" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${line2}</text>`
27-
: ''
28-
}
29-
</svg>`;
30-
}
31-
3217
export async function GET(request: Request) {
18+
const { searchParams } = new URL(request.url);
19+
const errTheme = resolveErrorTheme(searchParams);
20+
3321
const ip = getClientIp(request);
3422
const rateLimitKey =
3523
ip && ip !== 'unknown' ? ip : `unknown:${request.headers.get('user-agent') ?? 'no-agent'}`;
3624

3725
const rateLimitResult = await spotlightLimiter.checkWithResult(rateLimitKey);
3826
if (!rateLimitResult.success) {
39-
return new NextResponse(buildInlineErrorSVG('Rate Limit Exceeded'), {
40-
status: 429,
41-
headers: {
42-
'Content-Type': 'image/svg+xml; charset=utf-8',
43-
'Content-Security-Policy': SVG_CSP_HEADER,
44-
'Cache-Control': 'no-store',
45-
...getRateLimitHeaders(rateLimitResult),
46-
},
47-
});
27+
return new NextResponse(
28+
buildInlineErrorSVG('Rate Limit Exceeded', {
29+
bg: errTheme.bg,
30+
accent: errTheme.accent,
31+
text: errTheme.text,
32+
radius: errTheme.radius,
33+
width: 450,
34+
height: 160,
35+
}),
36+
{
37+
status: 429,
38+
headers: {
39+
'Content-Type': 'image/svg+xml; charset=utf-8',
40+
'Content-Security-Policy': SVG_CSP_HEADER,
41+
'Cache-Control': 'no-store',
42+
...getRateLimitHeaders(rateLimitResult),
43+
},
44+
}
45+
);
4846
}
4947

50-
const { searchParams } = new URL(request.url);
51-
5248
// repo is required for spotlight
5349
const repoName = searchParams.get('repo');
5450
if (!repoName) {
55-
return new NextResponse(buildInlineErrorSVG('Missing repo parameter'), {
56-
status: 400,
57-
headers: {
58-
'Content-Type': 'image/svg+xml; charset=utf-8',
59-
'Content-Security-Policy': SVG_CSP_HEADER,
60-
},
61-
});
51+
return new NextResponse(
52+
buildInlineErrorSVG('Missing repo parameter', {
53+
bg: errTheme.bg,
54+
accent: errTheme.accent,
55+
text: errTheme.text,
56+
radius: errTheme.radius,
57+
width: 450,
58+
height: 160,
59+
}),
60+
{
61+
status: 400,
62+
headers: {
63+
'Content-Type': 'image/svg+xml; charset=utf-8',
64+
'Content-Security-Policy': SVG_CSP_HEADER,
65+
},
66+
}
67+
);
6268
}
6369

6470
const parseResult = streakParamsSchema.safeParse(coerceQueryParams(searchParams));
6571
if (!parseResult.success) {
66-
return new NextResponse(buildInlineErrorSVG('Invalid parameters'), {
67-
status: 400,
68-
headers: {
69-
'Content-Type': 'image/svg+xml; charset=utf-8',
70-
'Content-Security-Policy': SVG_CSP_HEADER,
71-
},
72-
});
72+
return new NextResponse(
73+
buildInlineErrorSVG('Invalid parameters', {
74+
bg: errTheme.bg,
75+
accent: errTheme.accent,
76+
text: errTheme.text,
77+
radius: errTheme.radius,
78+
width: 450,
79+
height: 160,
80+
}),
81+
{
82+
status: 400,
83+
headers: {
84+
'Content-Type': 'image/svg+xml; charset=utf-8',
85+
'Content-Security-Policy': SVG_CSP_HEADER,
86+
},
87+
}
88+
);
7389
}
7490

7591
const { user, theme, bg, text, accent, format } = parseResult.data;
@@ -120,13 +136,23 @@ export async function GET(request: Request) {
120136
});
121137
} catch (error: unknown) {
122138
const message = error instanceof Error ? error.message : String(error);
123-
return new NextResponse(buildInlineErrorSVG(message), {
124-
status: message.includes('not found') ? 404 : 500,
125-
headers: {
126-
'Content-Type': 'image/svg+xml; charset=utf-8',
127-
'Content-Security-Policy': SVG_CSP_HEADER,
128-
'Cache-Control': 'no-store',
129-
},
130-
});
139+
return new NextResponse(
140+
buildInlineErrorSVG(message, {
141+
bg: errTheme.bg,
142+
accent: errTheme.accent,
143+
text: errTheme.text,
144+
radius: errTheme.radius,
145+
width: 450,
146+
height: 160,
147+
}),
148+
{
149+
status: message.includes('not found') ? 404 : 500,
150+
headers: {
151+
'Content-Type': 'image/svg+xml; charset=utf-8',
152+
'Content-Security-Policy': SVG_CSP_HEADER,
153+
'Cache-Control': 'no-store',
154+
},
155+
}
156+
);
131157
}
132158
}

app/api/streak/route.ts

Lines changed: 46 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
generateSkylineSVG,
3232
generateLanguagesSVG,
3333
generateActivityGraphSVG,
34+
buildInlineErrorSVG,
3435
} from '@/lib/svg/generator';
3536
import { generateConstellationSVG } from '@/lib/svg/constellation';
3637
import { generateRadarSVG } from '@/lib/svg/radar';
@@ -48,7 +49,7 @@ import type {
4849
ContributionCalendar,
4950
StreakStats,
5051
} from '@/types';
51-
import { getNormalizedThemeKey, themes } from '@/lib/svg/themes';
52+
import { getNormalizedThemeKey, themes, resolveErrorTheme } from '@/lib/svg/themes';
5253
import { streakParamsSchema, coerceQueryParams } from '@/lib/validations';
5354
import { sanitizeHexColor, sanitizeRadius, escapeXML } from '@/lib/svg/sanitizer';
5455
import { getClientIp } from '@/utils/getClientIp';
@@ -64,26 +65,6 @@ const validationCache = _vc;
6465
const SVG_CSP_HEADER =
6566
"default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src https://fonts.gstatic.com;";
6667

67-
function buildInlineErrorSVG(text: string): string {
68-
const MAX_LINE = 48;
69-
const chars = Array.from(text);
70-
const truncated =
71-
chars.length > MAX_LINE * 2 ? chars.slice(0, MAX_LINE * 2 - 1).join('') + '…' : text;
72-
const truncatedChars = Array.from(truncated);
73-
const line1 = escapeXML(truncatedChars.slice(0, MAX_LINE).join(''));
74-
const line2 =
75-
truncatedChars.length > MAX_LINE ? escapeXML(truncatedChars.slice(MAX_LINE).join('')) : null;
76-
const textY = line2 ? '62' : '75';
77-
return `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="150" viewBox="0 0 400 150">
78-
<rect width="400" height="150" fill="#2d0000" rx="8"/>
79-
<text x="200" y="${textY}" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${line1}</text>${
80-
line2
81-
? `\n <text x="200" y="91" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${line2}</text>`
82-
: ''
83-
}
84-
</svg>`;
85-
}
86-
8768
function getMonthlyReferenceDate(year: string | undefined, timezone: string): Date | undefined {
8869
if (!year) return undefined;
8970

@@ -119,7 +100,13 @@ export async function GET(request: Request) {
119100
Object.values(fieldErrors.fieldErrors).flat()[0] ??
120101
fieldErrors.formErrors[0] ??
121102
'Invalid parameters';
122-
const errorSvg = buildInlineErrorSVG(firstError);
103+
const errTheme = resolveErrorTheme(searchParams);
104+
const errorSvg = buildInlineErrorSVG(firstError, {
105+
bg: errTheme.bg,
106+
accent: errTheme.accent,
107+
text: errTheme.text,
108+
radius: errTheme.radius,
109+
});
123110
return new NextResponse(errorSvg, {
124111
status: 400,
125112
headers: {
@@ -786,7 +773,7 @@ export async function GET(request: Request) {
786773
},
787774
});
788775
} catch (error: unknown) {
789-
return buildErrorResponse(error, parseResult, requestId);
776+
return buildErrorResponse(error, parseResult, requestId, request);
790777
}
791778
}
792779

@@ -824,7 +811,8 @@ function sanitizeErrorMessage(message: string): string {
824811
function buildErrorResponse(
825812
error: unknown,
826813
parseResult: ParseResult,
827-
requestId?: string
814+
requestId?: string,
815+
request?: Request
828816
): NextResponse {
829817
const rawMessage = error instanceof Error ? error.message : String(error);
830818
const message = sanitizeErrorMessage(rawMessage);
@@ -880,17 +868,28 @@ function buildErrorResponse(
880868
rawMessage.toLowerCase().includes('validation') ||
881869
rawMessage.toLowerCase().includes('strictly for organizations');
882870

883-
const errBg = `#${sanitizeHexColor(parseResult.success ? parseResult.data.bg : undefined, '0d1117')}`;
871+
const searchParams = request ? new URL(request.url).searchParams : undefined;
872+
const errTheme = resolveErrorTheme(searchParams);
873+
const errBg =
874+
parseResult.success && parseResult.data.bg
875+
? `#${sanitizeHexColor(parseResult.data.bg, '0d1117')}`
876+
: errTheme.bg;
884877
const errAccentRaw =
885878
(parseResult.success &&
886879
(Array.isArray(parseResult.data.accent)
887880
? parseResult.data.accent[parseResult.data.accent.length - 1]
888881
: parseResult.data.accent)) ||
889882
undefined;
890-
const errAccent = `#${sanitizeHexColor(errAccentRaw, '58a6ff')}`;
891-
const errText = `#${sanitizeHexColor(parseResult.success ? parseResult.data.text : undefined, 'c9d1d9')}`;
892-
const errRadius = sanitizeRadius(parseResult.success ? parseResult.data.radius : undefined, 8);
893-
const errSpeed = (parseResult.success && parseResult.data.speed) || '8s';
883+
const errAccent = errAccentRaw ? `#${sanitizeHexColor(errAccentRaw, '58a6ff')}` : errTheme.accent;
884+
const errText =
885+
parseResult.success && parseResult.data.text
886+
? `#${sanitizeHexColor(parseResult.data.text, 'c9d1d9')}`
887+
: errTheme.text;
888+
const errRadius =
889+
parseResult.success && parseResult.data.radius !== undefined
890+
? sanitizeRadius(parseResult.data.radius, 8)
891+
: errTheme.radius;
892+
const errSpeed = (parseResult.success && parseResult.data.speed) || errTheme.speed;
894893

895894
if (isRateLimit) {
896895
const telemetry = getCircuitTelemetry();
@@ -942,7 +941,12 @@ function buildErrorResponse(
942941

943942
// 3. Return a 400 Bad Request for Validation Errors
944943
if (isValidationError) {
945-
const validationSvg = buildInlineErrorSVG(message);
944+
const validationSvg = buildInlineErrorSVG(message, {
945+
bg: errBg,
946+
accent: errAccent,
947+
text: errText,
948+
radius: errRadius,
949+
});
946950
const errorHeaders: Record<string, string> = {
947951
'Content-Type': 'image/svg+xml; charset=utf-8',
948952
'Cache-Control': 'no-store',
@@ -959,7 +963,12 @@ function buildErrorResponse(
959963

960964
// 4. Return a 504 Gateway Timeout for aborted/timed out requests
961965
if (isAbortError(error)) {
962-
const timeoutSvg = buildInlineErrorSVG('Request timed out. Please try again later.');
966+
const timeoutSvg = buildInlineErrorSVG('Request timed out. Please try again later.', {
967+
bg: errBg,
968+
accent: errAccent,
969+
text: errText,
970+
radius: errRadius,
971+
});
963972
const errorHeaders: Record<string, string> = {
964973
'Content-Type': 'image/svg+xml; charset=utf-8',
965974
'Cache-Control': 'no-store',
@@ -980,7 +989,12 @@ function buildErrorResponse(
980989
message,
981990
});
982991

983-
const errorSvg = buildInlineErrorSVG('Something went wrong. Please try again later.');
992+
const errorSvg = buildInlineErrorSVG('Something went wrong. Please try again later.', {
993+
bg: errBg,
994+
accent: errAccent,
995+
text: errText,
996+
radius: errRadius,
997+
});
984998
const errorHeaders: Record<string, string> = {
985999
'Content-Type': 'image/svg+xml; charset=utf-8',
9861000
'Cache-Control': 'no-store',

app/api/wakatime/route.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { NextResponse } from 'next/server';
22
import { getWakaTimeStats } from '@/services/wakatime/api';
33
import { generateWakaTimeSVG } from '@/lib/svg/wakatime';
4+
import { buildInlineErrorSVG } from '@/lib/svg/generator';
5+
import { resolveErrorTheme } from '@/lib/svg/themes';
46
import { wakatimeParamsSchema, coerceQueryParams } from '@/lib/validations';
57
import { optimizeSVG } from '@/lib/svg/optimizer';
68
import crypto from 'crypto';
@@ -20,10 +22,13 @@ export async function GET(request: Request) {
2022
fieldErrors.formErrors[0] ??
2123
'Invalid parameters';
2224

23-
const errorSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="150" viewBox="0 0 400 150">
24-
<rect width="400" height="150" fill="#2d0000" rx="8"/>
25-
<text x="200" y="75" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${firstError}</text>
26-
</svg>`;
25+
const errTheme = resolveErrorTheme(searchParams);
26+
const errorSvg = buildInlineErrorSVG(firstError, {
27+
bg: errTheme.bg,
28+
accent: errTheme.accent,
29+
text: errTheme.text,
30+
radius: errTheme.radius,
31+
});
2732

2833
return new NextResponse(errorSvg, {
2934
status: 400,

app/api/wrapped/route.empty-fallback.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ vi.mock('@/lib/svg/generator', () => ({
1414
generateWrappedSVG: vi.fn(() => '<svg>mock-wrapped</svg>'),
1515
generateNotFoundSVG: vi.fn(() => '<svg>mock-not-found</svg>'),
1616
generateRateLimitSVG: vi.fn(() => '<svg>mock-rate-limit</svg>'),
17+
buildInlineErrorSVG: vi.fn((text: string) => `<svg>mock-inline-error: ${text}</svg>`),
1718
}));
1819

1920
vi.mock('@/utils/getClientIp', () => ({

app/api/wrapped/route.error-resilience.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ vi.mock('@/lib/svg/generator', () => ({
2424
generateWrappedSVG: vi.fn(),
2525
generateNotFoundSVG: vi.fn().mockReturnValue('<svg>not found</svg>'),
2626
generateRateLimitSVG: vi.fn().mockReturnValue('<svg>rate limit</svg>'),
27+
buildInlineErrorSVG: vi.fn(
28+
(text: string) =>
29+
`<svg>mock-inline-error: ${text.replace(/[<>&'"]/g, (c: string) => (c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '&' ? '&amp;' : c === "'" ? '&apos;' : '&quot;'))}</svg>`
30+
),
2731
}));
2832

2933
// Import after mocks

0 commit comments

Comments
 (0)