From 5176e1419a420faeb345f4881f3a05508863fd81 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Thu, 25 Jun 2026 17:28:55 +0200 Subject: [PATCH] fix(chain-aliases): apply matchesChainSlug across all 9 sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #708 / #711. The alias system was added but only applied to getBenchmarksForChain + ChainHeadingsSummary + overlayEditorial. Every other place that did a strict-equality chain-slug match still 404'd or rendered wrong when called with the canonical slug ('gram') while the YAML dimension / result row still held the legacy slug ('ton'). The user hit /benchmarks/wallet-labels-coverage/gram and got a 404. This PR pushes matchesChainSlug into every site that does a slug === match on a chain. New helper in src/lib/chain-aliases.ts: - matchesChainSlug(a, b) — both args optional, case-insensitive, resolves both sides through canonicalChainSlug. Replaces every '=== chain' on a chain-slug. - chainSlugSiblings(slug) — returns the set { canonical, ...legacy } for use as a filter Set. Patched 9 surfaces: - src/app/benchmarks/[slug]/[chain]/page.tsx — perChainExplainer lookup, results.find (row shape), dimensions.find (dimension shape), region variant fetch. /benchmarks/wallet-labels-coverage/gram now resolves. - src/app/api/badge/[slug]/[provider]/route.ts — chainLabel + cell param resolution. Badge endpoint accepts ?chain=gram. - src/app/api/bench/[slug]/variant/route.ts — variant filter validation. ?chain=gram no longer returns 'unknown chain'. - src/app/benchmarks/[slug]/share-card/route.tsx — OG share card chain pill resolution. - src/components/benchmark-body.tsx — client-side initial tab selection from ?chain= URL param. - src/app/chains/[slug]/page.tsx — per-bench dimension/result/route detection on the chain hub. - src/app/benchmarks/[slug]/opengraph-image.tsx — chain label on OG image. - src/app/benchmarks/[slug]/twitter-image.tsx — same for Twitter cards. - src/app/sitemap.ts — emits canonical slug URLs (/gram) instead of legacy (/ton) so crawlers don't waste budget on 308s. Per-chain explainer filter uses canonical comparison. What is NOT in this PR (deliberately): - Schema change to dimensions (value/prom_value/aliases) — too much surface, deferred to a separate refactor once harness rotates. - Harness Go code update (chain='ton' → 'gram' label) — separate Railway redeploy. - middleware-level URL canonicalization — next.config.ts redirects already cover the legacy /ton URLs; middleware adds nothing right now. --- src/app/api/badge/[slug]/[provider]/route.ts | 16 ++++++++-- src/app/api/bench/[slug]/variant/route.ts | 10 +++++-- src/app/benchmarks/[slug]/[chain]/page.tsx | 30 ++++++++++++++----- src/app/benchmarks/[slug]/opengraph-image.tsx | 4 ++- .../benchmarks/[slug]/share-card/route.tsx | 3 +- src/app/benchmarks/[slug]/twitter-image.tsx | 4 ++- src/app/chains/[slug]/page.tsx | 18 +++++++---- src/app/sitemap.ts | 19 ++++++++---- src/components/benchmark-body.tsx | 7 ++++- src/lib/chain-aliases.ts | 27 +++++++++++++++++ 10 files changed, 112 insertions(+), 26 deletions(-) diff --git a/src/app/api/badge/[slug]/[provider]/route.ts b/src/app/api/badge/[slug]/[provider]/route.ts index 08bd7f51..e5e6009f 100644 --- a/src/app/api/badge/[slug]/[provider]/route.ts +++ b/src/app/api/badge/[slug]/[provider]/route.ts @@ -30,6 +30,7 @@ import { fmtUnit } from "@/lib/format"; import { readBestPerChain } from "@/lib/per-chain-contract"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; import { PROVIDER_RE, SLUG_RE } from "@/lib/slug"; +import { matchesChainSlug } from "@/lib/chain-aliases"; import type { Benchmark, ProviderResult } from "@/types/benchmark"; export const revalidate = 300; @@ -144,9 +145,14 @@ function truncate(s: string, max: number): string { return s.slice(0, max - 1).trimEnd() + "…"; } -/** Returns the human label for a chain value from the bench's spec. */ +/** Returns the human label for a chain value from the bench's spec. + * Canonical-aware so a request with the new slug ("gram") still finds + * the dimension entry whose value is the legacy "ton". */ function chainLabel(b: Benchmark, chain: string): string { - return b.dimensions?.chain?.find((c) => c.value === chain)?.label ?? chain; + return ( + b.dimensions?.chain?.find((c) => matchesChainSlug(c.value, chain))?.label ?? + chain + ); } /** Returns the human label for a region value from the bench's spec. */ @@ -185,8 +191,12 @@ export async function GET( const url = new URL(req.url); const rawChain = url.searchParams.get("chain")?.toLowerCase().trim() || null; const rawRegion = url.searchParams.get("region")?.toLowerCase().trim() || null; + // Resolve via the alias-aware matcher so /api/badge?chain=gram lands + // on the dimension whose YAML value is "ton" (kept to match the + // harness's Prom labels). Returns the actual dimension value so the + // downstream cell lookup hits the snapshot's storage key. const chainParam = rawChain - ? (b.dimensions?.chain?.find((c) => c.value.toLowerCase() === rawChain) + ? (b.dimensions?.chain?.find((c) => matchesChainSlug(c.value, rawChain)) ?.value ?? null) : null; const regionParam = rawRegion diff --git a/src/app/api/bench/[slug]/variant/route.ts b/src/app/api/bench/[slug]/variant/route.ts index 37bcabe8..4935a465 100644 --- a/src/app/api/bench/[slug]/variant/route.ts +++ b/src/app/api/bench/[slug]/variant/route.ts @@ -15,6 +15,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { getBenchmark } from "@/data/benchmarks"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; import { SLUG_RE } from "@/lib/slug"; +import { matchesChainSlug } from "@/lib/chain-aliases"; export const revalidate = 60; @@ -49,8 +50,13 @@ export async function GET( for (const dim of ["chain", "region", "kind"] as const) { const raw = url.searchParams.get(dim)?.toLowerCase().trim(); if (!raw || raw === "all") continue; - const known = (aggregate.dimensions?.[dim] ?? []).find( - (d) => d.value.toLowerCase() === raw, + // Canonical-aware matching: the chain dimension may still hold the + // legacy slug ("ton") even though clients now request the canonical + // ("gram"). The matcher resolves both sides to canonical. + const known = (aggregate.dimensions?.[dim] ?? []).find((d) => + dim === "chain" + ? matchesChainSlug(d.value, raw) + : d.value.toLowerCase() === raw, ); if (!known) { return new NextResponse(`unknown ${dim}`, { diff --git a/src/app/benchmarks/[slug]/[chain]/page.tsx b/src/app/benchmarks/[slug]/[chain]/page.tsx index 5bfd036c..320c9686 100644 --- a/src/app/benchmarks/[slug]/[chain]/page.tsx +++ b/src/app/benchmarks/[slug]/[chain]/page.tsx @@ -10,6 +10,10 @@ import { capDescription } from "@/lib/seo-text"; import { SITE } from "@/data/site"; import { buildBreadcrumbJsonLd, safeJsonLd } from "@/lib/jsonld"; import { CATEGORY_COLOR } from "@/lib/category-colors"; +import { + canonicalChainSlug, + matchesChainSlug, +} from "@/lib/chain-aliases"; import type { Benchmark, ProviderResult } from "@/types/benchmark"; // Dedicated per-chain landing pages. Only chains that have a hand-written @@ -119,8 +123,12 @@ async function loadChainPage( ): Promise { const benchmark = await getBenchmark(slug); if (!benchmark) return null; - const found = (benchmark.perChainExplainer ?? []).find( - (e) => e.slug === chain, + // Canonical-aware lookups: a URL like /benchmarks//gram must + // resolve when the bench's perChainExplainer / results / dimensions + // still carry the legacy slug ("ton") because YAMLs and the harness + // haven't all rotated past the rename yet. + const found = (benchmark.perChainExplainer ?? []).find((e) => + matchesChainSlug(e.slug, chain), ); if (!found) return null; const explainer = { @@ -130,19 +138,24 @@ async function loadChainPage( }; // Shape 1: the chain is a leaderboard row (l1-finality). - const result = benchmark.results.find((r) => r.slug === chain); + const result = benchmark.results.find((r) => matchesChainSlug(r.slug, chain)); if (result) { const sorted = sortLive(benchmark.results, benchmark.higherIsBetter); - const rank = sorted.findIndex((r) => r.slug === chain) + 1; + const rank = sorted.findIndex((r) => matchesChainSlug(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", + (c) => matchesChainSlug(c.value, chain) && c.value.toLowerCase() !== "all", ); if (!chainOption) return null; - const scoped = (await getBenchmark(slug, { chain })) ?? benchmark; + // Use the actual YAML value (not the canonical) when fetching the + // scoped variant — the Prom-label injection downstream expects the + // raw dimension value (e.g. "ton") so the regex straddle keeps + // matching the harness's current label. + const scoped = + (await getBenchmark(slug, { chain: chainOption.value })) ?? benchmark; const providers = sortLive(scoped.results, benchmark.higherIsBetter); const leader = providers[0] ?? null; @@ -157,7 +170,10 @@ async function loadChainPage( const variants = await Promise.all( regions.map(async (r) => ({ label: r.label, - bench: await getBenchmark(slug, { chain, region: r.value }), + bench: await getBenchmark(slug, { + chain: chainOption.value, + region: r.value, + }), })), ); for (const v of variants) { diff --git a/src/app/benchmarks/[slug]/opengraph-image.tsx b/src/app/benchmarks/[slug]/opengraph-image.tsx index dc917d7b..361d9c93 100644 --- a/src/app/benchmarks/[slug]/opengraph-image.tsx +++ b/src/app/benchmarks/[slug]/opengraph-image.tsx @@ -3,6 +3,7 @@ import { getBenchmark } from "@/data/benchmarks"; import { headlineSentence, leader } from "@/lib/citation"; import { fmtUnit } from "@/lib/format"; import { CATEGORY_COLOR } from "@/lib/category-colors"; +import { matchesChainSlug } from "@/lib/chain-aliases"; import { loadBenchmark } from "@/lib/spec"; export const runtime = "nodejs"; @@ -76,7 +77,8 @@ export default async function OG({ const sentence = headlineSentence(b); const catColor = CATEGORY_COLOR[b.category] ?? "#7a2e1f"; const chainLabel = chainId - ? b.dimensions?.chain?.find((c) => c.value === chainId)?.label ?? chainId + ? b.dimensions?.chain?.find((c) => matchesChainSlug(c.value, chainId)) + ?.label ?? chainId : null; const titleText = chainLabel ? `${b.title} on ${chainLabel}` : b.title; diff --git a/src/app/benchmarks/[slug]/share-card/route.tsx b/src/app/benchmarks/[slug]/share-card/route.tsx index be8f7e7b..655da40a 100644 --- a/src/app/benchmarks/[slug]/share-card/route.tsx +++ b/src/app/benchmarks/[slug]/share-card/route.tsx @@ -5,6 +5,7 @@ import { getBenchmark } from "@/data/benchmarks"; import { buildProviderColors } from "@/lib/series-colors"; import { fmtUnit, fmtValue, unitSuffix } from "@/lib/format"; import { logoPath } from "@/lib/logo-manifest"; +import { matchesChainSlug } from "@/lib/chain-aliases"; import { chipBackground, chipTextColor, initials } from "@/lib/brand"; import type { Benchmark, ProviderResult } from "@/types/benchmark"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; @@ -527,7 +528,7 @@ export async function GET( const isAll = chainParam === "all"; const chainOption = isAll ? null - : chainOptions.find((c) => c.value === chainParam) ?? null; + : chainOptions.find((c) => matchesChainSlug(c.value, chainParam)) ?? null; const benchmark = chainOption ? (await getBenchmark(slug, { chain: chainOption.value })) ?? aggregate : aggregate; diff --git a/src/app/benchmarks/[slug]/twitter-image.tsx b/src/app/benchmarks/[slug]/twitter-image.tsx index f0c5e96c..f720ed0e 100644 --- a/src/app/benchmarks/[slug]/twitter-image.tsx +++ b/src/app/benchmarks/[slug]/twitter-image.tsx @@ -3,6 +3,7 @@ import { getBenchmark } from "@/data/benchmarks"; import { headlineSentence, leader } from "@/lib/citation"; import { fmtUnit } from "@/lib/format"; import { CATEGORY_COLOR } from "@/lib/category-colors"; +import { matchesChainSlug } from "@/lib/chain-aliases"; import { loadBenchmark } from "@/lib/spec"; export const runtime = "nodejs"; @@ -66,7 +67,8 @@ export default async function TwitterImage({ const sentence = headlineSentence(b); const catColor = CATEGORY_COLOR[b.category] ?? "#7a2e1f"; const chainLabel = chainId - ? b.dimensions?.chain?.find((c) => c.value === chainId)?.label ?? chainId + ? b.dimensions?.chain?.find((c) => matchesChainSlug(c.value, chainId)) + ?.label ?? chainId : null; const titleText = chainLabel ? `${b.title} on ${chainLabel}` : b.title; diff --git a/src/app/chains/[slug]/page.tsx b/src/app/chains/[slug]/page.tsx index 1a745ffd..48f33ab5 100644 --- a/src/app/chains/[slug]/page.tsx +++ b/src/app/chains/[slug]/page.tsx @@ -20,6 +20,7 @@ import { ProviderLogo } from "@/components/provider-logo"; import { SITE } from "@/data/site"; import { buildBreadcrumbJsonLd, safeJsonLd } from "@/lib/jsonld"; import { capDescription } from "@/lib/seo-text"; +import { matchesChainSlug } from "@/lib/chain-aliases"; import type { Benchmark } from "@/types/benchmark"; export const revalidate = 60; @@ -200,12 +201,19 @@ export default async function ChainPage({
    {list.map((b) => { - const ownResult = b.results.find((r) => r.slug === slug); - const chainOption = b.dimensions?.chain?.find( - (c) => c.value === slug, + // Canonical-aware matches: the bench's results / dimensions / + // perChainExplainer might still carry the legacy slug ("ton") + // while the URL is the renamed "gram". The matcher resolves + // both sides to the canonical chain so all three lookups land. + const ownResult = b.results.find((r) => + matchesChainSlug(r.slug, slug), + ); + const chainOption = b.dimensions?.chain?.find((c) => + matchesChainSlug(c.value, slug), + ); + const hasChainRoute = (b.perChainExplainer ?? []).some((e) => + matchesChainSlug(e.slug, slug), ); - const hasChainRoute = - (b.perChainExplainer ?? []).some((e) => e.slug === slug); const href = hasChainRoute ? `/benchmarks/${b.slug}/${slug}` : `/benchmarks/${b.slug}`; diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index e4e7f57e..30d09ac6 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -5,6 +5,7 @@ import { getBenchmarks } from "@/data/benchmarks"; import { loadAllAlternatives } from "@/lib/alternatives"; import { loadAllAnswers } from "@/lib/answers"; import { CHAINS, getBenchmarksForChain } from "@/lib/chains"; +import { canonicalChainSlug } from "@/lib/chain-aliases"; import { getProvider, getProviderSlugs } from "@/lib/providers"; import { SITE } from "@/data/site"; import type { Benchmark } from "@/types/benchmark"; @@ -174,16 +175,24 @@ async function buildFullSitemap(): Promise { priority: 0.95, }, ]; - const resultSlugs = new Set(b.results.map((r) => r.slug)); + // Canonicalize at insertion + check time so a chain rebrand window + // (where YAML dimension still has the legacy value "ton" while + // perChainExplainer + chain registry have moved to "gram") doesn't + // drop the new URLs from the sitemap. Emit the canonical URL so + // crawlers never index legacy /ton paths that 308 to /gram. + const resultSlugs = new Set( + b.results.map((r) => canonicalChainSlug(r.slug)), + ); const chainValues = new Set( (b.dimensions?.chain ?? []) - .map((c) => c.value) - .filter((v) => v.toLowerCase() !== "all"), + .filter((c) => c.value.toLowerCase() !== "all") + .map((c) => canonicalChainSlug(c.value)), ); for (const e of b.perChainExplainer ?? []) { - if (!resultSlugs.has(e.slug) && !chainValues.has(e.slug)) continue; + const canon = canonicalChainSlug(e.slug); + if (!resultSlugs.has(canon) && !chainValues.has(canon)) continue; entries.push({ - url: `${SITE.url}/benchmarks/${b.slug}/${e.slug}`, + url: `${SITE.url}/benchmarks/${b.slug}/${canon}`, lastModified: last, changeFrequency: "hourly", priority: 0.85, diff --git a/src/components/benchmark-body.tsx b/src/components/benchmark-body.tsx index 34c084ab..d856a185 100644 --- a/src/components/benchmark-body.tsx +++ b/src/components/benchmark-body.tsx @@ -4,6 +4,7 @@ import { useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import type { Benchmark } from "@/types/benchmark"; import { liveResults } from "@/lib/provider-filters"; +import { matchesChainSlug } from "@/lib/chain-aliases"; import { ChainTabs } from "@/components/chain-tabs"; import { LedgerTable } from "@/components/ledger-table"; import { TimeSeriesChart } from "@/components/time-series-chart"; @@ -154,8 +155,12 @@ export function BenchmarkBody({ const urlRegion = searchParams.get("region"); const urlKind = searchParams.get("kind"); const urlLayer = searchParams.get("layer"); + // Canonical-aware lookup: a URL with the new slug ("?chain=gram") + // still selects the dimension whose YAML value is the legacy "ton". const resolvedInitialChain = - (urlChain && chainOptions.find((c) => c.value === urlChain)?.value) ?? initialChain; + (urlChain && + chainOptions.find((c) => matchesChainSlug(c.value, urlChain))?.value) ?? + initialChain; const resolvedInitialRegion = (urlRegion && regionOptions.find((r) => r.value === urlRegion)?.value) ?? initialRegion; const resolvedInitialKind = diff --git a/src/lib/chain-aliases.ts b/src/lib/chain-aliases.ts index 9250575e..a3a6d9af 100644 --- a/src/lib/chain-aliases.ts +++ b/src/lib/chain-aliases.ts @@ -26,3 +26,30 @@ export function canonicalChainSlug(slug: string): string { const lc = slug.toLowerCase(); return CHAIN_SLUG_ALIASES[lc] ?? lc; } + +/** Returns every legacy slug that aliases to the given canonical, plus + * the canonical itself. Use as a Set when filtering YAML dimensions / + * result rows whose slug may still be in legacy form. */ +export function chainSlugSiblings(slug: string): Set { + const canon = canonicalChainSlug(slug); + const set = new Set([canon]); + for (const [legacy, target] of Object.entries(CHAIN_SLUG_ALIASES)) { + if (target === canon) set.add(legacy); + } + return set; +} + +/** True when two slugs refer to the same chain — either both canonical, + * one legacy that aliases to the other, or both legacy mapping to the + * same canonical. The canonical chain-comparison helper used at every + * site that previously did `=== chain` (route handlers, OG generators, + * badge API, sitemap filters, etc.). Case-insensitive. Both args are + * optional so callers with `string | null` URL params can pass without + * an inline guard. */ +export function matchesChainSlug( + a: string | null | undefined, + b: string | null | undefined, +): boolean { + if (!a || !b) return false; + return canonicalChainSlug(a) === canonicalChainSlug(b); +}