Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions src/app/benchmarks/category/[cat]/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getBenchmarks } from "@/data/benchmarks";
import { BenchmarkGrid } from "@/components/benchmark-grid";
import { Breadcrumb } from "@/components/breadcrumb";
import { safeJsonLd, buildItemListJsonLd } from "@/lib/jsonld";
import { SITE } from "@/data/site";
import { pageMetadata } from "@/lib/page-metadata";
import { capDescription } from "@/lib/seo-text";
import { CATEGORIES, CATEGORY_BY_SLUG } from "@/lib/categories";

/**
* Per-category hub page. One static route per entry in `CATEGORIES` so
* each architectural slice (RPCs, Bridges, Blockchains, ...) has a
* crawlable, linkable URL. Lets external sites and internal nav deep
* link directly into "all Solana RPC benchmarks" instead of relying on
* the client-only filter that lives on `/benchmarks`.
*
* Prerendered at build time via `generateStaticParams` (closed list, no
* Prom calls needed). ISR refresh aligned with the main hub so a freshly
* added bench surfaces here on the same cadence.
*/

export const revalidate = 60;

type Params = { cat: string };

export async function generateStaticParams() {
// Closed enum, generated even when a category currently has zero live
// benches (e.g. Wallets at time of writing). The page-level fallback
// below 404s empty categories so crawlers don't index thin pages.
return CATEGORIES.map((c) => ({ cat: c.slug }));
}

export async function generateMetadata({
params,
}: {
params: Promise<Params>;
}): Promise<Metadata> {
const { cat } = await params;
const entry = CATEGORY_BY_SLUG.get(cat);
if (!entry) return {};
const description = capDescription(entry.description, 158);
return pageMetadata({
path: `/benchmarks/category/${entry.slug}`,
title: `${entry.heading}`,
description,
});
}

