From e0246a590aa3ad72cb9df76b74829f8804b84057 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:04:01 +0200 Subject: [PATCH] fix(hl): don't emit /hyperliquid/ links for builders without history (#978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ahrefs surfaced 14 /hyperliquid/ URLs returning 404 on our own domain (supurr, nautilus-trader, superstack, unigox, slash, liquid8, aura-money, superx, vibeliquid, blink, xtrade-protocol, kinto, ranger-finance, topdog). Root cause: 1. isHlBuilderSlug() returns true whenever the slug appears in hyperliquid-frontends.yml providers (spec-level). 2. /hyperliquid//page.tsx notFound()s when the same slug is NOT in fetchHlHistory().frontends (12-month rolling blob). 3. Some builders are in the spec but not in history (dormant, new, paused, or just below the history-blob activity threshold). Two inlink sources produced the 404 chain: a) /products/ checked isHlBuilderSlug() before redirecting to /hyperliquid/, sending 12 crawler paths into a 404. b) /hyperliquid hub cohort leaderboard rendered a link to /hyperliquid/ for every cohort row, even the 2 rows (aura-money, nautilus-trader) with no 12m history. Fixes: - New isHlBuilderWithHistory(slug) checks BOTH the spec AND the history blob. Wired into products/[slug]/page.tsx replacing the older check, so /products/ now renders the normal provider profile instead of redirecting to a 404. - hl-cohort-leaderboard swaps for a plain on rows where the history map has no entry. The row still shows all KPI data + '—' for the trend column; just no crawl path into /hyperliquid/ 404s. Co-authored-by: Florent Tapponnier --- .claude/settings.local.json | 7 ++++ src/app/products/[slug]/page.tsx | 8 ++-- src/components/hl-cohort-leaderboard.tsx | 47 +++++++++++++++++------- src/lib/hl-builder-stats.ts | 15 ++++++++ 4 files changed, 59 insertions(+), 18 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..b925adae --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "mcp__openchainbench__list_benchmarks" + ] + } +} diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index eecaa93f..114eadd3 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -17,7 +17,7 @@ import { Breadcrumb } from "@/components/breadcrumb"; import { buildBreadcrumbJsonLd, safeJsonLd } from "@/lib/jsonld"; import { fetchHlBuilderStats, - isHlBuilderSlug, + isHlBuilderWithHistory, } from "@/lib/hl-builder-stats"; import { HlBuilderDashboard } from "@/components/hl-builder-dashboard"; import { RelatedProvidersSection } from "@/components/related-providers-section"; @@ -64,7 +64,7 @@ export async function generateMetadata({ // injection — return the canonical + redirect-safe metadata so // crawlers that peek at the response before the 308 fires still see the // right canonical target. - if (await isHlBuilderSlug(slug)) { + if (await isHlBuilderWithHistory(slug)) { const canonicalUrl = `${SITE.url}/hyperliquid/${slug}`; return { alternates: { canonical: canonicalUrl }, @@ -145,7 +145,7 @@ export default async function ProviderPage({ // frontends (12-month focus chart + peer group + KPI strip). Redirect // /products/ straight there so backlinks + old SERP entries // land on the richer hub without splitting rank signal across two URLs. - if (await isHlBuilderSlug(slug)) { + if (await isHlBuilderWithHistory(slug)) { redirect(`/hyperliquid/${slug}`); } const p = await getProvider(slug); @@ -158,7 +158,7 @@ export default async function ProviderPage({ // on-node harness has data for. Cheap (6 Prom scalars in parallel) and // gracefully degrades to a hidden section when Prom is unreachable or // the slug isn't an HL builder. - const isHlBuilder = await isHlBuilderSlug(p.slug); + const isHlBuilder = await isHlBuilderWithHistory(p.slug); const hlStats = isHlBuilder ? await fetchHlBuilderStats(p.slug) : null; // Prediction-market deep-dive: if the slug is a tracked PM venue or diff --git a/src/components/hl-cohort-leaderboard.tsx b/src/components/hl-cohort-leaderboard.tsx index 2a794b14..272f987f 100644 --- a/src/components/hl-cohort-leaderboard.tsx +++ b/src/components/hl-cohort-leaderboard.tsx @@ -114,6 +114,12 @@ export function HlCohortLeaderboard({ {filtered.map((r, i) => { const hist = historyBySlug?.get(r.slug); + // Only builders that have a `hist` entry actually have a + // detail page at /hyperliquid/ (the page component + // 404s when the history blob has no matching frontend, see + // hyperliquid/[slug]/page.tsx). Skip the link so Ahrefs + + // Bing don't queue up crawl paths that dead-end at 404. + const hasDetailPage = historyBySlug ? !!hist : true; return ( - - - - {r.name} + {hasDetailPage ? ( + + + + {r.name} + + + ) : ( + + + + {r.name} + - + )} {fmtUSD(r.revenue30d)} {fmtUSD(r.volume30d)} @@ -152,12 +167,16 @@ export function HlCohortLeaderboard({ )} - - Open → - + {hasDetailPage ? ( + + Open → + + ) : ( + + )} ); diff --git a/src/lib/hl-builder-stats.ts b/src/lib/hl-builder-stats.ts index dd793574..c4513369 100644 --- a/src/lib/hl-builder-stats.ts +++ b/src/lib/hl-builder-stats.ts @@ -70,6 +70,21 @@ export async function isHlBuilderSlug(slug: string): Promise { return cachedSlugs.has(slug); } +/** + * Same as `isHlBuilderSlug` but ALSO requires the slug to be present in + * the current history blob. Used before redirecting /products/ to + * /hyperliquid/ so we never send crawlers or users into a 404: a + * builder that's in the spec but not in the last-12-months history blob + * (dormant, new, or paused) would otherwise 404 at the redirect target. + * The 12-month history is the source of truth for what /hyperliquid/ + * can actually render (see hyperliquid/[slug]/page.tsx:83-85). + */ +export async function isHlBuilderWithHistory(slug: string): Promise { + if (!(await isHlBuilderSlug(slug))) return false; + const history = await fetchHlHistory(); + return !!history?.frontends.some((f) => f.slug === slug); +} + function promUrl(): string | null { return process.env.PROMETHEUS_URL?.trim() || null; }