Skip to content
Merged
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
45 changes: 45 additions & 0 deletions app/api/badge/[login]/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { BADGE_STATS, DEFAULT_BADGE_STAT, renderBadgeSvg, resolveBadgeStat } from '../../../../lib/badge.js';
import { getBadgeDeveloper } from '../../../../lib/badge-lookup.js';

export const runtime = 'nodejs';

const LOGIN_PATTERN = /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i;

function svgResponse(svg, { cache = true } = {}) {
return new Response(svg, {
status: 200,
headers: {
'Content-Type': 'image/svg+xml; charset=utf-8',
// Badges are meant to be embedded (README, personal sites) and read on
// every page view, so cache briefly at the edge instead of per-request.
'Cache-Control': cache
? 'public, max-age=0, s-maxage=3600, stale-while-revalidate=86400'
: 'no-store',
},
});
}

export async function GET(request, { params }) {
const { login: rawLogin } = await params;
const login = rawLogin.replace(/\.svg$/i, '');

if (!LOGIN_PATTERN.test(login)) {
return svgResponse(renderBadgeSvg({ value: 'invalid login', unranked: true }), { cache: false });
}

const { searchParams } = new URL(request.url);
const statParam = searchParams.get('stat') || DEFAULT_BADGE_STAT;
if (!BADGE_STATS.includes(statParam)) {
return svgResponse(renderBadgeSvg({ value: 'invalid stat', unranked: true }), { cache: false });
}

try {
const developer = await getBadgeDeveloper(login);
const { value, unranked } = resolveBadgeStat(developer, statParam);
return svgResponse(renderBadgeSvg({ value, unranked }));
} catch (error) {
console.error('Badge render failed:', error.message);
// Degrade to an "unranked" badge rather than a broken image in READMEs.
return svgResponse(renderBadgeSvg({ value: 'unavailable', unranked: true }), { cache: false });
}
}
82 changes: 82 additions & 0 deletions components/BadgeSnippet.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
'use client';

import { useState } from 'react';

const STAT_OPTIONS = [
{ value: 'globalRank', label: 'Global Rank' },
{ value: 'countryRank', label: 'Country Rank' },
{ value: 'cityRank', label: 'City Rank' },
{ value: 'score', label: 'Score' },
{ value: 'stars', label: 'Stars' },
];

