// Shared helpers for rendering MDX docs pages. Pulled out of // `app/docs/[[...slug]]/page.tsx` so both the classic `/docs/` // route and the new `//` catch-all can share the same // nav-tree builder, snippet inliner, and component map. // // Only the pieces that don't depend on the rendered React tree live // here; the big `components` map still lives alongside the docs page // for now so it can import client components without circular issues. import fs from "fs"; import path from "path"; import matter from "gray-matter"; import { resolveWithinDir } from "./safe-fs"; import { getDocsMode } from "./registry"; import { isRouteGroupSegment } from "./route-groups"; export const CONTENT_DIR = path.join(process.cwd(), "src/content/docs"); export const SNIPPETS_DIR = path.join(CONTENT_DIR, "..", "snippets"); // Re-exported from lib/framework-categories so client components can // pull the constant without dragging fs through the bundle. export { FRAMEWORK_CATEGORY_ORDER, type FrameworkCategory, } from "./framework-categories"; // --------------------------------------------------------------------------- // Nav tree types // --------------------------------------------------------------------------- export type NavNode = | { type: "page"; title: string; slug: string; icon?: string } | { type: "section"; title: string; icon?: string } | { type: "group"; title: string; slug: string; children: NavNode[]; defaultOpen?: boolean; icon?: string; }; // Section headers (the all-caps separators) carry the only icons in // the sidebar — top-level visual scaffolding. Title comparison is // case-insensitive so meta.json edits don't need to match this map's // capitalization exactly. Update keys here when section names change // in `content/docs/meta.json`. // // Includes both the agnostic root sections AND per-framework // `docs_mode: "authored"` sections (e.g. Built-in Agent's own IA, // whose meta.json lives at // `content/docs/integrations/built-in-agent/meta.json`). Authored // frameworks don't merge into the root tree, so they need their own // section names registered here to receive icons — otherwise the // section header renders without a glyph and looks visually distinct // from the generated-mode sidebars. const SECTION_ICONS: Record = { // Agnostic root sections (`content/docs/meta.json`). "get started": "lucide/Rocket", concepts: "lucide/BookOpen", "build chat uis": "lucide/MessageSquare", "build generative ui": "lucide/Paintbrush", "add agent powers": "lucide/Wand2", runtime: "lucide/Cpu", "observe & operate": "lucide/SearchCheck", enterprise: "custom/copilotkit-kite", deploy: "lucide/Cloud", other: "lucide/MoreHorizontal", // Built-in Agent (authored) sections — match the section names in // `content/docs/integrations/built-in-agent/meta.json`. Adjust here // when those section labels change in that meta.json. "getting started": "lucide/Rocket", basics: "lucide/BookOpen", "generative ui": "lucide/Paintbrush", "app control": "lucide/WandSparkles", "built-in agent": "lucide/Bot", backend: "lucide/Server", "premium features": "custom/copilotkit-kite", tutorials: "lucide/ListChecks", troubleshooting: "lucide/LifeBuoy", }; export function sectionIconFor(title: string): string | undefined { return SECTION_ICONS[title.toLowerCase()]; } /** * Extract the frontmatter block (content between leading `---` fences) * from a raw MDX/Markdown source. Returns empty string when the file * has no frontmatter. Used to scope frontmatter regexes so we don't * accidentally match `title:` or `description:` that happen to appear * inside the MDX body. */ function extractFrontmatter(raw: string): string { // Accept both LF and CRLF line endings so Windows-authored MDX // doesn't silently bypass frontmatter extraction. const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/); return fmMatch?.[1] ?? ""; } // Escape text for safe interpolation into an HTML attribute/text context. // Duplicated here (rather than imported from components/snippet.tsx) to // keep docs-render server-only / framework-free — snippet.tsx is a // client component and importing it here would pull client code into // the server bundle. If this ever grows, extract to a shared util. export function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } // Process-scoped memoization for frequently-called filesystem readers. // buildNavTree walks the entire content tree on every page render and // calls readTitle / readMeta O(pages) times per request. Without this // cache each call reopens and re-parses the file from disk. // // Production / build: cache the entire process lifetime — content is // frozen at deploy time so stale reads aren't possible. // // Development: skip the cache so meta.json / frontmatter edits show up // in the sidebar nav without restarting the dev server. The // performance hit is acceptable for local dev (the request path // already does plenty of fs work for MDX rendering). // // Memory footprint in production: titles are tiny strings, meta is a // small JSON object — negligible even with hundreds of docs files. const isDev = process.env.NODE_ENV === "development"; const titleCache = new Map(); const metaCache = new Map< string, { title?: string; pages?: string[]; root?: boolean } | null >(); // Tree-level cache. Even with title/meta cached, `buildNavTree` still // allocates ~200 NavNode objects per call and is invoked from every // page render (sometimes twice in the same render via DocsPageView + // the route's own pageTree). Keying on `${dir}|${prefix}` makes // repeated calls return the same array reference. Dev mode skips it // so meta.json edits propagate without a restart. const navTreeCache = new Map(); export function readTitle(filePath: string): string | null { const cacheKey = path.resolve(filePath); if (!isDev && titleCache.has(cacheKey)) return titleCache.get(cacheKey)!; if (!fs.existsSync(filePath)) { titleCache.set(cacheKey, null); return null; } let raw: string; try { raw = fs.readFileSync(filePath, "utf-8"); } catch (err) { // A single bad file / permission error used to crash the whole // page render. Log loudly and cache a null so we don't retry on // every nav build in the same process. console.error("[docs-render] failed to read", filePath, err); titleCache.set(cacheKey, null); return null; } // Restrict frontmatter matches to the frontmatter block so we don't // grab a `title:` line that lives inside an MDX body (e.g. inside a // code sample or example config). Falls back to the first H1 when no // frontmatter title is set. const fm = extractFrontmatter(raw); const fmMatch = fm.match(/^title:\s*["']?(.+?)["']?\s*$/m); let title: string | null = null; if (fmMatch) { title = fmMatch[1].replace(/["']$/, ""); } else { const headingMatch = raw.match(/^#\s+(.+)$/m); if (headingMatch) title = headingMatch[1]; } titleCache.set(cacheKey, title); return title; } // Read the `icon:` field from an MDX file's frontmatter. Mirrors // `readTitle` so the icon survives the same caching / extraction story // (frontmatter-scoped regex, dev cache bypass). Returns the raw spec // string (e.g. `"lucide/Paintbrush"`); the bridge resolves it to a // React element when building the PageTree. export function readIcon(filePath: string): string | null { const cacheKey = `icon:${path.resolve(filePath)}`; if (!isDev && titleCache.has(cacheKey)) return titleCache.get(cacheKey)!; if (!fs.existsSync(filePath)) { titleCache.set(cacheKey, null); return null; } let raw: string; try { raw = fs.readFileSync(filePath, "utf-8"); } catch { titleCache.set(cacheKey, null); return null; } const fm = extractFrontmatter(raw); const match = fm.match(/^icon:\s*["']?(.+?)["']?\s*$/m); const icon = match ? match[1].replace(/["']$/, "") : null; titleCache.set(cacheKey, icon); return icon; } // A meta.json `pages` entry is either a string (page slug, section // header `---Title---`, or spread `...folder`) or an inline-folder // object: `{ title, pages, defaultOpen?, icon? }`. Inline folders let // a parent meta.json declare a folder grouping without moving the // underlying MDX files into a subdirectory — useful for visual // grouping inside a single content tier. export type MetaPageEntry = | string | { title: string; pages: MetaPageEntry[]; defaultOpen?: boolean; icon?: string; }; export function readMeta(dir: string): { title?: string; pages?: MetaPageEntry[]; root?: boolean; icon?: string; } | null { const metaPath = path.join(dir, "meta.json"); const cacheKey = path.resolve(metaPath); if (!isDev && metaCache.has(cacheKey)) return metaCache.get(cacheKey)!; if (!fs.existsSync(metaPath)) { metaCache.set(cacheKey, null); return null; } try { const parsed = JSON.parse(fs.readFileSync(metaPath, "utf-8")); metaCache.set(cacheKey, parsed); return parsed; } catch (err) { // Previously swallowed silently — a malformed meta.json rendered // as "no nav ordering" with zero signal. Log loudly so authors // see the offending path and parse error. Cache the null so we // don't re-parse a bad file on every call. console.error("[docs-render] failed to parse", metaPath, err); metaCache.set(cacheKey, null); return null; } } export function buildNavTree(dir: string, prefix: string = ""): NavNode[] { const cacheKey = `${path.resolve(dir)}|${prefix}`; if (!isDev) { const cached = navTreeCache.get(cacheKey); if (cached) return cached; } const tree = buildNavTreeInner(dir, prefix); if (!isDev) navTreeCache.set(cacheKey, tree); return tree; } function buildNavTreeInner(dir: string, prefix: string): NavNode[] { const meta = readMeta(dir); if (!meta) return buildNavTreeFromFilesystem(dir, prefix); const pages = meta.pages; if (!pages || !Array.isArray(pages)) { return buildNavTreeFromFilesystem(dir, prefix); } return parseMetaPages(dir, prefix, pages); } // Parse a meta.json `pages` array into NavNodes. Recursive: inline-folder // objects re-enter via the recursive call so the inner page syntax stays // identical to the surrounding tree. function parseMetaPages( dir: string, prefix: string, pages: MetaPageEntry[], ): NavNode[] { const nodes: NavNode[] = []; for (const entry of pages) { // Inline-folder object: `{ title, pages, defaultOpen?, icon? }`. // Lets a meta.json declare a folder grouping without moving its // MDX files into a subdirectory. The inner pages re-enter this // same parser (via the recursive call below) so the syntax inside // an inline folder is identical to the surrounding tree. if (typeof entry === "object" && entry !== null) { const children = parseMetaPages(dir, prefix, entry.pages); if (children.length > 0) { nodes.push({ type: "group", title: entry.title, // No backing directory — slug is purely a stable key for // sidebar React reconciliation. Prefix it so the React key // doesn't collide with a real folder of the same name. slug: `${prefix}#${entry.title}`, children, defaultOpen: entry.defaultOpen, icon: entry.icon, }); } continue; } const sectionMatch = entry.match(/^---(.+)---$/); if (sectionMatch) { const title = sectionMatch[1]; nodes.push({ type: "section", title, icon: sectionIconFor(title) }); continue; } if (entry.startsWith("[")) continue; const spreadMatch = entry.match(/^\.\.\.(.+)$/); if (spreadMatch) { const subDir = path.join(dir, spreadMatch[1]); const subPrefix = prefix ? `${prefix}/${spreadMatch[1]}` : spreadMatch[1]; if (fs.existsSync(subDir) && fs.statSync(subDir).isDirectory()) { const subMeta = readMeta(subDir); if (subMeta?.root) continue; const subChildren = buildNavTree(subDir, subPrefix); if (subChildren.length > 0) { const rawGroupTitle = subMeta?.title || spreadMatch[1].replace(/[()-]/g, " ").replace(/\s+/g, " ").trim(); const groupTitle = rawGroupTitle.charAt(0).toUpperCase() + rawGroupTitle.slice(1); // If the previous emitted node is a section header with the // same title as this group's title, the section header // already labels this content. Suppress the group title // (empty string) so the renderer skips rendering it — the // group still wraps its children for indentation/nesting. // Without this dedup the sidebar shows "BUILD GENERATIVE UI" // (section, uppercase) followed immediately by "Build // Generative UI" (group, regular case) — same text, doubled. const prev = nodes[nodes.length - 1]; const isDuplicateOfSection = prev?.type === "section" && prev.title === groupTitle; nodes.push({ type: "group", title: isDuplicateOfSection ? "" : groupTitle, slug: subPrefix, children: subChildren, icon: subMeta?.icon, }); } } continue; } const slug = prefix ? `${prefix}/${entry}` : entry; const mdxFile = path.join(dir, `${entry}.mdx`); const indexFile = path.join(dir, entry, "index.mdx"); const subDir = path.join(dir, entry); // Special case: a literal `"index"` entry in a folder's meta.json // represents the folder's root page (URL = `/` with no // trailing slug). Always emit a page node — even when `index.mdx` // doesn't yet exist on disk — so the framework override nav can // rewrite it onto the bare `/` URL where the data-driven // `FrameworkOverview` renders. Title falls back to "Introduction" // when no MDX is present to read from. `buildFrameworkOverridesNav` // handles the final slug rewrite (`"index"` → `""`). if (entry === "index") { const title = fs.existsSync(mdxFile) ? readTitle(mdxFile) || "Introduction" : "Introduction"; // At the docs root (no prefix), `"index"` represents the bare // `/` page (the unscoped docs landing). Rewrite the slug to "" // so the bridge builds `/` rather than `/index`. Inside a // sub-folder, `"index"` keeps the folder-relative slug (e.g. // `agentic-protocols/index`) and `buildFrameworkOverridesNav` // handles the final framework-scoped rewrite separately. const indexSlug = prefix ? slug : ""; const icon = fs.existsSync(mdxFile) ? readIcon(mdxFile) : null; nodes.push({ type: "page", title, slug: indexSlug, icon: icon ?? undefined, }); continue; } if (fs.existsSync(mdxFile)) { const title = readTitle(mdxFile) || entry.split("/").pop()!.replace(/-/g, " "); const icon = readIcon(mdxFile); nodes.push({ type: "page", title, slug, icon: icon ?? undefined }); } else if (fs.existsSync(subDir) && fs.statSync(subDir).isDirectory()) { const subMeta = readMeta(subDir); if (subMeta?.root) continue; const subPrefix = prefix ? `${prefix}/${entry}` : entry; if (subMeta?.pages) { const subChildren = buildNavTree(subDir, subPrefix); const groupTitle = subMeta.title || entry.replace(/-/g, " "); nodes.push({ type: "group", title: groupTitle.charAt(0).toUpperCase() + groupTitle.slice(1), slug: subPrefix, children: subChildren, icon: subMeta.icon, }); } else if (fs.existsSync(indexFile)) { const title = readTitle(indexFile) || subMeta?.title || entry.replace(/-/g, " "); const icon = readIcon(indexFile); nodes.push({ type: "page", title, slug: subPrefix, icon: icon ?? undefined, }); } else { const subChildren = buildNavTreeFromFilesystem(subDir, subPrefix); if (subChildren.length > 0) { const groupTitle = subMeta?.title || entry.replace(/-/g, " "); nodes.push({ type: "group", title: groupTitle.charAt(0).toUpperCase() + groupTitle.slice(1), slug: subPrefix, children: subChildren, }); } } } else if (fs.existsSync(indexFile)) { const title = readTitle(indexFile) || entry.replace(/-/g, " "); nodes.push({ type: "page", title, slug }); } } return nodes; } export function buildNavTreeFromFilesystem( dir: string, prefix: string, ): NavNode[] { if (!fs.existsSync(dir)) return []; const nodes: NavNode[] = []; let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (err) { // Permission denied / ENOTDIR / etc. Log and return an empty tree // rather than crashing every docs page render downstream. console.error("[docs-render] failed to read dir", dir, err); return []; } for (const entry of entries) { if (entry.name.startsWith(".") || entry.name === "meta.json") continue; if (entry.name.startsWith("(")) continue; const slug = prefix ? `${prefix}/${entry.name.replace(".mdx", "")}` : entry.name.replace(".mdx", ""); if (entry.isDirectory()) { const subMeta = readMeta(path.join(dir, entry.name)); if (subMeta?.root) continue; const subChildren = buildNavTree(path.join(dir, entry.name), slug); if (subChildren.length > 0) { const groupTitle = subMeta?.title || entry.name.replace(/-/g, " "); nodes.push({ type: "group", title: groupTitle.charAt(0).toUpperCase() + groupTitle.slice(1), slug, children: subChildren, }); } } else if (entry.name.endsWith(".mdx") && entry.name !== "index.mdx") { const title = readTitle(path.join(dir, entry.name)) || entry.name.replace(".mdx", "").replace(/-/g, " "); nodes.push({ type: "page", title, slug }); } } return nodes; } /** * Walk `content/docs/integrations//` and return NavNodes for * pages that have NO root equivalent. These are framework-specific * topics (e.g. Built-in Agent's `copilot-runtime`, LangGraph's `auth` * and `subgraphs`) that live only in the per-framework tree and need * their own sidebar entries. Pages that duplicate root files are * skipped — root wins, and the framework view already renders the * root MDX with a framework override. * * Takes the resolved folder name (not the URL slug). Callers should * use `getDocsFolder(slug)` from lib/registry to map language/runtime * variants to their shared folder (e.g. langgraph-python / typescript * / fastapi → `langgraph/`). */ export function buildFrameworkOverridesNav(folder: string): NavNode[] { const frameworkDir = path.join(CONTENT_DIR, "integrations", folder); if (!fs.existsSync(frameworkDir)) return []; const nodes = buildNavTree(frameworkDir, `integrations/${folder}`); // Drop entries whose equivalent root file exists. Root wins when // both are present — the per-framework tree is only an escape hatch // for framework-specific topics, not an alternative rendering. const prefix = `integrations/${folder}/`; const rewriteSlug = (slug: string): string => { const stripped = slug.replace(prefix, ""); if (stripped === "index") return ""; if (stripped.endsWith("/index")) return stripped.slice(0, -"/index".length); return stripped; }; const rootEquivalentExists = (slug: string): boolean => { const rootSlug = rewriteSlug(slug); if (rootSlug === "") return false; const rootMdx = path.join(CONTENT_DIR, `${rootSlug}.mdx`); const rootIndex = path.join(CONTENT_DIR, rootSlug, "index.mdx"); return fs.existsSync(rootMdx) || fs.existsSync(rootIndex); }; const rewriteNode = (node: NavNode): NavNode | null => { if (node.type === "page") { if (rootEquivalentExists(node.slug)) return null; return { ...node, slug: rewriteSlug(node.slug) }; } if (node.type === "group") { const children = node.children .map(rewriteNode) .filter((child): child is NavNode => child !== null); if (children.length > 0) { return { ...node, slug: rewriteSlug(node.slug), children }; } return null; } // Intentionally drop section nodes. Per-framework meta.json files // tend to mirror the root tree's sections ("Getting Started", // "Basics", etc.) and flowing them through here would (a) collide // with root sections of the same name on React keys and (b) double // up the visual hierarchy — the override block is already wrapped // in a single `{frameworkName}` section by mergeFrameworkNav. return null; }; const filtered = nodes .map(rewriteNode) .filter((node): node is NavNode => node !== null); // Flatten empty-title wrapper groups. buildNavTree clears the title on // a spread-derived group when the preceding section header has the // same name (so the renderer doesn't double-print "Generative UI"). // After we drop section headers above, those wrappers are left as // titleless containers that only add an extra indent step around // their children. Inline the children at the wrapper's level instead. const flattened: NavNode[] = []; for (const node of filtered) { if (node.type === "group" && node.title === "") { flattened.push(...node.children); } else { flattened.push(node); } } return flattened; } /** * Build a sidebar that contains ONLY the per-framework MDX tree * (no merge with root nav, no root-equivalent filtering). Authored * integrations use this because their `integrations//meta.json` * is the source of truth for page order and section grouping. * * Slugs are rewritten to drop the `integrations//` prefix and * the literal `index` → "" rewrite, so links resolve at * `//` and the framework root at `/`. */ export function buildFrameworkOnlyNav(folder: string): NavNode[] { const frameworkDir = path.join(CONTENT_DIR, "integrations", folder); if (!fs.existsSync(frameworkDir)) return []; const nodes = buildNavTree(frameworkDir, `integrations/${folder}`); const prefix = `integrations/${folder}/`; // Recursive slug rewrite so nested groups (e.g. `human-in-the-loop/`, // `premium/`) also get the prefix stripped from their children. // // Two `index` cases need rewriting: // 1. Top-level `index` → "" so the framework-root entry resolves to // `/` (not `//index`). // 2. Nested `/index` → `` so a folder's own root page // (e.g. `human-in-the-loop/index.mdx`) resolves to // `//human-in-the-loop` (the folder URL), not // `//human-in-the-loop/index` which 404s. Without // this rewrite the sidebar links into folder-root pages dead-end. const rewrite = (node: NavNode): NavNode => { if (node.type === "page") { const stripped = node.slug.replace(prefix, ""); let slug = stripped; if (stripped === "index") slug = ""; else if (stripped.endsWith("/index")) slug = stripped.slice(0, -"/index".length); return { ...node, slug }; } if (node.type === "group") { const stripped = node.slug.replace(prefix, ""); return { ...node, slug: stripped, children: node.children.map(rewrite), }; } return node; }; return appendSharedRootSections(nodes.map(rewrite)); } const SHARED_ROOT_SECTIONS = ["Platforms"]; function sectionRange( navTree: NavNode[], sectionTitle: string, ): { start: number; end: number } | null { const start = navTree.findIndex( (node) => node.type === "section" && node.title.toLowerCase() === sectionTitle.toLowerCase(), ); if (start === -1) return null; const nextSection = navTree.findIndex( (node, index) => index > start && node.type === "section", ); return { start, end: nextSection === -1 ? navTree.length : nextSection }; } function hasPageSlug(navTree: NavNode[], slug: string): boolean { return navTree.some((node) => { if (node.type === "page") return node.slug === slug; if (node.type === "group") return hasPageSlug(node.children, slug); return false; }); } function filterMissingPages(node: NavNode, navTree: NavNode[]): NavNode | null { if (node.type === "page") { return hasPageSlug(navTree, node.slug) ? null : node; } if (node.type === "group") { const children = node.children .map((child) => filterMissingPages(child, navTree)) .filter((child): child is NavNode => child !== null); return children.length > 0 ? { ...node, children } : null; } return node; } /** * Authored framework sidebars own their page order, but some root docs * sections are global product guidance rather than framework IA. Keep * those shared sections in every framework sidebar without duplicating * entries across each authored integration's meta.json. */ function appendSharedRootSections(navTree: NavNode[]): NavNode[] { let nextNavTree = navTree; const rootNavTree = buildNavTree(CONTENT_DIR); for (const sectionTitle of SHARED_ROOT_SECTIONS) { const rootRange = sectionRange(rootNavTree, sectionTitle); if (!rootRange) continue; const section = rootNavTree[rootRange.start]; const missingNodes = rootNavTree .slice(rootRange.start + 1, rootRange.end) .map((node) => filterMissingPages(node, nextNavTree)) .filter((node): node is NavNode => node !== null); if (missingNodes.length === 0) continue; const existingRange = sectionRange(nextNavTree, sectionTitle); if (existingRange) { nextNavTree = [ ...nextNavTree.slice(0, existingRange.end), ...missingNodes, ...nextNavTree.slice(existingRange.end), ]; } else { nextNavTree = [...nextNavTree, section, ...missingNodes]; } } return nextNavTree; } // Map a framework slug to the section-header icon spec used by the // sidebar bridge. LangGraph variants (-python, -typescript, -fastapi) // share the LangGraph mark; other integrations have no custom mark yet // and fall back to no icon. Extend as we ship more. export function frameworkSectionIcon(framework: string): string | undefined { if (framework.startsWith("langgraph")) return "custom/langgraph"; return undefined; } /** * Merge per-framework overrides into the root nav tree. The override * block is inserted as a labeled section right after the agent-control * section in the root ordering. * * Authored and generated frameworks both use this merged shell so the * sidebar information architecture is stable across framework switches. * Content resolution still decides whether a given slug renders authored * MDX first or the generated/root page first. */ export function mergeFrameworkNav( rootNav: NavNode[], overrideNav: NavNode[], frameworkName: string, frameworkIcon?: string, ): NavNode[] { if (overrideNav.length === 0) return rootNav; // Pull the framework-root page (the "Introduction" entry from // integrations//meta.json's literal "index" slot — // buildFrameworkOverridesNav rewrites its slug to "") out of the override // nav so we can place it inside the global "Get Started" section instead // of stranding it above all section headers as a top-level prefix. const introIdx = overrideNav.findIndex( (n) => n.type === "page" && n.slug === "", ); const introNode = introIdx >= 0 ? overrideNav[introIdx] : null; const remainingOverrideNav = introIdx >= 0 ? [...overrideNav.slice(0, introIdx), ...overrideNav.slice(introIdx + 1)] : overrideNav; const sectionHeader: NavNode = { type: "section", title: frameworkName, icon: frameworkIcon, }; const isSection = (n: NavNode, title: string) => n.type === "section" && n.title.toLowerCase() === title.toLowerCase(); // Section names tried in priority order. The first match wins; the // override block is inserted right before the *next* section header // after the matched anchor. Update this list when the JTBD section // names change in content/docs/meta.json. const ANCHOR_CANDIDATES = [ "add agent powers", "give your app agent powers", "app control", "agents & backends", "backend", "runtime", ]; let insertAt = -1; for (const anchor of ANCHOR_CANDIDATES) { const anchorIdx = rootNav.findIndex((n) => isSection(n, anchor)); if (anchorIdx === -1) continue; for (let i = anchorIdx + 1; i < rootNav.length; i++) { if (rootNav[i].type === "section") { insertAt = i; break; } } if (insertAt !== -1) break; } // Reconcile the rootNav's existing root-level introduction with the // framework's own introNode. At a framework view we want exactly one // Introduction entry, and it should link to the framework root. const rootHasIntro = rootNav.some((n) => n.type === "page" && n.slug === ""); const rootNavWithIntro = (() => { if (!introNode) return rootNav; if (rootHasIntro) { return rootNav.map((n) => n.type === "page" && n.slug === "" ? introNode : n, ); } const getStartedIdx = rootNav.findIndex((n) => isSection(n, "get started")); if (getStartedIdx === -1) return [introNode, ...rootNav]; return [ ...rootNav.slice(0, getStartedIdx + 1), introNode, ...rootNav.slice(getStartedIdx + 1), ]; })(); if (insertAt === -1) { return [...rootNavWithIntro, sectionHeader, ...remainingOverrideNav]; } const getStartedIdx = rootNav.findIndex((n) => isSection(n, "get started")); const prepended = !!introNode && !rootHasIntro && getStartedIdx === -1; const splicedAfterAnchor = !!introNode && !rootHasIntro && getStartedIdx !== -1 && insertAt > getStartedIdx; const adjustedInsertAt = prepended || splicedAfterAnchor ? insertAt + 1 : insertAt; return [ ...rootNavWithIntro.slice(0, adjustedInsertAt), sectionHeader, ...remainingOverrideNav, ...rootNavWithIntro.slice(adjustedInsertAt), ]; } /** * Build the framework-scoped sidebar IA used by generated framework * routes. Generated docs share the root docs IA and layer sparse * framework-specific overrides into that tree. */ export function buildFrameworkNav( docsFolder: string, frameworkName: string, frameworkSlug: string, ): NavNode[] { return mergeFrameworkNav( buildNavTree(CONTENT_DIR), buildFrameworkOverridesNav(docsFolder), frameworkName, frameworkSectionIcon(frameworkSlug), ); } /** * Return the list of framework slugs whose `integrations//` * tree contains an MDX file for `slugPath`. Matches either * `.mdx` or `/index.mdx`. Used by the framework-scoped * router to detect that a topic is available in *some* framework but * not the one the user is currently viewing, so we can render a * helpful "not available for " page instead of a bare 404. * * Most slugs map 1:1 to their folder, but language/runtime variants * share one folder (langgraph-python/typescript/fastapi → `langgraph/`, * ms-agent-dotnet/python → `microsoft-agent-framework/`) and two * legacy slugs were renamed after the folder existed (google-adk → * `adk/`, strands → `aws-strands/`). The caller supplies the * slug→folder resolver so this helper stays decoupled from the registry's * docs-folder mapping. * * `docs_mode: hidden` frameworks are filtered out — the "Try X, Y, Z" * suggestion surfaces would otherwise dead-end on a 404 (those frameworks * have no `/` route by design). */ export function findFrameworksWithPage( slugPath: string, integrationSlugs: readonly string[], slugToFolder: (slug: string) => string, ): string[] { const matches: string[] = []; for (const slug of integrationSlugs) { if (getDocsMode(slug) === "hidden") continue; const folder = slugToFolder(slug); const mdx = path.join( CONTENT_DIR, "integrations", folder, `${slugPath}.mdx`, ); const indexMdx = path.join( CONTENT_DIR, "integrations", folder, slugPath, "index.mdx", ); if (fs.existsSync(mdx) || fs.existsSync(indexMdx)) matches.push(slug); } return matches; } // --------------------------------------------------------------------------- // Snippet inlining (same rules as the docs page) // --------------------------------------------------------------------------- // Maps `` MDX references to the relative snippet file // under SNIPPETS_DIR. Keys are JSX component names (PascalCase) and // must match EXACTLY what authors write in MDX — they are not // filesystem paths and are case-sensitive on both sides of the map. // // Aliases (same target under multiple keys) are intentional and exist // because the codebase historically shipped both spellings: // - `AgUI` / `AGUI` — two legal casings; keep both // so authors writing either render correctly. Upstream consumers // reach this via SUBPATH_TO_COMPONENT which uses `AGUI`. // - `FrontendTools` / `FrontEndToolsImpl` — historical name kept // for backward compat with existing MDX that still uses // `` (confirmed in live docs content). Don't // collapse these without first rewriting all .mdx references. // // Filename casing: `migrate-to-1.10.X.mdx` and `migrate-to-1.8.2.mdx` // match the on-disk files exactly (uppercase X in 1.10.X), verified // against src/content/snippets/shared/troubleshooting/. export const SNIPPET_MAP: Record = { A2UI: "shared/generative-ui/a2ui.mdx", AgUI: "shared/backend/ag-ui.mdx", AGUI: "shared/backend/ag-ui.mdx", // alias of AgUI BuildWithAgents: "shared/guides/build-with-agents.mdx", CodingAgents: "shared/coding-agents.mdx", CommonIssues: "shared/troubleshooting/common-issues.mdx", CopilotRuntime: "copilot-runtime.mdx", CustomAgent: "shared/backend/custom-agent.mdx", DebugMode: "shared/troubleshooting/debug-mode.mdx", DisplayOnly: "shared/generative-ui/display-only.mdx", ErrorDebugging: "shared/troubleshooting/error-debugging.mdx", FrontendTools: "shared/app-control/frontend-tools.mdx", FrontEndToolsImpl: "shared/app-control/frontend-tools.mdx", // alias of FrontendTools GenerativeUISpecsOverview: "shared/generative-ui-specs-overview.mdx", HeadlessUI: "shared/basics/headless-ui.mdx", Inspector: "shared/premium/inspector.mdx", Interactive: "shared/generative-ui/interactive.mdx", MCPApps: "shared/generative-ui/mcp-apps.mdx", MCPSetup: "shared/guides/mcp-server-setup.mdx", MigrateTo1100: "shared/troubleshooting/migrate-to-1.10.X.mdx", MigrateTo182: "shared/troubleshooting/migrate-to-1.8.2.mdx", MigrateToV2: "shared/troubleshooting/migrate-to-v2.mdx", Observability: "shared/premium/observability.mdx", ObservabilityConnectors: "shared/troubleshooting/observability-connectors.mdx", Overview: "shared/premium/overview.mdx", PrebuiltComponents: "shared/basics/prebuilt-components.mdx", ProgrammaticControl: "shared/basics/programmatic-control.mdx", ReasoningMessages: "shared/guides/custom-look-and-feel/reasoning-messages.mdx", SelfHosting: "shared/premium/self-hosting.mdx", Slots: "shared/basics/slots.mdx", Threads: "shared/threads/threads.mdx", ToolRenderer: "shared/generative-ui/tool-rendering.mdx", // alias of ToolRendering ToolRendering: "shared/generative-ui/tool-rendering.mdx", DefaultToolRendering: "shared/guides/default-tool-rendering.mdx", // Versionless aliases retained for backward compat with older MDX that // emits `` / ``; both resolve to v2. MigrateTo: "shared/troubleshooting/migrate-to-v2.mdx", MigrateToV: "shared/troubleshooting/migrate-to-v2.mdx", CopilotUI: "copilot-ui.mdx", LandingCodeShowcase: "landing-code-showcase.mdx", UseAgentSnippet: "use-agent.mdx", InstallSDKSnippet: "install-sdk.mdx", InstallPythonSDK: "install-python-sdk.mdx", RunAndConnect: "coagents/run-and-connect-agent.mdx", RunAndConnectSnippet: "coagents/run-and-connect-agent.mdx", // alias of RunAndConnect CopilotCloudConfigureCopilotKitProvider: "copilot-cloud-configure-copilotkit-provider.mdx", // Historical spelling (no `Provider` suffix) still appears in tutorials. CopilotCloudConfigureCopilotKit: "copilot-cloud-configure-copilotkit-provider.mdx", SelfHostingCopilotRuntimeCreateEndpoint: "self-hosting-copilot-runtime-create-endpoint.mdx", SelfHostingCopilotRuntimeConfigureCopilotKitProvider: "self-hosting-copilot-runtime-configure-copilotkit-provider.mdx", SelfHostingCopilotRuntimeConfigureCopilotKit: "self-hosting-copilot-runtime-configure-copilotkit-provider.mdx", }; export const SUBPATH_TO_COMPONENT: Record = { "ag-ui": "AGUI", "build-with-agents": "CodingAgents", "copilot-runtime": "CopilotRuntime", "custom-look-and-feel/headless-ui": "HeadlessUI", "custom-look-and-feel/slots": "Slots", "frontend-tools": "FrontendTools", "generative-ui/a2ui": "A2UI", "generative-ui/mcp-apps": "MCPApps", "generative-ui/tool-rendering": "ToolRendering", "generative-ui/your-components/display-only": "DisplayOnly", "generative-ui/your-components/interactive": "Interactive", inspector: "Inspector", "prebuilt-components": "PrebuiltComponents", "programmatic-control": "ProgrammaticControl", "premium/headless-ui": "HeadlessUI", "premium/observability": "Observability", "premium/overview": "Overview", "troubleshooting/common-issues": "CommonIssues", "troubleshooting/error-debugging": "ErrorDebugging", "troubleshooting/migrate-to-1.10.X": "MigrateTo1100", "troubleshooting/migrate-to-1.8.2": "MigrateTo182", "troubleshooting/migrate-to-v2": "MigrateToV2", "troubleshooting/observability-connectors": "ObservabilityConnectors", }; /** * Strip leading `import ...` statements from an MDX source WITHOUT * touching lines inside fenced code blocks. Previously we ran * `/^import\s+.+$/gm` over the whole source, which mangled real code * samples like `import os` inside python fences. This implementation * walks the source line-by-line, tracks fence state with ``` / ~~~, * and only strips `import` lines that appear at the top of the file * (before the first non-import, non-blank content line). Import lines * that appear *later* in the prose are also preserved — the only ones * we want to remove are MDX's JSX component imports, which always sit * in the top-of-file block. * * Covered by: snippet contents that include a Python/JS code fence * with an `import` statement must have the `import` preserved in the * rendered output. */ export function stripLeadingImports(source: string): string { const lines = source.split("\n"); const out: string[] = []; let inFence = false; let fenceMarker: string | null = null; let pastHeader = false; // When an `import { ... }` is split across lines, the opening line // matches the single-line drop rule but the continuation lines // (` Foo,`, `} from "...";`) do not — historically those continuation // lines fell into the content branch and flipped `pastHeader = true`, // which then preserved every subsequent import in the MDX body and // produced runtime errors like `

