From ab53f32024b92bd69ed02638591bb8c7abaed1ff Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Thu, 18 Jun 2026 19:11:03 +0200 Subject: [PATCH 01/64] rss: ship /rss.xml feed for benchmark releases + fix duplicate import --- src/app/benchmarks/[slug]/page.tsx | 1 - src/app/rss.xml/route.ts | 117 +++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 src/app/rss.xml/route.ts diff --git a/src/app/benchmarks/[slug]/page.tsx b/src/app/benchmarks/[slug]/page.tsx index c165674d..6a9f8b55 100644 --- a/src/app/benchmarks/[slug]/page.tsx +++ b/src/app/benchmarks/[slug]/page.tsx @@ -10,7 +10,6 @@ import { BenchmarkBodySkeleton } from "@/components/benchmark-body-skeleton"; import { OraclePairMatrix } from "@/components/oracle-pair-matrix"; import { Breadcrumb } from "@/components/breadcrumb"; import { ChainHeadingsSummary } from "@/components/chain-headings-summary"; -import { OraclePairMatrix } from "@/components/oracle-pair-matrix"; import { CitationBar } from "@/components/citation-bar"; import { LiveIndicator } from "@/components/live-indicator"; import { ShareSection } from "@/components/share-section"; diff --git a/src/app/rss.xml/route.ts b/src/app/rss.xml/route.ts new file mode 100644 index 00000000..1af7fd10 --- /dev/null +++ b/src/app/rss.xml/route.ts @@ -0,0 +1,117 @@ +/** + * RSS 2.0 feed listing every live benchmark. + * + * Consumed by crypto news aggregators and journalists who watch for + * new benchmark releases (DefiLlama news pipeline, Wu Blockchain + * curation lists, custom Slack RSS-to-channel bots, etc). One entry + * per live bench, ordered by `datePublished` descending, with the + * canonical /benchmarks/ link and an auto-generated headline + * sentence as the item description. + * + * Why RSS 2.0 and not Atom: Atom is technically cleaner but every + * legacy aggregator on the planet speaks RSS 2.0 and most fall back + * to title-only if the feed declares Atom. RSS 2.0 maximises pickup. + * + * Freshness model: + * - `pubDate` per item = the bench's first commit (stable, from + * `bench-dates.ts`). + * - `lastBuildDate` on the channel = the most recent bench's pubDate. + * Aggregators use this to decide whether to refetch the body. + * + * Cache: 5 min edge TTL so a brand new bench surfaces in aggregator + * polls within minutes of being merged, without hammering the data + * layer on every poll. + */ + +import { NextResponse } from "next/server"; +import { loadAllBenchmarks } from "@/lib/spec"; +import { getBenchCreatedAt } from "@/lib/seo/bench-dates"; +import { headlineSentence } from "@/lib/citation"; +import { SITE } from "@/data/site"; +import type { Benchmark } from "@/types/benchmark"; + +export const revalidate = 300; + +const FEED_TITLE = "OpenChainBench benchmark releases"; +const FEED_DESCRIPTION = + "Live measurements for crypto infrastructure: RPCs, oracles, aggregators, bridges, prediction markets. One entry per public benchmark."; + +function escapeXml(input: string): string { + return input + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function toRfc822(d: Date): string { + // RSS 2.0 dates are RFC 822 with a 4-digit year. Native `toUTCString` + // returns the same shape; only swap the 2-letter "GMT" trailer for + // "+0000" which a few stricter parsers prefer. + return d.toUTCString().replace("GMT", "+0000"); +} + +function itemDescription(b: Benchmark): string { + const sentence = headlineSentence(b); + if (sentence) return `${sentence} ${b.subtitle}`.trim(); + return b.subtitle; +} + +export async function GET() { + const all = await loadAllBenchmarks(); + const live = all.filter((b) => b.editorialStatus === "live"); + + const items = live + .map((b) => ({ + bench: b, + pubDate: getBenchCreatedAt(b.slug), + })) + .sort((a, c) => c.pubDate.getTime() - a.pubDate.getTime()); + + const latest = items[0]?.pubDate ?? new Date(); + const self = `${SITE.url}/rss.xml`; + const lines: string[] = []; + lines.push(''); + lines.push( + '', + ); + lines.push(" "); + lines.push(` ${escapeXml(FEED_TITLE)}`); + lines.push(` ${SITE.url}`); + lines.push(` ${escapeXml(FEED_DESCRIPTION)}`); + lines.push(" en"); + lines.push(` ${toRfc822(latest)}`); + lines.push( + ` `, + ); + + for (const { bench, pubDate } of items) { + const link = `${SITE.url}/benchmarks/${bench.slug}`; + const title = bench.seoTitle ?? bench.title; + lines.push(" "); + lines.push(` ${escapeXml(title)}`); + lines.push(` ${link}`); + lines.push(` ${link}`); + lines.push(` ${toRfc822(pubDate)}`); + lines.push(` ${escapeXml(bench.category)}`); + lines.push( + ` ${escapeXml(itemDescription(bench))}`, + ); + lines.push(" "); + } + + lines.push(" "); + lines.push(""); + + return new NextResponse(lines.join("\n"), { + status: 200, + headers: { + "Content-Type": "application/rss+xml; charset=utf-8", + // 5 min CDN cache + 1 h SWR so a brand-new bench reaches a polling + // aggregator within minutes, but a flood of polls during quiet hours + // doesn't re-render the route. + "Cache-Control": "public, s-maxage=300, stale-while-revalidate=3600", + }, + }); +} From 056a4ea1efa6db7338b5f1c2fc6f0bf0be09ab62 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:18:53 +0300 Subject: [PATCH 02/64] chore: cherry-pick /partners + footer link to prod (#543) * feat(backlinks): badge embed snippet endpoint + /partners page (#541) * feat(footer): surface /partners in Developers column (#542) * feat(backlinks): badge embed snippet endpoint + /partners page * feat(footer): surface /partners in Developers column --- .../badge/[slug]/[provider]/snippet/route.ts | 113 ++++++++ src/app/partners/page.tsx | 274 ++++++++++++++++++ src/app/sitemap.ts | 1 + src/components/site-footer.tsx | 1 + 4 files changed, 389 insertions(+) create mode 100644 src/app/api/badge/[slug]/[provider]/snippet/route.ts create mode 100644 src/app/partners/page.tsx diff --git a/src/app/api/badge/[slug]/[provider]/snippet/route.ts b/src/app/api/badge/[slug]/[provider]/snippet/route.ts new file mode 100644 index 00000000..ef67d7e5 --- /dev/null +++ b/src/app/api/badge/[slug]/[provider]/snippet/route.ts @@ -0,0 +1,113 @@ +/** + * Copy-paste embed snippets for the per-(benchmark, provider) badge SVG. + * + * GET /api/badge///snippet?format=markdown|html|url|json + * + * Returns a ready-to-paste embed code so a provider can drop a live + * "Ranked #N on OpenChainBench" badge into their README, docs page, + * or marketing site without crafting the URL by hand. The badge SVG + * itself still lives at /api/badge//; this endpoint + * only wraps it. + * + * Why this exists separately from the SVG route: it is the one + * surface readers reach for when they want to BACKLINK us. Keeping + * snippet rendering out of the SVG path keeps the SVG cache hot + * (one cacheable shape per benchmark + provider) and avoids polluting + * the SVG content negotiation with a text/* branch. + * + * Optional query params forwarded to the badge URL so a provider can + * embed a scope-restricted badge (chain, region, kind). The site URL + * the badge links to also picks up the same scope where applicable, + * so a reader clicking through lands on the matching variant view. + */ + +import { type NextRequest, NextResponse } from "next/server"; +import { getBenchmark } from "@/data/benchmarks"; +import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; +import { PROVIDER_RE, SLUG_RE } from "@/lib/slug"; +import { SITE } from "@/data/site"; + +export const revalidate = 600; + +type Params = { slug: string; provider: string }; + +const FORMATS = ["markdown", "html", "url", "json"] as const; +type Format = (typeof FORMATS)[number]; + +function isFormat(v: string | null): v is Format { + return v != null && (FORMATS as readonly string[]).includes(v); +} + +export async function GET( + req: NextRequest, + { params }: { params: Promise }, +) { + const r = rateLimit(clientKey(req, "badge-snippet"), 120, 60); + if (!r.ok) return tooManyRequests(r.retryAfterSec); + + const { slug, provider } = await params; + if (!SLUG_RE.test(slug) || !PROVIDER_RE.test(provider)) { + return NextResponse.json({ error: "invalid_slug" }, { status: 400 }); + } + + const benchmark = await getBenchmark(slug); + if (!benchmark) { + return NextResponse.json({ error: "bench_not_found" }, { status: 404 }); + } + const result = benchmark.results.find((p) => p.slug === provider); + if (!result) { + return NextResponse.json({ error: "provider_not_found" }, { status: 404 }); + } + + const sp = req.nextUrl.searchParams; + const format: Format = isFormat(sp.get("format")) ? (sp.get("format") as Format) : "markdown"; + const chain = sp.get("chain")?.trim() || ""; + const region = sp.get("region")?.trim() || ""; + const kind = sp.get("kind")?.trim() || ""; + + const scopeQs = new URLSearchParams(); + if (chain) scopeQs.set("chain", chain); + if (region) scopeQs.set("region", region); + if (kind) scopeQs.set("kind", kind); + const scopeSuffix = scopeQs.toString(); + + const badgeUrl = + `${SITE.url}/api/badge/${slug}/${provider}` + + (scopeSuffix ? `?${scopeSuffix}` : ""); + const benchUrl = + `${SITE.url}/benchmarks/${slug}` + + (scopeSuffix ? `?${scopeSuffix}` : ""); + + const alt = `OpenChainBench ${benchmark.title} ranking for ${result.name}`; + + const snippets = { + markdown: `[![${alt}](${badgeUrl})](${benchUrl})`, + html: `${alt}`, + url: badgeUrl, + } as const; + + if (format === "json") { + return NextResponse.json( + { + benchmark: { slug, title: benchmark.title, url: benchUrl }, + provider: { slug: provider, name: result.name }, + badge_url: badgeUrl, + snippets, + scope: { chain: chain || null, region: region || null, kind: kind || null }, + license: "CC-BY-4.0", + }, + { + headers: { + "cache-control": "public, s-maxage=300, stale-while-revalidate=600", + }, + }, + ); + } + + return new NextResponse(snippets[format], { + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "public, s-maxage=300, stale-while-revalidate=600", + }, + }); +} diff --git a/src/app/partners/page.tsx b/src/app/partners/page.tsx new file mode 100644 index 00000000..29e3d8f2 --- /dev/null +++ b/src/app/partners/page.tsx @@ -0,0 +1,274 @@ +import Link from "next/link"; +import type { Metadata } from "next"; +import { pageMetadata } from "@/lib/page-metadata"; +import { SITE } from "@/data/site"; +import { safeJsonLd, buildBreadcrumbJsonLd } from "@/lib/jsonld"; + +/** + * Partners and integrations page. Documents the public assets a provider, + * tool, or media outlet can drop into their own surfaces to surface + * OpenChainBench measurements: live badges, share cards, citation + * snippets, and the public data endpoints (REST, OpenAPI, MCP, llms.txt). + * + * Purpose is twofold: + * 1. Reduce friction for sites that already want to cite us. Every + * asset on this page is meant to be copied and dropped in with no + * back-and-forth. + * 2. Surface the CC-BY-4.0 license clearly so legal teams have an easy + * answer when their content team asks "can we embed this badge?". + */ + +const DESCRIPTION = + "Live badges, share cards, citation snippets and public data APIs you can drop into your README, docs, or marketing site to surface live OpenChainBench measurements. CC-BY-4.0 licensed."; + +export const metadata: Metadata = pageMetadata({ + path: "/partners", + title: "Partners and integrations", + description: DESCRIPTION, +}); + +export const revalidate = 3600; + +export default function PartnersPage() { + const breadcrumbLd = { + "@context": "https://schema.org", + ...buildBreadcrumbJsonLd([ + { name: "Home", item: SITE.url }, + { name: "Partners", item: `${SITE.url}/partners` }, + ]), + }; + + return ( +
+