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
19 changes: 17 additions & 2 deletions next.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -178,9 +178,24 @@ const nextConfig: NextConfig = {
destination: "/chains/gram",
permanent: true,
},
// Scoped to the 3 benches that ship a gram (formerly ton)
// per_chain_explainer. The previous catch-all `/benchmarks/:slug/ton`
// fired for every bench, redirecting non-gram benches to a /gram
// path that 404s (e.g. aggregator-head-lag/ton → aggregator-head-lag/gram → 404),
// which Ahrefs and Google flag as a soft-404 redirect chain.
{
source: "/benchmarks/:slug/ton",
destination: "/benchmarks/:slug/gram",
source: "/benchmarks/l1-finality/ton",
destination: "/benchmarks/l1-finality/gram",
permanent: true,
},
{
source: "/benchmarks/network-fees/ton",
destination: "/benchmarks/network-fees/gram",
permanent: true,
},
{
source: "/benchmarks/wallet-labels-coverage/ton",
destination: "/benchmarks/wallet-labels-coverage/gram",
permanent: true,
},
...chainRedirects,
Expand Down
21 changes: 17 additions & 4 deletions src/app/compare/page.tsx
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowUpRight } from "lucide-react";
import { COMPARE_PAIRS } from "@/data/compare-pairs";
import { canonicalize } from "@/lib/providers";
import { COMPARE_PAIRS, type ComparePair } from "@/data/compare-pairs";
import { canonicalize, getProvider } from "@/lib/providers";
import { buildCompareGraph } from "@/lib/compare-pairing";
import { ProviderLogo } from "@/components/provider-logo";
import { CompareSelector } from "@/components/compare-selector";
Expand All@@ -24,9 +24,22 @@ export const metadata: Metadata = pageMetadata({
});