{Tab}

`. Track an open import // explicitly so we consume continuations through the closing `from "...";`. let inMultilineImport = false; for (const line of lines) { // Toggle fence state. Match the opening fence's marker (``` or ~~~) // so a stray triple-backtick inside a tilde fence (or vice-versa) // doesn't prematurely close it. const fenceMatch = line.match(/^\s*(```+|~~~+)/); if (fenceMatch) { if (!inFence) { inFence = true; // Store the full fence marker (e.g. ``` or ~~~~) rather than // its first character, so a single stray `\`` inside a ``` // fence doesn't prematurely close the block. fenceMarker = fenceMatch[1]; } else if (fenceMarker && line.trim().startsWith(fenceMarker)) { inFence = false; fenceMarker = null; } pastHeader = true; // fences are content out.push(line); continue; } if (!inFence && !pastHeader) { if (inMultilineImport) { // Continuation of a multi-line import block. Terminate when we // see the closing `from "..."` clause (with OR without the // trailing semicolon — modern style routinely omits the `;`). // Don't terminate purely on `;` — a bare `;` rarely appears // mid-import, and JSX expressions on subsequent body lines // (`` shown as runtime * usage in slots.mdx) rather than as snippet imports. */ function isInsideCodeFence(content: string, offset: number): boolean { // Split the text up to the match into completed lines + a possibly // partial trailing line. We treat all completed lines as candidate // fence boundaries and the trailing partial line as the context for // inline-code (single-backtick) detection. const lines = content.slice(0, offset).split("\n"); const completed = lines.slice(0, -1); const currentLine = lines[lines.length - 1] ?? ""; // Fenced blocks: walk completed lines and toggle on matching // opener/closer. CommonMark allows up to 3 leading spaces; MDX in // shell-docs is more permissive — fences inside `` and other // JSX containers are routinely indented 8+ spaces. Match any // leading whitespace so those fences aren't missed. let inFence = false; let openerChar: string | null = null; for (const line of completed) { const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/); if (!fenceMatch) continue; const marker = fenceMatch[1]; if (!inFence) { inFence = true; openerChar = marker[0]; } else if (marker[0] === openerChar) { inFence = false; openerChar = null; } } if (inFence) return true; // Inline code: count single-backtick toggles on the partial current // line. A single backtick opens an inline span that closes on the // next single backtick. Runs of 2+ backticks are rare in prose // (literal-backtick spans) and intentionally ignored so the common // `` case is caught reliably. let inlineToggles = 0; let i = 0; while (i < currentLine.length) { if (currentLine[i] !== "`") { i++; continue; } let run = 0; while (i + run < currentLine.length && currentLine[i + run] === "`") { run++; } if (run === 1) inlineToggles++; i += run; } return inlineToggles % 2 === 1; } /** * Names that look like JSX components (PascalCase) imported into the MDX * via `import { ... }` or `import Foo` from any path. Imports from * `@/snippets/...` are tracked separately so recursive snippet imports can * be inlined by their import target instead of requiring every helper in * SNIPPET_MAP. Other imports are treated as runtime React components * resolved at render time via the docsComponents registry. */ function gatherMdxImportComponentInfo(source: string): { runtimeComponentNames: Set; snippetRelByComponent: Map; } { const runtimeComponentNames = new Set(); const snippetRelByComponent = new Map(); const importRegex = /^import\s+(?:type\s+)?([\s\S]*?)\s+from\s+["']([^"']+)["']\s*;?\s*$/gm; let m: RegExpExecArray | null; while ((m = importRegex.exec(source)) !== null) { const importClause = m[1].trim(); const importPath = m[2]; const snippetRel = importPath.startsWith("@/snippets/") ? importPath.slice("@/snippets/".length) : null; const names = componentNamesFromImportClause(importClause); for (const name of names) { if (snippetRel) { snippetRelByComponent.set(name, snippetRel); } else { runtimeComponentNames.add(name); } } } return { runtimeComponentNames, snippetRelByComponent }; } function componentNamesFromImportClause(importClause: string): string[] { const names = new Set(); const defaultMatch = importClause.match(/^([A-Z]\w*)\s*(?:,|$)/); if (defaultMatch) names.add(defaultMatch[1]); const namespaceMatch = importClause.match(/^\*\s+as\s+([A-Z]\w*)$/); if (namespaceMatch) names.add(namespaceMatch[1]); const namedMatch = importClause.match(/\{([^}]+)\}/); if (namedMatch) { for (const part of namedMatch[1].split(",")) { const renamed = part.trim().split(/\s+as\s+/); const name = renamed[renamed.length - 1].trim(); if (/^[A-Z]\w*$/.test(name)) names.add(name); } } return [...names]; } export function inlineSnippets( content: string, slugPath: string = "", seen: Set = new Set(), ): string { const { runtimeComponentNames, snippetRelByComponent } = gatherMdxImportComponentInfo(content); let result = stripLeadingImports(content); result = result.replace( /<([A-Z]\w*)\s*(?:components=\{[^}]*\}\s*)?\/>/g, (match, componentName, offset: number, source: string) => { // Skip JSX-looking strings inside code fences / inline code: those // are rendered example code, not snippet imports. Suppresses the // bulk of `[docs-render] snippet missing` warnings that surfaced // post-cutover (e.g. , , // shown as usage examples inside ```tsx ... ``` blocks). if (isInsideCodeFence(source, offset)) { return match; } let snippetRel = SNIPPET_MAP[componentName] ?? snippetRelByComponent.get(componentName); if (!snippetRel && componentName === "SharedContent" && slugPath) { // The docs page could live at any of these URL shapes: // - integrations// (legacy per-framework docs) // - unselected/ (new unselected tree) // - (framework-scoped // //, // which arrives with no // prefix here) // Try each shape in order to find a SUBPATH_TO_COMPONENT match. const candidateSubpaths: string[] = []; const integrationsMatch = slugPath.match(/^integrations\/[^/]+\/(.+)$/); if (integrationsMatch) candidateSubpaths.push(integrationsMatch[1]); if (slugPath.startsWith("unselected/")) { candidateSubpaths.push(slugPath.slice("unselected/".length)); } candidateSubpaths.push(slugPath); for (const sub of candidateSubpaths) { const resolvedComponent = SUBPATH_TO_COMPONENT[sub]; if (resolvedComponent) { snippetRel = SNIPPET_MAP[resolvedComponent]; if (snippetRel) break; } } } if (!snippetRel) { // Components ending in `Icon` are conventionally lucide-react // icons. shell-docs's MDX renders them via the `docsComponents` // global registry in mdx-registry.tsx, so they're real runtime // React components — not snippet imports. Skip silently rather // than warn (matches the same shape as the fence-aware short // circuit above for `` in prose backticks). if (componentName.endsWith("Icon")) { return match; } // Icon-library components also hit the inliner as bare JSX // references (no explicit import — the registry provides them // via docsComponents at render time). Lucide square-prefixed // icons (SquareTerminal, SquareChartGantt, etc.), react-icons // fa/si/pi prefixes, and similar PascalCase + icon-library // shapes don't match the trailing-Icon filter above. Skip // them by name shape so the inliner doesn't log a warning for // every icon usage. if (/^(Fa|Si|Pi|Square)[A-Z]/.test(componentName)) { return match; } // Skip components the MDX explicitly imports. They're real React // components rendered through the docsComponents registry at // request time, not snippet references. stripLeadingImports() // above removes the import line; gatherMdxImportComponentInfo() // preserved the runtime import set so the inliner can tell these // apart from genuine missing-snippet cases. if (runtimeComponentNames.has(componentName)) { return match; } // Log so docs authors see a clean signal when a // reference can't be mapped to a snippet file (previously the // component just silently rendered nothing). Matches inside code // fences are short-circuited above so this warning only fires on // genuine prose-level references. console.warn( "[docs-render] snippet missing for component", componentName, "from slug", slugPath || "(none)", ); return match; } // Even though snippetRel comes from the hardcoded SNIPPET_MAP, // route through resolveWithinDir for defense-in-depth — any // future addition that builds the relative path from user // input is guarded by default. const snippetPath = resolveWithinDir(SNIPPETS_DIR, snippetRel); if (!snippetPath || !fs.existsSync(snippetPath)) { console.warn( "[docs-render] snippet file not found", snippetRel, "for component", componentName, "from slug", slugPath || "(none)", ); return match; } // Cycle protection: if this snippet file is already in-flight // higher up the recursion, emit a warning and stop. Without // this, a self-referencing or mutually-referencing snippet // loops until the stack overflows. if (seen.has(snippetPath)) { console.warn( "[docs-render] snippet cycle detected, refusing to re-inline", snippetPath, ); return `{/* snippet cycle: ${componentName} */}`; } let snippetContent: string; try { snippetContent = fs.readFileSync(snippetPath, "utf-8"); } catch (err) { // Previously a permission error / missing file mid-render // crashed the entire docs page. Log and leave the original // reference in the rendered output. console.error("[docs-render] failed to read snippet", snippetPath, err); return match; } snippetContent = snippetContent.replace(/^---[\s\S]*?---\r?\n?/, ""); const nextSeen = new Set(seen); nextSeen.add(snippetPath); return inlineSnippets(snippetContent, slugPath, nextSeen); }, ); return result; } // --------------------------------------------------------------------------- // Markdown tables inside JSX container tags // --------------------------------------------------------------------------- // JSX container components whose inner Markdown table bodies should be // promoted to real HTML tables. Previously only `Accordion` and `Tab` // were covered, which silently failed for tables inside `Callout`, // `Card`, `Step`, `Tabs`, etc. Keep this list in rough sync with the // container-ish entries in mdx-registry.tsx — any JSX component that // may wrap prose-like MDX bodies belongs here. const JSX_CONTAINER_TAGS = [ "Accordion", "Tab", "Tabs", "Callout", "Card", "Cards", "Step", "Steps", ]; // Split a GFM table row into cell values. GFM allows tables WITHOUT // the outer leading/trailing pipes (e.g. "a | b | c"), so we can't // unconditionally drop the first and last cells. Instead, drop a // leading/trailing cell only when it's empty (the artifact of an outer // pipe); keep genuine first/last cells. function parseTableRow(line: string): string[] { const cells = line.split("|").map((cell) => cell.trim()); if (cells.length > 0 && cells[0] === "") cells.shift(); if (cells.length > 0 && cells[cells.length - 1] === "") cells.pop(); return cells; } function convertMarkdownTableToHtml(tableLines: string[]): string { if (tableLines.length < 2) return tableLines.join("\n"); const headers = parseTableRow(tableLines[0]); const separatorLine = tableLines[1]; // Accept GFM separator lines with or without outer pipes. Must // contain at least one `|` and otherwise consist of spaces, colons, // and dashes only. if ( !/^\s*[|:\- ]*\|[|:\- ]*\s*$/.test(separatorLine) || !/\|/.test(separatorLine) ) { return tableLines.join("\n"); } const bodyRows = tableLines.slice(2).map(parseTableRow); // Escape every interpolated cell value. Without this, any MDX table // cell containing `