diff --git a/src/lib/categories.ts b/src/lib/categories.ts new file mode 100644 index 00000000..b1eaa988 --- /dev/null +++ b/src/lib/categories.ts @@ -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/` 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 , 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 + . */ + 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 = new Map( + CATEGORIES.map((c) => [c.slug, c]), +); + +export const CATEGORY_SLUG_BY_LABEL: ReadonlyMap = new Map( + CATEGORIES.map((c) => [c.label, c.slug]), +); + +/** Slug used in the `/benchmarks/category/` 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; +} diff --git a/src/lib/materialize/load.ts b/src/lib/materialize/load.ts index dd77a6b4..370e4b97 100644 --- a/src/lib/materialize/load.ts +++ b/src/lib/materialize/load.ts @@ -42,6 +42,7 @@ export type BenchmarkFilters = { chain?: string; region?: string; kind?: string; + venue?: string; }; export function filterSig(f: BenchmarkFilters): string { @@ -58,8 +59,8 @@ export function parseFilterSig(sig: string): BenchmarkFilters { if (!sig) return out; for (const kv of sig.split("&")) { const [k, v] = kv.split("="); - if (k && v && (k === "chain" || k === "region" || k === "kind")) { - out[k as "chain" | "region" | "kind"] = v; + if (k && v && (k === "chain" || k === "region" || k === "kind" || k === "venue")) { + out[k as "chain" | "region" | "kind" | "venue"] = v; } } return out; diff --git a/src/lib/spec-schema.ts b/src/lib/spec-schema.ts index ab57dac6..d57e8694 100644 --- a/src/lib/spec-schema.ts +++ b/src/lib/spec-schema.ts @@ -344,6 +344,14 @@ export const SpecSchema = z }) ) .optional(), + venue: z + .array( + z.object({ + value: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/), + label: z.string().min(1).max(64), + }) + ) + .optional(), }) .optional(),