diff --git a/src/pages/[...product]/llms.txt.ts b/src/pages/[...product]/llms.txt.ts index 91a7183c069..2c28ae2e31a 100644 --- a/src/pages/[...product]/llms.txt.ts +++ b/src/pages/[...product]/llms.txt.ts @@ -19,14 +19,32 @@ export const getStaticPaths = (async () => { const directory = await getCollection("directory"); const docs = await getCollection("docs"); - return directory + // Deduplicate by URL path: multiple directory entries may share the same + // entry.url (e.g. SDK variants). For shared URLs, prefer the generic + // entry (e.g. `sdk`) over language-specific variants (e.g. `go-sdk`). + const CANONICAL_IDS = new Set(["sdk"]); + + const entriesByUrl = new Map(); + for (const entry of directory) { + const productUrl = entry.data.entry?.url; + if (!productUrl || productUrl === "/" || productUrl.includes("#")) { + continue; + } + if (isDisallowedByRobots(productUrl)) continue; + + const urlPath = productUrl.slice(1, -1); + if (!urlPath) continue; + + const existing = entriesByUrl.get(urlPath); + if (!existing || CANONICAL_IDS.has(entry.id)) { + entriesByUrl.set(urlPath, entry); + } + } + + return [...entriesByUrl.values()] .map((entry) => { const productUrl = entry.data.entry?.url; - if (!productUrl || productUrl === "/" || productUrl.includes("#")) { - return null; - } - - if (isDisallowedByRobots(productUrl)) return null; + if (!productUrl) return null; const urlPath = productUrl.slice(1, -1); if (!urlPath) return null; diff --git a/src/pages/[...slug].astro b/src/pages/[...slug].astro index 83e79ee19e5..241375a46b0 100644 --- a/src/pages/[...slug].astro +++ b/src/pages/[...slug].astro @@ -22,7 +22,22 @@ import { const NOINDEX_PRODUCTS = ["email-security"]; export const prerender = true; -export const getStaticPaths = getDocsStaticPaths; +export const getStaticPaths = (async (...args) => { + const paths = await getDocsStaticPaths(...args); + // Content stubs that have a corresponding explicit src/pages route. + // The explicit page wins; filtering them here avoids duplicate-slug warnings. + const shadowed = new Set([ + "ai/models", + "workers-ai/models", + "ruleset-engine/rules-language/fields/reference", + "waf/change-log/changelog", + ]); + return paths.filter((p) => { + const slug = p.params.slug; + if (!slug) return true; + return !shadowed.has(slug); + }); +}) as typeof getDocsStaticPaths; const { entry, Content, headings } = await getDocsPageProps(Astro, { partialHeadings: { diff --git a/src/pages/changelog/rss/[product].xml.ts b/src/pages/changelog/rss/[product].xml.ts index 3875c3557d9..40f239821e8 100644 --- a/src/pages/changelog/rss/[product].xml.ts +++ b/src/pages/changelog/rss/[product].xml.ts @@ -6,6 +6,7 @@ import rss from "@astrojs/rss"; import { getCollection } from "astro:content"; import { config } from "virtual:nimbus/config"; import { getChangelogs, getRSSItems } from "~/util/changelog"; +import { groups } from "~/util/directory"; import type { APIRoute, @@ -16,15 +17,23 @@ import type { export const prerender = true; +const slugifyArea = (value: string) => value.replaceAll(" ", "-").toLowerCase(); + export const getStaticPaths = (async () => { const directory = await getCollection("directory"); - return directory.map((entry) => { - return { - params: { product: entry.id }, - props: { product: entry }, - }; - }); + // Area group slugs that would collide with product IDs — the area + // route owns those URLs, so skip them here to avoid route conflicts. + const areaSlugs = new Set(groups.map(slugifyArea)); + + return directory + .filter((entry) => !areaSlugs.has(entry.id)) + .map((entry) => { + return { + params: { product: entry.id }, + props: { product: entry }, + }; + }); }) satisfies GetStaticPaths; type Props = InferGetStaticPropsType; diff --git a/src/util/changelog.ts b/src/util/changelog.ts index 903e9c45160..8ddfe690d7e 100644 --- a/src/util/changelog.ts +++ b/src/util/changelog.ts @@ -108,10 +108,30 @@ export async function getChangelogs({ }: GetChangelogsOptions): Promise>> { let entries = await getCollection("changelog"); + // First pass: extract slug + folder for every entry (synchronous, + // deterministic — collection order is alphabetical by file path). + const parsed = entries.map((e) => { + const slug = e.id.split("/").slice(1).join("/"); + const folder = e.id.split("/")[0]; + return { entry: e, slug, folder }; + }); + + // Identify which slugs appear more than once. + const slugCounts = new Map(); + for (const { slug } of parsed) { + slugCounts.set(slug, (slugCounts.get(slug) ?? 0) + 1); + } + + // For duplicate slugs, the first folder in alphabetical order owns the + // bare slug; later folders are prefixed. This is deterministic because + // `getCollection` returns entries in alphabetical file-path order. + // Explicit overrides can force a specific canonical owner. + const CANONICAL_OWNERS: Record = { + "2026-04-01-l4-transport-telemetry-fields": "workers", + }; + entries = await Promise.all( - entries.map(async (e) => { - const slug = e.id.split("/").slice(1).join("/"); - const folder = e.id.split("/")[0]; + parsed.map(async ({ entry: e, slug, folder }) => { const product = { collection: "directory", id: folder } as const; const isValidProduct = await getEntry(product); @@ -126,9 +146,28 @@ export async function getChangelogs({ e.data.products.push(product); } + const count = slugCounts.get(slug) ?? 1; + const canonicalOwner = CANONICAL_OWNERS[slug]; + let dedupedId: string; + + if (count <= 1) { + dedupedId = slug; + } else if (canonicalOwner && folder === canonicalOwner) { + dedupedId = slug; + } else if (canonicalOwner) { + dedupedId = `${folder}/${slug}`; + } else { + // No explicit owner: first folder alphabetically wins. + const folders = parsed + .filter((p) => p.slug === slug) + .map((p) => p.folder); + const firstFolder = folders.sort()[0]; + dedupedId = folder === firstFolder ? slug : `${folder}/${slug}`; + } + return { ...e, - id: slug, + id: dedupedId, }; }), );