diff --git a/next.config.ts b/next.config.ts index dfe2988a..ece0984d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -121,6 +121,31 @@ const nextConfig: NextConfig = { }, ], }, + { + // Sitemap: force-dynamic in the route file (Data Cache 2MB cap + // blows past for the 500+ URL corpus), so nothing caches it by + // default and every crawler hit re-runs the full loader chain. + // Hold it edge-side for an hour with SWR so cold hits stay fast. + source: "/sitemap.xml", + headers: [ + { + key: "Cache-Control", + value: "public, s-maxage=3600, stale-while-revalidate=86400", + }, + ], + }, + { + // Citable JSON is polled by LLM crawlers (Perplexity, ChatGPT, + // Claude Deep Research) — a bare `public` with no s-maxage sent + // every scrape to origin. Same freshness window as sitemap. + source: "/api/citable", + headers: [ + { + key: "Cache-Control", + value: "public, s-maxage=3600, stale-while-revalidate=86400", + }, + ], + }, ]; }, async redirects() { diff --git a/src/app/benchmarks/[slug]/opengraph-image.tsx b/src/app/benchmarks/[slug]/opengraph-image.tsx index 361d9c93..4b50596e 100644 --- a/src/app/benchmarks/[slug]/opengraph-image.tsx +++ b/src/app/benchmarks/[slug]/opengraph-image.tsx @@ -19,38 +19,17 @@ export async function generateStaticParams() { return []; } -// Emit one OG image per (slug, chain) combo so social shares of -// `/benchmarks/{slug}?chain=X` render a chain-honest card instead of the -// cross-chain aggregate leader (which is the misleading baseline-skew -// case bench-001 ran into: GMGN looking like "fastest crypto data API" -// on the unfiltered view because Solana's faster baseline drags the -// average down). The `id` Next.js threads through to the default image -// handler is the chain value; the unfiltered card uses `id="default"`. -export async function generateImageMetadata({ - params, -}: { - params: Promise<{ slug: string }>; -}) { - const { slug } = await params; - // Load editorial-only via getBenchmark (memoised; safe at build time). - const b = await getBenchmark(slug); - const chains = (b?.dimensions?.chain ?? []).filter( - (c) => c.value !== "all", - ); - return [ - { - id: "default", - alt, - size, - contentType, - }, - ...chains.map((c) => ({ - id: c.value, - alt: `${alt}. ${c.label}`, - size, - contentType, - })), - ]; +// Emit a SINGLE OG image for the aggregate bench page. Previously we +// fanned out one entry per chain variant so `/benchmarks/{slug}?chain=X` +// social shares could render a chain-honest card, but Next hoists every +// generateImageMetadata entry into a separate +// on the parent page — X, LinkedIn, Slack pick nondeterministically +// among them, so a share of `?chain=solana` might render the `robinhood` +// card. Chain-scoped pages live at `/benchmarks/[slug]/[chain]` and can +// carry their own opengraph-image handler if per-chain cards become a +// priority; today they inherit the site-wide default OG. +export async function generateImageMetadata() { + return [{ id: "default", alt, size, contentType }]; } export default async function OG({ diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 8b5b5a2d..354c0776 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -4,7 +4,17 @@ import { ArrowLeft } from "lucide-react"; export const metadata: Metadata = { title: "Page not found", - robots: { index: false }, + // Full robots shape so Next replaces (not merges) the layout's robots + // meta. Previous shape `{ index: false }` caused two `` tags on prod 404s (layout emitted one from its + // own defaults, this route emitted a second) — Bing had been observed + // to ignore the duplicated tag and fall through to the layout's + // index=true. Match the shape used by the layout's IS_STAGING branch. + robots: { + index: false, + follow: false, + googleBot: { index: false, follow: false }, + }, }; export default function NotFound() { diff --git a/src/middleware.ts b/src/middleware.ts index 6c940da7..bf4116b1 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -69,6 +69,17 @@ const COMPARE_PATH = /^\/compare\/([a-z0-9][a-z0-9-]{0,79})\/?$/; export function middleware(req: NextRequest) { const { pathname, search } = req.nextUrl; + // Case normalisation. `/products/Alchemy` and `/Benchmarks/foo` used + // to serve 200 with the mixed-case URL while the HTML canonical + // pointed at the lowercase form — Google indexed the mixed-case URL + // as a valid variant and burned crawl budget on both. Force lowercase + // via 308 so only the canonical shape reaches origin. + if (pathname !== pathname.toLowerCase()) { + const url = req.nextUrl.clone(); + url.pathname = pathname.toLowerCase(); + return NextResponse.redirect(url, 308); + } + if (search && CANONICAL_NO_QUERY.has(pathname)) { const canonical = req.nextUrl.clone(); canonical.search = ""; @@ -136,6 +147,11 @@ export const config = { "/api/llm-context", "/api/freshness", "/api/openapi.json", + // Wildcard `/((?!_next|.*\..*).*)` covers every HTML route so the + // case-normalisation redirect fires on any URL, not just the + // enumerated bench/answer/compare/product paths. Excludes _next + // internals and any file with an extension (assets, RSS, sitemap). + "/((?!_next|api|.*\\..*).*)", "/benchmarks/:slug*", "/answers/:slug*", "/compare/:slug*",