From 7549c82d436fa133545d5c65fb3375744d8db2d4 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Wed, 24 Jun 2026 15:08:31 +0200 Subject: [PATCH 1/2] fix bench template leaks ({{best_name}} on pages) - renderBenchmarkText now covers seoTitle, seoDescription, subtitle, methodology, disclaimer. Raw tokens were leaking into RSC props + /api/citable consumers. - chain stash lookup is case-insensitive: perp-fees YAML uses uppercase asset codes (ETH/BTC/SOL) so {{best_p50:chain:BTC}} previously fell through. - bumped bench-unfiltered v12->v13 and all-benchmarks v16->v17 so prod cached entries regenerate. --- src/lib/bench-template.test.ts | 14 ++++++++ src/lib/bench-template.ts | 65 +++++++++++++++++++++++++++++----- src/lib/spec.ts | 13 +++++-- 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/src/lib/bench-template.test.ts b/src/lib/bench-template.test.ts index ccca140d..cbea07e7 100644 --- a/src/lib/bench-template.test.ts +++ b/src/lib/bench-template.test.ts @@ -131,5 +131,19 @@ describe("renderTemplate", () => { "{{best_name:chain:solana}}", ); }); + + test("matches chain key case-insensitively against the stash", () => { + // perp-fees declares dimensions.chain values as `ETH`, `BTC`, `SOL` + // (uppercase asset codes), so the stash ends up keyed `ETH` etc. + // Without case folding, `{{best_p50:chain:BTC}}` in an editorial + // line would fall through and render the raw token. + const upper = bench([r("alpha", "Alpha", 100), r("beta", "Beta", 200)]); + upper.bestPerChain = { + ETH: { name: "Alpha", slug: "alpha", ms: { p50: 100, p90: 0, p99: 0, mean: 100 }, successRate: 1 }, + BTC: { name: "Beta", slug: "beta", ms: { p50: 200, p90: 0, p99: 0, mean: 200 }, successRate: 1 }, + }; + expect(renderTemplate("{{best_name:chain:BTC}}", upper)).toBe("Beta"); + expect(renderTemplate("{{best_p50:chain:eth}}", upper)).toBe("100 ms"); + }); }); }); diff --git a/src/lib/bench-template.ts b/src/lib/bench-template.ts index 0e820389..f36e4300 100644 --- a/src/lib/bench-template.ts +++ b/src/lib/bench-template.ts @@ -56,12 +56,33 @@ const CHAIN_TEMPLATE_RE = /** Per-chain leader / trailer lookups against the Benchmark stash * populated by spec.ts. Inlined (not re-imported from spec.ts) to - * avoid a spec.ts → bench-template.ts → spec.ts circular import. */ + * avoid a spec.ts → bench-template.ts → spec.ts circular import. + * + * Case-insensitive lookup: the stash is keyed by the raw YAML value + * (`spec.dimensions.chain[*].value`), which authors write as either + * `solana` or `BTC` depending on convention. Without the fold, + * `{{best_p50:chain:BTC}}` on perp-fees (uppercase asset codes) misses + * a stash whose only entry is keyed `BTC`, because the template + * resolver lowercases `chain` before lookup. + */ +function findChainEntry( + stash: Record | undefined, + chain: string, +): T | undefined { + if (!stash) return undefined; + const direct = stash[chain]; + if (direct) return direct; + const lower = chain.toLowerCase(); + for (const [k, v] of Object.entries(stash)) { + if (k.toLowerCase() === lower) return v; + } + return undefined; +} function bestForChain(b: Benchmark, chain: string): ProviderResult | undefined { - return b.bestPerChain?.[chain]; + return findChainEntry(b.bestPerChain, chain); } function worstForChain(b: Benchmark, chain: string): ProviderResult | undefined { - return b.worstPerChain?.[chain]; + return findChainEntry(b.worstPerChain, chain); } export function renderTemplate(text: string, benchmark: Benchmark): string { @@ -77,21 +98,20 @@ export function renderTemplate(text: string, benchmark: Benchmark): string { CHAIN_TEMPLATE_RE, (whole, keyword: string, chain: string) => { const k = keyword.toLowerCase(); - const chainKey = chain.toLowerCase(); if (k === "best_name") { - const lead = bestForChain(benchmark, chainKey); + const lead = bestForChain(benchmark, chain); return lead ? lead.name : whole; } if (k === "best_p50") { - const lead = bestForChain(benchmark, chainKey); + const lead = bestForChain(benchmark, chain); return lead ? fmtUnit(lead.ms.p50, benchmark.unit) : whole; } if (k === "worst_name") { - const trailer = worstForChain(benchmark, chainKey); + const trailer = worstForChain(benchmark, chain); return trailer ? trailer.name : whole; } if (k === "worst_p50") { - const trailer = worstForChain(benchmark, chainKey); + const trailer = worstForChain(benchmark, chain); return trailer ? fmtUnit(trailer.ms.p50, benchmark.unit) : whole; } return whole; @@ -131,13 +151,40 @@ export function renderTemplate(text: string, benchmark: Benchmark): string { } /** Apply renderTemplate to every editorial field that supports it. The - * Benchmark object is mutated in place and returned for convenience. */ + * Benchmark object is mutated in place and returned for convenience. + * + * Fields covered: + * - abstract, findings, seoIntro, faq, perChainExplainer: page body copy. + * - seoTitle, seoDescription: meta tags + RSC props serialized to the + * client. Without rendering these, a `{{best_name}}` token in + * `seo_description` survives into the response payload (and was + * visible in dev tools / view-source as raw template syntax). The + * bench page's generateMetadata also rendered against `b` directly, + * but anywhere else that read the field (the bench object passed + * to client components, /api/citable downstream consumers) got the + * raw token. + * - subtitle, methodology, disclaimer: same risk, smaller blast + * radius today but no reason to leave them unprocessed. + */ export function renderBenchmarkText(benchmark: Benchmark): Benchmark { benchmark.abstract = renderTemplate(benchmark.abstract, benchmark); benchmark.findings = benchmark.findings.map((f) => renderTemplate(f, benchmark)); + benchmark.methodology = benchmark.methodology.map((m) => + renderTemplate(m, benchmark), + ); + benchmark.subtitle = renderTemplate(benchmark.subtitle, benchmark); + if (benchmark.seoTitle) { + benchmark.seoTitle = renderTemplate(benchmark.seoTitle, benchmark); + } + if (benchmark.seoDescription) { + benchmark.seoDescription = renderTemplate(benchmark.seoDescription, benchmark); + } if (benchmark.seoIntro) { benchmark.seoIntro = renderTemplate(benchmark.seoIntro, benchmark); } + if (benchmark.disclaimer) { + benchmark.disclaimer = renderTemplate(benchmark.disclaimer, benchmark); + } if (benchmark.faq) { benchmark.faq = benchmark.faq.map((item) => ({ q: renderTemplate(item.q, benchmark), diff --git a/src/lib/spec.ts b/src/lib/spec.ts index 1879979a..1cc86d39 100644 --- a/src/lib/spec.ts +++ b/src/lib/spec.ts @@ -204,7 +204,12 @@ const loadBenchmarkUnfilteredCached = unstable_cache( // worker writes snapshots every 60s, so the unstable_cache layer can // be 5x slower without freshness loss. Also invalidates main's // separately-bumped v11 from PR #664 so we converge on one schema. - ["bench-unfiltered-v12"], + // v13: renderBenchmarkText now also resolves seoTitle / seoDescription / + // subtitle / methodology / disclaimer. Cached v12 entries kept the + // raw `{{best_name}}` token on those fields (visible in client RSC + // payload + leaked via /api/citable). Bump so the next read regenerates + // through the wider resolver. + ["bench-unfiltered-v13"], { revalidate: 300, tags: ["benchmarks"] }, ); @@ -316,7 +321,11 @@ const loadAllBenchmarksCached = unstable_cache( // v16: bumped with bench-unfiltered-v12 (60s→300s revalidate, egress // reduction). Skips v15 which main set independently for the same // reason; aligning on v16 converges the schema. - ["all-benchmarks-v16"], + // v17: bumped with bench-unfiltered-v13 (wider renderBenchmarkText + // coverage). Without bumping this, the products / citable / sitemap + // surfaces would keep serving v16 benches with raw `{{best_name}}` in + // seoDescription for up to 300s after deploy. + ["all-benchmarks-v17"], { revalidate: 300, tags: ["benchmarks"] }, ); export const loadAllBenchmarks = cache(loadAllBenchmarksCached); From 4cf519ea6a43621cbf0b141897378388cb09c299 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Wed, 24 Jun 2026 15:19:31 +0200 Subject: [PATCH 2/2] redesign header CMC-style + add contextual sub-nav - remove the | separator between Contribute and GitHub - active-section underline (accent color) under the current tab, matched via usePathname so /benchmarks/ highlights Benchmarks - nav links bumped to 15px, taller header (h-16 desktop) for breathing - right utilities: search + github (icon-only) + theme, gap-spaced - backdrop-blur on the sticky bar so content scrolling under it stays legible without the harsh opaque white - new SiteSubNav: category tabs on /benchmarks*, hidden elsewhere. Horizontal scroll on mobile with edge-fade mask hint. --- src/components/site-header.tsx | 127 +++++++++++++++++++++++--------- src/components/site-sub-nav.tsx | 82 +++++++++++++++++++++ 2 files changed, 174 insertions(+), 35 deletions(-) create mode 100644 src/components/site-sub-nav.tsx diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index 81d97add..653f9434 100644 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -2,9 +2,11 @@ import { Menu, X } from "lucide-react"; import Link from "next/link"; +import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; import { SearchTrigger } from "@/components/search/search-trigger"; import { SiteLogoSwitcher } from "@/components/site-logo-switcher"; +import { SiteSubNav } from "@/components/site-sub-nav"; import { ThemeToggle } from "@/components/theme-toggle"; function GithubIcon({ size = 15 }: { size?: number }) { @@ -15,16 +17,35 @@ function GithubIcon({ size = 15 }: { size?: number }) { ); } -const NAV = [ - { href: "/benchmarks", label: "Benchmarks" }, - { href: "/products", label: "Products" }, - { href: "/methodology", label: "Methodology" }, - { href: "/about", label: "About" }, - { href: "/contribute", label: "Contribute" }, +type NavItem = { href: string; label: string; match: (p: string) => boolean }; + +// Active-state predicates. Bench/product detail pages share the same +// tab as the index, so `/benchmarks/aggregator-head-lag` highlights +// the "Benchmarks" tab. `/` matches exact only — without that, every +// route would inherit a "Home" highlight. +const NAV: NavItem[] = [ + { + href: "/benchmarks", + label: "Benchmarks", + match: (p) => p === "/benchmarks" || p.startsWith("/benchmarks/"), + }, + { + href: "/products", + label: "Products", + match: (p) => p === "/products" || p.startsWith("/products/"), + }, + { + href: "/methodology", + label: "Methodology", + match: (p) => p === "/methodology", + }, + { href: "/about", label: "About", match: (p) => p === "/about" }, + { href: "/contribute", label: "Contribute", match: (p) => p === "/contribute" }, ]; export function SiteHeader() { const [open, setOpen] = useState(false); + const pathname = usePathname() ?? "/"; // Close the mobile menu when the viewport crosses md so the dropdown // doesn't stick around as the desktop nav reappears. @@ -38,6 +59,12 @@ export function SiteHeader() { return () => mql.removeEventListener("change", onChange); }, [open]); + // Close the mobile menu on route change so a tap on a nav item collapses + // the drawer without the consumer having to wire onClick on every link. + useEffect(() => { + setOpen(false); + }, [pathname]); + return (
-
-
+
+
- +
@@ -108,17 +156,24 @@ export function SiteHeader() { className="md:hidden absolute left-0 right-0 top-full border-b border-rule bg-surface shadow-lg" >
    - {NAV.map((item) => ( -
  • - setOpen(false)} - > - {item.label} - -
  • - ))} + {NAV.map((item) => { + const active = item.match(pathname); + return ( +
  • + setOpen(false)} + > + {item.label} + +
  • + ); + })}
  • )}
