From 3e9f34297e4614d72c0e79da4abe81125f7dc7a1 Mon Sep 17 00:00:00 2001 From: Kermit457 Date: Mon, 15 Jun 2026 18:48:27 +0800 Subject: [PATCH] feat(tools): per-category embeddable badge SVG + gallery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing star-history SVG embed pattern (v3 deltas defensibility 2.0) to category ranking badges. README authors can embed a live "Trending in " SVG badge — every embed is free viral marketing for TrendingRepo. Files: - src/app/api/og/category-badge/[slug]/route.tsx (new) — dynamic [slug] = one of 32 category ids. Returns image/svg+xml self- contained (no external font/image refs, inline system-font stack). Reads consensus-trending + repo-categories maps; renders top-3 ranked repos + composite scores for the category. Cache-Control: s-maxage=3600, swr=86400. - src/app/api/og/category-badge/__tests__/ — vitest assertions on valid SVG XML + content-type + slug-derived category name in output. - src/app/tools/category-badges/page.tsx + loading.tsx + error.tsx (new) — gallery showing all 32 badges with markdown snippet to copy. - src/app/tools/page.tsx (modified, +7 lines) — adds Category Badges entry to CHART_TOOLS array next to Treemap and Star History. - vitest.config.ts (modified) — adds the badge route test pattern (next/server requires the vitest loader path, same as oembed/x402). No publicly stale batches: when consensus-trending is past 4h, the badge renders a "Refreshing" state instead of stale top-3. Companion PRs: C-CAT #3174 (taxonomy ids), classify #3185 (per-repo assignments), per-category-pages #3181 (HTML landing), Toolbox #241 (ULTRA harness). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/og/category-badge/[slug]/route.ts | 298 ++++++++++++++++++ .../og/category-badge/__tests__/route.test.ts | 107 +++++++ src/app/tools/category-badges/error.tsx | 22 ++ src/app/tools/category-badges/loading.tsx | 34 ++ src/app/tools/category-badges/page.tsx | 234 ++++++++++++++ src/app/tools/page.tsx | 23 ++ vitest.config.ts | 4 + 7 files changed, 722 insertions(+) create mode 100644 src/app/api/og/category-badge/[slug]/route.ts create mode 100644 src/app/api/og/category-badge/__tests__/route.test.ts create mode 100644 src/app/tools/category-badges/error.tsx create mode 100644 src/app/tools/category-badges/loading.tsx create mode 100644 src/app/tools/category-badges/page.tsx diff --git a/src/app/api/og/category-badge/[slug]/route.ts b/src/app/api/og/category-badge/[slug]/route.ts new file mode 100644 index 000000000..190d15c1c --- /dev/null +++ b/src/app/api/og/category-badge/[slug]/route.ts @@ -0,0 +1,298 @@ +// /api/og/category-badge/[slug] — Embeddable category-ranking badge (SVG). +// +// README authors can embed a live "Trending in " badge that links +// back to /categories/: +// +// [![Trending in MCP](https://trendingrepo.com/api/og/category-badge/mcp.svg)](https://trendingrepo.com/categories/mcp) +// +// The endpoint accepts either `/mcp` or `/mcp.svg` — the `.svg` suffix is +// stripped server-side so the URL looks like a "real" image asset to the +// markdown renderers that demand a file extension (GitHub README does). +// +// Defensibility note (v3 deltas plan): we already ship embeddable star-history +// SVGs at /api/og/star-history and treemap thumbnails at /api/og/top10 — every +// embed is free viral marketing AND a defensibility moat (someone has to host +// + classify + re-render the same payload to replace us). This is the third +// embed surface, scoped to the per-category leaderboard. +// +// Output: a self-contained 480x80 inline SVG document. No external font or +// image references — system-font stack only, inline path geometry for the +// category icon glyph. Safe to embed on any host (GitHub camo-proxies the +// SVG; image/svg+xml is on its allowlist). +// +// Cache-Control: +// - 1h fresh + 24h SWR (s-maxage=3600, stale-while-revalidate=86400) +// - Badges don't need to be live — they refresh on the next read after the +// worker tick. SWR window keeps GitHub camo + edge caches warm. +// +// Freshness state: +// - If the trending payload is older than 4h we render a "Refreshing" state +// instead of stale top-3 picks. The pattern matches consensus-trending's +// stale-after-6h guard (this one's tighter because badges are public). + +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { CATEGORIES } from "@/lib/constants"; +import { getDerivedRepos } from "@/lib/derived-repos"; +import { getLastFetchedAt, refreshTrendingFromStore } from "@/lib/trending"; +import { OG_COLORS } from "@/lib/seo"; +import { formatNumber } from "@/lib/utils"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// 1h fresh + 24h SWR — badges live in READMEs that get crawled once a day by +// GitHub camo, so a generous SWR is appropriate. We don't need sub-hour +// freshness; the worker ticks every ~10-15min and that lag is invisible at +// embed cadence. +const CACHE_HEADER = "public, s-maxage=3600, stale-while-revalidate=86400"; + +// 4h staleness threshold. Beyond this we show the "Refreshing" state so a +// stuck worker doesn't silently advertise a snapshot from yesterday in every +// embedded README on the internet. +const STALE_AFTER_MS = 4 * 60 * 60 * 1000; + +const WIDTH = 480; +const HEIGHT = 80; +// Left "chip" segment width. Category color background + icon + short name. +const CHIP_WIDTH = 144; + +// Inline path geometry for each Lucide icon name referenced in +// CATEGORIES[i].icon. Drawn at 24x24 (translate(x, y) scale(1)) — lucide +// icons are MIT-licensed and these are simplified glyph traces, not pixel- +// identical copies. We can't import lucide-react server-side without +// pulling React, so inline path data keeps the SVG self-contained. +const ICON_PATHS: Record = { + // Brain — two cerebral lobes + Brain: + "M9.5 2A2.5 2.5 0 0 0 7 4.5v.5a2.5 2.5 0 0 0-2 4.45 2.5 2.5 0 0 0 0 4.1 2.5 2.5 0 0 0 2 4.45v.5A2.5 2.5 0 0 0 9.5 21h0a2.5 2.5 0 0 0 2.5-2.5V4.5A2.5 2.5 0 0 0 9.5 2zM14.5 2A2.5 2.5 0 0 1 17 4.5v.5a2.5 2.5 0 0 1 2 4.45 2.5 2.5 0 0 1 0 4.1 2.5 2.5 0 0 1-2 4.45v.5a2.5 2.5 0 0 1-2.5 2.5h0a2.5 2.5 0 0 1-2.5-2.5V4.5A2.5 2.5 0 0 1 14.5 2z", + // Globe — circle with meridians + Globe: + "M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 0v20M2 12h20M5 5a14 14 0 0 1 14 0M5 19a14 14 0 0 1 14 0", + // Wrench — pipe wrench silhouette + Wrench: + "M14.7 6.3a3 3 0 0 0 4 4l2.6 2.6a1 1 0 0 1 0 1.4l-1.4 1.4a1 1 0 0 1-1.4 0L4.2 1.4a3 3 0 0 0-4 4L3 7.6 2 8.6a1 1 0 0 0 0 1.4L8.5 16.5a1 1 0 0 0 1.4 0l1-1 2.2 2.2", + // Server — two stacked rack units + Server: + "M4 4h16v6H4zM4 14h16v6H4zM7 7h.01M7 17h.01", + // Database — cylinder + Database: + "M4 6c0-2 4-3 8-3s8 1 8 3v12c0 2-4 3-8 3s-8-1-8-3V6zM4 6c0 2 4 3 8 3s8-1 8-3M4 12c0 2 4 3 8 3s8-1 8-3", + // Shield — heraldic shield + Shield: "M12 2 4 6v6c0 5 3.5 9 8 10 4.5-1 8-5 8-10V6l-8-4z", + // Smartphone + Smartphone: + "M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zM12 18h.01", + // BarChart3 — three bars + BarChart3: "M3 3v18h18M7 14v4M12 8v10M17 4v14", + // Coins — two stacked coins + Coins: + "M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM15 21a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 11h10M10 21h10", + // Cog — gear silhouette + Cog: "M12 8a4 4 0 1 1 0 8 4 4 0 0 1 0-8zM12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1", +}; + +// Fallback glyph when an icon name doesn't have inline geometry. A simple +// star — appropriate brand for TrendingRepo and never empty. +const FALLBACK_ICON_PATH = + "M12 2 14.4 8.6 21 9 16 13.4 17.6 20 12 16.4 6.4 20 8 13.4 3 9 9.6 8.6z"; + +// Escape XML special chars in user-derived strings. We control most of what +// renders into the SVG, but repo names and category descriptions flow from +// data that could contain `<`, `>`, `&`, `'`, `"`. +function escapeXml(input: string): string { + return input + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// Truncate with ellipsis at character count (SVG has no native text wrap and +// no measureText without a renderer — character budget is the pragmatic cap). +function truncate(value: string, max: number): string { + if (value.length <= max) return value; + return `${value.slice(0, Math.max(0, max - 1)).trimEnd()}…`; +} + +interface BadgeRowData { + fullName: string; + composite: number; +} + +interface BadgeState { + category: (typeof CATEGORIES)[number]; + rows: BadgeRowData[]; + refreshing: boolean; +} + +function pickTopRows(categoryId: string): BadgeRowData[] { + // Top-3 in this category by categoryRank ascending. categoryRank is 1-based + // and assigned during the derived-repos build pass, so reading it is much + // cheaper than re-sorting the full repo list per request. + const repos = getDerivedRepos(); + const inCategory = repos + .filter((r) => r.categoryId === categoryId && r.categoryRank > 0) + .sort((a, b) => a.categoryRank - b.categoryRank) + .slice(0, 3); + return inCategory.map((r) => ({ + fullName: r.fullName, + composite: r.momentumScore, + })); +} + +function categoryFreshnessMs(): number { + const iso = getLastFetchedAt(); + const t = Date.parse(iso); + if (!Number.isFinite(t)) return Number.POSITIVE_INFINITY; + return Date.now() - t; +} + +function buildSvg(state: BadgeState): string { + const { category, rows, refreshing } = state; + const chipColor = category.color; + const iconPath = ICON_PATHS[category.icon] ?? FALLBACK_ICON_PATH; + + // Right-segment layout. Three rows of 18px line-height, top padding 14px. + // When rows is empty we render a single "Trending in " line. + const rightX = CHIP_WIDTH + 16; + const rightWidth = WIDTH - CHIP_WIDTH - 16; + + const headline = refreshing + ? `Refreshing · ${category.name}` + : rows.length === 0 + ? `Trending in ${category.name}` + : `Trending in ${category.name}`; + + const headlineEsc = escapeXml(truncate(headline, 38)); + + let rowsMarkup = ""; + if (rows.length === 0 || refreshing) { + // Sub-line for the generic state. Keeps the badge feeling "alive" even + // when the classifier hasn't filled this category yet. + const subline = refreshing + ? `latest snapshot is warming · see trendingrepo.com` + : `${category.shortName} momentum · trendingrepo.com`; + rowsMarkup = `${escapeXml(truncate(subline, 50))}`; + } else { + const startY = 42; + const lineH = 16; + const RANK_BADGES = ["#1", "#2", "#3"]; + rowsMarkup = rows + .map((row, i) => { + const y = startY + i * lineH; + const rank = RANK_BADGES[i] ?? `#${i + 1}`; + const name = truncate(row.fullName, 28); + const score = formatNumber(Math.round(row.composite)); + // Per-row layout: rank badge (28px), repo name (centre), composite + // score right-aligned to the SVG right edge with a 12px gutter. + return [ + `${escapeXml(rank)}`, + `${escapeXml(name)}`, + `${escapeXml(score)}`, + ].join(""); + }) + .join(""); + } + + // Icon glyph — drawn at 24x24, translated to chip centre and stroked + // (Lucide uses 2px round strokes). The native viewBox is 0 0 24 24 so we + // translate to (32, 20) for a 28px icon area at the top of the chip. + const iconMarkup = [ + ``, + ``, + ``, + ].join(""); + + // Short name baseline at y=70 inside the chip. + const chipLabel = escapeXml(truncate(category.shortName, 12)); + + return [ + ``, + ``, + // Background — full card uses TrendingRepo bg. + ``, + // Border (1px stroke inset). Keeps the badge legible on light hosts. + ``, + // Left chip — category color block. + ``, + iconMarkup, + `${chipLabel}`, + // Right segment — headline + rows. + `${headlineEsc}`, + ``, + rowsMarkup, + // Brand strip — 3px brand-orange bar across the bottom right. + ``, + ``, + ].join(""); +} + +// `.svg` suffix support: README authors paste URLs like +// `.../category-badge/mcp.svg` because markdown renderers need an image +// extension. We strip `.svg` server-side so the slug lookup still hits +// CATEGORIES[i].id. +function normalizeSlug(raw: string): string { + const lower = raw.toLowerCase(); + return lower.endsWith(".svg") ? lower.slice(0, -4) : lower; +} + +function svgResponse(body: string, status = 200): NextResponse { + return new NextResponse(body, { + status, + headers: { + "Content-Type": "image/svg+xml; charset=utf-8", + "Cache-Control": CACHE_HEADER, + }, + }); +} + +interface RouteContext { + params: Promise<{ slug: string }>; +} + +export async function GET( + _request: NextRequest, + context: RouteContext, +): Promise { + const { slug: rawSlug } = await context.params; + const slug = normalizeSlug(rawSlug); + + const category = CATEGORIES.find((c) => c.id === slug); + if (!category) { + // Unknown slug — return a placeholder badge with TrendingRepo brand + // colors and the requested slug echoed back. Avoids 404 noise in + // README crawlers and tells the author exactly which slug failed. + const placeholder: BadgeState = { + category: { + id: slug, + name: "Unknown category", + shortName: truncate(rawSlug || "?", 12), + description: "", + icon: "BarChart3", + color: OG_COLORS.textTertiary, + }, + rows: [], + refreshing: false, + }; + return svgResponse(buildSvg(placeholder), 404); + } + + // Pull fresh trending payload. The 60s in-memory dedup inside + // refreshTrendingFromStore() keeps this cheap under embed load. + try { + await refreshTrendingFromStore(); + } catch { + // Refresh failure falls through to whatever the in-memory snapshot + // had. The freshness gate below catches genuinely stale state. + } + + const ageMs = categoryFreshnessMs(); + const refreshing = ageMs > STALE_AFTER_MS; + const rows = refreshing ? [] : pickTopRows(slug); + + const body = buildSvg({ category, rows, refreshing }); + return svgResponse(body); +} diff --git a/src/app/api/og/category-badge/__tests__/route.test.ts b/src/app/api/og/category-badge/__tests__/route.test.ts new file mode 100644 index 000000000..980e9f268 --- /dev/null +++ b/src/app/api/og/category-badge/__tests__/route.test.ts @@ -0,0 +1,107 @@ +// Coverage for /api/og/category-badge/[slug] — embeddable category badge. +// +// Behaviour matrix: +// - valid slug → 200 image/svg+xml, contains category name + slug +// - `.svg` suffix → 200 (slug stripped server-side) +// - unknown slug → 404 image/svg+xml with placeholder shape +// - cache-control → "public, s-maxage=3600, stale-while-revalidate=86400" +// - SVG validity → starts with ` { + process.env.NEXT_PUBLIC_APP_URL = "https://trendingrepo.com"; +}); + +async function callGet(rawUrl: string, slug: string): Promise { + const { GET } = await import("../[slug]/route"); + const req = new NextRequest(rawUrl); + return GET(req, { params: Promise.resolve({ slug }) }); +} + +function expectSvgEnvelope(res: Response, body: string): void { + expect(res.headers.get("content-type")).toMatch(/image\/svg\+xml/); + // Self-contained SVG must declare an XML prolog so file-extension + // sniffers (GitHub camo, README renderers) don't fall back to text/html. + expect(body.startsWith(""); +} + +describe("GET /api/og/category-badge/[slug]", () => { + it("returns a 200 SVG for a known slug", async () => { + const res = await callGet( + "https://trendingrepo.com/api/og/category-badge/mcp", + "mcp", + ); + const body = await res.text(); + expect(res.status).toBe(200); + expectSvgEnvelope(res, body); + // Category name flows through to the headline. + expect(body).toContain("Model Context Protocol"); + // Chip uses the category's short name. + expect(body).toContain(">MCP<"); + }); + + it("accepts a .svg suffix on the slug", async () => { + const res = await callGet( + "https://trendingrepo.com/api/og/category-badge/mcp.svg", + "mcp.svg", + ); + const body = await res.text(); + expect(res.status).toBe(200); + expectSvgEnvelope(res, body); + expect(body).toContain("Model Context Protocol"); + }); + + it("falls back to a placeholder + 404 for unknown slugs", async () => { + const res = await callGet( + "https://trendingrepo.com/api/og/category-badge/not-a-real-category", + "not-a-real-category", + ); + const body = await res.text(); + expect(res.status).toBe(404); + expectSvgEnvelope(res, body); + expect(body).toContain("Unknown category"); + }); + + it("sets the documented Cache-Control envelope", async () => { + const res = await callGet( + "https://trendingrepo.com/api/og/category-badge/ai-agents", + "ai-agents", + ); + const cc = res.headers.get("cache-control") ?? ""; + expect(cc).toContain("public"); + expect(cc).toContain("s-maxage=3600"); + expect(cc).toContain("stale-while-revalidate=86400"); + }); + + it("renders for every CATEGORIES entry without throwing", async () => { + const { CATEGORIES } = await import("@/lib/constants"); + for (const cat of CATEGORIES) { + const res = await callGet( + `https://trendingrepo.com/api/og/category-badge/${cat.id}`, + cat.id, + ); + expect(res.status, `slug ${cat.id} expected 200`).toBe(200); + const body = await res.text(); + expect(body, `slug ${cat.id} should declare XML prolog`).toMatch( + /^<\?xml/, + ); + // Chip color must be present — proves the SVG was rendered with the + // category's palette and not a placeholder fallback. + expect(body, `slug ${cat.id} should include chip color`).toContain( + cat.color, + ); + } + }); +}); diff --git a/src/app/tools/category-badges/error.tsx b/src/app/tools/category-badges/error.tsx new file mode 100644 index 000000000..937853341 --- /dev/null +++ b/src/app/tools/category-badges/error.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useEffect } from "react"; +import { ErrorPanel } from "@/components/ui/ErrorPanel"; + +interface ErrorProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function RouteError({ error, reset }: ErrorProps) { + useEffect(() => { + if (typeof window !== "undefined") { + const sentry = ( + window as unknown as { Sentry?: { captureException: (e: Error) => void } } + ).Sentry; + if (sentry?.captureException) sentry.captureException(error); + } + }, [error]); + + return ; +} diff --git a/src/app/tools/category-badges/loading.tsx b/src/app/tools/category-badges/loading.tsx new file mode 100644 index 000000000..80c97410e --- /dev/null +++ b/src/app/tools/category-badges/loading.tsx @@ -0,0 +1,34 @@ +// /tools/category-badges — V4 tool skeleton (header + gallery placeholder). + +export default function CategoryBadgesLoading() { + return ( +
+
+
+
+
+
+
+ {/* badge gallery — 6 card placeholders */} +
+ {[0, 1, 2, 3, 4, 5].map((i) => ( +
+ ))} +
+
+
+ ); +} diff --git a/src/app/tools/category-badges/page.tsx b/src/app/tools/category-badges/page.tsx new file mode 100644 index 000000000..4730264eb --- /dev/null +++ b/src/app/tools/category-badges/page.tsx @@ -0,0 +1,234 @@ +// /tools/category-badges — Gallery + embed recipes for the per-category +// ranking badges shipped at /api/og/category-badge/[slug].svg. +// +// Composition (matches the /tools/star-history layout): +// 01 Gallery — one card per CATEGORIES[i], shows the live badge + the +// markdown snippet to embed it. +// 02 Embed — copy-paste recipe block (markdown / html / raw img URL). +// +// Defensibility note: the badge endpoint is the third embeddable visual +// surface alongside the existing star-history SVG (/tools/star-history) and +// the treemap thumbnail. Every embed is free viral marketing + a +// defensibility moat — see v3 deltas plan §"Defensibility 2.0". + +import type { Metadata } from "next"; +import Link from "next/link"; + +import { Card, CardBody, CardHeader } from "@/components/ui/Card"; +import { PageHead } from "@/components/ui/PageHead"; +import { SectionHead } from "@/components/ui/SectionHead"; +import { CATEGORIES } from "@/lib/constants"; +import { absoluteUrl, SITE_NAME } from "@/lib/seo"; + +export const runtime = "nodejs"; +// 30-minute ISR — matches /tools/star-history. The badge inventory is a +// pure function of CATEGORIES (compile-time constant) so we could go much +// longer, but 30min keeps the chrome aligned with neighbouring tools. +export const revalidate = 1800; + +const PAGE_PATH = "/tools/category-badges"; + +export function generateMetadata(): Metadata { + const canonical = absoluteUrl(PAGE_PATH); + const title = `Category trending badges — ${SITE_NAME}`; + const description = + "Embed a live 'Trending in ' badge in your repo's README. SVG, no external font / image references, refreshes hourly off the same momentum pipeline as the rest of TrendingRepo."; + return { + title, + description, + keywords: [ + "GitHub README badge", + "trending badge", + "category leaderboard", + "shields-style badge", + "developer marketing", + ], + alternates: { canonical }, + openGraph: { + type: "website", + url: canonical, + title, + description, + siteName: SITE_NAME, + }, + twitter: { + card: "summary_large_image", + title, + description, + }, + }; +} + +function badgeImageUrl(slug: string): string { + // GitHub's markdown renderer needs a file extension for camo-proxied + // images — we keep the `.svg` suffix in the embed URL even though the + // route accepts the bare slug too. + return absoluteUrl(`/api/og/category-badge/${slug}.svg`); +} + +function categoryPageUrl(slug: string): string { + return absoluteUrl(`/categories/${slug}`); +} + +function markdownSnippet(slug: string, name: string): string { + return `[![Trending in ${name}](${badgeImageUrl(slug)})](${categoryPageUrl(slug)})`; +} + +function htmlSnippet(slug: string, name: string): string { + return `Trending in ${name}`; +} + +export default function CategoryBadgesToolPage() { + // Hard-cap the showcase example to the first category — keeps the + // section 02 snippet block focused. The full per-category grid lives + // in section 01 and renders every badge plus its own snippet. + const showcase = CATEGORIES[0]; + return ( +
+ + CATEGORY BADGES · TERMINAL · /TOOLS/CATEGORY-BADGES + + } + h1="Trending-in badges for your README." + lede="Drop a live category-ranking badge in any repo's README — it refreshes hourly off the same composite-momentum pipeline as the rest of TrendingRepo. Self-contained SVG, no external font or image hosts, works under GitHub camo." + clock={ + <> + {CATEGORIES.length} + CATEGORIES + + } + /> + + + one badge per category · click the snippet to copy + + } + /> +
+ {CATEGORIES.map((cat) => ( +
+ + + {`/${cat.id}.svg`} + + } + > + {cat.name} + + +

+ {cat.description} +

+ {/* Live badge preview. Rendered as a raw (not ) + because the destination is image/svg+xml and we want the + browser-native SVG rasterizer, not Next's bitmap loader. + `loading="lazy"` keeps the gallery cheap on first paint. */} +

+ + {/* eslint-disable-next-line @next/next/no-img-element */} + {`Trending + +

+
+                  {markdownSnippet(cat.id, cat.name)}
+                
+
+
+
+ ))} +
+ + markdown · html · raw URL} + /> +
+
+ + copy & paste}> + README / blog snippets + + +

+ Pick the format your renderer prefers. The endpoint accepts + both the bare slug and a .svg suffix — GitHub + READMEs need the suffix so the camo proxy treats it as an + image asset. +

+
+                {`# Markdown\n${markdownSnippet(showcase.id, showcase.name)}\n\n# HTML\n${htmlSnippet(showcase.id, showcase.name)}\n\n# Raw URL\n${badgeImageUrl(showcase.id)}`}
+              
+

+ Swap {showcase.id} for any slug from the gallery + above. Cache: 1h fresh + 24h SWR, refreshes on the next read + after the worker tick. +

+
+
+
+
+ +
+ Badges render from the same trending pipeline as the rest of the + site.{" "} + + Back to tools hub → + +
+
+ ); +} diff --git a/src/app/tools/page.tsx b/src/app/tools/page.tsx index 1677f0d96..c73185a5b 100644 --- a/src/app/tools/page.tsx +++ b/src/app/tools/page.tsx @@ -69,6 +69,13 @@ const CHART_TOOLS: ToolEntry[] = [ href: "/tools/treemap", status: "live", }, + { + num: "// 03", + title: "Category Badges", + desc: "Embed a live 'Trending in ' SVG badge in your repo's README, blog header, or pitch deck.", + href: "/tools/category-badges", + status: "live", + }, ]; const ESTIMATOR_TOOLS: ToolEntry[] = [ @@ -93,6 +100,20 @@ const CONTRIBUTE_TOOLS: ToolEntry[] = [ // Tiny inline previews — pure decoration, mockup-canonical 60×34. Tokens // only; no hardcoded hex. +function BadgeIcon() { + // Mini badge silhouette — color chip on the left, two text bars on the + // right. Mirrors the actual /api/og/category-badge layout in 60×34. + return ( + + ); +} + function ChartIcon({ kind }: { kind: "star-history" | "treemap" }) { if (kind === "star-history") { return ( @@ -177,6 +198,8 @@ function previewFor(title: string) { return ; case "Treemap": return ; + case "Category Badges": + return ; case "Revenue Estimate": return ; case "Submit Revenue": diff --git a/vitest.config.ts b/vitest.config.ts index 39c3d9283..08d0e3d29 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -47,6 +47,10 @@ export default defineConfig({ "src/app/api/oembed/__tests__/**/*.test.{ts,tsx}", // E6: x402 manifest stub route — same rationale as E5. "src/app/x402/__tests__/**/*.test.{ts,tsx}", + // Category badge SVG route — dynamic [slug] params + NextRequest; + // same vitest rationale as E5 (node:test can't load next/server + // cleanly). + "src/app/api/og/category-badge/__tests__/**/*.test.{ts,tsx}", // AGN-912: /githubrepo Metadata snapshot — node:test can't load // page.tsx (JSX + next/server-aware imports), vitest can. "src/app/githubrepo/__tests__/**/*.test.{ts,tsx}",