export default async function BenchmarkCategoryPage({
params,
}: {
params: Promise<Params>;
}) {
const { cat } = await params;
const entry = CATEGORY_BY_SLUG.get(cat);
if (!entry) notFound();

const all = await getBenchmarks();
const benchmarks = all.filter((b) => b.category === entry.label);
// Empty-category guard: a category in the enum that has no live bench
// yet returns 404 so the crawler doesn't land on a thin page. The
// category still ships in `generateStaticParams` so adding the first
// bench to it lights up the URL without a redeploy gate.
if (benchmarks.length === 0) notFound();

const url = `${SITE.url}/benchmarks/category/${entry.slug}`;
const jsonLd = buildItemListJsonLd({
name: `${entry.heading} on OpenChainBench`,
url,
description: entry.description,
items: benchmarks.map((b) => ({
name: b.title,
url: `${SITE.url}/benchmarks/${b.slug}`,
})),
breadcrumb: [
{ name: "Home", url: `${SITE.url}/` },
{ name: "All benchmarks", url: `${SITE.url}/benchmarks` },
{ name: entry.heading, url },
],
});

return (
<article className="mx-auto max-w-[1400px] px-4 sm:px-6 py-12 sm:py-16">
<script
type="application/ld+json"
// biome-ignore lint/security/noDangerouslySetInnerHtml: serialized via safeJsonLd
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
/>
<Breadcrumb
items={[
{ label: "Benchmarks", href: "/benchmarks" },
{ label: entry.heading },
]}
/>
<header className="mb-10 mt-4">
<h1 className="display text-4xl sm:text-5xl text-ink">
{entry.heading}
</h1>
<p className="mt-4 max-w-2xl text-base sm:text-lg text-ink-soft leading-snug">
{entry.description}
</p>
</header>
<BenchmarkGrid benchmarks={benchmarks} lockedCategory={entry.label} />
</article>
);
}
30 changes: 30 additions & 0 deletions src/app/sitemap.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 { CATEGORIES } from "@/lib/categories";
import { isAll } from "@/lib/dimensions";
import { getProviderSlugs } from "@/lib/providers";
import { SITE } from "@/data/site";
Expand DownExpand Up@@ -111,6 +112,14 @@ async function buildStaticFallback(): Promise<MetadataRoute.Sitemap> {
changeFrequency: "daily" as const,
priority: 0.85,
})),
// Category hubs are filesystem-driven (no KV / no Prom) so they belong
// in the static fallback alongside chain hubs and answers.
...CATEGORIES.map((c) => ({
url: `${SITE.url}/benchmarks/category/${c.slug}`,
lastModified: BUILD_TIME,
changeFrequency: "daily" as const,
priority: 0.8,
})),
...answers.map((a) => ({
url: `${SITE.url}/answers/${a.slug}`,
lastModified: BUILD_TIME,
Expand DownExpand Up@@ -253,13 +262,34 @@ async function buildFullSitemap(): Promise<MetadataRoute.Sitemap> {
)
).filter((r): r is NonNullable<typeof r> => r !== null);

// Category hub pages. Mirror the chain hub pattern: lastmod = max
// lastRunAt across the benches in the category so freshly run data
// bumps the per-category URL too. Categories that currently have zero
// live benches drop from the sitemap (the page route 404s them too).
const categoryRoutes: MetadataRoute.Sitemap = CATEGORIES.map((c) => {
const inCategory = benchmarks.filter((b) => b.category === c.label);
if (inCategory.length === 0) return null;
const last = inCategory.reduce<Date>((acc, b) => {
if (!b.lastRunAt) return acc;
const t = safeDate(b.lastRunAt, new Date(0));
return t > acc ? t : acc;
}, new Date(0));
return {
url: `${SITE.url}/benchmarks/category/${c.slug}`,
lastModified: last.getTime() > 0 ? last : catalogTs,
changeFrequency: "daily" as const,
priority: 0.8,
};
}).filter((r): r is NonNullable<typeof r> => r !== null);

return [
...staticRoutes,
...benchmarkRoutes,
...providerRoutes,
...alternativeRoutes,
...answerRoutes,
...chainRoutes,
...categoryRoutes,
];
}

Expand Down
85 changes: 62 additions & 23 deletions src/components/benchmark-grid.tsx
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,44 @@
"use client";

import { useMemo, useState } from "react";
import Link from "next/link";
import { LayoutGrid, List, Search } from "lucide-react";
import type { Benchmark } from "@/types/benchmark";
import { BenchmarkCard } from "@/components/benchmark-card";
import { categorySlugFromLabel } from "@/lib/categories";

/**
* Client-side filter/search shell for the All Benchmarks card grid.
* Hosts category pills (derived from data), a view-mode toggle (grid is
* the only fully-implemented mode here - list view degrades to a single
* column) and a search input with a ⌘K affordance.
*
* The category pills render as `<Link>` to `/benchmarks/category/<slug>`
* so crawlers see real hrefs and can follow them into the per-category
* hub pages (otherwise the category facet would only exist as
* client-only state and have no linkable URL). On click we still apply
* the in-place client filter and preventDefault so the user gets the
* snappy instant-filter UX without a navigation roundtrip.
*
* When `lockedCategory` is passed (e.g. by the `/benchmarks/category/<slug>`
* page) the grid renders pre-filtered, the search bar still works, and
* the category pills are hidden entirely so the page reads as a focused
* category hub instead of a partial filter UI.
*/
export function BenchmarkGrid({ benchmarks }: { benchmarks: Benchmark[] }) {
export function BenchmarkGrid({
benchmarks,
lockedCategory = null,
}: {
benchmarks: Benchmark[];
/** When set, force the grid to this category and hide the filter pills.
* Used by the per-category hub routes so the rendered DOM matches the
* URL and the in-page filter UI doesn't conflict with the route. */
lockedCategory?: string | null;
}) {
const [query, setQuery] = useState("");
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [activeCategory, setActiveCategory] = useState<string | null>(
lockedCategory,
);
const [view, setView] = useState<"grid" | "list">("grid");
const q = query.trim().toLowerCase();

Expand DownExpand Up@@ -46,34 +71,48 @@ export function BenchmarkGrid({ benchmarks }: { benchmarks: Benchmark[] }) {
});
}, [benchmarks, q, activeCategory]);

const showFilterPills = !lockedCategory;