+ +
); } diff --git a/src/components/site-sub-nav.tsx b/src/components/site-sub-nav.tsx new file mode 100644 index 00000000..87baee1f --- /dev/null +++ b/src/components/site-sub-nav.tsx @@ -0,0 +1,82 @@ +"use client"; + +import Link from "next/link"; +import { CATEGORIES } from "@/lib/categories"; + +type SubItem = { href: string; label: string; match: (p: string) => boolean }; + +// Per-section contextual sub-nav. Modelled on the CMC chart-hub pattern +// (horizontally scrollable category tabs below the main nav). The +// bench section is the only one with a deep enough taxonomy to need +// this today; other sections render nothing rather than an empty +// 40px-tall strip that would just push content down. +function getItems(pathname: string): SubItem[] | null { + if (pathname === "/benchmarks" || pathname.startsWith("/benchmarks/")) { + const items: SubItem[] = [ + { + href: "/benchmarks", + label: "All", + match: (p) => + p === "/benchmarks" || + (p.startsWith("/benchmarks/") && !p.startsWith("/benchmarks/category/")), + }, + ]; + for (const c of CATEGORIES) { + const href = `/benchmarks/category/${c.slug}`; + items.push({ + href, + label: c.label, + match: (p) => p === href, + }); + } + return items; + } + return null; +} + +export function SiteSubNav({ pathname }: { pathname: string }) { + const items = getItems(pathname); + if (!items) return null; + + return ( +
+ +
+ ); +}