From a4544975568b1807755fd8f65c90b18714f29e34 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Tue, 9 Jun 2026 22:36:10 +0200 Subject: [PATCH 1/9] feat(about): add Independence and disclosure section Addresses the Mobula conflict-of-interest gap flagged in external SEO audits and required for the 'neutral methodology arbiter' positioning. New section iii on the About page declares: - Mobula funds the project + why - Mobula competes in 5 benchmarks and leads 2 (made explicit above the fold) - Three safeguards: open Prometheus data, open harness source, public methodology review (with the Coinpaprika story as the live example) - No paid tier, no sponsored slots, no token policy - Hosting funding source E-E-A-T signal: makes the conflict legible, links the recent methodology review PRs (#349, #352, #353), and gives Search Console / AI citation engines a concrete reason to treat the bench as authoritative. --- src/app/about/page.tsx | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 07bda322..202c7bd5 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -52,7 +52,29 @@ export default function AboutPage() { 13 live benchmarks. ~150 (provider × chain) probe pairs. Every metric is queryable on the public Prometheus and reproducible from the harness source.

- + +

+ OpenChainBench is funded by Mobula because we needed honest infrastructure benchmark data internally and saw no neutral source existed. The site is open-sourced because the industry needs it. +

+

+ Mobula competes in five live benchmarks: aggregator head lag, bridge fee, bridge quote latency, network coverage and metadata coverage. Mobula currently leads aggregator head lag and bridge quote latency. This is a real conflict of interest and we want it visible above the fold, not buried. +

+

+ Three safeguards stand against the conflict: +

+
    +
  • ·Open Prometheus data. Every number on the site is a literal quantile_over_time query. Anyone can hit our /api/citable or /api/stat/<slug> endpoint and re-derive the leaderboard with their own aggregation. If Mobula were inflating its rank, the raw data would show it.
  • +
  • ·Open harness source. Every harness is on GitHub under harnesses/. Clone, run docker compose up, your /metrics endpoint emits the same numbers ours does within 30 seconds.
  • +
  • ·Public methodology review. We invite external review and ship the fixes publicly. In June 2026 the Coinpaprika data team flagged four issues on the stablecoin peg and oracle deviation benches. We shipped three of them in pull requests #349, #352 and #353 within twenty-four hours and pushed back on the fourth with citations to CME, Chainlink and CoinGecko convention.
  • +
+

+ We do not run a paid tier. We do not sell ranking slots. We do not take provider sponsorship in exchange for inclusion. There is no token. If you spot any deviation from this policy, file a private security advisory and we will treat it as the integrity incident it would be. +

+

+ Hosting and infrastructure costs are paid by Mobula. The site runs on Vercel; harnesses run on Railway. We are open to grant funding that preserves editorial independence, but we will not accept funding from any party we benchmark. +

+ +

Every benchmark is a YAML spec plus a harness. The spec describes what to measure, which providers, which Prometheus queries hold the numbers; the harness runs continuously on Railway and exposes those metrics. A single shared Prometheus scrapes every harness; the site queries Prometheus directly and re-renders every minute. Every provider is rendered with equal visual weight. readers do their own ranking.

@@ -62,7 +84,7 @@ export default function AboutPage() { walks through the steps. New providers, new metrics, new chains. all welcome via pull request.

- +
  • ·Read the{" "}live benchmarks{" "}.
  • ·Reproduce any number. the{" "}methodology{" "}page tells you how.
  • @@ -70,7 +92,7 @@ export default function AboutPage() {
  • ·Discuss ideas in{" "}GitHub Discussions{" "}or follow{" "}@OpenChainBench.
- +

See a number you can't reproduce? File a{" "} From b4f3a83eeabd999d560be8ce4fad2df588da8ae6 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Tue, 9 Jun 2026 22:43:45 +0200 Subject: [PATCH 2/9] feat(badge): add Share on X intent + Markdown copy on product badge cards Providers who clear the rank-1 threshold see embed-ready badge cards on their /products/[slug] page. Today it surfaces an SVG preview plus an HTML copy block. This adds two more ship-helpers: - 'Share on X' link with a pre-baked tweet intent. Opens X with the ranking claim, the bench URL and the @OpenChainBench tag pre-filled. Removes the friction of having to write the post; anchors every share back to us as a tagged tweet that the provider's followers will see. - 'Copy Markdown' details block for GitHub READMEs and docs. The markdown form ![alt](badgeUrl) is what 80% of crypto provider docs use, second only to HTML in landing pages. Both reuse the existing /api/badge// dynamic SVG so the displayed rank always reflects the live leaderboard. --- src/app/products/[slug]/page.tsx | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index bdbbe46f..f84554ed 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -415,6 +415,14 @@ export default async function ProviderPage({ const badgeUrl = `${SITE.url}${badgePath}`; const targetUrl = `${SITE.url}/benchmarks/${a.benchmark.slug}`; const html = `Ranked #1 on OpenChainBench: ${a.benchmark.title}`; + const markdown = `[![Ranked #1 on OpenChainBench: ${a.benchmark.title}](${badgeUrl})](${targetUrl})`; + // Pre-baked X intent. Providers click → tweet draft opens + // with the ranking claim, the bench URL and the OCB handle + // already filled in. Removes the friction of writing the + // post themselves and gives us the canonical anchor text + // back as a tagged tweet on every share. + const tweetText = `Independently benchmarked #1 on ${a.benchmark.title} by @OpenChainBench.\n\nReproducible methodology, live data:`; + const tweetIntent = `https://x.com/intent/tweet?text=${encodeURIComponent(tweetText)}&url=${encodeURIComponent(targetUrl)}`; return (

  • @@ -430,6 +438,17 @@ export default async function ProviderPage({ decoding="async" /> +

    Copy HTML @@ -438,6 +457,14 @@ export default async function ProviderPage({ {html}
    +
    + + Copy Markdown + +
    +{markdown}
    +                    
    +
  • ); })} From 1b716f968dfecd5db2248a2522957aa30cb93a5c Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Tue, 9 Jun 2026 22:54:46 +0200 Subject: [PATCH 3/9] fix(badge): emit per-chain badges when provider only leads on subset of chains Closes the misleading-badge bug: a provider with a global aggregate #1 on a chain-dimensioned bench (like dRPC #1 cross-region on rpc-capabilities) was getting an unscoped 'Ranked #1 on Fastest free public RPC' badge even when they only led the rankings on a subset of chains. Embedders broadcasting that badge would parrot an inaccurate claim and torch the bench's neutrality positioning the first time a competitor diffed the per-chain ranks. New logic in /products/[slug] Embeddable badges section: - Benches without chainDimensions: keep one global aggregate badge per #1 finish (unchanged behavior). - Benches with chainDimensions where the provider leads on every declared chain: still one global badge. A true cross-chain leader earns the unscoped claim. - Benches with chainDimensions where the provider leads on a subset only: drop the global badge entirely, emit one badge PER chain led using the existing ?chain= query param. Each card's title, alt text, HTML / Markdown snippets and X intent tweet include the chain label so the claim downstream is honest. The /api/badge/[slug]/[provider]?chain= endpoint already supported the per-chain rank computation. This PR just stops the product page from generating the misleading unscoped URL when a chain-restricted leader exists. --- src/app/products/[slug]/page.tsx | 86 ++++++++++++++++++++++++++++---- 1 file changed, 77 insertions(+), 9 deletions(-) diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index f84554ed..18203784 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -403,7 +403,69 @@ export default async function ProviderPage({ stays accurate without redeploying.

      - {sorted.filter((a) => a.rank === 1).map((a) => { + {(() => { + // Generate the list of badge cards to render. For benches WITHOUT + // chain dimensions, the global aggregate badge is honest and we + // emit one card per #1 finish. For benches WITH chain dimensions + // we walk rankPerChain instead: + // - If the provider leads on EVERY declared chain (and the + // aggregate #1 confirms it), we keep a single global badge. + // A true cross-chain leader gets the unscoped claim. + // - Otherwise we emit one badge PER chain the provider leads + // and SKIP the global aggregate badge entirely. Without this + // guard a provider that wins one chain out of ten still + // shows a misleading "#1 on Fastest free public RPC" badge + // even though they trail elsewhere. Embedders broadcasting + // that badge would be parroting an inaccurate claim, which + // is the exact reputation hit we built the bench to avoid. + type BadgeCard = { + key: string; + title: string; + chain?: { value: string; label: string }; + benchSlug: string; + }; + const cards: BadgeCard[] = []; + for (const a of sorted) { + const chainDims = a.benchmark.chainDimensions ?? []; + const realChains = chainDims.filter((c) => c.value !== "all"); + const perChain = a.rankPerChain ?? {}; + const wonChains = realChains.filter( + (c) => perChain[c.value]?.rank === 1, + ); + const hasChainDims = realChains.length > 0; + const isGlobalNumberOne = a.rank === 1; + if (!hasChainDims) { + if (isGlobalNumberOne) { + cards.push({ + key: a.benchmark.slug, + title: a.benchmark.title, + benchSlug: a.benchmark.slug, + }); + } + continue; + } + const leadsAllChains = + realChains.length > 0 && + wonChains.length === realChains.length; + if (isGlobalNumberOne && leadsAllChains) { + cards.push({ + key: a.benchmark.slug, + title: a.benchmark.title, + benchSlug: a.benchmark.slug, + }); + continue; + } + for (const c of wonChains) { + cards.push({ + key: `${a.benchmark.slug}-${c.value}`, + title: a.benchmark.title, + chain: c, + benchSlug: a.benchmark.slug, + }); + } + } + return cards; + })().map((card) => { // Absolute URL is the one shipped to embedders (it has to // work from any third-party origin), but the in-page preview // uses a relative path so it loads under the current @@ -411,28 +473,34 @@ export default async function ProviderPage({ // shows the browser's broken-image glyph on every non-prod // origin (staging Preview URLs, Vercel branch previews, etc.) // because the CSP refuses the cross-origin fetch. - const badgePath = `/api/badge/${a.benchmark.slug}/${p.slug}`; + const qs = card.chain ? `?chain=${encodeURIComponent(card.chain.value)}` : ""; + const badgePath = `/api/badge/${card.benchSlug}/${p.slug}${qs}`; const badgeUrl = `${SITE.url}${badgePath}`; - const targetUrl = `${SITE.url}/benchmarks/${a.benchmark.slug}`; - const html = `Ranked #1 on OpenChainBench: ${a.benchmark.title}`; - const markdown = `[![Ranked #1 on OpenChainBench: ${a.benchmark.title}](${badgeUrl})](${targetUrl})`; + const targetUrl = `${SITE.url}/benchmarks/${card.benchSlug}${qs}`; + const scopeSuffix = card.chain ? ` on ${card.chain.label}` : ""; + const cardTitle = card.chain + ? `${card.title} — ${card.chain.label}` + : card.title; + const altText = `Ranked #1 on OpenChainBench: ${card.title}${scopeSuffix}`; + const html = `${altText}`; + const markdown = `[![${altText}](${badgeUrl})](${targetUrl})`; // Pre-baked X intent. Providers click → tweet draft opens // with the ranking claim, the bench URL and the OCB handle // already filled in. Removes the friction of writing the // post themselves and gives us the canonical anchor text // back as a tagged tweet on every share. - const tweetText = `Independently benchmarked #1 on ${a.benchmark.title} by @OpenChainBench.\n\nReproducible methodology, live data:`; + const tweetText = `Independently benchmarked #1 on ${card.title}${scopeSuffix} by @OpenChainBench.\n\nReproducible methodology, live data:`; const tweetIntent = `https://x.com/intent/tweet?text=${encodeURIComponent(tweetText)}&url=${encodeURIComponent(targetUrl)}`; return ( -
    • +
    • - {a.benchmark.title} + {cardTitle}

      {/* eslint-disable-next-line @next/next/no-img-element */} {`Ranked Date: Wed, 10 Jun 2026 12:53:39 +0200 Subject: [PATCH 4/9] feat(badge): redesign with spinning 3d brand mark --- src/app/api/badge/[slug]/[provider]/route.ts | 68 ++++-- src/app/products/[slug]/page.tsx | 208 ++++++++++++------- 2 files changed, 184 insertions(+), 92 deletions(-) diff --git a/src/app/api/badge/[slug]/[provider]/route.ts b/src/app/api/badge/[slug]/[provider]/route.ts index d54fbfa7..4890d37c 100644 --- a/src/app/api/badge/[slug]/[provider]/route.ts +++ b/src/app/api/badge/[slug]/[provider]/route.ts @@ -33,11 +33,12 @@ export const revalidate = 300; type Params = { slug: string; provider: string }; -const H = 36; +const H = 44; // Width is fixed but generous so most benchmark titles fit without // truncation. Anything over ~32 chars gets ellipsis. const W = 360; -const LEFT_W = 78; +// Text column starts right of the spinning logo sphere. +const TEXT_X = 48; const TITLE_MAX = 32; function rankOf( @@ -187,32 +188,55 @@ export async function GET( const suffix = valueSuffix(b.unit); const title = truncate(b.title, TITLE_MAX); - // Provider initials in the bottom-left corner. mirrors the brand - // chip the site uses internally. - const ocbBrand = "OCB"; - - // Scope marker: rendered as a small subscript next to the rank when - // present. Keeps the badge layout stable when absent (most benches). + // Scope marker: small caps tspan appended to the figure line, so the + // badge height stays constant whether or not a scope is present. const scopeAriaSuffix = scopeLabel ? ` (${scopeLabel})` : ""; - const scopeSvg = scopeLabel - ? `${escapeXml(scopeLabel.toUpperCase())}` + const scopeTspan = scopeLabel + ? `${escapeXml(scopeLabel.toUpperCase())}` : ""; - // Bump the SVG canvas height when a scope label is rendered so the - // subscript doesn't clip outside the box on stricter image renderers. - const svgH = scopeLabel ? H + 8 : H; + // The spinning mark: the dark-mode brand sphere (near-black skin, + // light C-ring + grey corner markers, same geometry as site-logo.tsx) + // doing a continuous 360 around its vertical axis. The spin is a CSS + // coin-flip (scaleX 1 -> 0 -> 1); while scaleX is 0 the plain dark + // sphere shows, which is exactly what the back of the masthead sphere + // looks like. CSS animations inside SVG run in embeds, and the + // reduced-motion media query freezes it for users who opted out. + // Static renderers without CSS support just show the un-rotated mark. const svg = ` - + OpenChainBench. ${escapeXml(b.title)}. ${rankLabel}${scopeAriaSuffix}, ${value} ${suffix} - - - + + + + + + + + + + + + + + + + + + + + + + + - ${ocbBrand} - ${rankLabel} - ${escapeXml(title)} - ${value} ${suffix} - ${scopeSvg} + ${rankLabel}${escapeXml(title)} + ${value} ${suffix}${scopeTspan} + OCB `; diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index 18203784..009c86c5 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -81,6 +81,130 @@ export default async function ProviderPage({ return a.benchmark.title.localeCompare(b.benchmark.title); }); + // Embeddable badge cards. Scope rules, most exact source first: + // + // 1. Benches with a `rank_matrix_query` AND region dimensions use the + // exact per-cell rankings (chain × region). The per-chain ranks the + // legacy path relies on are cross-region averages: a provider that + // only wins from Singapore (dRPC) still reads as the chain leader + // because one fast region drags the mean down. Cells fix that: + // - leads EVERY cell → one unscoped global badge. + // - leads a full chain row (all regions) → one "on " badge. + // - leads a full region column (all chains) → one "from " + // badge (skipped when its cells are already claimed by rows). + // - leftover isolated cells → "on from " badges. + // 2. Benches with chain dimensions but no cell data keep the per-chain + // logic: global badge only for a true cross-chain leader, otherwise + // one badge per chain led. + // 3. Benches without dimensions: one global badge per aggregate #1. + type BadgeCard = { + key: string; + title: string; + chain?: { value: string; label: string }; + region?: { value: string; label: string }; + benchSlug: string; + }; + const badgeCards: BadgeCard[] = []; + for (const a of sorted) { + const chainDims = (a.benchmark.chainDimensions ?? []).filter( + (c) => c.value !== "all", + ); + const regionDims = (a.benchmark.regionDimensions ?? []).filter( + (r) => r.value !== "all", + ); + const cellRanks = a.benchmark.cellRanks; + const me = a.result.slug.toLowerCase(); + const benchSlug = a.benchmark.slug; + const title = a.benchmark.title; + + let handledByCells = false; + if (cellRanks && regionDims.length > 0) { + const finestKeys = Object.keys(cellRanks).filter((k) => { + const [c, r] = k.split("|"); + const chainOk = chainDims.length > 0 ? c !== "all" : c === "all"; + return chainOk && r !== "all"; + }); + if (finestKeys.length > 0) { + handledByCells = true; + const wonKeys = new Set( + finestKeys.filter( + (k) => cellRanks[k][0]?.slug.toLowerCase() === me, + ), + ); + if (wonKeys.size === finestKeys.length) { + badgeCards.push({ key: benchSlug, title, benchSlug }); + continue; + } + if (wonKeys.size === 0) continue; + const chainOf = (k: string) => k.split("|")[0]; + const regionOf = (k: string) => k.split("|")[1]; + const covered = new Set(); + for (const c of chainDims) { + const row = finestKeys.filter((k) => chainOf(k) === c.value); + if (row.length === 0 || !row.every((k) => wonKeys.has(k))) continue; + badgeCards.push({ + key: `${benchSlug}-${c.value}`, + title, + chain: c, + benchSlug, + }); + for (const k of row) covered.add(k); + } + for (const r of regionDims) { + const col = finestKeys.filter((k) => regionOf(k) === r.value); + if (col.length === 0 || !col.every((k) => wonKeys.has(k))) continue; + if (col.every((k) => covered.has(k))) continue; + badgeCards.push({ + key: `${benchSlug}-r-${r.value}`, + title, + region: r, + benchSlug, + }); + for (const k of col) covered.add(k); + } + for (const k of wonKeys) { + if (covered.has(k)) continue; + const chain = chainDims.find((c) => c.value === chainOf(k)); + const region = regionDims.find((r) => r.value === regionOf(k)); + if (!region) continue; + badgeCards.push({ + key: `${benchSlug}-${chainOf(k)}-${regionOf(k)}`, + title, + ...(chain ? { chain } : {}), + region, + benchSlug, + }); + } + continue; + } + } + if (handledByCells) continue; + + const perChain = a.rankPerChain ?? {}; + const wonChains = chainDims.filter((c) => perChain[c.value]?.rank === 1); + const isGlobalNumberOne = a.rank === 1; + if (chainDims.length === 0) { + if (isGlobalNumberOne) { + badgeCards.push({ key: benchSlug, title, benchSlug }); + } + continue; + } + const leadsAllChains = + chainDims.length > 0 && wonChains.length === chainDims.length; + if (isGlobalNumberOne && leadsAllChains) { + badgeCards.push({ key: benchSlug, title, benchSlug }); + continue; + } + for (const c of wonChains) { + badgeCards.push({ + key: `${benchSlug}-${c.value}`, + title, + chain: c, + benchSlug, + }); + } + } + const url = `${SITE.url}/products/${p.slug}`; const sameAs: string[] = []; if (reg?.url) sameAs.push(reg.url); @@ -392,7 +516,7 @@ export default async function ProviderPage({ - {p.wins > 0 && ( + {badgeCards.length > 0 && (

      Embeddable badges @@ -403,69 +527,7 @@ export default async function ProviderPage({ stays accurate without redeploying.

        - {(() => { - // Generate the list of badge cards to render. For benches WITHOUT - // chain dimensions, the global aggregate badge is honest and we - // emit one card per #1 finish. For benches WITH chain dimensions - // we walk rankPerChain instead: - // - If the provider leads on EVERY declared chain (and the - // aggregate #1 confirms it), we keep a single global badge. - // A true cross-chain leader gets the unscoped claim. - // - Otherwise we emit one badge PER chain the provider leads - // and SKIP the global aggregate badge entirely. Without this - // guard a provider that wins one chain out of ten still - // shows a misleading "#1 on Fastest free public RPC" badge - // even though they trail elsewhere. Embedders broadcasting - // that badge would be parroting an inaccurate claim, which - // is the exact reputation hit we built the bench to avoid. - type BadgeCard = { - key: string; - title: string; - chain?: { value: string; label: string }; - benchSlug: string; - }; - const cards: BadgeCard[] = []; - for (const a of sorted) { - const chainDims = a.benchmark.chainDimensions ?? []; - const realChains = chainDims.filter((c) => c.value !== "all"); - const perChain = a.rankPerChain ?? {}; - const wonChains = realChains.filter( - (c) => perChain[c.value]?.rank === 1, - ); - const hasChainDims = realChains.length > 0; - const isGlobalNumberOne = a.rank === 1; - if (!hasChainDims) { - if (isGlobalNumberOne) { - cards.push({ - key: a.benchmark.slug, - title: a.benchmark.title, - benchSlug: a.benchmark.slug, - }); - } - continue; - } - const leadsAllChains = - realChains.length > 0 && - wonChains.length === realChains.length; - if (isGlobalNumberOne && leadsAllChains) { - cards.push({ - key: a.benchmark.slug, - title: a.benchmark.title, - benchSlug: a.benchmark.slug, - }); - continue; - } - for (const c of wonChains) { - cards.push({ - key: `${a.benchmark.slug}-${c.value}`, - title: a.benchmark.title, - chain: c, - benchSlug: a.benchmark.slug, - }); - } - } - return cards; - })().map((card) => { + {badgeCards.map((card) => { // Absolute URL is the one shipped to embedders (it has to // work from any third-party origin), but the in-page preview // uses a relative path so it loads under the current @@ -473,16 +535,22 @@ export default async function ProviderPage({ // shows the browser's broken-image glyph on every non-prod // origin (staging Preview URLs, Vercel branch previews, etc.) // because the CSP refuses the cross-origin fetch. - const qs = card.chain ? `?chain=${encodeURIComponent(card.chain.value)}` : ""; + const scopeParams = new URLSearchParams(); + if (card.chain) scopeParams.set("chain", card.chain.value); + if (card.region) scopeParams.set("region", card.region.value); + const qs = scopeParams.size > 0 ? `?${scopeParams.toString()}` : ""; const badgePath = `/api/badge/${card.benchSlug}/${p.slug}${qs}`; const badgeUrl = `${SITE.url}${badgePath}`; const targetUrl = `${SITE.url}/benchmarks/${card.benchSlug}${qs}`; - const scopeSuffix = card.chain ? ` on ${card.chain.label}` : ""; - const cardTitle = card.chain - ? `${card.title} — ${card.chain.label}` + const scopeSuffix = `${card.chain ? ` on ${card.chain.label}` : ""}${card.region ? ` from ${card.region.label}` : ""}`; + const scopeLabels = [card.chain?.label, card.region?.label] + .filter(Boolean) + .join(" · "); + const cardTitle = scopeLabels + ? `${card.title} — ${scopeLabels}` : card.title; const altText = `Ranked #1 on OpenChainBench: ${card.title}${scopeSuffix}`; - const html = `${altText}`; + const html = `${altText}`; const markdown = `[![${altText}](${badgeUrl})](${targetUrl})`; // Pre-baked X intent. Providers click → tweet draft opens // with the ranking claim, the bench URL and the OCB handle @@ -501,7 +569,7 @@ export default async function ProviderPage({ {altText} From ce5175ba58f1c193c7dc865c39bd85121dea4375 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Wed, 10 Jun 2026 13:08:25 +0200 Subject: [PATCH 5/9] feat(seo): dedicated per-chain bench pages, drop duplicate ?chain= sitemap URLs --- src/app/benchmarks/[slug]/[chain]/page.tsx | 339 +++++++++++++++++++++ src/app/sitemap.ts | 30 +- src/components/chain-headings-summary.tsx | 15 +- 3 files changed, 367 insertions(+), 17 deletions(-) create mode 100644 src/app/benchmarks/[slug]/[chain]/page.tsx diff --git a/src/app/benchmarks/[slug]/[chain]/page.tsx b/src/app/benchmarks/[slug]/[chain]/page.tsx new file mode 100644 index 00000000..d8b26571 --- /dev/null +++ b/src/app/benchmarks/[slug]/[chain]/page.tsx @@ -0,0 +1,339 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft, ArrowUpRight } from "lucide-react"; +import { getBenchmark, getBenchmarks } from "@/data/benchmarks"; +import { liveResults } from "@/lib/provider-filters"; +import { fmtUnit } from "@/lib/format"; +import { capDescription } from "@/lib/seo-text"; +import { SITE } from "@/data/site"; +import { safeJsonLd } from "@/lib/jsonld"; +import { CATEGORY_COLOR } from "@/lib/category-colors"; +import type { Benchmark, ProviderResult } from "@/types/benchmark"; + +// Dedicated per-chain landing pages. Only chains that have a hand-written +// `per_chain_explainer` entry in the bench YAML get a route here - that +// unique editorial body is what keeps these pages from being doorway +// duplicates of the parent bench. The parent's `?chain=` query filter +// stays a client-side UI affordance; THIS route is the indexable, +// self-canonical document targeting long-tail queries like "ethereum +// finality time". +export const revalidate = 60; + +type Params = { slug: string; chain: string }; + +export async function generateStaticParams() { + const benchmarks = await getBenchmarks(); + return benchmarks.flatMap((b) => { + const resultSlugs = new Set(b.results.map((r) => r.slug)); + return (b.perChainExplainer ?? []) + .filter((e) => resultSlugs.has(e.slug)) + .map((e) => ({ slug: b.slug, chain: e.slug })); + }); +} + +type ChainPageData = { + benchmark: Benchmark; + explainer: { slug: string; h2: string; body: string }; + result: ProviderResult; + sorted: ProviderResult[]; + rank: number; +}; + +async function loadChainPage( + slug: string, + chain: string, +): Promise { + const benchmark = await getBenchmark(slug); + if (!benchmark) return null; + const explainer = (benchmark.perChainExplainer ?? []).find( + (e) => e.slug === chain, + ); + if (!explainer) return null; + const result = benchmark.results.find((r) => r.slug === chain); + if (!result) return null; + const live = liveResults(benchmark.results); + const sorted = [...live].sort((a, b) => + benchmark.higherIsBetter ? b.ms.p50 - a.ms.p50 : a.ms.p50 - b.ms.p50, + ); + const rank = sorted.findIndex((r) => r.slug === chain) + 1; + return { benchmark, explainer, result, sorted, rank }; +} + +/** Meta descriptions must not leak inline markdown from the YAML body + * (backticks around RPC method names, bold, links). */ +function stripInlineMarkdown(text: string): string { + return text + .replace(/`([^`]*)`/g, "$1") + .replace(/\*\*([^*]*)\*\*/g, "$1") + .replace(/\*([^*]*)\*/g, "$1") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/\s+/g, " ") + .trim(); +} + +function asOfDate(lastRunAt: string | undefined): string { + const d = lastRunAt ? new Date(lastRunAt) : new Date(); + return d.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +export async function generateMetadata({ + params, +}: { + params: Promise; +}): Promise { + const { slug, chain } = await params; + const data = await loadChainPage(slug, chain); + if (!data) return {}; + const { benchmark, explainer, result } = data; + const value = fmtUnit(result.ms.p50, benchmark.unit); + const title = `${explainer.h2}: ${value} p50 live`; + const description = capDescription( + stripInlineMarkdown(explainer.body), + 158, + ); + const canonical = `${SITE.url}/benchmarks/${benchmark.slug}/${chain}`; + const ogImage = `${SITE.url}/api/og/${benchmark.slug}`; + return { + title, + description, + alternates: { canonical }, + openGraph: { + title, + description, + type: "article", + url: canonical, + images: [ogImage], + }, + twitter: { + card: "summary_large_image", + title, + description, + images: [ogImage], + }, + }; +} + +export default async function BenchmarkChainPage({ + params, +}: { + params: Promise; +}) { + const { slug, chain } = await params; + const data = await loadChainPage(slug, chain); + if (!data) notFound(); + const { benchmark, explainer, result, sorted, rank } = data; + + const benchmarkUrl = `${SITE.url}/benchmarks/${benchmark.slug}`; + const pageUrl = `${benchmarkUrl}/${chain}`; + const catColor = CATEGORY_COLOR[benchmark.category]; + const p50 = fmtUnit(result.ms.p50, benchmark.unit); + const p90 = fmtUnit(result.ms.p90, benchmark.unit); + const p99 = fmtUnit(result.ms.p99, benchmark.unit); + const explainerSlugs = new Set( + (benchmark.perChainExplainer ?? []).map((e) => e.slug), + ); + + // Dated, citable key-facts sentence. LLM crawlers and journalists quote + // stats that carry an explicit date + source; featured snippets prefer + // the same shape. + const keyFacts = + `As of ${asOfDate(benchmark.lastRunAt)}, ${explainer.h2.toLowerCase()} is ${p50} at the median (p50, 24h window), with ${p90} at p90 and ${p99} at p99.` + + (rank > 0 + ? ` ${result.name} ranks #${rank} of ${sorted.length} chains measured on this benchmark.` + : ""); + + const jsonLd = { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "TechArticle", + "@id": `${pageUrl}#article`, + headline: explainer.h2, + description: capDescription(stripInlineMarkdown(explainer.body), 158), + url: pageUrl, + mainEntityOfPage: pageUrl, + articleBody: `${keyFacts} ${stripInlineMarkdown(explainer.body)}`, + image: `${SITE.url}/api/og/${benchmark.slug}`, + dateModified: benchmark.lastRunAt, + author: { "@id": `${SITE.url}/#org` }, + publisher: { "@id": `${SITE.url}/#org` }, + about: { "@id": `${benchmarkUrl}#dataset` }, + isPartOf: { "@id": `${benchmarkUrl}#article` }, + }, + { + "@type": "BreadcrumbList", + itemListElement: [ + { "@type": "ListItem", position: 1, name: "Home", item: SITE.url }, + { + "@type": "ListItem", + position: 2, + name: "Benchmarks", + item: `${SITE.url}/benchmarks`, + }, + { + "@type": "ListItem", + position: 3, + name: benchmark.title, + item: benchmarkUrl, + }, + { + "@type": "ListItem", + position: 4, + name: result.name, + item: pageUrl, + }, + ], + }, + ], + }; + + return ( +
        +