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 = ` - - ${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, 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 ` - - ${line1}${ - line2 - ? `\n ${line2}` - : '' - } - `; -} - 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 ` - - ${line1}${ - line2 - ? `\n ${line2}` - : '' - } - `; -} - 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 = ` - - ${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, 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(() => 'mock-wrapped'), generateNotFoundSVG: vi.fn(() => 'mock-not-found'), generateRateLimitSVG: vi.fn(() => 'mock-rate-limit'), + buildInlineErrorSVG: vi.fn((text: string) => `mock-inline-error: ${text}`), })); 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('not found'), generateRateLimitSVG: vi.fn().mockReturnValue('rate limit'), + buildInlineErrorSVG: vi.fn( + (text: string) => + `mock-inline-error: ${text.replace(/[<>&'"]/g, (c: string) => (c === '<' ? '<' : c === '>' ? '>' : c === '&' ? '&' : c === "'" ? ''' : '"'))}` + ), })); // 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 = ` - - - - ${escapeXML(message)} - - - `; + 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 = ` - - - - 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, + }); 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 ` + + ${line1}${ + line2 + ? `\n ${line2}` + : '' + } +`; +} 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);