From 4a1637b864b1382a594c78ece744d5327388d23f Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Wed, 10 Jun 2026 15:20:08 +0200 Subject: [PATCH] feat(seo): per-chain pages v2 for dimension benches + rpc-capabilities explainers --- benchmarks/rpc-capabilities.yml | 46 ++ src/app/benchmarks/[slug]/[chain]/page.tsx | 503 ++++++++++++++++----- src/app/benchmarks/[slug]/page.tsx | 40 ++ src/app/sitemap.ts | 7 +- 4 files changed, 472 insertions(+), 124 deletions(-) diff --git a/benchmarks/rpc-capabilities.yml b/benchmarks/rpc-capabilities.yml index ea579bac..43afb5d5 100644 --- a/benchmarks/rpc-capabilities.yml +++ b/benchmarks/rpc-capabilities.yml @@ -12,6 +12,52 @@ metric: RPC latency unit: ms higher_is_better: false +# Per-chain landing pages (/benchmarks/rpc-capabilities/). Each +# entry must keep its claims chain-scoped: the leader placeholder +# resolves against bestPerChain, never the cross-chain aggregate, and +# the page itself surfaces per-region leaders when they diverge. +per_chain_explainer: + - slug: ethereum + h2: "Fastest free Ethereum RPC" + body: | + {{best_name:chain:ethereum}} currently leads the free, no-key Ethereum RPC field at {{best_p50:chain:ethereum}} (`eth_blockNumber` p50, 24h), measured against 9 providers, the largest cohort in this bench. Ethereum is also where the silent-failure analysis earns its keep: Cloudflare-eth answers HTTP 200 with a JSON-RPC error field on many methods, and Merkle is excluded after recurring Cloudflare lockouts. Probes run every 15 seconds from us-east, eu-west and Singapore. + - slug: base + h2: "Fastest free Base RPC" + body: | + Coinbase's own `mainnet.base.org` goes head-to-head with PublicNode, dRPC, Tenderly and Merkle on Base, an unusually clean comparison because the chain-official endpoint is operated by the same team that runs the sequencer. The current leader is {{best_name:chain:base}} at {{best_p50:chain:base}} (`eth_blockNumber` p50, 24h) across 6 providers, probed every 15 seconds from three regions with stale-head detection against the cross-provider tip. + - slug: bnb + h2: "Fastest free BNB Chain RPC" + body: | + Binance's `bsc-dataseed1` is the incumbent default on BNB Chain, but PublicNode, dRPC and Merkle have closed the latency gap from EU origins. The current leader is {{best_name:chain:bnb}} at {{best_p50:chain:bnb}} (`eth_blockNumber` p50, 24h) across 5 providers. Every endpoint is probed with the identical call every 15 seconds from us-east, eu-west and Singapore, so the ranking reflects sustained round-trip latency, not a one-off burst. + - slug: arbitrum + h2: "Fastest free Arbitrum RPC" + body: | + Arbitrum carries the second-largest cohort in this bench, 8 no-key providers, and is one of the few chains where Lava and MeowRPC compete alongside PublicNode and the Arbitrum Foundation's own endpoint. The current leader is {{best_name:chain:arbitrum}} at {{best_p50:chain:arbitrum}} (`eth_blockNumber` p50, 24h). Latency is sampled every 15 seconds from three regions; archive-depth checks flag endpoints that serve pruned state as non-archive. + - slug: optimism + h2: "Fastest free Optimism RPC" + body: | + Optimism's field pits the Optimism Foundation endpoint against 5 multi-chain gateways. The current leader is {{best_name:chain:optimism}} at {{best_p50:chain:optimism}} (`eth_blockNumber` p50, 24h) across 6 providers. As on every chain here, the harness classifies each response (`ok`, `http_err`, `jsonrpc_err`, `stale`, `timeout`) so an endpoint stuck on an old head is never ranked as fastest, and probes originate from us-east, eu-west and Singapore. + - slug: avalanche + h2: "Fastest free Avalanche RPC" + body: | + Avalanche's chain-official endpoint competes with 5 no-key multi-chain gateways for the C-Chain. The current leader is {{best_name:chain:avalanche}} at {{best_p50:chain:avalanche}} (`eth_blockNumber` p50, 24h) across 6 providers. The probe is the same single call every 15 seconds from three regions, with stale-head detection flagging any provider more than 20 blocks behind the cross-provider tip. + - slug: polygon + h2: "Fastest free Polygon RPC" + body: | + Polygon has no chain-official endpoint in this bench, so the comparison is purely between multi-chain no-key gateways. The current leader is {{best_name:chain:polygon}} at {{best_p50:chain:polygon}} (`eth_blockNumber` p50, 24h) across 5 providers. Each one answers the identical call every 15 seconds from us-east, eu-west and Singapore, and the result classification separates real latency from silent JSON-RPC failures behind an HTTP 200. + - slug: linea + h2: "Fastest free Linea RPC" + body: | + The no-key field thins out on Linea: 4 providers qualify, all multi-chain gateways. The current leader is {{best_name:chain:linea}} at {{best_p50:chain:linea}} (`eth_blockNumber` p50, 24h). Thinner competition makes the reliability columns matter more than raw speed, a fast endpoint with a high `stale` or `timeout` rate is a worse default than a slightly slower consistent one. Probes run every 15 seconds from three regions. + - slug: scroll + h2: "Fastest free Scroll RPC" + body: | + Scroll is one of the smallest cohorts in this bench, 4 no-key providers, all multi-chain gateways. The current leader is {{best_name:chain:scroll}} at {{best_p50:chain:scroll}} (`eth_blockNumber` p50, 24h). The harness runs the identical probe every 15 seconds from us-east, eu-west and Singapore, with stale-head detection against the cross-provider tip so a frozen endpoint cannot top the table. + - slug: mantle + h2: "Fastest free Mantle RPC" + body: | + Mantle rounds out the long tail with 4 qualifying no-key providers, all multi-chain gateways. The current leader is {{best_name:chain:mantle}} at {{best_p50:chain:mantle}} (`eth_blockNumber` p50, 24h). Like every chain in this bench the number is a sustained median, the same call every 15 seconds from three regions over a rolling 24 hours, not a marketing burst, and archive-depth support is audited separately. + seo_intro: | This benchmark answers the question every developer reaching for a free public RPC asks before pasting a URL into their dapp. which diff --git a/src/app/benchmarks/[slug]/[chain]/page.tsx b/src/app/benchmarks/[slug]/[chain]/page.tsx index 35dc5faa..c49e67be 100644 --- a/src/app/benchmarks/[slug]/[chain]/page.tsx +++ b/src/app/benchmarks/[slug]/[chain]/page.tsx @@ -17,51 +17,92 @@ import type { Benchmark, ProviderResult } from "@/types/benchmark"; // 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". +// finality time" or "fastest free polygon rpc". +// +// Two bench shapes resolve here: +// - "row": the chain IS a leaderboard row (l1-finality, where +// results are chains). Page shows the chain's measured +// value + rank against sibling chains. +// - "dimension": the chain is a filter dimension (rpc-capabilities, +// where results are providers probed per chain). Page +// shows the provider leaderboard scoped to that chain, +// with per-region leaders called out when they differ - +// a provider that wins on 6 of 10 chains in one region +// must never be presented as the global winner. export const revalidate = 60; type Params = { slug: string; chain: string }; +function explainerChains(b: Benchmark): string[] { + const resultSlugs = new Set(b.results.map((r) => r.slug)); + const chainValues = new Set( + (b.dimensions?.chain ?? []) + .map((c) => c.value) + .filter((v) => v.toLowerCase() !== "all"), + ); + return (b.perChainExplainer ?? []) + .map((e) => e.slug) + .filter((s) => resultSlugs.has(s) || chainValues.has(s)); +} + 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 })); - }); + return benchmarks.flatMap((b) => + explainerChains(b).map((chain) => ({ slug: b.slug, chain })), + ); } -type ChainPageData = { +type Explainer = { slug: string; h2: string; body: string }; + +type RowShape = { + shape: "row"; benchmark: Benchmark; - explainer: { slug: string; h2: string; body: string }; + explainer: Explainer; result: ProviderResult; sorted: ProviderResult[]; rank: number; }; +type DimensionShape = { + shape: "dimension"; + benchmark: Benchmark; + explainer: Explainer; + chainLabel: string; + providers: ProviderResult[]; + leader: ProviderResult | null; + regionLeaders: { region: string; leader: ProviderResult }[]; +}; + +type ChainPageData = RowShape | DimensionShape; + /** The spec loader's renderTemplate only resolves placeholders for - * providers that are "live" this cycle. When a chain's data is in a - * transient gap, raw `{{p50:ethereum}}` tokens would leak into the H1 - * copy and the meta description. Resolve leftovers against the full - * results array (the row always exists - generateStaticParams gates on - * it), so the page degrades to the last scraped value instead of - * shipping template syntax to the SERP. */ + * providers that are "live" this cycle. When data is in a transient + * gap, raw `{{p50:ethereum}}` / `{{best_name:chain:base}}` tokens + * would leak into the H1 copy and the meta description. Resolve what + * we can against the full results array, degrade the rest to neutral + * copy so template syntax never ships to the SERP. */ function resolveLeftoverPlaceholders(text: string, b: Benchmark): string { - return text.replace( - /\{\{\s*(p50|p90|p99|mean|name):([a-z0-9-]+)\s*\}\}/gi, - (whole, keyword: string, slug: string) => { - const row = b.results.find( - (r) => r.slug.toLowerCase() === slug.toLowerCase(), - ); - const k = keyword.toLowerCase(); - if (k === "name") return row ? row.name : whole; - const raw = row?.ms[k as "p50" | "p90" | "p99" | "mean"]; - // Transient data gap: degrade to neutral copy ("... is measured - // live (p50, 24h)") rather than shipping template syntax. - if (!raw || raw <= 0) return "measured live"; - return fmtUnit(raw, b.unit); - }, + return text + .replace( + /\{\{\s*(p50|p90|p99|mean|name):([a-z0-9-]+)\s*\}\}/gi, + (whole, keyword: string, slug: string) => { + const row = b.results.find( + (r) => r.slug.toLowerCase() === slug.toLowerCase(), + ); + const k = keyword.toLowerCase(); + if (k === "name") return row ? row.name : whole; + const raw = row?.ms[k as "p50" | "p90" | "p99" | "mean"]; + if (!raw || raw <= 0) return "measured live"; + return fmtUnit(raw, b.unit); + }, + ) + .replace(/\{\{\s*(best_name|worst_name):chain:[a-z0-9_-]+\s*\}\}/gi, "the live leader") + .replace(/\{\{\s*(best_p50|worst_p50):chain:[a-z0-9_-]+\s*\}\}/gi, "measured live"); +} + +function sortLive(results: ProviderResult[], higherIsBetter: boolean) { + return [...liveResults(results)].sort((a, b) => + higherIsBetter ? b.ms.p50 - a.ms.p50 : a.ms.p50 - b.ms.p50, ); } @@ -75,19 +116,59 @@ async function loadChainPage( (e) => e.slug === chain, ); if (!found) return null; - const result = benchmark.results.find((r) => r.slug === chain); - if (!result) return null; const explainer = { ...found, h2: resolveLeftoverPlaceholders(found.h2, benchmark), body: resolveLeftoverPlaceholders(found.body, benchmark), }; - 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, + + // Shape 1: the chain is a leaderboard row (l1-finality). + const result = benchmark.results.find((r) => r.slug === chain); + if (result) { + const sorted = sortLive(benchmark.results, benchmark.higherIsBetter); + const rank = sorted.findIndex((r) => r.slug === chain) + 1; + return { shape: "row", benchmark, explainer, result, sorted, rank }; + } + + // Shape 2: the chain is a filter dimension (rpc-capabilities). + const chainOption = (benchmark.dimensions?.chain ?? []).find( + (c) => c.value === chain && c.value.toLowerCase() !== "all", ); - const rank = sorted.findIndex((r) => r.slug === chain) + 1; - return { benchmark, explainer, result, sorted, rank }; + if (!chainOption) return null; + const scoped = (await getBenchmark(slug, { chain })) ?? benchmark; + const providers = sortLive(scoped.results, benchmark.higherIsBetter); + const leader = providers[0] ?? null; + + // Per-region leaders on this chain. The cross-region scoped leaderboard + // is the headline, but when regional winners diverge the key-facts line + // says so explicitly instead of crowning one provider globally. + const regions = (benchmark.dimensions?.region ?? []).filter( + (r) => r.value.toLowerCase() !== "all", + ); + const regionLeaders: { region: string; leader: ProviderResult }[] = []; + if (regions.length > 0) { + const variants = await Promise.all( + regions.map(async (r) => ({ + label: r.label, + bench: await getBenchmark(slug, { chain, region: r.value }), + })), + ); + for (const v of variants) { + if (!v.bench) continue; + const lead = sortLive(v.bench.results, benchmark.higherIsBetter)[0]; + if (lead) regionLeaders.push({ region: v.label, leader: lead }); + } + } + + return { + shape: "dimension", + benchmark, + explainer, + chainLabel: chainOption.label, + providers, + leader, + regionLeaders, + }; } /** Meta descriptions must not leak inline markdown from the YAML body @@ -111,6 +192,76 @@ function asOfDate(lastRunAt: string | undefined): string { }); } +/** "dRPC in US-East and EU-West; PublicNode in Singapore." Groups + * regions by their leading provider, preserving region order. */ +function regionLeaderSentence( + regionLeaders: { region: string; leader: ProviderResult }[], +): string | null { + if (regionLeaders.length < 2) return null; + const names = new Set(regionLeaders.map((r) => r.leader.name)); + if (names.size === 1) { + return `${regionLeaders[0].leader.name} leads in all ${regionLeaders.length} regions measured.`; + } + const byLeader = new Map(); + for (const r of regionLeaders) { + const list = byLeader.get(r.leader.name) ?? []; + list.push(r.region); + byLeader.set(r.leader.name, list); + } + const parts = [...byLeader.entries()].map( + ([name, regions]) => `${name} in ${regions.join(" and ")}`, + ); + return `Regional leaders differ: ${parts.join("; ")}.`; +} + +/** Mid-sentence metric phrase: "Finality time" -> "finality time" but + * "RPC latency" stays as-is (leading acronym must not become "rpc"). */ +function metricPhrase(metric: string): string { + return /^[A-Z]{2}/.test(metric) + ? metric + : metric.charAt(0).toLowerCase() + metric.slice(1); +} + +function buildKeyFacts(data: ChainPageData): string { + const b = data.benchmark; + const date = asOfDate(b.lastRunAt); + if (data.shape === "row") { + const { result, sorted, rank } = data; + const subject = `${result.name} ${metricPhrase(b.metric)}`; + if (result.ms.p50 <= 0) { + return `${subject} is measured continuously on this benchmark. Live numbers will appear here as soon as the harness reports fresh samples.`; + } + const p50 = fmtUnit(result.ms.p50, b.unit); + const p90 = fmtUnit(result.ms.p90, b.unit); + const p99 = fmtUnit(result.ms.p99, b.unit); + return ( + `As of ${date}, ${subject} 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 { leader, providers, chainLabel, regionLeaders } = data; + if (!leader) { + return `${chainLabel} ${metricPhrase(b.metric)} is measured continuously on this benchmark. Live numbers will appear here as soon as the harness reports fresh samples.`; + } + const base = `As of ${date}, ${leader.name} leads ${chainLabel} ${metricPhrase(b.metric)} at ${fmtUnit(leader.ms.p50, b.unit)} (p50, 24h window), measured across ${providers.length} providers.`; + const regional = regionLeaderSentence(regionLeaders); + return regional ? `${base} ${regional}` : base; +} + +function pageTitle(data: ChainPageData): string { + const b = data.benchmark; + if (data.shape === "row") { + return data.result.ms.p50 > 0 + ? `${data.explainer.h2}: ${fmtUnit(data.result.ms.p50, b.unit)} p50 live` + : `${data.explainer.h2}: live benchmark`; + } + return data.leader + ? `${data.explainer.h2}: ${data.leader.name} leads at ${fmtUnit(data.leader.ms.p50, b.unit)}` + : `${data.explainer.h2}: live benchmark`; +} + export async function generateMetadata({ params, }: { @@ -119,17 +270,13 @@ export async function generateMetadata({ const { slug, chain } = await params; const data = await loadChainPage(slug, chain); if (!data) return {}; - const { benchmark, explainer, result } = data; - const hasData = result.ms.p50 > 0; - const title = hasData - ? `${explainer.h2}: ${fmtUnit(result.ms.p50, benchmark.unit)} p50 live` - : `${explainer.h2}: live benchmark`; + const title = pageTitle(data); const description = capDescription( - stripInlineMarkdown(explainer.body), + stripInlineMarkdown(data.explainer.body), 158, ); - const canonical = `${SITE.url}/benchmarks/${benchmark.slug}/${chain}`; - const ogImage = `${SITE.url}/api/og/${benchmark.slug}`; + const canonical = `${SITE.url}/benchmarks/${data.benchmark.slug}/${chain}`; + const ogImage = `${SITE.url}/api/og/${data.benchmark.slug}`; return { title, description, @@ -158,29 +305,15 @@ export default async function BenchmarkChainPage({ const { slug, chain } = await params; const data = await loadChainPage(slug, chain); if (!data) notFound(); - const { benchmark, explainer, result, sorted, rank } = data; + const { benchmark, explainer } = 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 hasData = result.ms.p50 > 0; - const subject = `${result.name} ${benchmark.metric.toLowerCase()}`; - const keyFacts = hasData - ? `As of ${asOfDate(benchmark.lastRunAt)}, ${subject} 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.` - : "") - : `${subject} is measured continuously on this benchmark. Live numbers will appear here as soon as the harness reports fresh samples.`; + const crumbName = + data.shape === "row" ? data.result.name : data.chainLabel; + const keyFacts = buildKeyFacts(data); + const gatedChains = new Set(explainerChains(benchmark)); const jsonLd = { "@context": "https://schema.org", @@ -219,7 +352,7 @@ export default async function BenchmarkChainPage({ { "@type": "ListItem", position: 4, - name: result.name, + name: crumbName, item: pageUrl, }, ], @@ -262,7 +395,7 @@ export default async function BenchmarkChainPage({
  • /
  • - {result.name} + {crumbName}
  • @@ -279,7 +412,7 @@ export default async function BenchmarkChainPage({ {benchmark.category} - {result.name} + {crumbName}

    @@ -298,63 +431,20 @@ export default async function BenchmarkChainPage({ ))} - {/* Cross-chain context table. Every sibling with its own explainer - links to its dedicated page (internal mesh); chains without one - deep-link to their anchor on the parent bench. */} - {sorted.length > 1 && ( -
    -

    - How {result.name} compares -

    -

    - Live p50 over the last 24 hours across every chain on this - benchmark, ranked{" "} - {benchmark.higherIsBetter ? "highest" : "lowest"} first. -

    -
      - {sorted.map((r, i) => { - const isCurrent = r.slug === chain; - const href = explainerSlugs.has(r.slug) - ? `/benchmarks/${benchmark.slug}/${r.slug}` - : `/benchmarks/${benchmark.slug}#${r.slug}`; - const row = ( - - - - #{i + 1} - - - {r.name} - - - - {fmtUnit(r.ms.p50, benchmark.unit)} - - - ); - return ( -
    1. - {isCurrent ? ( - row - ) : ( - - {row} - - )} -
    2. - ); - })} -
    -
    + {data.shape === "row" ? ( + + ) : ( + + )} + + {/* Sibling chain pages, for the dimension shape (the row shape's + comparison list already links siblings). */} + {data.shape === "dimension" && ( + )}

    @@ -372,3 +462,170 @@ export default async function BenchmarkChainPage({ ); } + +/** Row shape: cross-chain ranking with the current chain highlighted. + * Every sibling with its own explainer links to its dedicated page + * (internal mesh); chains without one deep-link to their anchor on the + * parent bench. */ +function RowComparison({ + data, + chain, + gatedChains, +}: { + data: RowShape; + chain: string; + gatedChains: Set; +}) { + const { benchmark, result, sorted } = data; + if (sorted.length < 2) return null; + return ( +

    +

    + How {result.name} compares +

    +

    + Live p50 over the last 24 hours across every chain on this benchmark, + ranked {benchmark.higherIsBetter ? "highest" : "lowest"} first. +

    +
      + {sorted.map((r, i) => { + const isCurrent = r.slug === chain; + const href = gatedChains.has(r.slug) + ? `/benchmarks/${benchmark.slug}/${r.slug}` + : `/benchmarks/${benchmark.slug}#${r.slug}`; + const row = ( + + + + #{i + 1} + + + {r.name} + + + + {fmtUnit(r.ms.p50, benchmark.unit)} + + + ); + return ( +
    1. + {isCurrent ? ( + row + ) : ( + + {row} + + )} +
    2. + ); + })} +
    +
    + ); +} + +/** Dimension shape: provider leaderboard scoped to this chain, plus the + * per-region leader breakdown when the bench declares regions. */ +function DimensionLeaderboard({ data }: { data: DimensionShape }) { + const { benchmark, chainLabel, providers, regionLeaders } = data; + if (providers.length === 0) return null; + return ( + <> +
    +

    + {chainLabel} leaderboard +

    +

    + Live p50 over the last 24 hours, scoped to {chainLabel} only, ranked{" "} + {benchmark.higherIsBetter ? "highest" : "lowest"} first. Cross-region + average; the regional breakdown below shows where leaders diverge. +

    +
      + {providers.map((r, i) => ( +
    1. + + + + + #{i + 1} + + + {r.name} + + + + {fmtUnit(r.ms.p50, benchmark.unit)} + + + +
    2. + ))} +
    +
    + + {regionLeaders.length > 1 && ( +
    +

    + Leader by region on {chainLabel} +

    +
      + {regionLeaders.map((r) => ( +
    • + {r.region} + + {r.leader.name} ยท {fmtUnit(r.leader.ms.p50, benchmark.unit)} + +
    • + ))} +
    +
    + )} + + ); +} + +function SiblingChains({ + benchmark, + current, + gatedChains, +}: { + benchmark: Benchmark; + current: string; + gatedChains: Set; +}) { + const siblings = (benchmark.dimensions?.chain ?? []).filter( + (c) => + c.value !== current && + c.value.toLowerCase() !== "all" && + gatedChains.has(c.value), + ); + if (siblings.length === 0) return null; + return ( + + ); +} diff --git a/src/app/benchmarks/[slug]/page.tsx b/src/app/benchmarks/[slug]/page.tsx index 3b0d99a7..a3ed67af 100644 --- a/src/app/benchmarks/[slug]/page.tsx +++ b/src/app/benchmarks/[slug]/page.tsx @@ -497,6 +497,11 @@ export default async function BenchmarkPage({ phrases land in static HTML for crawlers to index. */} {!isDraft && } + {/* Dimension benches (chains as filters, not rows) don't render + ChainHeadingsSummary, so the dedicated per-chain pages need + their own server-rendered discovery links here. */} + {!isDraft && } + {/* FAQ section - every question/answer mirrors a FAQPage JSON-LD entry above. Google requires the content to be visible on the page, so we render the same text here. */} @@ -598,6 +603,41 @@ function variantKey( return `${chain ?? "__none"}|${region ?? "__none"}|${kind ?? "__none"}`; } +/** Links to /benchmarks// pages for dimension-shaped + * benches. Row-shaped benches (l1-finality) already link their pages + * through the ChainHeadingsSummary headings, so this only renders + * chains that exist as dimension values, not as result rows. */ +function PerChainPagesNav({ benchmark }: { benchmark: Benchmark }) { + const resultSlugs = new Set(benchmark.results.map((r) => r.slug)); + const explainerSlugs = new Set( + (benchmark.perChainExplainer ?? []).map((e) => e.slug), + ); + const chains = (benchmark.dimensions?.chain ?? []).filter( + (c) => + c.value.toLowerCase() !== "all" && + explainerSlugs.has(c.value) && + !resultSlugs.has(c.value), + ); + if (chains.length === 0) return null; + return ( + + ); +} + function DraftNotice({ source }: { source: string }) { return (
    diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index fa188316..056a400e 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -114,8 +114,13 @@ export default async function sitemap(): Promise { }, ]; const resultSlugs = new Set(b.results.map((r) => r.slug)); + const chainValues = new Set( + (b.dimensions?.chain ?? []) + .map((c) => c.value) + .filter((v) => v.toLowerCase() !== "all"), + ); for (const e of b.perChainExplainer ?? []) { - if (!resultSlugs.has(e.slug)) continue; + if (!resultSlugs.has(e.slug) && !chainValues.has(e.slug)) continue; entries.push({ url: `${SITE.url}/benchmarks/${b.slug}/${e.slug}`, lastModified: last,