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 = `
+`;
+ 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/, 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 ;
}
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 (
+
+
+
+
+
Hyperliquid frontend
+
+
+
{name}
+
+
+ {slug}
+
+
+ {cohortRow ? (
+
+
+
+
+
+
+ ) : null}
+
+
+ 12-month history aggregating
+
+
+ {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{" "}
+
+ Hyperliquid frontends grid
+ {" "}
+ for live rankings.
+
+
+
+ );
+}
+
function Kpi({ label, value }: { label: string; value: string }) {
return (
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}` });
}