diff --git a/app/api/spotify/route.ts b/app/api/spotify/route.ts
index 8370ebd30..e3a1b94ac 100644
--- a/app/api/spotify/route.ts
+++ b/app/api/spotify/route.ts
@@ -3,6 +3,8 @@
import { NextResponse } from 'next/server';
import { getCurrentlyPlaying } from '@/services/spotify/api';
import { generateSpotifySVG } from '@/lib/svg/spotify';
+import { buildInlineErrorSVG } from '@/lib/svg/generator';
+import { resolveErrorTheme } from '@/lib/svg/themes';
import { spotifyParamsSchema, coerceQueryParams } from '@/lib/validations';
import { optimizeSVG } from '@/lib/svg/optimizer';
import crypto from 'crypto';
@@ -39,10 +41,13 @@ export async function GET(request: Request) {
fieldErrors.formErrors[0] ??
'Invalid parameters';
- const errorSvg = ``;
+ const errTheme = resolveErrorTheme(searchParams);
+ const errorSvg = buildInlineErrorSVG(firstError, {
+ bg: errTheme.bg,
+ accent: errTheme.accent,
+ text: errTheme.text,
+ radius: errTheme.radius,
+ });
return new NextResponse(errorSvg, {
status: 400,
diff --git a/app/api/spotlight/route.ts b/app/api/spotlight/route.ts
index cfe22ae3c..ddfc6fd26 100644
--- a/app/api/spotlight/route.ts
+++ b/app/api/spotlight/route.ts
@@ -2,7 +2,8 @@ import crypto from 'crypto';
import { NextResponse } from 'next/server';
import { fetchRepoDetails } from '@/lib/github';
import { generateRepoSpotlightSVG } from '@/lib/svg/repoSpotlight';
-import { getNormalizedThemeKey, themes } from '@/lib/svg/themes';
+import { getNormalizedThemeKey, themes, resolveErrorTheme } from '@/lib/svg/themes';
+import { buildInlineErrorSVG } from '@/lib/svg/generator';
import { streakParamsSchema, coerceQueryParams } from '@/lib/validations';
import { getClientIp } from '@/utils/getClientIp';
import { RateLimiter, getRateLimitHeaders } from '@/lib/rate-limit';
@@ -13,63 +14,78 @@ const spotlightLimiter = new RateLimiter(50, 60_000, 1);
const SVG_CSP_HEADER =
"default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src https://fonts.gstatic.com;";
-function buildInlineErrorSVG(text: string): string {
- const MAX_LINE = 48;
- const truncated = text.length > MAX_LINE * 2 ? text.slice(0, MAX_LINE * 2 - 1) + '…' : text;
- const line1 = escapeXML(truncated.slice(0, MAX_LINE));
- const line2 = truncated.length > MAX_LINE ? escapeXML(truncated.slice(MAX_LINE)) : null;
- const textY = line2 ? '62' : '75';
- return ``;
-}
-
export async function GET(request: Request) {
+ const { searchParams } = new URL(request.url);
+ const errTheme = resolveErrorTheme(searchParams);
+
const ip = getClientIp(request);
const rateLimitKey =
ip && ip !== 'unknown' ? ip : `unknown:${request.headers.get('user-agent') ?? 'no-agent'}`;
const rateLimitResult = await spotlightLimiter.checkWithResult(rateLimitKey);
if (!rateLimitResult.success) {
- return new NextResponse(buildInlineErrorSVG('Rate Limit Exceeded'), {
- status: 429,
- headers: {
- 'Content-Type': 'image/svg+xml; charset=utf-8',
- 'Content-Security-Policy': SVG_CSP_HEADER,
- 'Cache-Control': 'no-store',
- ...getRateLimitHeaders(rateLimitResult),
- },
- });
+ return new NextResponse(
+ buildInlineErrorSVG('Rate Limit Exceeded', {
+ bg: errTheme.bg,
+ accent: errTheme.accent,
+ text: errTheme.text,
+ radius: errTheme.radius,
+ width: 450,
+ height: 160,
+ }),
+ {
+ status: 429,
+ headers: {
+ 'Content-Type': 'image/svg+xml; charset=utf-8',
+ 'Content-Security-Policy': SVG_CSP_HEADER,
+ 'Cache-Control': 'no-store',
+ ...getRateLimitHeaders(rateLimitResult),
+ },
+ }
+ );
}
- const { searchParams } = new URL(request.url);
-
// repo is required for spotlight
const repoName = searchParams.get('repo');
if (!repoName) {
- return new NextResponse(buildInlineErrorSVG('Missing repo parameter'), {
- status: 400,
- headers: {
- 'Content-Type': 'image/svg+xml; charset=utf-8',
- 'Content-Security-Policy': SVG_CSP_HEADER,
- },
- });
+ return new NextResponse(
+ buildInlineErrorSVG('Missing repo parameter', {
+ bg: errTheme.bg,
+ accent: errTheme.accent,
+ text: errTheme.text,
+ radius: errTheme.radius,
+ width: 450,
+ height: 160,
+ }),
+ {
+ status: 400,
+ headers: {
+ 'Content-Type': 'image/svg+xml; charset=utf-8',
+ 'Content-Security-Policy': SVG_CSP_HEADER,
+ },
+ }
+ );
}
const parseResult = streakParamsSchema.safeParse(coerceQueryParams(searchParams));
if (!parseResult.success) {
- return new NextResponse(buildInlineErrorSVG('Invalid parameters'), {
- status: 400,
- headers: {
- 'Content-Type': 'image/svg+xml; charset=utf-8',
- 'Content-Security-Policy': SVG_CSP_HEADER,
- },
- });
+ return new NextResponse(
+ buildInlineErrorSVG('Invalid parameters', {
+ bg: errTheme.bg,
+ accent: errTheme.accent,
+ text: errTheme.text,
+ radius: errTheme.radius,
+ width: 450,
+ height: 160,
+ }),
+ {
+ status: 400,
+ headers: {
+ 'Content-Type': 'image/svg+xml; charset=utf-8',
+ 'Content-Security-Policy': SVG_CSP_HEADER,
+ },
+ }
+ );
}
const { user, theme, bg, text, accent, format } = parseResult.data;
@@ -120,13 +136,23 @@ export async function GET(request: Request) {
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
- return new NextResponse(buildInlineErrorSVG(message), {
- status: message.includes('not found') ? 404 : 500,
- headers: {
- 'Content-Type': 'image/svg+xml; charset=utf-8',
- 'Content-Security-Policy': SVG_CSP_HEADER,
- 'Cache-Control': 'no-store',
- },
- });
+ return new NextResponse(
+ buildInlineErrorSVG(message, {
+ bg: errTheme.bg,
+ accent: errTheme.accent,
+ text: errTheme.text,
+ radius: errTheme.radius,
+ width: 450,
+ height: 160,
+ }),
+ {
+ status: message.includes('not found') ? 404 : 500,
+ headers: {
+ 'Content-Type': 'image/svg+xml; charset=utf-8',
+ 'Content-Security-Policy': SVG_CSP_HEADER,
+ 'Cache-Control': 'no-store',
+ },
+ }
+ );
}
}
diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts
index b7a803e6e..39c9d21cf 100644
--- a/app/api/streak/route.ts
+++ b/app/api/streak/route.ts
@@ -30,6 +30,7 @@ import {
generateSkylineSVG,
generateLanguagesSVG,
generateActivityGraphSVG,
+ buildInlineErrorSVG,
} from '@/lib/svg/generator';
import { generateConstellationSVG } from '@/lib/svg/constellation';
import { generateRadarSVG } from '@/lib/svg/radar';
@@ -46,7 +47,7 @@ import type {
ContributionCalendar,
StreakStats,
} from '@/types';
-import { getNormalizedThemeKey, themes } from '@/lib/svg/themes';
+import { getNormalizedThemeKey, themes, resolveErrorTheme } from '@/lib/svg/themes';
import { streakParamsSchema, coerceQueryParams } from '@/lib/validations';
import { sanitizeHexColor, sanitizeRadius, escapeXML } from '@/lib/svg/sanitizer';
import { getClientIp } from '@/utils/getClientIp';
@@ -62,26 +63,6 @@ const validationCache = _vc;
const SVG_CSP_HEADER =
"default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src https://fonts.gstatic.com;";
-function buildInlineErrorSVG(text: string): string {
- const MAX_LINE = 48;
- const chars = Array.from(text);
- const truncated =
- chars.length > MAX_LINE * 2 ? chars.slice(0, MAX_LINE * 2 - 1).join('') + '…' : text;
- const truncatedChars = Array.from(truncated);
- const line1 = escapeXML(truncatedChars.slice(0, MAX_LINE).join(''));
- const line2 =
- truncatedChars.length > MAX_LINE ? escapeXML(truncatedChars.slice(MAX_LINE).join('')) : null;
- const textY = line2 ? '62' : '75';
- return ``;
-}
-
function getMonthlyReferenceDate(year: string | undefined, timezone: string): Date | undefined {
if (!year) return undefined;
@@ -117,7 +98,13 @@ export async function GET(request: Request) {
Object.values(fieldErrors.fieldErrors).flat()[0] ??
fieldErrors.formErrors[0] ??
'Invalid parameters';
- const errorSvg = buildInlineErrorSVG(firstError);
+ const errTheme = resolveErrorTheme(searchParams);
+ const errorSvg = buildInlineErrorSVG(firstError, {
+ bg: errTheme.bg,
+ accent: errTheme.accent,
+ text: errTheme.text,
+ radius: errTheme.radius,
+ });
return new NextResponse(errorSvg, {
status: 400,
headers: {
@@ -775,7 +762,7 @@ export async function GET(request: Request) {
},
});
} catch (error: unknown) {
- return buildErrorResponse(error, parseResult, requestId);
+ return buildErrorResponse(error, parseResult, requestId, request);
}
}
@@ -813,7 +800,8 @@ function sanitizeErrorMessage(message: string): string {
function buildErrorResponse(
error: unknown,
parseResult: ParseResult,
- requestId?: string
+ requestId?: string,
+ request?: Request
): NextResponse {
const rawMessage = error instanceof Error ? error.message : String(error);
const message = sanitizeErrorMessage(rawMessage);
@@ -869,17 +857,28 @@ function buildErrorResponse(
rawMessage.toLowerCase().includes('validation') ||
rawMessage.toLowerCase().includes('strictly for organizations');
- const errBg = `#${sanitizeHexColor(parseResult.success ? parseResult.data.bg : undefined, '0d1117')}`;
+ const searchParams = request ? new URL(request.url).searchParams : undefined;
+ const errTheme = resolveErrorTheme(searchParams);
+ const errBg =
+ parseResult.success && parseResult.data.bg
+ ? `#${sanitizeHexColor(parseResult.data.bg, '0d1117')}`
+ : errTheme.bg;
const errAccentRaw =
(parseResult.success &&
(Array.isArray(parseResult.data.accent)
? parseResult.data.accent[parseResult.data.accent.length - 1]
: parseResult.data.accent)) ||
undefined;
- const errAccent = `#${sanitizeHexColor(errAccentRaw, '58a6ff')}`;
- const errText = `#${sanitizeHexColor(parseResult.success ? parseResult.data.text : undefined, 'c9d1d9')}`;
- const errRadius = sanitizeRadius(parseResult.success ? parseResult.data.radius : undefined, 8);
- const errSpeed = (parseResult.success && parseResult.data.speed) || '8s';
+ const errAccent = errAccentRaw ? `#${sanitizeHexColor(errAccentRaw, '58a6ff')}` : errTheme.accent;
+ const errText =
+ parseResult.success && parseResult.data.text
+ ? `#${sanitizeHexColor(parseResult.data.text, 'c9d1d9')}`
+ : errTheme.text;
+ const errRadius =
+ parseResult.success && parseResult.data.radius !== undefined
+ ? sanitizeRadius(parseResult.data.radius, 8)
+ : errTheme.radius;
+ const errSpeed = (parseResult.success && parseResult.data.speed) || errTheme.speed;
if (isRateLimit) {
const telemetry = getCircuitTelemetry();
@@ -931,7 +930,12 @@ function buildErrorResponse(
// 3. Return a 400 Bad Request for Validation Errors
if (isValidationError) {
- const validationSvg = buildInlineErrorSVG(message);
+ const validationSvg = buildInlineErrorSVG(message, {
+ bg: errBg,
+ accent: errAccent,
+ text: errText,
+ radius: errRadius,
+ });
const errorHeaders: Record = {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Cache-Control': 'no-store',
@@ -948,7 +952,12 @@ function buildErrorResponse(
// 4. Return a 504 Gateway Timeout for aborted/timed out requests
if (isAbortError(error)) {
- const timeoutSvg = buildInlineErrorSVG('Request timed out. Please try again later.');
+ const timeoutSvg = buildInlineErrorSVG('Request timed out. Please try again later.', {
+ bg: errBg,
+ accent: errAccent,
+ text: errText,
+ radius: errRadius,
+ });
const errorHeaders: Record = {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Cache-Control': 'no-store',
@@ -969,7 +978,12 @@ function buildErrorResponse(
message,
});
- const errorSvg = buildInlineErrorSVG('Something went wrong. Please try again later.');
+ const errorSvg = buildInlineErrorSVG('Something went wrong. Please try again later.', {
+ bg: errBg,
+ accent: errAccent,
+ text: errText,
+ radius: errRadius,
+ });
const errorHeaders: Record = {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Cache-Control': 'no-store',
diff --git a/app/api/wakatime/route.ts b/app/api/wakatime/route.ts
index 13a3b6c71..c7e91ee66 100644
--- a/app/api/wakatime/route.ts
+++ b/app/api/wakatime/route.ts
@@ -1,6 +1,8 @@
import { NextResponse } from 'next/server';
import { getWakaTimeStats } from '@/services/wakatime/api';
import { generateWakaTimeSVG } from '@/lib/svg/wakatime';
+import { buildInlineErrorSVG } from '@/lib/svg/generator';
+import { resolveErrorTheme } from '@/lib/svg/themes';
import { wakatimeParamsSchema, coerceQueryParams } from '@/lib/validations';
import { optimizeSVG } from '@/lib/svg/optimizer';
import crypto from 'crypto';
@@ -20,10 +22,13 @@ export async function GET(request: Request) {
fieldErrors.formErrors[0] ??
'Invalid parameters';
- const errorSvg = ``;
+ const errTheme = resolveErrorTheme(searchParams);
+ const errorSvg = buildInlineErrorSVG(firstError, {
+ bg: errTheme.bg,
+ accent: errTheme.accent,
+ text: errTheme.text,
+ radius: errTheme.radius,
+ });
return new NextResponse(errorSvg, {
status: 400,
diff --git a/app/api/wrapped/route.empty-fallback.test.ts b/app/api/wrapped/route.empty-fallback.test.ts
index 0eabaa84f..071ea0472 100644
--- a/app/api/wrapped/route.empty-fallback.test.ts
+++ b/app/api/wrapped/route.empty-fallback.test.ts
@@ -14,6 +14,7 @@ vi.mock('@/lib/svg/generator', () => ({
generateWrappedSVG: vi.fn(() => ''),
generateNotFoundSVG: vi.fn(() => ''),
generateRateLimitSVG: vi.fn(() => ''),
+ buildInlineErrorSVG: vi.fn((text: string) => ``),
}));
vi.mock('@/utils/getClientIp', () => ({
diff --git a/app/api/wrapped/route.error-resilience.test.ts b/app/api/wrapped/route.error-resilience.test.ts
index cb7f0d28e..26ffb0d44 100644
--- a/app/api/wrapped/route.error-resilience.test.ts
+++ b/app/api/wrapped/route.error-resilience.test.ts
@@ -24,6 +24,10 @@ vi.mock('@/lib/svg/generator', () => ({
generateWrappedSVG: vi.fn(),
generateNotFoundSVG: vi.fn().mockReturnValue(''),
generateRateLimitSVG: vi.fn().mockReturnValue(''),
+ buildInlineErrorSVG: vi.fn(
+ (text: string) =>
+ ``
+ ),
}));
// Import after mocks
diff --git a/app/api/wrapped/route.ts b/app/api/wrapped/route.ts
index 69d42f46b..4b9f42af0 100644
--- a/app/api/wrapped/route.ts
+++ b/app/api/wrapped/route.ts
@@ -2,11 +2,16 @@
import { NextResponse } from 'next/server';
import { getWrappedData, getCircuitTelemetry } from '@/lib/github';
-import { generateWrappedSVG, generateNotFoundSVG, generateRateLimitSVG } from '@/lib/svg/generator';
-import { escapeXML } from '@/lib/svg/sanitizer';
+import {
+ generateWrappedSVG,
+ generateNotFoundSVG,
+ generateRateLimitSVG,
+ buildInlineErrorSVG,
+} from '@/lib/svg/generator';
+import { escapeXML, sanitizeHexColor, sanitizeRadius } from '@/lib/svg/sanitizer';
import { wrappedParamsSchema, coerceQueryParams } from '@/lib/validations';
import type { BadgeParams } from '@/types';
-import { themes } from '@/lib/svg/themes';
+import { themes, resolveErrorTheme } from '@/lib/svg/themes';
import { getClientIp } from '@/utils/getClientIp';
import { quotaMonitor } from '@/services/github/quota-monitor';
import { refreshPolicy } from '@/services/github/refresh-policy';
@@ -149,13 +154,17 @@ export async function GET(request: Request) {
},
});
} catch (error: unknown) {
- return buildErrorResponse(error, parseResult);
+ return buildErrorResponse(error, parseResult, request);
}
}
type ParseResult = ReturnType;
-function buildErrorResponse(error: unknown, parseResult: ParseResult): NextResponse {
+function buildErrorResponse(
+ error: unknown,
+ parseResult: ParseResult,
+ request?: Request
+): NextResponse {
const message = error instanceof Error ? error.message : String(error);
const isNotFound =
@@ -168,22 +177,31 @@ function buildErrorResponse(error: unknown, parseResult: ParseResult): NextRespo
message.toLowerCase().includes('invalid') ||
message.toLowerCase().includes('validation');
- const errBg = `#${(parseResult.success && parseResult.data.bg) || '0d1117'}`;
- const errAccent = `#${
+ const searchParams = request ? new URL(request.url).searchParams : null;
+ const errTheme = resolveErrorTheme(searchParams);
+ const errBg =
+ parseResult.success && parseResult.data.bg
+ ? `#${sanitizeHexColor(parseResult.data.bg, '0d1117')}`
+ : errTheme.bg;
+ const errAccentRaw =
(parseResult.success &&
(Array.isArray(parseResult.data.accent)
? parseResult.data.accent[parseResult.data.accent.length - 1]
: parseResult.data.accent)) ||
- '58a6ff'
- }`;
- const errText = `#${(parseResult.success && parseResult.data.text) || 'c9d1d9'}`;
- const errRadius = parseResult.success
- ? (() => {
- const r = Number(parseResult.data.radius);
- return Number.isFinite(r) ? Math.min(32, Math.max(0, r)) : 8;
- })()
- : 8;
- const errSpeed = (parseResult.success && parseResult.data.speed) || '8s';
+ undefined;
+ const errAccent = errAccentRaw ? `#${sanitizeHexColor(errAccentRaw, '58a6ff')}` : errTheme.accent;
+ const errText =
+ parseResult.success && parseResult.data.text
+ ? `#${sanitizeHexColor(parseResult.data.text, 'c9d1d9')}`
+ : errTheme.text;
+ const errRadius =
+ parseResult.success && parseResult.data.radius !== undefined
+ ? (() => {
+ const r = Number(parseResult.data.radius);
+ return Number.isFinite(r) ? Math.min(32, Math.max(0, r)) : 8;
+ })()
+ : errTheme.radius;
+ const errSpeed = (parseResult.success && parseResult.data.speed) || errTheme.speed;
if (isRateLimit) {
const telemetry = getCircuitTelemetry();
@@ -225,14 +243,12 @@ function buildErrorResponse(error: unknown, parseResult: ParseResult): NextRespo
}
if (isValidationError) {
- const validationSvg = `
-
- `;
+ const validationSvg = buildInlineErrorSVG(message, {
+ bg: errBg,
+ accent: errAccent,
+ text: errText,
+ radius: errRadius,
+ });
return new NextResponse(validationSvg, {
status: 400,
@@ -249,14 +265,12 @@ function buildErrorResponse(error: unknown, parseResult: ParseResult): NextRespo
message,
});
- const errorSvg = `
-
- `;
+ const errorSvg = buildInlineErrorSVG('Something went wrong. Please try again later.', {
+ bg: errBg,
+ accent: errAccent,
+ text: errText,
+ radius: errRadius,
+ });
return new NextResponse(errorSvg, {
status: 500,
diff --git a/lib/svg/fallbackTheme.test.ts b/lib/svg/fallbackTheme.test.ts
new file mode 100644
index 000000000..3c53b54fa
--- /dev/null
+++ b/lib/svg/fallbackTheme.test.ts
@@ -0,0 +1,69 @@
+import { describe, it, expect } from 'vitest';
+import { resolveErrorTheme } from './themes';
+import { buildInlineErrorSVG } from './generator';
+
+describe('Visually Themed Fallback SVGs (#7796)', () => {
+ describe('resolveErrorTheme', () => {
+ it('uses default colors when no params are provided', () => {
+ const theme = resolveErrorTheme(null);
+ expect(theme.bg).toBe('#0d1117');
+ expect(theme.text).toBe('#ffffff');
+ expect(theme.accent).toBe('#2da44e');
+ expect(theme.radius).toBe(8);
+ });
+
+ it('resolves theme colors when theme parameter is provided', () => {
+ const params = new URLSearchParams('theme=dracula');
+ const theme = resolveErrorTheme(params);
+ expect(theme.bg).toBe('#282a36');
+ expect(theme.text).toBe('#f8f8f2');
+ expect(theme.accent).toBe('#bd93f9');
+ });
+
+ it('allows explicit bg, accent, and text parameter overrides', () => {
+ const params = new URLSearchParams('theme=nord&bg=112233&accent=ff00aa');
+ const theme = resolveErrorTheme(params);
+ expect(theme.bg).toBe('#112233');
+ expect(theme.accent).toBe('#ff00aa');
+ expect(theme.text).toBe('#d8dee9'); // from nord theme base
+ });
+
+ it('sanitizes radius and speed parameters', () => {
+ const params = new URLSearchParams('radius=16&speed=5s');
+ const theme = resolveErrorTheme(params);
+ expect(theme.radius).toBe(16);
+ expect(theme.speed).toBe('5s');
+ });
+
+ it('handles raw object dictionary query params', () => {
+ const theme = resolveErrorTheme({ theme: 'tokyonight', radius: '12' });
+ expect(theme.bg).toBe('#1a1b26');
+ expect(theme.accent).toBe('#f7768e');
+ expect(theme.radius).toBe(12);
+ });
+ });
+
+ describe('buildInlineErrorSVG', () => {
+ it('renders SVG with requested theme background and accent colors', () => {
+ const svg = buildInlineErrorSVG('Rate Limit Exceeded', {
+ bg: '#282a36',
+ accent: '#bd93f9',
+ text: '#f8f8f2',
+ radius: 12,
+ });
+
+ expect(svg).toContain('fill="#282a36"');
+ expect(svg).toContain('stroke="#bd93f9"');
+ expect(svg).toContain('fill="#f8f8f2"');
+ expect(svg).toContain('rx="12"');
+ expect(svg).toContain('Rate Limit Exceeded');
+ });
+
+ it('falls back to default colors if options are omitted', () => {
+ const svg = buildInlineErrorSVG('Validation Error');
+ expect(svg).toContain('fill="#0d1117"');
+ expect(svg).toContain('stroke="#00ffaa"');
+ expect(svg).toContain('Validation Error');
+ });
+ });
+});
diff --git a/lib/svg/generator.ts b/lib/svg/generator.ts
index dbdd06e87..824993813 100644
--- a/lib/svg/generator.ts
+++ b/lib/svg/generator.ts
@@ -4014,3 +4014,41 @@ function _renderPeakAnnotation(
font-family="${statsFont.replace(/"/g, "'")}" font-size="11" font-weight="700" fill="${labelFill}">${peakCount}${dateLabel ? `${escapeXML(dateLabel)}` : ''}
`;
}
+
+export interface ErrorSVGOptions {
+ bg?: string;
+ accent?: string;
+ text?: string;
+ radius?: number;
+ width?: number;
+ height?: number;
+}
+
+export function buildInlineErrorSVG(text: string, options?: ErrorSVGOptions): string {
+ const bg = options?.bg || '#0d1117';
+ const accent = options?.accent || '#00ffaa';
+ const textCol = options?.text || '#c9d1d9';
+ const radius = options?.radius ?? 8;
+ const width = options?.width ?? 400;
+ const height = options?.height ?? 150;
+
+ const MAX_LINE = 48;
+ const chars = Array.from(text);
+ const truncated =
+ chars.length > MAX_LINE * 2 ? chars.slice(0, MAX_LINE * 2 - 1).join('') + '…' : text;
+ const truncatedChars = Array.from(truncated);
+ const line1 = escapeXML(truncatedChars.slice(0, MAX_LINE).join(''));
+ const line2 =
+ truncatedChars.length > MAX_LINE ? escapeXML(truncatedChars.slice(MAX_LINE).join('')) : null;
+ const textY = line2 ? String(Math.round(height * 0.42)) : String(Math.round(height * 0.5));
+ const line2Y = String(Math.round(height * 0.61));
+
+ return ``;
+}
diff --git a/lib/svg/themes.ts b/lib/svg/themes.ts
index d57e75033..e9fb2baa9 100644
--- a/lib/svg/themes.ts
+++ b/lib/svg/themes.ts
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { BadgeTheme } from '../../types';
-import { hexColor } from './sanitizer';
+import { hexColor, sanitizeHexColor, sanitizeRadius, sanitizeSpeed } from './sanitizer';
const HEX_COLOR_REGEX = /^[0-9a-fA-F]{3,4}$|^[0-9a-fA-F]{6,8}$/;
@@ -88,4 +88,62 @@ export function getNormalizedThemeKey(themeInput: string | undefined | null): st
return matchedKey || 'default';
}
+export interface ErrorThemeColors {
+ bg: string;
+ accent: string;
+ text: string;
+ radius: number;
+ speed: string;
+}
+
+/**
+ * Resolves theme colors and badge parameters from request query params or raw object.
+ * Enables error fallbacks to visually match requested user themes.
+ */
+export function resolveErrorTheme(
+ searchParams?: URLSearchParams | Record | null
+): ErrorThemeColors {
+ let themeInput: string | undefined;
+ let bgInput: string | undefined;
+ let accentInput: string | undefined;
+ let textInput: string | undefined;
+ let radiusInput: string | number | undefined;
+ let speedInput: string | undefined;
+
+ if (searchParams) {
+ if (typeof (searchParams as URLSearchParams).get === 'function') {
+ const sp = searchParams as URLSearchParams;
+ themeInput = sp.get('theme') ?? undefined;
+ bgInput = sp.get('bg') ?? undefined;
+ accentInput = sp.get('accent') ?? undefined;
+ textInput = sp.get('text') ?? undefined;
+ radiusInput = sp.get('radius') ?? undefined;
+ speedInput = sp.get('speed') ?? undefined;
+ } else {
+ const obj = searchParams as Record;
+ const getVal = (k: string) => {
+ const v = obj[k];
+ return Array.isArray(v) ? v[0] : v;
+ };
+ themeInput = getVal('theme');
+ bgInput = getVal('bg');
+ accentInput = getVal('accent');
+ textInput = getVal('text');
+ radiusInput = getVal('radius');
+ speedInput = getVal('speed');
+ }
+ }
+
+ const themeKey = getNormalizedThemeKey(themeInput);
+ const baseTheme = themes[themeKey] || themes.default;
+
+ const bg = `#${sanitizeHexColor(bgInput, baseTheme.bg)}`;
+ const accent = `#${sanitizeHexColor(accentInput, baseTheme.accent)}`;
+ const text = `#${sanitizeHexColor(textInput, baseTheme.text)}`;
+ const radius = sanitizeRadius(radiusInput, 8);
+ const speed = sanitizeSpeed(speedInput, '8s');
+
+ return { bg, accent, text, radius, speed };
+}
+
validateThemes(themes);