diff --git a/src/app/[locale]/agents/[slug]/page.tsx b/src/app/[locale]/agents/[slug]/page.tsx index 2fd91efb..78cf2e78 100644 --- a/src/app/[locale]/agents/[slug]/page.tsx +++ b/src/app/[locale]/agents/[slug]/page.tsx @@ -18,14 +18,19 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s const t = await getTranslations({ locale, namespace: "AgentDetail" }); const agent = await prisma.agent.findFirst({ where: { slug, status: "APPROVED" }, - select: { name: true, description: true }, + select: { name: true, description: true, kind: true, _count: { select: { skills: true } } }, }); if (!agent) return { title: t("notFound") }; const description = (agent.description || `${agent.name} on ${SITE_NAME}`).slice(0, 200); + // Thin: a scraped PROJECT entry with no declared skills and a barely-there + // description adds little unique value over the GitHub repo itself — keep it out + // of the index to limit scaled-content exposure. Substantive entries stay indexed. + const thin = agent.kind === "PROJECT" && agent._count.skills === 0 && (agent.description ?? "").trim().length < 80; return { metadataBase: new URL(absoluteUrl("")), title: agent.name, description, + ...(thin ? { robots: { index: false, follow: true } } : {}), alternates: localizedAlternates(locale, `/agents/${slug}`), openGraph: { title: `${agent.name} — ${SITE_NAME}`, description, url: absoluteUrl(`/agents/${slug}`), type: "website", locale: localeOg(locale), images: [absoluteUrl("/opengraph-image")] }, twitter: { card: "summary_large_image", title: `${agent.name} — ${SITE_NAME}`, description }, diff --git a/src/proxy.ts b/src/proxy.ts index 34f396e3..5d7b3d2f 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,10 +1,34 @@ import createMiddleware from "next-intl/middleware"; import { routing } from "@/i18n/routing"; +import type { NextRequest } from "next/server"; // In Next.js 16 the request interceptor file is `proxy.ts` (formerly // `middleware.ts`). next-intl's middleware factory returns a plain -// `(NextRequest) => NextResponse` handler, so it works unchanged here. -export default createMiddleware(routing); +// `(NextRequest) => NextResponse` handler, which we wrap to add SEO headers. +const handle = createMiddleware(routing); + +const NON_DEFAULT_LOCALES = new Set(routing.locales.filter((l) => l !== routing.defaultLocale)); + +// Entity DETAIL pages (/{locale}/agents/{slug}, /{locale}/skills/{slug}) carry +// English-only bodies (scraped repo descriptions / AgentCards / READMEs); the 14 +// non-default locales are translated UI chrome around identical content. Emit +// `X-Robots-Tag: noindex` on those so the 15-locale fan-out isn't read as scaled / +// duplicate content. `follow` keeps link equity flowing, and hreflang still points +// crawlers at the canonical English page (which stays indexable). Listing, +// scenario, and marketing pages are genuinely translated, so they keep indexing in +// every locale. +function isNonDefaultLocaleEntityDetail(pathname: string): boolean { + const seg = pathname.split("/").filter(Boolean); + return seg.length >= 3 && NON_DEFAULT_LOCALES.has(seg[0]) && (seg[1] === "agents" || seg[1] === "skills"); +} + +export default function proxy(req: NextRequest) { + const res = handle(req); + if (isNonDefaultLocaleEntityDetail(req.nextUrl.pathname)) { + res.headers.set("X-Robots-Tag", "noindex, follow"); + } + return res; +} export const config = { // Run on page routes only. Exclude: