Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions app/(root)/dashboard/[username]/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 14 additions & 1 deletion app/(root)/dashboard/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Metadata> {
Comment thread
Animesh-86 marked this conversation as resolved.
// 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);
Comment thread
Animesh-86 marked this conversation as resolved.

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.`;

Expand Down
80 changes: 55 additions & 25 deletions app/api/og/route.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,43 @@
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) {
// Normalize short hex (e.g., #fff to #ffffff)
const normalizedHex =
hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
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;
}
Comment on lines +10 to +22

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}`;
Comment thread
Animesh-86 marked this conversation as resolved.

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';
Comment on lines +27 to +40

let totalCommits = 0;
let longestStreak = 0;
Expand All @@ -29,7 +60,7 @@ export async function GET(req: NextRequest) {
style={{
width: '1200px',
height: '630px',
background: '#0d1117',
background: resolvedBg,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
Expand All @@ -43,7 +74,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',
Expand All @@ -53,21 +84,14 @@ export async function GET(req: NextRequest) {
style={{
display: 'flex',
fontSize: '48px',
color: '#58a6ff',
color: resolvedAccent,
fontWeight: 'bold',
marginBottom: '24px',
}}
>
{'⚡ CommitPulse'}
</div>
<div
style={{
display: 'flex',
fontSize: '32px',
color: '#c9d1d9',
marginBottom: '48px',
}}
>
<div style={{ display: 'flex', fontSize: '32px', color: resolvedText, marginBottom: '48px' }}>
{`@${user}`}
</div>
<div style={{ display: 'flex', gap: '48px' }}>
Expand All @@ -77,16 +101,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',
}}
>
<div style={{ display: 'flex', fontSize: '56px', fontWeight: 'bold', color: '#58a6ff' }}>
<div
style={{ display: 'flex', fontSize: '56px', fontWeight: 'bold', color: resolvedAccent }}
>
{String(totalCommits)}
</div>
<div style={{ display: 'flex', fontSize: '18px', color: '#8b949e', marginTop: '8px' }}>
<div style={{ display: 'flex', fontSize: '18px', color: subText, marginTop: '8px' }}>
Total Commits
</div>
</div>
Expand All @@ -96,16 +122,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',
}}
>
<div style={{ display: 'flex', fontSize: '56px', fontWeight: 'bold', color: '#f78166' }}>
<div
style={{ display: 'flex', fontSize: '56px', fontWeight: 'bold', color: resolvedAccent }}
>
{String(longestStreak)}
</div>
<div style={{ display: 'flex', fontSize: '18px', color: '#8b949e', marginTop: '8px' }}>
<div style={{ display: 'flex', fontSize: '18px', color: subText, marginTop: '8px' }}>
{'Longest Streak 🔥'}
</div>
</div>
Expand All @@ -115,16 +143,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',
}}
>
<div style={{ display: 'flex', fontSize: '56px', fontWeight: 'bold', color: '#3fb950' }}>
<div
style={{ display: 'flex', fontSize: '56px', fontWeight: 'bold', color: resolvedAccent }}
>
{String(currentStreak)}
</div>
<div style={{ display: 'flex', fontSize: '18px', color: '#8b949e', marginTop: '8px' }}>
<div style={{ display: 'flex', fontSize: '18px', color: subText, marginTop: '8px' }}>
{'Current Streak ⚡'}
</div>
</div>
Expand All @@ -135,7 +165,7 @@ export async function GET(req: NextRequest) {
position: 'absolute',
bottom: '32px',
fontSize: '16px',
color: '#484f58',
color: subText,
}}
>
commitpulse.vercel.app
Expand Down
33 changes: 32 additions & 1 deletion lib/validations.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod';
import { sanitizeHexColor, sanitizeSpeed, sanitizeRadius, sanitizeFont } from './svg/sanitizer';
import { themes } from './svg/themes';

function dimensionParam(name: string, min: number, max: number) {
return z
Expand Down Expand Up @@ -124,7 +125,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))
.default('dark')
.refine((val) => Object.hasOwn(themes, val), { message: 'Invalid theme' }),
Comment on lines +134 to +140
bg: z
.string()
.trim()
.optional()
.transform((val) => (val === '' ? undefined : val))
.transform((val) => (val ? sanitizeHexColor(val, '0d1117') : undefined)),
text: z
.string()
.trim()
.optional()
.transform((val) => (val === '' ? undefined : val))
.transform((val) => (val ? sanitizeHexColor(val, 'c9d1d9') : undefined)),
Comment on lines +147 to +152
accent: z
.string()
.trim()
.optional()
.transform((val) => (val === '' ? undefined : val))
.transform((val) => (val ? sanitizeHexColor(val, '58a6ff') : undefined)),
Comment on lines +141 to +158
Comment on lines +153 to +158
});

export const statsParamsSchema = z.object({
Expand Down
Loading