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
74 changes: 49 additions & 25 deletions dashboard/app/api/badge/[owner]/[name]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { analyzeRepo } from "@/lib/scorer";
import { auth } from "@/lib/auth";

type Params = { params: Promise<{ owner: string; name: string }> };

Expand All @@ -11,15 +10,32 @@ function scoreColor(score: number): string {
return "dc2626";
}

function buildSvg(owner: string, name: string, score: number): string {
function escapeXml(value: string): string {
return value.replace(/[&<>"']/g, (char) => {
const entities: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&apos;",
};
return entities[char];
});
}

function buildSvg(value: string, color: string): string {
const label = "DevLens";
const value = `${score}/100`;
const color = scoreColor(score);
const safeValue = escapeXml(value);
const lw = 68;
const vw = 52;
const vw = Math.max(52, Math.min(110, safeValue.length * 7 + 14));
const tw = lw + vw;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${tw}" height="20" role="img" aria-label="${label}: ${value}">
<title>${label}: ${value}</title>
const labelX = Math.round(lw / 2) * 10;
const valueX = (lw + Math.round(vw / 2)) * 10;
const labelLength = (lw - 10) * 10;
const valueLength = (vw - 10) * 10;

return `<svg xmlns="http://www.w3.org/2000/svg" width="${tw}" height="20" role="img" aria-label="${label}: ${safeValue}">
<title>${label}: ${safeValue}</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
Expand All @@ -31,29 +47,37 @@ function buildSvg(owner: string, name: string, score: number): string {
<rect width="${tw}" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110">
<text x="${Math.round(lw / 2) * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(lw - 10) * 10}" lengthAdjust="spacing">${label}</text>
<text x="${Math.round(lw / 2) * 10}" y="140" transform="scale(.1)" textLength="${(lw - 10) * 10}" lengthAdjust="spacing">${label}</text>
<text x="${(lw + Math.round(vw / 2)) * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(vw - 10) * 10}" lengthAdjust="spacing">${value}</text>
<text x="${(lw + Math.round(vw / 2)) * 10}" y="140" transform="scale(.1)" textLength="${(vw - 10) * 10}" lengthAdjust="spacing">${value}</text>
<text x="${labelX}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${labelLength}" lengthAdjust="spacing">${label}</text>
<text x="${labelX}" y="140" transform="scale(.1)" textLength="${labelLength}" lengthAdjust="spacing">${label}</text>
<text x="${valueX}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${valueLength}" lengthAdjust="spacing">${safeValue}</text>
<text x="${valueX}" y="140" transform="scale(.1)" textLength="${valueLength}" lengthAdjust="spacing">${safeValue}</text>
</g>
</svg>`;
}

export async function GET(_req: NextRequest, { params }: Params) {
const { owner, name } = await params;
let score = 0;

try {
const session = await auth();
const token = (session as any)?.accessToken ?? process.env.GITHUB_TOKEN;
const report = await analyzeRepo(owner, name, token);
score = report.healthScore ?? 0;
} catch { /* return 0-score badge on error */ }

const svg = buildSvg(owner, name, score);
return new NextResponse(svg, {
headers: {
"Content-Type": "image/svg+xml",
"Cache-Control": "s-maxage=3600, stale-while-revalidate=86400",
},
});
const report = await analyzeRepo(owner, name, process.env.GITHUB_TOKEN);
const score = report.healthScore;

if (!Number.isFinite(score) || score < 0 || score > 100) {
throw new Error("Analysis returned an invalid health score");
}

return new NextResponse(buildSvg(`${score}/100`, scoreColor(score)), {
headers: {
"Content-Type": "image/svg+xml; charset=utf-8",
"Cache-Control": "s-maxage=3600, stale-while-revalidate=86400",
},
});
} catch {
return new NextResponse(buildSvg("unavailable", "6b7280"), {
headers: {
"Content-Type": "image/svg+xml; charset=utf-8",
"Cache-Control": "no-store",
},
});
}
}
4 changes: 2 additions & 2 deletions dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"@vercel/speed-insights": "^1.1.0",
"clsx": "2.1.1",
"lucide-react": "0.479.0",
"next": "16.3.1",
"next": "16.3.3",
"next-auth": "5.0.0-beta.32",
"react": "19.2.4",
"react-dom": "19.2.4",
Expand All @@ -26,7 +26,7 @@
"@types/react": "19",
"@types/react-dom": "19",
"eslint": "9",
"eslint-config-next": "16.3.1",
"eslint-config-next": "16.3.3",
"typescript": "5"
}
}
Loading