From 00b3ec935d87f99717197fbf21f9990517a2a517 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Thu, 16 Jul 2026 15:48:09 +0200 Subject: [PATCH] seo: 200 placeholders + sitemap threshold buffer - badge/[slug]/[provider]: serve pending SVG (200) instead of 404 when a provider is missing ranking data - hyperliquid/[slug]: render lightweight placeholder page (200) instead of 307 to /hyperliquid when history blob is missing the builder - compare adHocPairs: bump inclusion threshold to 3 (brand) / 4 (other) so a single bench flap can't cross both the sitemap gate and the page noindex gate Fixes 3 recurring Ahrefs issues (broken badge images, noindex-in-sitemap on compare, 3xx-in-sitemap on hyperliquid) all rooted in the sitemap/page ISR race. --- src/app/api/badge/[slug]/[provider]/route.ts | 61 +++++--- src/app/hyperliquid/[slug]/page.tsx | 138 +++++++++++++++++-- src/lib/compare/adhoc-pairs.ts | 9 +- 3 files changed, 180 insertions(+), 28 deletions(-) diff --git a/src/app/api/badge/[slug]/[provider]/route.ts b/src/app/api/badge/[slug]/[provider]/route.ts index e5e6009f..d699b26b 100644 --- a/src/app/api/badge/[slug]/[provider]/route.ts +++ b/src/app/api/badge/[slug]/[provider]/route.ts @@ -220,21 +220,14 @@ export async function GET( // Legacy approximation for chain-dimensioned benches without a // rank_matrix_query in their spec. const scoped = rankOfChain(b, provider, chainParam); - if (!scoped) { - return new NextResponse("not found", { - status: 404, - // Short TTL: a scoped miss is usually transient (bench cache - // entry predating cellRanks, or a Prom hiccup on the matrix - // query), so don't let the CDN pin the 404 for long. - headers: { "cache-control": "public, s-maxage=60" }, - }); - } + // Serve a placeholder SVG instead of 404 on a transient scoped miss + // (bench cache entry predating cellRanks, Prom hiccup on the matrix + // query, provider without data on the scope). Keeps external embeds + // from displaying broken images and Ahrefs from flagging inbound. + if (!scoped) return placeholderSvg(b.title); r = { rank: scoped.rank, total: scoped.total, value: scoped.value }; } else { - return new NextResponse("not found", { - status: 404, - headers: { "cache-control": "public, s-maxage=60" }, - }); + return placeholderSvg(b.title); } scopeLabel = [ chainParam ? chainLabel(b, chainParam) : null, @@ -244,12 +237,7 @@ export async function GET( .join(" · "); } else { r = rankOf(b.results, provider, b.higherIsBetter); - if (!r) { - return new NextResponse("not found", { - status: 404, - headers: { "cache-control": "public, s-maxage=60" }, - }); - } + if (!r) return placeholderSvg(b.title); // Hint the aggregate scope when the bench declares dimensions so // embedders can read it. Benches without dimensions get no scope // label (it would be noise). @@ -382,6 +370,41 @@ export async function GET( }); } +/** Placeholder SVG served with HTTP 200 when the provider isn't ranked + * yet on this bench (missing p50, unknown scope cell, provider not in + * results). Serving 200 instead of 404 keeps embed images from breaking + * on partner sites during transient data gaps and keeps Ahrefs from + * flagging inbound links as broken. Short CDN TTL (60s) so a resolved + * gap surfaces quickly. */ +function placeholderSvg(benchTitle: string): NextResponse { + const title = truncate(benchTitle, 60); + const line1 = "Measurement pending"; + const line2 = "OpenChainBench"; + const W = Math.min( + W_MAX, + Math.max(W_MIN, TEXT_X + title.length * CH_11 + 30), + ); + const svg = ` + + OpenChainBench. ${escapeXml(title)}. Measurement pending. + + + + ${escapeXml(title)} + ${escapeXml(line1)} + ${escapeXml(line2)} +`; + return new NextResponse(svg, { + status: 200, + headers: { + "Content-Type": "image/svg+xml; charset=utf-8", + Vary: "Accept, Accept-Encoding", + "Cache-Control": + "public, max-age=60, s-maxage=60, stale-while-revalidate=300", + }, + }); +} + // C0 control chars (minus \t \n \r) + DEL are forbidden in XML 1.0 text. // A spec PR with a title containing one of these would otherwise produce // an SVG that browsers refuse to render - self-DoS on every embed of the diff --git a/src/app/hyperliquid/[slug]/page.tsx b/src/app/hyperliquid/[slug]/page.tsx index 2c937d45..231318dc 100644 --- a/src/app/hyperliquid/[slug]/page.tsx +++ b/src/app/hyperliquid/[slug]/page.tsx @@ -1,11 +1,12 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { notFound, redirect } from "next/navigation"; +import { notFound } from "next/navigation"; import { fetchHlBuilderStats, fetchHlCohort, fetchHlHistory, isHlBuilderSlug, + type HlCohortRow, type HlHistoryFrontendCompact, } from "@/lib/hl-builder-stats"; import { Breadcrumb } from "@/components/breadcrumb"; @@ -56,7 +57,18 @@ export async function generateMetadata({ const { slug } = await params; const history = await fetchHlHistory(); const frontend = history?.frontends.find((f) => f.slug === slug); - if (!frontend) return {}; + // Fallback title uses a title-cased slug so a tracked builder without + // a fresh history entry still ships a meaningful (the page + // falls back to a placeholder render below when frontend is missing, + // matching this 200 metadata). + const displayName = frontend?.name ?? titleCaseSlug(slug); + if (!frontend) { + return pageMetadata({ + path: `/hyperliquid/${slug}`, + title: `${displayName} — Hyperliquid frontend`, + description: `${displayName} on Hyperliquid: 12-month builder fees, volume and first-active date. Rolling 30-day metrics refresh as data becomes available.`, + }); + } const currentFees = lastNonNull(frontend.fees); const peakFees = peakOf(frontend.fees); const description = `${frontend.name} on Hyperliquid: ${fmtUSDShort( @@ -66,7 +78,7 @@ export async function generateMetadata({ )}). 12-month history with volume, fees and first-active date.`; return pageMetadata({ path: `/hyperliquid/${slug}`, - title: `${frontend.name} — Hyperliquid frontend`, + title: `${displayName} — Hyperliquid frontend`, description, }); } @@ -85,13 +97,19 @@ export default async function HlFrontendPage({ // Data-outage guard: this page is the target of PERMANENT redirects // from /products/<slug>, so it must never 404 on a runtime data gap // (stale KV snapshot, Prom unreachable). Tracked builders without a - // resolvable history bounce temporarily (307) to the hub instead; - // crawlers keep the canonical URL and retry, users land somewhere - // useful. Unknown slugs still 404. + // resolvable history render a lightweight placeholder with HTTP 200 + // instead of the previous 307 bounce to /hyperliquid. Ahrefs was + // flagging sitemap-listed builder URLs as 3xx-in-sitemap during the + // brief windows where the history blob dropped their entries; a 200 + // placeholder keeps the URL crawlable while data catches up. Any + // partial data we still have (cohort row from the KPI grid) surfaces + // in the placeholder so the page isn't a soft 404. Unknown slugs + // still 404. const frontend = history?.frontends.find((f) => f.slug === slug); if (!history || !frontend) { - if (await isHlBuilderSlug(slug)) redirect("/hyperliquid"); - notFound(); + if (!(await isHlBuilderSlug(slug))) notFound(); + const cohortRow = cohort?.rows.find((r) => r.slug === slug); + return <HlBuilderPending slug={slug} cohortRow={cohortRow} />; } const cohortRow = cohort?.rows.find((r) => r.slug === slug); @@ -234,6 +252,110 @@ export default async function HlFrontendPage({ ); } +/** Title-case a slug ("mass-dot-money" → "Mass Dot Money"). Used as the + * display fallback for a tracked builder whose 12-month history entry + * hasn't materialised yet. */ +function titleCaseSlug(slug: string): string { + return slug + .split("-") + .filter(Boolean) + .map((p) => p.charAt(0).toUpperCase() + p.slice(1)) + .join(" "); +} + +/** Lightweight placeholder rendered with HTTP 200 when a builder is in + * the spec catalog but temporarily absent from the history blob. Shows + * whatever cohort data is still resolvable so the page carries real + * content instead of a soft-404 shell. */ +function HlBuilderPending({ + slug, + cohortRow, +}: { + slug: string; + cohortRow: HlCohortRow | undefined; +}) { + const name = cohortRow?.name ?? titleCaseSlug(slug); + const breadcrumbLd = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: "Home", + item: "https://openchainbench.com/", + }, + { + "@type": "ListItem", + position: 2, + name: "Hyperliquid", + item: "https://openchainbench.com/hyperliquid", + }, + { + "@type": "ListItem", + position: 3, + name, + item: `https://openchainbench.com/hyperliquid/${slug}`, + }, + ], + }; + return ( + <article className="mx-auto max-w-[1200px] 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(breadcrumbLd) }} + /> + <Breadcrumb + items={[ + { label: "Home", href: "/" }, + { label: "Hyperliquid", href: "/hyperliquid" }, + { label: name }, + ]} + /> + <header className="mb-8"> + <p className="label-mono text-ink-faint mb-2">Hyperliquid frontend</p> + <div className="flex items-center gap-4"> + <ProviderLogo slug={slug} name={name} size={56} /> + <h1 className="display text-4xl sm:text-5xl text-ink">{name}</h1> + </div> + <p + className="mt-2 text-[12px] text-ink-faint" + style={{ fontFamily: "var(--font-mono, monospace)" }} + > + {slug} + </p> + </header> + {cohortRow ? ( + <section className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-10"> + <Kpi label="Fees 30d" value={fmtUSDShort(cohortRow.revenue30d)} /> + <Kpi label="Volume 30d" value={fmtUSDShort(cohortRow.volume30d)} /> + <Kpi label="Users 30d" value={cohortRow.users30d.toLocaleString()} /> + <Kpi + label="Cohort share 24h" + value={`${(cohortRow.cohortVolumeShare24h * 100).toFixed(2)}%`} + /> + </section> + ) : null} + <section className="rounded-lg border border-ink/10 bg-paper-soft/40 p-5 max-w-2xl"> + <h2 className="text-lg font-semibold text-ink mb-2"> + 12-month history aggregating + </h2> + <p className="text-sm text-ink-soft"> + {name} is tracked on the Hyperliquid builder cohort. The full + 12-month history chart, peer group and detailed KPIs will appear + here as soon as the next data sweep completes. In the meantime, + browse the{" "} + <Link href="/hyperliquid" className="underline hover:no-underline"> + Hyperliquid frontends grid + </Link>{" "} + for live rankings. + </p> + </section> + </article> + ); +} + function Kpi({ label, value }: { label: string; value: string }) { return ( <div className="rounded-lg border border-ink/10 bg-paper p-4"> diff --git a/src/lib/compare/adhoc-pairs.ts b/src/lib/compare/adhoc-pairs.ts index 067b55dd..4e110b0a 100644 --- a/src/lib/compare/adhoc-pairs.ts +++ b/src/lib/compare/adhoc-pairs.ts @@ -56,8 +56,15 @@ export function adHocPairs(profiles: ProviderProfile[]): AdHocPair[] { const bBenches = liveBenchesBySlug.get(bSlug)!; let sharedLive = 0; for (const s of aBenches) if (bBenches.has(s)) sharedLive += 1; + // Buffer above the render-time noindex gate (< 2 live shared). The + // page recomputes on its own ISR clock, so a single bench flapping + // unavailable/back can drop `liveSharedCount` under 2 for one render + // window while the sitemap (force-dynamic) still lists the pair. + // Ahrefs then flags "noindex page in sitemap". Requiring >= 3 (brand) + // and >= 4 (otherwise) here means a single bench flap can't cross + // both thresholds simultaneously. const bothBrand = BRAND_WHITELIST.has(aSlug) && BRAND_WHITELIST.has(bSlug); - const threshold = bothBrand ? 2 : 3; + const threshold = bothBrand ? 3 : 4; if (sharedLive < threshold) continue; out.push({ a: aSlug, b: bSlug, slug: `${aSlug}-vs-${bSlug}` }); }