export default function BadgeSnippet({ login, siteUrl }) {
const [stat, setStat] = useState('globalRank');
const [format, setFormat] = useState('markdown');
Comment on lines +13 to +15
const [copied, setCopied] = useState(false);

const statQuery = stat === 'globalRank' ? '' : `?stat=${stat}`;
const badgeUrl = `${siteUrl}/api/badge/${encodeURIComponent(login)}.svg${statQuery}`;
const profileUrl = `${siteUrl}/share/${encodeURIComponent(login)}`;

const snippets = {
markdown: `[![devglobe](${badgeUrl})](${profileUrl})`,
html: `<a href="${profileUrl}"><img src="${badgeUrl}" alt="devglobe badge" /></a>`,
};

async function handleCopy() {
try {
await navigator.clipboard.writeText(snippets[format]);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard API can fail silently (permissions, insecure context);
// the snippet is still selectable text, so no further action needed.
}
}

return (
<section className="badge-card" id="get-your-badge">
<span className="badge-card__eyebrow">Embeddable badge</span>
<h2 className="badge-card__title">Get your badge</h2>
<p className="badge-card__subtitle">
Embed a live-updating rank badge in your GitHub README or personal site. It refreshes automatically as your DevGlobe stats update.
</p>

<div className="badge-card__stats" role="tablist" aria-label="Badge stat">
{STAT_OPTIONS.map(option => (
<button
key={option.value}
type="button"
role="tab"
aria-selected={stat === option.value}
className={`badge-card__stat${stat === option.value ? ' badge-card__stat--active' : ''}`}
onClick={() => setStat(option.value)}
>
{option.label}
</button>
))}
</div>

<div className="badge-card__format">
{['markdown', 'html'].map(option => (
<button
key={option}
type="button"
className={`badge-card__format-btn${format === option ? ' badge-card__format-btn--active' : ''}`}
onClick={() => setFormat(option)}
>
{option === 'markdown' ? 'Markdown' : 'HTML'}
</button>
))}
</div>

<div className="badge-card__snippet">
<code>{snippets[format]}</code>
<button type="button" className="badge-card__copy" onClick={handleCopy}>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
</section>
);
}
7 changes: 4 additions & 3 deletions components/DetailPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ export default function DetailPanel({ dev, onClose, onCardGenerated, claimedLogi
{merged.soUserId && (
<a href={`https://stackoverflow.com/users/${merged.soUserId}`} target="_blank" rel="noreferrer">StackOverflow ↗</a>
)}
<button
<a href={`/share/${encodeURIComponent(dev.login)}#get-your-badge`} target="_blank" rel="noopener noreferrer">Get Badge ↗</a>
<button
className="btn btn--share"
onClick={handleGenerateCard}
>
Expand Down Expand Up @@ -557,7 +558,7 @@ function CardModal({ dev, claimSuccess, onClose }) {
const linkedinCaption = `I mapped my open-source contributions on DevGlobe and discovered my developer identity: ${agent.name}. ${rankText}. Build your card and see where your work places you in the global developer community.\n\n${hashtagText}`;

const shareLinks = {
twitter: `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(shareUrl)}&hashtags=${shareHashtags.join(',')}`,
twitter: `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(shareUrl)}`,
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`,
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`,
reddit: `https://reddit.com/submit?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(`My DevGlobe Developer Card - ${name} ${hashtagText}`)}`,
Expand Down Expand Up @@ -676,4 +677,4 @@ function CardModal({ dev, claimSuccess, onClose }) {
</div>
</div>
);
}
}
51 changes: 51 additions & 0 deletions docs/prd/badge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Embeddable developer badge

**Issue:** [#145](https://github.com/sajeetharan/devglobe/issues/145)

Any developer indexed on DevGlobe can embed a live-updating badge in a GitHub README, personal site, or blog. The badge is a small SVG image, in the same spirit as shields.io and committers.top badges.

## Markdown

```markdown
[![devglobe](https://www.devglobe.dev/api/badge/YOUR_GITHUB_USERNAME.svg)](https://www.devglobe.dev/share/YOUR_GITHUB_USERNAME)
```

Replace `YOUR_GITHUB_USERNAME` with your GitHub login. That's the whole setup — nothing to install, no script, no auth.

## HTML

```html
<a href="https://www.devglobe.dev/share/YOUR_GITHUB_USERNAME">
<img src="https://www.devglobe.dev/api/badge/YOUR_GITHUB_USERNAME.svg" alt="devglobe badge" />
</a>
```

## Choosing what the badge shows

Add `?stat=` to the URL:

| `stat` value | Shows |
|---|---|
| `globalRank` (default) | `Global #4` |
| `countryRank` | `USA #2` |
| `cityRank` | `Portland #1` |
| `score` | `91/100` |
| `stars` | `182.0K stars` |

Example:

```markdown
![devglobe](https://www.devglobe.dev/api/badge/YOUR_GITHUB_USERNAME.svg?stat=countryRank)
```

## Staying up to date

The badge has no server-side cache beyond a 1-hour edge cache (`s-maxage=3600`), so it reflects the latest data DevGlobe has for that developer — it updates automatically as soon as the developer's underlying stats refresh (currently on DevGlobe's periodic contribution refresh cycle; see [#124](https://github.com/sajeetharan/devglobe/issues/124)). There is nothing for the developer to re-run or re-embed — the same URL just renders differently over time.

## If a developer isn't ranked yet

The endpoint never 404s for a syntactically valid GitHub username — a developer with no DevGlobe data yet gets a grey **"unranked"** badge instead of a broken image, so READMEs never show a broken-image icon.

## Endpoint

`GET /api/badge/[login].svg` — public, no auth required, only exposes fields already public via `/api/developer` (rank, score, star count). See `lib/badge.js` for stat resolution and SVG rendering, and `lib/badge-lookup.js` for the data source (Cosmos DB, falling back to bundled sample data — same pattern as `/api/card`).
41 changes: 41 additions & 0 deletions lib/badge-lookup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { promises as fs } from 'fs';
import path from 'path';
import { getCosmosContainer } from './cosmos.js';
import { addDeveloperRanks } from './ranking.js';
import { scoreAll } from './scoring.js';

const BADGE_FIELDS = 'c.login, c.score, c.globalRank, c.globalTotal, c.country, c.countryRank, c.countryTotal, c.city, c.cityRank, c.cityTotal, c.totalStars, c.claimed';

async function getFromCosmos(login) {
const container = getCosmosContainer();
if (!container) return null;

try {
const { resources } = await container.items.query({
query: `SELECT TOP 1 ${BADGE_FIELDS}
FROM c
WHERE (c.login = @login OR c.id = @login)
AND (NOT IS_DEFINED(c.nomination) OR c.nomination.status = 'approved')`,
parameters: [{ name: '@login', value: login }],
}).fetchAll();
return resources[0] || null;
} catch (error) {
console.error('Badge: Cosmos error', error.message);
return null;
}
}

async function getFromSampleData(login) {
const filePath = path.join(process.cwd(), 'data', 'developers-sample.json');
const raw = await fs.readFile(filePath, 'utf-8');
const data = JSON.parse(raw);
const developers = addDeveloperRanks(scoreAll(data));
return developers.find(d => d.login.toLowerCase() === login.toLowerCase()) || null;
}

/** Public, badge-safe view of a developer: only fields already exposed via /api/developer. */
export async function getBadgeDeveloper(login) {
const fromCosmos = await getFromCosmos(login);
if (fromCosmos) return fromCosmos;
return getFromSampleData(login);
}
105 changes: 105 additions & 0 deletions lib/badge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
export const BADGE_STATS = ['globalRank', 'countryRank', 'cityRank', 'score', 'stars'];
export const DEFAULT_BADGE_STAT = 'globalRank';

const BRAND = {
bg: '#0b1017',
labelBg: '#000000',
valueBg: '#1d4ed8',
divider: '#1e293b',
border: '#cbd5e1',
labelText: '#ffffff',
valueText: '#ffffff',
muted: '#94a3b8',
unrankedText: '#64748b',
};

function formatCompactNumber(value) {
const n = Number(value) || 0;
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}

/**
* Resolve which stat to show and its display value for a developer.
* Returns { value: string, unranked: boolean } — unranked is true when the
* developer exists but doesn't have that particular stat (e.g. no country
* detected), so the badge can degrade gracefully instead of breaking.
*/
export function resolveBadgeStat(developer, statParam) {
const stat = BADGE_STATS.includes(statParam) ? statParam : DEFAULT_BADGE_STAT;

if (!developer) return { stat, value: 'unranked', unranked: true };

switch (stat) {
case 'cityRank': {
if (!developer.cityRank || !developer.cityTotal) return { stat, value: 'unranked', unranked: true };
return { stat, value: `${developer.city || 'City'} #${developer.cityRank}`, unranked: false };
}
case 'countryRank': {
if (!developer.countryRank || !developer.countryTotal) return { stat, value: 'unranked', unranked: true };
return { stat, value: `${developer.country || 'Country'} #${developer.countryRank}`, unranked: false };
}
case 'score': {
if (!Number.isFinite(developer.score)) return { stat, value: 'unranked', unranked: true };
return { stat, value: `${Math.round(developer.score)}/100`, unranked: false };
}
case 'stars': {
if (!Number.isFinite(developer.totalStars)) return { stat, value: 'unranked', unranked: true };
return { stat, value: `${formatCompactNumber(developer.totalStars)} stars`, unranked: false };
}
case 'globalRank':
default: {
if (!developer.globalRank || !developer.globalTotal) return { stat, value: 'unranked', unranked: true };
return { stat, value: `Global #${developer.globalRank}`, unranked: false };
}
}
}

function escapeXml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

// Rough monospace-ish width estimate so the badge is only as wide as it
// needs to be, matching the shields.io convention of tight auto-sizing.
function estimateTextWidth(text, fontSize) {
return Math.round(text.length * fontSize * 0.62);
}

/**
* Render a flat, two-segment badge: "devglobe" label + a stat value,
* following the shields.io/committers.top visual convention.
*/
export function renderBadgeSvg({ value, unranked = false, height = 20 }) {
const fontSize = 11;
const paddingX = 10;
const labelText = 'Devglobe rank';
const valueText = value;

const labelWidth = estimateTextWidth(labelText, fontSize) + paddingX * 2;
const valueWidth = estimateTextWidth(valueText, fontSize) + paddingX * 2;
const totalWidth = labelWidth + valueWidth;
const valueTextColor = unranked ? BRAND.unrankedText : BRAND.valueText;
const radius = 3;

return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}" viewBox="0 0 ${totalWidth} ${height}" role="img" aria-label="${escapeXml(`${labelText}: ${valueText}`)}">
<title>${escapeXml(`${labelText}: ${valueText}`)}</title>
<clipPath id="round">
<rect width="${totalWidth - 1}" height="${height - 1}" x="0.5" y="0.5" rx="${radius}" fill="#fff"/>
</clipPath>
<g clip-path="url(#round)">
<rect width="${labelWidth}" height="${height}" fill="${BRAND.labelBg}"/>
<rect x="${labelWidth}" width="${valueWidth}" height="${height}" fill="${BRAND.valueBg}"/>
<rect x="${labelWidth}" y="0" width="1" height="${height}" fill="${BRAND.divider}"/>
</g>
<rect width="${totalWidth - 1}" height="${height - 1}" x="0.5" y="0.5" rx="${radius}" fill="none" stroke="${BRAND.border}"/>
<g text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="${fontSize}">
<text x="${labelWidth / 2}" y="${height / 2 + 4}" fill="${BRAND.labelText}">${escapeXml(labelText)}</text>
<text x="${labelWidth + valueWidth / 2}" y="${height / 2 + 4}" fill="${valueTextColor}">${escapeXml(valueText)}</text>
</g>
</svg>`;
}
Loading
Loading