return (
<div>
{/* Filter row */}
<div className="mb-8 flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-3">
<ul className="-mx-4 px-4 sm:mx-0 sm:px-0 flex flex-nowrap sm:flex-wrap overflow-x-auto sm:overflow-visible items-center gap-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<li>
<button
type="button"
className="pill"
data-active={activeCategory === null}
onClick={() => setActiveCategory(null)}
>
All
</button>
</li>
{categories.map((c) => (
<li key={c}>
<button
type="button"
{showFilterPills && (
<ul className="-mx-4 px-4 sm:mx-0 sm:px-0 flex flex-nowrap sm:flex-wrap overflow-x-auto sm:overflow-visible items-center gap-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<li>
<Link
href="/benchmarks"
className="pill"
data-active={activeCategory === c}
onClick={() => setActiveCategory(activeCategory === c ? null : c)}
data-active={activeCategory === null}
onClick={(e) => {
e.preventDefault();
setActiveCategory(null);
}}
>
{c}
</button>
All
</Link>
</li>
))}
</ul>
{categories.map((c) => {
const slug = categorySlugFromLabel(c);
const href = slug ? `/benchmarks/category/${slug}` : "/benchmarks";
return (
<li key={c}>
<Link
href={href}
className="pill"
data-active={activeCategory === c}
onClick={(e) => {
e.preventDefault();
setActiveCategory(activeCategory === c ? null : c);
}}
>
{c}
</Link>
</li>
);
})}
</ul>
)}

<div className="sm:ml-auto flex items-center gap-3">
{/* View toggle */}
Expand Down
110 changes: 110 additions & 0 deletions src/lib/categories.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
/**
* Category taxonomy for benchmark hubs.
*
* Mirrors the closed `Category` enum from `spec-schema.ts` (the source
* of truth that every YAML spec gets validated against). Centralised
* here so the `/benchmarks/category/<cat>` routes, the sitemap, the
* client filter and the meta-builders all share one slug ↔ label ↔
* description vocabulary. If a new category is added to the schema,
* adding it here is the only other touch-point needed to surface a
* dedicated hub URL.
*
* Slugs are lowercase, kebab-case, ASCII-only so they survive URL
* canonicalisation, RSS readers and JSON-LD @id fragments without
* needing encoding. Labels match the schema enum verbatim so the
* client filter can compare `b.category === entry.label` without an
* extra lookup.
*
* Descriptions are SEO copy that explains the category scope. They
* land in <meta description>, OG description, and the H1 sub-paragraph
* on the category hub page, so each must read as a standalone sentence
* (a) without the surrounding template and (b) with the category name
* substituted in. No em / en dashes per the project copy rules.
*/

export type Category =
| "Aggregators"
| "Bridges"
| "Blockchains"
| "Trading"
| "Wallets"
| "RPCs"
| "NFT APIs";

export type CategoryEntry = {
/** URL slug. Lowercase, kebab-case, ASCII. */
slug: string;
/** Schema enum label, matches `Benchmark.category` exactly. */
label: Category;
/** Plural human heading used in H1 + breadcrumb. */
heading: string;
/** SEO copy under the H1 + <meta description>. */
description: string;
};

export const CATEGORIES: readonly CategoryEntry[] = [
{
slug: "aggregators",
label: "Aggregators",
heading: "Aggregator benchmarks",
description:
"Live OpenChainBench measurements for crypto data aggregators. Compare quote latency, market coverage, and freshness across the providers that fan out queries to multiple underlying sources.",
},
{
slug: "bridges",
label: "Bridges",
heading: "Bridge benchmarks",
description:
"Open, reproducible benchmarks for cross-chain bridges and intent layers. Compare quote latency, end-to-end fees, and route coverage across the bridges that move value between chains.",
},
{
slug: "blockchains",
label: "Blockchains",
heading: "Blockchain benchmarks",
description:
"Network-level measurements that compare blockchains head to head. Time to finality, block time, gas estimation accuracy, and validator economics on every L1 and L2 OpenChainBench tracks.",
},
{
slug: "trading",
label: "Trading",
heading: "Trading benchmarks",
description:
"Latency, fee, and freshness benchmarks for trading venues and execution providers. Perpetuals, spot DEX quotes, oracle deviation, and Polymarket data freshness measured continuously.",
},
{
slug: "wallets",
label: "Wallets",
heading: "Wallet benchmarks",
description:
"Benchmarks that compare wallet infrastructure providers on label coverage, address resolution, and the data layers that power production wallet UIs.",
},
{
slug: "rpcs",
label: "RPCs",
heading: "RPC benchmarks",
description:
"Live OpenChainBench measurements for RPC and node providers. Compare capability coverage, transaction landing latency, and reliability across the endpoints that power production dApps.",
},
{
slug: "nft-apis",
label: "NFT APIs",
heading: "NFT API benchmarks",
description:
"Benchmarks that compare NFT data API providers on collection metadata coverage, freshness, and the indexing depth that production NFT marketplaces and wallets depend on.",
},
];

export const CATEGORY_BY_SLUG: ReadonlyMap<string, CategoryEntry> = new Map(
CATEGORIES.map((c) => [c.slug, c]),
);

export const CATEGORY_SLUG_BY_LABEL: ReadonlyMap<Category, string> = new Map(
CATEGORIES.map((c) => [c.label, c.slug]),
);

/** Slug used in the `/benchmarks/category/<slug>` URL for a given
* `Benchmark.category` value. Returns `null` for unknown labels so
* call sites can degrade to the unscoped hub instead of a 404 link. */
export function categorySlugFromLabel(label: string): string | null {
return CATEGORY_SLUG_BY_LABEL.get(label as Category) ?? null;
}
Loading