From 2fb1d09b83d94ea281a1724a6592ad75f9cdaad0 Mon Sep 17 00:00:00 2001 From: Animesh Date: Thu, 28 May 2026 09:33:42 +0530 Subject: [PATCH] feat(og): add dynamic social card customization parameters --- app/(root)/dashboard/[username]/page.test.tsx | 22 ++++- app/(root)/dashboard/[username]/page.tsx | 15 +++- app/api/og/route.test.ts | 38 +++++++++ app/api/og/route.tsx | 82 +++++++++++++------ lib/validations.ts | 41 +++++++++- 5 files changed, 168 insertions(+), 30 deletions(-) diff --git a/app/(root)/dashboard/[username]/page.test.tsx b/app/(root)/dashboard/[username]/page.test.tsx index 3fe8bff6..a10c6631 100644 --- a/app/(root)/dashboard/[username]/page.test.tsx +++ b/app/(root)/dashboard/[username]/page.test.tsx @@ -100,17 +100,35 @@ describe('DashboardPage', () => { }); describe('generateMetadata', () => { - it('generates correct metadata for a given user', async () => { + it('generates correct metadata for a given user and forwards valid searchParams', async () => { const username = 'octocat'; const metadata = await generateMetadata({ params: Promise.resolve({ username }), + searchParams: Promise.resolve({ + theme: 'neon', + bg: '000000', + text: '00ff00', + accent: 'ff00ff', + ignoredArray: ['a', 'b'], + ignoredUndefined: undefined, + }), }); const openGraphImage = (metadata.openGraph?.images as any[])?.[0]; expect(metadata.title).toBe("octocat's Commit Pulse"); expect(metadata.description).toContain("octocat's GitHub contribution pulse"); - expect(openGraphImage.url).toContain('api/og?username=octocat'); + + const url = openGraphImage.url; + expect(url).toContain('api/og?'); + expect(url).toContain('user=octocat'); + expect(url).toContain('theme=neon'); + expect(url).toContain('bg=000000'); + expect(url).toContain('text=00ff00'); + expect(url).toContain('accent=ff00ff'); + expect(url).not.toContain('ignoredArray'); + expect(url).not.toContain('ignoredUndefined'); + expect(openGraphImage.width).toBe(1200); expect(openGraphImage.height).toBe(630); expect(openGraphImage.alt).toContain(username); diff --git a/app/(root)/dashboard/[username]/page.tsx b/app/(root)/dashboard/[username]/page.tsx index 1789bed4..649bf2cd 100644 --- a/app/(root)/dashboard/[username]/page.tsx +++ b/app/(root)/dashboard/[username]/page.tsx @@ -20,13 +20,26 @@ const BASE_URL = export async function generateMetadata({ params, + searchParams, }: { params: Promise<{ username: string }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }): Promise { // Lightweight — no API calls here. // Real data is fetched by /api/og on demand when social platforms render the preview. const { username } = await params; - const ogImage = `${BASE_URL}/api/og?username=${username}`; + const resolvedSearchParams = await searchParams; + + const queryParams = new URLSearchParams({ user: username }); + if (typeof resolvedSearchParams?.theme === 'string') + queryParams.set('theme', resolvedSearchParams.theme); + if (typeof resolvedSearchParams?.bg === 'string') queryParams.set('bg', resolvedSearchParams.bg); + if (typeof resolvedSearchParams?.text === 'string') + queryParams.set('text', resolvedSearchParams.text); + if (typeof resolvedSearchParams?.accent === 'string') + queryParams.set('accent', resolvedSearchParams.accent); + + const ogImage = `${BASE_URL}/api/og?${queryParams.toString()}`; const title = `${username}'s Commit Pulse`; const description = `Check out ${username}'s GitHub contribution pulse — streaks, insights, and more on CommitPulse.`; diff --git a/app/api/og/route.test.ts b/app/api/og/route.test.ts index 69a73030..4e58428c 100644 --- a/app/api/og/route.test.ts +++ b/app/api/og/route.test.ts @@ -52,4 +52,42 @@ describe('OG Route', () => { expect(res.status).toBe(200); }); + + it('handles custom themes and valid custom colors without crashing', async () => { + vi.mocked(fetchGitHubContributions).mockResolvedValue({} as never); + vi.mocked(calculateStreak).mockReturnValue({ + totalContributions: 120, + longestStreak: 20, + currentStreak: 5, + todayDate: '2026-05-27', + }); + + // Uses 4-digit hex shorthand for bg to ensure getLuminance handles it + const req = new NextRequest( + 'http://localhost:3000/api/og?user=testuser&theme=dracula&bg=000&text=ffffff&accent=ff0000' + ); + const res = await GET(req as never); + + expect(res).toBeDefined(); + expect(res.status).toBe(200); + }); + + it('handles invalid custom themes and invalid colors gracefully with fallbacks', async () => { + vi.mocked(fetchGitHubContributions).mockResolvedValue({} as never); + vi.mocked(calculateStreak).mockReturnValue({ + totalContributions: 120, + longestStreak: 20, + currentStreak: 5, + todayDate: '2026-05-27', + }); + + // Invalid theme and invalid hexes + const req = new NextRequest( + 'http://localhost:3000/api/og?user=testuser&theme=non_existent_theme_xyz&bg=not-hex&text=xyz&accent=12' + ); + const res = await GET(req as never); + + expect(res).toBeDefined(); + expect(res.status).toBe(200); + }); }); diff --git a/app/api/og/route.tsx b/app/api/og/route.tsx index f11a7662..aa6546ea 100644 --- a/app/api/og/route.tsx +++ b/app/api/og/route.tsx @@ -1,12 +1,45 @@ import { ImageResponse } from 'next/og'; import { NextRequest } from 'next/server'; import { ogParamsSchema } from '../../../lib/validations'; +import { themes } from '../../../lib/svg/themes'; import { fetchGitHubContributions } from '../../../lib/github'; import { calculateStreak } from '../../../lib/calculate'; +export const runtime = 'edge'; + +function getLuminance(hex: string) { + let normalizedHex = hex.trim(); + // Normalize short hex (e.g., #fff or #ffff) to #rrggbb (alpha is ignored for luminance) + if (normalizedHex.length === 4 || normalizedHex.length === 5) { + normalizedHex = `#${normalizedHex[1]}${normalizedHex[1]}${normalizedHex[2]}${normalizedHex[2]}${normalizedHex[3]}${normalizedHex[3]}`; + } + const r = parseInt(normalizedHex.slice(1, 3), 16) / 255 || 0; + const g = parseInt(normalizedHex.slice(3, 5), 16) / 255 || 0; + const b = parseInt(normalizedHex.slice(5, 7), 16) / 255 || 0; + + const [R, G, B] = [r, g, b].map((c) => + c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) + ); + return 0.2126 * R + 0.7152 * G + 0.0722 * B; +} + export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); - const { user } = ogParamsSchema.parse(Object.fromEntries(searchParams.entries())); + + const { user, theme, bg, text, accent } = ogParamsSchema.parse( + Object.fromEntries(searchParams.entries()) + ); + + const selectedTheme = themes[theme] || themes.dark; + const resolvedBg = `#${bg || selectedTheme.bg}`; + const resolvedText = `#${text || selectedTheme.text}`; + const resolvedAccent = `#${accent || selectedTheme.accent}`; + + const luminance = getLuminance(resolvedBg); + const isLight = luminance > 0.5; + const cardBg = isLight ? 'rgba(0,0,0,0.08)' : 'rgba(255,255,255,0.08)'; + const cardBorder = isLight ? 'rgba(0,0,0,0.08)' : 'rgba(255,255,255,0.1)'; + const subText = isLight ? '#666666' : '#8b949e'; let totalCommits = 0; let longestStreak = 0; @@ -29,7 +62,7 @@ export async function GET(req: NextRequest) { style={{ width: '1200px', height: '630px', - background: '#0d1117', + background: resolvedBg, display: 'flex', flexDirection: 'column', alignItems: 'center', @@ -43,7 +76,7 @@ export async function GET(req: NextRequest) { position: 'absolute', width: '600px', height: '300px', - background: 'radial-gradient(ellipse, #58a6ff22 0%, transparent 70%)', + background: `radial-gradient(ellipse, ${resolvedAccent}33 0%, transparent 70%)`, top: '50px', left: '300px', display: 'flex', @@ -53,21 +86,14 @@ export async function GET(req: NextRequest) { style={{ display: 'flex', fontSize: '48px', - color: '#58a6ff', + color: resolvedAccent, fontWeight: 'bold', marginBottom: '24px', }} > {'⚡ CommitPulse'} -
+
{`@${user}`}
@@ -77,16 +103,18 @@ export async function GET(req: NextRequest) { display: 'flex', flexDirection: 'column', alignItems: 'center', - background: '#161b22', - border: '1px solid #30363d', + background: cardBg, + border: `1px solid ${cardBorder}`, borderRadius: '16px', padding: '32px 48px', }} > -
+
{String(totalCommits)}
-
+
Total Commits
@@ -96,16 +124,18 @@ export async function GET(req: NextRequest) { display: 'flex', flexDirection: 'column', alignItems: 'center', - background: '#161b22', - border: '1px solid #30363d', + background: cardBg, + border: `1px solid ${cardBorder}`, borderRadius: '16px', padding: '32px 48px', }} > -
+
{String(longestStreak)}
-
+
{'Longest Streak 🔥'}
@@ -115,16 +145,18 @@ export async function GET(req: NextRequest) { display: 'flex', flexDirection: 'column', alignItems: 'center', - background: '#161b22', - border: '1px solid #30363d', + background: cardBg, + border: `1px solid ${cardBorder}`, borderRadius: '16px', padding: '32px 48px', }} > -
+
{String(currentStreak)}
-
+
{'Current Streak ⚡'}
@@ -135,7 +167,7 @@ export async function GET(req: NextRequest) { position: 'absolute', bottom: '32px', fontSize: '16px', - color: '#484f58', + color: subText, }} > commitpulse.vercel.app diff --git a/lib/validations.ts b/lib/validations.ts index a9c538d3..3aff2a0a 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -1,5 +1,12 @@ import { z } from 'zod'; -import { sanitizeHexColor, sanitizeSpeed, sanitizeRadius, sanitizeFont } from './svg/sanitizer'; +import { + isValidHex, + sanitizeHexColor, + sanitizeSpeed, + sanitizeRadius, + sanitizeFont, +} from './svg/sanitizer'; +import { themes } from './svg/themes'; function dimensionParam(name: string, min: number, max: number) { return z @@ -124,7 +131,37 @@ export const githubParamsSchema = z.object({ }); export const ogParamsSchema = z.object({ - user: z.string().optional().default('unknown'), + user: z + .string() + .trim() + .optional() + .transform((val) => (val === '' ? undefined : val)) + .default('unknown'), + theme: z + .string() + .trim() + .optional() + .transform((val) => (val === '' ? undefined : val)) + .transform((val) => (val && Object.hasOwn(themes, val) ? val : 'dark')) + .default('dark'), + bg: z + .string() + .trim() + .optional() + .transform((val) => (val === '' ? undefined : val)) + .transform((val) => (val && isValidHex(val) ? sanitizeHexColor(val, '000000') : undefined)), + text: z + .string() + .trim() + .optional() + .transform((val) => (val === '' ? undefined : val)) + .transform((val) => (val && isValidHex(val) ? sanitizeHexColor(val, '000000') : undefined)), + accent: z + .string() + .trim() + .optional() + .transform((val) => (val === '' ? undefined : val)) + .transform((val) => (val && isValidHex(val) ? sanitizeHexColor(val, '000000') : undefined)), }); export const statsParamsSchema = z.object({