|
| 1 | +// SVG badge for README embedding (shields.io style) |
| 2 | +// Usage: /api/badge?project=owner--repo |
| 3 | +// Returns: ~240x28 SVG badge |
| 4 | + |
| 5 | +async function fetchProject(projectId) { |
| 6 | + const url = process.env.SUPABASE_URL |
| 7 | + const key = process.env.SUPABASE_SERVICE_ROLE_KEY |
| 8 | + if (!url || !key) return null |
| 9 | + |
| 10 | + const res = await fetch( |
| 11 | + `${url}/rest/v1/ranked_projects?id=eq.${encodeURIComponent(projectId)}&select=rank,stars_gained_7d&limit=1`, |
| 12 | + { headers: { apikey: key, Authorization: `Bearer ${key}` } } |
| 13 | + ) |
| 14 | + if (!res.ok) return null |
| 15 | + const rows = await res.json() |
| 16 | + return rows[0] || null |
| 17 | +} |
| 18 | + |
| 19 | +function escapeXml(str) { |
| 20 | + return String(str) |
| 21 | + .replace(/&/g, '&') |
| 22 | + .replace(/</g, '<') |
| 23 | + .replace(/>/g, '>') |
| 24 | + .replace(/"/g, '"') |
| 25 | + .replace(/'/g, ''') |
| 26 | +} |
| 27 | + |
| 28 | +function formatStars(n) { |
| 29 | + if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + 'k' |
| 30 | + return String(n) |
| 31 | +} |
| 32 | + |
| 33 | +function generateBadge(project) { |
| 34 | + const rank = project.rank |
| 35 | + const stars = formatStars(project.stars_gained_7d || 0) |
| 36 | + const rightText = escapeXml(`#${rank} · +${stars} ★`) |
| 37 | + |
| 38 | + // Color: gold for top 3, blue for rest |
| 39 | + const bg = rank <= 3 ? '#FFB830' : '#4D9CFF' |
| 40 | + const textColor = rank <= 3 ? '#1a1200' : '#fff' |
| 41 | + |
| 42 | + const labelText = 'ShipRanked' |
| 43 | + const labelWidth = 78 |
| 44 | + const valueWidth = rightText.length * 7.2 + 16 |
| 45 | + const totalWidth = labelWidth + valueWidth |
| 46 | + |
| 47 | + return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${labelText}: ${rightText}"> |
| 48 | + <linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient> |
| 49 | + <clipPath id="r"><rect width="${totalWidth}" height="20" rx="3" fill="#fff"/></clipPath> |
| 50 | + <g clip-path="url(#r)"> |
| 51 | + <rect width="${labelWidth}" height="20" fill="#333"/> |
| 52 | + <rect x="${labelWidth}" width="${valueWidth}" height="20" fill="${bg}"/> |
| 53 | + <rect width="${totalWidth}" height="20" fill="url(#s)"/> |
| 54 | + </g> |
| 55 | + <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11"> |
| 56 | + <text x="${labelWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${labelText}</text> |
| 57 | + <text x="${labelWidth / 2}" y="14">${labelText}</text> |
| 58 | + </g> |
| 59 | + <g fill="${textColor}" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11"> |
| 60 | + <text x="${labelWidth + valueWidth / 2}" y="15" fill-opacity=".3">${rightText}</text> |
| 61 | + <text x="${labelWidth + valueWidth / 2}" y="14">${rightText}</text> |
| 62 | + </g> |
| 63 | +</svg>` |
| 64 | +} |
| 65 | + |
| 66 | +function generateNotRankedBadge() { |
| 67 | + const labelText = 'ShipRanked' |
| 68 | + const rightText = 'not ranked' |
| 69 | + const labelWidth = 78 |
| 70 | + const valueWidth = 72 |
| 71 | + const totalWidth = labelWidth + valueWidth |
| 72 | + |
| 73 | + return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${labelText}: ${rightText}"> |
| 74 | + <linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient> |
| 75 | + <clipPath id="r"><rect width="${totalWidth}" height="20" rx="3" fill="#fff"/></clipPath> |
| 76 | + <g clip-path="url(#r)"> |
| 77 | + <rect width="${labelWidth}" height="20" fill="#333"/> |
| 78 | + <rect x="${labelWidth}" width="${valueWidth}" height="20" fill="#777"/> |
| 79 | + <rect width="${totalWidth}" height="20" fill="url(#s)"/> |
| 80 | + </g> |
| 81 | + <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11"> |
| 82 | + <text x="${labelWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${labelText}</text> |
| 83 | + <text x="${labelWidth / 2}" y="14">${labelText}</text> |
| 84 | + <text x="${labelWidth + valueWidth / 2}" y="15" fill-opacity=".3">${rightText}</text> |
| 85 | + <text x="${labelWidth + valueWidth / 2}" y="14">${rightText}</text> |
| 86 | + </g> |
| 87 | +</svg>` |
| 88 | +} |
| 89 | + |
| 90 | +export default async function handler(req, res) { |
| 91 | + res.setHeader('Content-Type', 'image/svg+xml') |
| 92 | + res.setHeader('Cache-Control', 's-maxage=3600, stale-while-revalidate=86400') |
| 93 | + res.setHeader('Access-Control-Allow-Origin', '*') |
| 94 | + |
| 95 | + const { project } = req.query |
| 96 | + if (!project || !project.includes('--')) { |
| 97 | + return res.status(200).send(generateNotRankedBadge()) |
| 98 | + } |
| 99 | + |
| 100 | + const [owner, ...repoParts] = project.split('--') |
| 101 | + const repo = repoParts.join('--') |
| 102 | + const projectId = `github:${owner}/${repo}` |
| 103 | + |
| 104 | + try { |
| 105 | + const data = await fetchProject(projectId) |
| 106 | + if (!data) return res.status(200).send(generateNotRankedBadge()) |
| 107 | + return res.status(200).send(generateBadge(data)) |
| 108 | + } catch (err) { |
| 109 | + console.error('Badge error:', err) |
| 110 | + return res.status(200).send(generateNotRankedBadge()) |
| 111 | + } |
| 112 | +} |
0 commit comments