export default async function ComparePage() {
const pairs = [...COMPARE_PAIRS].sort((a, b) =>
a.slug.localeCompare(b.slug),
// Filter out pairs whose providers don't resolve in the live registry
// (e.g. raydium has no bench appearances yet, so /compare/jupiter-vs-raydium
// 404s downstream). Without this, the hub leaks dead links into both
// the rendered list and the JSON-LD ItemList that Google ingests.
const resolved = await Promise.all(
COMPARE_PAIRS.map(async (pair) => {
const [a, b] = await Promise.all([
getProvider(pair.providerA),
getProvider(pair.providerB),
]);
return a && b ? pair : null;
}),
);
const pairs = resolved
.filter((p): p is ComparePair => p !== null)
.sort((a, b) => a.slug.localeCompare(b.slug));
const graph = await buildCompareGraph();

const jsonld = {
Expand Down
12 changes: 10 additions & 2 deletions src/app/hyperliquid/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,15 +62,23 @@ export default async function HyperliquidHubPage() {
// are referenced in the page's plain prose and stayed out of this list
// intentionally; mixing two ItemLists with overlapping naming would
// confuse Search Console's rich-results validator more than it helps.
// Drop anonymous builder addresses (raw 0x... slugs with no human
// brand) from the ItemList. Those /products/<hex> URLs now 404 per the
// SEO blacklist in @/lib/providers (thin auto-generated titles, no
// editorial body, no inbound brand demand) so emitting them in
// schema.org ItemList would point Search Console at known 404s.
const linkableFrontends = frontends
? frontends.rows.filter((r) => !/^0x[a-f0-9]+$/.test(r.slug.toLowerCase()))
: [];
const itemListLd = frontends
? {
"@context": "https://schema.org",
"@type": "ItemList",
name: "Hyperliquid frontends leaderboard",
description:
"Hyperliquid frontends tracked by OpenChainBench, ranked by 30-day builder revenue.",
numberOfItems: frontends.rows.length,
itemListElement: frontends.rows.slice(0, 100).map((r, i) => ({
numberOfItems: linkableFrontends.length,
itemListElement: linkableFrontends.slice(0, 100).map((r, i) => ({
"@type": "ListItem",
position: i + 1,
url: `https://openchainbench.com/products/${r.slug}`,
Expand Down
30 changes: 22 additions & 8 deletions src/components/perp-venue-section.tsx
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import Link from "next/link";
import { loadAlternativeSlugs } from "@/lib/alternatives";
import { fetchPerpVenueKpis } from "@/lib/perp-venue-data";
import { PerpVenueKpiStrip } from "@/components/perp-venue-kpi-strip";
import {
Expand DownExpand Up@@ -50,8 +51,17 @@ export async function PerpVenueSection({
cohortSlug,
benchRows,
}: PerpVenueSectionProps) {
const kpis = await fetchPerpVenueKpis(cohortSlug);
const [kpis, altSlugs] = await Promise.all([
fetchPerpVenueKpis(cohortSlug),
loadAlternativeSlugs(),
]);
const externalHost = safeHost(externalUrl);
// Only emit /alternatives/<slug> when the YAML actually exists for
// this venue. Most perp cohort venues (lighter, vertex, grvt, ostium,
// variational, pacifica, aevo, edgex, paradex, extended, aster, ...)
// ship without an alternatives page and the hardcoded link was
// leaking 404s into Google's crawl from /products/<slug>.
const hasAlternativesPage = altSlugs.includes(slug);

const measured = benchRows.filter(
(r) => r.value !== null && r.rank !== null,
Expand DownExpand Up@@ -121,13 +131,17 @@ export async function PerpVenueSection({
All perp venues
</Link>
<span aria-hidden>·</span>
<Link
href={`/alternatives/${slug}`}
className="hover:text-ink underline underline-offset-2"
>
{name} alternatives
</Link>
<span aria-hidden>·</span>
{hasAlternativesPage && (
<>
<Link
href={`/alternatives/${slug}`}
className="hover:text-ink underline underline-offset-2"
>
{name} alternatives
</Link>
<span aria-hidden>·</span>
</>
)}
<a
href={externalUrl}
target="_blank"
Expand Down
27 changes: 19 additions & 8 deletions src/components/pm-venue-section.tsx
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import Link from "next/link";
import { loadAlternativeSlugs } from "@/lib/alternatives";
import {
deriveCategoryShares,
fetchPmTopMarkets,
Expand DownExpand Up@@ -53,11 +54,17 @@ export async function PmVenueSection({
venueType,
benchRows,
}: PmVenueSectionProps) {
const [kpis, topMarkets, history] = await Promise.all([
const [kpis, topMarkets, history, altSlugs] = await Promise.all([
fetchPmVenueKpis(slug),
fetchPmTopMarkets(slug),
fetchPmVenueHistory(slug, 90),
loadAlternativeSlugs(),
]);
// Only emit /alternatives/<slug> when the YAML actually exists for
// this venue. PM venues without an alternatives page (lighter,
// manifold, myriad, limitless, ...) would otherwise leak a 404 link
// into Google's crawl from /products/<slug>.
const hasAlternativesPage = altSlugs.includes(slug);

const categoryShares = deriveCategoryShares(topMarkets);
const externalHost = safeHost(externalUrl);
Expand DownExpand Up@@ -157,13 +164,17 @@ export async function PmVenueSection({
{name} product page
</Link>
<span aria-hidden>·</span>
<Link
href={`/alternatives/${slug}`}
className="hover:text-ink underline underline-offset-2"
>
{name} alternatives
</Link>
<span aria-hidden>·</span>
{hasAlternativesPage && (
<>
<Link
href={`/alternatives/${slug}`}
className="hover:text-ink underline underline-offset-2"
>
{name} alternatives
</Link>
<span aria-hidden>·</span>
</>
)}
<a
href={externalUrl}
target="_blank"
Expand Down
48 changes: 43 additions & 5 deletions src/lib/providers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -379,7 +379,7 @@ async function buildProviders(): Promise<ProviderProfile[]> {
}

const profiles = Array.from(byKey.values()).filter(
(p) => !DEAD_COMPOSITE_SLUGS.has(p.slug),
(p) => !isBlacklistedSlug(p.slug),
);
profiles.sort((a, b) => {
if (a.wins !== b.wins) return b.wins - a.wins;
Expand All@@ -395,16 +395,44 @@ async function buildProviders(): Promise<ProviderProfile[]> {
// now ranks the parent provider only (codex, predexon), but stale
// materialize-worker snapshots can recreate ghost /products/<slug>
// pages with no data. Hard-block so getProvider() returns undefined and
// the route 404s cleanly. Keep in sync with the same set in
// related-providers.ts.
const DEAD_COMPOSITE_SLUGS = new Set([
// the route 404s cleanly.
//
// Exported as the single source of truth. related-providers.ts imports
// this set instead of duplicating the literal — PR #728 originally
// shipped two copies and they immediately drifted (composite URLs kept
// leaking into sitemap.xml + /products listing even after the page
// itself 404'd, see SEO audit 2026-06-24).
export const DEAD_COMPOSITE_SLUGS = new Set([
"codex-kalshi",
"codex-polymarket",
"predexon-kalshi",
"predexon-limitless",
"predexon-polymarket",
]);

// Anonymous Hyperliquid builder addresses with no human-readable brand.
// The hyperliquid-frontends bench tracks 104 builders, ~15 of which have
// never been claimed by a frontend operator — they surface as raw
// `0x40e9d9fe...` slugs. Each one mints a thin /products/<hex> page
// titled "0x... review and live performance benchmarks", with no editorial
// body and no inbound brand search demand. Google indexed the lot as
// thin content and they were burning PageRank from the products hub.
//
// Filter at the profile and resolution layers so:
// - `getProviders()` drops them before /products/index renders the table
// - `getProviderSlugs()` drops them before sitemap.ts emits URLs
// - `getProvider()` returns undefined so the per-slug page 404s and
// Google deindexes via 404 signal
// The HL bench page itself still lists every builder in its leaderboard
// (that's the bench's job); only the dedicated /products/<hex> route is
// suppressed.
const HEX_ADDRESS_SLUG = /^0x[a-f0-9]+$/;

function isBlacklistedSlug(slug: string): boolean {
const lc = slug.toLowerCase();
return DEAD_COMPOSITE_SLUGS.has(lc) || HEX_ADDRESS_SLUG.test(lc);
}

// Cohort venues that should have a /products/<slug> page even when no
// bench in benchmarks/ ranks them. Imported as a flat list here to
// avoid a build-time cycle with the perp-stats module.
Expand DownExpand Up@@ -436,7 +464,12 @@ const PERP_VENUE_SEED = [
* bump this key when the buildProviders() output shape changes. */
const buildProvidersCached = unstable_cache(
buildProviders,
["providers-v3"],
// v4 bump: hex builder addresses + composite zombies are now filtered
// at buildProviders() exit. Without this bump, Vercel ISR would keep
// serving the v3 list (containing 0x... slugs) until the `benchmarks`
// tag fires, leaving thin /products/<hex> URLs in the sitemap for the
// next 60s window after deploy.
["providers-v4"],
{ revalidate: 60, tags: ["benchmarks"] },
);

Expand All@@ -450,6 +483,11 @@ export async function getProviderSlugs(): Promise<string[]> {
}

export async function getProvider(slug: string): Promise<ProviderProfile | undefined> {
// Early reject blacklisted slugs (dead composites + anonymous hex
// builder addresses) so /products/<slug> 404s cleanly even if a stale
// cache layer ever produces a profile. Mirrors the buildProviders()
// filter; cheap second guard against eventual-consistency leaks.
if (isBlacklistedSlug(slug)) return undefined;
const profiles = await getProviders();
// Aliased URLs (e.g. /products/helius-sender, /products/btc-usd) resolve
// to the canonical profile so backward-compat links keep working.
Expand Down
31 changes: 13 additions & 18 deletions src/lib/related-providers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,10 @@

import { cache } from "react";
import { unstable_cache } from "next/cache";
import { canonicalize, getProviders } from "@/lib/providers";
// DEAD_COMPOSITE_SLUGS lives in providers.ts (the canonical source of
// /products/<slug> eligibility); re-imported here so both modules share
// one list and we never drift the two literals out of sync.
import { canonicalize, DEAD_COMPOSITE_SLUGS, getProviders } from "@/lib/providers";
import { canonicalPairSlug } from "@/lib/compare-pairing-shared";
import { loadAllAlternatives } from "@/lib/alternatives";
import { loadBenchmark } from "@/lib/spec";
Expand DownExpand Up@@ -48,23 +51,6 @@ const COMPARE_CAP = 12;
/** Hard cap on how many alternatives lists we render. */
const ALTERNATIVES_CAP = 8;

/**
* Zombie slugs from pre-PR #647 pm-api-latency, when each aggregator was
* split per venue (codex-kalshi, codex-polymarket, predexon-*). The bench
* now collapses everything into the parent provider slug (codex,
* predexon) but the materialize worker's per-slug Redis blobs survive
* for the TTL window after each schema change, and old slugs leak into
* compare candidates / alternatives links until they expire. Filtering
* here keeps the UI clean across the eventual-consistency gap.
*/
const DEAD_COMPOSITE_SLUGS = new Set([
"codex-kalshi",
"codex-polymarket",
"predexon-kalshi",
"predexon-limitless",
"predexon-polymarket",
]);

/**
* Returns the providers that share at least one live benchmark with the
* given product, sorted by shared-bench count descending then by name.
Expand DownExpand Up@@ -124,6 +110,14 @@ async function buildAlternativesReverseMap(): Promise<
Map<string, AlternativeFeature[]>
> {
const alternatives = await loadAllAlternatives();
// Defensive: only ever emit /alternatives/<slug> for a slug we
// actually have a live YAML for. loadAllAlternatives() already
// filters to status=live, so today this is equivalent to the
// alternatives array, but the explicit Set makes the invariant
// local and survives any future refactor where alt-like records
// start flowing in from a different source (bench metadata,
// materialize worker, ...).
const liveSlugs = new Set(alternatives.map((a) => a.slug));
const benches = await Promise.all(
alternatives.map((alt) =>
loadBenchmark(alt.benchmark, { chain: alt.chain }).then((bench) => ({
Expand All@@ -135,6 +129,7 @@ async function buildAlternativesReverseMap(): Promise<
const map = new Map<string, AlternativeFeature[]>();
for (const { alt, bench } of benches) {
if (!bench) continue;
if (!liveSlugs.has(alt.slug)) continue;
const altTargetSlug = alt.target_product
.toLowerCase()
.replace(/\s+/g, "-");
Expand Down
Loading