chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export default function DocsError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
useEffect(() => {
|
||||
// Log the full Error instance (not just .message/.digest) so the
|
||||
// stack trace reaches the server log / browser devtools. Include
|
||||
// pathname where available so reports can be tied back to a page.
|
||||
console.error(
|
||||
`[docs] Page render error${pathname ? ` on ${pathname}` : ""}:`,
|
||||
error,
|
||||
);
|
||||
}, [error, pathname]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<div className="text-center max-w-md">
|
||||
<h2 className="text-lg font-semibold text-[var(--text)] mb-2">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-muted)] mb-4">
|
||||
We hit an error rendering this page. Please refresh, and if it keeps
|
||||
happening, report it with the ID below so we can track it down.
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-xs text-[var(--text-faint)] mb-4 font-mono">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={reset}
|
||||
className="shell-docs-radius-control h-10 border border-[var(--border)] px-4 text-sm text-[var(--text-secondary)] transition-colors hover:bg-[var(--bg-elevated)]"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// /<...slug> — the root docs entry point.
|
||||
//
|
||||
// The Built-in Agent (the default framework) is served at the root
|
||||
// surface: the bare `/` renders the docs overview with the BIA sidebar,
|
||||
// and `/<slug>` URLs resolve BIA-authored pages first (see
|
||||
// UnscopedDocsPage). Other frameworks remain at `/<framework>/<slug>`.
|
||||
|
||||
import React from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { DocsLandingNext } from "@/components/docs-landing-next";
|
||||
import { HeroQuickstartDropdown } from "@/components/hero-quickstart-dropdown";
|
||||
import {
|
||||
HeroStartActions,
|
||||
LearnMoreAgentsLink,
|
||||
} from "@/components/hero-start-commands";
|
||||
import { LandingSampleTabs } from "@/components/landing-sample-tabs";
|
||||
import { ShellDocsLayout } from "@/components/shell-docs-layout";
|
||||
import { SidebarFrameworkSelector } from "@/components/sidebar-framework-selector";
|
||||
import { UnscopedDocsPage } from "@/components/unscoped-docs-page";
|
||||
import {
|
||||
buildFrameworkNav,
|
||||
buildRootSurfaceNav,
|
||||
loadDoc,
|
||||
} from "@/lib/docs-render";
|
||||
import { compareByDisplayOrder } from "@/lib/framework-order";
|
||||
import { navTreeToPageTree } from "@/lib/page-tree-bridge";
|
||||
import {
|
||||
getDocsFolder,
|
||||
getDocsMode,
|
||||
getIntegration,
|
||||
getIntegrations,
|
||||
ROOT_FRAMEWORK,
|
||||
} from "@/lib/registry";
|
||||
import { buildDocMetadata } from "@/lib/seo-metadata";
|
||||
|
||||
// Force dynamic rendering so unknown slugs reliably return HTTP 404
|
||||
// from `notFound()` instead of being cached as a 200 with the not-found
|
||||
// UI baked in (the search-engine-killing soft-404). The bare home page
|
||||
// and known unscoped docs are still cheap to render — they're
|
||||
// filesystem reads of MDX content — and Railway / upstream CDN caches
|
||||
// successful responses at the edge anyway.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Soft-default framework rendered on the bare `/` URL — the same
|
||||
// framework whose docs are served at the root surface, so the sidebar
|
||||
// tree on `/` is identical to what the user sees after clicking any
|
||||
// Built-in Agent sidebar link.
|
||||
const HOME_DEFAULT_FRAMEWORK = ROOT_FRAMEWORK;
|
||||
|
||||
// Per-framework self-canonical: each variant of a doc page declares
|
||||
// itself canonical so search engines index every framework's quickstart
|
||||
// (etc.) at its own URL rather than collapsing them all onto the bare
|
||||
// /quickstart. Done at the page level so the metadata depends on params.
|
||||
//
|
||||
// For the bare home page we hand-wire title/description so visitors and
|
||||
// social platforms see CopilotKit's positioning rather than the page's
|
||||
// own first MDX line.
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const slugPath = slug?.join("/") ?? "";
|
||||
const canonicalPath = slugPath ? `/${slugPath}` : "/";
|
||||
// Home page: brand-level title + tagline. Other unscoped slugs (e.g.
|
||||
// /quickstart, /concepts/architecture) read frontmatter via loadDoc.
|
||||
if (!slugPath) {
|
||||
return buildDocMetadata({
|
||||
title: "CopilotKit: the frontend stack for agents",
|
||||
description:
|
||||
"Connect any agent framework or model to your React app for chat, generative UI, canvas, and human-in-the-loop workflows.",
|
||||
canonicalPath: "/",
|
||||
});
|
||||
}
|
||||
// Root URLs serve the BIA-authored page when one exists (see
|
||||
// UnscopedDocsPage) — mirror that resolution for metadata.
|
||||
const doc =
|
||||
loadDoc(
|
||||
`integrations/${getDocsFolder(HOME_DEFAULT_FRAMEWORK)}/${slugPath}`,
|
||||
) ?? loadDoc(slugPath);
|
||||
return buildDocMetadata({
|
||||
title: doc?.fm.title ?? slugPath,
|
||||
description: doc?.fm.description,
|
||||
canonicalPath,
|
||||
ogPath: `/og${canonicalPath}/og.png`,
|
||||
});
|
||||
}
|
||||
|
||||
function DocsOverview() {
|
||||
// Sidebar matches the soft-default framework so home `/` and the
|
||||
// root-served BIA pages share the same authored IA. The empty href
|
||||
// prefix serves every sidebar link at the root (`/quickstart`, …);
|
||||
// the tree's `index` entry resolves to `/` and gets the active
|
||||
// highlight on landing.
|
||||
const docsFolder = getDocsFolder(HOME_DEFAULT_FRAMEWORK);
|
||||
const integrationName =
|
||||
getIntegration(HOME_DEFAULT_FRAMEWORK)?.name ?? "Built-in Agent";
|
||||
// Same unified root-surface sidebar every other root page uses, so the
|
||||
// sidebar is stable from the home page into any doc.
|
||||
const navTree =
|
||||
getDocsMode(HOME_DEFAULT_FRAMEWORK) === "authored"
|
||||
? buildRootSurfaceNav(docsFolder)
|
||||
: buildFrameworkNav(docsFolder, integrationName, HOME_DEFAULT_FRAMEWORK);
|
||||
const pageTree = navTreeToPageTree(navTree, "");
|
||||
|
||||
// The home hero has no framework context, so its quickstart CTA is the
|
||||
// framework picker dropdown (same accent treatment as the framework pages'
|
||||
// direct quickstart link). The default framework sorts first; its
|
||||
// quickstart lives at the root.
|
||||
const quickstartOptions = getIntegrations()
|
||||
.filter((i) => getDocsMode(i.slug) !== "hidden")
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.slug === HOME_DEFAULT_FRAMEWORK) return -1;
|
||||
if (b.slug === HOME_DEFAULT_FRAMEWORK) return 1;
|
||||
return compareByDisplayOrder(a.slug, b.slug);
|
||||
})
|
||||
.map((i) => ({
|
||||
slug: i.slug,
|
||||
name: i.slug === HOME_DEFAULT_FRAMEWORK ? "CopilotKit (Default)" : i.name,
|
||||
logo: i.logo ?? null,
|
||||
href:
|
||||
i.slug === HOME_DEFAULT_FRAMEWORK
|
||||
? "/quickstart"
|
||||
: `/${i.slug}/quickstart`,
|
||||
}));
|
||||
return (
|
||||
<ShellDocsLayout tree={pageTree} banner={<SidebarFrameworkSelector />}>
|
||||
<div className="docs-inner-content max-w-[1040px] mx-auto px-4 md:px-6 pt-0 pb-6">
|
||||
<section className="relative border-b border-[var(--border)] pb-6 sm:pb-7">
|
||||
<div className="flex max-w-[765px] flex-col">
|
||||
<div>
|
||||
<h1 className="max-w-[24ch] text-[2rem] font-semibold leading-[1.08] tracking-[-0.02em] text-[var(--text)] sm:text-[2.5rem] md:mt-3">
|
||||
CopilotKit
|
||||
</h1>
|
||||
<p className="mt-3 max-w-[58ch] text-lg font-medium leading-snug text-[var(--text-muted)] sm:text-[1.375rem]">
|
||||
The frontend stack for agentic user experience.
|
||||
</p>
|
||||
<p className="mt-4 max-w-[58ch] text-base leading-[1.55] text-[var(--text-secondary)] sm:text-lg">
|
||||
Build production chat, generative UI, shared state, and
|
||||
human-in-the-loop workflows on any AG-UI compatible backend.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-7">
|
||||
<HeroStartActions
|
||||
quickstart={
|
||||
<HeroQuickstartDropdown options={quickstartOptions} />
|
||||
}
|
||||
trailing={<LearnMoreAgentsLink />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-10 pt-8">
|
||||
<LandingSampleTabs />
|
||||
<DocsLandingNext />
|
||||
</div>
|
||||
</div>
|
||||
</ShellDocsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function DocsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
|
||||
// Overview page when no slug — the only path this route exclusively owns.
|
||||
// All other paths (e.g. /quickstart) are intercepted by [framework] first
|
||||
// due to Next.js routing precedence and fall through to UnscopedDocsPage there.
|
||||
if (!slug || slug.length === 0) {
|
||||
return <DocsOverview />;
|
||||
}
|
||||
|
||||
return <UnscopedDocsPage slugPath={slug.join("/")} />;
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const pageSource = readFileSync(
|
||||
new URL("../page.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("FrameworkRootShell layout", () => {
|
||||
it("does not add top padding above framework landing content", () => {
|
||||
const shellSource = pageSource.match(
|
||||
/function FrameworkRootShell[\s\S]*?<\/ShellDocsLayout>/,
|
||||
)?.[0];
|
||||
|
||||
expect(shellSource).toContain(
|
||||
'className="docs-inner-content max-w-[900px] mx-auto px-4 md:px-6 pt-0 pb-6"',
|
||||
);
|
||||
expect(shellSource).not.toContain("pt-2 pb-6 md:pt-3 xl:pt-4");
|
||||
});
|
||||
|
||||
it("parses frontend routes before resolving frontend content slugs", () => {
|
||||
expect(pageSource).toContain("parseFrontendRoutePath");
|
||||
expect(pageSource).toContain("activeBackendFramework");
|
||||
expect(pageSource).toContain("frameworkOverride={activeBackendFramework}");
|
||||
});
|
||||
|
||||
it("redirects retired frontend URL shapes instead of rendering them", () => {
|
||||
expect(pageSource).toContain('if (framework === "frontends")');
|
||||
expect(pageSource).toContain("legacyFrontendPathRedirect(");
|
||||
expect(pageSource).toContain(
|
||||
"const frontendRedirect = legacyFrontendPathRedirect(",
|
||||
);
|
||||
});
|
||||
|
||||
it("canonicalizes React guidance routes to the React root", () => {
|
||||
expect(pageSource).toContain(
|
||||
'return frontendPathForBackend("react", slugPath);',
|
||||
);
|
||||
});
|
||||
|
||||
it("renders backend docs when a frontend route includes a backend slug", () => {
|
||||
expect(pageSource).toContain("scopedFramework = activeBackendFramework");
|
||||
expect(pageSource).toContain("scopedSlugHrefPrefix = frontendRoutePath(");
|
||||
expect(pageSource).toContain("frameworkOverride={scopedFramework}");
|
||||
expect(pageSource).toContain(
|
||||
"slugHrefPrefix={scopedSlugHrefPrefix ?? `/${scopedFramework}`}",
|
||||
);
|
||||
expect(pageSource).toContain(
|
||||
"preferIndexMdx={Boolean(scopedSlugHrefPrefix)}",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps frontend root pages available under frontend/backend routes", () => {
|
||||
const frontendRootIndex = pageSource.indexOf(
|
||||
"if (!activeFrontendSlugPath) {\n return (\n <FrontendQuickstartDocsPage",
|
||||
);
|
||||
const backendScopingIndex = pageSource.indexOf(
|
||||
"if (activeBackendFramework) {\n scopedFramework = activeBackendFramework",
|
||||
);
|
||||
|
||||
expect(frontendRootIndex).toBeGreaterThan(-1);
|
||||
expect(backendScopingIndex).toBeGreaterThan(-1);
|
||||
expect(frontendRootIndex).toBeLessThan(backendScopingIndex);
|
||||
});
|
||||
|
||||
it("keeps frontend guidance pages available under frontend/backend routes", () => {
|
||||
const guidanceIndex = pageSource.indexOf(
|
||||
"if (isFrontendGuidanceSlug(activeFrontendSlugPath))",
|
||||
);
|
||||
const backendScopingIndex = pageSource.indexOf(
|
||||
"if (activeBackendFramework) {\n scopedFramework = activeBackendFramework",
|
||||
);
|
||||
|
||||
expect(guidanceIndex).toBeGreaterThan(-1);
|
||||
expect(backendScopingIndex).toBeGreaterThan(-1);
|
||||
expect(guidanceIndex).toBeLessThan(backendScopingIndex);
|
||||
expect(pageSource).toContain("<FrontendGuidanceDocsPage");
|
||||
});
|
||||
|
||||
it("uses backend metadata for frontend routes that include a backend slug", () => {
|
||||
expect(pageSource).toContain("frameworkMetadata(");
|
||||
expect(pageSource).toMatch(
|
||||
/frameworkMetadata\(\s*activeBackendFramework,\s*activeFrontendSlugPath/s,
|
||||
);
|
||||
expect(pageSource).toContain("scopedRoutePath(");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const globalsCss = readFileSync(
|
||||
new URL("../globals.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
describe("globals.css mobile docs layout", () => {
|
||||
it("collapses the Fumadocs grid to one content column on mobile", () => {
|
||||
expect(globalsCss).toContain(
|
||||
"grid-template-columns: minmax(0, 1fr) !important;",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not double-count the announcement banner in sub-xl docs layout offsets", () => {
|
||||
const subXlDocsLayoutRules = globalsCss.matchAll(
|
||||
/@media \((?:max-width: 767px|min-width: 768px\) and \(max-width: 1279px)\) \{\n #nd-docs-layout \{(?<body>[\s\S]*?)\n \}/g,
|
||||
);
|
||||
|
||||
const bodies = Array.from(
|
||||
subXlDocsLayoutRules,
|
||||
(match) => match.groups?.body ?? "",
|
||||
);
|
||||
|
||||
expect(bodies).toHaveLength(2);
|
||||
const [mobileBody, tabletBody] = bodies;
|
||||
for (const body of bodies) {
|
||||
expect(body).toContain("--fd-docs-row-1: 0px !important;");
|
||||
expect(body).not.toContain("--fd-banner-height");
|
||||
}
|
||||
expect(mobileBody).toContain(
|
||||
"padding-top: calc(var(--fd-nav-height) + 1rem) !important;",
|
||||
);
|
||||
expect(tabletBody).toContain(
|
||||
"padding-top: var(--fd-nav-height) !important;",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds extra left breathing room when the tablet sidebar is visible", () => {
|
||||
expect(globalsCss).toContain(
|
||||
"@media (min-width: 768px) and (max-width: 1279px) {\n .docs-inner-content {\n padding-left: 24px !important;",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("globals.css docs headings", () => {
|
||||
it("keeps heading anchors in block-level heading rows", () => {
|
||||
expect(globalsCss).toContain(
|
||||
".reference-content .docs-heading {\n display: flex;",
|
||||
);
|
||||
expect(globalsCss).not.toContain(
|
||||
".reference-content .docs-heading {\n display: inline-flex;",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("globals.css cookbook sidebar", () => {
|
||||
it("removes the empty cookbook sidebar banner and aligns the recipe list", () => {
|
||||
const normalizedGlobalsCss = normalizeWhitespace(globalsCss);
|
||||
|
||||
expect(normalizedGlobalsCss).toContain(
|
||||
normalizeWhitespace(`
|
||||
.shell-docs-sidebar-cookbook > div:first-child {
|
||||
display: none !important;
|
||||
}
|
||||
`),
|
||||
);
|
||||
expect(normalizedGlobalsCss).toContain(
|
||||
normalizeWhitespace(`
|
||||
.shell-docs-sidebar-cookbook [data-radix-scroll-area-viewport] > div:first-child {
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 1.5rem !important;
|
||||
}
|
||||
`),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
|
||||
function readPublicAsset(relativePath: string) {
|
||||
return readFileSync(
|
||||
new URL(`../../../public/${relativePath}`, import.meta.url),
|
||||
);
|
||||
}
|
||||
|
||||
describe("public image assets", () => {
|
||||
it("serves agentic protocol diagrams as real PNG files", () => {
|
||||
for (const assetPath of [
|
||||
"images/agui-ecosystem-light.png",
|
||||
"images/agui-ecosystem-dark.png",
|
||||
"images/any-agentic-backend-light.png",
|
||||
"images/any-agentic-backend-dark.png",
|
||||
"images/mcp-and-a2a-through-agui-light.png",
|
||||
"images/mcp-and-a2a-through-agui-dark.png",
|
||||
]) {
|
||||
const bytes = readPublicAsset(assetPath);
|
||||
|
||||
expect(bytes.subarray(0, pngSignature.length)).toEqual(pngSignature);
|
||||
expect(bytes.toString("utf8", 0, 32)).not.toContain(
|
||||
"version https://git-lfs",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("serves the slack early-access gate previews as real PNG files", () => {
|
||||
for (const assetPath of [
|
||||
"images/slack-bot-generative-ui-light.png",
|
||||
"images/slack-bot-generative-ui-dark.png",
|
||||
]) {
|
||||
const bytes = readPublicAsset(assetPath);
|
||||
|
||||
expect(bytes.subarray(0, pngSignature.length)).toEqual(pngSignature);
|
||||
expect(bytes.toString("utf8", 0, 32)).not.toContain(
|
||||
"version https://git-lfs",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export default function AgUiError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
useEffect(() => {
|
||||
// Log the full Error instance (not just .message/.digest) so the
|
||||
// stack trace reaches the server log / browser devtools. Include
|
||||
// pathname where available so reports can be tied back to a page.
|
||||
console.error(
|
||||
`[ag-ui] Page render error${pathname ? ` on ${pathname}` : ""}:`,
|
||||
error,
|
||||
);
|
||||
}, [error, pathname]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<div className="text-center max-w-md">
|
||||
<h2 className="text-lg font-semibold text-[var(--text)] mb-2">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-muted)] mb-4">
|
||||
We hit an error rendering this page. Please refresh, and if it keeps
|
||||
happening, report it with the ID below so we can track it down.
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-xs text-[var(--text-faint)] mb-4 font-mono">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={reset}
|
||||
className="shell-docs-radius-control h-10 border border-[var(--border)] px-4 text-sm text-[var(--text-secondary)] transition-colors hover:bg-[var(--bg-elevated)]"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
import React from "react";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import matter from "gray-matter";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { MDXRemote } from "next-mdx-remote/rsc";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
rehypeCode,
|
||||
rehypeCodeDefaultOptions,
|
||||
} from "fumadocs-core/mdx-plugins";
|
||||
import Link from "next/link";
|
||||
import { ShellDocsLayout } from "@/components/shell-docs-layout";
|
||||
import { DocsPage, DocsBody, DocsTitle } from "fumadocs-ui/page";
|
||||
import type * as PageTree from "fumadocs-core/page-tree";
|
||||
import { MdxCodeBlock } from "@/components/mdx-code-block";
|
||||
import { docsComponents } from "@/lib/mdx-registry";
|
||||
import { stripLeadingImports } from "@/lib/docs-render";
|
||||
import { transformerMeta } from "@/lib/rehype-code-meta";
|
||||
import { resolveWithinDir, safeReadFileSync } from "@/lib/safe-fs";
|
||||
import { buildDocMetadata } from "@/lib/seo-metadata";
|
||||
|
||||
// Self-canonical for /ag-ui[/<slug>]. AG-UI pages aren't per-framework
|
||||
// but get a canonical for parity with the rest of the docs surface.
|
||||
// Title/description come from the page's MDX frontmatter so every AG-UI
|
||||
// doc emits its own social card and `<meta description>` rather than
|
||||
// inheriting the layout's generic site-wide values.
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const slugPath = slug && slug.length > 0 ? slug.join("/") : "";
|
||||
const canonicalPath = slugPath ? `/ag-ui/${slugPath}` : "/ag-ui";
|
||||
// Overview page (no slug): hard-code the protocol-level title rather
|
||||
// than reading from MDX, since /ag-ui has no backing file — its body
|
||||
// is rendered by `OverviewContent` in this same module.
|
||||
if (!slugPath) {
|
||||
return buildDocMetadata({
|
||||
title: "AG-UI: the Agent-User Interaction Protocol",
|
||||
description:
|
||||
"AG-UI is an open protocol for connecting AI agents to frontend applications via a standard event-based interface.",
|
||||
canonicalPath,
|
||||
});
|
||||
}
|
||||
// Read frontmatter from the AG-UI MDX file. Mirror the page render's
|
||||
// own resolution (file.mdx → folder/index.mdx) so the metadata always
|
||||
// matches what the user sees. Use safeReadFileSync to keep the read
|
||||
// path-traversal-guarded.
|
||||
const mdxResolved = resolveWithinDir(CONTENT_DIR, `${slugPath}.mdx`);
|
||||
const indexResolved = resolveWithinDir(
|
||||
CONTENT_DIR,
|
||||
path.join(slugPath, "index.mdx"),
|
||||
);
|
||||
let title: string | undefined;
|
||||
let description: string | undefined;
|
||||
const relPath = mdxResolved
|
||||
? path.relative(CONTENT_DIR, mdxResolved)
|
||||
: indexResolved
|
||||
? path.relative(CONTENT_DIR, indexResolved)
|
||||
: null;
|
||||
if (relPath) {
|
||||
const raw = safeReadFileSync(CONTENT_DIR, relPath);
|
||||
if (raw !== null) {
|
||||
try {
|
||||
const { data } = matter(raw);
|
||||
if (typeof data.title === "string" && data.title.length > 0) {
|
||||
title = data.title;
|
||||
}
|
||||
if (
|
||||
typeof data.description === "string" &&
|
||||
data.description.length > 0
|
||||
) {
|
||||
description = data.description;
|
||||
}
|
||||
} catch {
|
||||
// Malformed frontmatter — fall back to the slug-derived title.
|
||||
}
|
||||
}
|
||||
}
|
||||
return buildDocMetadata({
|
||||
title: title ?? titleFromSlug(slugPath),
|
||||
description,
|
||||
canonicalPath,
|
||||
});
|
||||
}
|
||||
|
||||
// Force dynamic rendering so unknown AG-UI slugs reliably return HTTP
|
||||
// 404 from `notFound()` instead of being cached as a 200 (soft-404).
|
||||
// See the matching note in the framework route.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CONTENT_DIR = path.join(process.cwd(), "src/content/ag-ui");
|
||||
|
||||
// A nav entry is either a page (slug) or a named sub-group with children
|
||||
type NavEntry = string | { group: string; children: NavEntry[] };
|
||||
type NavSection = { section: string; entries: NavEntry[] };
|
||||
type NavTab = { tab: string; sections: NavSection[] };
|
||||
|
||||
// Hardcoded navigation matching the original AG-UI docs.json structure.
|
||||
// Only these pages appear in the sidebar — no filesystem scanning.
|
||||
const NAV_DEFINITION: NavTab[] = [
|
||||
{
|
||||
tab: "Docs",
|
||||
sections: [
|
||||
{
|
||||
section: "Get Started",
|
||||
entries: [
|
||||
"introduction",
|
||||
"agentic-protocols",
|
||||
{
|
||||
group: "Quickstart",
|
||||
children: [
|
||||
"quickstart/applications",
|
||||
{
|
||||
group: "Build integrations",
|
||||
children: [
|
||||
"quickstart/introduction",
|
||||
"quickstart/server",
|
||||
"quickstart/middleware",
|
||||
],
|
||||
},
|
||||
"quickstart/clients",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "Concepts",
|
||||
entries: [
|
||||
"concepts/architecture",
|
||||
"concepts/events",
|
||||
"concepts/agents",
|
||||
"concepts/middleware",
|
||||
"concepts/messages",
|
||||
"concepts/reasoning",
|
||||
"concepts/state",
|
||||
"concepts/serialization",
|
||||
"concepts/tools",
|
||||
"concepts/capabilities",
|
||||
"concepts/generative-ui-specs",
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "Draft Proposals",
|
||||
entries: [
|
||||
"drafts/overview",
|
||||
"drafts/multimodal-messages",
|
||||
"drafts/interrupts",
|
||||
"drafts/generative-ui",
|
||||
"drafts/meta-events",
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "Tutorials",
|
||||
entries: ["tutorials/cursor", "tutorials/debugging"],
|
||||
},
|
||||
{
|
||||
section: "Development",
|
||||
entries: [
|
||||
"development/updates",
|
||||
"development/roadmap",
|
||||
"development/contributing",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tab: "SDKs",
|
||||
sections: [
|
||||
{
|
||||
section: "TypeScript",
|
||||
entries: [
|
||||
{
|
||||
group: "@ag-ui/core",
|
||||
children: [
|
||||
"sdk/js/core/overview",
|
||||
"sdk/js/core/types",
|
||||
"sdk/js/core/multimodal-inputs",
|
||||
"sdk/js/core/events",
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "@ag-ui/client",
|
||||
children: [
|
||||
"sdk/js/client/overview",
|
||||
"sdk/js/client/abstract-agent",
|
||||
"sdk/js/client/http-agent",
|
||||
"sdk/js/client/middleware",
|
||||
"sdk/js/client/subscriber",
|
||||
"sdk/js/client/compaction",
|
||||
],
|
||||
},
|
||||
// sdk/js/encoder and sdk/js/proto removed (empty placeholder pages)
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "Python",
|
||||
entries: [
|
||||
{
|
||||
group: "ag_ui.core",
|
||||
children: [
|
||||
"sdk/python/core/overview",
|
||||
"sdk/python/core/types",
|
||||
"sdk/python/core/multimodal-inputs",
|
||||
"sdk/python/core/events",
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "ag_ui.encoder",
|
||||
children: ["sdk/python/encoder/overview"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Fallback title derived from the slug itself when we can't read a better
|
||||
// one from the file (missing file, IO error, malformed frontmatter, etc.).
|
||||
function titleFromSlug(slug: string): string {
|
||||
return slug.split("/").pop()?.replace(/-/g, " ") || slug;
|
||||
}
|
||||
|
||||
// Read the title for a given slug from its MDX file. Uses gray-matter so
|
||||
// frontmatter parsing is scoped to the frontmatter block (previously a
|
||||
// global `title:` regex could match any `title:` line buried in an MDX
|
||||
// body). Guards fs reads so a single malformed file doesn't crash the
|
||||
// whole nav build.
|
||||
function getTitleForSlug(slug: string): string {
|
||||
const filePath = path.join(CONTENT_DIR, `${slug}.mdx`);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return titleFromSlug(slug);
|
||||
}
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(filePath, "utf-8");
|
||||
} catch (err) {
|
||||
console.error(`[ag-ui] Failed to read ${filePath}:`, err);
|
||||
return titleFromSlug(slug);
|
||||
}
|
||||
try {
|
||||
const { data, content } = matter(raw);
|
||||
if (typeof data.title === "string" && data.title.length > 0) {
|
||||
return data.title;
|
||||
}
|
||||
const headingMatch = content.match(/^#\s+(.+)$/m);
|
||||
if (headingMatch) return headingMatch[1];
|
||||
} catch (err) {
|
||||
console.error(`[ag-ui] Failed to parse frontmatter in ${filePath}:`, err);
|
||||
}
|
||||
return titleFromSlug(slug);
|
||||
}
|
||||
|
||||
// Resolved nav types used for rendering
|
||||
type ResolvedPage = { kind: "page"; slug: string; title: string };
|
||||
type ResolvedGroup = {
|
||||
kind: "group";
|
||||
name: string;
|
||||
children: ResolvedNavItem[];
|
||||
};
|
||||
type ResolvedNavItem = ResolvedPage | ResolvedGroup;
|
||||
type ResolvedSection = { section: string; items: ResolvedNavItem[] };
|
||||
type ResolvedTab = { tab: string; sections: ResolvedSection[] };
|
||||
|
||||
function resolveEntry(entry: NavEntry): ResolvedNavItem {
|
||||
if (typeof entry === "string") {
|
||||
return { kind: "page", slug: entry, title: getTitleForSlug(entry) };
|
||||
}
|
||||
return {
|
||||
kind: "group",
|
||||
name: entry.group,
|
||||
children: entry.children.map(resolveEntry),
|
||||
};
|
||||
}
|
||||
|
||||
function getNavTabs(): ResolvedTab[] {
|
||||
return NAV_DEFINITION.map((tab) => ({
|
||||
tab: tab.tab,
|
||||
sections: tab.sections.map((sec) => ({
|
||||
section: sec.section,
|
||||
items: sec.entries.map(resolveEntry),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
// AG-UI-specific MDX component map: spread the full shared `docsComponents`
|
||||
// registry (which already provides Callout/Cards/Tabs/FrameworkTabs/Snippet/
|
||||
// PropertyReference/Steps/Step/InlineDemo/etc.) so AG-UI MDX no longer
|
||||
// silently renders raw JSX when it uses anything outside a tiny local
|
||||
// subset. The shared `InlineDemo` in mdx-registry is also the canonical
|
||||
// implementation whose "Open full demo →" link uses an absolute
|
||||
// `${NEXT_PUBLIC_SHELL_URL}/integrations/...` URL — the previous local
|
||||
// copy used a relative `/integrations/...` URL that 404'd on the docs
|
||||
// host (which has no /integrations route). Steps/Step now render through
|
||||
// the real @/components/docs-steps component rather than a local no-op
|
||||
// shim that discarded numbering.
|
||||
const components = {
|
||||
...docsComponents,
|
||||
// Same `pre` override the docs renderer uses — surfaces a copy button
|
||||
// and the optional file-path caption (driven by `rehypeCodeMeta` below).
|
||||
pre: MdxCodeBlock,
|
||||
};
|
||||
|
||||
function OverviewContent() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-16 text-center">
|
||||
<h1 className="text-3xl font-semibold text-[var(--violet)] tracking-tight mb-3">
|
||||
The Agent-User Interaction Protocol
|
||||
</h1>
|
||||
<p className="text-base text-[var(--text-secondary)] leading-relaxed mb-10">
|
||||
AG-UI is an open protocol for connecting AI agents to frontend
|
||||
applications. It defines a standard event-based interface for streaming
|
||||
agent state, tool calls, and generative UI to any client.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 text-left mb-10">
|
||||
<Link
|
||||
href="/ag-ui/concepts/architecture"
|
||||
className="shell-docs-radius-surface group border border-[var(--border)] bg-[var(--bg-surface)] p-5 shadow-[var(--shadow-control)] transition-colors hover:border-[var(--accent)] hover:bg-[var(--bg-elevated)]"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-[var(--text)] group-hover:text-[var(--accent)] mb-1">
|
||||
Concepts
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
Architecture, events, agents, state, tools, middleware
|
||||
</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/ag-ui/quickstart/introduction"
|
||||
className="shell-docs-radius-surface group border border-[var(--border)] bg-[var(--bg-surface)] p-5 shadow-[var(--shadow-control)] transition-colors hover:border-[var(--accent)] hover:bg-[var(--bg-elevated)]"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-[var(--text)] group-hover:text-[var(--accent)] mb-1">
|
||||
Quick Start
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
Build your first AG-UI integration step by step
|
||||
</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/ag-ui/sdk/js/core/overview"
|
||||
className="shell-docs-radius-surface group border border-[var(--border)] bg-[var(--bg-surface)] p-5 shadow-[var(--shadow-control)] transition-colors hover:border-[var(--accent)] hover:bg-[var(--bg-elevated)]"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-[var(--text)] group-hover:text-[var(--accent)] mb-1">
|
||||
JavaScript SDK
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
@ag-ui/core, @ag-ui/client, @ag-ui/encoder
|
||||
</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/ag-ui/sdk/python/core/overview"
|
||||
className="shell-docs-radius-surface group border border-[var(--border)] bg-[var(--bg-surface)] p-5 shadow-[var(--shadow-control)] transition-colors hover:border-[var(--accent)] hover:bg-[var(--bg-elevated)]"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-[var(--text)] group-hover:text-[var(--accent)] mb-1">
|
||||
Python SDK
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
ag_ui.core, ag_ui.encoder
|
||||
</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/ag-ui/tutorials/cursor"
|
||||
className="shell-docs-radius-surface group border border-[var(--border)] bg-[var(--bg-surface)] p-5 shadow-[var(--shadow-control)] transition-colors hover:border-[var(--accent)] hover:bg-[var(--bg-elevated)]"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-[var(--text)] group-hover:text-[var(--accent)] mb-1">
|
||||
Tutorials
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
Hands-on guides and debugging walkthroughs
|
||||
</p>
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/ag-ui-protocol/ag-ui"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="shell-docs-radius-surface group border border-[var(--border)] bg-[var(--bg-surface)] p-5 shadow-[var(--shadow-control)] transition-colors hover:border-[var(--accent)] hover:bg-[var(--bg-elevated)]"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-[var(--text)] group-hover:text-[var(--accent)] mb-1">
|
||||
GitHub
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
Open source · Apache 2.0 · ag-ui-protocol/ag-ui
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-[var(--text-faint)]">
|
||||
2 SDKs · 15+ framework adapters · Open protocol
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function AgUiDocPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const isOverview = !slug || slug.length === 0;
|
||||
const slugPath = isOverview ? "" : slug.join("/");
|
||||
|
||||
const navTabs = getNavTabs();
|
||||
|
||||
function renderNavItem(
|
||||
item: ResolvedNavItem,
|
||||
depth: number = 0,
|
||||
): React.ReactNode {
|
||||
const indent = depth * 16;
|
||||
if (item.kind === "page") {
|
||||
const isActive = item.slug === slugPath;
|
||||
return (
|
||||
<Link
|
||||
key={item.slug}
|
||||
href={`/ag-ui/${item.slug}`}
|
||||
data-active={isActive ? "true" : undefined}
|
||||
className={`block py-[5px] text-[14px] transition-colors ${
|
||||
isActive
|
||||
? "text-[var(--text)] font-medium"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text)]"
|
||||
}`}
|
||||
style={{ paddingLeft: `${indent}px` }}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={item.name} className="mt-2">
|
||||
<div
|
||||
className="py-[5px] text-[14px] text-[var(--text-muted)]"
|
||||
style={{ paddingLeft: `${indent}px` }}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
{item.children.map((child) => renderNavItem(child, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Overview page: no sidebar, card-based landing. Wrap in a scroll
|
||||
// container because <main> in the root layout is fixed-height with
|
||||
// hidden overflow (canonical layout architecture).
|
||||
if (isOverview) {
|
||||
return (
|
||||
<div className="flex-1 min-w-0 overflow-y-auto">
|
||||
<OverviewContent />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Doc page: sidebar + MDX content. slugPath is user-supplied (URL
|
||||
// segments) so route every filesystem access through resolveWithinDir
|
||||
// so a crafted path like `..%2F..%2Fsecrets` can't escape CONTENT_DIR.
|
||||
const mdxResolved = resolveWithinDir(CONTENT_DIR, `${slugPath}.mdx`);
|
||||
const indexResolved = resolveWithinDir(
|
||||
CONTENT_DIR,
|
||||
path.join(slugPath, "index.mdx"),
|
||||
);
|
||||
|
||||
let filePath: string;
|
||||
if (mdxResolved && fs.existsSync(mdxResolved)) {
|
||||
filePath = mdxResolved;
|
||||
} else if (indexResolved && fs.existsSync(indexResolved)) {
|
||||
filePath = indexResolved;
|
||||
} else {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const source = safeReadFileSync(
|
||||
CONTENT_DIR,
|
||||
path.relative(CONTENT_DIR, filePath),
|
||||
);
|
||||
if (source === null) {
|
||||
console.error(`[ag-ui] Failed to read ${filePath}`);
|
||||
notFound();
|
||||
}
|
||||
|
||||
let content = "";
|
||||
let title = titleFromSlug(slugPath) || "AG-UI";
|
||||
try {
|
||||
const parsed = matter(source);
|
||||
content = stripLeadingImports(parsed.content);
|
||||
if (typeof parsed.data.title === "string" && parsed.data.title.length > 0) {
|
||||
title = parsed.data.title;
|
||||
} else {
|
||||
const headingMatch = parsed.content.match(/^#\s+(.+)$/m);
|
||||
if (headingMatch) title = headingMatch[1];
|
||||
}
|
||||
// The page wrapper below renders `title` inside its own <h1>. If the
|
||||
// MDX body also leads with a `# Title` heading — which is the common
|
||||
// case, since that's how we extract the title when frontmatter is
|
||||
// absent — MDXRemote renders a second h1 and the page shows two
|
||||
// stacked titles. Strip one leading `# …` line (skipping any blank
|
||||
// lines above it) so the body picks up from the body text. We only
|
||||
// strip the FIRST heading and only when it's the first non-blank
|
||||
// content line, so code fences and deeper headings are untouched.
|
||||
content = content.replace(/^(\s*\n)*#\s+.+\n?/, "");
|
||||
} catch (err) {
|
||||
console.error(`[ag-ui] Failed to parse MDX in ${filePath}:`, err);
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Convert AG-UI's tab > section > item structure into a Fumadocs
|
||||
// PageTree. Tabs become top-level separators; sections become folders;
|
||||
// items map to pages (recursively when they're groups).
|
||||
function itemToNode(item: ResolvedNavItem): PageTree.Node {
|
||||
if (item.kind === "page") {
|
||||
return {
|
||||
type: "page",
|
||||
name: item.title,
|
||||
url: `/ag-ui/${item.slug}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "folder",
|
||||
name: item.name,
|
||||
defaultOpen: true,
|
||||
children: item.children.map(itemToNode),
|
||||
};
|
||||
}
|
||||
const pageTree: PageTree.Root = {
|
||||
name: "AG-UI",
|
||||
children: navTabs.flatMap((tab) => [
|
||||
{ type: "separator" as const, name: tab.tab },
|
||||
...tab.sections.flatMap((s) => [
|
||||
{
|
||||
type: "folder" as const,
|
||||
name: s.section,
|
||||
defaultOpen: true,
|
||||
children: s.items.map(itemToNode),
|
||||
},
|
||||
]),
|
||||
]),
|
||||
};
|
||||
|
||||
return (
|
||||
<ShellDocsLayout
|
||||
tree={pageTree}
|
||||
banner={
|
||||
<Link
|
||||
href="/ag-ui"
|
||||
className="block text-xs font-mono uppercase tracking-widest text-[var(--violet)]"
|
||||
>
|
||||
AG-UI Protocol
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<DocsPage
|
||||
toc={[]}
|
||||
tableOfContent={{ enabled: false }}
|
||||
tableOfContentPopover={{ enabled: false }}
|
||||
breadcrumb={{ enabled: false }}
|
||||
footer={{ enabled: false }}
|
||||
>
|
||||
<div className="max-w-3xl px-8 py-8">
|
||||
<DocsTitle className="text-2xl font-semibold tracking-tight mb-6">
|
||||
{title}
|
||||
</DocsTitle>
|
||||
<DocsBody className="reference-content">
|
||||
<MDXRemote
|
||||
source={content}
|
||||
components={components}
|
||||
options={{
|
||||
mdxOptions: {
|
||||
remarkPlugins: [remarkGfm],
|
||||
rehypePlugins: [
|
||||
[
|
||||
rehypeCode,
|
||||
{
|
||||
fallbackLanguage: "plaintext",
|
||||
transformers: [
|
||||
...(rehypeCodeDefaultOptions.transformers ?? []),
|
||||
transformerMeta(),
|
||||
],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</DocsBody>
|
||||
</div>
|
||||
</DocsPage>
|
||||
</ShellDocsLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// /cookbook/<...slug> — individual cookbook recipe pages.
|
||||
//
|
||||
// Same scoped-sidebar pattern as the landing route (mirrors how
|
||||
// /reference handles per-item pages). Forces the sidebar tree to the
|
||||
// cookbook subtree so each recipe shows only its sibling recipes, not
|
||||
// the full Documentation tree.
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { DocsPageView } from "@/components/docs-page-view";
|
||||
import { buildCookbookNavTree } from "@/lib/cookbook-nav";
|
||||
import { loadDoc } from "@/lib/docs-render";
|
||||
import { buildDocMetadata } from "@/lib/seo-metadata";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const slugTail = slug.join("/");
|
||||
const slugPath = `cookbook/${slugTail}`;
|
||||
const doc = loadDoc(slugPath);
|
||||
return buildDocMetadata({
|
||||
title: doc?.fm.title ?? slugPath,
|
||||
description: doc?.fm.description,
|
||||
canonicalPath: `/cookbook/${slugTail}`,
|
||||
ogPath: `/og/cookbook/${slugTail}/og.png`,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function CookbookSlugPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
if (!slug || slug.length === 0) notFound();
|
||||
const slugPath = `cookbook/${slug.join("/")}`;
|
||||
return (
|
||||
<DocsPageView
|
||||
slugPath={slugPath}
|
||||
slugHrefPrefix=""
|
||||
navTree={buildCookbookNavTree()}
|
||||
sidebarBannerSlot={null}
|
||||
sidebarClassName="shell-docs-sidebar-cookbook"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from "next";
|
||||
import { DocsPageView } from "@/components/docs-page-view";
|
||||
import { buildCookbookNavTree } from "@/lib/cookbook-nav";
|
||||
import { loadDoc } from "@/lib/docs-render";
|
||||
import { buildDocMetadata } from "@/lib/seo-metadata";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const doc = loadDoc("cookbook");
|
||||
return buildDocMetadata({
|
||||
title: doc?.fm.title ?? "Cookbook",
|
||||
description: doc?.fm.description,
|
||||
canonicalPath: "/cookbook",
|
||||
ogPath: "/og/cookbook/og.png",
|
||||
});
|
||||
}
|
||||
|
||||
export default function CookbookLandingPage() {
|
||||
return (
|
||||
<DocsPageView
|
||||
slugPath="cookbook"
|
||||
slugHrefPrefix=""
|
||||
navTree={buildCookbookNavTree()}
|
||||
sidebarBannerSlot={null}
|
||||
sidebarClassName="shell-docs-sidebar-cookbook"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const navigation = vi.hoisted(() => ({
|
||||
redirect: vi.fn((path: string) => {
|
||||
throw new Error(`NEXT_REDIRECT:${path}`);
|
||||
}),
|
||||
notFound: vi.fn(() => {
|
||||
throw new Error("NEXT_NOT_FOUND");
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: navigation.redirect,
|
||||
notFound: navigation.notFound,
|
||||
}));
|
||||
|
||||
import FrontendDocPage from "./page";
|
||||
|
||||
const redirectMock = vi.mocked(redirect);
|
||||
|
||||
function callFrontendDocPage(frontend: string, slug: string[]) {
|
||||
return FrontendDocPage({
|
||||
params: Promise.resolve({ frontend, slug }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("legacy frontend docs route", () => {
|
||||
it("canonicalizes React guidance docs to the React root", async () => {
|
||||
await expect(
|
||||
callFrontendDocPage("react", ["using-these-docs"]),
|
||||
).rejects.toThrow("NEXT_REDIRECT:/");
|
||||
|
||||
expect(redirectMock).toHaveBeenCalledWith("/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { frontendPathForBackend, isFrontendId } from "@/lib/frontend-options";
|
||||
import { loadDoc } from "@/lib/docs-render";
|
||||
import { resolveFrontendDocPage } from "@/lib/frontend-doc-policy";
|
||||
import { buildDocMetadata } from "@/lib/seo-metadata";
|
||||
|
||||
type FrontendDocPageParams = {
|
||||
frontend: string;
|
||||
slug?: string[];
|
||||
};
|
||||
|
||||
function slugPathFromParams(params: FrontendDocPageParams): string {
|
||||
return params.slug?.join("/") ?? "";
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<FrontendDocPageParams>;
|
||||
}): Promise<Metadata> {
|
||||
const resolvedParams = await params;
|
||||
const { frontend } = resolvedParams;
|
||||
const slugPath = slugPathFromParams(resolvedParams);
|
||||
|
||||
if (!isFrontendId(frontend) || frontend === "react") {
|
||||
return buildDocMetadata({
|
||||
title: "Frontend docs",
|
||||
canonicalPath: "/",
|
||||
});
|
||||
}
|
||||
|
||||
const resolution = resolveFrontendDocPage(frontend, slugPath);
|
||||
const doc =
|
||||
resolution.status === "found" ? loadDoc(resolution.contentSlugPath) : null;
|
||||
|
||||
return buildDocMetadata({
|
||||
title: doc?.fm.title ?? slugPath,
|
||||
description: doc?.fm.description,
|
||||
canonicalPath:
|
||||
resolution.status === "found"
|
||||
? resolution.canonicalPath
|
||||
: `/${frontend}/${slugPath}`,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function FrontendDocPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<FrontendDocPageParams>;
|
||||
}) {
|
||||
const resolvedParams = await params;
|
||||
const { frontend } = resolvedParams;
|
||||
if (!isFrontendId(frontend)) notFound();
|
||||
const slugPath = slugPathFromParams(resolvedParams);
|
||||
if (frontend === "react") {
|
||||
redirect(frontendPathForBackend("react", slugPath));
|
||||
}
|
||||
|
||||
if (slugPath === "quickstart") redirect(`/${frontend}`);
|
||||
if (slugPath === "using-these-docs") {
|
||||
redirect(`/${frontend}/using-these-docs`);
|
||||
}
|
||||
|
||||
redirect(`/${frontend}/${slugPath}`);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import {
|
||||
FRONTEND_PAGE_IDS,
|
||||
getFrontendContentSlug,
|
||||
} from "@/lib/frontend-page-content";
|
||||
import { getFrontendOption, isFrontendId } from "@/lib/frontend-options";
|
||||
import { loadDoc } from "@/lib/docs-render";
|
||||
import { buildDocMetadata } from "@/lib/seo-metadata";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return FRONTEND_PAGE_IDS.map((frontend) => ({ frontend }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ frontend: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { frontend } = await params;
|
||||
if (!isFrontendId(frontend) || frontend === "react") {
|
||||
return buildDocMetadata({
|
||||
title: "Frontend quickstart",
|
||||
canonicalPath: "/",
|
||||
});
|
||||
}
|
||||
|
||||
const contentSlug = getFrontendContentSlug(frontend);
|
||||
const doc = loadDoc(contentSlug);
|
||||
const option = getFrontendOption(frontend);
|
||||
|
||||
return buildDocMetadata({
|
||||
title: `${doc?.fm.title ?? option.name} quickstart`,
|
||||
description: doc?.fm.description,
|
||||
canonicalPath: `/${frontend}`,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function FrontendQuickstartPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ frontend: string }>;
|
||||
}) {
|
||||
const { frontend } = await params;
|
||||
if (!isFrontendId(frontend)) notFound();
|
||||
if (frontend === "react") redirect("/");
|
||||
|
||||
redirect(`/${frontend}`);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import {
|
||||
FRONTEND_PAGE_IDS,
|
||||
getFrontendGuidanceContentSlug,
|
||||
} from "@/lib/frontend-page-content";
|
||||
import { getFrontendOption, isFrontendId } from "@/lib/frontend-options";
|
||||
import { loadDoc } from "@/lib/docs-render";
|
||||
import { buildDocMetadata } from "@/lib/seo-metadata";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return FRONTEND_PAGE_IDS.map((frontend) => ({ frontend }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ frontend: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { frontend } = await params;
|
||||
if (!isFrontendId(frontend) || frontend === "react") {
|
||||
return buildDocMetadata({
|
||||
title: "Using these frontend docs",
|
||||
canonicalPath: "/",
|
||||
});
|
||||
}
|
||||
|
||||
const doc = loadDoc(getFrontendGuidanceContentSlug(frontend));
|
||||
const option = getFrontendOption(frontend);
|
||||
|
||||
return buildDocMetadata({
|
||||
title: `${option.name}: ${doc?.fm.title ?? "using these docs"}`,
|
||||
description: doc?.fm.description,
|
||||
canonicalPath: `/${frontend}/using-these-docs`,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function FrontendGuidancePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ frontend: string }>;
|
||||
}) {
|
||||
const { frontend } = await params;
|
||||
if (!isFrontendId(frontend)) notFound();
|
||||
if (frontend === "react") redirect("/");
|
||||
|
||||
redirect(`/${frontend}/using-these-docs`);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function FrontendsIndexPage() {
|
||||
redirect("/");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Plus_Jakarta_Sans } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
import { RootProvider } from "fumadocs-ui/provider/next";
|
||||
import { AnalyticsClient } from "@/components/analytics-client";
|
||||
import { Banners } from "@/components/banners";
|
||||
import { BrandNav } from "@/components/brand-nav";
|
||||
import { FrameworkProvider } from "@/components/framework-provider";
|
||||
import { ShellSearchProvider } from "@/components/search-trigger";
|
||||
import { PostHogProvider } from "@/lib/providers/posthog-provider";
|
||||
import { ScarfPixel } from "@/lib/providers/scarf-pixel";
|
||||
import { getIntegrations } from "@/lib/registry";
|
||||
import { RESERVED_ROUTE_SLUGS } from "@/lib/reserved-route-slugs";
|
||||
import { getRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { serializeRuntimeConfig } from "@/lib/runtime-config-serialize";
|
||||
import "./globals.css";
|
||||
|
||||
// serializeRuntimeConfig is extracted to `lib/runtime-config-serialize.ts`
|
||||
// so it can be unit-tested for the OWASP escape behavior (XSS via
|
||||
// `</script>`, U+2028/U+2029 line-terminator injection) without
|
||||
// importing the layout into the test runner.
|
||||
|
||||
const plusJakartaSans = Plus_Jakarta_Sans({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-prose",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CopilotKit Docs",
|
||||
description: "Docs, live demos, and integrations for CopilotKit",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// FrameworkProvider needs the set of known framework slugs so it can
|
||||
// detect URL-scoped framework views. The framework *selector* now
|
||||
// lives inside the docs sidebar, not in the top bar, so its own
|
||||
// options are wired up in the docs page-level server components.
|
||||
//
|
||||
// Guard against registry slugs that would collide with top-level
|
||||
// route segments under src/app/ (see RESERVED_ROUTE_SLUGS). Without
|
||||
// this filter, a registry entry named e.g. "reference" would cause
|
||||
// FrameworkProvider.urlFramework to treat /reference as a framework
|
||||
// scope rather than the reference docs route.
|
||||
const reserved = new Set<string>(RESERVED_ROUTE_SLUGS);
|
||||
const knownFrameworks = getIntegrations()
|
||||
.map((i) => i.slug)
|
||||
.filter((slug) => {
|
||||
if (reserved.has(slug)) {
|
||||
// Always log — a registry integration slug colliding with a
|
||||
// reserved top-level route is a hard wiring bug that production
|
||||
// operators need to see in logs, not a dev-only warning.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`[layout] integration slug "${slug}" collides with a reserved top-level route and was dropped from knownFrameworks. Rename the integration slug or update RESERVED_ROUTE_SLUGS.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Distinguish "unset" from "empty" for the commit-SHA overlay.
|
||||
// Docker ARG scope bugs can surface as an empty string rather than
|
||||
// undefined; showing "dev" in that case is misleading. See the
|
||||
// Dockerfile fix for the root cause.
|
||||
const rawSha = process.env.NEXT_PUBLIC_COMMIT_SHA;
|
||||
const commitLabel =
|
||||
rawSha === undefined
|
||||
? "dev"
|
||||
: rawSha === ""
|
||||
? "unknown"
|
||||
: rawSha.slice(0, 7);
|
||||
|
||||
// Server-side: read live env at request time. `unstable_noStore()`
|
||||
// inside getRuntimeConfig opts this segment out of the static
|
||||
// cache so the inline <script> below always reflects the current
|
||||
// Railway env vars.
|
||||
const runtimeConfig = getRuntimeConfig();
|
||||
const injection = `window.__SHOWCASE_CONFIG__=${serializeRuntimeConfig(runtimeConfig)};`;
|
||||
const REO_KEY = runtimeConfig.reoKey;
|
||||
const REB2B_KEY = runtimeConfig.reb2bKey;
|
||||
|
||||
return (
|
||||
// suppressHydrationWarning is required because the inline theme-init
|
||||
// script below adds/removes `class="dark"` on <html> before React
|
||||
// hydrates. Without this, Next.js detects the className mismatch and
|
||||
// reverts the client tree to match the server (which doesn't know the
|
||||
// user's persisted theme), stripping the `.dark` class and breaking
|
||||
// every `dark:` variant. This is the canonical Next.js recipe for a
|
||||
// theme script in the document head.
|
||||
<html
|
||||
lang="en"
|
||||
className={plusJakartaSans.variable}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
{/* MUST be the first child of <head>. Every client component
|
||||
* reads window.__SHOWCASE_CONFIG__ during hydration; populating
|
||||
* it from a raw inline <script> guarantees the value is set
|
||||
* before the parser reaches any next-script beforeInteractive
|
||||
* block (those run after the parser passes our inline script).
|
||||
* Using a plain <script> rather than next/script also avoids
|
||||
* the deferred-execution semantics of `strategy="beforeInteractive"`
|
||||
* — `beforeInteractive` runs before hydration but AFTER raw
|
||||
* parse-time scripts. */}
|
||||
<script
|
||||
id="__showcase_config__"
|
||||
dangerouslySetInnerHTML={{ __html: injection }}
|
||||
/>
|
||||
{/* Apply the persisted theme before first paint to avoid a
|
||||
* light-flash on dark-preferring loads. Reads `localStorage.theme`
|
||||
* and falls back to `prefers-color-scheme` when the persisted
|
||||
* value is missing OR explicitly `"system"` (next-themes persists
|
||||
* the literal string `"system"` when the user picks that mode via
|
||||
* its API — without the `=== "system"` check here, a system-
|
||||
* preferring user who explicitly chose system mode would get the
|
||||
* very light-flash this script exists to prevent). */}
|
||||
<Script
|
||||
id="theme-init"
|
||||
strategy="beforeInteractive"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){try{var t=localStorage.theme;if(!t||t==='system'){t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}if(t==='dark'){document.documentElement.classList.add('dark');}}catch(e){}})();`,
|
||||
}}
|
||||
/>
|
||||
{REO_KEY ? (
|
||||
<Script
|
||||
id="reo-init-script"
|
||||
strategy="afterInteractive"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
!function(){
|
||||
var e, t, n;
|
||||
e = ${JSON.stringify(REO_KEY)};
|
||||
t = function() {
|
||||
if (window.Reo) {
|
||||
window.Reo.init({ clientID: e });
|
||||
}
|
||||
};
|
||||
n = document.createElement("script");
|
||||
n.src = "https://static.reo.dev/" + e + "/reo.js";
|
||||
n.defer = true;
|
||||
n.onload = t;
|
||||
document.head.appendChild(n);
|
||||
}();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Script
|
||||
id="hubspot-script"
|
||||
type="text/javascript"
|
||||
src="https://js.hs-scripts.com/45532593.js"
|
||||
async
|
||||
defer
|
||||
/>
|
||||
{REB2B_KEY ? (
|
||||
<Script
|
||||
id="reb2b-script"
|
||||
strategy="afterInteractive"
|
||||
src={`https://b2bjsstore.s3.us-west-2.amazonaws.com/b/${REB2B_KEY}/${REB2B_KEY}.js.gz`}
|
||||
/>
|
||||
) : null}
|
||||
</head>
|
||||
<body>
|
||||
<AnalyticsClient />
|
||||
{/* No <Suspense> wrapper around the page tree. Previously this
|
||||
* was wrapped in a Suspense with a null fallback, which caused
|
||||
* Next.js to start streaming the response BEFORE the page
|
||||
* component called `notFound()`. Once bytes are in the wire,
|
||||
* Next can't change the response status, so every unknown URL
|
||||
* returned HTTP 200 + the not-found UI (a soft-404 that demoted
|
||||
* the entire site in search rankings). The PostHogProvider and
|
||||
* FrameworkProvider are client components and don't suspend
|
||||
* during server render, so removing the boundary is safe.
|
||||
*/}
|
||||
<PostHogProvider>
|
||||
<FrameworkProvider knownFrameworks={knownFrameworks}>
|
||||
{/* RootProvider supplies Fumadocs's theme provider (next-themes).
|
||||
* Search is handled exclusively by shell-docs's SearchTrigger. */}
|
||||
<RootProvider
|
||||
theme={{ enabled: true, defaultTheme: "system" }}
|
||||
search={{ enabled: false }}
|
||||
>
|
||||
<ShellSearchProvider>
|
||||
{/* Body is a fixed-height (100vh) flex column with hidden
|
||||
* overflow (see globals.css). Banner + nav sit naturally
|
||||
* at the top; <main> takes the remaining height and is
|
||||
* the horizontal flex row that hosts sidebar + the
|
||||
* scrolling `.docs-content-wrapper`. No sticky positioning
|
||||
* is needed — chrome stays put because it's outside the
|
||||
* scroll container. Mirrors canonical `#nd-home-layout`
|
||||
* (margin: 0 4px; xl: 0 8px 8px 8px). */}
|
||||
<Banners />
|
||||
<BrandNav />
|
||||
<main className="flex flex-1 min-h-0 overflow-hidden mx-1 md:mx-[22px] mt-2 md:mt-3 mb-2 md:mb-3">
|
||||
{children}
|
||||
</main>
|
||||
</ShellSearchProvider>
|
||||
</RootProvider>
|
||||
</FrameworkProvider>
|
||||
</PostHogProvider>
|
||||
<div aria-hidden="true" className="shell-docs-commit-label">
|
||||
{commitLabel}
|
||||
</div>
|
||||
<ScarfPixel />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// `/llms-full.txt` — every documentation page concatenated into a
|
||||
// single Markdown blob, with each page rendered through
|
||||
// `renderPageToLlmText()` so `<Snippet />` tags are inlined as fenced
|
||||
// code blocks (otherwise the body would be useless to an LLM that
|
||||
// can't execute MDX).
|
||||
//
|
||||
// Pages are separated by an H2-rule header so a crawler can split the
|
||||
// file back into per-page chunks; this matches the convention used by
|
||||
// Fumadocs's reference site and `llmstxt.org` examples.
|
||||
//
|
||||
// This endpoint is intentionally slow (renders every MDX page through
|
||||
// `renderPageToLlmText` on each cold request). `revalidate = false`
|
||||
// caches the rendered response on the Next.js server side until the
|
||||
// next deploy — without that the dev server would be fine but every
|
||||
// origin hit in production (health check, monitoring probe, cold CDN
|
||||
// miss) would re-walk the entire docs tree. The `Cache-Control` header
|
||||
// below is the SEPARATE per-response CDN/browser hint with a shorter
|
||||
// max-age so external caches refresh more frequently than server-cached
|
||||
// responses.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAllLlmPages, renderPageToLlmText } from "@/lib/llm-text";
|
||||
import { getBaseUrl } from "@/lib/sitemap-helpers";
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export function GET(): NextResponse {
|
||||
const baseUrl = getBaseUrl();
|
||||
const pages = getAllLlmPages();
|
||||
const chunks: string[] = [];
|
||||
|
||||
// Top matter — short site description so the LLM has context before
|
||||
// it dives into individual pages.
|
||||
chunks.push("# CopilotKit Docs (Full)\n");
|
||||
chunks.push(
|
||||
"> Concatenated documentation for CopilotKit — the frontend framework for AI agents.",
|
||||
);
|
||||
chunks.push(
|
||||
"> Each section below is one page; the source URL is in the H2 header.\n",
|
||||
);
|
||||
|
||||
for (const page of pages) {
|
||||
const body = renderPageToLlmText(page);
|
||||
if (!body) continue;
|
||||
const url = `${baseUrl}/${page.url}`;
|
||||
chunks.push(`---\n\n## Source: ${url}\n`);
|
||||
chunks.push(body);
|
||||
}
|
||||
|
||||
return new NextResponse(chunks.join("\n"), {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=300, stale-while-revalidate=3600",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadDoc } from "@/lib/docs-render";
|
||||
import { resolveFrontendDocPage } from "@/lib/frontend-doc-policy";
|
||||
import { getFrontendContentSlug } from "@/lib/frontend-page-content";
|
||||
import { getDocsFolder, getDocsMode, getIntegrations } from "@/lib/registry";
|
||||
import { renderPageToLlmText } from "@/lib/llm-text";
|
||||
import { GET } from "./route";
|
||||
|
||||
vi.mock("@/lib/docs-render", () => ({
|
||||
loadDoc: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/frontend-doc-policy", () => ({
|
||||
resolveFrontendDocPage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/frontend-page-content", () => ({
|
||||
FRONTEND_GUIDANCE_CONTENT_SLUG: "frontends/using-these-docs",
|
||||
getFrontendContentSlug: vi.fn((id: string) => `frontends/${id}`),
|
||||
getFrontendGuidanceContentSlug: vi.fn((id: string) =>
|
||||
id === "slack" || id === "teams"
|
||||
? "frontends/using-these-docs"
|
||||
: "frontends/docs-status",
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/frontend-options", () => ({
|
||||
isFrontendId: vi.fn((value: string | undefined) =>
|
||||
["react", "vue", "react-native", "slack", "teams"].includes(value ?? ""),
|
||||
),
|
||||
parseFrontendRoutePath: vi.fn(
|
||||
(pathname: string, backendFrameworkSlugs: readonly string[] = []) => {
|
||||
const [first, ...rest] = pathname.split("/").filter(Boolean);
|
||||
if (!["vue", "react-native", "slack", "teams"].includes(first ?? "")) {
|
||||
return null;
|
||||
}
|
||||
const [maybeBackend, ...tail] = rest;
|
||||
const backend =
|
||||
maybeBackend && backendFrameworkSlugs.includes(maybeBackend)
|
||||
? maybeBackend
|
||||
: null;
|
||||
return {
|
||||
frontend: first,
|
||||
backend,
|
||||
slugPath: backend ? tail.join("/") : rest.join("/"),
|
||||
};
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/registry", () => ({
|
||||
getDocsFolder: vi.fn((slug: string) =>
|
||||
slug === "langgraph-python" || slug === "langgraph-typescript"
|
||||
? "langgraph"
|
||||
: slug,
|
||||
),
|
||||
getDocsMode: vi.fn(() => "generated"),
|
||||
getIntegrations: vi.fn(() => [
|
||||
{ slug: "langgraph-python" },
|
||||
{ slug: "langgraph-typescript" },
|
||||
]),
|
||||
ROOT_FRAMEWORK: "built-in-agent",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/llm-text", () => ({
|
||||
renderPageToLlmText: vi.fn(() => "rendered markdown"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/reference-items", () => ({
|
||||
resolveReferencePage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/sitemap-helpers", () => ({
|
||||
AG_UI_CONTENT_DIR: "/tmp/ag-ui",
|
||||
}));
|
||||
|
||||
const loadDocMock = vi.mocked(loadDoc);
|
||||
const resolveFrontendDocPageMock = vi.mocked(resolveFrontendDocPage);
|
||||
const getFrontendContentSlugMock = vi.mocked(getFrontendContentSlug);
|
||||
const getDocsFolderMock = vi.mocked(getDocsFolder);
|
||||
const getDocsModeMock = vi.mocked(getDocsMode);
|
||||
const getIntegrationsMock = vi.mocked(getIntegrations);
|
||||
const renderPageToLlmTextMock = vi.mocked(renderPageToLlmText);
|
||||
|
||||
function callLlmsMdxRoute(slug: string[]) {
|
||||
return GET(new Request("http://localhost:3003/test.mdx"), {
|
||||
params: Promise.resolve({ slug }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("llms-mdx route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getFrontendContentSlugMock.mockImplementation(
|
||||
(id: string) => `frontends/${id}`,
|
||||
);
|
||||
getDocsFolderMock.mockImplementation((slug: string) =>
|
||||
slug === "langgraph-python" || slug === "langgraph-typescript"
|
||||
? "langgraph"
|
||||
: slug,
|
||||
);
|
||||
getDocsModeMock.mockReturnValue("generated");
|
||||
getIntegrationsMock.mockReturnValue([
|
||||
{ slug: "langgraph-python" } as never,
|
||||
{ slug: "langgraph-typescript" } as never,
|
||||
]);
|
||||
renderPageToLlmTextMock.mockReturnValue("rendered markdown");
|
||||
});
|
||||
|
||||
it("prefers framework quickstart overrides for generated docs", async () => {
|
||||
loadDocMock.mockImplementation((slug: string) =>
|
||||
slug === "integrations/langgraph/quickstart"
|
||||
? {
|
||||
source: "",
|
||||
filePath: "integrations/langgraph/quickstart.mdx",
|
||||
fm: {
|
||||
title: "LangGraph Quickstart",
|
||||
description: "Framework-specific quickstart.",
|
||||
},
|
||||
}
|
||||
: slug === "quickstart"
|
||||
? {
|
||||
source: "",
|
||||
filePath: "quickstart.mdx",
|
||||
fm: {
|
||||
title: "Root Quickstart",
|
||||
description: "Routing shim.",
|
||||
},
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const response = await callLlmsMdxRoute(["langgraph-python", "quickstart"]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.text()).resolves.toBe("rendered markdown");
|
||||
expect(loadDocMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"integrations/langgraph/quickstart",
|
||||
);
|
||||
expect(loadDocMock).not.toHaveBeenCalledWith("quickstart");
|
||||
expect(renderPageToLlmTextMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: "integrations/langgraph/quickstart.mdx",
|
||||
framework: "langgraph-python",
|
||||
loadSlug: "integrations/langgraph/quickstart",
|
||||
}),
|
||||
{ framework: "langgraph-python" },
|
||||
);
|
||||
});
|
||||
|
||||
it("serves frontend quickstart markdown from the frontend guide content", async () => {
|
||||
loadDocMock.mockImplementation((slug: string) =>
|
||||
slug === "frontends/slack"
|
||||
? {
|
||||
source: "",
|
||||
filePath: "frontends/slack.mdx",
|
||||
fm: {
|
||||
title: "Slack Quickstart",
|
||||
description: "Slack frontend docs.",
|
||||
},
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const response = await callLlmsMdxRoute(["slack"]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.text()).resolves.toBe("rendered markdown");
|
||||
expect(loadDocMock).toHaveBeenCalledWith("frontends/slack");
|
||||
expect(renderPageToLlmTextMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "slack",
|
||||
filePath: "frontends/slack.mdx",
|
||||
loadSlug: "frontends/slack",
|
||||
}),
|
||||
{ framework: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
it("serves frontend quickstart markdown under two-axis frontend/backend root URLs", async () => {
|
||||
resolveFrontendDocPageMock.mockReturnValue({ status: "not-found" });
|
||||
loadDocMock.mockImplementation((slug: string) =>
|
||||
slug === "frontends/vue"
|
||||
? {
|
||||
source: "",
|
||||
filePath: "frontends/vue.mdx",
|
||||
fm: {
|
||||
title: "Vue Quickstart",
|
||||
description: "Vue frontend docs.",
|
||||
},
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const response = await callLlmsMdxRoute(["vue", "langgraph-python"]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.text()).resolves.toBe("rendered markdown");
|
||||
expect(loadDocMock).toHaveBeenCalledWith("frontends/vue");
|
||||
expect(loadDocMock).not.toHaveBeenCalledWith("index");
|
||||
expect(loadDocMock).not.toHaveBeenCalledWith(
|
||||
"integrations/langgraph/index",
|
||||
);
|
||||
expect(renderPageToLlmTextMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "vue/langgraph-python",
|
||||
filePath: "frontends/vue.mdx",
|
||||
loadSlug: "frontends/vue",
|
||||
framework: "langgraph-python",
|
||||
}),
|
||||
{ framework: "langgraph-python" },
|
||||
);
|
||||
});
|
||||
|
||||
it("serves frontend guidance markdown from the shared guidance page", async () => {
|
||||
loadDocMock.mockImplementation((slug: string) =>
|
||||
slug === "frontends/using-these-docs"
|
||||
? {
|
||||
source: "",
|
||||
filePath: "frontends/using-these-docs.mdx",
|
||||
fm: {
|
||||
title: "About early access",
|
||||
description: "How to read frontend docs.",
|
||||
},
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const response = await callLlmsMdxRoute(["slack", "using-these-docs"]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.text()).resolves.toBe("rendered markdown");
|
||||
expect(loadDocMock).toHaveBeenCalledWith("frontends/using-these-docs");
|
||||
expect(renderPageToLlmTextMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "slack/using-these-docs",
|
||||
filePath: "frontends/using-these-docs.mdx",
|
||||
loadSlug: "frontends/using-these-docs",
|
||||
}),
|
||||
{ framework: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
it("serves frontend guidance markdown under two-axis frontend/backend URLs", async () => {
|
||||
loadDocMock.mockImplementation((slug: string) =>
|
||||
slug === "frontends/docs-status"
|
||||
? {
|
||||
source: "",
|
||||
filePath: "frontends/docs-status.mdx",
|
||||
fm: {
|
||||
title: "Docs status",
|
||||
description: "What to expect while frontend docs catch up.",
|
||||
},
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const response = await callLlmsMdxRoute([
|
||||
"react-native",
|
||||
"langgraph-typescript",
|
||||
"using-these-docs",
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.text()).resolves.toBe("rendered markdown");
|
||||
expect(loadDocMock).toHaveBeenCalledWith("frontends/docs-status");
|
||||
expect(loadDocMock).not.toHaveBeenCalledWith("using-these-docs");
|
||||
expect(loadDocMock).not.toHaveBeenCalledWith(
|
||||
"integrations/langgraph/using-these-docs",
|
||||
);
|
||||
expect(renderPageToLlmTextMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "react-native/langgraph-typescript/using-these-docs",
|
||||
filePath: "frontends/docs-status.mdx",
|
||||
loadSlug: "frontends/docs-status",
|
||||
framework: "langgraph-typescript",
|
||||
}),
|
||||
{ framework: "langgraph-typescript" },
|
||||
);
|
||||
});
|
||||
|
||||
it("serves frontend nested markdown through the frontend doc policy", async () => {
|
||||
resolveFrontendDocPageMock.mockReturnValue({
|
||||
status: "found",
|
||||
slugPath: "concepts/architecture",
|
||||
contentSlugPath: "concepts/architecture",
|
||||
canonicalPath: "/concepts/architecture",
|
||||
policy: { kind: "universal" },
|
||||
});
|
||||
loadDocMock.mockImplementation((slug: string) =>
|
||||
slug === "concepts/architecture"
|
||||
? {
|
||||
source: "",
|
||||
filePath: "concepts/architecture.mdx",
|
||||
fm: {
|
||||
title: "Architecture",
|
||||
description: "Shared architecture docs.",
|
||||
},
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const response = await callLlmsMdxRoute([
|
||||
"slack",
|
||||
"concepts",
|
||||
"architecture",
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.text()).resolves.toBe("rendered markdown");
|
||||
expect(resolveFrontendDocPageMock).toHaveBeenCalledWith(
|
||||
"slack",
|
||||
"concepts/architecture",
|
||||
);
|
||||
expect(loadDocMock).toHaveBeenCalledWith("concepts/architecture");
|
||||
expect(renderPageToLlmTextMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "slack/concepts/architecture",
|
||||
filePath: "concepts/architecture.mdx",
|
||||
loadSlug: "concepts/architecture",
|
||||
}),
|
||||
{ framework: undefined },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import path from "path";
|
||||
import { AG_UI_CONTENT_DIR } from "@/lib/sitemap-helpers";
|
||||
import { loadDoc } from "@/lib/docs-render";
|
||||
import { resolveFrontendDocPage } from "@/lib/frontend-doc-policy";
|
||||
import {
|
||||
getFrontendContentSlug,
|
||||
getFrontendGuidanceContentSlug,
|
||||
} from "@/lib/frontend-page-content";
|
||||
import { isFrontendId, parseFrontendRoutePath } from "@/lib/frontend-options";
|
||||
import type { FrontendId } from "@/lib/frontend-options";
|
||||
import {
|
||||
getDocsFolder,
|
||||
getDocsMode,
|
||||
getIntegrations,
|
||||
ROOT_FRAMEWORK,
|
||||
} from "@/lib/registry";
|
||||
import type { LlmPage } from "@/lib/llm-text";
|
||||
import { renderPageToLlmText } from "@/lib/llm-text";
|
||||
import { resolveReferencePage } from "@/lib/reference-items";
|
||||
import fs from "fs";
|
||||
import matter from "gray-matter";
|
||||
|
||||
// Per-page raw-Markdown endpoint. The `next.config.ts` rewrites map
|
||||
// `<path>.md` and `<path>.mdx` requests onto this route so external
|
||||
// crawlers and the in-page LLMCopyButton can fetch a clean, LLM-friendly
|
||||
// version of each docs page.
|
||||
//
|
||||
// What we serve (different from the previous version, which returned
|
||||
// the unrendered MDX source verbatim):
|
||||
//
|
||||
// - `<Snippet ... />` tags are resolved to fenced markdown code blocks
|
||||
// using `demo-content.json`, so the LLM sees real code, not a JSX
|
||||
// tag it can't interpret.
|
||||
// - `<InlineDemo />` tags become short HTML comments (no body content
|
||||
// — they're live iframes on the site).
|
||||
// - Shared `<Component />` snippets (`<AGUI />`, `<FrontendTools />`,
|
||||
// etc.) are inlined from the shared snippets dir, same as the live
|
||||
// page renderer.
|
||||
// - Frontmatter is stripped and replaced with an H1 + description
|
||||
// blockquote so the title survives.
|
||||
//
|
||||
// URL resolution mirrors what `app/[framework]/[[...slug]]/page.tsx` does:
|
||||
// - Frontend-scoped URLs reuse the same `/<frontend>` content
|
||||
// resolution as the live frontend pages.
|
||||
// - When the first segment is a known integration slug, we try
|
||||
// `integrations/<docsFolder>/<rest>.mdx` first (or root depending on
|
||||
// docs_mode), so framework-scoped URLs resolve the correct MDX.
|
||||
// - Otherwise we walk the bare slug, then fall back to `/reference/...`
|
||||
// and `/ag-ui/...` content roots.
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ slug?: string[] }> },
|
||||
): Promise<NextResponse> {
|
||||
const { slug = [] } = await params;
|
||||
if (slug.length === 0) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const resolved = resolvePage(slug);
|
||||
if (!resolved) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const body = renderPageToLlmText(resolved.page, {
|
||||
framework: resolved.framework,
|
||||
});
|
||||
if (!body) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
return new NextResponse(body, {
|
||||
headers: {
|
||||
"Content-Type": "text/markdown; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=60, stale-while-revalidate=300",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface ResolvedPage {
|
||||
page: LlmPage;
|
||||
framework?: string;
|
||||
}
|
||||
|
||||
type FrontendPageId = Exclude<FrontendId, "react">;
|
||||
|
||||
function isFrontendGuidanceSlug(slugPath: string): boolean {
|
||||
return slugPath === "using-these-docs";
|
||||
}
|
||||
|
||||
function isFrontendRootSlug(slugPath: string): boolean {
|
||||
return !slugPath || slugPath === "quickstart";
|
||||
}
|
||||
|
||||
function resolvePage(slug: string[]): ResolvedPage | null {
|
||||
const first = slug[0]!;
|
||||
const rest = slug.slice(1).join("/");
|
||||
const url = slug.join("/");
|
||||
|
||||
// /<frontend>[/<slug>].md → the same MDX rendered by the
|
||||
// frontend-scoped docs pages.
|
||||
if (isFrontendId(first)) {
|
||||
if (first === "react") return null;
|
||||
|
||||
const frontend = first as FrontendPageId;
|
||||
const frontendRoute = parseFrontendRoutePath(
|
||||
`/${slug.join("/")}`,
|
||||
getIntegrations().map((integration) => integration.slug),
|
||||
);
|
||||
const frontendRest = frontendRoute?.slugPath ?? rest;
|
||||
const activeBackendFramework =
|
||||
frontendRoute?.backend === ROOT_FRAMEWORK
|
||||
? undefined
|
||||
: (frontendRoute?.backend ?? undefined);
|
||||
if (isFrontendGuidanceSlug(frontendRest)) {
|
||||
const contentSlug = getFrontendGuidanceContentSlug(frontend);
|
||||
const doc = loadDoc(contentSlug);
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
page: {
|
||||
url,
|
||||
title: doc.fm.title,
|
||||
description: doc.fm.description,
|
||||
filePath: doc.filePath,
|
||||
loadSlug: contentSlug,
|
||||
framework: activeBackendFramework,
|
||||
},
|
||||
framework: activeBackendFramework,
|
||||
};
|
||||
}
|
||||
|
||||
if (isFrontendRootSlug(frontendRest)) {
|
||||
const contentSlug = getFrontendContentSlug(frontend);
|
||||
const doc = loadDoc(contentSlug);
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
page: {
|
||||
url,
|
||||
title: doc.fm.title,
|
||||
description: doc.fm.description,
|
||||
filePath: doc.filePath,
|
||||
loadSlug: contentSlug,
|
||||
framework: activeBackendFramework,
|
||||
},
|
||||
framework: activeBackendFramework,
|
||||
};
|
||||
}
|
||||
|
||||
if (activeBackendFramework) {
|
||||
return resolveFrameworkScopedPage(
|
||||
activeBackendFramework,
|
||||
frontendRest || "index",
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const contentSlug = (() => {
|
||||
const resolution = resolveFrontendDocPage(frontend, frontendRest);
|
||||
return resolution.status === "found" ? resolution.contentSlugPath : null;
|
||||
})();
|
||||
|
||||
if (!contentSlug) return null;
|
||||
const doc = loadDoc(contentSlug);
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
page: {
|
||||
url,
|
||||
title: doc.fm.title,
|
||||
description: doc.fm.description,
|
||||
filePath: doc.filePath,
|
||||
loadSlug: contentSlug,
|
||||
framework: activeBackendFramework,
|
||||
},
|
||||
framework: activeBackendFramework,
|
||||
};
|
||||
}
|
||||
|
||||
// /reference/<slug>.md → src/content/reference/<slug>.mdx
|
||||
if (first === "reference") {
|
||||
const referenceSlug = rest ? rest.split("/") : [];
|
||||
const resolved = resolveReferencePage(referenceSlug);
|
||||
if (!resolved) return null;
|
||||
const { data } = matter(resolved.raw);
|
||||
return {
|
||||
page: {
|
||||
url,
|
||||
title:
|
||||
typeof data.title === "string"
|
||||
? data.title
|
||||
: resolved.pageSlug || "Reference",
|
||||
description:
|
||||
typeof data.description === "string" ? data.description : undefined,
|
||||
filePath: resolved.filePath,
|
||||
loadSlug: `__reference__/${resolved.contentSlug}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// /ag-ui/<slug>.md → src/content/ag-ui/<slug>.mdx
|
||||
if (first === "ag-ui") {
|
||||
const agSlug = rest || "index";
|
||||
const filePath = findExistingMdx(AG_UI_CONTENT_DIR, agSlug);
|
||||
if (!filePath) return null;
|
||||
return {
|
||||
page: {
|
||||
url,
|
||||
title: agSlug,
|
||||
filePath,
|
||||
loadSlug: `__ag-ui__/${agSlug}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Framework-scoped URL: first segment is an integration slug.
|
||||
const frameworkSlugs = new Set(getIntegrations().map((i) => i.slug));
|
||||
if (frameworkSlugs.has(first)) {
|
||||
return resolveFrameworkScopedPage(first, rest || "index", url);
|
||||
}
|
||||
|
||||
// Bare unscoped doc. The root surface serves ROOT_FRAMEWORK's
|
||||
// authored page when one exists (mirrors UnscopedDocsPage), so the
|
||||
// `.md` variant must resolve the same MDX the page renders.
|
||||
const rootOverride = `integrations/${getDocsFolder(ROOT_FRAMEWORK)}/${url}`;
|
||||
const candidates =
|
||||
getDocsMode(ROOT_FRAMEWORK) === "authored" ? [rootOverride, url] : [url];
|
||||
for (const candidate of candidates) {
|
||||
const doc = loadDoc(candidate);
|
||||
if (!doc) continue;
|
||||
const isOverride = candidate !== url;
|
||||
return {
|
||||
page: {
|
||||
url,
|
||||
title: doc.fm.title,
|
||||
description: doc.fm.description,
|
||||
filePath: doc.filePath,
|
||||
loadSlug: candidate,
|
||||
framework: isOverride ? ROOT_FRAMEWORK : undefined,
|
||||
},
|
||||
framework: isOverride ? ROOT_FRAMEWORK : undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveFrameworkScopedPage(
|
||||
framework: string,
|
||||
tail: string,
|
||||
url: string,
|
||||
): ResolvedPage | null {
|
||||
const docsFolder = getDocsFolder(framework);
|
||||
const docsMode = getDocsMode(framework);
|
||||
const rootSlugPath = tail;
|
||||
const frameworkSlugPath = `integrations/${docsFolder}/${tail}`;
|
||||
|
||||
// `authored` frameworks own their entire IA — try the per-framework
|
||||
// tree first. `generated` is the inverse — root wins, framework
|
||||
// tree is the override, except quickstart where the root file is
|
||||
// only a routing shim and the page route prefers framework content.
|
||||
const candidateOrder =
|
||||
docsMode === "authored" || tail === "quickstart"
|
||||
? [frameworkSlugPath, rootSlugPath]
|
||||
: [rootSlugPath, frameworkSlugPath];
|
||||
|
||||
for (const candidate of candidateOrder) {
|
||||
const doc = loadDoc(candidate);
|
||||
if (!doc) continue;
|
||||
return {
|
||||
page: {
|
||||
url,
|
||||
title: doc.fm.title,
|
||||
description: doc.fm.description,
|
||||
filePath: doc.filePath,
|
||||
loadSlug: candidate,
|
||||
framework,
|
||||
},
|
||||
framework,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `<root>/<slug>.mdx` or `<root>/<slug>/index.mdx` if present.
|
||||
* Returns null when neither exists. Constrained to `root` via
|
||||
* `path.resolve()` + prefix check to keep slug input from escaping the
|
||||
* content dir.
|
||||
*/
|
||||
function findExistingMdx(root: string, slug: string): string | null {
|
||||
const candidates = [
|
||||
path.join(root, `${slug}.mdx`),
|
||||
path.join(root, slug, "index.mdx"),
|
||||
];
|
||||
const resolvedRoot = path.resolve(root);
|
||||
for (const cand of candidates) {
|
||||
const resolved = path.resolve(cand);
|
||||
if (!resolved.startsWith(resolvedRoot + path.sep)) {
|
||||
console.warn(
|
||||
"[llms-mdx] rejecting candidate outside content root",
|
||||
cand,
|
||||
"root:",
|
||||
root,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (fs.existsSync(resolved)) return resolved;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// `/llms.txt` — Markdown index of every documentation page.
|
||||
//
|
||||
// Follows the llmstxt convention (https://llmstxt.org / Fumadocs's
|
||||
// `llms()` helper): a single H1 + description + nested list of page
|
||||
// links. LLM crawlers pull this once and walk the per-page `.md`
|
||||
// endpoints from the URLs listed here.
|
||||
//
|
||||
// We don't use Fumadocs's `llms(source)` helper because this site
|
||||
// builds its sidebar from a hand-rolled meta.json walker (`docs-render`),
|
||||
// not the loader API. The shared `getAllLlmPages()` enumerator yields
|
||||
// the same set the sitemap emits — bare docs + per-framework override
|
||||
// pages + reference + ag-ui — so the index stays in sync with the
|
||||
// actual route table.
|
||||
//
|
||||
// Re-render cadence: this handler walks the filesystem (`getAllLlmPages`)
|
||||
// every time it runs, which is expensive on the full docs tree. We set
|
||||
// `revalidate = false` so the Next.js route-handler cache holds the
|
||||
// response indefinitely on the server side — the only reason to redo
|
||||
// the walk is a redeploy. The `Cache-Control` header below is the
|
||||
// SEPARATE per-response CDN/browser hint (short max-age so external
|
||||
// caches refresh quickly when we ship a doc change); the two cache
|
||||
// layers are independent.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAllLlmPages, renderLlmsIndex } from "@/lib/llm-text";
|
||||
import { getBaseUrl } from "@/lib/sitemap-helpers";
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export function GET(): NextResponse {
|
||||
const pages = getAllLlmPages();
|
||||
const body = renderLlmsIndex(pages, getBaseUrl());
|
||||
return new NextResponse(body, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=60, stale-while-revalidate=300",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Root-level not-found page. Required by Next.js so a thrown
|
||||
// `notFound()` from any route handler yields a proper HTTP 404 response
|
||||
// (not a soft-200 with a default body). Without this file, prod was
|
||||
// returning 200 + Next's built-in "404: This page could not be found"
|
||||
// HTML; Google read every nonexistent URL as low-quality content and
|
||||
// downranked the whole site.
|
||||
//
|
||||
// Setting the explicit response status is the load-bearing piece. The
|
||||
// rendered body is intentionally minimal — docs-shell chrome already
|
||||
// wraps every page through the root layout.
|
||||
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Page not found",
|
||||
description: "The page you're looking for doesn't exist.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex-1 min-w-0 overflow-y-auto">
|
||||
<div className="mx-auto max-w-2xl px-6 py-24 text-center">
|
||||
<p className="text-xs font-mono uppercase tracking-widest text-[var(--accent)] mb-3">
|
||||
404
|
||||
</p>
|
||||
<h1 className="text-3xl font-semibold text-[var(--text)] tracking-tight mb-3">
|
||||
This page doesn't exist
|
||||
</h1>
|
||||
<p className="text-base text-[var(--text-secondary)] leading-relaxed mb-8">
|
||||
The URL you followed may be out of date, or the page may have moved.
|
||||
Try the docs home or browse from there.
|
||||
</p>
|
||||
<div className="flex justify-center gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="shell-docs-radius-control inline-flex h-10 items-center border border-[var(--accent)] bg-[var(--accent)] px-4 text-sm font-medium text-[var(--primary-foreground)] transition-opacity hover:opacity-90"
|
||||
>
|
||||
Go to docs home
|
||||
</Link>
|
||||
<Link
|
||||
href="/reference"
|
||||
className="shell-docs-radius-control inline-flex h-10 items-center border border-[var(--border)] bg-[var(--bg-surface)] px-4 text-sm font-medium text-[var(--text)] transition-colors hover:border-[var(--accent)]"
|
||||
>
|
||||
API reference
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ImageResponse } from "next/og";
|
||||
import { notFound } from "next/navigation";
|
||||
import { loadDoc } from "@/lib/docs-render";
|
||||
import { getDocsFolder, getIntegration } from "@/lib/registry";
|
||||
import { GET } from "./route";
|
||||
|
||||
vi.mock("next/og", () => ({
|
||||
ImageResponse: vi.fn(function MockImageResponse() {
|
||||
return new Response("png", {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/png" },
|
||||
});
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
notFound: vi.fn(() => {
|
||||
const error = new Error("not found") as Error & { digest: string };
|
||||
error.digest = "NEXT_HTTP_ERROR_FALLBACK;404";
|
||||
throw error;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Default loadDoc behavior: only the bare `quickstart` slug resolves.
|
||||
// Re-applied in beforeEach because individual tests install their own
|
||||
// slug-keyed implementations and `vi.clearAllMocks()` does not restore
|
||||
// the factory implementation.
|
||||
const defaultLoadDoc = (slug: string) =>
|
||||
slug === "quickstart"
|
||||
? {
|
||||
source: "",
|
||||
filePath: "quickstart.mdx",
|
||||
fm: {
|
||||
title: "Quickstart",
|
||||
description: "Build with CopilotKit.",
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
vi.mock("@/lib/docs-render", () => ({
|
||||
loadDoc: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/registry", () => ({
|
||||
// Identity fallback mirrors the real helper (slug → folder of the
|
||||
// same name unless overridden).
|
||||
getDocsFolder: vi.fn((slug: string) => slug),
|
||||
getIntegration: vi.fn(() => null),
|
||||
ROOT_FRAMEWORK: "built-in-agent",
|
||||
}));
|
||||
|
||||
const imageResponseMock = vi.mocked(ImageResponse);
|
||||
const loadDocMock = vi.mocked(loadDoc);
|
||||
const notFoundMock = vi.mocked(notFound);
|
||||
const getDocsFolderMock = vi.mocked(getDocsFolder);
|
||||
const getIntegrationMock = vi.mocked(getIntegration);
|
||||
|
||||
function callOgRoute(slug: string[]) {
|
||||
return GET(new Request("http://localhost:3003/og/test/og.png") as never, {
|
||||
params: Promise.resolve({ slug }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("shell-docs OG route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
loadDocMock.mockImplementation(defaultLoadDoc);
|
||||
imageResponseMock.mockImplementation(function MockImageResponse() {
|
||||
return new Response("png", {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/png" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("constructs a PNG response with canonical Plus Jakarta fonts", async () => {
|
||||
const response = await callOgRoute(["quickstart", "og.png"]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("image/png");
|
||||
expect(imageResponseMock).toHaveBeenCalledOnce();
|
||||
|
||||
const [, options] = imageResponseMock.mock.calls[0];
|
||||
expect(options?.width).toBe(1200);
|
||||
expect(options?.height).toBe(630);
|
||||
expect(options?.fonts).toHaveLength(2);
|
||||
expect(options?.fonts?.map((font) => font.name)).toEqual([
|
||||
"Plus Jakarta Sans",
|
||||
"Plus Jakarta Sans",
|
||||
]);
|
||||
expect(options?.fonts?.map((font) => font.weight)).toEqual([500, 700]);
|
||||
expect(
|
||||
options?.fonts?.every((font) => font.data instanceof ArrayBuffer),
|
||||
).toBe(true);
|
||||
expect(new Set(options?.fonts?.map((font) => font.data)).size).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps unknown slugs on the Next.js 404 path", async () => {
|
||||
await expect(callOgRoute(["missing", "og.png"])).rejects.toMatchObject({
|
||||
digest: expect.stringContaining("NEXT_HTTP_ERROR"),
|
||||
});
|
||||
|
||||
expect(loadDocMock).toHaveBeenCalledWith("missing");
|
||||
expect(notFoundMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns 500 when image rendering fails", async () => {
|
||||
imageResponseMock.mockImplementationOnce(function MockImageResponse() {
|
||||
throw new Error("render failed");
|
||||
});
|
||||
|
||||
const response = await callOgRoute(["quickstart", "og.png"]);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
await expect(response.text()).resolves.toBe("OG image generation failed");
|
||||
});
|
||||
|
||||
it("still resolves framework-scoped docs before rendering", async () => {
|
||||
getIntegrationMock.mockReturnValueOnce({ name: "LangGraph" } as never);
|
||||
loadDocMock.mockImplementation((slug: string) =>
|
||||
slug === "integrations/langgraph/quickstart"
|
||||
? {
|
||||
source: "",
|
||||
filePath: "integrations/langgraph/quickstart.mdx",
|
||||
fm: {
|
||||
title: "LangGraph Quickstart",
|
||||
description: "Framework scoped docs.",
|
||||
},
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const response = await callOgRoute(["langgraph", "quickstart", "og.png"]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(loadDocMock).toHaveBeenLastCalledWith(
|
||||
"integrations/langgraph/quickstart",
|
||||
);
|
||||
expect(getDocsFolderMock).toHaveBeenCalledWith("langgraph");
|
||||
});
|
||||
|
||||
it("serves the root surface from the Built-in Agent override when one exists", async () => {
|
||||
// `/server-tools` has no bare root MDX — the BIA-authored page is
|
||||
// what the live route renders, so the OG image must read the same
|
||||
// file.
|
||||
loadDocMock.mockImplementation((slug: string) =>
|
||||
slug === "integrations/built-in-agent/server-tools"
|
||||
? {
|
||||
source: "",
|
||||
filePath: "integrations/built-in-agent/server-tools.mdx",
|
||||
fm: {
|
||||
title: "Server Tools",
|
||||
description: "Define tools on the Built-in Agent.",
|
||||
},
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const response = await callOgRoute(["server-tools", "og.png"]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(loadDocMock).toHaveBeenCalledWith(
|
||||
"integrations/built-in-agent/server-tools",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
import React from "react";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ImageResponse } from "next/og";
|
||||
import { loadDoc } from "@/lib/docs-render";
|
||||
import { getDocsFolder, getIntegration, ROOT_FRAMEWORK } from "@/lib/registry";
|
||||
|
||||
// Per-page Open Graph image route — emits a 1200x630-ish PNG used by
|
||||
// Twitter cards, LinkedIn previews, and Slack unfurls. Ported from
|
||||
// upstream `docs/app/og/[...slug]/route.tsx` and adapted to shell-docs:
|
||||
// the upstream version reads page metadata via fumadocs `source`, while
|
||||
// shell-docs reads it directly from MDX frontmatter via `loadDoc()`.
|
||||
//
|
||||
// Public signature is preserved: requests at `/og/<slug>/og.png` map
|
||||
// to the page at `<slug>` (the trailing `og.png` is stripped, matching
|
||||
// upstream's `generateStaticParams` which appends it).
|
||||
//
|
||||
// Previously this route fetched Inter TTFs from fonts.gstatic.com at
|
||||
// request time and any failure (Railway egress hiccup, font URL drift,
|
||||
// cold cache) put the request into the catch block, which 307-redirected
|
||||
// to a static CDN fallback that itself was broken (25 bytes). The result
|
||||
// was zero working OG images on most pages in prod. We now skip the font
|
||||
// fetch entirely. Keep that reliability improvement by loading all OG
|
||||
// render assets from shell-docs/public instead of external URLs.
|
||||
|
||||
const PUBLIC_ROOT_CANDIDATES = [
|
||||
path.join(process.cwd(), "public"),
|
||||
path.join(process.cwd(), "showcase/shell-docs/public"),
|
||||
];
|
||||
|
||||
function readPublicAsset(relativePath: string): Buffer {
|
||||
for (const publicRoot of PUBLIC_ROOT_CANDIDATES) {
|
||||
const candidate = path.join(publicRoot, relativePath);
|
||||
if (existsSync(candidate)) {
|
||||
return readFileSync(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Missing shell-docs public asset: ${relativePath}`);
|
||||
}
|
||||
|
||||
function toArrayBuffer(buffer: Buffer): ArrayBuffer {
|
||||
return buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength,
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
function toDataUri(buffer: Buffer, mimeType: string): string {
|
||||
return `data:${mimeType};base64,${buffer.toString("base64")}`;
|
||||
}
|
||||
|
||||
function compactText(value: unknown): string {
|
||||
return typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
return `${value.slice(0, maxLength - 3).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function humanizeSlugSegment(segment: string | undefined): string {
|
||||
if (!segment) return "Docs";
|
||||
return segment
|
||||
.split("-")
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
const plusJakartaMedium = toArrayBuffer(
|
||||
readPublicAsset("fonts/plus-jakarta-sans/PlusJakartaSans-Medium.ttf"),
|
||||
);
|
||||
const plusJakartaBold = toArrayBuffer(
|
||||
readPublicAsset("fonts/plus-jakarta-sans/PlusJakartaSans-Bold.ttf"),
|
||||
);
|
||||
const copilotKitLogo = toDataUri(
|
||||
readPublicAsset("images/og/copilotkit-logo-lockup.png"),
|
||||
"image/png",
|
||||
);
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
// In Next.js 13+ (app directory), route handlers use the following signature:
|
||||
export async function GET(
|
||||
_: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string[] }> },
|
||||
) {
|
||||
// Skip OG image generation during build phase to avoid fetch errors
|
||||
if (process.env.NEXT_PHASE === "phase-production-build") {
|
||||
return new Response("OG image skipped during build", { status: 200 });
|
||||
}
|
||||
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
// Drop the trailing `og.png` segment to recover the actual page slug.
|
||||
const slugParts = resolvedParams.slug.slice(0, -1);
|
||||
const slugPath = slugParts.join("/");
|
||||
// Resolution mirrors the page routes' order so that the OG image
|
||||
// always reflects the same MDX file the user sees:
|
||||
// 1. ROOT_FRAMEWORK override — the root surface serves the
|
||||
// BIA-authored page when one exists (see UnscopedDocsPage).
|
||||
// 2. Direct loadDoc(slugPath) for unscoped paths (e.g. concepts/...).
|
||||
// 3. Framework-scoped: when the first segment is a registered
|
||||
// integration slug, try integrations/<docsFolder>/<rest>.
|
||||
// 4. Bare framework root (e.g. "built-in-agent") -> the file at
|
||||
// that name in the docs root (built-in-agent.mdx) — the
|
||||
// existing behavior preserved here so prior callers keep
|
||||
// working.
|
||||
let doc = slugPath
|
||||
? (loadDoc(`integrations/${getDocsFolder(ROOT_FRAMEWORK)}/${slugPath}`) ??
|
||||
loadDoc(slugPath))
|
||||
: null;
|
||||
if (!doc && slugParts.length >= 2) {
|
||||
const [framework, ...rest] = slugParts;
|
||||
if (getIntegration(framework)) {
|
||||
const docsFolder = getDocsFolder(framework);
|
||||
doc = loadDoc(`integrations/${docsFolder}/${rest.join("/")}`);
|
||||
}
|
||||
}
|
||||
if (!doc) notFound();
|
||||
|
||||
const title = truncateText(
|
||||
compactText(doc.fm.title) || "CopilotKit Docs",
|
||||
86,
|
||||
);
|
||||
const description = truncateText(
|
||||
compactText(doc.fm.description) ||
|
||||
"Build production-ready agentic experiences with CopilotKit.",
|
||||
132,
|
||||
);
|
||||
const titleFontSize = title.length > 58 ? 54 : title.length > 36 ? 62 : 70;
|
||||
const sectionLabel = humanizeSlugSegment(slugParts[0]);
|
||||
|
||||
return new ImageResponse(
|
||||
<section
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: 44,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background:
|
||||
"linear-gradient(135deg, #EDEDF5 0%, #FFFFFF 48%, #E9E9EF 100%)",
|
||||
color: "#010507",
|
||||
fontFamily: "Plus Jakarta Sans",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: -80,
|
||||
right: -80,
|
||||
bottom: -120,
|
||||
height: 260,
|
||||
opacity: 0.2,
|
||||
background:
|
||||
"linear-gradient(90deg, #BEC2FF 0%, #85ECCE 48%, #54A4F2 100%)",
|
||||
borderRadius: 999,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 92,
|
||||
right: 64,
|
||||
width: 360,
|
||||
height: 360,
|
||||
opacity: 0.18,
|
||||
borderRadius: 48,
|
||||
transform: "rotate(10deg)",
|
||||
background: "#BEC2FF",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 150,
|
||||
right: 152,
|
||||
width: 280,
|
||||
height: 280,
|
||||
opacity: 0.22,
|
||||
borderRadius: 44,
|
||||
transform: "rotate(-8deg)",
|
||||
background: "#85ECCE",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={copilotKitLogo}
|
||||
width={258}
|
||||
height={50}
|
||||
alt="CopilotKit"
|
||||
style={{
|
||||
width: 258,
|
||||
height: 50,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: 44,
|
||||
padding: "0 20px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid #DBDBE5",
|
||||
background: "#FFFFFFB2",
|
||||
color: "#57575B",
|
||||
fontSize: 20,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
CopilotKit Docs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section
|
||||
style={{
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
marginTop: 34,
|
||||
padding: 44,
|
||||
borderRadius: 34,
|
||||
border: "2px solid #FFFFFF",
|
||||
background: "#FFFFFFB2",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
maxWidth: 860,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
color: "#57575B",
|
||||
fontSize: 24,
|
||||
fontWeight: 500,
|
||||
marginBottom: 18,
|
||||
}}
|
||||
>
|
||||
{sectionLabel}
|
||||
</div>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "#010507",
|
||||
fontSize: titleFontSize,
|
||||
lineHeight: 1.04,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
margin: "22px 0 0",
|
||||
maxWidth: 880,
|
||||
color: "#57575B",
|
||||
fontSize: 30,
|
||||
lineHeight: 1.25,
|
||||
fontWeight: 500,
|
||||
letterSpacing: 0,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [
|
||||
{
|
||||
name: "Plus Jakarta Sans",
|
||||
data: plusJakartaMedium,
|
||||
weight: 500,
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
name: "Plus Jakarta Sans",
|
||||
data: plusJakartaBold,
|
||||
weight: 700,
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
// Note: notFound() throws an internal Next.js redirect-like error; let
|
||||
// it propagate so the framework returns a proper 404. Anything else is
|
||||
// a real failure we want surfaced as a 500 with a server-side log so
|
||||
// operators see breakage rather than silent fallback to a static PNG
|
||||
// that may itself be broken.
|
||||
const digest = (error as { digest?: string } | undefined)?.digest;
|
||||
if (typeof digest === "string" && digest.startsWith("NEXT_HTTP_ERROR")) {
|
||||
throw error;
|
||||
}
|
||||
console.error("Error generating OG image:", error);
|
||||
return new Response("OG image generation failed", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import type { Metadata } from "next";
|
||||
import type React from "react";
|
||||
import { Fragment } from "react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { MDXRemote } from "next-mdx-remote/rsc";
|
||||
import matter from "gray-matter";
|
||||
import { ChevronRight, LinkIcon } from "lucide-react";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
rehypeCode,
|
||||
rehypeCodeDefaultOptions,
|
||||
} from "fumadocs-core/mdx-plugins";
|
||||
import { PropertyReference } from "@/components/property-reference";
|
||||
import { MdxCodeBlock } from "@/components/mdx-code-block";
|
||||
import { transformerMeta } from "@/lib/rehype-code-meta";
|
||||
import {
|
||||
Callout,
|
||||
Cards,
|
||||
Card,
|
||||
Accordions,
|
||||
Accordion,
|
||||
} from "@/components/mdx-components";
|
||||
import {
|
||||
MarkdownCopyButton,
|
||||
ViewOptionsPopover,
|
||||
} from "@/components/ai/page-actions";
|
||||
import { OpsPlatformCTA } from "@/components/react/ops-platform-cta";
|
||||
import {
|
||||
DocsPage,
|
||||
DocsBody,
|
||||
DocsTitle,
|
||||
DocsDescription,
|
||||
} from "fumadocs-ui/page";
|
||||
import { ShellDocsLayout } from "@/components/shell-docs-layout";
|
||||
import { ReferenceVersionSelector } from "@/components/reference-version-selector";
|
||||
import {
|
||||
REFERENCE_VERSIONS,
|
||||
buildReferencePageTree,
|
||||
referenceHref,
|
||||
referenceStaticParams,
|
||||
referenceVersionHref,
|
||||
resolveReferencePage,
|
||||
} from "@/lib/reference-items";
|
||||
import { stripLeadingImports } from "@/lib/docs-render";
|
||||
import { buildDocMetadata } from "@/lib/seo-metadata";
|
||||
|
||||
// Self-canonical for /reference/<slug>. Reference pages are not
|
||||
// per-framework, but we still emit a canonical so the production URL
|
||||
// is unambiguous and any future host aliases can't fragment indexing.
|
||||
// Title/description come from the page's MDX frontmatter so each API
|
||||
// reference page emits its own social card and SEO description rather
|
||||
// than inheriting the layout's generic site-wide values.
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const resolved = resolveReferencePage(slug);
|
||||
const raw = resolved?.raw ?? null;
|
||||
let title: string | undefined;
|
||||
let description: string | undefined;
|
||||
if (raw !== null) {
|
||||
try {
|
||||
const { data } = matter(raw);
|
||||
if (typeof data.title === "string" && data.title.length > 0) {
|
||||
title = data.title;
|
||||
}
|
||||
if (typeof data.description === "string" && data.description.length > 0) {
|
||||
description = data.description;
|
||||
}
|
||||
} catch {
|
||||
// Malformed frontmatter — fall back to slug-derived title.
|
||||
}
|
||||
}
|
||||
return buildDocMetadata({
|
||||
title: title ?? slug[slug.length - 1],
|
||||
description,
|
||||
canonicalPath: resolved
|
||||
? referenceHref(resolved.version, resolved.pageSlug)
|
||||
: `/reference/${slug.join("/")}`,
|
||||
});
|
||||
}
|
||||
|
||||
// next-mdx-remote components map
|
||||
const mdxComponents = {
|
||||
PropertyReference,
|
||||
// Render fenced code blocks through the same Shiki + Fumadocs CodeBlock
|
||||
// chrome the main docs use (syntax highlighting + copy button), paired with
|
||||
// the rehypeCode plugin wired into the MDXRemote options below.
|
||||
pre: MdxCodeBlock,
|
||||
Callout,
|
||||
Cards,
|
||||
Card,
|
||||
Accordions,
|
||||
Accordion,
|
||||
OpsPlatformCTA,
|
||||
LinkIcon,
|
||||
Frame: ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="shell-docs-radius-surface my-6 border border-[var(--border)] bg-[var(--bg-surface)] p-4 shadow-[var(--shadow-control)]">
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
// Strip unknown imports — MDX import statements become no-ops in next-mdx-remote
|
||||
};
|
||||
|
||||
function buildGitHubUrl(absFilePath: string): string {
|
||||
const marker = "/showcase/";
|
||||
const idx = absFilePath.indexOf(marker);
|
||||
const repoRelative =
|
||||
idx >= 0 ? absFilePath.slice(idx + 1) : "showcase/shell-docs";
|
||||
return `https://github.com/CopilotKit/CopilotKit/blob/main/${repoRelative}`;
|
||||
}
|
||||
|
||||
function categoryLabel(pageSlug: string): string | null {
|
||||
const category = pageSlug.split("/").filter(Boolean)[0];
|
||||
if (!category) return null;
|
||||
return category.charAt(0).toUpperCase() + category.slice(1);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return referenceStaticParams();
|
||||
}
|
||||
|
||||
export default async function ReferenceSlugPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const resolved = resolveReferencePage(slug);
|
||||
if (resolved === null) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { version, pageSlug, contentSlug, filePath, raw } = resolved;
|
||||
let content = "";
|
||||
let data: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed = matter(raw);
|
||||
content = parsed.content;
|
||||
data = parsed.data;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[reference] Failed to parse frontmatter in ${contentSlug}.mdx:`,
|
||||
err,
|
||||
);
|
||||
notFound();
|
||||
}
|
||||
|
||||
const cleanedContent = stripLeadingImports(content);
|
||||
|
||||
const title =
|
||||
typeof data.title === "string" && data.title.length > 0
|
||||
? data.title
|
||||
: slug[slug.length - 1];
|
||||
const description =
|
||||
typeof data.description === "string" ? data.description : undefined;
|
||||
const pageTree = buildReferencePageTree(version);
|
||||
const markdownUrl = `${referenceHref(version, pageSlug).replace(/\/$/, "")}.mdx`;
|
||||
const versionOptions = REFERENCE_VERSIONS.map((referenceVersion) => ({
|
||||
version: referenceVersion,
|
||||
href: referenceVersionHref(referenceVersion, pageSlug),
|
||||
}));
|
||||
const breadcrumbs = [
|
||||
{ label: "Reference", href: "/reference" },
|
||||
{ label: version, href: referenceVersionHref(version) },
|
||||
...(categoryLabel(pageSlug)
|
||||
? [{ label: categoryLabel(pageSlug) ?? "", href: null }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<ShellDocsLayout
|
||||
tree={pageTree}
|
||||
banner={
|
||||
<ReferenceVersionSelector
|
||||
activeVersion={version}
|
||||
options={versionOptions}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DocsPage
|
||||
toc={[]}
|
||||
tableOfContent={{ enabled: false }}
|
||||
tableOfContentPopover={{ enabled: false }}
|
||||
breadcrumb={{ enabled: false }}
|
||||
footer={{ enabled: false }}
|
||||
>
|
||||
<div className="docs-inner-content max-w-[900px] mx-auto px-4 md:px-6 pt-2 pb-6 md:pt-3 xl:pt-4">
|
||||
<nav className="mb-2 flex flex-wrap items-center gap-1 text-[11px] font-medium leading-none text-[var(--text-muted)]">
|
||||
{breadcrumbs.map((crumb, i) => {
|
||||
const isLast = i === breadcrumbs.length - 1;
|
||||
const labelClass = `truncate ${isLast ? "text-[var(--text)] font-medium" : ""}`;
|
||||
return (
|
||||
<Fragment key={`${crumb.label}-${i}`}>
|
||||
{i > 0 && (
|
||||
<ChevronRight
|
||||
className="size-3 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{crumb.href && !isLast ? (
|
||||
<Link
|
||||
href={crumb.href}
|
||||
className={`${labelClass} transition-opacity hover:opacity-80`}
|
||||
>
|
||||
{crumb.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={labelClass}>{crumb.label}</span>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<DocsTitle className="text-[32px] md:text-[40px] font-medium leading-[1.2]">
|
||||
{title}
|
||||
</DocsTitle>
|
||||
{description && (
|
||||
<DocsDescription className="text-lg text-[var(--text-muted)] mt-5 leading-relaxed">
|
||||
{description}
|
||||
</DocsDescription>
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-row flex-wrap gap-2 items-center my-6">
|
||||
<MarkdownCopyButton markdownUrl={markdownUrl} />
|
||||
<ViewOptionsPopover
|
||||
markdownUrl={markdownUrl}
|
||||
githubUrl={buildGitHubUrl(filePath)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr className="border-t border-[var(--border)] mt-2 mb-6" />
|
||||
|
||||
<DocsBody className="reference-content">
|
||||
<MDXRemote
|
||||
source={cleanedContent}
|
||||
components={mdxComponents}
|
||||
options={{
|
||||
mdxOptions: {
|
||||
remarkPlugins: [remarkGfm],
|
||||
rehypePlugins: [
|
||||
[
|
||||
rehypeCode,
|
||||
{
|
||||
fallbackLanguage: "plaintext",
|
||||
transformers: [
|
||||
...(rehypeCodeDefaultOptions.transformers ?? []),
|
||||
transformerMeta(),
|
||||
],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</DocsBody>
|
||||
</div>
|
||||
</DocsPage>
|
||||
</ShellDocsLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
DocsPage,
|
||||
DocsBody,
|
||||
DocsTitle,
|
||||
DocsDescription,
|
||||
} from "fumadocs-ui/page";
|
||||
import { ShellDocsLayout } from "@/components/shell-docs-layout";
|
||||
import { Cards, Card } from "@/components/mdx-components";
|
||||
import { ReferenceVersionSelector } from "@/components/reference-version-selector";
|
||||
import {
|
||||
REFERENCE_CATEGORIES,
|
||||
REFERENCE_VERSIONS,
|
||||
buildReferencePageTree,
|
||||
loadReferenceVersionItems,
|
||||
referenceVersionHref,
|
||||
} from "@/lib/reference-items";
|
||||
import type { ReferenceCategory, ReferenceItem } from "@/lib/reference-items";
|
||||
|
||||
function displayTitle(item: ReferenceItem): string {
|
||||
if (item.category === "Components") return `<${item.title} />`;
|
||||
if (item.category === "Hooks" || item.category === "Functions") {
|
||||
return `${item.title}()`;
|
||||
}
|
||||
return item.title;
|
||||
}
|
||||
|
||||
function categoryItems(
|
||||
items: ReferenceItem[],
|
||||
category: ReferenceCategory,
|
||||
): ReferenceItem[] {
|
||||
return items.filter((item) => item.category === category);
|
||||
}
|
||||
|
||||
// SDK *families* shown as cards at the top of the Overview. This is not a
|
||||
// 1:1 mapping of REFERENCE_VERSIONS — React v1 is a legacy version reachable
|
||||
// via the sidebar picker, not its own card. The body below this chooser lists
|
||||
// the React reference (the default landing); the sidebar picker switches SDKs.
|
||||
const SDK_CHOICES: { name: string; description: string; href: string }[] = [
|
||||
{
|
||||
name: "React",
|
||||
description:
|
||||
"Hooks and components for building CopilotKit into a React app.",
|
||||
href: referenceVersionHref("v2"),
|
||||
},
|
||||
{
|
||||
name: "React Native",
|
||||
description:
|
||||
"Headless provider, prebuilt UI, and hooks for building CopilotKit into a React Native app.",
|
||||
href: referenceVersionHref("react-native"),
|
||||
},
|
||||
{
|
||||
name: "Vue",
|
||||
description:
|
||||
"Composables and components for building CopilotKit into a Vue app.",
|
||||
href: referenceVersionHref("vue"),
|
||||
},
|
||||
{
|
||||
name: "Angular",
|
||||
description:
|
||||
"Components, services, and functions for building CopilotKit into an Angular app.",
|
||||
href: referenceVersionHref("angular"),
|
||||
},
|
||||
{
|
||||
name: "Core (TypeScript)",
|
||||
description:
|
||||
"The framework-agnostic @copilotkit/core client — runs anywhere JavaScript runs.",
|
||||
href: referenceVersionHref("core"),
|
||||
},
|
||||
{
|
||||
name: "Channels",
|
||||
description:
|
||||
"The bot stack — createBot, JSX message components, and the Slack adapter.",
|
||||
href: referenceVersionHref("channels"),
|
||||
},
|
||||
];
|
||||
|
||||
export default function ReferencePage() {
|
||||
const activeVersion = "v2";
|
||||
const allItems = loadReferenceVersionItems(activeVersion);
|
||||
const pageTree = buildReferencePageTree(activeVersion);
|
||||
const intro =
|
||||
"Reference documentation for the CopilotKit SDKs. Pick the SDK you're building with, then browse its components, hooks, classes, and types.";
|
||||
const versionOptions = REFERENCE_VERSIONS.map((version) => ({
|
||||
version,
|
||||
href: referenceVersionHref(version),
|
||||
}));
|
||||
|
||||
return (
|
||||
<ShellDocsLayout
|
||||
tree={pageTree}
|
||||
banner={
|
||||
<ReferenceVersionSelector
|
||||
activeVersion={activeVersion}
|
||||
options={versionOptions}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DocsPage
|
||||
toc={[]}
|
||||
tableOfContent={{ enabled: false }}
|
||||
tableOfContentPopover={{ enabled: false }}
|
||||
breadcrumb={{ enabled: false }}
|
||||
footer={{ enabled: false }}
|
||||
>
|
||||
<div className="docs-inner-content max-w-[900px] mx-auto px-4 md:px-6 pt-2 pb-6 md:pt-3 xl:pt-4">
|
||||
<DocsTitle className="text-[32px] md:text-[40px] font-medium leading-[1.2]">
|
||||
Overview
|
||||
</DocsTitle>
|
||||
<DocsDescription className="text-lg text-[var(--text-muted)] mt-5 leading-relaxed">
|
||||
{intro}
|
||||
</DocsDescription>
|
||||
|
||||
<DocsBody className="reference-content prose-sm mt-8">
|
||||
<section>
|
||||
<h2>Choose your SDK</h2>
|
||||
<Cards>
|
||||
{SDK_CHOICES.map((sdk) => (
|
||||
<Card
|
||||
key={sdk.name}
|
||||
href={sdk.href}
|
||||
title={sdk.name}
|
||||
description={sdk.description}
|
||||
/>
|
||||
))}
|
||||
</Cards>
|
||||
</section>
|
||||
|
||||
{REFERENCE_CATEGORIES.map((category) => {
|
||||
const items = categoryItems(allItems, category);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section key={category}>
|
||||
<h2>
|
||||
{category === "Components" ? "UI Components" : category}
|
||||
</h2>
|
||||
<Cards>
|
||||
{items.map((item) => (
|
||||
<Card
|
||||
key={item.slug}
|
||||
href={item.url}
|
||||
title={displayTitle(item)}
|
||||
description={item.description}
|
||||
/>
|
||||
))}
|
||||
</Cards>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</DocsBody>
|
||||
</div>
|
||||
</DocsPage>
|
||||
</ShellDocsLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Next.js App Router robots config. Allows all user-agents across the
|
||||
// public docs surface, blocks the internal `/api/` routes, and points
|
||||
// crawlers at sitemap.xml so they can discover every framework variant.
|
||||
|
||||
import type { MetadataRoute } from "next";
|
||||
import { getBaseUrl } from "@/lib/sitemap-helpers";
|
||||
|
||||
// Force-dynamic so the emitted sitemap URL reflects the live
|
||||
// NEXT_PUBLIC_BASE_URL at request time — see app/sitemap.ts for the
|
||||
// same rationale.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const baseUrl = getBaseUrl();
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: "/api/",
|
||||
},
|
||||
],
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Next.js App Router sitemap. Emits one entry per (root URL, framework
|
||||
// variant) pair so search engines can crawl every framework-scoped doc
|
||||
// at its self-canonical URL.
|
||||
//
|
||||
// The expansion is:
|
||||
// - Root URL (/)
|
||||
// - Bare unscoped pages (/<slug>) from src/content/docs/**.mdx
|
||||
// - Framework-scoped pages (/<fw>/<slug>) bare slugs × every registered
|
||||
// integration, plus per-framework
|
||||
// override pages under
|
||||
// src/content/docs/integrations/
|
||||
// - Reference (/reference/<slug>) from src/content/reference/
|
||||
// - AG-UI (/ag-ui/<slug>) from src/content/ag-ui/
|
||||
//
|
||||
// Each entry's `lastModified` is resolved via resolveLastModified —
|
||||
// frontmatter `lastmod` first, then file mtime, then `new Date()`.
|
||||
|
||||
import type { MetadataRoute } from "next";
|
||||
import {
|
||||
getAgUiPages,
|
||||
getBareDocsPages,
|
||||
getBaseUrl,
|
||||
getFrameworkOverridePages,
|
||||
getReferencePages,
|
||||
resolveLastModified,
|
||||
} from "@/lib/sitemap-helpers";
|
||||
import {
|
||||
FRONTEND_PAGE_IDS,
|
||||
getFrontendContentSlug,
|
||||
getFrontendGuidanceContentSlug,
|
||||
} from "@/lib/frontend-page-content";
|
||||
import { loadDoc } from "@/lib/docs-render";
|
||||
import { getDocsFolder, getIntegrations, ROOT_FRAMEWORK } from "@/lib/registry";
|
||||
|
||||
// Force-dynamic so the sitemap is regenerated per request and reads
|
||||
// the LIVE NEXT_PUBLIC_BASE_URL via getRuntimeConfig(). Without this
|
||||
// Next.js would statically prerender the sitemap at build time and
|
||||
// freeze whichever value `process.env.NEXT_PUBLIC_BASE_URL` had at
|
||||
// `next build` — defeating the runtime-config switch.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = getBaseUrl();
|
||||
const now = new Date();
|
||||
const entries: MetadataRoute.Sitemap = [];
|
||||
|
||||
// 1. Root / overview.
|
||||
entries.push({
|
||||
url: `${baseUrl}/`,
|
||||
lastModified: now,
|
||||
});
|
||||
|
||||
// 2. Bare unscoped docs and 3. framework-scoped variants. Each bare
|
||||
// slug generates one bare entry plus N framework-scoped entries — one
|
||||
// per registered integration. Per-framework override pages are added
|
||||
// alongside (deduped against the bare × framework cross-product).
|
||||
//
|
||||
// ROOT_FRAMEWORK (Built-in Agent) is excluded from the framework-
|
||||
// scoped expansion: its docs are served at the root surface, and every
|
||||
// `/built-in-agent/*` URL permanently redirects to `/*`. Its override
|
||||
// pages are emitted at their bare root URLs instead.
|
||||
const bareDocs = getBareDocsPages();
|
||||
const integrations = getIntegrations().filter(
|
||||
(i) => i.slug !== ROOT_FRAMEWORK,
|
||||
);
|
||||
|
||||
// Track every framework-scoped URL we've already emitted so the
|
||||
// override loop below can skip duplicates without scanning the array.
|
||||
const seenFrameworkUrls = new Set<string>();
|
||||
const bareSlugs = new Set(bareDocs.map((d) => d.slug));
|
||||
|
||||
for (const { slug, filePath } of bareDocs) {
|
||||
const lastModified = resolveLastModified(filePath);
|
||||
// The root `built-in-agent.mdx` topic page collides with the
|
||||
// retired framework prefix: its bare URL permanently redirects to
|
||||
// `/`, so only the framework-scoped variants are listed.
|
||||
if (slug !== ROOT_FRAMEWORK) {
|
||||
entries.push({
|
||||
url: `${baseUrl}/${slug}`,
|
||||
lastModified,
|
||||
});
|
||||
}
|
||||
for (const integration of integrations) {
|
||||
const url = `${baseUrl}/${integration.slug}/${slug}`;
|
||||
seenFrameworkUrls.add(url);
|
||||
entries.push({ url, lastModified });
|
||||
}
|
||||
}
|
||||
|
||||
// Framework landing pages: /<framework> on its own. ROOT_FRAMEWORK's
|
||||
// landing is the root entry already pushed above.
|
||||
for (const integration of integrations) {
|
||||
const url = `${baseUrl}/${integration.slug}`;
|
||||
seenFrameworkUrls.add(url);
|
||||
entries.push({ url, lastModified: now });
|
||||
}
|
||||
|
||||
// Per-framework override pages — topics that only exist under
|
||||
// integrations/<folder>/. These are addressable as /<framework>/<slug>
|
||||
// and aren't covered by the bare × framework cross-product above.
|
||||
for (const integration of integrations) {
|
||||
const folder = getDocsFolder(integration.slug);
|
||||
for (const { slug, filePath } of getFrameworkOverridePages(folder)) {
|
||||
const url = `${baseUrl}/${integration.slug}/${slug}`;
|
||||
if (seenFrameworkUrls.has(url)) continue;
|
||||
seenFrameworkUrls.add(url);
|
||||
entries.push({
|
||||
url,
|
||||
lastModified: resolveLastModified(filePath),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ROOT_FRAMEWORK override pages serve at bare root URLs. Slugs that
|
||||
// shadow a bare doc (e.g. quickstart) are already covered above; the
|
||||
// folder's index is the root entry.
|
||||
for (const { slug, filePath } of getFrameworkOverridePages(
|
||||
getDocsFolder(ROOT_FRAMEWORK),
|
||||
)) {
|
||||
if (!slug || bareSlugs.has(slug)) continue;
|
||||
entries.push({
|
||||
url: `${baseUrl}/${slug}`,
|
||||
lastModified: resolveLastModified(filePath),
|
||||
});
|
||||
}
|
||||
|
||||
// Frontend quickstarts. The source MDX lives under
|
||||
// content/docs/frontends/*, but those files are not served as
|
||||
// /frontends/* docs anymore; they canonicalize to /<frontend>.
|
||||
for (const frontend of FRONTEND_PAGE_IDS) {
|
||||
const doc = loadDoc(getFrontendContentSlug(frontend));
|
||||
if (doc) {
|
||||
entries.push({
|
||||
url: `${baseUrl}/${frontend}`,
|
||||
lastModified: resolveLastModified(doc.filePath),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Status/guidance page, emitted once per non-React frontend.
|
||||
for (const frontend of FRONTEND_PAGE_IDS) {
|
||||
const doc = loadDoc(getFrontendGuidanceContentSlug(frontend));
|
||||
if (doc) {
|
||||
entries.push({
|
||||
url: `${baseUrl}/${frontend}/using-these-docs`,
|
||||
lastModified: resolveLastModified(doc.filePath),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Reference docs.
|
||||
for (const { slug, filePath } of getReferencePages()) {
|
||||
entries.push({
|
||||
url: `${baseUrl}/reference/${slug}`,
|
||||
lastModified: resolveLastModified(filePath),
|
||||
});
|
||||
}
|
||||
// Reference index.
|
||||
entries.push({ url: `${baseUrl}/reference`, lastModified: now });
|
||||
|
||||
// 5. AG-UI.
|
||||
for (const { slug, filePath } of getAgUiPages()) {
|
||||
entries.push({
|
||||
url: `${baseUrl}/ag-ui/${slug}`,
|
||||
lastModified: resolveLastModified(filePath),
|
||||
});
|
||||
}
|
||||
// AG-UI overview landing.
|
||||
entries.push({ url: `${baseUrl}/ag-ui`, lastModified: now });
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const brandNavSource = readFileSync(
|
||||
new URL("../brand-nav.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const globalsCss = readFileSync(
|
||||
new URL("../../app/globals.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("BrandNav layout", () => {
|
||||
it("uses a CSS class for the same desktop layout cap as the docs grid", () => {
|
||||
expect(brandNavSource).toContain("shell-docs-brand-nav-inner");
|
||||
expect(globalsCss).toContain(".shell-docs-brand-nav-inner");
|
||||
expect(globalsCss).toContain(
|
||||
"--shell-docs-layout-width: calc(97rem + 11px);",
|
||||
);
|
||||
expect(brandNavSource).not.toContain("max-w-[calc(");
|
||||
expect(brandNavSource).not.toContain("max-w-[1534px]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DocsLandingNext } from "../docs-landing-next";
|
||||
|
||||
vi.mock("../stored-framework-highlight", () => ({
|
||||
StoredFrameworkHighlight: () => null,
|
||||
}));
|
||||
|
||||
describe("DocsLandingNext", () => {
|
||||
it("uses container-sized backend cards instead of viewport-only columns", () => {
|
||||
const markup = renderToStaticMarkup(<DocsLandingNext />);
|
||||
|
||||
expect(markup).toContain("grid-cols-1");
|
||||
expect(markup).toContain(
|
||||
"sm:grid-cols-[repeat(auto-fit,minmax(min(100%,16rem),1fr))]",
|
||||
);
|
||||
expect(markup).not.toContain("lg:grid-cols-3");
|
||||
expect(markup).not.toContain("pr-20");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { EarlyAccessGate } from "../early-access-gate";
|
||||
import { EARLY_ACCESS_GATES, getEarlyAccessGate } from "@/lib/early-access";
|
||||
|
||||
describe("early-access config", () => {
|
||||
it("registers early-access gates with the shared password", () => {
|
||||
expect(EARLY_ACCESS_GATES.whatsapp.password).toBe("earlyaccess");
|
||||
expect(EARLY_ACCESS_GATES.whatsapp.storageKey).toBe(
|
||||
"shell-docs-early-access:whatsapp",
|
||||
);
|
||||
});
|
||||
|
||||
it("points the request-access CTA at the beyond-the-web form", () => {
|
||||
expect(EARLY_ACCESS_GATES.whatsapp.requestUrl).toBe(
|
||||
"https://go.copilotkit.ai/beyond-the-web-form",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves known ids and rejects unknown ones", () => {
|
||||
expect(getEarlyAccessGate("whatsapp")).toBe(EARLY_ACCESS_GATES.whatsapp);
|
||||
expect(getEarlyAccessGate("slack")).toBeNull();
|
||||
expect(getEarlyAccessGate("teams")).toBeNull();
|
||||
expect(getEarlyAccessGate("nope")).toBeNull();
|
||||
expect(getEarlyAccessGate(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EarlyAccessGate", () => {
|
||||
it("server-renders gated content blurred, inert, and hidden from AT", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<EarlyAccessGate gate="whatsapp">
|
||||
<p>secret whatsapp guide</p>
|
||||
</EarlyAccessGate>,
|
||||
);
|
||||
|
||||
// Content stays in the DOM (it's what gets blurred)…
|
||||
expect(markup).toContain("secret whatsapp guide");
|
||||
// …but is visually blurred and unreachable.
|
||||
expect(markup).toContain("blur-");
|
||||
expect(markup).toContain("inert=");
|
||||
expect(markup).toContain('aria-hidden="true"');
|
||||
expect(markup).toContain("pointer-events-none");
|
||||
});
|
||||
|
||||
it("does not server-render the unlock card (it mounts client-side)", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<EarlyAccessGate gate="whatsapp">
|
||||
<p>secret whatsapp guide</p>
|
||||
</EarlyAccessGate>,
|
||||
);
|
||||
|
||||
expect(markup).not.toContain("Enter password");
|
||||
expect(markup).not.toContain("Unlock");
|
||||
});
|
||||
|
||||
it("passes children through untouched for unknown gate ids", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<EarlyAccessGate gate="not-a-gate">
|
||||
<p>plain content</p>
|
||||
</EarlyAccessGate>,
|
||||
);
|
||||
|
||||
expect(markup).toBe("<p>plain content</p>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const navigation = vi.hoisted(() => ({
|
||||
pathname: "/",
|
||||
replace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => navigation.pathname,
|
||||
useRouter: () => ({ replace: navigation.replace }),
|
||||
}));
|
||||
|
||||
vi.mock("posthog-js/react", () => ({
|
||||
usePostHog: () => ({ capture: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../framework-provider", () => ({
|
||||
useFramework: () => ({
|
||||
framework: null,
|
||||
effectiveFramework: "built-in-agent",
|
||||
setStoredFramework: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { FrameworkSelector } from "../framework-selector";
|
||||
|
||||
const options = [
|
||||
{
|
||||
slug: "built-in-agent",
|
||||
name: "CopilotKit's Built-in Agent",
|
||||
category: "core",
|
||||
logo: "/logos/built-in-agent.svg",
|
||||
deployed: true,
|
||||
},
|
||||
{
|
||||
slug: "langgraph-python",
|
||||
name: "LangChain",
|
||||
category: "agent-frameworks",
|
||||
logo: "/logos/langgraph.svg",
|
||||
deployed: true,
|
||||
},
|
||||
{
|
||||
slug: "crewai-crews",
|
||||
name: "CrewAI",
|
||||
category: "agent-frameworks",
|
||||
logo: "/logos/crewai.svg",
|
||||
deployed: true,
|
||||
},
|
||||
{
|
||||
slug: "mastra",
|
||||
name: "Mastra",
|
||||
category: "agent-frameworks",
|
||||
logo: "/logos/mastra.svg",
|
||||
deployed: true,
|
||||
},
|
||||
{
|
||||
slug: "pydantic-ai",
|
||||
name: "PydanticAI",
|
||||
category: "agent-frameworks",
|
||||
logo: "/logos/pydantic-ai.svg",
|
||||
deployed: true,
|
||||
},
|
||||
];
|
||||
|
||||
describe("FrameworkSelector", () => {
|
||||
it("renders separate sidebar selectors for frontend and backend", () => {
|
||||
navigation.pathname = "/";
|
||||
const markup = renderToStaticMarkup(
|
||||
<FrameworkSelector
|
||||
options={options}
|
||||
categoryOrder={[]}
|
||||
variant="sidebar"
|
||||
/>,
|
||||
);
|
||||
const frontendIndex = markup.indexOf("Frontend");
|
||||
const backendIndex = markup.indexOf("Agent backend");
|
||||
|
||||
expect(frontendIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(backendIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(frontendIndex).toBeLessThan(backendIndex);
|
||||
expect(markup).toContain("React");
|
||||
expect(markup).not.toContain("Early access");
|
||||
expect(markup).toContain("CopilotKit");
|
||||
expect(
|
||||
markup.match(
|
||||
/shell-docs-picker-group shell-docs-picker-group-selected shell-docs-picker-group-bordered/g,
|
||||
)?.length,
|
||||
).toBe(1);
|
||||
expect(markup).not.toContain("shell-docs-picker-row-selected");
|
||||
expect(markup).not.toContain("shell-docs-nav-link-active");
|
||||
expect(markup.match(/shell-docs-picker-row-divided/g)?.length).toBe(1);
|
||||
expect(markup.match(/<button/g)?.length).toBe(2);
|
||||
expect(markup).not.toContain("Choose your stack");
|
||||
expect(markup).not.toContain("Current stack");
|
||||
expect(markup).not.toContain("Current backend");
|
||||
expect(markup).not.toContain("Current");
|
||||
expect(markup).not.toContain("Choose any agent backend");
|
||||
expect(markup).not.toContain("Change");
|
||||
expect(markup).not.toContain("Other available agent backends");
|
||||
expect(markup).not.toContain("+1");
|
||||
expect(markup).not.toContain("Agent backends");
|
||||
expect(markup).not.toContain("Choose your agent backend");
|
||||
expect(markup).not.toContain("Any agent backend");
|
||||
expect(markup).not.toContain("Agentic backend");
|
||||
});
|
||||
|
||||
it("reflects the frontend selected by the URL", () => {
|
||||
navigation.pathname = "/vue";
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<FrameworkSelector
|
||||
options={options}
|
||||
categoryOrder={[]}
|
||||
variant="sidebar"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Frontend");
|
||||
expect(markup).toContain("Vue");
|
||||
expect(markup).not.toContain("Early access");
|
||||
expect(markup).not.toContain("px-1 py-0 text-[8px]");
|
||||
expect(markup).not.toContain("leading-[10px]");
|
||||
expect(markup).toContain("mt-0.5 flex min-w-0 items-center gap-2");
|
||||
expect(markup).not.toContain("React Native");
|
||||
});
|
||||
|
||||
it("keeps the early access badge for Slack and Teams only", () => {
|
||||
navigation.pathname = "/slack";
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<FrameworkSelector
|
||||
options={options}
|
||||
categoryOrder={[]}
|
||||
variant="sidebar"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Slack");
|
||||
expect(markup).toContain("Early access");
|
||||
expect(markup).toContain("px-1 py-0 text-[8px]");
|
||||
expect(markup).toContain("leading-[10px]");
|
||||
expect(markup).toContain("self-center");
|
||||
});
|
||||
|
||||
it("uses a picker menu shadow that does not bleed upward", () => {
|
||||
const componentSource = readFileSync(
|
||||
new URL("../framework-selector.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const cssSource = readFileSync(
|
||||
new URL("../../app/globals.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(componentSource).toContain("shell-docs-picker-menu");
|
||||
expect(componentSource).not.toContain("shadow-[var(--shadow-panel)]");
|
||||
expect(cssSource).toContain(".shell-docs-picker-menu");
|
||||
expect(cssSource).toContain("clip-path: inset(0 -48px -48px -48px)");
|
||||
});
|
||||
|
||||
it("keeps the picker fill subtler than active navigation", () => {
|
||||
const cssSource = readFileSync(
|
||||
new URL("../../app/globals.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(cssSource).toContain(`.shell-docs-picker-group-selected {
|
||||
background: color-mix(in oklch, var(--accent-dim) 34%, transparent);
|
||||
}`);
|
||||
expect(cssSource).not.toContain(`.shell-docs-picker-group-selected {
|
||||
background: color-mix(in oklch, var(--accent) 13%, transparent);
|
||||
}`);
|
||||
});
|
||||
|
||||
it("routes backend selections even from frontend docs routes", () => {
|
||||
const componentSource = readFileSync(
|
||||
new URL("../framework-selector.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(componentSource).not.toContain("if (!urlFrontend)");
|
||||
expect(componentSource).toContain(
|
||||
"router.replace(\n backendPathForCurrentPath(",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { FrontendLogo } from "../frontend-logo";
|
||||
|
||||
describe("FrontendLogo", () => {
|
||||
it("renders brand logo paths from icon libraries", () => {
|
||||
expect(renderToStaticMarkup(<FrontendLogo icon="react" />)).toContain(
|
||||
"M14.23 12.004",
|
||||
);
|
||||
expect(renderToStaticMarkup(<FrontendLogo icon="vue" />)).toContain(
|
||||
"M24,1.61H14.06L12,5.16",
|
||||
);
|
||||
const slackMarkup = renderToStaticMarkup(<FrontendLogo icon="slack" />);
|
||||
expect(slackMarkup).toContain("M5.042 15.165a2.528");
|
||||
expect(slackMarkup).toContain('fill="#36C5F0"');
|
||||
expect(slackMarkup).toContain('fill="#2EB67D"');
|
||||
expect(slackMarkup).toContain('fill="#ECB22E"');
|
||||
expect(slackMarkup).toContain('fill="#E01E5A"');
|
||||
expect(renderToStaticMarkup(<FrontendLogo icon="teams" />)).toContain(
|
||||
"M18.581 11.513h3.413",
|
||||
);
|
||||
expect(
|
||||
renderToStaticMarkup(<FrontendLogo icon="react-native" />),
|
||||
).toContain("M6.357 9c-2.637");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const heroStartCommandsSource = readFileSync(
|
||||
new URL("../hero-start-commands.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const globalsCss = readFileSync(
|
||||
new URL("../../app/globals.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// `hero_command_copied` fires alongside the global <CopyTracker>'s
|
||||
// `cli_command_copied` for the same copy. The sibling records
|
||||
// `location: window.location.pathname`; this event must carry the same
|
||||
// dimension so the two are joinable — and because `location` is the only
|
||||
// surface signal that distinguishes the `onboard` card across the home hero
|
||||
// and the framework landing heroes (its command is identical everywhere).
|
||||
// shell-docs vitest runs in the `node` environment with no jsdom/RTL, so this
|
||||
// asserts the capture shape at the source level, matching the suite's
|
||||
// convention (see brand-nav.test.tsx).
|
||||
describe("hero_command_copied analytics", () => {
|
||||
it("captures the hero_command_copied event", () => {
|
||||
expect(heroStartCommandsSource).toContain(
|
||||
'posthog?.capture("hero_command_copied"',
|
||||
);
|
||||
});
|
||||
|
||||
it("records a location dimension joinable with cli_command_copied", () => {
|
||||
expect(heroStartCommandsSource).toContain("location:");
|
||||
expect(heroStartCommandsSource).toContain("window.location.pathname");
|
||||
});
|
||||
|
||||
it("guards the location read for SSR, mirroring the CopyTracker sibling", () => {
|
||||
expect(heroStartCommandsSource).toContain('typeof window !== "undefined"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("hero quickstart CTA styling", () => {
|
||||
it("opts the primary quickstart link out of prose link colors", () => {
|
||||
expect(heroStartCommandsSource).toContain("shell-docs-primary-cta");
|
||||
expect(globalsCss).toContain(".reference-content a.shell-docs-primary-cta");
|
||||
expect(globalsCss).toContain("color: var(--primary-foreground);");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LandingSampleTabs } from "../landing-sample-tabs";
|
||||
|
||||
vi.mock("fumadocs-ui/components/dynamic-codeblock", () => ({
|
||||
DynamicCodeBlock: ({ code }: { code: string }) => <pre>{code}</pre>,
|
||||
}));
|
||||
|
||||
describe("LandingSampleTabs", () => {
|
||||
it("replaces the mobile preview with four simple cards", () => {
|
||||
const markup = renderToStaticMarkup(<LandingSampleTabs />);
|
||||
|
||||
expect(markup).toContain('aria-label="CopilotKit mobile samples"');
|
||||
expect(markup).toContain("data-mobile-sample-card");
|
||||
expect(markup).toContain("sm:hidden");
|
||||
expect(markup).toMatch(/hidden[^"]*sm:block/);
|
||||
expect(markup).toContain("min-w-0 overflow-hidden");
|
||||
expect(markup).toContain(
|
||||
"Drop in a chat surface where your users already work.",
|
||||
);
|
||||
expect(markup).toContain(
|
||||
"Own every pixel and still use the agent runtime.",
|
||||
);
|
||||
expect(markup).toContain("Let agents render real React components.");
|
||||
expect(markup).toContain("Connect any backend that speaks AG-UI.");
|
||||
expect(markup.indexOf("Generative UI")).toBeLessThan(
|
||||
markup.indexOf("Any agent"),
|
||||
);
|
||||
expect(markup).not.toContain("overflow-x-auto");
|
||||
expect(markup).not.toContain("Open docs");
|
||||
expect(markup).not.toContain("min-h-[540px]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../framework-provider", () => ({
|
||||
useFramework: () => ({ storedFramework: "built-in-agent" }),
|
||||
}));
|
||||
|
||||
import { StoredFrameworkHighlight } from "../stored-framework-highlight";
|
||||
|
||||
describe("StoredFrameworkHighlight", () => {
|
||||
it("keeps the selected label out of cramped mobile backend cards", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<StoredFrameworkHighlight slug="built-in-agent" />,
|
||||
);
|
||||
|
||||
expect(markup).toContain("sr-only");
|
||||
expect(markup).toContain("Current backend selection");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
// <AgentCoreCommandTabs> — framework-aware code-block tabs for AgentCore
|
||||
// quickstart commands. Built on top of Fumadocs's <Tabs> (via the
|
||||
// shell-docs <Tabs>/<Tab> wrapper) and <DynamicCodeBlock>, so AgentCore
|
||||
// commands share the same Shiki-highlighted chrome and copy button as
|
||||
// every other code block in the docs.
|
||||
//
|
||||
// Usage in MDX:
|
||||
// <AgentCoreCommandTabs
|
||||
// lgCommand="npx copilotkit@latest create -f agentcore-langgraph"
|
||||
// stCommand="npx copilotkit@latest create -f agentcore-strands"
|
||||
// />
|
||||
//
|
||||
// With no `framework` prop the component shows both LangGraph and Strands
|
||||
// tabs (the canonical shell-docs page lets the user pick). Passing
|
||||
// `framework="langgraph"` or `framework="strands"` collapses to a single
|
||||
// tab; we keep that prop for parity with the upstream API even though
|
||||
// the canonical shell-docs page doesn't use it.
|
||||
|
||||
import React from "react";
|
||||
import { Tabs, Tab } from "@/components/docs-tabs";
|
||||
import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock";
|
||||
|
||||
interface AgentCoreCommandTabsProps {
|
||||
framework?: "langgraph" | "strands";
|
||||
lgCommand: string;
|
||||
stCommand: string;
|
||||
}
|
||||
|
||||
export function AgentCoreCommandTabs({
|
||||
framework,
|
||||
lgCommand,
|
||||
stCommand,
|
||||
}: AgentCoreCommandTabsProps) {
|
||||
const items =
|
||||
framework === "langgraph"
|
||||
? ["LangGraph"]
|
||||
: framework === "strands"
|
||||
? ["Strands"]
|
||||
: ["LangGraph", "Strands"];
|
||||
|
||||
return (
|
||||
<Tabs groupId="agentcore-framework" items={items}>
|
||||
{(framework === "langgraph" || !framework) && (
|
||||
<Tab value="LangGraph">
|
||||
<DynamicCodeBlock lang="bash" code={lgCommand} />
|
||||
</Tab>
|
||||
)}
|
||||
{(framework === "strands" || !framework) && (
|
||||
<Tab value="Strands">
|
||||
<DynamicCodeBlock lang="bash" code={stCommand} />
|
||||
</Tab>
|
||||
)}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
"use client";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
ExternalLinkIcon,
|
||||
TextIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useCopyButton } from "fumadocs-ui/utils/use-copy-button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { usePathname } from "fumadocs-core/framework";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import ClaudeIcon from "@/components/icons/claude";
|
||||
import ClaudeCodeIcon from "@/components/icons/claude-code";
|
||||
import CodexIcon from "@/components/icons/codex";
|
||||
import WindsurfIcon from "@/components/icons/windsurf";
|
||||
import { getRuntimeConfig } from "@/lib/runtime-config.client";
|
||||
|
||||
/**
|
||||
* Resolve the canonical base URL on the client. Reads from
|
||||
* window.__SHOWCASE_CONFIG__ (populated by the root layout's inline
|
||||
* <script>) so the rendered absolute URL reflects the current deploy's
|
||||
* NEXT_PUBLIC_BASE_URL without rebuilding the artifact. The runtime
|
||||
* reader already strips trailing slashes so callers can concatenate
|
||||
* `${BASE}${path}` safely.
|
||||
*
|
||||
* Still inlined here (rather than reaching into `@/lib/sitemap-helpers`)
|
||||
* because that module also pulls in `fs` / `path` / `gray-matter` for
|
||||
* sitemap generation — Node-only deps that fail the client bundle when
|
||||
* a `"use client"` component reaches for them.
|
||||
*/
|
||||
function getClientBaseUrl(): string {
|
||||
return getRuntimeConfig().baseUrl;
|
||||
}
|
||||
|
||||
// Module-scoped cache of resolved markdown bodies. Survives navigations
|
||||
// so repeated clicks on the same page don't re-fetch. Stored as the
|
||||
// awaited STRING (not a Promise) to avoid the failed-fetch poisoning
|
||||
// pattern where a rejected promise gets cached and replayed on every
|
||||
// subsequent click — see `fetchMarkdown` below.
|
||||
const cache = new Map<string, string>();
|
||||
|
||||
/** Fetch the markdown body for a docs URL, caching successful responses
|
||||
* only. Throws on network failure or non-2xx response so the click
|
||||
* handler can surface the error instead of silently copying a 404 page
|
||||
* body or replaying a permanently-broken cache entry. */
|
||||
async function fetchMarkdown(url: string): Promise<string> {
|
||||
const cached = cache.get(url);
|
||||
if (cached !== undefined) return cached;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`fetchMarkdown: ${url} responded ${res.status} ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
const body = await res.text();
|
||||
cache.set(url, body);
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* see https://fumadocs.dev/docs/integrations/llms#page-actions to customize.
|
||||
*/
|
||||
export function MarkdownCopyButton({
|
||||
markdownUrl,
|
||||
...props
|
||||
}: ComponentProps<"button"> & {
|
||||
/**
|
||||
* A URL to fetch the raw Markdown/MDX content of page
|
||||
*/
|
||||
markdownUrl: string;
|
||||
}) {
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const posthog = usePostHog();
|
||||
const [checked, onClick] = useCopyButton(async () => {
|
||||
// Single code path for both cache-hit and cache-miss so the loader
|
||||
// state, error handling, and clipboard API stay consistent. The
|
||||
// upstream Fumadocs example uses two branches (writeText for hits,
|
||||
// ClipboardItem(promise) for misses), but the ClipboardItem promise
|
||||
// flow has spotty browser support (Safari, non-secure contexts) and
|
||||
// diverges from the simpler hit path for no benefit.
|
||||
setLoading(true);
|
||||
try {
|
||||
const body = await fetchMarkdown(markdownUrl);
|
||||
await navigator.clipboard.writeText(body);
|
||||
// Fire a dedicated event for the "Copy Markdown" affordance so
|
||||
// analytics can distinguish page-content copies from the global
|
||||
// CLI-command tracker (`cli_command_copied` in
|
||||
// `lib/track-command-copy.ts`), which intercepts every clipboard
|
||||
// write at the navigator level and classifies anything that
|
||||
// doesn't match an install command as `code` — not meaningful
|
||||
// for the new docs-as-context surface.
|
||||
posthog?.capture("markdown_copied", {
|
||||
path: pathname,
|
||||
markdown_url: markdownUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
// Log AND re-throw. The throw is load-bearing: Fumadocs's
|
||||
// `useCopyButton` runs `Promise.resolve(callback()).then(setChecked(true))`
|
||||
// with NO `.catch()`. If we swallow here (return normally), the
|
||||
// outer `.then()` still fires and the button flips to its
|
||||
// checkmark state — the user sees a "copied!" indicator on a
|
||||
// failed copy and may paste stale or wrong content into the LLM
|
||||
// they're prompting. Re-throwing causes a single unhandled
|
||||
// promise rejection (browser console noise / Sentry entry) but
|
||||
// critically keeps the button in its idle state, which is the
|
||||
// correct visual feedback. A follow-up PR can introduce an
|
||||
// explicit error UI state to surface "Copy failed" to the user.
|
||||
console.error("[page-actions] Copy Markdown failed", markdownUrl, err);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
// Spread caller props FIRST so the component-owned `disabled` and
|
||||
// `onClick` below take precedence over anything the caller passes
|
||||
// — those are load-bearing for the component's core behavior, and
|
||||
// a caller overriding them could silently break the loading guard
|
||||
// or the copy handler. `className` is MERGED (not overridden):
|
||||
// caller-supplied tokens are passed through `cn(..., props.className)`
|
||||
// so authors can add layout/styling tweaks alongside the variant.
|
||||
{...props}
|
||||
disabled={isLoading}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: "secondary",
|
||||
size: "sm",
|
||||
className: "gap-2 [&_svg]:size-3.5 [&_svg]:text-[var(--text-muted)]",
|
||||
}),
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{checked ? <Check /> : <Copy />}
|
||||
{props.children ?? "Copy Markdown"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* see https://fumadocs.dev/docs/integrations/llms#page-actions to customize.
|
||||
*/
|
||||
export function ViewOptionsPopover({
|
||||
markdownUrl,
|
||||
githubUrl,
|
||||
...props
|
||||
}: ComponentProps<typeof PopoverTrigger> & {
|
||||
/**
|
||||
* A URL to the raw Markdown/MDX content of page
|
||||
*/
|
||||
markdownUrl?: string;
|
||||
|
||||
/**
|
||||
* Source file URL on GitHub
|
||||
*/
|
||||
githubUrl?: string;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const posthog = usePostHog();
|
||||
const items = useMemo(() => {
|
||||
// Build the absolute URL deterministically from `getClientBaseUrl()`
|
||||
// so SSR and the first client render agree. The previous
|
||||
// `typeof window === "undefined" ? pathname : new URL(pathname, ...)`
|
||||
// branch produced a relative path on the server and an absolute URL
|
||||
// on the client, causing a React hydration mismatch on every
|
||||
// popover anchor AND embedding a path-only URL ("Read /quickstart,
|
||||
// I want to ask...") into the LLM-app deep-link prompt — which the
|
||||
// target LLM can't resolve.
|
||||
const pageUrl = `${getClientBaseUrl()}${pathname}`;
|
||||
const q = `Read ${pageUrl}, I want to ask questions about it.`;
|
||||
|
||||
return [
|
||||
githubUrl && {
|
||||
title: "Open in GitHub",
|
||||
target: "github",
|
||||
href: githubUrl,
|
||||
icon: (
|
||||
<svg fill="currentColor" role="img" viewBox="0 0 24 24">
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
markdownUrl && {
|
||||
title: "View as Markdown",
|
||||
target: "view-as-markdown",
|
||||
href: markdownUrl,
|
||||
icon: <TextIcon />,
|
||||
},
|
||||
{
|
||||
title: "Open in Windsurf",
|
||||
target: "windsurf",
|
||||
href: `windsurf://cascade/newChat?${new URLSearchParams({
|
||||
prompt: q,
|
||||
})}`,
|
||||
icon: <WindsurfIcon />,
|
||||
},
|
||||
{
|
||||
title: "Open in Claude Code",
|
||||
target: "claude-code",
|
||||
href: `claude-cli://open?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: <ClaudeCodeIcon />,
|
||||
},
|
||||
{
|
||||
title: "Open in Codex",
|
||||
target: "codex",
|
||||
href: `https://chatgpt.com/codex?${new URLSearchParams({
|
||||
prompt: q,
|
||||
})}`,
|
||||
icon: <CodexIcon />,
|
||||
},
|
||||
{
|
||||
title: "Open in ChatGPT",
|
||||
target: "chatgpt",
|
||||
href: `https://chatgpt.com/?${new URLSearchParams({
|
||||
hints: "search",
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>OpenAI</title>
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Open in Claude",
|
||||
target: "claude",
|
||||
href: `https://claude.ai/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: <ClaudeIcon />,
|
||||
},
|
||||
{
|
||||
title: "Open in Cursor",
|
||||
target: "cursor",
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Cursor</title>
|
||||
<path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23" />
|
||||
</svg>
|
||||
),
|
||||
href: `https://cursor.com/link/prompt?${new URLSearchParams({
|
||||
text: q,
|
||||
})}`,
|
||||
},
|
||||
].filter((v) => !!v);
|
||||
}, [githubUrl, markdownUrl, pathname]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
{...props}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: "secondary",
|
||||
size: "sm",
|
||||
}),
|
||||
"gap-2 data-[state=open]:border-[var(--accent)] data-[state=open]:bg-[var(--accent-dim)] data-[state=open]:text-[var(--accent)]",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children ?? "Open"}
|
||||
<ChevronDown className="size-3.5 text-[var(--text-muted)]" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col">
|
||||
{items.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
// item.href embeds `getClientBaseUrl()` (the SSR placeholder
|
||||
// during server-render, real value post-hydration), so React
|
||||
// would log a hydration mismatch on every popover anchor.
|
||||
// Suppression scopes to this attribute mismatch only.
|
||||
suppressHydrationWarning
|
||||
// Fire a PostHog event keyed by `target` (github, windsurf,
|
||||
// claude, chatgpt, codex, view-as-markdown, etc.) so the
|
||||
// analytics dashboard can attribute LLM-routing intent to
|
||||
// specific docs pages. Capture runs synchronously before
|
||||
// navigation; PostHog buffers and flushes async, so the new
|
||||
// tab opens without waiting on the network.
|
||||
onClick={() =>
|
||||
posthog?.capture("open_in_llm_clicked", {
|
||||
target: item.target,
|
||||
path: pathname,
|
||||
})
|
||||
}
|
||||
className="shell-docs-radius-control inline-flex items-center gap-2 p-2 text-sm text-[var(--text-secondary)] transition-colors hover:bg-[var(--bg-elevated)] hover:text-[var(--text)] [&_svg]:size-4"
|
||||
>
|
||||
{item.icon}
|
||||
{item.title}
|
||||
<ExternalLinkIcon className="ms-auto size-3.5 text-[var(--text-muted)]" />
|
||||
</a>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useGoogleAnalytics } from "@/lib/hooks/use-google-analytics";
|
||||
import { CopyTracker } from "@/lib/providers/copy-tracker";
|
||||
|
||||
/**
|
||||
* Client-side wrapper that mounts analytics/visitor-id hooks.
|
||||
*
|
||||
* layout.tsx is a server component, so any hook-based telemetry must run
|
||||
* inside a "use client" boundary. Mirrors upstream's `ProvidersWrapper`
|
||||
* pattern: one client boundary, all hooks together.
|
||||
*/
|
||||
export function AnalyticsClient() {
|
||||
useGoogleAnalytics();
|
||||
return <CopyTracker />;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, BookOpenCheck, X } from "lucide-react";
|
||||
|
||||
type BannerEntry = {
|
||||
mobileText: string;
|
||||
title: string;
|
||||
source: string;
|
||||
buttonText: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
const bannerContent: BannerEntry[] = [
|
||||
{
|
||||
mobileText: "Free Generative UI course",
|
||||
title: "Build Interactive Agents with Generative UI",
|
||||
source: "DeepLearning.AI",
|
||||
buttonText: "Start free course",
|
||||
href: "https://www.deeplearning.ai/short-courses/build-interactive-agents-with-generative-ui/",
|
||||
},
|
||||
];
|
||||
|
||||
const BANNER_REAPPEAR_DELAY = 3 * 24 * 60 * 60 * 1000;
|
||||
const BANNER_DISMISSED_KEY = "nd-banner-rotating-banner";
|
||||
const BANNER_DISMISSED_TIME_KEY = "nd-banner-rotating-banner-dismissed-at";
|
||||
const LEGACY_DISMISSED_STORAGE_KEY = "shell-docs-course-banner-dismissed";
|
||||
|
||||
function setBannerHeight(height: "0px" | "40px") {
|
||||
document.documentElement.style.setProperty("--fd-banner-height", height);
|
||||
}
|
||||
|
||||
export function Banners() {
|
||||
const content = bannerContent[0];
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHydrated(true);
|
||||
|
||||
const checkBannerExpiry = () => {
|
||||
const isDismissed =
|
||||
localStorage.getItem(BANNER_DISMISSED_KEY) === "true" ||
|
||||
localStorage.getItem(LEGACY_DISMISSED_STORAGE_KEY) === "true";
|
||||
|
||||
if (!isDismissed) {
|
||||
setDismissed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const dismissedAt = localStorage.getItem(BANNER_DISMISSED_TIME_KEY);
|
||||
if (!dismissedAt) {
|
||||
localStorage.setItem(BANNER_DISMISSED_TIME_KEY, Date.now().toString());
|
||||
setDismissed(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - parseInt(dismissedAt, 10);
|
||||
if (elapsed >= BANNER_REAPPEAR_DELAY) {
|
||||
localStorage.removeItem(BANNER_DISMISSED_KEY);
|
||||
localStorage.removeItem(BANNER_DISMISSED_TIME_KEY);
|
||||
localStorage.removeItem(LEGACY_DISMISSED_STORAGE_KEY);
|
||||
setDismissed(false);
|
||||
} else {
|
||||
setDismissed(true);
|
||||
}
|
||||
};
|
||||
|
||||
checkBannerExpiry();
|
||||
|
||||
const handleStorage = () => checkBannerExpiry();
|
||||
window.addEventListener("storage", handleStorage);
|
||||
const interval = setInterval(checkBannerExpiry, 1000);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setBannerHeight(!hydrated || dismissed ? "0px" : "40px");
|
||||
}, [hydrated, dismissed]);
|
||||
|
||||
const dismissBanner = useCallback(() => {
|
||||
localStorage.setItem(BANNER_DISMISSED_KEY, "true");
|
||||
localStorage.setItem(BANNER_DISMISSED_TIME_KEY, Date.now().toString());
|
||||
localStorage.setItem(LEGACY_DISMISSED_STORAGE_KEY, "true");
|
||||
setDismissed(true);
|
||||
}, []);
|
||||
|
||||
if (!hydrated || dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id="shell-docs-course-banner"
|
||||
className="sticky top-0 z-40 flex h-10 flex-row items-center justify-center overflow-hidden border-b px-4 text-center text-sm font-medium text-white shadow-none shell-docs-course-banner"
|
||||
>
|
||||
<div className="relative z-1 flex w-full min-w-0 items-center justify-center gap-2 pr-8 md:gap-2.5">
|
||||
<span
|
||||
className="shell-docs-radius-icon hidden h-5 w-5 shrink-0 items-center justify-center border border-white/30 bg-white/16 text-white lg:inline-flex"
|
||||
aria-label="Free course"
|
||||
>
|
||||
<BookOpenCheck className="h-3 w-3" aria-hidden="true" />
|
||||
</span>
|
||||
<p className="min-w-0 truncate text-xs font-medium text-white md:text-[13px]">
|
||||
<span className="md:hidden">{content.mobileText}</span>
|
||||
<span className="hidden md:inline">{content.title}</span>
|
||||
<span className="hidden text-white/72 md:inline">
|
||||
{" "}
|
||||
with {content.source}
|
||||
</span>
|
||||
</p>
|
||||
<Link
|
||||
href={content.href}
|
||||
target={content.href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={
|
||||
content.href.startsWith("http") ? "noopener noreferrer" : undefined
|
||||
}
|
||||
className="shell-docs-radius-control inline-flex h-6 shrink-0 items-center gap-1 bg-white px-2.5 text-[11px] font-semibold text-[var(--accent)] no-underline shadow-[var(--shadow-control)] transition-colors duration-150 hover:bg-white/90 md:px-3"
|
||||
>
|
||||
{content.buttonText}
|
||||
<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close Banner"
|
||||
onClick={dismissBanner}
|
||||
className="absolute inset-e-2 top-1/2 z-10 inline-flex h-6 w-6 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border border-white/25 bg-white/10 text-white/85 transition-colors duration-100 hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/55"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { CalendarDays, ChefHat } from "lucide-react";
|
||||
import { SearchTrigger } from "./search-trigger";
|
||||
import { CopilotKitMark } from "./copilotkit-mark";
|
||||
import { ThemeSwitch } from "./theme-switch";
|
||||
import BookIcon from "./icons/book";
|
||||
import ConsoleIcon from "./icons/console";
|
||||
import ExternalLinkIcon from "./icons/external-link";
|
||||
|
||||
// Enterprise Intelligence sign-up CTA. UTM params let marketing
|
||||
// attribute navbar-driven sign-ups distinctly from in-content SignupLink
|
||||
// and OpsPlatformCTA clicks. Exported so MobileTopNav reuses the same URL.
|
||||
export const INTELLIGENCE_CTA_HREF =
|
||||
"https://dashboard.operations.copilotkit.ai/?utm_source=docs&utm_medium=cta&utm_campaign=intelligence&utm_content=navbar";
|
||||
|
||||
export const TALK_TO_ENGINEER_HREF =
|
||||
"https://copilotkit.ai/talk-to-an-engineer";
|
||||
|
||||
// Center cluster — primary docs destinations only. Conversion CTAs live in
|
||||
// the right utility cluster so the center nav stays balanced.
|
||||
type LeftLink = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
|
||||
const LEFT_LINKS: LeftLink[] = [
|
||||
{
|
||||
icon: <BookIcon className="text-current" />,
|
||||
label: "Docs",
|
||||
href: "/",
|
||||
},
|
||||
{
|
||||
icon: <ConsoleIcon className="text-current" />,
|
||||
label: "Reference",
|
||||
href: "/reference",
|
||||
},
|
||||
{
|
||||
icon: <ChefHat className="w-5 h-5 text-current" />,
|
||||
label: "Cookbook",
|
||||
href: "/cookbook",
|
||||
},
|
||||
];
|
||||
|
||||
export interface BrandNavProps {
|
||||
// Preserved for backwards compat with the original call site signature.
|
||||
// The framework selector lives in the docs sidebar now, not the navbar.
|
||||
frameworkOptions?: unknown;
|
||||
frameworkCategoryOrder?: unknown;
|
||||
}
|
||||
|
||||
export function BrandNav(_props: BrandNavProps = {}) {
|
||||
const pathname = usePathname();
|
||||
const posthog = usePostHog();
|
||||
|
||||
// Active-route detection: anything under /reference highlights Reference,
|
||||
// anything under /cookbook highlights Cookbook, everything else (root,
|
||||
// framework-scoped pages) highlights Docs.
|
||||
const firstSegment = pathname === "/" ? "/" : `/${pathname.split("/")[1]}`;
|
||||
const activeRoute =
|
||||
firstSegment === "/reference"
|
||||
? "/reference"
|
||||
: firstSegment === "/cookbook"
|
||||
? "/cookbook"
|
||||
: "/";
|
||||
|
||||
const handleTalkToEngineersClick = () => {
|
||||
posthog?.capture("talk_to_us_clicked", { location: "docs_nav" });
|
||||
window.location.href = TALK_TO_ENGINEER_HREF;
|
||||
};
|
||||
|
||||
const handleFreeDeveloperAccessClick = () => {
|
||||
posthog?.capture("try_for_free_clicked", { location: "docs_navbar_right" });
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="shell-docs-brand-nav relative hidden h-16 bg-[var(--bg)] xl:mx-[22px] xl:block">
|
||||
<div className="shell-docs-brand-nav-inner relative grid h-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-4 bg-[var(--nav-surface)]">
|
||||
<Link
|
||||
href="/"
|
||||
className="shell-docs-brand-link flex min-w-0 shrink-0 items-center gap-2 justify-self-start"
|
||||
aria-label="CopilotKit Docs"
|
||||
>
|
||||
<CopilotKitMark />
|
||||
<span className="text-base font-bold tracking-tight text-[var(--text)]">
|
||||
CopilotKit
|
||||
</span>
|
||||
<span
|
||||
className="shell-docs-radius-control ml-1 border border-[var(--border)] bg-[var(--accent-dim)] px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--accent)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
Docs
|
||||
</span>
|
||||
</Link>
|
||||
<ul className="hidden min-w-0 items-center gap-2 justify-self-center xl:flex">
|
||||
{LEFT_LINKS.map((link) => {
|
||||
const isActive = activeRoute === link.href;
|
||||
return (
|
||||
<li key={link.href} className="relative h-full group">
|
||||
<Link
|
||||
href={link.href}
|
||||
className={`shell-docs-radius-control flex h-10 items-center px-4 transition-colors duration-200 ${
|
||||
isActive
|
||||
? "shell-docs-nav-link-active"
|
||||
: "shell-docs-nav-link-idle"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="[@media(width<808px)]:hidden">
|
||||
{link.icon}
|
||||
</span>
|
||||
<span className="text-sm font-medium whitespace-nowrap">
|
||||
{link.label}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="flex min-w-0 items-center gap-2 justify-self-end pl-2">
|
||||
<SearchTrigger iconOnly />
|
||||
<Link
|
||||
href={INTELLIGENCE_CTA_HREF}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleFreeDeveloperAccessClick}
|
||||
className="shell-docs-nav-cta shell-docs-radius-control hidden h-10 cursor-pointer items-center gap-2 whitespace-nowrap border px-4 text-sm font-medium no-underline shadow-[var(--shadow-control)] transition-colors duration-200 [@media(width>=1280px)]:flex"
|
||||
aria-label="Get Enterprise Intelligence free"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
Get Enterprise Intelligence free
|
||||
<ExternalLinkIcon className="text-current opacity-70" />
|
||||
</Link>
|
||||
{/* Talk to an engineer. Secondary in the docs nav so search can own
|
||||
* the far-right utility slot. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTalkToEngineersClick}
|
||||
className="shell-docs-nav-cta shell-docs-radius-control hidden h-10 cursor-pointer items-center whitespace-nowrap border px-4 text-sm font-medium shadow-[var(--shadow-control)] transition-colors duration-200 [@media(width>=1500px)]:flex"
|
||||
aria-label="Talk to an engineer"
|
||||
>
|
||||
Talk to an engineer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTalkToEngineersClick}
|
||||
className="shell-docs-nav-cta shell-docs-radius-control hidden h-10 w-10 cursor-pointer items-center justify-center border shadow-[var(--shadow-control)] transition-colors duration-200 xl:flex [@media(width>=1500px)]:hidden"
|
||||
aria-label="Talk to an engineer"
|
||||
title="Talk to an engineer"
|
||||
>
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
</button>
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { Tabs, Tab } from "../docs-tabs";
|
||||
|
||||
interface IframeSwitcherProps {
|
||||
id?: string;
|
||||
exampleUrl: string;
|
||||
codeUrl: string;
|
||||
exampleLabel?: string;
|
||||
codeLabel?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
export function IframeSwitcher({
|
||||
id,
|
||||
exampleUrl,
|
||||
codeUrl,
|
||||
exampleLabel = "Demo",
|
||||
codeLabel = "Code",
|
||||
height = "600px",
|
||||
}: IframeSwitcherProps) {
|
||||
return (
|
||||
<div id={id}>
|
||||
<Tabs items={[exampleLabel, codeLabel]}>
|
||||
<Tab value={exampleLabel}>
|
||||
<iframe
|
||||
src={exampleUrl}
|
||||
className="shell-docs-radius-surface w-full border-0 bg-[var(--bg-surface)]"
|
||||
style={{ height }}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
loading="lazy"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab value={codeLabel}>
|
||||
<iframe
|
||||
src={codeUrl}
|
||||
className="shell-docs-radius-surface w-full border-0 bg-[var(--bg-surface)]"
|
||||
style={{ height }}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
loading="lazy"
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { IframeSwitcher } from "./iframe-switcher";
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { FrameworkOverview } from "../framework-overview";
|
||||
import type { FrameworkOverviewData } from "@/data/frameworks/types";
|
||||
|
||||
const overviewData: FrameworkOverviewData = {
|
||||
slug: "langgraph-python",
|
||||
frameworkName: "LangChain",
|
||||
iconKey: "langgraph",
|
||||
header: "Bring your LangChain agents to your users",
|
||||
subheader: "Build rich, interactive, agent-powered applications.",
|
||||
guideLink: "/langgraph-python/quickstart",
|
||||
initCommand: "npx copilotkit@latest init",
|
||||
featuresLink: "/langgraph-python",
|
||||
supportedFeatures: [],
|
||||
liveDemos: [],
|
||||
};
|
||||
|
||||
describe("FrameworkOverview", () => {
|
||||
it("renders the primary quickstart CTA and optional agent CLI setup action", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<FrameworkOverview
|
||||
data={overviewData}
|
||||
currentFramework="langgraph-python"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Quickstart");
|
||||
expect(markup).toContain("Start using agents");
|
||||
expect(markup).toContain('aria-controls="hero-cli-commands"');
|
||||
expect(markup).toContain("border-[var(--accent)]");
|
||||
expect(markup).toContain("bg-[var(--accent)]");
|
||||
expect(markup).toContain("shell-docs-primary-cta");
|
||||
expect(markup).toContain("text-[var(--primary-foreground)]");
|
||||
});
|
||||
|
||||
it("renders the framework identity icon in accent purple", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<FrameworkOverview
|
||||
data={overviewData}
|
||||
currentFramework="langgraph-python"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain(
|
||||
"shell-docs-radius-icon flex h-10 w-10 items-center justify-center border border-[var(--accent)] bg-[var(--accent-dim)] text-[var(--accent)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not add top padding before the framework hero", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<FrameworkOverview
|
||||
data={overviewData}
|
||||
currentFramework="langgraph-python"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('class="pb-8 sm:pb-12"');
|
||||
expect(markup).not.toContain("pt-2 sm:pt-4");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,463 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight, Copy, Check, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { customIcons } from "@/components/icons";
|
||||
import type { IconKey } from "@/components/icons";
|
||||
import {
|
||||
HeroStartActions,
|
||||
QuickstartLinkButton,
|
||||
} from "@/components/hero-start-commands";
|
||||
import { OpsPlatformCTA } from "@/components/react/ops-platform-cta";
|
||||
import type {
|
||||
FrameworkOverviewData,
|
||||
OpsPlatformCTAData,
|
||||
} from "@/data/frameworks/types";
|
||||
|
||||
export interface FrameworkOverviewProps {
|
||||
data: FrameworkOverviewData;
|
||||
/**
|
||||
* The framework slug from the current URL (e.g. "langgraph-typescript").
|
||||
* Used to rewrite the data record's links so they stay within the user's
|
||||
* selected variant — without this, langgraph-typescript users clicking
|
||||
* "Quickstart" land on langgraph-python's quickstart via SLUG_RENAMES,
|
||||
* because the data record's `guideLink` embeds the primary variant's slug.
|
||||
*/
|
||||
currentFramework: string;
|
||||
/**
|
||||
* Optional slot rendered between the supported-features section and the
|
||||
* architecture section. When supplied, this takes precedence over `data.cta`
|
||||
* (which is the structured fallback). Routes that pre-render
|
||||
* `after-features.mdx` should pass the compiled MDX here.
|
||||
*/
|
||||
afterFeatures?: ReactNode;
|
||||
/**
|
||||
* Optional override for the framework icon. Takes precedence over the
|
||||
* `iconKey` lookup in `data.iconKey`. Used by the MDX adapter
|
||||
* (`MdxFrameworkOverview`) so authored `index.mdx` files can pass a
|
||||
* concrete `<XIcon />` JSX node instead of having to use a registered
|
||||
* iconKey. When supplied, `data.iconKey` is ignored.
|
||||
*/
|
||||
iconOverride?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the framework slug embedded in a URL for the user's currently selected
|
||||
* variant. Applied to both in-app internal paths and feature-viewer external
|
||||
* URLs (which encode the framework slug as a path segment).
|
||||
*/
|
||||
function rewriteHref(href: string, fromSlug: string, toSlug: string): string {
|
||||
if (!fromSlug || fromSlug === toSlug) return href;
|
||||
if (href === `/${fromSlug}`) return `/${toSlug}`;
|
||||
if (href.startsWith(`/${fromSlug}/`)) {
|
||||
return `/${toSlug}${href.slice(fromSlug.length + 1)}`;
|
||||
}
|
||||
const featureViewerNeedle = `feature-viewer.copilotkit.ai/${fromSlug}/`;
|
||||
if (href.includes(featureViewerNeedle)) {
|
||||
return href.replace(
|
||||
featureViewerNeedle,
|
||||
`feature-viewer.copilotkit.ai/${toSlug}/`,
|
||||
);
|
||||
}
|
||||
return href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Track A's `OpsPlatformCTAData.variant` ("card" | "banner") onto the
|
||||
* variants supported by shell-docs's `OpsPlatformCTA` ("tile" | "inline" |
|
||||
* "card" | "info"). "banner" => "inline" preserves the full-width prominent
|
||||
* CTA intent without introducing a new variant.
|
||||
*/
|
||||
function ctaVariantFor(data: OpsPlatformCTAData): "card" | "inline" {
|
||||
return data.variant === "banner" ? "inline" : "card";
|
||||
}
|
||||
|
||||
/**
|
||||
* Section eyebrow — small sans-serif label with a hairline rule. Dropped
|
||||
* the prior monospace + wide-tracking treatment because it read as
|
||||
* editorial pastiche on a developer-docs surface.
|
||||
*/
|
||||
function SectionEyebrow({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)] whitespace-nowrap">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-[var(--border)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Docs framework slug -> the `copilotkit` CLI's own `--framework` value. The
|
||||
// CLI uses different identifiers than our docs slugs, so the create command on
|
||||
// a framework page only pre-selects a framework when there's a verified 1:1
|
||||
// template match (values confirmed against the CLI's AGENT_FRAMEWORKS enum).
|
||||
// Slugs intentionally omitted fall back to a bare `npx copilotkit create`:
|
||||
// - crewai-crews: the CLI ships "CrewAI Flows" (`flows`), not Crews — different
|
||||
// - langgraph-fastapi, claude-sdk-*, langroid, ms-agent-harness-dotnet,
|
||||
// spring-ai, agent-spec, deepagents: no matching CLI template
|
||||
const DOCS_SLUG_TO_CLI_FRAMEWORK: Record<string, string> = {
|
||||
"langgraph-python": "langgraph-py",
|
||||
"langgraph-typescript": "langgraph-js",
|
||||
"google-adk": "adk",
|
||||
strands: "aws-strands-py",
|
||||
"ms-agent-dotnet": "microsoft-agent-framework-dotnet",
|
||||
"ms-agent-python": "microsoft-agent-framework-py",
|
||||
mastra: "mastra",
|
||||
"pydantic-ai": "pydantic-ai",
|
||||
llamaindex: "llamaindex",
|
||||
agno: "agno",
|
||||
ag2: "ag2",
|
||||
};
|
||||
|
||||
export function FrameworkOverview({
|
||||
data,
|
||||
currentFramework,
|
||||
afterFeatures,
|
||||
iconOverride,
|
||||
}: FrameworkOverviewProps) {
|
||||
const {
|
||||
frameworkName,
|
||||
iconKey,
|
||||
header,
|
||||
subheader,
|
||||
guideLink: rawGuideLink,
|
||||
initCommand,
|
||||
supportedFeatures = [],
|
||||
architectureImage,
|
||||
architectureVideo,
|
||||
liveDemos = [],
|
||||
cta,
|
||||
} = data;
|
||||
|
||||
// Derive the primary variant's slug from the data record's own links —
|
||||
// typically the path segment after the leading `/` of `guideLink`
|
||||
// (e.g. "/langgraph/quickstart" → "langgraph"). This is the slug we
|
||||
// rewrite *away from* so that variant users land on their own variant's
|
||||
// sub-pages.
|
||||
const fromSlug = rawGuideLink.split("/")[1] ?? "";
|
||||
const link = (href: string) => rewriteHref(href, fromSlug, currentFramework);
|
||||
|
||||
// Frameworks whose init is the generic top-level command get the unified
|
||||
// two-command recommendation (matching the home hero). Frameworks with
|
||||
// bespoke setup (e.g. a2a's `git clone`, ms-agent-dotnet) keep their own
|
||||
// single command chip — those commands aren't interchangeable with the CLI.
|
||||
const isGenericInit = initCommand.trim() === "npx copilotkit@latest init";
|
||||
const createFramework = DOCS_SLUG_TO_CLI_FRAMEWORK[currentFramework];
|
||||
|
||||
const [activeDemo, setActiveDemo] = useState<string>(
|
||||
liveDemos[0]?.type || "saas",
|
||||
);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Look up the icon by key. If the key isn't registered (forward-compat with
|
||||
// string IconKey from Track A), fall back to rendering nothing rather than
|
||||
// crashing — the framework name still appears next to it.
|
||||
const IconComponent = customIcons[iconKey as IconKey];
|
||||
const hasIcon = Boolean(iconOverride || IconComponent);
|
||||
|
||||
const handleCopyCommand = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(initCommand);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
// clipboard.writeText rejects in non-secure contexts (http://),
|
||||
// when the document isn't focused, or when the user has denied
|
||||
// permission. Don't flip the "copied" indicator on failure — the
|
||||
// user would see a checkmark and paste an empty/stale buffer.
|
||||
console.error(
|
||||
"[framework-overview] clipboard write failed; copy button no-op",
|
||||
err,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// If no explicit afterFeatures slot is supplied, render the structured cta
|
||||
// (if any) so data-driven intros still get a CTA without needing MDX.
|
||||
const resolvedAfterFeatures: ReactNode =
|
||||
afterFeatures ??
|
||||
(cta ? (
|
||||
<OpsPlatformCTA
|
||||
variant={ctaVariantFor(cta)}
|
||||
title={cta.title}
|
||||
body={cta.body}
|
||||
ctaLabel={cta.ctaLabel}
|
||||
surface={cta.surface}
|
||||
/>
|
||||
) : null);
|
||||
|
||||
const activeDemoData = liveDemos.find((demo) => demo.type === activeDemo);
|
||||
|
||||
return (
|
||||
<div className="relative pb-24">
|
||||
<div className="relative z-10">
|
||||
{/* =========================================================
|
||||
HERO
|
||||
========================================================= */}
|
||||
<header className="pb-8 sm:pb-12">
|
||||
{/* Framework identity: icon + name in a horizontal lockup. */}
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
{hasIcon && (
|
||||
<div className="shell-docs-radius-icon flex h-10 w-10 items-center justify-center border border-[var(--accent)] bg-[var(--accent-dim)] text-[var(--accent)]">
|
||||
{iconOverride ??
|
||||
(IconComponent ? (
|
||||
<IconComponent className="h-6 w-6" />
|
||||
) : null)}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-base font-semibold tracking-tight text-[var(--text)]">
|
||||
{frameworkName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Headline + supporting copy — tightened from the prior
|
||||
display-scale type. Still left-aligned with balanced wrap. */}
|
||||
<h1 className="text-[1.75rem] sm:text-[2.25rem] md:text-[2.5rem] font-semibold leading-[1.1] tracking-[-0.02em] text-[var(--text)] text-balance max-w-[24ch]">
|
||||
{header}
|
||||
</h1>
|
||||
<p className="mt-4 max-w-[58ch] text-base sm:text-lg text-[var(--text-muted)] leading-[1.55] text-pretty">
|
||||
{subheader}
|
||||
</p>
|
||||
|
||||
{/* Action cluster — the same <HeroStartActions> block as the home
|
||||
hero, with Quickstart primary and the agent CLI setup menu
|
||||
secondary. The quickstart slot is a direct link here because a
|
||||
framework is already selected. Frameworks with bespoke setup
|
||||
(e.g. a2a's `git clone`, ms-agent-dotnet) keep their own
|
||||
copy-command chip because those commands aren't interchangeable
|
||||
with the CLI. */}
|
||||
<div className="mt-7">
|
||||
{isGenericInit ? (
|
||||
<HeroStartActions
|
||||
createFramework={createFramework}
|
||||
quickstart={<QuickstartLinkButton href={link(rawGuideLink)} />}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<QuickstartLinkButton href={link(rawGuideLink)} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyCommand}
|
||||
className="shell-docs-radius-control group inline-flex h-11 w-full cursor-pointer items-center justify-between gap-3 border border-[var(--border)] bg-[var(--bg-surface)] px-4 text-[var(--text)] shadow-[var(--shadow-control)] transition-colors hover:bg-[var(--bg-elevated)] sm:w-auto sm:justify-start"
|
||||
aria-label="Copy install command"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-[13.5px]">
|
||||
<span className="text-[var(--accent)] opacity-70 font-mono">
|
||||
$
|
||||
</span>
|
||||
<span className="font-mono text-[13px] text-[var(--text-secondary)] group-hover:text-[var(--text)]">
|
||||
{initCommand}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[var(--text-muted)] group-hover:text-[var(--text)]">
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-[var(--accent)]" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* =========================================================
|
||||
SUPPORTED FEATURES — numbered milestone list
|
||||
========================================================= */}
|
||||
{supportedFeatures.length > 0 && (
|
||||
<section className="mb-20 sm:mb-28">
|
||||
<SectionEyebrow label="What you can build" />
|
||||
<div className="mb-12 max-w-[58ch]">
|
||||
<h2 className="text-[2rem] sm:text-[2.5rem] font-semibold tracking-[-0.02em] leading-[1.1] text-[var(--text)]">
|
||||
Build with {frameworkName}
|
||||
</h2>
|
||||
<p className="mt-3 text-[15px] sm:text-base text-[var(--text-muted)] leading-relaxed">
|
||||
The user-facing primitives every {frameworkName} integration
|
||||
ships with — pick the one that fits your product and drop the
|
||||
code in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-16 sm:gap-24">
|
||||
{supportedFeatures.map((feature) => {
|
||||
const hasMedia = Boolean(feature.videoUrl);
|
||||
return (
|
||||
<article
|
||||
key={feature.title}
|
||||
className="grid lg:grid-cols-12 gap-8 lg:gap-12 items-start"
|
||||
>
|
||||
{/* Left column: title + description + links */}
|
||||
<div className="lg:col-span-5">
|
||||
<h3 className="text-[1.5rem] sm:text-[1.75rem] font-semibold tracking-[-0.015em] leading-[1.15] text-[var(--text)]">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-[15px] text-[var(--text-muted)] leading-[1.6]">
|
||||
{feature.description}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||
<Link
|
||||
href={link(feature.documentationLink)}
|
||||
className="inline-flex items-center gap-1.5 text-[14px] font-medium text-[var(--accent)] hover:text-[var(--accent)] hover:brightness-110 no-underline group"
|
||||
>
|
||||
Read the docs
|
||||
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
{feature.demoLink && (
|
||||
<Link
|
||||
href={link(feature.demoLink)}
|
||||
className="inline-flex items-center gap-1.5 text-[14px] text-[var(--text-muted)] hover:text-[var(--text)] no-underline transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Live demo
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column: video. If no media, the left column
|
||||
spans wider and we leave the right empty (graceful
|
||||
fallback for sparse data records). */}
|
||||
{hasMedia && (
|
||||
<div className="lg:col-span-7">
|
||||
<div className="shell-docs-radius-surface relative overflow-hidden border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--shadow-panel)]">
|
||||
<video
|
||||
src={feature.videoUrl}
|
||||
className="w-full block"
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="shell-docs-radius-surface pointer-events-none absolute inset-0 ring-1 ring-inset ring-white/5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* =========================================================
|
||||
AFTER FEATURES (CTA or MDX escape hatch)
|
||||
========================================================= */}
|
||||
{resolvedAfterFeatures && (
|
||||
<section className="mb-20 sm:mb-28">{resolvedAfterFeatures}</section>
|
||||
)}
|
||||
|
||||
{/* =========================================================
|
||||
ARCHITECTURE
|
||||
========================================================= */}
|
||||
{(architectureImage || architectureVideo) && (
|
||||
<section className="mb-20 sm:mb-28">
|
||||
<SectionEyebrow label="How it fits together" />
|
||||
<div className="mb-10 max-w-[58ch]">
|
||||
<h2 className="text-[2rem] sm:text-[2.5rem] font-semibold tracking-[-0.02em] leading-[1.1] text-[var(--text)]">
|
||||
Architecture
|
||||
</h2>
|
||||
<p className="mt-3 text-[15px] sm:text-base text-[var(--text-muted)] leading-relaxed">
|
||||
The shape of a CopilotKit + {frameworkName} application — from
|
||||
your UI down to the agent runtime.
|
||||
</p>
|
||||
</div>
|
||||
<div className="shell-docs-radius-surface overflow-hidden border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--shadow-panel)]">
|
||||
{architectureImage && (
|
||||
<Image
|
||||
src={architectureImage}
|
||||
alt={`CopilotKit ${frameworkName} architecture diagram`}
|
||||
height={800}
|
||||
width={1600}
|
||||
className="w-full h-auto block"
|
||||
/>
|
||||
)}
|
||||
{architectureVideo && (
|
||||
<video
|
||||
src={architectureVideo}
|
||||
className="w-full block"
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* =========================================================
|
||||
LIVE DEMOS
|
||||
========================================================= */}
|
||||
{liveDemos.length > 0 && (
|
||||
<section className="mb-20 sm:mb-28">
|
||||
<SectionEyebrow label="Live example" />
|
||||
<div className="mb-8 max-w-[58ch]">
|
||||
<h2 className="text-[2rem] sm:text-[2.5rem] font-semibold tracking-[-0.02em] leading-[1.1] text-[var(--text)]">
|
||||
Run {frameworkName} in your browser
|
||||
</h2>
|
||||
<p className="mt-3 text-[15px] sm:text-base text-[var(--text-muted)] leading-relaxed">
|
||||
Two patterns we see most often — drive a SaaS workflow, or
|
||||
collaborate on a canvas with your agent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Segmented control — flat, single-row, with a moving accent
|
||||
underline. Mirrors the dojo's "view toggle" treatment but
|
||||
in a flatter style that suits a landing page. */}
|
||||
{liveDemos.length > 1 && (
|
||||
<div className="shell-docs-radius-control mb-6 inline-flex items-center gap-1 border border-[var(--border)] bg-[var(--bg-surface)] p-1 shadow-[var(--shadow-control)]">
|
||||
{liveDemos.map((demo) => {
|
||||
const active = activeDemo === demo.type;
|
||||
return (
|
||||
<button
|
||||
key={demo.type}
|
||||
type="button"
|
||||
onClick={() => setActiveDemo(demo.type)}
|
||||
className={`shell-docs-radius-control h-8 px-4 text-[13px] font-medium transition-colors ${
|
||||
active
|
||||
? "bg-[var(--bg-elevated)] text-[var(--text)] shadow-[var(--shadow-control)]"
|
||||
: "bg-transparent text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
}`}
|
||||
>
|
||||
{demo.title}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeDemoData && (
|
||||
<p className="mb-5 text-[14.5px] text-[var(--text-muted)] leading-[1.6] max-w-[68ch]">
|
||||
{activeDemoData.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="shell-docs-radius-surface relative overflow-hidden border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--shadow-panel)]">
|
||||
{activeDemoData && (
|
||||
<iframe
|
||||
src={activeDemoData.iframeUrl}
|
||||
className="w-full h-[480px] sm:h-[600px] block"
|
||||
title={`${activeDemoData.title} Demo`}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
aria-hidden
|
||||
className="shell-docs-radius-surface pointer-events-none absolute inset-0 ring-1 ring-inset ring-white/5"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { FrameworkOverview } from "./framework-overview";
|
||||
import type {
|
||||
FrameworkOverviewData,
|
||||
LiveDemo,
|
||||
SupportedFeature,
|
||||
} from "@/data/frameworks/types";
|
||||
|
||||
/**
|
||||
* MDX-friendly adapter for `<FrameworkOverview>`. Accepts the flat props
|
||||
* the v1 docs MDX uses (e.g. `frameworkName`, `frameworkIcon`, `header`,
|
||||
* `supportedFeatures`, ...) and renders the existing data-driven
|
||||
* `FrameworkOverview` component with a synthesized `FrameworkOverviewData`.
|
||||
*
|
||||
* Why this exists: `docs_mode: authored` frameworks ship their own
|
||||
* `integrations/<folder>/index.mdx` ported from v1, which uses flat
|
||||
* props that don't match the typed `data: FrameworkOverviewData` shape
|
||||
* the data-driven path uses. Without this adapter, MDX `<FrameworkOverview>`
|
||||
* would render as the registry shim (empty div + children) and the
|
||||
* ported props would be dropped on the floor.
|
||||
*
|
||||
* The icon is passed through as a JSX node via `iconOverride`, so MDX
|
||||
* files keep using `frameworkIcon={<MastraIcon className="..." />}`
|
||||
* without needing to know about the iconKey registry.
|
||||
*
|
||||
* `currentFramework` is the URL framework slug (e.g. `langgraph-fastapi`)
|
||||
* supplied by the page render site via a per-render override of this
|
||||
* component in the MDX components map. It feeds into
|
||||
* `FrameworkOverview`'s `rewriteHref` link rewriter so internal links
|
||||
* and feature-viewer URLs land on the URL-active variant. Two cases:
|
||||
*
|
||||
* 1. Same-slug case (e.g. `/mastra` rendering `integrations/mastra/index.mdx`,
|
||||
* where the MDX's `guideLink="/mastra/quickstart"` already embeds the
|
||||
* URL slug). `fromSlug === toSlug` → `rewriteHref` short-circuits and
|
||||
* every link passes through unchanged.
|
||||
* 2. Shared-folder case (e.g. `/langgraph-fastapi` rendering
|
||||
* `integrations/langgraph/index.mdx`, where the MDX embeds
|
||||
* `/langgraph/...` and we need to rewrite to `/langgraph-fastapi/...`).
|
||||
* Without this prop the adapter passed an empty `currentFramework`,
|
||||
* so the rewriter stripped `/langgraph/` entirely (toSlug was empty),
|
||||
* producing broken `//` URLs.
|
||||
*
|
||||
* Fallback: when the page render site doesn't override this component
|
||||
* (e.g. an MDX rendered outside the framework-scoped router), we derive
|
||||
* the slug from `guideLink` itself — that makes `fromSlug === toSlug`
|
||||
* so the rewriter no-ops, preserving the historical empty-slug behavior
|
||||
* for the same-slug case.
|
||||
*/
|
||||
export interface MdxFrameworkOverviewProps {
|
||||
frameworkName: string;
|
||||
frameworkIcon?: ReactNode;
|
||||
header: string;
|
||||
subheader?: string;
|
||||
bannerVideo?: string;
|
||||
guideLink?: string;
|
||||
initCommand?: string;
|
||||
featuresLink?: string;
|
||||
supportedFeatures?: SupportedFeature[];
|
||||
architectureImage?: string;
|
||||
architectureVideo?: string;
|
||||
liveDemos?: LiveDemo[];
|
||||
tutorialLink?: string;
|
||||
/**
|
||||
* URL framework slug bound by the per-render override in
|
||||
* `app/[framework]/[[...slug]]/page.tsx` (see header comment). Authored
|
||||
* MDX never sets this directly — the render site injects it via the
|
||||
* components map so the rewriter has the URL-active variant to rewrite
|
||||
* toward.
|
||||
*/
|
||||
currentFramework?: string;
|
||||
}
|
||||
|
||||
export function MdxFrameworkOverview(props: MdxFrameworkOverviewProps) {
|
||||
// Derive the "from" slug embedded in guideLink so the fallback path
|
||||
// (no `currentFramework` from the render site) makes rewriteHref a
|
||||
// no-op. Mirrors `FrameworkOverview`'s own `fromSlug` derivation.
|
||||
const guideLinkSlug = (props.guideLink ?? "").split("/")[1] ?? "";
|
||||
const currentFramework = props.currentFramework ?? guideLinkSlug;
|
||||
const synthData: FrameworkOverviewData = {
|
||||
// slug is only used by FrameworkOverview's link-rewriting logic to
|
||||
// strip a "from-slug" prefix off internal links. We pass an empty
|
||||
// string here because the `data.slug` field is no longer the source
|
||||
// of truth for the rewriter — `FrameworkOverview` derives `fromSlug`
|
||||
// from `guideLink` directly. Leaving this empty avoids a stale
|
||||
// duplicate of the prefix.
|
||||
slug: "",
|
||||
frameworkName: props.frameworkName,
|
||||
// iconKey is ignored because we always pass `iconOverride` below.
|
||||
iconKey: "",
|
||||
header: props.header,
|
||||
subheader: props.subheader ?? "",
|
||||
bannerVideo: props.bannerVideo,
|
||||
guideLink: props.guideLink ?? "",
|
||||
initCommand: props.initCommand ?? "npx copilotkit@latest init",
|
||||
featuresLink: props.featuresLink ?? "",
|
||||
supportedFeatures: props.supportedFeatures ?? [],
|
||||
architectureImage: props.architectureImage,
|
||||
architectureVideo: props.architectureVideo,
|
||||
liveDemos: props.liveDemos ?? [],
|
||||
tutorialLink: props.tutorialLink,
|
||||
};
|
||||
return (
|
||||
<FrameworkOverview
|
||||
data={synthData}
|
||||
currentFramework={currentFramework}
|
||||
iconOverride={props.frameworkIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
// Inline CopilotKit brand mark. Used by both BrandNav (desktop chrome)
|
||||
// and MobileTopNav. Each render gets unique gradient `id`s via React's
|
||||
// useId() so two instances on the same page don't collide.
|
||||
|
||||
import React from "react";
|
||||
|
||||
export function CopilotKitMark({ className }: { className?: string }) {
|
||||
const uid = React.useId();
|
||||
const id = (n: number) => `cpk-mark-${uid}-${n}`;
|
||||
return (
|
||||
<svg
|
||||
viewBox="111 0 25 26"
|
||||
width="22"
|
||||
height="24"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={id(0)}
|
||||
x1="129.301"
|
||||
y1="2.339"
|
||||
x2="125.623"
|
||||
y2="12.452"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#6430AB" />
|
||||
<stop offset="1" stopColor="#AA89D8" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={id(1)}
|
||||
x1="126.451"
|
||||
y1="8.039"
|
||||
x2="121.717"
|
||||
y2="17.187"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#005DBB" />
|
||||
<stop offset="1" stopColor="#3D92E8" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={id(2)}
|
||||
x1="128.565"
|
||||
y1="2.339"
|
||||
x2="127.139"
|
||||
y2="6.798"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#1B70C4" />
|
||||
<stop offset="1" stopColor="#54A4F2" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={id(3)}
|
||||
x1="117.94"
|
||||
y1="22.784"
|
||||
x2="132.981"
|
||||
y2="22.784"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#4497EA" />
|
||||
<stop offset="0.2548" stopColor="#1463B2" />
|
||||
<stop offset="0.4987" stopColor="#0A437D" />
|
||||
<stop offset="0.6667" stopColor="#2476C8" />
|
||||
<stop offset="0.9725" stopColor="#0C549A" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M119.539 8.67724C121.647 5.91912 123.397 3.19174 124.071 0.989235C124.089 0.929306 124.159 0.903848 124.211 0.938445C126.553 2.4891 130.818 3.50978 134.591 3.53373C134.655 3.53415 134.7 3.59815 134.677 3.65868C133.422 6.84085 131.89 12.5427 131.831 19.054C131.831 19.1507 131.695 19.1854 131.647 19.1014C129.5 15.3443 122.623 10.0649 119.574 8.81884C119.517 8.79565 119.501 8.72596 119.539 8.67724Z"
|
||||
fill={`url(#${id(0)})`}
|
||||
/>
|
||||
<path
|
||||
d="M126.653 6.99011C123.357 8.03363 120.345 8.61377 119.626 8.74558C119.581 8.75399 119.571 8.81729 119.615 8.83516C122.687 10.1126 129.53 15.3766 131.657 19.1184C131.661 19.1266 131.672 19.1296 131.68 19.1259C131.689 19.1218 131.693 19.1112 131.69 19.1021L126.653 6.99011Z"
|
||||
fill={`url(#${id(1)})`}
|
||||
/>
|
||||
<path
|
||||
d="M124.221 0.931583C127.042 2.47061 130.303 3.16182 134.629 3.52604C134.656 3.52836 134.665 3.56478 134.641 3.57743C134.087 3.86176 130.918 5.47449 128.565 6.33825C127.934 6.56966 127.3 6.78434 126.675 6.98241C126.662 6.98674 126.647 6.97992 126.641 6.96671L124.156 0.989873C124.139 0.949626 124.183 0.91071 124.221 0.931583Z"
|
||||
fill={`url(#${id(2)})`}
|
||||
/>
|
||||
<path
|
||||
d="M125.209 3.30419L122.405 12.6362M122.405 12.6362H129.07M122.405 12.6362L111.874 25.0387"
|
||||
stroke="#ABABAB"
|
||||
strokeWidth="0.321797"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M119.181 22.4856L117.94 22.6601C118.584 24.3624 119.904 25.1059 121.479 25.1059C125.341 25.1059 124.163 20.7388 126.4 20.7388C128.023 20.7388 127.364 24.2784 130.857 24.2784C132.989 24.2784 133.201 22.1307 132.837 21.2067C132.835 21.201 132.833 21.1959 132.83 21.1908L132.259 20.316C132.222 20.2578 132.131 20.2797 132.125 20.3489L132.018 21.4092C132.011 21.483 132.013 21.5565 132.021 21.6301C132.109 22.3627 132.165 24.1405 130.857 24.1405C129.477 24.1405 129.145 20.6468 126.4 20.6468C123.181 20.6468 123.594 24.968 121.618 24.968C120.313 24.968 119.319 23.497 119.181 22.4856Z"
|
||||
fill={`url(#${id(3)})`}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// <CopyButton> — minimal clipboard button used by <Snippet>.
|
||||
// Lives on the client so it can access navigator.clipboard.
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
type CopyState = "idle" | "copied" | "error";
|
||||
|
||||
export function CopyButton({ text }: { text: string }) {
|
||||
const [state, setState] = useState<CopyState>("idle");
|
||||
// Track the pending reset timer so we can clear it on unmount (avoids the
|
||||
// React 18 "state update on an unmounted component" warning) and also
|
||||
// overwrite a stale timer when the user clicks again before it fires.
|
||||
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const copied = state === "copied";
|
||||
const error = state === "error";
|
||||
|
||||
let label: string;
|
||||
if (copied) label = "Copied";
|
||||
else if (error) label = "Copy blocked";
|
||||
else label = "Copy";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
// Clear any in-flight reset from a previous click so rapid-fire copies
|
||||
// don't race each other and flip the label back early.
|
||||
if (resetTimerRef.current) {
|
||||
clearTimeout(resetTimerRef.current);
|
||||
resetTimerRef.current = null;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setState("copied");
|
||||
resetTimerRef.current = setTimeout(() => setState("idle"), 1500);
|
||||
} catch (err) {
|
||||
// Clipboard blocked (permissions, insecure context, etc.) — surface
|
||||
// the failure to the user so they know the button didn't work, and
|
||||
// log enough for devs to debug rather than silently swallowing.
|
||||
console.warn("[copy-button] clipboard write failed", err);
|
||||
setState("error");
|
||||
resetTimerRef.current = setTimeout(() => setState("idle"), 2000);
|
||||
}
|
||||
}}
|
||||
aria-label={label}
|
||||
className={[
|
||||
"shell-docs-radius-control cursor-pointer border border-[var(--border)] px-2 py-0.5 text-[10px] leading-[1.2] transition-colors",
|
||||
copied
|
||||
? "bg-[var(--accent-light)] text-[var(--accent)]"
|
||||
: error
|
||||
? "bg-[var(--bg-elevated)] text-[var(--text)]"
|
||||
: "bg-[var(--bg-surface)] text-[var(--text-muted)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text)]",
|
||||
].join(" ")}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// <DemoSource> — in-page source viewer used inside <InlineDemo>'s Code
|
||||
// tab. Reads from the same `demo-content.json` bundle <Snippet> consumes,
|
||||
// scoped to one (integration, demo) cell.
|
||||
//
|
||||
// Defaults to showing only files flagged in the manifest's `highlight:`
|
||||
// array (rendered in manifest order via `highlightOrder`). Falls back to
|
||||
// the full bundled file list when nothing is flagged. Pass
|
||||
// `onlyHighlighted={false}` to always show every file in the cell.
|
||||
//
|
||||
// Rendering: Fumadocs's `<CodeBlockTabs>` for the per-file tab strip and
|
||||
// `<DynamicCodeBlock>` (Shiki at runtime via `useShiki`) for the body of
|
||||
// each tab. This shares chrome and syntax-highlight theme with the
|
||||
// authored fenced blocks rendered through MDX, so the Code tab inside
|
||||
// InlineDemo looks identical to the rest of the page.
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
CodeBlockTab,
|
||||
CodeBlockTabs,
|
||||
CodeBlockTabsList,
|
||||
CodeBlockTabsTrigger,
|
||||
} from "fumadocs-ui/components/codeblock";
|
||||
import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock";
|
||||
import demoContent from "../data/demo-content.json";
|
||||
|
||||
interface DemoFile {
|
||||
filename: string;
|
||||
language: string;
|
||||
content: string;
|
||||
highlighted?: boolean;
|
||||
highlightOrder?: number;
|
||||
}
|
||||
|
||||
interface DemoRecord {
|
||||
files?: DemoFile[];
|
||||
}
|
||||
|
||||
const demos: Record<string, DemoRecord> = (
|
||||
demoContent as { demos: Record<string, DemoRecord> }
|
||||
).demos;
|
||||
|
||||
// Manifest language slugs → Shiki language names. Shiki recognises most
|
||||
// slugs directly; this map exists only to disambiguate aliases authors
|
||||
// write differently from Shiki's canonical names.
|
||||
const SHIKI_LANGUAGE_MAP: Record<string, string> = {
|
||||
typescript: "typescript",
|
||||
javascript: "javascript",
|
||||
python: "python",
|
||||
csharp: "csharp",
|
||||
css: "css",
|
||||
json: "json",
|
||||
yaml: "yaml",
|
||||
markdown: "markdown",
|
||||
text: "plaintext",
|
||||
};
|
||||
|
||||
function resolveShikiLanguage(lang: string): string {
|
||||
return SHIKI_LANGUAGE_MAP[lang] ?? lang;
|
||||
}
|
||||
|
||||
function basename(p: string): string {
|
||||
const idx = p.lastIndexOf("/");
|
||||
return idx === -1 ? p : p.slice(idx + 1);
|
||||
}
|
||||
|
||||
function WarningBox({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="shell-docs-radius-surface shell-docs-warning-surface my-4 border border-l-4 p-4 text-sm text-[var(--text-secondary)] shadow-[var(--shadow-control)]"
|
||||
role="alert"
|
||||
>
|
||||
<div className="font-semibold mb-1 text-[var(--text)]">
|
||||
Missing demo source
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DemoSourceProps {
|
||||
/** Integration slug, e.g. `langgraph-python`. Used directly as the
|
||||
* bundle key prefix — no `getDocsFolder` translation. */
|
||||
integration: string;
|
||||
/** Demo id, e.g. `agentic-chat`. Matches manifest `demos[i].id`. */
|
||||
demo: string;
|
||||
/**
|
||||
* When true (default), only files flagged `highlighted: true` in the
|
||||
* manifest are shown, sorted by `highlightOrder`. If no files are
|
||||
* flagged, falls back to showing all files so the Code tab is never
|
||||
* blank. Pass `false` to always show every file in the cell.
|
||||
*/
|
||||
onlyHighlighted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a filename to the radix-tabs `value` format Fumadocs's
|
||||
* `<Tabs items={[...]}>` derives internally — lowercase + first
|
||||
* whitespace replaced with `-`. Keeps `CodeBlockTabsTrigger` /
|
||||
* `CodeBlockTab` value props in sync for radix pairing.
|
||||
*/
|
||||
function tabValue(filename: string): string {
|
||||
return filename.toLowerCase().replace(/\s/, "-");
|
||||
}
|
||||
|
||||
export function DemoSource({
|
||||
integration,
|
||||
demo,
|
||||
onlyHighlighted = true,
|
||||
}: DemoSourceProps) {
|
||||
const key = `${integration}::${demo}`;
|
||||
const record = demos[key];
|
||||
|
||||
const displayFiles: DemoFile[] = useMemo(() => {
|
||||
const all = record?.files ?? [];
|
||||
if (!onlyHighlighted) return all;
|
||||
const flagged = all.filter((f) => f.highlighted);
|
||||
if (flagged.length === 0) return all;
|
||||
return [...flagged].sort((a, b) => {
|
||||
const ao = a.highlightOrder;
|
||||
const bo = b.highlightOrder;
|
||||
if (ao === undefined && bo === undefined)
|
||||
return a.filename.localeCompare(b.filename);
|
||||
if (ao === undefined) return 1;
|
||||
if (bo === undefined) return -1;
|
||||
return ao - bo;
|
||||
});
|
||||
}, [record, onlyHighlighted]);
|
||||
|
||||
if (!record) {
|
||||
return (
|
||||
<WarningBox>
|
||||
No demo found for <code>{key}</code>. Check the integration slug and
|
||||
demo id (matches manifest <code>slug</code> / <code>demos[i].id</code>
|
||||
).
|
||||
</WarningBox>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayFiles.length === 0) {
|
||||
return (
|
||||
<WarningBox>
|
||||
Demo <code>{key}</code> has no bundled source files. The manifest may be
|
||||
missing a <code>route:</code> or its demo folder is empty.
|
||||
</WarningBox>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultValue = tabValue(displayFiles[0].filename);
|
||||
|
||||
return (
|
||||
<CodeBlockTabs defaultValue={defaultValue}>
|
||||
<CodeBlockTabsList>
|
||||
{displayFiles.map((f) => (
|
||||
<CodeBlockTabsTrigger
|
||||
key={f.filename}
|
||||
value={tabValue(f.filename)}
|
||||
title={f.filename}
|
||||
>
|
||||
{basename(f.filename)}
|
||||
</CodeBlockTabsTrigger>
|
||||
))}
|
||||
</CodeBlockTabsList>
|
||||
{displayFiles.map((f) => (
|
||||
<CodeBlockTab key={f.filename} value={tabValue(f.filename)}>
|
||||
<DynamicCodeBlock
|
||||
lang={resolveShikiLanguage(f.language)}
|
||||
code={f.content.replace(/\n$/, "")}
|
||||
/>
|
||||
</CodeBlockTab>
|
||||
))}
|
||||
</CodeBlockTabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
// DocsLandingNext — the primary backend picker on the docs landing page.
|
||||
// The hero explains CopilotKit at the product level; this block gives the
|
||||
// visitor the next concrete action by linking every visible backend docs
|
||||
// surface from one grid.
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { StoredFrameworkHighlight } from "./stored-framework-highlight";
|
||||
import { FrameworkLogo } from "./icons/framework-icons";
|
||||
import { compareByDisplayOrder } from "@/lib/framework-order";
|
||||
import { getDocsMode, getIntegrations } from "@/lib/registry";
|
||||
|
||||
const backendDescriptions: Record<string, string> = {
|
||||
"built-in-agent": "Use CopilotKit's in-process agent to get started fast.",
|
||||
"langgraph-python":
|
||||
"Python LangGraph agents with the broadest feature coverage.",
|
||||
"langgraph-typescript": "TypeScript LangGraph agents over the AG-UI adapter.",
|
||||
"langgraph-fastapi": "Python LangGraph agents exposed through FastAPI.",
|
||||
deepagents: "LangChain Deep Agents connected to CopilotKit product UI.",
|
||||
"google-adk": "Gemini-powered Google ADK agents connected through AG-UI.",
|
||||
mastra: "TypeScript-native agents, tools, memory, and workflows.",
|
||||
"crewai-crews": "CrewAI crews wired into CopilotKit product interfaces.",
|
||||
"pydantic-ai": "Typed Python agents with PydanticAI and CopilotKit UI.",
|
||||
agno: "Agno agents with tools, state, and generative UI examples.",
|
||||
ag2: "AG2 agents with CopilotKit chat, tools, and HITL flows.",
|
||||
llamaindex: "LlamaIndex workflows connected to CopilotKit experiences.",
|
||||
strands: "AWS Strands agents with CopilotKit frontend primitives.",
|
||||
"strands-typescript": "TypeScript AWS Strands agents over the AG-UI adapter.",
|
||||
"ms-agent-python": "Microsoft Agent Framework agents in Python.",
|
||||
"ms-agent-dotnet": "Microsoft Agent Framework agents in .NET.",
|
||||
"ms-agent-harness-dotnet": "Microsoft Agent Harness on .NET via AG-UI.",
|
||||
};
|
||||
|
||||
function BackendGrid() {
|
||||
const integrations = getIntegrations()
|
||||
// `docs_mode: hidden` frameworks have no docs page — surfacing them
|
||||
// here would link straight to a 404.
|
||||
.filter((i) => getDocsMode(i.slug) !== "hidden")
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.slug === "built-in-agent") return -1;
|
||||
if (b.slug === "built-in-agent") return 1;
|
||||
return compareByDisplayOrder(a.slug, b.slug);
|
||||
});
|
||||
|
||||
return (
|
||||
<section id="backends" className="not-prose">
|
||||
<div className="mb-5 max-w-2xl">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-[var(--text)] sm:text-2xl">
|
||||
Build with any agent backend
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-[var(--text-secondary)]">
|
||||
Start with CopilotKit's default agent or open the docs for a partner
|
||||
framework.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-[repeat(auto-fit,minmax(min(100%,16rem),1fr))] sm:gap-3">
|
||||
{integrations.map((i) => (
|
||||
<Link
|
||||
key={i.slug}
|
||||
href={i.slug === "built-in-agent" ? "/quickstart" : `/${i.slug}`}
|
||||
className="shell-docs-radius-surface group relative flex min-h-[84px] items-start gap-3 overflow-hidden border border-[var(--border)] bg-[var(--bg-elevated)]/30 p-3.5 no-underline transition-colors hover:border-[var(--accent)] hover:bg-[var(--bg-surface)] sm:min-h-[96px]"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="shell-docs-radius-icon flex h-8 w-8 shrink-0 items-center justify-center bg-[var(--accent-dim)] text-[var(--accent)] transition-colors group-hover:bg-[var(--accent-light)]"
|
||||
>
|
||||
<FrameworkLogo
|
||||
slug={i.slug}
|
||||
fallbackSrc={i.logo}
|
||||
size={17}
|
||||
className="text-[var(--accent)]"
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 sm:pr-2">
|
||||
<span className="block text-sm font-semibold leading-snug text-[var(--text)] transition-colors group-hover:text-[var(--accent)]">
|
||||
{i.name}
|
||||
</span>
|
||||
<span className="mt-1 line-clamp-2 block text-xs leading-relaxed text-[var(--text-muted)]">
|
||||
{backendDescriptions[i.slug] ?? i.description}
|
||||
</span>
|
||||
</span>
|
||||
<StoredFrameworkHighlight slug={i.slug} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsLandingNext() {
|
||||
return <BackendGrid />;
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
// DocsPageView — shared server component that renders a single MDX doc
|
||||
// with its sidebar, breadcrumbs, and page-level Snippet defaults.
|
||||
//
|
||||
// Used by both the classic `/docs/<slug>` route and the framework-scoped
|
||||
// `/<framework>/<slug>` catch-all. The `slugHrefPrefix` prop controls
|
||||
// how sidebar links and breadcrumbs are serialized back into URLs — so
|
||||
// framework-scoped views keep every internal link in the `<framework>`
|
||||
// namespace without duplicating the nav builder.
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { MDXRemote } from "next-mdx-remote/rsc";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
rehypeCode,
|
||||
rehypeCodeDefaultOptions,
|
||||
} from "fumadocs-core/mdx-plugins";
|
||||
import {
|
||||
DocsPage,
|
||||
DocsBody,
|
||||
DocsTitle,
|
||||
DocsDescription,
|
||||
} from "fumadocs-ui/page";
|
||||
import { ShellDocsLayout } from "@/components/shell-docs-layout";
|
||||
import { SidebarFrameworkSelector } from "@/components/sidebar-framework-selector";
|
||||
import { EarlyAccessGate } from "@/components/early-access-gate";
|
||||
import { getEarlyAccessGate } from "@/lib/early-access";
|
||||
import {
|
||||
MarkdownCopyButton,
|
||||
ViewOptionsPopover,
|
||||
} from "@/components/ai/page-actions";
|
||||
import { Snippet } from "@/components/snippet";
|
||||
import { WhenFrameworkHas } from "@/components/when-framework-has";
|
||||
import { Tabs as DocsTabs } from "@/components/docs-tabs";
|
||||
import { MdxCodeBlock } from "@/components/mdx-code-block";
|
||||
import { MdxFrameworkOverview } from "@/components/content/landing-pages/mdx-framework-overview";
|
||||
import type { MdxFrameworkOverviewProps } from "@/components/content/landing-pages/mdx-framework-overview";
|
||||
import { FrameworkSetup } from "@/lib/setup-concept";
|
||||
import { docsComponents } from "@/lib/mdx-registry";
|
||||
import { resolveDocsHref } from "@/lib/docs-link-rewrite";
|
||||
import { transformerMeta } from "@/lib/rehype-code-meta";
|
||||
import { getIntegration, getTabDefault } from "@/lib/registry";
|
||||
import type { NavNode } from "@/lib/docs-render";
|
||||
import { navTreeToPageTree } from "@/lib/page-tree-bridge";
|
||||
import { tocHeadingsToFumadocs } from "@/lib/toc-bridge";
|
||||
import {
|
||||
buildBreadcrumbs,
|
||||
buildNavTree,
|
||||
convertTablesInJSX,
|
||||
inlineSnippets,
|
||||
loadDoc,
|
||||
CONTENT_DIR,
|
||||
} from "@/lib/docs-render";
|
||||
import {
|
||||
childrenToText,
|
||||
extractHeadings,
|
||||
filterFrameworkScopedBlocks,
|
||||
slugify,
|
||||
} from "@/lib/toc";
|
||||
|
||||
export interface DocsPageViewProps {
|
||||
/** Slug path relative to `CONTENT_DIR` (no leading slash). */
|
||||
slugPath: string;
|
||||
/**
|
||||
* Optional content path to load the MDX from, when it differs from
|
||||
* `slugPath`. Used when a per-framework override backs the page (e.g.
|
||||
* BIA serves `integrations/built-in-agent/server-tools.mdx` at the
|
||||
* root URL `/server-tools`). Defaults to `slugPath`.
|
||||
*/
|
||||
contentSlugPath?: string;
|
||||
/**
|
||||
* Prefix used to build sidebar + breadcrumb hrefs.
|
||||
* - `/docs` for the classic docs route
|
||||
* - `/<framework>` for framework-scoped pages
|
||||
*/
|
||||
slugHrefPrefix: string;
|
||||
/** Optional framework slug to thread into <Snippet> as a default. */
|
||||
frameworkOverride?: string | null;
|
||||
/** Pre-built nav tree. When omitted, defaults to the full docs tree. */
|
||||
navTree?: NavNode[];
|
||||
/** Banner slot rendered above the main content column. */
|
||||
bannerSlot?: React.ReactNode;
|
||||
/** Banner slot rendered at the top of the sidebar. */
|
||||
sidebarBannerSlot?: React.ReactNode;
|
||||
/** Optional class attached to the shared Fumadocs sidebar wrapper. */
|
||||
sidebarClassName?: string;
|
||||
/** When set, hide the main MDX body (used by pivot-only pages). */
|
||||
hideBody?: boolean;
|
||||
/**
|
||||
* Optional client component that wraps the MDX body — used by the
|
||||
* `/docs/<feature>` router pages to conditionally hide code when no
|
||||
* framework is selected. Must accept `children` and render them
|
||||
* (or suppress them) based on its own state.
|
||||
*/
|
||||
ContentWrapper?: React.ComponentType<{ children: React.ReactNode }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the public GitHub URL for an MDX source file from its absolute
|
||||
* filesystem path. `loadDoc()` returns an absolute path
|
||||
* (`/Users/.../showcase/shell-docs/src/content/docs/...mdx`); the path
|
||||
* GitHub serves is repo-relative starting from the first `showcase/`
|
||||
* segment. Falls back to `null` (no link rendered upstream is not yet
|
||||
* wired but caller passes string; treat as best-effort).
|
||||
*/
|
||||
function buildGitHubUrl(absFilePath: string): string {
|
||||
const marker = "/showcase/";
|
||||
const idx = absFilePath.indexOf(marker);
|
||||
// If we can't find the marker, fall back to the repo root so the
|
||||
// GitHub link is still well-formed even if it 404s — better than an
|
||||
// anchor pointing to an absolute fs path.
|
||||
const repoRelative =
|
||||
idx >= 0 ? absFilePath.slice(idx + 1) : "showcase/shell-docs";
|
||||
return `https://github.com/CopilotKit/CopilotKit/blob/main/${repoRelative}`;
|
||||
}
|
||||
|
||||
export async function DocsPageView({
|
||||
slugPath,
|
||||
contentSlugPath,
|
||||
slugHrefPrefix,
|
||||
frameworkOverride,
|
||||
navTree,
|
||||
bannerSlot,
|
||||
sidebarBannerSlot,
|
||||
sidebarClassName,
|
||||
hideBody = false,
|
||||
ContentWrapper,
|
||||
}: DocsPageViewProps) {
|
||||
const doc = loadDoc(contentSlugPath ?? slugPath);
|
||||
if (!doc) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-16 text-center">
|
||||
<h1 className="text-2xl font-semibold text-[var(--text)] mb-3">
|
||||
Not found
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
No page matches <code>{slugPath}</code>.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rawContent = doc.source.replace(/^---[\s\S]*?---\n?/, "");
|
||||
const inlined = inlineSnippets(rawContent, slugPath);
|
||||
const content = convertTablesInJSX(inlined);
|
||||
|
||||
const defaultFramework = frameworkOverride ?? doc.fm.defaultFramework;
|
||||
const defaultCell = doc.fm.defaultCell;
|
||||
|
||||
// Extract H2/H3 headings for the right-rail TOC. Run on the final
|
||||
// content (post-snippet-inlining) so a page like threads.mdx whose
|
||||
// body comes from a shared snippet still surfaces its sections.
|
||||
//
|
||||
// Filter `<WhenFrameworkHas>` branches against the active framework
|
||||
// first so the TOC only lists the headings that actually render in
|
||||
// the body. Without this, framework-gated pages like `/auth` surface
|
||||
// every per-framework variant's headings simultaneously even though
|
||||
// only one variant's body renders.
|
||||
const tocSource = filterFrameworkScopedBlocks(content, defaultFramework);
|
||||
const tocHeadings =
|
||||
hideBody || doc.fm.hideTOC ? [] : extractHeadings(tocSource);
|
||||
|
||||
const tree = navTree ?? buildNavTree(CONTENT_DIR);
|
||||
// Breadcrumb root label tracks the framework whose content is being
|
||||
// rendered. On framework-scoped pages this reads "LangGraph (Python)";
|
||||
// on unscoped pages it falls back to "Docs". The sidebar no longer
|
||||
// surfaces this label as a separate link — the selector pill at the
|
||||
// top of the sidebar already names the framework.
|
||||
const rootLabel =
|
||||
(frameworkOverride && getIntegration(frameworkOverride)?.name) || "Docs";
|
||||
const breadcrumbs = buildBreadcrumbs(slugPath, {
|
||||
rootLabel,
|
||||
rootHref: slugHrefPrefix || "/",
|
||||
slugHrefPrefix,
|
||||
});
|
||||
|
||||
// Bridge shell-docs's NavNode tree + headings into Fumadocs's shapes
|
||||
// so DocsLayout (sidebar) and DocsPage (right-rail TOC) can render them.
|
||||
const pageTree = navTreeToPageTree(tree, slugHrefPrefix);
|
||||
const fumadocsToc = tocHeadingsToFumadocs(tocHeadings);
|
||||
|
||||
return (
|
||||
<ShellDocsLayout
|
||||
tree={pageTree}
|
||||
sidebarClassName={sidebarClassName}
|
||||
banner={
|
||||
sidebarBannerSlot === undefined ? (
|
||||
<SidebarFrameworkSelector />
|
||||
) : (
|
||||
sidebarBannerSlot
|
||||
)
|
||||
}
|
||||
>
|
||||
<DocsPage
|
||||
toc={fumadocsToc}
|
||||
breadcrumb={{ enabled: false }}
|
||||
footer={{ enabled: false }}
|
||||
tableOfContentPopover={{ enabled: false }}
|
||||
>
|
||||
<MaybeEarlyAccessGate gate={doc.fm.earlyAccess}>
|
||||
<div className="docs-inner-content max-w-[900px] mx-auto px-4 md:px-6 pt-2 pb-6 md:pt-3 xl:pt-4">
|
||||
{/* Breadcrumb styling tracks canonical fumadocs PageBreadcrumb,
|
||||
* but tighter: this should read as quiet page chrome, not a
|
||||
* second title row above the H1. */}
|
||||
<nav className="mb-2 flex flex-wrap items-center gap-1 text-[11px] font-medium leading-none text-[var(--text-muted)]">
|
||||
{breadcrumbs.map((crumb, i) => {
|
||||
const isLast = i === breadcrumbs.length - 1;
|
||||
const labelClass = `truncate ${isLast ? "text-[var(--text)] font-medium" : ""}`;
|
||||
return (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && (
|
||||
<ChevronRight
|
||||
className="size-3 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{crumb.href ? (
|
||||
<Link
|
||||
href={crumb.href}
|
||||
className={`${labelClass} transition-opacity hover:opacity-80`}
|
||||
>
|
||||
{crumb.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={labelClass}>{crumb.label}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<DocsTitle className="text-[32px] md:text-[40px] font-medium leading-[1.2]">
|
||||
{doc.fm.title}
|
||||
</DocsTitle>
|
||||
{doc.fm.description && (
|
||||
<DocsDescription className="text-lg text-[var(--text-muted)] mt-5 leading-relaxed">
|
||||
{doc.fm.description}
|
||||
</DocsDescription>
|
||||
)}
|
||||
|
||||
{/* Page actions (Copy Markdown / Open in <LLM>) — fumadocs's
|
||||
upstream LLM page-actions feature. `markdownUrl` resolves
|
||||
through the `/:path*.mdx` rewrite to the route handler at
|
||||
`app/llms-mdx/[[...slug]]/route.ts`, which serves the raw
|
||||
MDX via the same `loadDoc()` the page uses. The GitHub URL
|
||||
is computed from `doc.filePath` (absolute fs path) by
|
||||
slicing from the `/showcase/` segment. */}
|
||||
{(() => {
|
||||
// Markdown URL = the canonical page URL with `.mdx` appended.
|
||||
// The Next.js rewrite in `next.config.ts` routes this to
|
||||
// `/llms-mdx/[[...slug]]`, which re-runs the same framework-
|
||||
// aware content resolution the page uses. Using the page URL
|
||||
// (rather than `contentSlugPath`) keeps the "View as Markdown"
|
||||
// link the user opens in a new tab visually aligned with the
|
||||
// page they're reading.
|
||||
const base = `${slugHrefPrefix || ""}/${slugPath}`
|
||||
.replace(/\/+/g, "/")
|
||||
.replace(/^\/+/, "/");
|
||||
const markdownUrl = `${base.replace(/\/$/, "")}.mdx`;
|
||||
return (
|
||||
<div className="flex min-w-0 flex-row flex-wrap gap-2 items-center my-6">
|
||||
<MarkdownCopyButton markdownUrl={markdownUrl} />
|
||||
<ViewOptionsPopover
|
||||
markdownUrl={markdownUrl}
|
||||
githubUrl={buildGitHubUrl(doc.filePath)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Thin divider between the page-actions row and the page body
|
||||
(banner / content). Visually separates the page metadata
|
||||
chrome (title + page actions) from the page content
|
||||
underneath. Uses the project's `--border` token so it tracks
|
||||
the rest of the page chrome in light and dark modes. */}
|
||||
<hr className="border-t border-[var(--border)] mt-2 mb-6" />
|
||||
|
||||
{bannerSlot}
|
||||
|
||||
{!hideBody &&
|
||||
(() => {
|
||||
const body = (
|
||||
<DocsBody className="reference-content">
|
||||
<MDXRemote
|
||||
source={content}
|
||||
components={{
|
||||
...docsComponents,
|
||||
// Wrap MDX-rendered <pre> blocks (triple-fenced code)
|
||||
// with the same figure chrome <Snippet> uses — copy
|
||||
// button always visible, file-path caption when the
|
||||
// fence carries `title="..."`. The `transformerMeta`
|
||||
// Shiki transformer (wired in `options.mdxOptions.rehypePlugins`
|
||||
// below) is what puts `data-title` / `data-language`
|
||||
// on the <pre> for this component to read.
|
||||
pre: MdxCodeBlock,
|
||||
Snippet: (props: Record<string, unknown>) => (
|
||||
<Snippet
|
||||
{...(props as Record<string, string | undefined>)}
|
||||
defaultFramework={defaultFramework}
|
||||
defaultCell={defaultCell}
|
||||
/>
|
||||
),
|
||||
WhenFrameworkHas: (props: Record<string, unknown>) => (
|
||||
<WhenFrameworkHas
|
||||
{...(props as {
|
||||
flag:
|
||||
| "a2ui_pattern"
|
||||
| "interrupt_pattern"
|
||||
| "thread_persistence_pattern"
|
||||
| "agent_config_pattern"
|
||||
| "auth_pattern";
|
||||
equals?: string;
|
||||
absent?: boolean;
|
||||
framework?: string;
|
||||
children?: React.ReactNode;
|
||||
})}
|
||||
defaultFramework={defaultFramework}
|
||||
/>
|
||||
),
|
||||
// MDX pages author in-page variant selectors as
|
||||
// `<Tabs groupId="language_langgraph_agent" default="Python">`.
|
||||
// When the URL scope is a specific variant (e.g.
|
||||
// `/langgraph-typescript/*`), pre-select the
|
||||
// matching tab instead of the author's hardcoded
|
||||
// default so the code visible on arrival matches
|
||||
// the URL the user followed. Slugs without a
|
||||
// mapping (or tabs whose groupId isn't listed in
|
||||
// TAB_DEFAULTS_BY_SLUG) fall through to the MDX
|
||||
// `default` and the component's first-label
|
||||
// fallback unchanged.
|
||||
Tabs: (props: {
|
||||
groupId?: string;
|
||||
default?: string;
|
||||
items?: string[];
|
||||
children?: React.ReactNode;
|
||||
persist?: boolean;
|
||||
}) => {
|
||||
const urlDefault = getTabDefault(
|
||||
frameworkOverride ?? null,
|
||||
props.groupId,
|
||||
);
|
||||
return (
|
||||
<DocsTabs
|
||||
{...props}
|
||||
default={urlDefault ?? props.default}
|
||||
>
|
||||
{props.children}
|
||||
</DocsTabs>
|
||||
);
|
||||
},
|
||||
InlineDemo: (props: Record<string, unknown>) => {
|
||||
const InlineDemoComp = docsComponents.InlineDemo;
|
||||
return (
|
||||
<InlineDemoComp
|
||||
{...(props as {
|
||||
integration?: string;
|
||||
demo?: string;
|
||||
})}
|
||||
integration={
|
||||
defaultFramework ??
|
||||
(props.integration as string | undefined)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
// Bind the URL framework slug into MdxFrameworkOverview
|
||||
// so its link rewriter has a target to rewrite TO. The
|
||||
// shared `integrations/<folder>/index.mdx` files
|
||||
// (langgraph/, microsoft-agent-framework/, crewai-flows/)
|
||||
// serve multiple URL variants — without this override the
|
||||
// adapter's empty-slug fallback would strip the embedded
|
||||
// framework prefix entirely (e.g. `/langgraph/quickstart`
|
||||
// → `/quickstart`).
|
||||
FrameworkOverview: (
|
||||
props: MdxFrameworkOverviewProps,
|
||||
) => (
|
||||
<MdxFrameworkOverview
|
||||
{...props}
|
||||
currentFramework={
|
||||
frameworkOverride ?? props.currentFramework
|
||||
}
|
||||
/>
|
||||
),
|
||||
// Same closure pattern: thread the URL framework
|
||||
// slug into <FrameworkSetup concept="..." /> so it
|
||||
// can resolve the per-framework concept file.
|
||||
FrameworkSetup: (props: {
|
||||
concept: string;
|
||||
heading?: string | null;
|
||||
headingId?: string;
|
||||
currentFramework?: string;
|
||||
}) => (
|
||||
<FrameworkSetup
|
||||
{...props}
|
||||
currentFramework={
|
||||
frameworkOverride ?? props.currentFramework
|
||||
}
|
||||
/>
|
||||
),
|
||||
// Inject stable IDs on H2/H3 so the right-rail TOC's
|
||||
// #anchor links resolve. Slugify the child text with the
|
||||
// same algorithm used by extractHeadings() so IDs line up
|
||||
// with the TOC entries.
|
||||
// H2/H3 carry stable slug IDs (already used by the
|
||||
// right-rail TOC) and now also surface a hover-only
|
||||
// `#` anchor link for deep-linking, mirroring the
|
||||
// canonical fumadocs prose chrome.
|
||||
h2: ({
|
||||
children,
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) => {
|
||||
const id = slugify(childrenToText(children));
|
||||
// Spread rest BEFORE id/className so the computed
|
||||
// slug-id always wins over any MDX-supplied id.
|
||||
// Otherwise an authored `<h2 id="custom">` would
|
||||
// override the slugified id, breaking the TOC's
|
||||
// `href="#${id}"` and any inbound deep-links that
|
||||
// already rely on the slug.
|
||||
return (
|
||||
<h2
|
||||
{...rest}
|
||||
id={id}
|
||||
className={`docs-heading group ${rest.className ?? ""}`}
|
||||
>
|
||||
{children}
|
||||
<a
|
||||
href={`#${id}`}
|
||||
aria-label="Link to this section"
|
||||
className="docs-heading-anchor"
|
||||
>
|
||||
#
|
||||
</a>
|
||||
</h2>
|
||||
);
|
||||
},
|
||||
h3: ({
|
||||
children,
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) => {
|
||||
const id = slugify(childrenToText(children));
|
||||
// Spread rest BEFORE id/className — see h2 above for
|
||||
// rationale.
|
||||
return (
|
||||
<h3
|
||||
{...rest}
|
||||
id={id}
|
||||
className={`docs-heading group ${rest.className ?? ""}`}
|
||||
>
|
||||
{children}
|
||||
<a
|
||||
href={`#${id}`}
|
||||
aria-label="Link to this section"
|
||||
className="docs-heading-anchor"
|
||||
>
|
||||
#
|
||||
</a>
|
||||
</h3>
|
||||
);
|
||||
},
|
||||
a: ({
|
||||
href,
|
||||
children,
|
||||
...rest
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
||||
<Link
|
||||
href={
|
||||
resolveDocsHref(href, {
|
||||
slugHrefPrefix,
|
||||
frameworkOverride,
|
||||
}) ?? "#"
|
||||
}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
options={{
|
||||
mdxOptions: {
|
||||
remarkPlugins: [remarkGfm],
|
||||
// Use Fumadocs's Shiki-based `rehypeCode` for
|
||||
// syntax highlighting. Our custom `transformerMeta`
|
||||
// surfaces the parsed fence `title="..."` and
|
||||
// resolved language as data-attrs on the <pre>
|
||||
// so MdxCodeBlock can render Fumadocs's CodeBlock
|
||||
// chrome with the file-path figcaption + copy
|
||||
// button.
|
||||
rehypePlugins: [
|
||||
[
|
||||
rehypeCode,
|
||||
{
|
||||
fallbackLanguage: "plaintext",
|
||||
transformers: [
|
||||
...(rehypeCodeDefaultOptions.transformers ??
|
||||
[]),
|
||||
transformerMeta(),
|
||||
],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</DocsBody>
|
||||
);
|
||||
if (ContentWrapper) {
|
||||
return <ContentWrapper>{body}</ContentWrapper>;
|
||||
}
|
||||
return body;
|
||||
})()}
|
||||
</div>
|
||||
</MaybeEarlyAccessGate>
|
||||
</DocsPage>
|
||||
</ShellDocsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side gate hook-up: pages opt in via `earlyAccess: <gate-id>`
|
||||
* frontmatter. Unknown or absent gate ids render children directly so
|
||||
* ungated pages never mount the client-side gate component.
|
||||
*/
|
||||
function MaybeEarlyAccessGate({
|
||||
gate,
|
||||
children,
|
||||
}: {
|
||||
gate?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (!gate || !getEarlyAccessGate(gate)) return <>{children}</>;
|
||||
return <EarlyAccessGate gate={gate}>{children}</EarlyAccessGate>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Step as FumadocsStep,
|
||||
Steps as FumadocsSteps,
|
||||
} from "fumadocs-ui/components/steps";
|
||||
|
||||
export function Steps({ children }: { children: React.ReactNode }) {
|
||||
return <FumadocsSteps>{children}</FumadocsSteps>;
|
||||
}
|
||||
|
||||
interface StepProps {
|
||||
title?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Step({ title, children }: StepProps) {
|
||||
return (
|
||||
<FumadocsStep>
|
||||
{title && <h4 className="docs-step-title">{title}</h4>}
|
||||
{children}
|
||||
</FumadocsStep>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// <Tabs>/<Tab> — thin wrappers around Fumadocs's built-in tabs.
|
||||
//
|
||||
// Why a wrapper instead of re-exporting Fumadocs directly:
|
||||
//
|
||||
// - Fumadocs's <Tabs items={[...]}> escapes each item via
|
||||
// `value.toLowerCase().replace(/\s/, "-")` to derive radix-tabs
|
||||
// `value` keys. Our authored MDX uses the literal label as the
|
||||
// <Tab value="...">, e.g. `<Tab value="JavaScript">` against
|
||||
// `<Tabs items={["JavaScript", "Python"]}>`. Fumadocs's Tab
|
||||
// component applies this same escapeValue internally, so we pass
|
||||
// the raw label — pre-escaping would double-escape multi-word
|
||||
// values and break the trigger↔content match for labels like
|
||||
// "JSON Configuration File" (two spaces → only first replaced →
|
||||
// residual space in trigger but not in double-escaped content).
|
||||
//
|
||||
// - We accept `groupId` / `persist` / `default` props that the legacy
|
||||
// custom-Tabs MDX content was written against, so existing pages
|
||||
// don't need to be rewritten. `groupId` and `persist` are accepted
|
||||
// and currently ignored (Fumadocs's built-in tabs don't persist
|
||||
// cross-page state in this configuration); `default` is mapped to
|
||||
// Fumadocs's `defaultValue`.
|
||||
//
|
||||
// All other props (className, etc.) forward through to Fumadocs.
|
||||
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Tabs as FumadocsTabs,
|
||||
Tab as FumadocsTab,
|
||||
} from "fumadocs-ui/components/tabs";
|
||||
import type {
|
||||
TabsProps as FumadocsTabsProps,
|
||||
TabProps as FumadocsTabProps,
|
||||
} from "fumadocs-ui/components/tabs";
|
||||
|
||||
/**
|
||||
* Mirror Fumadocs's internal `escapeValue` — keep this in sync with
|
||||
* `node_modules/fumadocs-ui/dist/components/tabs.js`. Used ONLY for
|
||||
* the `Tabs` defaultValue so the initial-selection value matches the
|
||||
* trigger values Fumadocs generates from `items`. Do NOT apply to
|
||||
* individual `Tab` values — Fumadocs's Tab component calls this
|
||||
* internally; pre-escaping here would double-escape and break the
|
||||
* trigger↔content pairing for multi-word labels.
|
||||
*/
|
||||
function escapeValue(v: string): string {
|
||||
return v.toLowerCase().replace(/\s/, "-");
|
||||
}
|
||||
|
||||
interface ExtendedTabsProps extends Omit<FumadocsTabsProps, "defaultValue"> {
|
||||
/**
|
||||
* Initial active tab label. MDX authors write `default="Python"`
|
||||
* (legacy convention from when this component shimmed fumadocs);
|
||||
* we also accept Fumadocs's `defaultValue`.
|
||||
*/
|
||||
default?: string;
|
||||
defaultValue?: string;
|
||||
/** Accepted for source compat — fumadocs persistent-tab feature. */
|
||||
groupId?: string;
|
||||
persist?: boolean;
|
||||
}
|
||||
|
||||
type TabChildProps = {
|
||||
value?: unknown;
|
||||
title?: unknown;
|
||||
};
|
||||
|
||||
function deriveItemsFromChildren(
|
||||
children: React.ReactNode,
|
||||
): string[] | undefined {
|
||||
const items = React.Children.toArray(children)
|
||||
.map((child) => {
|
||||
if (!React.isValidElement<TabChildProps>(child)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = child.props.value ?? child.props.title;
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
})
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
|
||||
export function Tabs({
|
||||
default: defaultProp,
|
||||
defaultValue,
|
||||
groupId: _groupId,
|
||||
persist: _persist,
|
||||
items: itemsProp,
|
||||
children,
|
||||
...rest
|
||||
}: ExtendedTabsProps) {
|
||||
const items = itemsProp ?? deriveItemsFromChildren(children);
|
||||
const resolvedDefault = defaultValue ?? defaultProp ?? items?.[0];
|
||||
|
||||
return (
|
||||
<FumadocsTabs
|
||||
{...rest}
|
||||
items={items}
|
||||
defaultValue={resolvedDefault ? escapeValue(resolvedDefault) : undefined}
|
||||
>
|
||||
{children}
|
||||
</FumadocsTabs>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExtendedTabProps extends FumadocsTabProps {
|
||||
/** Legacy MDX prop — mirrors `value`. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function Tab({ value, title, ...rest }: ExtendedTabProps) {
|
||||
// Pass the raw label to FumadocsTab — Fumadocs's Tab component
|
||||
// applies escapeValue internally to match the trigger's derived key.
|
||||
// Pre-escaping here would double-escape and corrupt multi-word labels
|
||||
// (e.g. "JSON Configuration File" → "json-configuration file" after
|
||||
// one pass, then "json-configuration-file" after the second pass,
|
||||
// which no longer matches the trigger's "json-configuration file").
|
||||
const resolved = value ?? title;
|
||||
return <FumadocsTab value={resolved} {...rest} />;
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { TocHeading } from "@/lib/toc";
|
||||
|
||||
export interface DocsTocProps {
|
||||
headings: TocHeading[];
|
||||
}
|
||||
|
||||
interface SvgState {
|
||||
path: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface ThumbState {
|
||||
top: number;
|
||||
height: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
// useLayoutEffect on the server warns; swap to useEffect during SSR.
|
||||
const useIsoLayoutEffect =
|
||||
typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
||||
|
||||
// Right-rail TOC. Hidden below xl (1280px) because the main column
|
||||
// already fills most of the viewport at laptop widths.
|
||||
//
|
||||
// This is a 1:1 port of fumadocs-ui's "clerk" TOC variant
|
||||
// (@fumadocs/ui/dist/components/toc/clerk.js): persistent gray vertical
|
||||
// lines per item, diagonal SVG connectors at depth changes, and a
|
||||
// violet thumb that slides behind an SVG mask. The mask is the union
|
||||
// of every item's vertical line segment, so the violet pill paints
|
||||
// only along the line path of the active heading.
|
||||
//
|
||||
// The active line follows the shell accent token while inactive
|
||||
// connectors use the shared border token so the TOC tracks theme changes.
|
||||
function getItemOffset(depth: number): number {
|
||||
if (depth <= 2) return 14;
|
||||
if (depth === 3) return 26;
|
||||
return 36;
|
||||
}
|
||||
|
||||
function getLineOffset(depth: number): number {
|
||||
return depth >= 3 ? 10 : 0;
|
||||
}
|
||||
|
||||
export function DocsToc({ headings }: DocsTocProps) {
|
||||
const [activeSlug, setActiveSlug] = useState<string | null>(
|
||||
headings[0]?.slug ?? null,
|
||||
);
|
||||
const [svg, setSvg] = useState<SvgState | null>(null);
|
||||
const [thumb, setThumb] = useState<ThumbState>({
|
||||
top: 0,
|
||||
height: 0,
|
||||
visible: false,
|
||||
});
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const linkRefs = useRef<Map<string, HTMLAnchorElement>>(new Map());
|
||||
|
||||
// Scroll-spy: pick the last heading whose top has crossed the
|
||||
// trigger line (~25% from viewport top), with an explicit
|
||||
// scrollMax override so the last heading is active when the user
|
||||
// hits the bottom of the page. IntersectionObserver alone doesn't
|
||||
// cover this case — once the last heading has scrolled past the
|
||||
// trigger band, no entry fires "intersecting" so the previous
|
||||
// active stays selected even though the user has clearly arrived
|
||||
// at the last section.
|
||||
useEffect(() => {
|
||||
if (headings.length === 0) return;
|
||||
|
||||
// The actual scroll happens on `.docs-content-wrapper` (canonical
|
||||
// page architecture: body has `overflow: hidden`). Fall back to
|
||||
// window for safety on routes that don't wrap content this way.
|
||||
const scrollEl =
|
||||
document.querySelector<HTMLElement>(".docs-content-wrapper") ?? null;
|
||||
|
||||
const update = () => {
|
||||
const triggerY = window.innerHeight * 0.25;
|
||||
let activeIdx = 0;
|
||||
for (let i = 0; i < headings.length; i++) {
|
||||
const el = document.getElementById(headings[i].slug);
|
||||
if (!el) continue;
|
||||
if (el.getBoundingClientRect().top <= triggerY) {
|
||||
activeIdx = i;
|
||||
}
|
||||
}
|
||||
if (scrollEl) {
|
||||
const atBottom =
|
||||
scrollEl.scrollTop + scrollEl.clientHeight >=
|
||||
scrollEl.scrollHeight - 4;
|
||||
if (atBottom) activeIdx = headings.length - 1;
|
||||
} else {
|
||||
const docEl = document.documentElement;
|
||||
const atBottom =
|
||||
window.scrollY + window.innerHeight >= docEl.scrollHeight - 4;
|
||||
if (atBottom) activeIdx = headings.length - 1;
|
||||
}
|
||||
setActiveSlug(headings[activeIdx]?.slug ?? null);
|
||||
};
|
||||
|
||||
update();
|
||||
const target: HTMLElement | Window = scrollEl ?? window;
|
||||
target.addEventListener("scroll", update, { passive: true });
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
target.removeEventListener("scroll", update);
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [headings]);
|
||||
|
||||
// Build the SVG mask path by tracing each item's vertical line
|
||||
// segment. Mirrors clerk.js `onResize`.
|
||||
useIsoLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const onResize = () => {
|
||||
if (container.clientHeight === 0) return;
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
const d: string[] = [];
|
||||
for (let i = 0; i < headings.length; i++) {
|
||||
const element = linkRefs.current.get(headings[i].slug);
|
||||
if (!element) continue;
|
||||
const styles = getComputedStyle(element);
|
||||
const offset = getLineOffset(headings[i].depth) + 1;
|
||||
const top = element.offsetTop + parseFloat(styles.paddingTop);
|
||||
const bottom =
|
||||
element.offsetTop +
|
||||
element.clientHeight -
|
||||
parseFloat(styles.paddingBottom);
|
||||
w = Math.max(offset, w);
|
||||
h = Math.max(h, bottom);
|
||||
d.push(`${i === 0 ? "M" : "L"}${offset} ${top}`);
|
||||
d.push(`L${offset} ${bottom}`);
|
||||
}
|
||||
setSvg({ path: d.join(" "), width: w + 1, height: h });
|
||||
};
|
||||
|
||||
onResize();
|
||||
const observer = new ResizeObserver(onResize);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [headings]);
|
||||
|
||||
// Track the active item's geometry so the thumb (the violet pill
|
||||
// behind the mask) animates between segments.
|
||||
useIsoLayoutEffect(() => {
|
||||
if (!activeSlug) {
|
||||
setThumb((s) => ({ ...s, visible: false }));
|
||||
return;
|
||||
}
|
||||
const container = containerRef.current;
|
||||
const link = linkRefs.current.get(activeSlug);
|
||||
if (!container || !link) return;
|
||||
|
||||
const measure = () => {
|
||||
setThumb({
|
||||
top: link.offsetTop,
|
||||
height: link.clientHeight,
|
||||
visible: true,
|
||||
});
|
||||
};
|
||||
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(container);
|
||||
if (typeof document !== "undefined" && document.fonts?.ready) {
|
||||
document.fonts.ready.then(measure).catch(() => {});
|
||||
}
|
||||
return () => ro.disconnect();
|
||||
}, [activeSlug, headings]);
|
||||
|
||||
if (headings.length === 0) return null;
|
||||
|
||||
const maskUrl = svg
|
||||
? `url("data:image/svg+xml,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svg.width} ${svg.height}"><path d="${svg.path}" stroke="black" stroke-width="1" fill="none"/></svg>`,
|
||||
)}")`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<aside className="hidden xl:block w-[220px] shrink-0 sticky top-0 self-start max-h-[calc(100vh-100px)] overflow-y-auto py-8 pl-6 pr-4">
|
||||
<div className="text-[13px] font-medium text-[var(--text-secondary)] mb-3">
|
||||
On this page
|
||||
</div>
|
||||
<div className="relative">
|
||||
{svg && maskUrl && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-0 top-0"
|
||||
style={{
|
||||
width: svg.width,
|
||||
height: svg.height,
|
||||
WebkitMaskImage: maskUrl,
|
||||
maskImage: maskUrl,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="absolute w-full bg-[var(--accent)]"
|
||||
style={{
|
||||
top: thumb.top,
|
||||
height: thumb.height,
|
||||
opacity: thumb.visible ? 1 : 0,
|
||||
transition:
|
||||
"top 220ms cubic-bezier(0.4,0,0.2,1), height 220ms cubic-bezier(0.4,0,0.2,1), opacity 120ms ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex flex-col text-[13px] leading-[1.55]"
|
||||
>
|
||||
{headings.map((h, i) => {
|
||||
const isActive = activeSlug === h.slug;
|
||||
const upperDepth = headings[i - 1]?.depth ?? h.depth;
|
||||
const lowerDepth = headings[i + 1]?.depth ?? h.depth;
|
||||
const offset = getLineOffset(h.depth);
|
||||
const upperOffset = getLineOffset(upperDepth);
|
||||
const lowerOffset = getLineOffset(lowerDepth);
|
||||
const padStart = getItemOffset(h.depth);
|
||||
const lineTopAdjust = offset !== upperOffset;
|
||||
const lineBottomAdjust = offset !== lowerOffset;
|
||||
|
||||
return (
|
||||
<a
|
||||
key={h.slug}
|
||||
ref={(el) => {
|
||||
if (el) linkRefs.current.set(h.slug, el);
|
||||
else linkRefs.current.delete(h.slug);
|
||||
}}
|
||||
href={`#${h.slug}`}
|
||||
data-active={isActive}
|
||||
onClick={() => setActiveSlug(h.slug)}
|
||||
className={`relative py-1.5 transition-colors ${
|
||||
isActive ? "text-[var(--accent)]" : "text-[var(--text-muted)]"
|
||||
}`}
|
||||
style={{
|
||||
paddingInlineStart: padStart,
|
||||
}}
|
||||
>
|
||||
{/* Diagonal connector when this item's depth differs
|
||||
* from the previous sibling's. */}
|
||||
{offset !== upperOffset && (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
className="absolute -top-1.5 left-0 size-4"
|
||||
>
|
||||
<line
|
||||
x1={upperOffset}
|
||||
y1="0"
|
||||
x2={offset}
|
||||
y2="12"
|
||||
className="stroke-[var(--border)]"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{/* Faint vertical line. Trimmed at top/bottom edges
|
||||
* when the depth changes, so the diagonal connector
|
||||
* meets it cleanly. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute w-px bg-[var(--border)]"
|
||||
style={{
|
||||
insetInlineStart: offset,
|
||||
top: lineTopAdjust ? 6 : 0,
|
||||
bottom: lineBottomAdjust ? 6 : 0,
|
||||
}}
|
||||
/>
|
||||
{h.text}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
// EarlyAccessGate — client-side soft gate for early-access docs.
|
||||
//
|
||||
// Renders the gated page content blurred and non-interactive with an
|
||||
// unlock card floating above it. The card lives in a sticky,
|
||||
// viewport-height frame inside an absolute overlay, so it stays
|
||||
// centered in view while the blurred page scrolls underneath — without
|
||||
// covering the sidebar or top nav the way a true fixed modal would.
|
||||
//
|
||||
// Unlock state persists in localStorage (per gate id), so entering the
|
||||
// password once unlocks every page behind the same gate.
|
||||
|
||||
import React, { useEffect, useId, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { Lock } from "lucide-react";
|
||||
import { getEarlyAccessGate } from "@/lib/early-access";
|
||||
import type { EarlyAccessGateConfig } from "@/lib/early-access";
|
||||
|
||||
const UNLOCKED_VALUE = "unlocked";
|
||||
|
||||
function readUnlocked(storageKey: string): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey) === UNLOCKED_VALUE;
|
||||
} catch {
|
||||
// Storage unavailable (private mode, blocked) — treat as locked;
|
||||
// a correct password still unlocks for the current page view.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function persistUnlocked(storageKey: string): void {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, UNLOCKED_VALUE);
|
||||
} catch {
|
||||
// Best effort — the in-memory unlock still applies.
|
||||
}
|
||||
}
|
||||
|
||||
export function EarlyAccessGate({
|
||||
gate,
|
||||
children,
|
||||
}: {
|
||||
gate: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const config = getEarlyAccessGate(gate);
|
||||
if (!config) return <>{children}</>;
|
||||
return <GateShell config={config}>{children}</GateShell>;
|
||||
}
|
||||
|
||||
function GateShell({
|
||||
config,
|
||||
children,
|
||||
}: {
|
||||
config: EarlyAccessGateConfig;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// null = not yet hydrated. The server (and first client render)
|
||||
// always paint the locked state so hydration matches; the effect
|
||||
// then either reveals the content or pops the unlock card in.
|
||||
const [unlocked, setUnlocked] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setUnlocked(readUnlocked(config.storageKey));
|
||||
}, [config.storageKey]);
|
||||
|
||||
const locked = unlocked !== true;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
inert={locked}
|
||||
aria-hidden={locked}
|
||||
className={`transition-[filter,opacity] duration-500 ease-out ${
|
||||
locked
|
||||
? "pointer-events-none min-h-[75svh] select-none opacity-60 blur-[14px]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{unlocked === false && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10">
|
||||
{/* Sticky scrollport-height frame: pins to the top of the
|
||||
docs scroll container while the gated region scrolls,
|
||||
keeping the card centered in view the whole way down. The
|
||||
height subtracts the fixed nav + banner so the frame (and
|
||||
the card's max-height) tracks the visible content area. */}
|
||||
<div className="sticky top-0 flex h-[calc(100svh-var(--fd-nav-height,64px)-var(--fd-banner-height,0px))] items-center justify-center px-4 sm:px-6">
|
||||
<UnlockCard
|
||||
config={config}
|
||||
onUnlock={() => {
|
||||
persistUnlocked(config.storageKey);
|
||||
setUnlocked(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnlockCard({
|
||||
config,
|
||||
onUnlock,
|
||||
}: {
|
||||
config: EarlyAccessGateConfig;
|
||||
onUnlock: () => void;
|
||||
}) {
|
||||
const titleId = useId();
|
||||
const inputId = useId();
|
||||
const errorId = useId();
|
||||
const [value, setValue] = useState("");
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (value.trim() === config.password) {
|
||||
onUnlock();
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={titleId}
|
||||
className="pointer-events-auto max-h-[calc(100%-2rem)] w-full max-w-[820px] overflow-y-auto rounded-2xl border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--shadow-modal)]"
|
||||
>
|
||||
<div className="flex items-center gap-4 p-6">
|
||||
<div
|
||||
className="flex size-[52px] shrink-0 items-center justify-center rounded-xl bg-[var(--accent-dim)] text-[var(--text)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Lock className="size-[26px]" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-[var(--accent)]">
|
||||
{config.eyebrow}
|
||||
</div>
|
||||
<h2
|
||||
id={titleId}
|
||||
className="mt-1 text-xl font-bold leading-snug tracking-tight text-[var(--text)] md:text-[23px]"
|
||||
>
|
||||
{config.title}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="@container border-t border-[var(--border)] p-6">
|
||||
{/* Two columns once the CARD itself is wide enough (container
|
||||
query, not viewport — at tablet widths the sidebar leaves
|
||||
the card too narrow to split). Copy + form on the left,
|
||||
product preview on the right; on narrow cards the preview
|
||||
is hidden so the form needs no internal scrolling. The
|
||||
preview column stretches to exactly the height of the copy
|
||||
column (default align-items, fill + object-cover image). */}
|
||||
<div className="flex flex-col gap-5 @3xl:flex-row @3xl:gap-6">
|
||||
<div className="min-w-0 flex-1">
|
||||
{config.description.map((paragraph, index) => (
|
||||
<p
|
||||
key={paragraph}
|
||||
className={`text-[15px] leading-relaxed text-[var(--text-secondary)] ${
|
||||
index > 0 ? "mt-3" : ""
|
||||
}`}
|
||||
>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<p className="mt-3 text-[15px] leading-relaxed text-[var(--text-secondary)]">
|
||||
{config.requestPrompt}{" "}
|
||||
<a
|
||||
href={config.requestUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium text-[var(--accent)] underline decoration-[color-mix(in_oklch,var(--accent)_40%,transparent)] underline-offset-[3px] transition-colors hover:decoration-[var(--accent)]"
|
||||
>
|
||||
{config.requestLinkLabel}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-5" noValidate>
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-semibold text-[var(--text)]"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-2 flex flex-col gap-3 sm:flex-row">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Lock
|
||||
className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-muted)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
id={inputId}
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
setValue(event.target.value);
|
||||
if (error) setError(false);
|
||||
}}
|
||||
placeholder="Enter password"
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
aria-invalid={error || undefined}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
className={`h-12 w-full rounded-xl border bg-[var(--bg)] pl-10 pr-3.5 text-[15px] text-[var(--text)] outline-none transition-colors placeholder:text-[var(--text-faint)] focus:ring-2 ${
|
||||
error
|
||||
? "border-[var(--destructive)] focus:border-[var(--destructive)] focus:ring-[color-mix(in_oklch,var(--destructive)_20%,transparent)]"
|
||||
: "border-[var(--border)] focus:border-[var(--accent)] focus:ring-[var(--accent-dim)]"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="h-12 shrink-0 cursor-pointer rounded-xl bg-[var(--accent)] px-6 text-[15px] font-semibold text-[var(--primary-foreground)] transition-colors hover:bg-[var(--accent-strong)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--accent)]"
|
||||
>
|
||||
Unlock
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p
|
||||
id={errorId}
|
||||
role="alert"
|
||||
className="mt-2.5 text-[13px] font-medium text-[var(--destructive)]"
|
||||
>
|
||||
That password didn't work — double-check it and try
|
||||
again.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{config.image && (
|
||||
<div className="relative hidden overflow-hidden rounded-xl border border-[var(--border)] shadow-[var(--shadow-control)] @3xl:block @3xl:w-[300px] @3xl:shrink-0">
|
||||
{/* Theme-paired variants, mirroring the docs' diagram
|
||||
pattern — the light image renders `dark:hidden`, the
|
||||
dark one `dark:block`. */}
|
||||
{/* `object-contain` keeps the screenshot uncropped while
|
||||
the stretched container tracks the copy column's
|
||||
height; each img's background matches its variant's
|
||||
own canvas color so the letterboxed area blends in
|
||||
and the content keeps even padding on every side. */}
|
||||
<Image
|
||||
src={config.image.lightSrc}
|
||||
alt={config.image.alt}
|
||||
fill
|
||||
sizes="300px"
|
||||
className="bg-white object-contain dark:hidden"
|
||||
/>
|
||||
<Image
|
||||
src={config.image.darkSrc}
|
||||
alt={config.image.alt}
|
||||
fill
|
||||
sizes="300px"
|
||||
className="hidden bg-[#1c1f24] object-contain dark:block"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
// FrameworkProvider — tracks the currently "active" agentic backend.
|
||||
//
|
||||
// Three fields, three different jobs:
|
||||
//
|
||||
// - `framework` — STRICTLY URL-derived. Non-null only on `/<framework>/...`
|
||||
// routes. Use this when chrome legitimately needs to know "is the URL
|
||||
// scoped to a framework?" (e.g. a banner or selector state that only
|
||||
// applies when the URL is genuinely scoped).
|
||||
//
|
||||
// - `storedFramework` — last REMEMBERED choice from localStorage. Use to
|
||||
// mark the user's last pick in a picker UI (e.g. ring around their card)
|
||||
// without treating it as the active selection.
|
||||
//
|
||||
// - `effectiveFramework` — what the page renders as. Falls back through
|
||||
// URL → DEFAULT_FRAMEWORK so it's never null. This is the
|
||||
// field every snippet renderer, sidebar link, and "Continue with X"
|
||||
// pointer should read. Treating no-choice as Built-in Agent removes
|
||||
// the dead-end where fresh visitors saw a forced picker before any
|
||||
// working code.
|
||||
//
|
||||
// Whenever the URL asserts a framework, we persist it as the new
|
||||
// storedFramework so the preference carries.
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { backendFromPathname } from "@/lib/frontend-options";
|
||||
|
||||
export interface FrameworkContextValue {
|
||||
/**
|
||||
* URL-derived framework. Non-null only on `/<framework>/...` routes.
|
||||
* Use only when chrome needs to know "is the URL scoped to a
|
||||
* framework?" (e.g. redirect logic). For rendering content, prefer
|
||||
* `effectiveFramework`.
|
||||
*/
|
||||
framework: string | null;
|
||||
/**
|
||||
* Last remembered framework from localStorage — advisory only. Use to
|
||||
* mark the user's last pick in a picker UI without implying the
|
||||
* current view is scoped to it.
|
||||
*/
|
||||
storedFramework: string | null;
|
||||
/**
|
||||
* Framework the page should render as — never null. Falls through
|
||||
* URL → DEFAULT_FRAMEWORK. The stored preference is advisory only so
|
||||
* frameworkless root pages keep CopilotKit/Built-in Agent chrome.
|
||||
*/
|
||||
effectiveFramework: string;
|
||||
/** All known framework slugs derived from the registry. */
|
||||
knownFrameworks: string[];
|
||||
/** Persist a new framework preference (does not navigate). */
|
||||
setStoredFramework: (slug: string | null) => void;
|
||||
}
|
||||
|
||||
const FrameworkContext = createContext<FrameworkContextValue | null>(null);
|
||||
|
||||
const STORAGE_KEY = "selectedFramework";
|
||||
|
||||
/**
|
||||
* Built-in Agent is the default integration: zero config, runs in-process
|
||||
* via the Next.js runtime, no external agent server. Treating fresh
|
||||
* visitors as if they'd picked it removes the forced-picker dead end and
|
||||
* gives them working code on first paint.
|
||||
*
|
||||
* Its docs are served at the ROOT surface (no `/built-in-agent/` URL
|
||||
* prefix). Mirrors ROOT_FRAMEWORK in `lib/registry.ts`, which client
|
||||
* modules must not import (it would pull registry.json into the bundle).
|
||||
*/
|
||||
export const DEFAULT_FRAMEWORK = "built-in-agent";
|
||||
|
||||
// Log each failure mode once per session so we don't spam the console
|
||||
// on repeated reads/writes when localStorage is unavailable (SSR, private
|
||||
// mode, storage disabled), but we still surface the failure to devs the
|
||||
// first time it happens.
|
||||
let readLogged = false;
|
||||
let writeLogged = false;
|
||||
|
||||
function readStoredFramework(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY);
|
||||
} catch (err) {
|
||||
if (!readLogged) {
|
||||
console.warn("[framework-provider] localStorage read failed", err);
|
||||
readLogged = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredFramework(slug: string | null) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
if (slug === null) {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
} else {
|
||||
window.localStorage.setItem(STORAGE_KEY, slug);
|
||||
}
|
||||
} catch (err) {
|
||||
// localStorage may be unavailable (SSR, private mode, etc.) — log
|
||||
// once per session so the failure isn't completely silent.
|
||||
if (!writeLogged) {
|
||||
console.warn("[framework-provider] localStorage write failed", err);
|
||||
writeLogged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function FrameworkProvider({
|
||||
children,
|
||||
knownFrameworks,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
knownFrameworks: string[];
|
||||
}) {
|
||||
const pathname = usePathname() ?? "";
|
||||
const urlFramework = useMemo(() => {
|
||||
return backendFromPathname(pathname, knownFrameworks);
|
||||
}, [pathname, knownFrameworks]);
|
||||
|
||||
const [stored, setStored] = useState<string | null>(null);
|
||||
|
||||
// Hydrate stored framework on client mount
|
||||
useEffect(() => {
|
||||
setStored(readStoredFramework());
|
||||
}, []);
|
||||
|
||||
// Whenever the URL asserts a framework, persist it so the preference
|
||||
// follows the user when they navigate back to /docs/*.
|
||||
//
|
||||
// `stored` is intentionally NOT a dep: including it causes this effect
|
||||
// to re-run every time we update stored from within (infinite-ish
|
||||
// ping-pong with the state setter below). We only care about URL
|
||||
// changes as the trigger. The internal `!== stored` check short-circuits
|
||||
// the no-op case using the latest closed-over value.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
if (urlFramework && urlFramework !== stored) {
|
||||
writeStoredFramework(urlFramework);
|
||||
setStored(urlFramework);
|
||||
}
|
||||
}, [urlFramework]);
|
||||
|
||||
// ACTIVE framework is strictly URL-derived. localStorage NEVER promotes
|
||||
// itself into `framework` — it lives in `storedFramework` where it can
|
||||
// be shown as "your last pick" without implying the current view is
|
||||
// scoped to it.
|
||||
const framework = urlFramework;
|
||||
|
||||
// Effective framework falls through URL → default. localStorage is only
|
||||
// the remembered preference; promoting it here makes root BIA pages show
|
||||
// the wrong selected backend after a user previously viewed another
|
||||
// framework.
|
||||
const effectiveFramework = framework ?? DEFAULT_FRAMEWORK;
|
||||
|
||||
const setStoredFramework = (slug: string | null) => {
|
||||
// Validate the slug against the known registry. Callers passing a
|
||||
// slug we don't recognise would poison the stored preference (e.g.
|
||||
// leaking a route segment like "docs"). Drop + warn instead.
|
||||
if (slug !== null && !knownFrameworks.includes(slug)) {
|
||||
console.warn(
|
||||
`[framework-provider] setStoredFramework called with unknown slug "${slug}" — ignoring`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
writeStoredFramework(slug);
|
||||
setStored(slug);
|
||||
};
|
||||
|
||||
const value: FrameworkContextValue = {
|
||||
framework,
|
||||
storedFramework: stored,
|
||||
effectiveFramework,
|
||||
knownFrameworks,
|
||||
setStoredFramework,
|
||||
};
|
||||
|
||||
return (
|
||||
<FrameworkContext.Provider value={value}>
|
||||
{children}
|
||||
</FrameworkContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFramework(): FrameworkContextValue {
|
||||
const ctx = useContext(FrameworkContext);
|
||||
if (!ctx) {
|
||||
// Fail loudly: a silent fallback masks wiring bugs (components
|
||||
// rendered outside the provider tree would silently report "no
|
||||
// framework" forever). Matches every other context pattern in the
|
||||
// app.
|
||||
throw new Error("useFramework must be used within FrameworkProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
"use client";
|
||||
|
||||
// FrameworkSelector - persistent docs selector. The sidebar variant
|
||||
// exposes frontend and agent backend as separate, simple dropdowns.
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { DEFAULT_FRAMEWORK, useFramework } from "./framework-provider";
|
||||
import { FrontendLogo } from "./frontend-logo";
|
||||
import { FrameworkLogo } from "./icons/framework-icons";
|
||||
import { compareByDisplayOrder } from "@/lib/framework-order";
|
||||
import {
|
||||
FRONTEND_OPTIONS,
|
||||
backendPathForCurrentPath,
|
||||
frontendFromPathname,
|
||||
frontendPathForCurrentPath,
|
||||
isFrontendEarlyAccess,
|
||||
} from "@/lib/frontend-options";
|
||||
import type { FrontendId } from "@/lib/frontend-options";
|
||||
|
||||
export interface FrameworkOption {
|
||||
slug: string;
|
||||
name: string;
|
||||
category: string;
|
||||
logo?: string | null;
|
||||
deployed: boolean;
|
||||
}
|
||||
|
||||
export interface FrameworkSelectorProps {
|
||||
options: FrameworkOption[];
|
||||
/**
|
||||
* Ordered category ids (from the registry) used to group entries in the
|
||||
* dropdown panel. Unknown categories fall through to "Other".
|
||||
*/
|
||||
categoryOrder: { id: string; name: string }[];
|
||||
/** Extra wrapper class (positioning). */
|
||||
className?: string;
|
||||
/**
|
||||
* Presentation flavor.
|
||||
* - `topbar` (default, legacy): compact pill sized for a horizontal bar.
|
||||
* - `sidebar`: two full-width selector rows for frontend and backend.
|
||||
*/
|
||||
variant?: "topbar" | "sidebar";
|
||||
}
|
||||
|
||||
function SelectorAffordance({ active }: { active: boolean }) {
|
||||
return (
|
||||
<span className="ml-1 flex shrink-0 items-center" aria-hidden="true">
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 transition-colors ${
|
||||
active
|
||||
? "text-[var(--accent)]"
|
||||
: "text-[var(--text-muted)] group-hover:text-[var(--accent)]"
|
||||
}`}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FrontendEarlyAccessBadge() {
|
||||
return (
|
||||
<span className="shell-docs-radius-control inline-flex shrink-0 self-center border border-[var(--border)] bg-[var(--bg-elevated)] px-1 py-0 text-[8px] font-semibold leading-[10px] text-[var(--text-muted)]">
|
||||
Early access
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function FrameworkSelector({
|
||||
options,
|
||||
categoryOrder: _categoryOrder,
|
||||
className,
|
||||
variant = "topbar",
|
||||
}: FrameworkSelectorProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname() ?? "";
|
||||
const posthog = usePostHog();
|
||||
const { effectiveFramework, setStoredFramework } = useFramework();
|
||||
const urlFrontend = frontendFromPathname(pathname);
|
||||
const [openMenu, setOpenMenu] = useState<"frontend" | "backend" | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on outside-click / Escape.
|
||||
useEffect(() => {
|
||||
if (!openMenu) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
const target = e.target instanceof Node ? e.target : null;
|
||||
if (!target) return;
|
||||
if (rootRef.current?.contains(target)) return;
|
||||
setOpenMenu(null);
|
||||
};
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpenMenu(null);
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClick);
|
||||
document.removeEventListener("keydown", handleKey);
|
||||
};
|
||||
}, [openMenu]);
|
||||
|
||||
// Display whatever the page is currently rendering as: URL framework
|
||||
// when present, then stored choice, then the soft-default.
|
||||
const current = options.find((o) => o.slug === effectiveFramework);
|
||||
|
||||
const isSidebar = variant === "sidebar";
|
||||
const displayNameFor = (opt: FrameworkOption) =>
|
||||
isSidebar && opt.slug === "built-in-agent" ? "CopilotKit" : opt.name;
|
||||
const label = current ? displayNameFor(current) : "Pick an agentic backend";
|
||||
const effectiveFrontendId = urlFrontend ?? "react";
|
||||
const selectedFrontend =
|
||||
FRONTEND_OPTIONS.find((option) => option.id === effectiveFrontendId) ??
|
||||
FRONTEND_OPTIONS[0];
|
||||
|
||||
function selectFrontend(id: FrontendId) {
|
||||
if (id !== effectiveFrontendId) {
|
||||
router.replace(
|
||||
frontendPathForCurrentPath(
|
||||
id,
|
||||
pathname,
|
||||
options.map((option) => option.slug),
|
||||
),
|
||||
);
|
||||
}
|
||||
setOpenMenu(null);
|
||||
}
|
||||
|
||||
function selectFramework(slug: string) {
|
||||
setStoredFramework(slug);
|
||||
try {
|
||||
const opt = options.find((o) => o.slug === slug);
|
||||
posthog?.capture("docs.framework_selected", {
|
||||
framework: slug,
|
||||
framework_name: opt?.name ?? slug,
|
||||
category: opt?.category,
|
||||
from_path: pathname,
|
||||
});
|
||||
} catch {
|
||||
// Swallow - analytics is fire-and-forget.
|
||||
}
|
||||
router.replace(
|
||||
backendPathForCurrentPath(
|
||||
slug,
|
||||
pathname,
|
||||
options.map((option) => option.slug),
|
||||
DEFAULT_FRAMEWORK,
|
||||
),
|
||||
);
|
||||
setOpenMenu(null);
|
||||
}
|
||||
|
||||
// Single flat list, ordered by the canonical display order. The
|
||||
// category buckets ("Most Popular / Agent Frameworks / Intelligence Platform /
|
||||
// Emerging") used to live here but partners read them as a tier
|
||||
// list — we now show every backend in one neutral list.
|
||||
const flatOptions = options
|
||||
.filter((opt) => !(isSidebar && opt.slug === "built-in-agent"))
|
||||
.slice()
|
||||
.sort((a, b) => compareByDisplayOrder(a.slug, b.slug));
|
||||
|
||||
const pinnedBIA = isSidebar
|
||||
? (options.find((o) => o.slug === "built-in-agent") ?? null)
|
||||
: null;
|
||||
|
||||
const topbarBtnClasses =
|
||||
"shell-docs-radius-control flex items-center gap-1.5 px-2.5 py-1.5 border border-[var(--border)] bg-[var(--bg-surface)] text-[12px] font-medium text-[var(--text)] hover:border-[var(--accent)] transition-colors cursor-pointer max-w-[220px]";
|
||||
|
||||
const backendOptions = (
|
||||
includePinnedBIA: boolean,
|
||||
optionHoverClass: string,
|
||||
) => (
|
||||
<>
|
||||
{includePinnedBIA && pinnedBIA && (
|
||||
<button
|
||||
key={pinnedBIA.slug}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={pinnedBIA.slug === effectiveFramework}
|
||||
onClick={() => selectFramework(pinnedBIA.slug)}
|
||||
className={`shell-docs-radius-control flex w-full cursor-pointer items-center gap-2 px-2 py-1.5 text-[13px] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] ${
|
||||
pinnedBIA.slug === effectiveFramework
|
||||
? "bg-[var(--accent-dim)] text-[var(--accent)]"
|
||||
: `text-[var(--text-secondary)] ${optionHoverClass}`
|
||||
}`}
|
||||
>
|
||||
<FrameworkLogo
|
||||
slug={pinnedBIA.slug}
|
||||
fallbackSrc={pinnedBIA.logo}
|
||||
size={16}
|
||||
className="shrink-0 text-[var(--accent)]"
|
||||
/>
|
||||
<span className="flex-1 truncate text-left">
|
||||
{displayNameFor(pinnedBIA)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{flatOptions.map((opt) => {
|
||||
const isActive = opt.slug === effectiveFramework;
|
||||
return (
|
||||
<button
|
||||
key={opt.slug}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onClick={() => selectFramework(opt.slug)}
|
||||
className={`shell-docs-radius-control flex w-full cursor-pointer items-center gap-2 px-2 py-1.5 text-[13px] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] ${
|
||||
isActive
|
||||
? "bg-[var(--accent-dim)] text-[var(--accent)]"
|
||||
: `text-[var(--text-secondary)] ${optionHoverClass}`
|
||||
}`}
|
||||
>
|
||||
<FrameworkLogo
|
||||
slug={opt.slug}
|
||||
fallbackSrc={opt.logo}
|
||||
size={16}
|
||||
className="shrink-0 text-[var(--accent)]"
|
||||
/>
|
||||
<span className="flex-1 truncate text-left">
|
||||
{displayNameFor(opt)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`relative ${openMenu ? "z-50" : ""} ${className ?? ""}`}
|
||||
>
|
||||
{isSidebar ? (
|
||||
<>
|
||||
<div className="shell-docs-picker-group shell-docs-picker-group-selected shell-docs-picker-group-bordered space-y-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setOpenMenu((menu) => (menu === "frontend" ? null : "frontend"))
|
||||
}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={openMenu === "frontend"}
|
||||
aria-label="Choose frontend"
|
||||
className="shell-docs-picker-row group flex min-h-[52px] w-full cursor-pointer items-center gap-2.5 px-2 py-1.5 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
>
|
||||
<span
|
||||
className="shell-docs-picker-icon-chip flex h-8 w-8 shrink-0 items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<FrontendLogo icon={selectedFrontend.icon} size={19} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[11px] font-medium leading-tight text-[var(--text-muted)]">
|
||||
Frontend
|
||||
</span>
|
||||
<span className="mt-0.5 flex min-w-0 items-center gap-2 text-[13px] font-semibold leading-tight text-[var(--text)]">
|
||||
<span className="truncate">{selectedFrontend.name}</span>
|
||||
{isFrontendEarlyAccess(selectedFrontend.id) && (
|
||||
<FrontendEarlyAccessBadge />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<SelectorAffordance active={openMenu === "frontend"} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setOpenMenu((menu) => (menu === "backend" ? null : "backend"))
|
||||
}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={openMenu === "backend"}
|
||||
aria-label="Choose agent backend"
|
||||
className="shell-docs-picker-row shell-docs-picker-row-divided group flex min-h-[52px] w-full cursor-pointer items-center gap-2.5 px-2 py-1.5 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
>
|
||||
<span
|
||||
className="shell-docs-picker-icon-chip flex h-8 w-8 shrink-0 items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{current ? (
|
||||
<FrameworkLogo
|
||||
slug={current.slug}
|
||||
fallbackSrc={current.logo}
|
||||
size={17}
|
||||
className="text-[var(--accent)]"
|
||||
/>
|
||||
) : (
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[var(--accent)] opacity-70" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[11px] font-medium leading-tight text-[var(--text-muted)]">
|
||||
Agent backend
|
||||
</span>
|
||||
{current ? (
|
||||
<span className="mt-0.5 block truncate text-[13px] font-semibold leading-tight text-[var(--text)]">
|
||||
{label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="mt-0.5 block truncate text-[13px] font-medium leading-tight text-[var(--text-muted)]">
|
||||
No backend selected
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<SelectorAffordance active={openMenu === "backend"} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{openMenu === "frontend" && (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Choose frontend"
|
||||
className="shell-docs-radius-surface shell-docs-picker-menu absolute left-0 right-0 top-full z-50 mt-1 border border-[var(--border)] bg-[var(--bg-surface)] p-2"
|
||||
>
|
||||
{FRONTEND_OPTIONS.map((option) => {
|
||||
const isActive = option.id === selectedFrontend.id;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onClick={() => selectFrontend(option.id)}
|
||||
className={`shell-docs-radius-control flex w-full cursor-pointer items-center gap-2 px-2 py-1.5 text-[13px] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] ${
|
||||
isActive
|
||||
? "bg-[var(--accent-dim)] text-[var(--accent)]"
|
||||
: "text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text)]"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="shell-docs-picker-icon-chip flex h-7 w-7 shrink-0 items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<FrontendLogo icon={option.icon} size={17} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2 text-left font-medium">
|
||||
<span className="truncate">{option.name}</span>
|
||||
{isFrontendEarlyAccess(option.id) && (
|
||||
<FrontendEarlyAccessBadge />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openMenu === "backend" && (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Choose agent backend"
|
||||
className="shell-docs-radius-surface shell-docs-picker-menu absolute left-0 top-full z-50 mt-1 max-h-[60vh] w-[320px] max-w-[calc(100vw-2rem)] overflow-y-auto border border-[var(--border)] bg-[var(--bg-surface)] p-2"
|
||||
>
|
||||
{backendOptions(
|
||||
true,
|
||||
"hover:bg-[var(--bg-elevated)] hover:text-[var(--text)]",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setOpenMenu((menu) => (menu === "backend" ? null : "backend"))
|
||||
}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={openMenu === "backend"}
|
||||
className={topbarBtnClasses}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--accent)]" />
|
||||
<span className="truncate">
|
||||
{current ? (
|
||||
<>
|
||||
<span className="mr-1 font-mono text-[10px] uppercase tracking-wider text-[var(--text-faint)]">
|
||||
Backend
|
||||
</span>
|
||||
{label}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[var(--text-muted)]">{label}</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="h-3 w-3 shrink-0 text-[var(--text-muted)]"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{openMenu === "backend" && (
|
||||
<div
|
||||
role="listbox"
|
||||
className="shell-docs-radius-surface shell-docs-picker-menu absolute left-0 top-full z-50 mt-1 max-h-[70vh] w-[340px] overflow-y-auto border border-[var(--border)] bg-[var(--bg-surface)] p-2"
|
||||
>
|
||||
{backendOptions(
|
||||
false,
|
||||
"hover:bg-[var(--bg-elevated)] hover:text-[var(--text)]",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// <FrameworkTabs> — tabbed view of the same region rendered against
|
||||
// multiple integration frameworks' cells.
|
||||
//
|
||||
// Usage:
|
||||
// <FrameworkTabs
|
||||
// frameworks={["langgraph-python", "mastra", "crewai-crews"]}
|
||||
// cell="agentic-chat"
|
||||
// region="provider-setup"
|
||||
// />
|
||||
//
|
||||
// Each tab runs <Snippet framework=... cell=... region=...>. When a
|
||||
// framework is missing that region/cell the Snippet's built-in warning
|
||||
// box surfaces inline, so authors get a clear signal to tag the missing
|
||||
// region in the corresponding cell.
|
||||
//
|
||||
// FrameworkTabs is intentionally **client-side** (uses useState for the
|
||||
// active tab). The inner <Snippet> is a server component — Next.js will
|
||||
// transport its rendered HTML across the boundary, which keeps syntax
|
||||
// highlighting sharp and avoids duplicating the highlight.js dependency
|
||||
// in the client bundle.
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
|
||||
interface FrameworkTabsProps {
|
||||
frameworks: string[];
|
||||
cell: string;
|
||||
region: string;
|
||||
/** Render an alternative label for each framework (e.g. pretty names). */
|
||||
labels?: Record<string, string>;
|
||||
/** Pre-rendered <Snippet> content, keyed by framework slug. Populated by
|
||||
* the parent MDX renderer which walks the `frameworks` list and emits
|
||||
* a Snippet per framework on the server side. */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FrameworkTabs({
|
||||
frameworks,
|
||||
labels,
|
||||
children,
|
||||
}: FrameworkTabsProps) {
|
||||
const [active, setActive] = useState<string>(frameworks[0] ?? "");
|
||||
|
||||
// children should be an array of <div data-framework="..."> wrappers.
|
||||
// We filter + render the active one. This keeps all snippets rendered
|
||||
// on the server (good: syntax highlighting) and client only swaps.
|
||||
const wrapped = React.Children.toArray(children);
|
||||
|
||||
const displayLabel = (slug: string) =>
|
||||
labels?.[slug] ??
|
||||
slug
|
||||
.split("-")
|
||||
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div className="shell-docs-radius-surface my-4 mb-5 overflow-hidden border border-[var(--border)] bg-[var(--bg-surface)]">
|
||||
<div
|
||||
role="tablist"
|
||||
className="flex flex-wrap gap-1 border-b border-[var(--border)] bg-[var(--bg-elevated)] px-2 pt-1.5"
|
||||
>
|
||||
{frameworks.map((fw) => {
|
||||
const isActive = fw === active;
|
||||
return (
|
||||
<button
|
||||
key={fw}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => setActive(fw)}
|
||||
className={[
|
||||
"cursor-pointer border-0 border-b-2 px-3.5 py-2 text-[0.8125rem] [border-radius:var(--shell-docs-radius-control)_var(--shell-docs-radius-control)_0_0]",
|
||||
isActive
|
||||
? "border-[var(--accent)] bg-[var(--bg-surface)] font-semibold text-[var(--text)]"
|
||||
: "border-transparent bg-transparent font-medium text-[var(--text-muted)] hover:bg-[var(--accent-dim)] hover:text-[var(--accent)]",
|
||||
].join(" ")}
|
||||
>
|
||||
{displayLabel(fw)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{wrapped.map((child, i) => {
|
||||
const fw = frameworks[i];
|
||||
if (fw !== active) return null;
|
||||
return <React.Fragment key={fw}>{child}</React.Fragment>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { FrontendIcon } from "@/lib/frontend-options";
|
||||
import type React from "react";
|
||||
import { BiLogoMicrosoftTeams } from "react-icons/bi";
|
||||
import { SiAngular, SiReact, SiVuedotjs } from "react-icons/si";
|
||||
import { TbBrandReactNative } from "react-icons/tb";
|
||||
|
||||
type BrandIcon = React.ComponentType<{
|
||||
"aria-hidden"?: boolean;
|
||||
className?: string;
|
||||
color?: string;
|
||||
focusable?: boolean | "false";
|
||||
size?: number | string;
|
||||
title?: string;
|
||||
}>;
|
||||
|
||||
function SlackColorLogo({
|
||||
className,
|
||||
size = 18,
|
||||
}: {
|
||||
"aria-hidden"?: boolean;
|
||||
className?: string;
|
||||
focusable?: boolean | "false";
|
||||
size?: number | string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden={true}
|
||||
className={className}
|
||||
focusable={false}
|
||||
height={size}
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill="#E01E5A"
|
||||
d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52z"
|
||||
/>
|
||||
<path
|
||||
fill="#E01E5A"
|
||||
d="M6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z"
|
||||
/>
|
||||
<path
|
||||
fill="#36C5F0"
|
||||
d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834z"
|
||||
/>
|
||||
<path
|
||||
fill="#36C5F0"
|
||||
d="M8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z"
|
||||
/>
|
||||
<path
|
||||
fill="#2EB67D"
|
||||
d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834z"
|
||||
/>
|
||||
<path
|
||||
fill="#2EB67D"
|
||||
d="M17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312z"
|
||||
/>
|
||||
<path
|
||||
fill="#ECB22E"
|
||||
d="M15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52z"
|
||||
/>
|
||||
<path
|
||||
fill="#ECB22E"
|
||||
d="M15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const FRONTEND_ICONS: Record<FrontendIcon, { Icon: BrandIcon; color: string }> =
|
||||
{
|
||||
react: { Icon: SiReact, color: "#61DAFB" },
|
||||
vue: { Icon: SiVuedotjs, color: "#4FC08D" },
|
||||
"react-native": { Icon: TbBrandReactNative, color: "#61DAFB" },
|
||||
angular: { Icon: SiAngular, color: "#DD0031" },
|
||||
slack: { Icon: SlackColorLogo, color: "currentColor" },
|
||||
teams: { Icon: BiLogoMicrosoftTeams, color: "#6264A7" },
|
||||
};
|
||||
|
||||
export function FrontendLogo({
|
||||
icon,
|
||||
size = 18,
|
||||
className,
|
||||
}: {
|
||||
icon: FrontendIcon;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const { Icon, color } = FRONTEND_ICONS[icon];
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
className={className}
|
||||
color={color}
|
||||
focusable={false}
|
||||
size={size}
|
||||
title=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useFramework } from "./framework-provider";
|
||||
import { FrameworkLogo } from "./icons/framework-icons";
|
||||
|
||||
export type HeroQuickstartOption = {
|
||||
slug: string;
|
||||
name: string;
|
||||
logo?: string | null;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export function HeroQuickstartDropdown({
|
||||
options,
|
||||
}: {
|
||||
options: HeroQuickstartOption[];
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const { setStoredFramework } = useFramework();
|
||||
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const buttonRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
const target = event.target instanceof Node ? event.target : null;
|
||||
if (!target || rootRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative w-full sm:w-fit">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className="shell-docs-radius-control inline-flex h-11 w-full items-center justify-center gap-2 border border-[var(--accent)] bg-[var(--accent)] px-4 text-sm font-semibold text-[var(--primary-foreground)] shadow-[var(--shadow-control)] transition-colors duration-150 hover:bg-[var(--accent-strong)] sm:w-fit"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
Quickstart
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
className="shell-docs-radius-surface absolute left-0 top-[calc(100%+8px)] z-30 w-full min-w-[280px] overflow-hidden border border-[var(--border)] bg-[var(--bg-surface)] p-1.5 shadow-[var(--shadow-panel)] sm:w-[320px]"
|
||||
role="menu"
|
||||
>
|
||||
<div className="max-h-[360px] overflow-y-auto">
|
||||
{options.map((option) => (
|
||||
<Link
|
||||
key={option.slug}
|
||||
href={option.href}
|
||||
role="menuitem"
|
||||
className="shell-docs-radius-control group flex items-center gap-3 px-2.5 py-2.5 no-underline transition-colors hover:bg-[var(--accent-dim)]"
|
||||
onClick={() => {
|
||||
setStoredFramework(option.slug);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="shell-docs-radius-icon flex h-8 w-8 shrink-0 items-center justify-center border border-[var(--border)] bg-[var(--accent-dim)] text-[var(--accent)] transition-colors group-hover:bg-[var(--accent-light)]"
|
||||
>
|
||||
<FrameworkLogo
|
||||
slug={option.slug}
|
||||
fallbackSrc={option.logo ?? undefined}
|
||||
size={17}
|
||||
className="text-[var(--accent)]"
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-semibold text-[var(--text)] transition-colors group-hover:text-[var(--accent)]">
|
||||
{option.name}
|
||||
</span>
|
||||
<span className="block text-xs text-[var(--text-muted)]">
|
||||
Open quickstart
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
|
||||
// <HeroStartActions> — the docs hero's primary call-to-action, shared verbatim
|
||||
// by the home hero and the framework landing heroes so both surfaces read
|
||||
// identically. It keeps the hero focused on the guided Quickstart path, while
|
||||
// making CLI setup available as an optional reveal for users who already know
|
||||
// they want terminal commands:
|
||||
//
|
||||
// • "Start a new project" → npx copilotkit@latest create
|
||||
// • "Add to an existing project" → npx copilotkit@latest skills onboard
|
||||
// • Quickstart → guided docs walkthrough
|
||||
//
|
||||
// Commands live in a compact popover menu anchored to the CLI button, not as
|
||||
// hero content. Each row is a single copy button with an `aria-live` status
|
||||
// sibling so the copy result is announced to assistive tech. Clipboard failures
|
||||
// (insecure context, sandboxed iframe, permissions policy, in-app webview,
|
||||
// older browsers) fall back to the older selection API before reporting a
|
||||
// screen-reader-only failure state.
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { ArrowRight, ChevronDown } from "lucide-react";
|
||||
|
||||
type CopyState = "idle" | "copied" | "error";
|
||||
|
||||
type StartCommand = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
command: string;
|
||||
};
|
||||
|
||||
// `createFramework` is the CLI's own `--framework` value (e.g. "langgraph-js"),
|
||||
// NOT the docs slug — callers translate before passing it in. When set, the
|
||||
// "new project" command pre-selects that framework so the framework landing
|
||||
// pages recommend a ready-to-run command. `skills onboard` takes no framework
|
||||
// flag (it hands the choice to your coding agent), so it's identical everywhere.
|
||||
function buildCommands(createFramework?: string): StartCommand[] {
|
||||
// Pin `@latest` so npx always resolves the newest published CLI instead of
|
||||
// silently reusing a stale cached `copilotkit` from a previous run — matches
|
||||
// the convention used everywhere else in the docs.
|
||||
const createCommand = createFramework
|
||||
? `npx copilotkit@latest create --framework ${createFramework}`
|
||||
: "npx copilotkit@latest create";
|
||||
return [
|
||||
{
|
||||
id: "create",
|
||||
label: "Start from scratch",
|
||||
description: "Start in 5 minutes with one of our curated starters",
|
||||
command: createCommand,
|
||||
},
|
||||
{
|
||||
id: "onboard",
|
||||
label: "Use your existing agent",
|
||||
description:
|
||||
"Start using your agent harness of choice with CopilotKit skills",
|
||||
command: "npx copilotkit@latest skills onboard",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function copyCommandText(command: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
return { copied: true, clipboardBlocked: false };
|
||||
} catch {
|
||||
// Some embedded browsers block the async Clipboard API even on localhost.
|
||||
// Use the older selection path before surfacing failure to the user.
|
||||
}
|
||||
|
||||
if (typeof document === "undefined") {
|
||||
return { copied: false, clipboardBlocked: true };
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = command;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.inset = "0 auto auto 0";
|
||||
textarea.style.opacity = "0";
|
||||
textarea.style.pointerEvents = "none";
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, command.length);
|
||||
|
||||
try {
|
||||
const copied = document.execCommand("copy");
|
||||
return { copied, clipboardBlocked: true };
|
||||
} finally {
|
||||
textarea.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function CommandMenuItem({ id, label, description, command }: StartCommand) {
|
||||
const posthog = usePostHog();
|
||||
const [copyState, setCopyState] = React.useState<CopyState>("idle");
|
||||
const resetTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const copied = copyState === "copied";
|
||||
const errored = copyState === "error";
|
||||
|
||||
const onCopy = async () => {
|
||||
if (resetTimerRef.current) {
|
||||
clearTimeout(resetTimerRef.current);
|
||||
resetTimerRef.current = null;
|
||||
}
|
||||
// Distinguishes WHICH hero card was copied (create vs onboard) — the
|
||||
// global <CopyTracker> patch on navigator.clipboard.writeText already
|
||||
// emits the generic `cli_command_copied` volume event for this same copy,
|
||||
// so this intentionally uses a different event name to avoid
|
||||
// double-counting that funnel. `command` is page content (bounded
|
||||
// cardinality: bare + one variant per CLI framework), not user input.
|
||||
//
|
||||
// `location` mirrors the pathname `cli_command_copied` records, so the two
|
||||
// paired events join on the same dimension. It's the only surface signal
|
||||
// for the `onboard` card, whose command is byte-identical on the home hero
|
||||
// and every framework landing hero (only `create` embeds the framework in
|
||||
// `command`). Guarded for SSR to match the <CopyTracker> sibling, though
|
||||
// onCopy only runs from a browser click.
|
||||
const trackHeroCopy = (clipboardBlocked: boolean) => {
|
||||
try {
|
||||
posthog?.capture("hero_command_copied", {
|
||||
command_id: id,
|
||||
command,
|
||||
clipboard_blocked: clipboardBlocked,
|
||||
location:
|
||||
typeof window !== "undefined"
|
||||
? window.location.pathname
|
||||
: undefined,
|
||||
});
|
||||
} catch {
|
||||
// Never let analytics break the copy interaction.
|
||||
}
|
||||
};
|
||||
const result = await copyCommandText(command);
|
||||
if (result.copied) {
|
||||
setCopyState("copied");
|
||||
trackHeroCopy(result.clipboardBlocked);
|
||||
resetTimerRef.current = setTimeout(() => setCopyState("idle"), 1500);
|
||||
} else {
|
||||
console.warn("[hero-start-commands] clipboard write failed");
|
||||
setCopyState("error");
|
||||
trackHeroCopy(true);
|
||||
resetTimerRef.current = setTimeout(() => setCopyState("idle"), 2500);
|
||||
}
|
||||
};
|
||||
|
||||
const copyAriaLabel = errored
|
||||
? `Copy failed for "${command}" — select the command text manually`
|
||||
: `Copy command: ${command}`;
|
||||
|
||||
let status = "";
|
||||
if (copied) status = `Copied ${command} to clipboard`;
|
||||
else if (errored)
|
||||
status = "Clipboard blocked; select the command text manually";
|
||||
|
||||
return (
|
||||
<div className="min-w-0 px-3.5 py-3.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-semibold leading-snug text-[var(--text)]">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-0.5 max-w-[42ch] text-xs leading-snug text-[var(--text-muted)]">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
aria-label={copyAriaLabel}
|
||||
className={`shell-docs-radius-control group mt-3 flex w-full cursor-pointer items-start gap-3 border px-3 py-2.5 text-left shadow-[var(--shadow-control)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] ${
|
||||
errored
|
||||
? "border-[var(--border)] bg-[var(--bg-elevated)]"
|
||||
: copied
|
||||
? "border-[var(--accent)] bg-[var(--accent-dim)]"
|
||||
: "border-[color-mix(in_oklch,var(--accent)_22%,var(--border))] bg-[var(--bg-elevated)] hover:border-[var(--accent)] hover:bg-[var(--accent-dim)]"
|
||||
}`}
|
||||
>
|
||||
{/* Long commands wrap at spaces only — each token is non-breaking so
|
||||
a flag like `--framework` can never split mid-token. */}
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5 font-mono text-[12.5px] font-semibold leading-[1.45] text-[var(--text)]">
|
||||
<span aria-hidden="true" className="select-none text-[var(--accent)]">
|
||||
$
|
||||
</span>
|
||||
{command.split(" ").map((token, i) => (
|
||||
<span key={`${token}-${i}`} className="whitespace-nowrap">
|
||||
{token}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`shrink-0 pt-0.5 font-sans text-[11px] font-semibold transition-colors ${
|
||||
errored
|
||||
? "text-[var(--text-faint)]"
|
||||
: copied
|
||||
? "text-[var(--accent)]"
|
||||
: "text-[var(--text-muted)] group-hover:text-[var(--accent)]"
|
||||
}`}
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<span aria-live="polite" className="sr-only">
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandMenu({
|
||||
createFramework,
|
||||
trailing,
|
||||
}: {
|
||||
createFramework?: string;
|
||||
trailing?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="divide-y divide-[var(--border)]">
|
||||
{buildCommands(createFramework).map((command) => (
|
||||
<CommandMenuItem key={command.id} {...command} />
|
||||
))}
|
||||
{trailing ? <div className="py-1.5">{trailing}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// "Start the quickstart" — the preserved accent CTA from the pre-cards hero,
|
||||
// pointed at a framework's quickstart guide. Rendered by the framework landing
|
||||
// heroes in the action row beneath the command cards; the home hero puts
|
||||
// <HeroQuickstartDropdown> (same visual treatment, framework picker) in the
|
||||
// identical slot.
|
||||
export function QuickstartLinkButton({ href }: { href: string }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="shell-docs-primary-cta shell-docs-radius-control group inline-flex h-11 w-full items-center justify-center gap-2 border border-[var(--accent)] bg-[var(--accent)] px-4 text-sm font-semibold text-[var(--primary-foreground)] no-underline shadow-[var(--shadow-control)] transition-colors hover:bg-[var(--accent-strong)] sm:w-fit"
|
||||
>
|
||||
Quickstart
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroStartActions({
|
||||
createFramework,
|
||||
quickstart,
|
||||
trailing,
|
||||
}: {
|
||||
createFramework?: string;
|
||||
quickstart: React.ReactNode;
|
||||
trailing?: React.ReactNode;
|
||||
}) {
|
||||
const [showCli, setShowCli] = React.useState(false);
|
||||
const cliMenuRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!showCli) return;
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
const target = event.target instanceof Node ? event.target : null;
|
||||
if (!target || cliMenuRef.current?.contains(target)) return;
|
||||
setShowCli(false);
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
setShowCli(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [showCli]);
|
||||
|
||||
return (
|
||||
<div className="flex max-w-[820px] flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
{quickstart}
|
||||
<div ref={cliMenuRef} className="relative w-full sm:w-fit">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showCli}
|
||||
aria-controls="hero-cli-commands"
|
||||
onClick={() => setShowCli((value) => !value)}
|
||||
className="shell-docs-radius-control inline-flex h-11 w-full cursor-pointer items-center justify-center gap-2 border border-[var(--border)] bg-[var(--bg-surface)] px-4 text-sm font-semibold text-[var(--text)] shadow-[var(--shadow-control)] transition-colors hover:border-[var(--accent)] hover:bg-[var(--bg-elevated)] hover:text-[var(--accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] sm:w-fit"
|
||||
>
|
||||
Start using agents
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={`h-4 w-4 transition-transform ${showCli ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{showCli ? (
|
||||
<div
|
||||
id="hero-cli-commands"
|
||||
className="shell-docs-radius-surface absolute left-0 top-[calc(100%+8px)] z-30 w-full min-w-0 overflow-hidden border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--shadow-panel)] sm:w-[440px]"
|
||||
>
|
||||
<CommandMenu
|
||||
createFramework={createFramework}
|
||||
trailing={trailing}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LearnMoreAgentsLink() {
|
||||
return (
|
||||
<Link
|
||||
href="/build-with-agents"
|
||||
prefetch={false}
|
||||
className="block px-3.5 py-3 no-underline transition-colors hover:bg-[var(--accent-dim)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
>
|
||||
<span className="block text-[13px] font-semibold leading-snug text-[var(--text)]">
|
||||
Build with agents
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs leading-snug text-[var(--text-muted)]">
|
||||
Explore agent backends and framework options
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock";
|
||||
import type { CodeBlockProps } from "fumadocs-ui/components/codeblock";
|
||||
import type { ShikiTransformer } from "shiki";
|
||||
|
||||
interface HighlightedDynamicCodeBlockProps {
|
||||
lang: string;
|
||||
code: string;
|
||||
codeblock?: CodeBlockProps;
|
||||
highlightedLines?: number[];
|
||||
}
|
||||
|
||||
function lineHighlightTransformer(
|
||||
highlightedLines: readonly number[],
|
||||
): ShikiTransformer {
|
||||
const highlighted = new Set(highlightedLines);
|
||||
|
||||
return {
|
||||
name: "shell-docs:snippet-line-highlight",
|
||||
pre(hast) {
|
||||
this.addClassToHast(hast, "has-highlighted");
|
||||
},
|
||||
line(hast, line) {
|
||||
if (highlighted.has(line)) {
|
||||
this.addClassToHast(hast, "highlighted");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function HighlightedDynamicCodeBlock({
|
||||
lang,
|
||||
code,
|
||||
codeblock,
|
||||
highlightedLines,
|
||||
}: HighlightedDynamicCodeBlockProps) {
|
||||
const options = React.useMemo(
|
||||
() => ({
|
||||
themes: {
|
||||
light: "github-light",
|
||||
dark: "github-dark",
|
||||
},
|
||||
transformers:
|
||||
highlightedLines && highlightedLines.length > 0
|
||||
? [lineHighlightTransformer(highlightedLines)]
|
||||
: undefined,
|
||||
}),
|
||||
[highlightedLines],
|
||||
);
|
||||
|
||||
return (
|
||||
<DynamicCodeBlock
|
||||
lang={lang}
|
||||
code={code}
|
||||
codeblock={codeblock}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { FrameworkLogo } from "../framework-icons";
|
||||
|
||||
describe("FrameworkLogo", () => {
|
||||
it("renders the Deep Agents mark from the framework icon registry", () => {
|
||||
const markup = renderToStaticMarkup(<FrameworkLogo slug="deepagents" />);
|
||||
|
||||
expect(markup).toContain("<svg");
|
||||
expect(markup).toContain('viewBox="0 0 98 98"');
|
||||
expect(markup).toContain("M72.5361 42.2004");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
|
||||
interface IconProps {
|
||||
className?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A2A protocol icon. Ported from docs/lib/icons/custom-icons.tsx::A2AIcon.
|
||||
* Renders with `currentColor` so callers can theme it via Tailwind `text-*`.
|
||||
*/
|
||||
export function A2AIcon({ className, width = 30, height = 18.5 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="3 9 30 18.5"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M8.7 14.725C8.51667 14.9083 8.28333 15 8 15C7.71667 15 7.475 14.9083 7.275 14.725C7.09167 14.525 7 14.2833 7 14C7 13.7167 7.09167 13.4833 7.275 13.3C7.475 13.1 7.71667 13 8 13C8.28333 13 8.51667 13.1 8.7 13.3C8.9 13.4833 9 13.7167 9 14C9 14.2833 8.9 14.525 8.7 14.725Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M14.7 14.725C14.5167 14.9083 14.2833 15 14 15C13.7167 15 13.475 14.9083 13.275 14.725C13.0917 14.525 13 14.2833 13 14C13 13.7167 13.0917 13.4833 13.275 13.3C13.475 13.1 13.7167 13 14 13C14.2833 13 14.5167 13.1 14.7 13.3C14.9 13.4833 15 13.7167 15 14C15 14.2833 14.9 14.525 14.7 14.725Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M22.7 14.725C22.5167 14.9083 22.2833 15 22 15C21.7167 15 21.475 14.9083 21.275 14.725C21.0917 14.525 21 14.2833 21 14C21 13.7167 21.0917 13.4833 21.275 13.3C21.475 13.1 21.7167 13 22 13C22.2833 13 22.5167 13.1 22.7 13.3C22.9 13.4833 23 13.7167 23 14C23 14.2833 22.9 14.525 22.7 14.725Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M28.7 14.725C28.5167 14.9083 28.2833 15 28 15C27.7167 15 27.475 14.9083 27.275 14.725C27.0917 14.525 27 14.2833 27 14C27 13.7167 27.0917 13.4833 27.275 13.3C27.475 13.1 27.7167 13 28 13C28.2833 13 28.5167 13.1 28.7 13.3C28.9 13.4833 29 13.7167 29 14C29 14.2833 28.9 14.525 28.7 14.725Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M5 14C5 12.3431 6.34315 11 8 11H14C14.776 11 15.284 11.1537 15.64 11.3818C15.8589 10.794 16.1517 10.1709 16.555 9.59795C15.8797 9.21153 15.0386 9 14 9H8C5.23858 9 3 11.2386 3 14C3 16.7612 5.23759 19 7.99926 19H14C15.7634 19 16.9573 18.3902 17.7375 17.35C18.4228 16.4363 18.7148 15.266 18.9483 14.3299L18.9701 14.2425C19.2327 13.1924 19.4442 12.4077 19.8625 11.85C20.2073 11.3902 20.7634 11 22 11H28.0005C29.6572 11 31 12.343 31 14C31 15.6569 29.6569 17 28 17H22C21.224 17 20.716 16.8463 20.36 16.6182C20.1411 17.206 19.8483 17.8291 19.445 18.402C20.1203 18.7885 20.9614 19 22 19H28C30.7614 19 33 16.7614 33 14C33 11.2388 30.7621 9 28.0005 9H22C20.2366 9 19.0427 9.60979 18.2625 10.65C17.5772 11.5637 17.2852 12.734 17.0517 13.6701L17.0299 13.7575C16.7673 14.8076 16.5558 15.5923 16.1375 16.15C15.7927 16.6098 15.2366 17 14 17H7.99926C6.34265 17 5 15.6571 5 14Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M7 23.5C6.71667 23.5 6.475 23.4083 6.275 23.225C6.09167 23.025 6 22.7833 6 22.5C6 22.2167 6.09167 21.9833 6.275 21.8C6.475 21.6 6.71667 21.5 7 21.5H12C12.2833 21.5 12.5167 21.6 12.7 21.8C12.9 21.9833 13 22.2167 13 22.5C13 22.7833 12.9 23.025 12.7 23.225C12.5167 23.4083 12.2833 23.5 12 23.5H7ZM5 27.5C4.71667 27.5 4.475 27.4083 4.275 27.225C4.09167 27.025 4 26.7833 4 26.5C4 26.2167 4.09167 25.9833 4.275 25.8C4.475 25.6 4.71667 25.5 5 25.5H8C8.28333 25.5 8.51667 25.6 8.7 25.8C8.9 25.9833 9 26.2167 9 26.5C9 26.7833 8.9 27.025 8.7 27.225C8.51667 27.4083 8.28333 27.5 8 27.5H5ZM12 27.5C11.7167 27.5 11.475 27.4083 11.275 27.225C11.0917 27.025 11 26.7833 11 26.5C11 26.2167 11.0917 25.9833 11.275 25.8C11.475 25.6 11.7167 25.5 12 25.5H20C20.2833 25.5 20.5167 25.6 20.7 25.8C20.9 25.9833 21 26.2167 21 26.5C21 26.7833 20.9 27.025 20.7 27.225C20.5167 27.4083 20.2833 27.5 20 27.5H12ZM28 27.5C27.7167 27.5 27.475 27.4083 27.275 27.225C27.0917 27.025 27 26.7833 27 26.5C27 26.2167 27.0917 25.9833 27.275 25.8C27.475 25.6 27.7167 25.5 28 25.5H32C32.2833 25.5 32.5167 25.6 32.7 25.8C32.9 25.9833 33 26.2167 33 26.5C33 26.7833 32.9 27.025 32.7 27.225C32.5167 27.4083 32.2833 27.5 32 27.5H28ZM16 23.5C15.7167 23.5 15.475 23.4083 15.275 23.225C15.0917 23.025 15 22.7833 15 22.5C15 22.2167 15.0917 21.9833 15.275 21.8C15.475 21.6 15.7167 21.5 16 21.5C16.2833 21.5 16.5167 21.6 16.7 21.8C16.9 21.9833 17 22.2167 17 22.5C17 22.7833 16.9 23.025 16.7 23.225C16.5167 23.4083 16.2833 23.5 16 23.5ZM24 27.5C23.7167 27.5 23.475 27.4083 23.275 27.225C23.0917 27.025 23 26.7833 23 26.5C23 26.2167 23.0917 25.9833 23.275 25.8C23.475 25.6 23.7167 25.5 24 25.5C24.2833 25.5 24.5167 25.6 24.7 25.8C24.9 25.9833 25 26.2167 25 26.5C25 26.7833 24.9 27.025 24.7 27.225C24.5167 27.4083 24.2833 27.5 24 27.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M19.275 23.225C19.475 23.4083 19.7167 23.5 20 23.5H23C23.2833 23.5 23.5167 23.4083 23.7 23.225C23.9 23.025 24 22.7833 24 22.5C24 22.2167 23.9 21.9833 23.7 21.8C23.5167 21.6 23.2833 21.5 23 21.5H20C19.7167 21.5 19.475 21.6 19.275 21.8C19.0917 21.9833 19 22.2167 19 22.5C19 22.7833 19.0917 23.025 19.275 23.225Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M26.275 23.225C26.475 23.4083 26.7167 23.5 27 23.5H30C30.2833 23.5 30.5167 23.4083 30.7 23.225C30.9 23.025 31 22.7833 31 22.5C31 22.2167 30.9 21.9833 30.7 21.8C30.5167 21.6 30.2833 21.5 30 21.5H27C26.7167 21.5 26.475 21.6 26.275 21.8C26.0917 21.9833 26 22.2167 26 22.5C26 22.7833 26.0917 23.025 26.275 23.225Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
|
||||
interface IconProps {
|
||||
className?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentSpec mark. Ported from docs/lib/icons/custom-icons.tsx::AgentSpecMarkIcon.
|
||||
* Renders with `currentColor` for theme-aware coloring.
|
||||
*/
|
||||
export function AgentSpecMarkIcon({
|
||||
className,
|
||||
width = 180,
|
||||
height = 120,
|
||||
}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 180 120"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g data-cell-id={0} transform="scale(0.8) translate(18, 15)">
|
||||
<path
|
||||
d="M71.231 88.354c-4.938-.898-8.527-2.998-10.061-5.886-.882-1.66-.868-5.134.027-6.824.934-1.764 1.773-2.774 3.223-3.88.715-.545 1.3-1.116 1.3-1.268 0-.152-.407-.6-.903-.997-.497-.396-1.225-1.377-1.618-2.18-.614-1.256-.699-1.71-.605-3.248.135-2.215.685-3.382 2.502-5.303.78-.825 1.418-1.545 1.418-1.601 0-.056-.592-.75-1.315-1.54-.723-.791-1.75-2.319-2.281-3.395-.829-1.676-.986-2.29-1.1-4.287-.353-6.221 3.107-11.277 9.143-13.36 1.465-.506 2.317-.612 4.945-.612 1.886 0 3.54.133 4.075.33.633.233 2.923.33 7.739.33h6.84v5.807l-3.744.073-3.743.074.673.8c1.447 1.717 1.913 3.015 2.01 5.595.22 5.859-2.684 10.146-8.294 12.243-2.25.842-6.041 1.03-8.334.413-2.595-.699-2.662-.685-3.632.756-.997 1.482-1.117 2.712-.402 4.129.926 1.836 1.462 1.96 9.322 2.158 7.293.184 9.691.487 12.03 1.522 1.82.805 2.893 1.744 3.74 3.273.707 1.276.77 1.608.77 4.041 0 2.48-.058 2.77-.903 4.498-1.766 3.611-5.768 6.53-10.738 7.833-1.632.427-3.117.575-6.483.643-2.4.048-4.921-.013-5.6-.137zM81.32 83.14c4.62-1.277 7.182-4.176 6.38-7.221-.396-1.501-.674-1.826-2.135-2.488-1.043-.472-1.98-.563-7.541-.736-3.493-.108-6.674-.276-7.07-.373-.559-.137-.917-.014-1.588.545-3.924 3.264-3.165 8.147 1.539 9.907 2.85 1.067 7.311 1.223 10.415.366zm-2.34-28.601c1.474-.666 2.68-1.87 3.46-3.45.515-1.047.611-1.658.611-3.888 0-2.917-.52-4.546-1.932-6.052-2.61-2.784-7.79-2.638-10.368.29-1.413 1.606-1.958 3.467-1.797 6.137.289 4.762 3.326 7.888 7.38 7.595.817-.06 2.008-.344 2.646-.632zm32.778 17.653c-7.526-1.942-12.399-7.834-13.23-15.997-.494-4.862.212-9.085 2.143-12.83 1.873-3.63 4.527-6.248 8.045-7.934 2.459-1.179 4.22-1.595 6.693-1.582 3.894.02 7.38 1.344 10.052 3.818 2.02 1.87 3.786 5.23 4.414 8.399.407 2.051.546 7.105.239 8.643l-.166.827h-12.005c-13.709 0-12.353-.316-11.521 2.684.837 3.02 2.713 5.654 4.967 6.975 3.555 2.083 8.778 2.007 13.273-.194.912-.446 1.702-.765 1.755-.707.052.058.65 1.135 1.327 2.393l1.23 2.288-.894.553c-1.805 1.115-4.963 2.394-6.902 2.795-2.75.568-6.937.51-9.42-.13zm11.826-24.13c-.456-4.817-2.738-7.614-6.615-8.109-4.046-.516-7.815 1.489-9.65 5.133-.486.967-.77 1.871-1.228 3.901l-.164.728h17.814zm67.718 24.39c-3.298-.502-5.654-2.118-6.997-4.798-1.637-3.267-1.694-3.766-1.835-15.889l-.128-11.046h-5.232v-5.49l.827-.165c.455-.091 1.616-.168 2.58-.17.964-.002 1.897-.096 2.074-.208.279-.177 1.134-8.445 1.134-10.964v-.73h6.35v11.642h9.79v6.085h-9.817l.08 10.914c.072 9.955.122 11.013.571 12.04 1.027 2.348 3.025 3.259 6.081 2.771a651.35 651.35 0 0 1 2.4-.38c.424-.065.604.267 1.105 2.04.785 2.78.78 3.001-.09 3.334-2.51.96-6.362 1.4-8.893 1.015zM23.561 46.945l8.337-25.01h8.669l8.211 24.824c4.516 13.653 8.21 24.904 8.21 25.003 0 .098-1.777.179-3.95.179-3.536 0-3.965-.049-4.086-.463-.372-1.28-2.681-8.985-3.37-11.245l-.787-2.58H27.222l-2.145 7.078-2.144 7.078-3.855.073-3.855.073zm19.276 4.106c-.848-2.674-4.54-15.307-5.416-18.533-.612-2.256-1.213-4.206-1.337-4.333-.124-.13-.358.282-.527.926a398.135 398.135 0 0 1-4.355 15.437c-1.097 3.634-1.995 6.7-1.995 6.813 0 .114 3.104.207 6.897.207 6.723 0 6.893-.013 6.733-.517zm96.437 2.237V34.634h6.025l.152 1.124c.084.619.23 1.81.325 2.646.095.837.233 1.521.306 1.521.072 0 .803-.576 1.623-1.28 2.243-1.925 4.895-3.646 6.451-4.187 2.127-.738 6.961-.666 8.984.136 2.061.816 3.977 2.602 4.96 4.62 1.692 3.478 1.69 3.453 1.801 18.77l.102 13.956h-7.398l-.114-12.766c-.097-10.998-.175-12.986-.564-14.354-.902-3.175-2.677-4.492-6.029-4.472-1.734.01-2.272.134-3.704.85-.921.46-2.538 1.562-3.593 2.45l-1.919 1.611v26.68h-7.408z"
|
||||
fill="currentColor"
|
||||
transform="translate(-20, -35)"
|
||||
/>
|
||||
<path
|
||||
d="M252.555 61.026V34.833h6.026l.149.86c.081.473.227 1.425.324 2.116.096.692.24 1.257.32 1.257.078 0 .75-.466 1.49-1.036 1.916-1.473 5.05-3.101 6.894-3.58 6.361-1.656 12.895 1.491 15.746 7.583 1.432 3.062 1.892 5.25 2.045 9.733.11 3.24.043 4.269-.437 6.615-1.042 5.094-3.647 9.405-7.185 11.89-2.56 1.797-4.33 2.43-7.264 2.594-3.427.192-5.692-.517-9.176-2.874-.835-.565-1.551-1.027-1.591-1.027-.04 0-.028 4.108.027 9.128l.1 9.128h-7.468zm19.484 4.757c4.123-2.01 6.178-6.955 5.825-14.017-.386-7.743-3.154-11.417-8.612-11.428-2.888-.006-5.398 1.104-8.322 3.68l-.967.853v18.532l1.257.924c3.404 2.505 7.528 3.06 10.82 1.456zm-50.001 6.89c-3.743-.678-7.977-2.553-10.955-4.85-2.797-2.159-2.77-1.87-.426-4.624L212.7 60.8l1.107.973c1.654 1.451 4.224 2.883 6.602 3.678 1.864.623 2.542.709 5.688.72 4.191.016 5.689-.395 7.616-2.092 1.567-1.38 2.162-2.73 2.162-4.906 0-4.188-1.4-5.308-12.916-10.331-7.914-3.453-11.223-6.733-11.938-11.839-.965-6.88 3.482-13.034 10.885-15.066 2.326-.638 7.894-.619 10.276.036 3.29.903 6.556 2.598 9.116 4.73l.78.65-1.812 2.218a242.292 242.292 0 0 1-2.02 2.452c-.114.129-.78-.184-1.482-.696-4.566-3.33-10.217-4.291-14.34-2.44-4.14 1.857-5.277 6.378-2.393 9.507 1.292 1.401 2.733 2.172 9.676 5.176 7.559 3.27 9.852 4.71 11.776 7.391 1.608 2.241 2.166 4.452 2.028 8.043-.109 2.849-.157 3.051-1.262 5.336-1.865 3.852-4.939 6.403-9.405 7.803-1.461.458-2.661.6-5.688.67-2.11.05-4.413-.014-5.117-.141zm84.246-.151c-5.015-1.088-9.53-4.658-11.814-9.342-2.383-4.888-2.623-12.63-.55-17.761 3.417-8.46 11.488-12.93 19.599-10.857 5.13 1.312 8.48 4.702 10.074 10.193.763 2.628 1.139 7.532.74 9.657l-.223 1.19-12.055.133-12.054.132.35 1.72c1.713 8.402 10.189 11.794 18.712 7.489.71-.358 1.382-.553 1.496-.432.113.12.736 1.184 1.384 2.363l1.177 2.145-1.35.804c-1.973 1.175-4.403 2.137-6.528 2.584-2.302.484-6.682.475-8.958-.018zm11.48-23.997c-.654-5.649-3.219-8.392-7.851-8.398-4.568-.006-7.776 2.486-9.235 7.174-.898 2.886-1.774 2.613 8.374 2.613h8.873zM343.897 72.4c-7.44-1.95-12.38-8.102-12.978-16.16-.263-3.542.057-7.117.863-9.66 2.485-7.834 9.91-12.97 18.007-12.458 1.31.082 3.036.349 3.836.591 1.462.444 4.351 2.028 5.524 3.03l.63.537-1.556 2.02c-.855 1.11-1.633 2.14-1.729 2.287-.104.16-.742-.107-1.587-.664-4.191-2.76-9.443-2.146-12.901 1.51-2.44 2.58-3.484 5.59-3.484 10.053 0 6.716 2.915 11.548 7.83 12.977 2.915.847 6.716.113 9.654-1.864l1.315-.885 1.581 2.272 1.582 2.272L359 69.41c-1.85 1.438-4.252 2.596-6.457 3.116-2.353.555-6.269.498-8.647-.125z"
|
||||
fill="currentColor"
|
||||
opacity="0.7"
|
||||
transform="translate(-190, 40)"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
|
||||
interface BookIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CLASSNAME = "text-icon";
|
||||
|
||||
// Open-book mark matching the BrandNav icon vocabulary
|
||||
// (ConsoleIcon, CloudIcon — outline strokes on a 20x20 grid).
|
||||
const BookIcon = ({ className }: BookIconProps) => {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={[DEFAULT_CLASSNAME, className].filter(Boolean).join(" ")}
|
||||
>
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookIcon;
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
|
||||
interface BurgerMenuIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CLASSNAME = "text-icon";
|
||||
|
||||
const BurgerMenuIcon = ({ className }: BurgerMenuIconProps) => {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={[DEFAULT_CLASSNAME, className].filter(Boolean).join(" ")}
|
||||
>
|
||||
<path
|
||||
d="M17.5 10C17.5 10.1658 17.4342 10.3247 17.3169 10.4419C17.1997 10.5592 17.0408 10.625 16.875 10.625H3.125C2.95924 10.625 2.80027 10.5592 2.68306 10.4419C2.56585 10.3247 2.5 10.1658 2.5 10C2.5 9.83424 2.56585 9.67527 2.68306 9.55806C2.80027 9.44085 2.95924 9.375 3.125 9.375H16.875C17.0408 9.375 17.1997 9.44085 17.3169 9.55806C17.4342 9.67527 17.5 9.83424 17.5 10ZM3.125 5.625H16.875C17.0408 5.625 17.1997 5.55915 17.3169 5.44194C17.4342 5.32473 17.5 5.16576 17.5 5C17.5 4.83424 17.4342 4.67527 17.3169 4.55806C17.1997 4.44085 17.0408 4.375 16.875 4.375H3.125C2.95924 4.375 2.80027 4.44085 2.68306 4.55806C2.56585 4.67527 2.5 4.83424 2.5 5C2.5 5.16576 2.56585 5.32473 2.68306 5.44194C2.80027 5.55915 2.95924 5.625 3.125 5.625ZM16.875 14.375H3.125C2.95924 14.375 2.80027 14.4408 2.68306 14.5581C2.56585 14.6753 2.5 14.8342 2.5 15C2.5 15.1658 2.56585 15.3247 2.68306 15.4419C2.80027 15.5592 2.95924 15.625 3.125 15.625H16.875C17.0408 15.625 17.1997 15.5592 17.3169 15.4419C17.4342 15.3247 17.5 15.1658 17.5 15C17.5 14.8342 17.4342 14.6753 17.3169 14.5581C17.1997 14.4408 17.0408 14.375 16.875 14.375Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default BurgerMenuIcon;
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
|
||||
interface ClaudeCodeIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ClaudeCodeIcon = ({ className }: ClaudeCodeIconProps) => {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<title>Claude Code</title>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M20.998 10.949H24v3.102h-3v3.028h-1.487V20H18v-2.921h-1.487V20H15v-2.921H9V20H7.488v-2.921H6V20H4.487v-2.921H3V14.05H0V10.95h3V5h17.998v5.949zM6 10.949h1.488V8.102H6v2.847zm10.51 0H18V8.102h-1.49v2.847z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClaudeCodeIcon;
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
|
||||
interface ClaudeIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ClaudeIcon = ({ className }: ClaudeIconProps) => {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<title>Claude</title>
|
||||
<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClaudeIcon;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
|
||||
interface CloudIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CLASSNAME = "text-icon";
|
||||
|
||||
const CloudIcon = ({ className }: CloudIconProps) => {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={[DEFAULT_CLASSNAME, className].filter(Boolean).join(" ")}
|
||||
>
|
||||
<g clipPath="url(#clip0_6481_45665)">
|
||||
<path
|
||||
d="M12.5002 3.125C11.2234 3.12598 9.97202 3.48214 8.88606 4.15364C7.80009 4.82514 6.92234 5.78551 6.35098 6.92734C5.67126 6.82821 4.9784 6.8702 4.31563 7.05069C3.65285 7.23117 3.03438 7.54629 2.49878 7.97638C1.96318 8.40647 1.52194 8.94231 1.20259 9.55047C0.883245 10.1586 0.692634 10.8261 0.64265 11.5112C0.592666 12.1963 0.684381 12.8843 0.912074 13.5324C1.13977 14.1804 1.49855 14.7747 1.96606 15.2779C2.43356 15.7812 2.99975 16.1828 3.6293 16.4575C4.25886 16.7323 4.93828 16.8744 5.6252 16.875H12.5002C14.3236 16.875 16.0722 16.1507 17.3616 14.8614C18.6509 13.572 19.3752 11.8234 19.3752 10C19.3752 8.17664 18.6509 6.42795 17.3616 5.13864C16.0722 3.84933 14.3236 3.125 12.5002 3.125ZM12.5002 15.625H5.6252C4.63063 15.625 3.67681 15.2299 2.97354 14.5267C2.27028 13.8234 1.8752 12.8696 1.8752 11.875C1.8752 10.8804 2.27028 9.92661 2.97354 9.22335C3.67681 8.52009 4.63063 8.125 5.6252 8.125C5.71113 8.125 5.79707 8.125 5.88223 8.13359C5.71131 8.74099 5.62482 9.36902 5.6252 10C5.6252 10.1658 5.69104 10.3247 5.80825 10.4419C5.92546 10.5592 6.08443 10.625 6.2502 10.625C6.41596 10.625 6.57493 10.5592 6.69214 10.4419C6.80935 10.3247 6.8752 10.1658 6.8752 10C6.8752 8.88748 7.2051 7.79994 7.82318 6.87492C8.44126 5.94989 9.31977 5.22892 10.3476 4.80318C11.3754 4.37743 12.5064 4.26604 13.5976 4.48308C14.6887 4.70012 15.691 5.23585 16.4777 6.02252C17.2643 6.80919 17.8001 7.81147 18.0171 8.90262C18.2342 9.99376 18.1228 11.1248 17.697 12.1526C17.2713 13.1804 16.5503 14.0589 15.6253 14.677C14.7003 15.2951 13.6127 15.625 12.5002 15.625Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_6481_45665">
|
||||
<rect width="20" height="20" fill="currentColor" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default CloudIcon;
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
|
||||
interface CodexIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CodexIcon = ({ className }: CodexIconProps) => {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<title>Codex</title>
|
||||
<path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodexIcon;
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
|
||||
interface ConsoleIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CLASSNAME = "text-icon";
|
||||
|
||||
const ConsoleIcon = ({ className }: ConsoleIconProps) => {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={[DEFAULT_CLASSNAME, className].filter(Boolean).join(" ")}
|
||||
>
|
||||
<path
|
||||
d="M10 10C10.0001 10.0938 9.97902 10.1863 9.93845 10.2708C9.89788 10.3554 9.83881 10.4297 9.76562 10.4883L6.64062 12.9883C6.5765 13.0396 6.5029 13.0777 6.42403 13.1006C6.34516 13.1235 6.26256 13.1305 6.18095 13.1215C6.09933 13.1124 6.0203 13.0874 5.94837 13.0477C5.87644 13.0081 5.81302 12.9547 5.76172 12.8906C5.71042 12.8265 5.67226 12.7529 5.6494 12.674C5.62655 12.5952 5.61945 12.5126 5.62852 12.4309C5.63759 12.3493 5.66264 12.2703 5.70225 12.1984C5.74186 12.1264 5.79525 12.063 5.85938 12.0117L8.37422 10L5.85938 7.98828C5.79525 7.93698 5.74186 7.87356 5.70225 7.80163C5.66264 7.7297 5.63759 7.65067 5.62852 7.56905C5.61945 7.48744 5.62655 7.40484 5.6494 7.32597C5.67226 7.2471 5.71042 7.1735 5.76172 7.10938C5.81302 7.04525 5.87644 6.99186 5.94837 6.95225C6.0203 6.91264 6.09933 6.88759 6.18095 6.87852C6.26256 6.86945 6.34516 6.87655 6.42403 6.8994C6.5029 6.92226 6.5765 6.96042 6.64062 7.01172L9.76562 9.51172C9.83881 9.57032 9.89788 9.64463 9.93845 9.72915C9.97902 9.81368 10.0001 9.90624 10 10ZM13.75 11.875H10.625C10.4592 11.875 10.3003 11.9408 10.1831 12.0581C10.0658 12.1753 10 12.3342 10 12.5C10 12.6658 10.0658 12.8247 10.1831 12.9419C10.3003 13.0592 10.4592 13.125 10.625 13.125H13.75C13.9158 13.125 14.0747 13.0592 14.1919 12.9419C14.3092 12.8247 14.375 12.6658 14.375 12.5C14.375 12.3342 14.3092 12.1753 14.1919 12.0581C14.0747 11.9408 13.9158 11.875 13.75 11.875ZM18.125 4.375V15.625C18.125 15.9565 17.9933 16.2745 17.7589 16.5089C17.5245 16.7433 17.2065 16.875 16.875 16.875H3.125C2.79348 16.875 2.47554 16.7433 2.24112 16.5089C2.0067 16.2745 1.875 15.9565 1.875 15.625V4.375C1.875 4.04348 2.0067 3.72554 2.24112 3.49112C2.47554 3.2567 2.79348 3.125 3.125 3.125H16.875C17.2065 3.125 17.5245 3.2567 17.7589 3.49112C17.9933 3.72554 18.125 4.04348 18.125 4.375ZM16.875 15.625V4.375H3.125V15.625H16.875Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConsoleIcon;
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
|
||||
interface DiscordIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CLASSNAME = "text-icon";
|
||||
|
||||
const DiscordIcon = ({ className }: DiscordIconProps) => {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={[DEFAULT_CLASSNAME, className].filter(Boolean).join(" ")}
|
||||
>
|
||||
<path
|
||||
d="M16.0862 4.44764C14.9453 3.92586 13.7337 3.54559 12.4691 3.33334C12.3099 3.60749 12.133 3.97893 12.0092 4.27077C10.6641 4.0762 9.32867 4.0762 8.00212 4.27077C7.87831 3.97893 7.69258 3.60749 7.54223 3.33334C6.26876 3.54559 5.05717 3.92586 3.92429 4.44764C1.63377 7.82593 1.01472 11.1246 1.32425 14.3791C2.84536 15.4846 4.31343 16.1567 5.75584 16.5989C6.10959 16.1213 6.42797 15.6083 6.70212 15.0689C6.18034 14.8743 5.68509 14.6356 5.20753 14.3526C5.33135 14.2642 5.45517 14.1669 5.57013 14.0696C8.45317 15.3873 11.5759 15.3873 14.4235 14.0696C14.5473 14.1669 14.6623 14.2642 14.7862 14.3526C14.3086 14.6356 13.8133 14.8743 13.2916 15.0689C13.5657 15.6083 13.8841 16.1213 14.2378 16.5989C15.6793 16.1567 17.1562 15.4846 18.6694 14.3791C19.0497 10.6118 18.0672 7.33956 16.0862 4.44764ZM7.10008 12.3716C6.23339 12.3716 5.52589 11.5845 5.52589 10.6206C5.52589 9.65659 6.2157 8.8695 7.10008 8.8695C7.97559 8.8695 8.69192 9.65659 8.67425 10.6206C8.67425 11.5845 7.97559 12.3716 7.10008 12.3716ZM12.9113 12.3716C12.0446 12.3716 11.3362 11.5845 11.3362 10.6206C11.3362 9.65659 12.0269 8.8695 12.9113 8.8695C13.7868 8.8695 14.5032 9.65659 14.4854 10.6206C14.4854 11.5845 13.7957 12.3716 12.9113 12.3716Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscordIcon;
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
|
||||
interface ExternalLinkIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CLASSNAME = "text-icon";
|
||||
|
||||
const ExternalLinkIcon = ({ className }: ExternalLinkIconProps) => {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={[DEFAULT_CLASSNAME, className].filter(Boolean).join(" ")}
|
||||
>
|
||||
<path
|
||||
d="M12.4998 4V10.5C12.4998 10.6326 12.4471 10.7598 12.3533 10.8536C12.2596 10.9473 12.1324 11 11.9998 11C11.8672 11 11.74 10.9473 11.6462 10.8536C11.5525 10.7598 11.4998 10.6326 11.4998 10.5V5.20687L4.35354 12.3538C4.25972 12.4476 4.13247 12.5003 3.99979 12.5003C3.86711 12.5003 3.73986 12.4476 3.64604 12.3538C3.55222 12.2599 3.49951 12.1327 3.49951 12C3.49951 11.8673 3.55222 11.7401 3.64604 11.6462L10.7929 4.5H5.49979C5.36718 4.5 5.24 4.44732 5.14624 4.35355C5.05247 4.25979 4.99979 4.13261 4.99979 4C4.99979 3.86739 5.05247 3.74021 5.14624 3.64645C5.24 3.55268 5.36718 3.5 5.49979 3.5H11.9998C12.1324 3.5 12.2596 3.55268 12.3533 3.64645C12.4471 3.74021 12.4998 3.86739 12.4998 4Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExternalLinkIcon;
|
||||
@@ -0,0 +1,388 @@
|
||||
import React from "react";
|
||||
|
||||
interface IconProps {
|
||||
className?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const wrap = (
|
||||
vb: string,
|
||||
inner: React.ReactNode,
|
||||
defaultWidth: number = 20,
|
||||
defaultHeight: number = 20,
|
||||
) =>
|
||||
function Icon({
|
||||
className,
|
||||
width = defaultWidth,
|
||||
height = defaultHeight,
|
||||
}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={vb}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{inner}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
function CopilotKitKiteIcon({ className, width = 20, height = 20 }: IconProps) {
|
||||
const uid = React.useId();
|
||||
const id = (n: number) => `cpk-framework-mark-${uid}-${n}`;
|
||||
return (
|
||||
<svg
|
||||
viewBox="111 0 25 26"
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={id(0)}
|
||||
x1="129.301"
|
||||
y1="2.339"
|
||||
x2="125.623"
|
||||
y2="12.452"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#6430AB" />
|
||||
<stop offset="1" stopColor="#AA89D8" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={id(1)}
|
||||
x1="126.451"
|
||||
y1="8.039"
|
||||
x2="121.717"
|
||||
y2="17.187"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#005DBB" />
|
||||
<stop offset="1" stopColor="#3D92E8" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={id(2)}
|
||||
x1="128.565"
|
||||
y1="2.339"
|
||||
x2="127.139"
|
||||
y2="6.798"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#1B70C4" />
|
||||
<stop offset="1" stopColor="#54A4F2" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={id(3)}
|
||||
x1="117.94"
|
||||
y1="22.784"
|
||||
x2="132.981"
|
||||
y2="22.784"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#4497EA" />
|
||||
<stop offset="0.2548" stopColor="#1463B2" />
|
||||
<stop offset="0.4987" stopColor="#0A437D" />
|
||||
<stop offset="0.6667" stopColor="#2476C8" />
|
||||
<stop offset="0.9725" stopColor="#0C549A" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M119.539 8.67724C121.647 5.91912 123.397 3.19174 124.071 0.989235C124.089 0.929306 124.159 0.903848 124.211 0.938445C126.553 2.4891 130.818 3.50978 134.591 3.53373C134.655 3.53415 134.7 3.59815 134.677 3.65868C133.422 6.84085 131.89 12.5427 131.831 19.054C131.831 19.1507 131.695 19.1854 131.647 19.1014C129.5 15.3443 122.623 10.0649 119.574 8.81884C119.517 8.79565 119.501 8.72596 119.539 8.67724Z"
|
||||
fill={`url(#${id(0)})`}
|
||||
/>
|
||||
<path
|
||||
d="M126.653 6.99011C123.357 8.03363 120.345 8.61377 119.626 8.74558C119.581 8.75399 119.571 8.81729 119.615 8.83516C122.687 10.1126 129.53 15.3766 131.657 19.1184C131.661 19.1266 131.672 19.1296 131.68 19.1259C131.689 19.1218 131.693 19.1112 131.69 19.1021L126.653 6.99011Z"
|
||||
fill={`url(#${id(1)})`}
|
||||
/>
|
||||
<path
|
||||
d="M124.221 0.931583C127.042 2.47061 130.303 3.16182 134.629 3.52604C134.656 3.52836 134.665 3.56478 134.641 3.57743C134.087 3.86176 130.918 5.47449 128.565 6.33825C127.934 6.56966 127.3 6.78434 126.675 6.98241C126.662 6.98674 126.647 6.97992 126.641 6.96671L124.156 0.989873C124.139 0.949626 124.183 0.91071 124.221 0.931583Z"
|
||||
fill={`url(#${id(2)})`}
|
||||
/>
|
||||
<path
|
||||
d="M125.209 3.30419L122.405 12.6362M122.405 12.6362H129.07M122.405 12.6362L111.874 25.0387"
|
||||
stroke="#ABABAB"
|
||||
strokeWidth="0.321797"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M119.181 22.4856L117.94 22.6601C118.584 24.3624 119.904 25.1059 121.479 25.1059C125.341 25.1059 124.163 20.7388 126.4 20.7388C128.023 20.7388 127.364 24.2784 130.857 24.2784C132.989 24.2784 133.201 22.1307 132.837 21.2067C132.835 21.201 132.833 21.1959 132.83 21.1908L132.259 20.316C132.222 20.2578 132.131 20.2797 132.125 20.3489L132.018 21.4092C132.011 21.483 132.013 21.5565 132.021 21.6301C132.109 22.3627 132.165 24.1405 130.857 24.1405C129.477 24.1405 129.145 20.6468 126.4 20.6468C123.181 20.6468 123.594 24.968 121.618 24.968C120.313 24.968 119.319 23.497 119.181 22.4856Z"
|
||||
fill={`url(#${id(3)})`}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const LanggraphIcon = wrap(
|
||||
"0 0 128 128",
|
||||
<>
|
||||
<path
|
||||
d="M40.1024 85.0722C47.6207 77.5537 51.8469 67.3453 51.8469 56.7136C51.8469 46.0818 47.617 35.8734 40.1024 28.355L11.7446 0C4.22995 7.5185 0 17.7269 0 28.3586C0 38.9903 4.22995 49.1987 11.7446 56.7172L40.0987 85.0722H40.1024Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M99.4385 87.698C91.9239 80.1832 81.7121 75.9531 71.0844 75.9531C60.4566 75.9531 50.2448 80.1832 42.7266 87.698L71.0844 116.057C78.599 123.571 88.8107 127.802 99.4421 127.802C110.074 127.802 120.282 123.571 127.8 116.057L99.4421 87.698H99.4385Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M11.8146 115.987C19.3329 123.502 29.541 127.732 40.1724 127.732V87.6289H0.0664062C0.0700559 98.2606 4.29635 108.469 11.8146 115.987Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M110.387 45.7684C102.869 38.2535 92.6608 34.0198 82.0258 34.0234C71.3943 34.0234 61.1863 38.2535 53.668 45.772L82.0258 74.1306L110.387 45.7684Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
export const MastraIcon = wrap(
|
||||
"0 0 24 23",
|
||||
<>
|
||||
<path
|
||||
d="M22.2633 11.4903C22.2633 5.80398 17.5537 1.17371 11.7185 1.17368C5.88325 1.17368 1.1737 5.80396 1.17368 11.4903C1.17368 17.1767 5.88324 21.8069 11.7185 21.8069C17.5537 21.8069 22.2633 17.1767 22.2633 11.4903ZM23.437 11.4903C23.437 17.8476 18.1788 22.9806 11.7185 22.9806C5.25811 22.9806 0 17.8476 0 11.4903C1.77337e-05 5.13302 5.25812 0 11.7185 0C18.1788 2.9092e-05 23.4369 5.13304 23.437 11.4903Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M19.1705 18.7896C20.4457 17.5405 20.88 15.6057 20.4358 13.3978C19.9924 11.1945 18.6808 8.78885 16.5791 6.73014C14.4773 4.67135 12.0207 3.38609 9.77123 2.95181C7.51727 2.5167 5.54189 2.94207 4.26664 4.19119C2.99146 5.44033 2.55711 7.37522 3.00135 9.58299C3.44472 11.7864 4.75687 14.1926 6.85871 16.2514C8.96043 18.3099 11.4165 19.5947 13.6659 20.029C15.9198 20.4642 17.8952 20.0387 19.1705 18.7896ZM20.0089 19.6109C18.3685 21.2177 15.9421 21.652 13.4366 21.1683C10.9267 20.6837 8.26541 19.2717 6.02024 17.0727C3.77497 14.8734 2.33301 12.2662 1.83827 9.80759C1.34445 7.35343 1.78783 4.97672 3.42818 3.3699C5.0686 1.76309 7.495 1.32887 10.0005 1.81257C12.5106 2.29715 15.1723 3.70955 17.4176 5.90885C19.6627 8.10806 21.1041 10.7147 21.5989 13.1732C22.0927 15.6275 21.6494 18.0041 20.0089 19.6109Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M16.3219 10.9427V12.1164H7.17413V10.9427H16.3219Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M14.0727 9.25295L14.4826 9.67254L9.83521 14.2252L9.01344 13.386L13.6618 8.83337L14.0727 9.25295Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M14.4821 13.386L14.0712 13.8056L13.6613 14.2252L9.01298 9.67254L9.83475 8.83337L14.4821 13.386Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M4.10244 6.69156C2.1703 7.96093 1.04502 9.66608 1.04502 11.4901C1.04502 13.3141 2.1703 15.0192 4.10244 16.2886C6.03164 17.556 8.72298 18.3545 11.7185 18.3545C14.714 18.3545 17.4053 17.556 19.3345 16.2886C21.2666 15.0192 22.3919 13.3141 22.3919 11.4901C22.3919 9.66608 21.2666 7.96093 19.3345 6.69156C17.4053 5.42415 14.714 4.62567 11.7185 4.62567C8.72298 4.62567 6.03164 5.42415 4.10244 6.69156ZM3.59209 5.94625C5.69173 4.56685 8.56615 3.72822 11.7185 3.72822C14.8708 3.72822 17.7452 4.56685 19.8449 5.94625C21.9416 7.32373 23.3082 9.27516 23.3082 11.4901C23.3082 13.705 21.9416 15.6564 19.8449 17.0339C17.7452 18.4133 14.8708 19.252 11.7185 19.252C8.56615 19.252 5.69173 18.4133 3.59209 17.0339C1.49541 15.6564 0.128798 13.705 0.128798 11.4901C0.128798 9.27516 1.49541 7.32373 3.59209 5.94625Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
export const PydanticAiIcon = wrap(
|
||||
"0 0 22 19",
|
||||
<path
|
||||
d="M21.0411 13.87L11.2585 0.317288C10.9505 -0.105763 10.2407 -0.105763 9.93579 0.317288L0.153139 13.87C0.0535638 14.0086 2.72845e-06 14.1751 0 14.3458C0.000238659 14.5172 0.0543294 14.6843 0.154633 14.8233C0.254937 14.9624 0.396386 15.0664 0.559004 15.1207L10.3418 18.3202H10.3433C10.5076 18.3737 10.6846 18.3737 10.8488 18.3202H10.8503L20.633 15.1208C20.755 15.0813 20.8659 15.0133 20.9565 14.9224C21.0471 14.8316 21.1147 14.7204 21.1538 14.5982C21.194 14.4764 21.2046 14.3468 21.1846 14.2201C21.1646 14.0935 21.1146 13.9734 21.0388 13.87H21.0411ZM10.5976 2.18488L14.5186 7.61733L10.8519 6.41846C10.8235 6.40925 10.7936 6.41079 10.7653 6.40403C10.7376 6.39719 10.7094 6.39257 10.6809 6.39022C10.6527 6.38639 10.6258 6.37565 10.5976 6.37565C10.5692 6.37565 10.5431 6.38639 10.5147 6.39022C10.4865 6.39329 10.4581 6.3979 10.4307 6.40403C10.4015 6.4094 10.3716 6.4094 10.3456 6.41846L6.70099 7.61043L6.67797 7.6181L10.5991 2.18457H10.5976V2.18488ZM5.05758 9.86117L9.3263 8.4642L9.78203 8.31582V16.4236L2.12814 13.9197L5.05758 9.86117ZM11.4139 16.4221V8.31582L16.1382 9.86117L19.0677 13.9174L11.4132 16.4221H11.4139Z"
|
||||
fill="currentColor"
|
||||
/>,
|
||||
);
|
||||
|
||||
export const CrewaiIcon = wrap(
|
||||
"0 0 20 22",
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M15.5394 12.6906L15.5881 12.6266L15.7073 12.4677C15.8499 12.2763 15.9881 12.0822 16.1244 11.8854C16.1879 11.7922 16.2738 11.7164 16.3742 11.6649C16.4745 11.6135 16.5862 11.588 16.6989 11.5908C16.8117 11.5935 16.922 11.6245 17.0197 11.6809C17.1174 11.7372 17.1994 11.8172 17.2583 11.9134C17.3471 12.0615 17.4055 12.2259 17.43 12.3969C17.4545 12.5678 17.4446 12.742 17.4009 12.9091L17.2781 13.3804L17.242 13.5176C16.8596 14.8735 16.0859 16.0866 15.0176 17.005C14.5012 17.4474 13.9902 17.8518 13.416 18.1841C13.0306 18.4071 12.6424 18.6309 12.2397 18.8214C11.348 19.2764 10.3844 19.5738 9.39145 19.7007C8.71617 19.7883 8.03457 19.8235 7.35388 19.8055C6.09141 19.7594 4.87712 19.3091 3.88991 18.5208C2.917 17.7204 2.25543 16.6042 2.02025 15.3665C1.83245 14.4512 1.80648 13.5101 1.94351 12.5859C2.15692 11.1393 2.62778 9.74259 3.3338 8.46203C3.89483 7.38918 4.56395 6.37643 5.33075 5.43952C6.12514 4.46964 7.06566 3.62933 8.11853 2.94875C8.89567 2.44241 9.76411 2.09251 10.6752 1.91868C11.8551 1.69749 12.8726 2.10555 13.8178 2.76097C14.4763 3.20861 15.032 3.79115 15.4482 4.46993C15.8301 5.16146 15.9321 5.97487 15.7317 6.73952C15.5274 7.57468 15.2275 8.38349 14.8379 9.14995L14.6366 9.59863C14.3468 10.2423 14.057 10.886 13.7609 11.5288C13.7004 11.7005 13.5742 11.8413 13.41 11.9201C13.2459 11.999 13.0571 12.0095 12.8852 11.9495C12.3751 11.7877 11.9164 11.495 11.5547 11.1005C11.1931 10.7061 10.9412 10.2237 10.8242 9.70154C10.7384 8.95224 10.8603 8.1948 11.1762 7.5114L11.1943 7.46265C11.4037 6.9264 11.6322 6.39737 11.8633 5.87015L11.8903 5.81146C12.0013 5.55823 12.1127 5.30515 12.2244 5.05223L12.2424 5.0098C12.2757 4.95407 12.2974 4.89219 12.3061 4.82788C12.3148 4.76357 12.3105 4.69816 12.2932 4.63558C12.276 4.573 12.2464 4.51455 12.206 4.46374C12.1656 4.41293 12.1153 4.37082 12.0583 4.33993C11.6415 4.43698 11.2638 4.65792 10.9749 4.97368L10.9424 5.00618L10.9415 5.00709L10.9162 5.03237C10.4274 5.52368 9.96052 6.03636 9.51694 6.5689C8.58712 7.73335 7.7941 9.00073 7.15346 10.3461C6.75201 11.1202 6.50198 11.9637 6.41679 12.8315C6.3318 13.4984 6.35313 14.1745 6.47999 14.8348C6.54998 15.2536 6.76149 15.6358 7.07923 15.9176C7.39697 16.1993 7.80173 16.3636 8.22596 16.383C9.85187 16.4733 11.4669 16.0553 12.8446 15.1841C13.37 14.823 13.8747 14.4339 14.3567 14.0168C14.791 13.6052 15.1783 13.1583 15.5394 12.6897V12.6906ZM16.6209 9.73495C17.0683 9.71911 17.5117 9.82442 17.9042 10.0397C18.2967 10.2551 18.6237 10.5724 18.8508 10.9582C19.2868 11.6859 19.4123 12.5579 19.1984 13.3777L19.0756 13.8489C18.6125 15.6254 17.6186 17.2184 16.2264 18.4152C15.6802 18.8819 15.0681 19.3739 14.3459 19.791L14.3206 19.8064C13.9577 20.0158 13.5226 20.2677 13.0585 20.4889C11.9842 21.0323 10.8242 21.3889 9.62979 21.5424C8.85881 21.6426 8.08062 21.6823 7.30332 21.6616H7.28707C5.62695 21.6014 4.03004 21.0096 2.73164 19.9734L2.72081 19.9644L2.70997 19.9553C1.40527 18.8818 0.516855 17.386 0.19844 15.7267C-0.0304348 14.6035 -0.0615965 13.449 0.106356 12.3151C0.351041 10.6559 0.88971 9.05365 1.69706 7.58362C2.31458 6.40532 3.05021 5.2928 3.89262 4.2632C4.80848 3.14479 5.89274 2.17565 7.10651 1.39055C8.08577 0.753203 9.17988 0.31286 10.3276 0.0941572H10.3312L10.334 0.0923517C12.191 -0.254316 13.7131 0.429991 14.8695 1.22985C15.7425 1.82481 16.4794 2.59803 17.0317 3.49854L17.0533 3.53465L17.0741 3.57257C17.6837 4.67709 17.8478 5.97293 17.5327 7.19452C17.317 8.07022 17.0118 8.92245 16.6209 9.73495Z"
|
||||
fill="currentColor"
|
||||
/>,
|
||||
);
|
||||
|
||||
export const AgnoIcon = wrap(
|
||||
"0 0 20 18",
|
||||
<>
|
||||
<path
|
||||
d="M12.8184 0H4.63127V2.9279H10.8196L16.0933 17.2121H19.5L12.8184 0Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M8.38314 14.2843H0V17.2122H8.38314V14.2843Z" fill="currentColor" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const Ag2Icon = wrap(
|
||||
"0 0 23 23",
|
||||
<>
|
||||
<path
|
||||
d="M0 0.809983C0 1.55054 0.0462848 1.61997 0.671129 1.61997C1.24969 1.61997 1.3654 1.71254 1.43483 2.24481C1.48111 2.77709 1.61997 2.91594 2.26795 2.96223C2.91594 3.03165 3.00851 3.12422 3.00851 3.72592C3.00851 4.32763 3.07794 4.39705 3.70278 4.39705C4.35077 4.39705 4.39705 4.46648 4.39705 5.20704C4.39705 5.94759 4.35077 6.01702 3.70278 6.01702C3.07794 6.01702 3.00851 6.08645 3.00851 6.68815C3.00851 7.28985 2.91594 7.38242 2.26795 7.45185C1.50426 7.52128 1.50426 7.52128 1.43483 8.9561L1.3654 10.4141H3.00851V7.40556H3.70278C4.32763 7.40556 4.39705 7.33614 4.39705 6.71129V6.01702H7.40556V4.39705H14.8111V6.01702H17.8196V6.71129C17.8196 7.35928 17.8891 7.40556 18.6296 7.40556H19.4396V10.4141H20.8281V7.40556H20.1339C19.509 7.40556 19.4396 7.33614 19.4396 6.71129C19.4396 6.0633 19.3702 6.01702 18.6296 6.01702C17.8659 6.01702 17.8196 5.97074 17.8196 5.20704C17.8196 4.44334 17.8659 4.39705 18.6296 4.39705C19.3702 4.39705 19.4396 4.35077 19.4396 3.70278C19.4396 3.07794 19.509 3.00851 20.1339 3.00851C20.7587 3.00851 20.8281 2.93908 20.8281 2.33738C20.8281 1.73568 20.9207 1.64311 21.5918 1.57368C22.2398 1.50426 22.3324 1.41169 22.4018 0.740556C22.4713 0.0231424 22.4481 0 21.6613 0C20.8976 0 20.8281 0.0462847 20.8281 0.694272C20.8281 1.31912 20.7587 1.38854 20.1339 1.38854C19.4859 1.38854 19.4396 1.45797 19.4396 2.19853C19.4396 2.96223 19.3933 3.00851 18.6296 3.00851C17.8891 3.00851 17.8196 3.05479 17.8196 3.70278V4.39705H14.8111V3.00851H7.40556V4.39705H4.39705V3.70278C4.39705 3.07794 4.32763 3.00851 3.72592 3.00851C3.12422 3.00851 3.03165 2.91594 2.96223 2.24481C2.91594 1.61997 2.77709 1.48111 2.26795 1.43483C1.71254 1.3654 1.61997 1.24969 1.61997 0.671129C1.61997 0.0462848 1.55054 0 0.809984 0C0.0462848 0 0 0.0462848 0 0.809983Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M5.9706 8.14607C5.90117 8.8172 5.8086 8.90977 5.13747 8.97919L4.37377 9.04862L4.51263 11.9183L11.1776 11.9877L17.8195 12.034V9.02548H17.1252C16.4772 9.02548 16.431 8.95605 16.431 8.2155V7.40551H6.04002L5.9706 8.14607ZM9.02539 9.71975C9.02539 10.3677 8.95596 10.414 8.21541 10.414C7.47485 10.414 7.40542 10.3677 7.40542 9.71975C7.40542 9.07176 7.47485 9.02548 8.21541 9.02548C8.95596 9.02548 9.02539 9.07176 9.02539 9.71975ZM14.811 9.71975C14.811 10.3446 14.7416 10.414 14.1167 10.414C13.4919 10.414 13.4224 10.3446 13.4224 9.71975C13.4224 9.09491 13.4919 9.02548 14.1167 9.02548C14.7416 9.02548 14.811 9.09491 14.811 9.71975Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M1.61997 15.1583C1.61997 15.922 1.57368 15.9683 0.809983 15.9683H0V22.2167H1.61997V18.9768H4.62848V22.2167H6.24844V15.9683H5.55417C4.90619 15.9683 4.8599 15.8989 4.8599 15.1583V14.3483H1.61997V15.1583ZM4.62848 17.3568H1.61997V15.9683H4.62848V17.3568Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M9.48829 15.1583C9.48829 15.8989 9.44201 15.9683 8.79402 15.9683H8.09975V20.5968H8.79402C9.44201 20.5968 9.48829 20.6662 9.48829 21.4068V22.2167H12.7282V21.4068C12.7282 20.6431 12.7745 20.5968 13.5382 20.5968H14.3482V17.3337L11.224 17.4725L11.1545 18.2131C11.0851 18.9537 11.1083 18.9768 11.8951 18.9768C12.6819 18.9768 12.7282 19.0231 12.7282 19.7868V20.5968H9.71972V15.9683H14.3482V14.3483H9.48829V15.1583Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M16.0147 15.0889L16.0841 15.8526L20.8283 15.9914V16.6626C20.8283 17.3337 20.8283 17.3337 19.2777 17.4031L17.7041 17.4725L17.6346 18.2131C17.5652 18.8611 17.4726 18.9537 16.8246 19.0231L16.0841 19.0925L15.9452 22.2167H22.4482V20.5968H17.5883V18.9768H20.8283V18.3057C20.8283 17.704 20.9209 17.6114 21.592 17.542C22.24 17.4725 22.3325 17.38 22.402 16.7088C22.4714 15.9914 22.4482 15.9683 21.6614 15.9683C20.8746 15.9683 20.8283 15.922 20.8283 15.1583V14.3483H15.9452L16.0147 15.0889Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
export const LlamaIndexIcon = wrap(
|
||||
"0 0 21 21",
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M4.12501 0C1.84683 0 0 1.85387 0 4.14074V16.563C0 18.8498 1.84682 20.7037 4.12501 20.7037H16.5C18.7781 20.7037 20.625 18.8498 20.625 16.563V4.14074C20.625 1.85387 18.7781 0 16.5 0H4.12501ZM9.64068 13.7713C10.1939 13.9655 11.6322 14.2067 12.9599 13.6179C12.9096 13.761 12.8323 14.1077 12.99 14.3846C13.0953 14.5694 13.2127 14.7261 13.324 14.8747C13.5635 15.1945 13.7746 15.4762 13.7746 15.9179C13.7746 16.5553 13.5382 16.9313 13.3776 17.1868C13.345 17.2386 13.3155 17.2855 13.2918 17.3286C13.1811 17.3184 12.9599 17.3961 12.9599 17.7886H13.7746V17.1446C13.8953 17.0526 14.1849 16.7337 14.3781 16.1939C14.5712 15.6542 14.5993 15.1717 14.5893 14.9979C14.6798 15.2024 14.9755 15.6787 15.4342 15.9486C15.5046 16.2041 15.6092 16.9054 15.4643 17.6659C15.3939 17.6659 15.229 17.7273 15.1324 17.9726H15.9471C16.0477 17.7068 16.2187 17.0035 16.098 16.3166C16.1684 16.2246 16.3092 15.9302 16.3092 15.4886C16.3092 15.047 16.1684 14.7321 16.098 14.6299V13.4646C15.9471 13.1784 15.7057 12.4035 15.9471 11.5939C16.1382 11.5735 16.5325 11.4038 16.5808 10.8886C16.6411 10.2446 16.3997 9.3859 15.9471 9.07923C15.6696 8.89121 15.4261 8.95679 15.1333 9.03568C14.9484 9.08547 14.7439 9.14056 14.4987 9.14056C14.1814 9.14056 13.8867 9.00648 13.5199 8.83959C13.1545 8.67329 12.7174 8.47442 12.115 8.34321C11.1549 8.1341 10.1211 8.16864 9.18483 8.19992C8.82644 8.2119 8.48234 8.2234 8.16214 8.22057C8.11185 7.38232 7.95696 5.47893 7.73972 4.5712C7.46815 3.43654 6.65343 2.91519 6.20082 2.70052V3.22186C6.13042 3.06853 5.89909 2.74959 5.53697 2.70052C5.61745 2.98675 5.76027 3.59601 5.68785 3.74318C5.59733 3.92719 5.41629 3.98852 5.20508 4.04986C5.11676 4.0755 5.039 4.07434 4.96737 4.07327C4.86769 4.07178 4.77989 4.07048 4.69211 4.14186C4.54123 4.26453 4.36019 4.63254 4.60159 4.87787C4.84296 5.12319 5.26541 5.30719 5.68785 5.33786V5.6752C5.57721 5.77743 5.35596 6.17201 5.35596 6.93254C5.35596 7.69309 5.47664 8.00587 5.537 8.0672C5.49676 8.28187 5.44648 8.68055 5.537 9.23256C5.59581 9.59128 5.74391 9.85847 5.8171 9.99051C5.82534 10.0054 5.83263 10.0185 5.83873 10.0299C5.78845 10.3263 5.73615 11.0358 5.92925 11.5019C6.17065 12.0846 6.53274 12.6059 7.49832 13.1272V14.4459C7.57879 14.865 7.71558 15.832 7.619 16.3473C7.52246 16.8624 7.37763 17.2161 7.31727 17.3286C7.18651 17.3286 6.98535 17.4513 7.01554 17.8193H7.619C7.70955 17.6966 7.92679 17.3593 8.07164 16.9912C8.21646 16.6232 8.41361 15.9588 8.49408 15.6726C8.57453 15.9077 8.71735 16.568 8.64494 17.3286C8.51418 17.4001 8.23457 17.6353 8.16216 18.0032H8.97686C9.05733 17.9215 9.23032 17.6905 9.27862 17.4206C9.32689 17.1507 9.31883 16.3473 9.30878 15.9793C9.3993 15.8668 9.58638 15.4947 9.61051 14.9059C9.63465 14.3171 9.64068 13.9041 9.64068 13.7713Z"
|
||||
fill="currentColor"
|
||||
/>,
|
||||
);
|
||||
|
||||
export const StrandsIcon = wrap(
|
||||
"0 0 290 463",
|
||||
<>
|
||||
<path
|
||||
d="M97.2902 52.7884C85.0674 49.1667 72.2234 56.1389 68.6017 68.3616C64.9801 80.5843 71.9524 93.4283 84.1749 97.0501L235.117 139.775C245.223 142.769 246.357 156.628 236.874 161.226L32.546 260.291C-14.9439 283.316 -9.16107 352.74 41.4835 367.591L189.551 411.009L190.125 411.169C202.183 414.376 214.665 407.396 218.196 395.355C221.784 383.122 214.774 370.296 202.541 366.709L54.4738 323.291C44.3447 320.321 43.1879 306.436 52.6857 301.831L257.014 202.766C304.432 179.776 298.758 110.483 248.233 95.512L97.2902 52.7884Z"
|
||||
fill="currentColor"
|
||||
opacity="0.5"
|
||||
/>
|
||||
<path
|
||||
d="M259.147 0.981812C271.389 -2.57498 284.197 4.46571 287.754 16.7074C291.311 28.9492 284.27 41.757 272.028 45.3138L71.1727 103.671C40.7142 112.521 37.1976 154.262 65.7459 168.083L241.343 253.093C307.872 285.302 299.794 382.546 228.862 403.336L30.4041 461.502C18.1707 465.088 5.34708 458.078 1.76153 445.844C-1.8239 433.611 5.18637 420.787 17.4197 417.202L215.878 359.035C246.277 350.125 249.739 308.449 221.226 294.645L45.6297 209.635C-20.9834 177.386 -12.7772 79.9893 58.2928 59.3402L259.147 0.981812Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
export const AdkIcon = wrap(
|
||||
"0 0 512 512",
|
||||
<>
|
||||
<g transform="scale(0.9)" style={{ transformOrigin: "center" }}>
|
||||
<rect
|
||||
width="512"
|
||||
height="512"
|
||||
rx="128"
|
||||
ry="128"
|
||||
fill="currentColor"
|
||||
fillOpacity={0.15}
|
||||
/>
|
||||
</g>
|
||||
<g transform="scale(0.85)" style={{ transformOrigin: "center" }}>
|
||||
<path
|
||||
d="M0 0 C1.31304611 -0.0071553 2.62609222 -0.01431061 3.97892761 -0.02168274 C5.41728187 -0.02452244 6.85563638 -0.02723424 8.29399109 -0.02983093 C9.82245027 -0.03610391 11.35090795 -0.04275144 12.87936401 -0.04974365 C17.88434302 -0.07069886 22.88932201 -0.08114259 27.89433289 -0.09111023 C29.62590742 -0.0951619 31.35748179 -0.09927894 33.08905602 -0.10346031 C41.23265771 -0.12249846 49.37625046 -0.13672865 57.5198701 -0.14507228 C66.87604093 -0.1548198 76.23197362 -0.18107987 85.58806068 -0.22157317 C92.8470237 -0.25189579 100.10591912 -0.2665566 107.36494416 -0.26985329 C111.68704116 -0.27218669 116.00888341 -0.28092007 120.33091164 -0.30631447 C156.48887641 -0.50776358 186.91460747 5.52279404 213.96855164 31.39717102 C235.95430616 54.21378255 243.8744487 81.85805342 243.56230164 113.01435852 C242.82592359 140.93045009 229.0853173 166.40142904 209.24198914 185.52217102 C191.67223194 201.09852568 168.35185318 212.20479416 144.56210327 212.29434204 C143.24905716 212.30149734 141.93601105 212.30865265 140.58317566 212.31602478 C139.14482141 212.31886448 137.7064669 212.32157628 136.26811218 212.32417297 C134.739653 212.33044595 133.21119532 212.33709348 131.68273926 212.34408569 C126.67776025 212.3650409 121.67278127 212.37548463 116.66777039 212.38545227 C114.93619586 212.38950394 113.20462148 212.39362098 111.47304726 212.39780235 C103.32944556 212.4168405 95.18585281 212.43107069 87.04223317 212.43941432 C77.68606234 212.44916184 68.33012965 212.47542191 58.97404259 212.51591522 C51.71507957 212.54623783 44.45618416 212.56089864 37.19715911 212.56419533 C32.87506212 212.56652873 28.55321986 212.57526211 24.23119164 212.60065651 C-11.92677313 212.80210562 -42.3525042 206.771548 -69.40644836 180.89717102 C-91.39220289 158.08055949 -99.31234543 130.43628862 -99.00019836 99.27998352 C-98.26382032 71.36389195 -84.52321403 45.892913 -64.67988586 26.77217102 C-47.11012867 11.19581636 -23.78974991 0.08954788 0 0 Z M-46.71894836 56.14717102 C-47.38668274 56.79299133 -48.05441711 57.43881165 -48.74238586 58.10420227 C-61.67195913 71.53106682 -66.89202154 90.95525774 -66.71894836 109.14717102 C-65.29530616 129.51620557 -57.05214152 145.96044845 -42.71894836 160.14717102 C-42.07312805 160.8149054 -41.42730774 161.48263977 -40.76191711 162.17060852 C-24.98727636 177.36100332 -3.35172872 180.30022142 17.56498718 180.29243469 C19.52295479 180.29747261 19.52295479 180.29747261 21.52047729 180.3026123 C25.07862687 180.31139119 28.63675235 180.31378787 32.19491172 180.31442809 C34.42546379 180.31514223 36.65600987 180.31728344 38.88656044 180.31992531 C46.68985503 180.32915951 54.49313262 180.33326274 62.2964325 180.33247375 C69.53487138 180.33187394 76.77323552 180.34241182 84.01165551 180.35821742 C90.25489513 180.37135716 96.49811051 180.37665495 102.74136382 180.37601835 C106.45736026 180.37576518 110.17329021 180.37851461 113.88927269 180.3891964 C118.04294698 180.40081177 122.19645719 180.39609147 126.35014343 180.39009094 C127.55737061 180.39571045 128.76459778 180.40132996 130.00840759 180.40711975 C152.39194774 180.32551845 172.41914732 174.77307242 188.63261414 158.82295227 C189.50659851 157.93994446 190.38058289 157.05693665 191.28105164 156.14717102 C192.2826532 155.17844055 192.2826532 155.17844055 193.30448914 154.19013977 C206.23406241 140.76327522 211.45412481 121.3390843 211.28105164 103.14717102 C209.85740944 82.77813647 201.61424479 66.33389359 187.28105164 52.14717102 C186.63523132 51.47943665 185.98941101 50.81170227 185.32402039 50.12373352 C169.54937963 34.93333872 147.91383199 31.99412062 126.99711609 32.00190735 C125.69180435 31.99854874 124.38649261 31.99519012 123.04162598 31.99172974 C119.4834764 31.98295085 115.92535092 31.98055418 112.36719155 31.97991395 C110.13663948 31.97919981 107.9060934 31.9770586 105.67554283 31.97441673 C97.87224824 31.96518253 90.06897065 31.9610793 82.26567078 31.96186829 C75.02723189 31.9624681 67.78886776 31.95193022 60.55044776 31.93612462 C54.30720814 31.92298488 48.06399276 31.91768709 41.82073945 31.9183237 C38.10474301 31.91857686 34.38881306 31.91582744 30.67283058 31.90514565 C26.51915629 31.89353027 22.36564608 31.89825057 18.21195984 31.9042511 C17.00473267 31.89863159 15.79750549 31.89301208 14.55369568 31.88722229 C-9.74912493 31.97582051 -29.57176548 38.82295232 -46.71894836 56.14717102 Z "
|
||||
fill="currentColor"
|
||||
transform="translate(183.7189483642578,64.85282897949219)"
|
||||
/>
|
||||
<path
|
||||
d="M0 0 C4.58944252 2.59067597 8.07973488 5.95843049 9.58984375 11.1171875 C10.43895989 16.98821911 10.4991943 22.35248537 7.52734375 27.6171875 C4.02449419 32.28765359 0.50237103 34.69595422 -5.24609375 35.52734375 C-11.27485226 35.82346665 -15.88471772 35.3237664 -20.78515625 31.6484375 C-25.50423874 27.20160977 -26.66198643 23.50460631 -26.97265625 17.1796875 C-26.75311095 11.33246435 -25.4377323 7.39134984 -21.41015625 3.1171875 C-15.27205905 -1.89350409 -7.4975257 -2.74625739 0 0 Z "
|
||||
fill="currentColor"
|
||||
transform="translate(327.41015625,153.8828125)"
|
||||
/>
|
||||
<path
|
||||
d="M0 0 C4.58944252 2.59067597 8.07973488 5.95843049 9.58984375 11.1171875 C10.43895989 16.98821911 10.4991943 22.35248537 7.52734375 27.6171875 C4.02449419 32.28765359 0.50237103 34.69595422 -5.24609375 35.52734375 C-11.27485226 35.82346665 -15.88471772 35.3237664 -20.78515625 31.6484375 C-25.50423874 27.20160977 -26.66198643 23.50460631 -26.97265625 17.1796875 C-26.75311095 11.33246435 -25.4377323 7.39134984 -21.41015625 3.1171875 C-15.27205905 -1.89350409 -7.4975257 -2.74625739 0 0 Z "
|
||||
fill="currentColor"
|
||||
transform="translate(203.41015625,153.8828125)"
|
||||
/>
|
||||
</g>
|
||||
</>,
|
||||
22,
|
||||
22,
|
||||
);
|
||||
|
||||
export const MicrosoftIcon = wrap(
|
||||
"0 0 168 150",
|
||||
<path
|
||||
d="M 90.960771,144.01092 C 83.871729,143.26 78.629814,137.77624 77.497686,129.9267 L 77,126.47603 66.666667,120.88809 c -11.453816,-6.19386 -11.574026,-6.21767 -19.889669,-3.93922 -3.548028,0.97214 -4.744806,1.03191 -7.487689,0.3739 -7.668686,-1.83969 -12.347338,-8.28258 -11.83681,-16.30027 0.32442,-5.094928 2.403075,-8.622947 7.066932,-11.99443 4.778858,-3.454616 5.019843,-4.120995 4.90377,-13.560053 -0.05576,-4.534114 -0.407867,-8.816529 -0.782468,-9.516476 -0.3746,-0.699948 -2.321598,-2.484546 -4.326661,-3.965773 -10.423241,-7.700099 -9.089311,-22.333363 2.489929,-27.314618 4.361515,-1.876273 9.230904,-1.566238 14.529332,0.925087 2.016667,0.948239 4.192811,1.727001 4.835876,1.730582 1.804839,0.01005 17.97451,-8.554696 19.664124,-10.415691 0.825,-0.908681 1.5,-2.480968 1.5,-3.49397 0,-3.421097 1.963581,-8.402658 4.314827,-10.94662 7.613967,-8.2380348 20.7353,-6.2693941 25.43133,3.815552 0.86578,1.859317 1.58771,4.430576 1.60429,5.713909 0.0519,4.018088 1.34305,5.270747 10.7225,10.402806 10.15475,5.556271 11.53453,5.809288 17.79887,3.26386 6.99736,-2.843282 13.10843,-1.843203 17.88984,2.927677 3.13303,3.126134 4.23834,6.003411 4.23834,11.032968 0,5.493267 -1.48953,8.663767 -5.71662,12.167916 -1.68396,1.395958 -3.48661,3.348207 -4.00589,4.338332 -1.21883,2.32396 -1.28631,17.513591 -0.0872,19.626576 0.44534,0.784742 2.41205,2.862124 4.37047,4.616406 3.95007,3.538337 4.71411,4.960968 5.48763,10.21789 1.19183,8.09974 -4.74788,15.76895 -13.26802,17.13138 -1.87417,0.29969 -4.10585,0.001 -7.34014,-0.98093 -5.98226,-1.81706 -6.66487,-1.78671 -11.26294,0.50075 -14.92444,7.42466 -18.84395,10.06058 -18.84395,12.67278 0,2.48173 -2.22339,7.53574 -4.17272,9.48507 -4.0301,4.0301 -7.57856,5.23819 -13.533179,4.60744 z m -6.531423,-28.07026 c 4.199271,-2.5953 4.898667,-4.53547 4.901493,-13.59708 l 0.0025,-7.989752 -4.59761,-4.343582 c -4.958595,-4.684622 -6.38161,-6.911549 -7.050312,-11.033285 -0.235894,-1.453997 -0.55715,-3.278075 -0.713903,-4.053508 -0.21071,-1.042351 -3.127948,-2.910559 -11.190864,-7.166666 -11.973227,-6.320212 -14.091873,-6.8395 -16.810947,-4.120426 -1.587326,1.587326 -1.635656,1.921964 -1.61274,11.166667 0.01539,6.206982 0.305978,10.027844 0.833333,10.957106 0.445341,0.784742 2.367903,2.822432 4.272362,4.5282 3.733037,3.343573 6.204015,8.403928 6.204015,12.705306 0,4.87154 0.669342,5.46226 14.333333,12.64971 5.24205,2.75739 7.359719,2.81247 11.429348,0.29731 z m 33.522362,-2.16144 c 4.19011,-2.29676 8.18086,-4.92932 8.86835,-5.85013 0.72494,-0.97098 1.41491,-3.33785 1.64266,-5.63498 0.5183,-5.227634 2.37851,-9.068263 5.76818,-11.9091 6.80819,-5.705854 6.43577,-4.884175 6.43577,-14.199286 0,-6.609305 -0.23102,-8.704418 -1.12705,-10.221276 -2.07226,-3.508054 -7.11869,-4.462516 -11.05621,-2.091128 -1.18254,0.712189 -5.15008,2.871679 -8.81674,4.798867 -10.19683,5.359417 -10.24838,5.396261 -10.6678,7.62369 -1.33265,7.077493 -1.80644,7.958279 -6.94374,12.908529 L 97,94.075482 v 8.591898 8.59189 l 2.261036,2.3328 c 5.503034,5.67769 8.617084,5.70887 18.690674,0.18715 z M 85.226526,63.332805 c 5.793899,-2.740963 10.380956,-2.74862 15.440144,-0.02577 5.73327,3.085639 7.83348,2.809868 17.13709,-2.250205 4.3246,-2.352078 8.42252,-5.019987 9.1065,-5.928685 0.8434,-1.120513 1.24359,-2.770706 1.24359,-5.128053 0,-2.52814 -0.33908,-3.796055 -1.24359,-4.650161 -1.04206,-0.983982 -5.33792,-3.37929 -17.57693,-9.800613 -2.46548,-1.293544 -4.57848,-1.073396 -8.66666,0.902959 -5.039428,2.436217 -10.293912,2.436217 -15.333337,0 -2.016666,-0.97492 -4.379358,-1.775513 -5.250426,-1.779095 -2.130547,-0.0088 -19.131831,9.028711 -20.405437,10.847038 -1.31471,1.877011 -1.295362,6.792852 0.03559,9.043587 1.318607,2.229853 16.458479,10.641666 19.286936,10.71595 1.1,0.02889 3.901936,-0.847237 6.226526,-1.946948 z"
|
||||
fill="currentColor"
|
||||
/>,
|
||||
);
|
||||
|
||||
export const AnthropicIcon = wrap(
|
||||
"0 0 24 24",
|
||||
<path
|
||||
d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z"
|
||||
fill="currentColor"
|
||||
/>,
|
||||
);
|
||||
|
||||
export const DeepAgentsIcon = wrap(
|
||||
"0 0 98 98",
|
||||
<>
|
||||
<path
|
||||
d="M72.5361 42.2004V22.0426H51.7354L51.7354 22.5212C62.5113 22.7991 71.2917 31.6243 72.1695 42.2004H72.5361Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M49.223 22.0428H24.8759V63.0891C24.8759 70.6689 30.7215 75.3844 40.133 75.3844H72.5471V45.3962H49.223V22.0428Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
export const SpringIcon = wrap(
|
||||
"0 0 24 24",
|
||||
<path
|
||||
d="M21.8537 1.4158a10.4504 10.4504 0 0 1-1.284 2.2471A11.9666 11.9666 0 1 0 3.8518 20.7757l.4445.3951a11.9543 11.9543 0 0 0 19.6316-8.2971c.3457-3.0126-.568-6.8649-2.0743-11.458zM5.5805 20.8745a1.0174 1.0174 0 1 1-.1482-1.4323 1.0396 1.0396 0 0 1 .1482 1.4323zm16.1991-3.5806c-2.9385 3.9263-9.2601 2.5928-13.2852 2.7904 0 0-.7161.0494-1.4323.1481 0 0 .2717-.1234.6174-.2469 2.8398-.9877 4.1732-1.1853 5.9018-2.0743 3.2349-1.6545 6.4698-5.2844 7.1118-9.0379-1.2347 3.6053-4.9881 6.7167-8.3959 7.9761-2.3459.8643-6.5685 1.7039-6.5685 1.7039l-.1729-.0988c-2.8645-1.4076-2.9632-7.6304 2.2718-9.6306 2.2966-.889 4.4696-.395 6.9637-.9877 2.6422-.6174 5.7043-2.5929 6.939-5.1857 1.3828 4.1732 3.062 10.643.0493 14.6434z"
|
||||
fill="currentColor"
|
||||
/>,
|
||||
);
|
||||
|
||||
const FRAMEWORK_ICONS: Record<string, React.ComponentType<IconProps>> = {
|
||||
"built-in-agent": CopilotKitKiteIcon,
|
||||
deepagents: DeepAgentsIcon,
|
||||
"langgraph-python": LanggraphIcon,
|
||||
"langgraph-typescript": LanggraphIcon,
|
||||
"langgraph-fastapi": LanggraphIcon,
|
||||
mastra: MastraIcon,
|
||||
"pydantic-ai": PydanticAiIcon,
|
||||
"crewai-crews": CrewaiIcon,
|
||||
agno: AgnoIcon,
|
||||
ag2: Ag2Icon,
|
||||
llamaindex: LlamaIndexIcon,
|
||||
strands: StrandsIcon,
|
||||
"strands-typescript": StrandsIcon,
|
||||
"google-adk": AdkIcon,
|
||||
"ms-agent-python": MicrosoftIcon,
|
||||
"ms-agent-dotnet": MicrosoftIcon,
|
||||
"ms-agent-harness-dotnet": MicrosoftIcon,
|
||||
"claude-sdk-python": AnthropicIcon,
|
||||
"claude-sdk-typescript": AnthropicIcon,
|
||||
"spring-ai": SpringIcon,
|
||||
};
|
||||
|
||||
interface FrameworkLogoProps {
|
||||
slug: string;
|
||||
fallbackSrc?: string | null;
|
||||
className?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function FrameworkLogo({
|
||||
slug,
|
||||
fallbackSrc,
|
||||
className,
|
||||
size = 16,
|
||||
}: FrameworkLogoProps) {
|
||||
const Icon = FRAMEWORK_ICONS[slug];
|
||||
if (Icon) {
|
||||
return <Icon className={className} width={size} height={size} />;
|
||||
}
|
||||
if (fallbackSrc) {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={fallbackSrc}
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
style={{ width: size, height: size, display: "inline-block" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
|
||||
interface GithubIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CLASSNAME = "text-icon";
|
||||
|
||||
const GithubIcon = ({ className }: GithubIconProps) => {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={[DEFAULT_CLASSNAME, className].filter(Boolean).join(" ")}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M9.99215 1C5.01972 1 1 5.04936 1 10.059C1 14.0634 3.57558 17.4531 7.14858 18.6528C7.5953 18.743 7.75893 18.4579 7.75893 18.2181C7.75893 18.008 7.74421 17.2882 7.74421 16.5381C5.24281 17.0782 4.72191 15.4582 4.72191 15.4582C4.31992 14.4083 3.7243 14.1385 3.7243 14.1385C2.90559 13.5836 3.78393 13.5836 3.78393 13.5836C4.69209 13.6436 5.16863 14.5134 5.16863 14.5134C5.97243 15.8932 7.26767 15.5033 7.78875 15.2633C7.86311 14.6784 8.10147 14.2734 8.35455 14.0485C6.35951 13.8385 4.26047 13.0586 4.26047 9.57893C4.26047 8.58905 4.61755 7.77918 5.18335 7.14932C5.09408 6.9244 4.78136 5.99433 5.27281 4.74952C5.27281 4.74952 6.03206 4.50951 7.74402 5.6794C8.47697 5.4811 9.23285 5.38023 9.99215 5.37938C10.7514 5.37938 11.5254 5.48448 12.2401 5.6794C13.9522 4.50951 14.7115 4.74952 14.7115 4.74952C15.2029 5.99433 14.89 6.9244 14.8008 7.14932C15.3815 7.77918 15.7238 8.58905 15.7238 9.57893C15.7238 13.0586 13.6248 13.8234 11.6148 14.0485C11.9425 14.3334 12.2252 14.8733 12.2252 15.7283C12.2252 16.9431 12.2105 17.918 12.2105 18.2179C12.2105 18.4579 12.3743 18.743 12.8208 18.653C16.3938 17.4529 18.9694 14.0634 18.9694 10.059C18.9841 5.04936 14.9497 1 9.99215 1Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default GithubIcon;
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Icon registry for shell-docs.
|
||||
*
|
||||
* `customIcons` maps stable string keys to icon components and is consumed by:
|
||||
* - `FrameworkOverview` (via `iconKey` prop), and
|
||||
* - integration `index.mdx` files that previously referenced `customIcons.<key>`
|
||||
* from `docs/lib/icons/custom-icons.tsx`.
|
||||
*
|
||||
* Keys mirror the legacy `docs/` registry so MDX references remain stable.
|
||||
*/
|
||||
import {
|
||||
AdkIcon,
|
||||
Ag2Icon,
|
||||
AgnoIcon,
|
||||
AnthropicIcon,
|
||||
CrewaiIcon,
|
||||
DeepAgentsIcon,
|
||||
LanggraphIcon,
|
||||
LlamaIndexIcon,
|
||||
MastraIcon,
|
||||
MicrosoftIcon,
|
||||
PydanticAiIcon,
|
||||
SpringIcon,
|
||||
StrandsIcon,
|
||||
} from "./framework-icons";
|
||||
import { A2AIcon } from "./a2a";
|
||||
import { AgentSpecMarkIcon } from "./agent-spec-mark";
|
||||
|
||||
export const customIcons = {
|
||||
a2a: A2AIcon,
|
||||
adk: AdkIcon,
|
||||
ag2: Ag2Icon,
|
||||
agentspecMark: AgentSpecMarkIcon,
|
||||
agno: AgnoIcon,
|
||||
anthropic: AnthropicIcon,
|
||||
awsStrands: StrandsIcon,
|
||||
crewai: CrewaiIcon,
|
||||
deepagents: DeepAgentsIcon,
|
||||
langgraph: LanggraphIcon,
|
||||
llamaindex: LlamaIndexIcon,
|
||||
mastra: MastraIcon,
|
||||
microsoft: MicrosoftIcon,
|
||||
pydantic: PydanticAiIcon,
|
||||
spring: SpringIcon,
|
||||
} as const;
|
||||
|
||||
export type IconKey = keyof typeof customIcons;
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
|
||||
interface RocketIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CLASSNAME = "text-icon";
|
||||
|
||||
const RocketIcon = ({ className }: RocketIconProps) => {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={[DEFAULT_CLASSNAME, className].filter(Boolean).join(" ")}
|
||||
>
|
||||
<path
|
||||
d="M17.4885 3.68125C17.47 3.37651 17.3406 3.08907 17.1247 2.87319C16.9088 2.65731 16.6214 2.52789 16.3166 2.50938C15.3338 2.45078 12.8221 2.54063 10.7377 4.62422L10.3666 5H5.80958C5.6448 4.99907 5.48148 5.03092 5.32911 5.09369C5.17675 5.15646 5.03839 5.24889 4.92208 5.36563L2.24239 8.04688C2.07806 8.2111 1.96274 8.41788 1.90939 8.644C1.85603 8.87012 1.86675 9.10663 1.94034 9.327C2.01393 9.54737 2.14748 9.74286 2.326 9.89155C2.50452 10.0402 2.72095 10.1362 2.95099 10.1688L5.95646 10.5883L9.41036 14.0422L9.82989 17.0492C9.86216 17.2793 9.95812 17.4957 10.1069 17.6741C10.2558 17.8525 10.4515 17.9856 10.6721 18.0586C10.8005 18.1015 10.9351 18.1234 11.0705 18.1234C11.2345 18.1237 11.3969 18.0916 11.5485 18.0288C11.7 17.9661 11.8376 17.874 11.9533 17.7578L14.6346 15.0781C14.7513 14.9618 14.8438 14.8235 14.9065 14.6711C14.9693 14.5187 15.0011 14.3554 15.0002 14.1906V9.63359L15.3729 9.26094C17.4572 7.17656 17.5471 4.66484 17.4885 3.68125ZM5.80958 6.25H9.11661L6.02833 9.3375L3.12521 8.93281L5.80958 6.25ZM11.6229 5.51172C12.2234 4.90745 12.946 4.43829 13.7424 4.13556C14.5387 3.83284 15.3905 3.70352 16.2408 3.75625C16.2956 4.60702 16.1676 5.45965 15.8655 6.25685C15.5634 7.05405 15.0941 7.77734 14.4893 8.37813L10.0002 12.8656L7.13458 10L11.6229 5.51172ZM13.7502 14.1906L11.0682 16.875L10.6627 13.9711L13.7502 10.8836V14.1906ZM7.95646 14.9328C7.60489 15.7031 6.42911 17.5 3.12521 17.5C2.95945 17.5 2.80048 17.4342 2.68326 17.3169C2.56605 17.1997 2.50021 17.0408 2.50021 16.875C2.50021 13.5711 4.29708 12.3953 5.06739 12.043C5.14208 12.0089 5.22275 11.9899 5.30479 11.987C5.38683 11.9841 5.46863 11.9974 5.54553 12.0261C5.62243 12.0549 5.69292 12.0985 5.75297 12.1544C5.81302 12.2104 5.86146 12.2777 5.89552 12.3523C5.92958 12.427 5.9486 12.5077 5.95148 12.5897C5.95437 12.6718 5.94107 12.7536 5.91234 12.8305C5.88361 12.9074 5.84002 12.9779 5.78405 13.0379C5.72808 13.098 5.66083 13.1464 5.58614 13.1805C5.0838 13.4094 3.97521 14.1461 3.77989 16.2203C5.85411 16.025 6.59239 14.9164 6.81974 14.4141C6.8538 14.3394 6.90224 14.2721 6.96229 14.2162C7.02234 14.1602 7.09283 14.1166 7.16973 14.0879C7.24663 14.0591 7.32843 14.0458 7.41047 14.0487C7.49251 14.0516 7.57317 14.0706 7.64786 14.1047C7.72255 14.1387 7.7898 14.1872 7.84577 14.2472C7.90174 14.3073 7.94533 14.3778 7.97406 14.4547C8.00279 14.5316 8.01609 14.6134 8.0132 14.6954C8.01032 14.7775 7.9913 14.8581 7.95724 14.9328H7.95646Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default RocketIcon;
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
|
||||
interface WindsurfIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const WindsurfIcon = ({ className }: WindsurfIconProps) => {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<title>Windsurf</title>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M23.78 5.004h-.228a2.187 2.187 0 00-2.18 2.196v4.912c0 .98-.804 1.775-1.76 1.775a1.818 1.818 0 01-1.472-.773L13.168 5.95a2.197 2.197 0 00-1.81-.95c-1.134 0-2.154.972-2.154 2.173v4.94c0 .98-.797 1.775-1.76 1.775-.57 0-1.136-.289-1.472-.773L.408 5.098C.282 4.918 0 5.007 0 5.228v4.284c0 .216.066.426.188.604l5.475 7.889c.324.466.8.812 1.351.938 1.377.316 2.645-.754 2.645-2.117V11.89c0-.98.787-1.775 1.76-1.775h.002c.586 0 1.135.288 1.472.773l4.972 7.163a2.15 2.15 0 001.81.95c1.158 0 2.151-.973 2.151-2.173v-4.939c0-.98.787-1.775 1.76-1.775h.194c.122 0 .22-.1.22-.222V5.225a.221.221 0 00-.22-.222z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default WindsurfIcon;
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useFramework } from "./framework-provider";
|
||||
import { getRuntimeConfig } from "@/lib/runtime-config.client";
|
||||
|
||||
export function IntegrationGrid({
|
||||
path,
|
||||
description,
|
||||
}: {
|
||||
path?: string;
|
||||
exclude?: string[];
|
||||
description?: string;
|
||||
}) {
|
||||
const { framework } = useFramework();
|
||||
|
||||
// On a framework-scoped route the user already chose a backend — hide.
|
||||
if (framework) return null;
|
||||
|
||||
// Shell host is now read at runtime from window.__SHOWCASE_CONFIG__
|
||||
// (set by the root layout) so the rendered <a href> reflects the
|
||||
// current deploy's NEXT_PUBLIC_SHELL_URL without a rebuild. Pulled
|
||||
// after the early-return so we never call into the client reader on
|
||||
// renders that produce no DOM.
|
||||
const shellHost = getRuntimeConfig().shellUrl;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Choose your AI backend</h2>
|
||||
{description && (
|
||||
<p className="mb-4 text-[var(--text-secondary)]">{description}</p>
|
||||
)}
|
||||
<div className="shell-docs-radius-surface mb-4 bg-[var(--bg-elevated)] p-4 text-sm text-[var(--text-muted)]">
|
||||
See{" "}
|
||||
<a
|
||||
href={`${shellHost}/integrations`}
|
||||
className="text-[var(--accent)]"
|
||||
// shellHost is the SSR placeholder during server-render and the
|
||||
// real value post-hydration (runtime-config.client.ts). React
|
||||
// would otherwise log a hydration mismatch on this href every
|
||||
// pageload; suppression scopes to THIS attribute mismatch only.
|
||||
suppressHydrationWarning
|
||||
>
|
||||
Integrations
|
||||
</a>{" "}
|
||||
for all available frameworks{path ? ` (${path})` : ""}.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock";
|
||||
import { Bot, Code2, MessagesSquare, Paintbrush } from "lucide-react";
|
||||
|
||||
type SampleTab = {
|
||||
id: string;
|
||||
label: string;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
hrefLabel: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
function cn(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
const SAMPLE_TABS: SampleTab[] = [
|
||||
{
|
||||
id: "chat-components",
|
||||
label: "Chat components",
|
||||
eyebrow: "Prebuilt UI",
|
||||
title: "Drop in a chat surface where your users already work.",
|
||||
description:
|
||||
"Use CopilotChat, CopilotSidebar, or CopilotPopup when you want a complete agent UI out of the box.",
|
||||
href: "/prebuilt-components/chat",
|
||||
hrefLabel: "View chat components",
|
||||
icon: MessagesSquare,
|
||||
code: `import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
|
||||
export function SupportAssistant() {
|
||||
return (
|
||||
<CopilotChat
|
||||
labels={{
|
||||
modalHeaderTitle: "Product assistant",
|
||||
welcomeMessageText: "What should we work on?",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}`,
|
||||
},
|
||||
{
|
||||
id: "headless-ui",
|
||||
label: "Headless UI",
|
||||
eyebrow: "Custom surfaces",
|
||||
title: "Own every pixel and still use the agent runtime.",
|
||||
description:
|
||||
"Headless hooks let you build custom chat, command palettes, canvas controls, and inline assistants.",
|
||||
href: "/custom-look-and-feel/headless-ui",
|
||||
hrefLabel: "View headless UI",
|
||||
icon: Code2,
|
||||
code: `import { useAgent, useCopilotKit } from "@copilotkit/react-core/v2";
|
||||
import { randomUUID } from "@copilotkit/shared/v2";
|
||||
|
||||
export function CustomComposer() {
|
||||
const { agent } = useAgent();
|
||||
const { copilotkit } = useCopilotKit();
|
||||
|
||||
async function sendMessage(content: string) {
|
||||
agent.addMessage({
|
||||
id: randomUUID(),
|
||||
role: "user",
|
||||
content,
|
||||
});
|
||||
|
||||
await copilotkit.runAgent({ agent });
|
||||
}
|
||||
|
||||
return <Composer onSend={sendMessage} />;
|
||||
}`,
|
||||
},
|
||||
{
|
||||
id: "any-agent",
|
||||
label: "Any agent",
|
||||
eyebrow: "AG-UI runtime",
|
||||
title: "Connect any backend that speaks AG-UI.",
|
||||
description:
|
||||
"Define agents in CopilotRuntime and stream standard AG-UI events back to your React app.",
|
||||
href: "/agentic-protocols/ag-ui",
|
||||
hrefLabel: "View AG-UI",
|
||||
icon: Bot,
|
||||
code: `import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {
|
||||
my_agent: new HttpAgent({
|
||||
url: "http://localhost:8000/",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const serviceAdapter = new ExperimentalEmptyAdapter();
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
runtime,
|
||||
serviceAdapter,
|
||||
endpoint: "/api/copilotkit",
|
||||
});
|
||||
|
||||
return handleRequest(req);
|
||||
}`,
|
||||
},
|
||||
{
|
||||
id: "generative-ui",
|
||||
label: "Generative UI",
|
||||
eyebrow: "useComponent",
|
||||
title: "Let agents render real React components.",
|
||||
description:
|
||||
"Register components as frontend tools so the agent can show cards, forms, approvals, and rich app states.",
|
||||
href: "/reference/hooks/useComponent",
|
||||
hrefLabel: "View useComponent",
|
||||
icon: Paintbrush,
|
||||
code: `import { useComponent } from "@copilotkit/react-core/v2";
|
||||
import { z } from "zod";
|
||||
|
||||
const weatherCardSchema = z.object({
|
||||
city: z.string().describe("City name"),
|
||||
unit: z.enum(["c", "f"]).default("c"),
|
||||
});
|
||||
|
||||
function WeatherCard(props: z.infer<typeof weatherCardSchema>) {
|
||||
return <ForecastCard city={props.city} unit={props.unit} />;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
useComponent(
|
||||
{
|
||||
name: "showWeatherCard",
|
||||
description: "Render a forecast card in chat.",
|
||||
parameters: weatherCardSchema,
|
||||
render: WeatherCard,
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return null;
|
||||
}`,
|
||||
},
|
||||
];
|
||||
|
||||
function findSampleTab(id: string) {
|
||||
const tab = SAMPLE_TABS.find((sample) => sample.id === id);
|
||||
if (!tab) {
|
||||
throw new Error(`Unknown landing sample tab: ${id}`);
|
||||
}
|
||||
return tab;
|
||||
}
|
||||
|
||||
const MOBILE_SAMPLE_TABS = [
|
||||
findSampleTab("chat-components"),
|
||||
findSampleTab("headless-ui"),
|
||||
findSampleTab("generative-ui"),
|
||||
findSampleTab("any-agent"),
|
||||
];
|
||||
|
||||
export function LandingSampleTabs() {
|
||||
const [activeId, setActiveId] = React.useState(SAMPLE_TABS[0].id);
|
||||
const activeTab =
|
||||
SAMPLE_TABS.find((tab) => tab.id === activeId) ?? SAMPLE_TABS[0];
|
||||
|
||||
return (
|
||||
<section className="not-prose space-y-4 sm:space-y-5">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-xl font-semibold text-[var(--text)] sm:text-2xl">
|
||||
Build your agent's user experience
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-[var(--text-secondary)]">
|
||||
Pick the UI primitive that matches the product surface you are
|
||||
building.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-label="CopilotKit mobile samples"
|
||||
className="grid gap-2 sm:hidden"
|
||||
>
|
||||
{MOBILE_SAMPLE_TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.id}
|
||||
href={tab.href}
|
||||
data-mobile-sample-card={tab.id}
|
||||
className="shell-docs-radius-surface group flex min-w-0 items-start gap-3 border border-[var(--border)] bg-[var(--bg-surface)] p-3.5 no-underline shadow-[var(--shadow-control)] transition-colors hover:border-[var(--accent)] hover:bg-[var(--bg-elevated)]"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="shell-docs-radius-icon flex h-9 w-9 shrink-0 items-center justify-center border border-[var(--accent)] bg-[var(--accent-dim)] text-[var(--accent)]"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold leading-snug text-[var(--text)]">
|
||||
{tab.label}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs leading-relaxed text-[var(--text-muted)]">
|
||||
{tab.title}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="shell-docs-radius-surface hidden min-w-0 overflow-hidden border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--shadow-control)] sm:block">
|
||||
<div className="flex h-10 items-center justify-between border-b border-[var(--border)] bg-[var(--bg-elevated)] px-4">
|
||||
<div className="flex items-center gap-2" aria-hidden="true">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[var(--window-control-close)]" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[var(--window-control-minimize)]" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[var(--window-control-zoom)]" />
|
||||
</div>
|
||||
<div className="hidden font-mono text-xs text-[var(--text-muted)] sm:block">
|
||||
Preview
|
||||
</div>
|
||||
<div className="h-2.5 w-[46px]" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 lg:min-h-[460px] lg:grid-cols-[250px_minmax(0,1fr)]">
|
||||
<div className="min-w-0 border-b border-[var(--border)] bg-[var(--bg-elevated)]/60 p-2 sm:p-3 lg:border-b-0 lg:border-r">
|
||||
<div
|
||||
aria-label="CopilotKit samples"
|
||||
className="grid w-full grid-cols-2 gap-1.5 lg:grid-cols-1"
|
||||
role="tablist"
|
||||
>
|
||||
{SAMPLE_TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const selected = tab.id === activeTab.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
id={`landing-sample-tab-${tab.id}`}
|
||||
aria-label={tab.label}
|
||||
aria-controls={`landing-sample-panel-${tab.id}`}
|
||||
aria-selected={selected}
|
||||
className={cn(
|
||||
"shell-docs-radius-control group flex min-h-11 min-w-[9.75rem] items-center gap-2 border px-2.5 py-2 text-left transition-colors sm:min-h-14 sm:min-w-0 sm:gap-3 sm:px-3 sm:py-2.5",
|
||||
selected
|
||||
? "border-[var(--accent)] bg-[var(--bg-surface)] text-[var(--text)] shadow-[var(--shadow-control)]"
|
||||
: "border-transparent text-[var(--text-secondary)] hover:bg-[var(--bg-surface)]/70 hover:text-[var(--text)]",
|
||||
)}
|
||||
role="tab"
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"shell-docs-radius-icon flex h-7 w-7 shrink-0 items-center justify-center border transition-colors sm:h-8 sm:w-8",
|
||||
selected
|
||||
? "border-[var(--accent)] bg-[var(--accent-dim)] text-[var(--accent)]"
|
||||
: "border-[var(--border)] bg-[var(--bg-surface)] text-[var(--text-muted)] group-hover:text-[var(--accent)]",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[0.8125rem] font-medium leading-tight tracking-[-0.005em]">
|
||||
{tab.label}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={`landing-sample-panel-${activeTab.id}`}
|
||||
aria-labelledby={`landing-sample-tab-${activeTab.id}`}
|
||||
className="flex min-h-0 min-w-0 flex-col gap-3 p-4 sm:min-h-[440px] sm:gap-5 sm:p-5 lg:p-6"
|
||||
role="tabpanel"
|
||||
>
|
||||
<div className="max-w-2xl">
|
||||
<h3 className="text-[1.0625rem] font-semibold leading-tight text-[var(--text)] sm:text-xl">
|
||||
{activeTab.title}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-[var(--text-muted)]">
|
||||
{activeTab.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="shell-docs-radius-surface min-h-0 min-w-0 flex-1 overflow-hidden border border-[var(--border)] bg-[var(--bg-surface)]">
|
||||
<div className="flex h-9 items-center justify-between border-b border-[var(--border)] bg-[var(--bg-elevated)] px-3">
|
||||
<span className="font-mono text-xs text-[var(--text-muted)]">
|
||||
example.tsx
|
||||
</span>
|
||||
<span className="shell-docs-radius-control bg-[var(--bg-surface)] px-2 py-0.5 font-mono text-[11px] text-[var(--text-muted)]">
|
||||
tsx
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-[260px] min-w-0 overflow-auto sm:h-[300px] [&_figure]:my-0 [&_figure]:min-w-0 [&_figure]:border-0 [&_pre]:min-h-full [&_pre]:rounded-none [&_pre]:border-0">
|
||||
<DynamicCodeBlock lang="tsx" code={activeTab.code ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-stretch sm:justify-end">
|
||||
<Link
|
||||
href={activeTab.href}
|
||||
className="shell-docs-radius-control inline-flex h-9 w-full shrink-0 items-center justify-center border border-[var(--accent)] bg-[var(--accent-dim)] px-3 text-sm font-semibold text-[var(--accent)] no-underline transition-colors hover:bg-[var(--accent-light)] sm:w-auto"
|
||||
>
|
||||
{activeTab.hrefLabel}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Compatibility shims for v1 MDX components used by ported framework docs.
|
||||
// Each shim should be deleted as its referencing framework graduates from
|
||||
// the v1 port to the new auto-gen pattern.
|
||||
export {};
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import posthog from "posthog-js";
|
||||
import { CloudIcon } from "lucide-react";
|
||||
|
||||
export function LinkToCopilotCloud({
|
||||
className,
|
||||
subPath,
|
||||
asButton = true,
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
className?: string;
|
||||
subPath?: string;
|
||||
asButton?: boolean;
|
||||
children?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
// Build base URL without PostHog session ID to avoid hydration issues
|
||||
const baseUrl = new URL(`https://dashboard.operations.copilotkit.ai/sign-in`);
|
||||
baseUrl.searchParams.set("ref", "docs");
|
||||
|
||||
if (subPath) {
|
||||
baseUrl.pathname += subPath;
|
||||
}
|
||||
|
||||
// Only add PostHog session ID on client side after hydration
|
||||
const [href, setHref] = useState(baseUrl.toString());
|
||||
|
||||
useEffect(() => {
|
||||
if (isClient) {
|
||||
const sessionId = posthog.get_session_id();
|
||||
if (sessionId) {
|
||||
const url = new URL(baseUrl.toString());
|
||||
url.searchParams.set("session_id", sessionId);
|
||||
setHref(url.toString());
|
||||
}
|
||||
}
|
||||
}, [isClient, baseUrl.toString()]);
|
||||
|
||||
let cn = "";
|
||||
|
||||
if (asButton) {
|
||||
cn =
|
||||
"shell-docs-radius-control flex items-center whitespace-nowrap border border-[var(--accent)] bg-[var(--accent-dim)] p-3 px-4 text-sm text-[var(--accent)] no-underline shadow-[var(--shadow-control)]";
|
||||
cn +=
|
||||
" transition-colors duration-100 hover:bg-[var(--accent)] hover:text-[var(--primary-foreground)]";
|
||||
} else {
|
||||
cn =
|
||||
"_text-primary-600 decoration-from-font underline [text-underline-position:from-font]";
|
||||
}
|
||||
|
||||
if (className) {
|
||||
cn += ` ${className}`;
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
target="_blank"
|
||||
className={cn}
|
||||
onClick={onClick}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{asButton ? <CloudIcon className="w-5 h-5 mr-2" /> : null}
|
||||
{children ? children : "Free Developer Access"}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// <MdxCodeBlock> — `pre` override for MDX-rendered fenced code blocks.
|
||||
//
|
||||
// Wraps the rehype-code (Shiki) output with Fumadocs's `<CodeBlock>` +
|
||||
// `<Pre>` chrome so every fenced block gets the floating Copy button and
|
||||
// — when a `title="..."` meta is supplied on the fence — the file-path
|
||||
// figcaption. The `data-title` / `data-language` data-attrs come from
|
||||
// our `transformerMeta` Shiki transformer (see `lib/rehype-code-meta.ts`)
|
||||
// which copies the title / lang onto the `<pre>` element rehype-code
|
||||
// emits.
|
||||
//
|
||||
// Why Pre matters: rehype-code (Shiki) wraps each source line in
|
||||
// `<span class="line">` and tokens in colored child spans. Fumadocs's
|
||||
// `<Pre>` adds `*:flex *:flex-col` to the `<pre>` element so the line
|
||||
// spans stack as rows. Plain `<pre>` would render the inline tokens
|
||||
// fine but skip the per-line layout Fumadocs uses for line numbers,
|
||||
// diff/highlight gutters, etc.
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { Children, isValidElement } from "react";
|
||||
import { CodeBlock, Pre } from "fumadocs-ui/components/codeblock";
|
||||
|
||||
interface MdxCodeBlockProps extends React.HTMLAttributes<HTMLPreElement> {
|
||||
"data-title"?: string;
|
||||
"data-language"?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a React tree and concatenate every text leaf into a single string.
|
||||
* Used to recover the raw source of a highlighted code block when we
|
||||
* need to dedent a JSX-nested fence whose body inherited the surrounding
|
||||
* indent.
|
||||
*/
|
||||
function extractText(node: React.ReactNode): string {
|
||||
if (node === null || node === undefined || node === false) return "";
|
||||
if (typeof node === "string") return node;
|
||||
if (typeof node === "number") return String(node);
|
||||
if (Array.isArray(node)) return node.map(extractText).join("");
|
||||
if (isValidElement(node)) {
|
||||
const children = (node.props as { children?: React.ReactNode }).children;
|
||||
return extractText(children);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip uniform leading whitespace from a multi-line code body.
|
||||
*
|
||||
* Why: when a triple-fenced block sits inside JSX (e.g. ```python inside
|
||||
* `<Tab>`/`<Step>`), MDX preserves the JSX nesting's leading indent on
|
||||
* every body line. extractText() recovers that text faithfully, so the
|
||||
* clipboard payload comes out with 16-24 leading spaces per line and
|
||||
* pasted code is invalid. This helper measures the minimum indent
|
||||
* across non-blank lines and strips it from every line — turning a
|
||||
* uniformly-indented block back into column-0 code.
|
||||
*
|
||||
* Tabs are counted as 1 unit (we don't expand to N spaces); the goal
|
||||
* is to fix uniform-indent leakage, not normalize mixed indentation.
|
||||
* Blank lines are left as-is rather than over-trimmed.
|
||||
*/
|
||||
function dedent(text: string): string {
|
||||
const lines = text.split("\n");
|
||||
const nonBlank = lines.filter((l) => l.trim().length > 0);
|
||||
if (nonBlank.length === 0) return text;
|
||||
const minIndent = Math.min(
|
||||
...nonBlank.map((l) => l.match(/^[\s]*/)![0].length),
|
||||
);
|
||||
if (minIndent === 0) return text;
|
||||
return lines
|
||||
.map((l) => (l.length >= minIndent ? l.slice(minIndent) : l))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function MdxCodeBlock(props: MdxCodeBlockProps) {
|
||||
const {
|
||||
"data-title": title,
|
||||
"data-language": language,
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const rawCodeText = (() => {
|
||||
const kids = Children.toArray(children);
|
||||
const codeEl = kids.find(
|
||||
(k) => isValidElement(k) && (k.type === "code" || k.type === "CODE"),
|
||||
);
|
||||
if (codeEl && isValidElement(codeEl)) {
|
||||
return extractText(
|
||||
(codeEl.props as { children?: React.ReactNode }).children,
|
||||
);
|
||||
}
|
||||
return extractText(children);
|
||||
})();
|
||||
|
||||
const codeText = dedent(rawCodeText);
|
||||
const indentLeaked = codeText !== rawCodeText;
|
||||
|
||||
return (
|
||||
<CodeBlock title={title}>
|
||||
<Pre {...rest} className={className} suppressHydrationWarning>
|
||||
{indentLeaked ? (
|
||||
// Fence body was uniformly indented (JSX-nested fence). Render
|
||||
// the dedented plain text so what's on screen matches what
|
||||
// gets copied. We lose token highlighting on these blocks, but
|
||||
// the alternative is indented-and-invalid code in the viewer.
|
||||
<code
|
||||
className={`language-${language ?? "plaintext"}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{codeText}
|
||||
</code>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Pre>
|
||||
</CodeBlock>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type React from "react";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
Card as FumadocsCard,
|
||||
Cards as FumadocsCards,
|
||||
} from "fumadocs-ui/components/card";
|
||||
|
||||
// Re-export fumadocs's default `<Callout>` so historical imports from
|
||||
// `@/components/mdx-components` keep working. Fumadocs supports the
|
||||
// types `info | warn | warning | error | success | idea`, plus the
|
||||
// alias `tip` (resolves to info). Other custom types fall back to the
|
||||
// default tone.
|
||||
export { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
export function Cards({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof FumadocsCards>) {
|
||||
// `not-prose` opts the wrapped Cards out of the .reference-content
|
||||
// prose-link styling (which forces underline + accent color on every
|
||||
// <a>). The Card's own className already controls link appearance.
|
||||
return (
|
||||
<FumadocsCards
|
||||
{...props}
|
||||
className={["not-prose my-6 grid-cols-1 gap-4 sm:grid-cols-2", className]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DocsCardProps = React.ComponentProps<typeof FumadocsCard> & {
|
||||
logo?: string;
|
||||
logoAlt?: string;
|
||||
logoClassName?: string;
|
||||
};
|
||||
|
||||
export function Card({
|
||||
href,
|
||||
className,
|
||||
style,
|
||||
icon,
|
||||
logo,
|
||||
logoAlt = "",
|
||||
logoClassName,
|
||||
...props
|
||||
}: DocsCardProps) {
|
||||
// Match the docs-landing pointer-card style:
|
||||
// - bordered surface, accent border on hover, subtle shadow on hover
|
||||
// - title flips to accent color on hover via `group-hover` so the link
|
||||
// feels active without using a default underline
|
||||
// - `not-prose` is the load-bearing class: the article body uses
|
||||
// `.reference-content` which forces `text-decoration: underline;
|
||||
// color: var(--accent)` on every <a>; that rule wins over the
|
||||
// Tailwind `no-underline` class on specificity. `not-prose`
|
||||
// triggers the global escape-hatch rule that drops both.
|
||||
const resolvedHref = href?.replace(/^\/reference\/v2\//, "/reference/");
|
||||
const resolvedIcon =
|
||||
icon ??
|
||||
(logo ? (
|
||||
<Image
|
||||
src={logo}
|
||||
alt={logoAlt}
|
||||
width={20}
|
||||
height={20}
|
||||
className={["h-5 w-5 shrink-0 object-contain", logoClassName]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
unoptimized
|
||||
/>
|
||||
) : undefined);
|
||||
const mergedClassName = [
|
||||
"shell-docs-radius-surface border-[var(--border)] bg-[var(--bg-surface)] text-[var(--text)] shadow-[var(--shadow-control)]",
|
||||
"[&_h3]:!mt-0 [&_h3]:!mb-1.5 [&_h3]:!text-base [&_h3]:!font-semibold [&_h3]:!leading-snug [&_p]:!text-sm [&_p]:!leading-relaxed",
|
||||
href
|
||||
? "not-prose hover:border-[var(--accent)] hover:bg-[var(--bg-elevated)]"
|
||||
: null,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<FumadocsCard
|
||||
{...props}
|
||||
href={resolvedHref}
|
||||
icon={resolvedIcon}
|
||||
className={mergedClassName}
|
||||
style={
|
||||
href ? { textDecoration: "none", color: "inherit", ...style } : style
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Accordions({ children }: { children: React.ReactNode }) {
|
||||
return <div className="my-4 space-y-2">{children}</div>;
|
||||
}
|
||||
|
||||
export function Accordion({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<details className="shell-docs-radius-surface group border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--shadow-control)]">
|
||||
<summary className="cursor-pointer select-none px-4 py-3 text-sm font-semibold text-[var(--text)] transition-colors hover:bg-[var(--bg-elevated)]">
|
||||
{title}
|
||||
</summary>
|
||||
<div className="px-4 pb-4 text-sm text-[var(--text-secondary)]">
|
||||
{children}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { Calendar } from "lucide-react";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { TALK_TO_ENGINEER_HREF } from "./brand-nav";
|
||||
|
||||
export function MobileSidebarFooterTalk() {
|
||||
const posthog = usePostHog();
|
||||
|
||||
const handleTalkToEngineersClick = () => {
|
||||
posthog?.capture("talk_to_us_clicked", {
|
||||
location: "docs_sidebar_mobile_footer",
|
||||
});
|
||||
window.location.href = TALK_TO_ENGINEER_HREF;
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTalkToEngineersClick}
|
||||
className="shell-docs-radius-control flex h-10 w-full items-center justify-center gap-2 border border-[var(--border)] bg-[var(--bg-surface)] px-3 text-sm font-medium text-[var(--text)] shadow-[var(--shadow-control)] transition-colors hover:border-[var(--accent)] hover:bg-[var(--bg-elevated)] hover:text-[var(--accent)] xl:hidden"
|
||||
>
|
||||
<Calendar className="h-4 w-4" aria-hidden="true" />
|
||||
Talk to an engineer
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
// Fumadocs v16 moved `SidebarTrigger` from `components/layout/sidebar`
|
||||
// to `components/sidebar/base` — keep the v16 path here.
|
||||
import { SidebarTrigger } from "fumadocs-ui/components/sidebar/base";
|
||||
import { Menu } from "lucide-react";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { INTELLIGENCE_CTA_HREF } from "./brand-nav";
|
||||
import { SearchTrigger } from "./search-trigger";
|
||||
import { CopilotKitMark } from "./copilotkit-mark";
|
||||
import { ThemeSwitch } from "./theme-switch";
|
||||
import { PrimaryDocsTabs } from "./primary-docs-tabs";
|
||||
|
||||
// Mobile/tablet top nav. Replaces shell-docs's BrandNav below xl because the
|
||||
// full desktop chrome doesn't fit reliably. Rendered via DocsLayout's
|
||||
// `nav.component` slot so it sits inside the SidebarProvider —
|
||||
// `SidebarTrigger` toggles the Fumadocs mobile sidebar
|
||||
// (`#nd-sidebar-mobile`), which natively renders the page tree we pass
|
||||
// to DocsLayout.
|
||||
//
|
||||
// Positioned `fixed` (mirrors Fumadocs's default Navbar) because the
|
||||
// nav.component slot otherwise becomes a flex-row sibling of LayoutBody
|
||||
// inside shell-docs's outer `<main className="flex …">` — without
|
||||
// fixed positioning, the nav swallows the left half of the viewport.
|
||||
//
|
||||
export function MobileTopNav() {
|
||||
const posthog = usePostHog();
|
||||
|
||||
const handleFreeDeveloperAccessClick = () => {
|
||||
posthog?.capture("try_for_free_clicked", {
|
||||
location: "docs_mobile_nav_right",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
id="nd-subnav"
|
||||
className="shell-docs-mobile-nav xl:hidden fixed top-(--fd-banner-height) left-0 right-0 z-30 border-b border-[var(--border)] bg-[var(--bg)]"
|
||||
>
|
||||
<div className="shell-docs-mobile-nav-top">
|
||||
<Link
|
||||
href="/"
|
||||
className="shell-docs-mobile-brand"
|
||||
aria-label="CopilotKit Docs"
|
||||
>
|
||||
<CopilotKitMark />
|
||||
<span className="font-bold text-[var(--text)] tracking-tight">
|
||||
CopilotKit
|
||||
</span>
|
||||
<span
|
||||
className="shell-docs-radius-control border border-[var(--border)] bg-[var(--accent-dim)] px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--accent)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
Docs
|
||||
</span>
|
||||
</Link>
|
||||
<PrimaryDocsTabs className="shell-docs-mobile-tabs" />
|
||||
<div className="shell-docs-mobile-actions">
|
||||
<Link
|
||||
href={INTELLIGENCE_CTA_HREF}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleFreeDeveloperAccessClick}
|
||||
className="shell-docs-radius-control hidden h-10 w-10 shrink-0 cursor-pointer items-center justify-center border border-[var(--border)] bg-[var(--bg-surface)] text-[var(--text-muted)] shadow-[var(--shadow-control)] transition-colors hover:border-[var(--accent)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text)] md:flex"
|
||||
aria-label="Get Enterprise Intelligence free"
|
||||
title="Get Enterprise Intelligence free"
|
||||
>
|
||||
<CopilotKitMark className="h-5 w-5" />
|
||||
</Link>
|
||||
<div className="shell-docs-mobile-search">
|
||||
<SearchTrigger iconOnly />
|
||||
</div>
|
||||
<ThemeSwitch />
|
||||
<SidebarTrigger
|
||||
className="shell-docs-mobile-menu shell-docs-radius-control flex h-10 w-10 items-center justify-center text-[var(--text-muted)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text)]"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</SidebarTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ChefHat } from "lucide-react";
|
||||
import BookIcon from "./icons/book";
|
||||
|
||||
const PRIMARY_DOCS_LINKS = [
|
||||
{
|
||||
href: "/",
|
||||
label: "Docs",
|
||||
icon: <BookIcon className="h-4 w-4 text-current" />,
|
||||
},
|
||||
{
|
||||
href: "/reference",
|
||||
label: "Reference",
|
||||
icon: <BookIcon className="h-4 w-4 text-current" />,
|
||||
},
|
||||
{
|
||||
href: "/cookbook",
|
||||
label: "Cookbook",
|
||||
icon: <ChefHat className="h-4 w-4 text-current" />,
|
||||
},
|
||||
];
|
||||
|
||||
function getActiveRoute(pathname: string) {
|
||||
const firstSegment = pathname === "/" ? "/" : `/${pathname.split("/")[1]}`;
|
||||
|
||||
if (firstSegment === "/reference") {
|
||||
return "/reference";
|
||||
}
|
||||
|
||||
if (firstSegment === "/cookbook") {
|
||||
return "/cookbook";
|
||||
}
|
||||
|
||||
return "/";
|
||||
}
|
||||
|
||||
export function PrimaryDocsTabs({ className }: { className?: string }) {
|
||||
const pathname = usePathname();
|
||||
const activeRoute = getActiveRoute(pathname);
|
||||
|
||||
return (
|
||||
<nav className={className} aria-label="Primary docs sections">
|
||||
{PRIMARY_DOCS_LINKS.map((link) => {
|
||||
const isActive = activeRoute === link.href;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`shell-docs-radius-control shell-docs-primary-tab ${
|
||||
isActive
|
||||
? "shell-docs-nav-link-active"
|
||||
: "shell-docs-nav-link-idle"
|
||||
}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
{link.icon}
|
||||
<span>{link.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { TypeTable } from "fumadocs-ui/components/type-table";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
deprecated?: boolean;
|
||||
children?: ReactNode;
|
||||
cloudOnly?: boolean;
|
||||
default?: string;
|
||||
collapsable?: boolean;
|
||||
};
|
||||
|
||||
export function PropertyReference({
|
||||
children,
|
||||
name,
|
||||
type,
|
||||
required = false,
|
||||
deprecated = false,
|
||||
cloudOnly = false,
|
||||
default: defaultValue,
|
||||
}: Props) {
|
||||
return (
|
||||
<TypeTable
|
||||
className="shell-docs-property-reference"
|
||||
type={{
|
||||
[name]: {
|
||||
type,
|
||||
required,
|
||||
deprecated,
|
||||
default:
|
||||
defaultValue === undefined ? undefined : (
|
||||
<code>{defaultValue}</code>
|
||||
),
|
||||
description: (
|
||||
<div className="space-y-3">
|
||||
{cloudOnly && (
|
||||
<div>
|
||||
<span className="shell-docs-radius-control inline-flex items-center justify-center bg-[var(--accent)] px-2 py-0.5 text-xs font-semibold text-[var(--primary-foreground)]">
|
||||
COPILOT CLOUD
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="prose-no-margin">{children}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { SignupLink } from "../signup-link";
|
||||
import { OpsPlatformCTA } from "../ops-platform-cta";
|
||||
|
||||
// These tests exercise the SSR path of "use client" components whose
|
||||
// render-time bodies call `new URL(getRuntimeConfig().<url>)`. During
|
||||
// SSR the client reader returns the SSR_PLACEHOLDER (no window); if any
|
||||
// URL field there is the empty string, `new URL("")` throws and the
|
||||
// whole server-rendered HTML response 500s. The placeholder MUST be a
|
||||
// parseable URL.
|
||||
|
||||
function hrefFromStaticMarkup(html: string): string {
|
||||
const match = html.match(/href="([^"]+)"/);
|
||||
expect(match).not.toBeNull();
|
||||
return match![1].replaceAll("&", "&");
|
||||
}
|
||||
|
||||
describe("client component SSR safety (shell-docs)", () => {
|
||||
beforeEach(() => {
|
||||
// Force SSR path — getRuntimeConfig() in runtime-config.client.ts
|
||||
// returns the SSR_PLACEHOLDER when `typeof window === "undefined"`.
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
});
|
||||
|
||||
it("SignupLink SSR-renders without throwing", () => {
|
||||
expect(() =>
|
||||
renderToStaticMarkup(<SignupLink surface="test">Sign up</SignupLink>),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("SignupLink SSR href parses as a URL", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SignupLink surface="test">Sign up</SignupLink>,
|
||||
);
|
||||
expect(() => new URL(hrefFromStaticMarkup(html))).not.toThrow();
|
||||
});
|
||||
|
||||
it("OpsPlatformCTA SSR-renders without throwing", () => {
|
||||
expect(() =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(OpsPlatformCTA, {
|
||||
title: "Test",
|
||||
surface: "test-surface",
|
||||
}),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("OpsPlatformCTA SSR href parses as a URL", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(OpsPlatformCTA, {
|
||||
title: "Test",
|
||||
surface: "test-surface",
|
||||
}),
|
||||
);
|
||||
expect(() => new URL(hrefFromStaticMarkup(html))).not.toThrow();
|
||||
});
|
||||
|
||||
it("OpsPlatformCTA supports a custom href", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(OpsPlatformCTA, {
|
||||
title: "Test",
|
||||
surface: "test-surface",
|
||||
href: "https://copilotkit.ai/talk-to-an-engineer",
|
||||
analyticsEvent: "talk_to_us_clicked",
|
||||
}),
|
||||
);
|
||||
const url = new URL(hrefFromStaticMarkup(html));
|
||||
expect(`${url.origin}${url.pathname}`).toBe(
|
||||
"https://copilotkit.ai/talk-to-an-engineer",
|
||||
);
|
||||
expect(url.searchParams.get("utm_content")).toBe("test-surface");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const navigation = vi.hoisted(() => ({
|
||||
push: vi.fn(),
|
||||
searchParams: new URLSearchParams("threads-path=cli"),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: navigation.push }),
|
||||
useSearchParams: () => navigation.searchParams,
|
||||
}));
|
||||
|
||||
import { TailoredContent, TailoredContentOption } from "../tailored-content";
|
||||
|
||||
describe("TailoredContent", () => {
|
||||
it("renders implementation path options as native buttons with a selected affordance", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<TailoredContent id="threads-path">
|
||||
<TailoredContentOption
|
||||
id="cli"
|
||||
title="Start with the CLI"
|
||||
description="Bootstrap a new full-stack project pre-configured for threads."
|
||||
icon={<span />}
|
||||
>
|
||||
<p>CLI content</p>
|
||||
</TailoredContentOption>
|
||||
<TailoredContentOption
|
||||
id="manual"
|
||||
title="Add to an existing app"
|
||||
description="Wire threads into a project you already have."
|
||||
icon={<span />}
|
||||
>
|
||||
<p>Manual content</p>
|
||||
</TailoredContentOption>
|
||||
</TailoredContent>,
|
||||
);
|
||||
|
||||
expect(markup.match(/<button/g)?.length).toBe(2);
|
||||
expect(markup).toContain('type="button"');
|
||||
expect(markup).toContain('role="tab"');
|
||||
expect(markup).toContain('aria-selected="true"');
|
||||
expect(markup).toContain('aria-label="Start with the CLI, selected"');
|
||||
expect(markup).toContain("tailored-content-selected-indicator");
|
||||
expect(markup).toContain("CLI content");
|
||||
expect(markup).not.toContain("Manual content");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
MessageCircle,
|
||||
RefreshCw,
|
||||
SendHorizontal,
|
||||
Sparkles,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { CopilotKitMark } from "@/components/copilotkit-mark";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const suggestions = [
|
||||
"Plan a weekend project",
|
||||
"Draft a quick reply",
|
||||
"Show me what changed",
|
||||
];
|
||||
|
||||
export function NewLookAndFeelPreview() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open CopilotKit new look preview"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className={cn(
|
||||
"shell-docs-radius-icon fixed bottom-5 right-5 z-50 flex h-14 w-14 items-center justify-center border shadow-[var(--shadow-panel)] transition",
|
||||
"border-[var(--border)] bg-[var(--bg-surface)] text-[var(--accent)] hover:-translate-y-0.5 hover:border-[var(--accent)] hover:bg-[var(--bg-elevated)]",
|
||||
)}
|
||||
>
|
||||
{open ? (
|
||||
<X className="h-6 w-6" />
|
||||
) : (
|
||||
<MessageCircle className="h-6 w-6" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<aside
|
||||
aria-label="CopilotKit new look and feel preview"
|
||||
className={cn(
|
||||
"shell-docs-radius-surface fixed bottom-24 left-4 right-4 z-50 mx-auto flex max-h-[min(660px,calc(100vh-8rem))] w-auto max-w-[390px] flex-col overflow-hidden border shadow-[var(--shadow-modal)]",
|
||||
"border-[var(--border)] bg-[var(--bg-surface)] text-[var(--text)]",
|
||||
"sm:left-auto sm:right-5 sm:mx-0 sm:w-[390px]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--border)] px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="shell-docs-radius-icon flex h-9 w-9 items-center justify-center bg-[var(--accent-dim)]">
|
||||
<CopilotKitMark className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold">CopilotKit</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">
|
||||
New look and feel
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shell-docs-radius-control flex h-7 items-center gap-1 border border-[var(--accent)] bg-[var(--accent-dim)] px-2 text-xs font-medium text-[var(--accent)]">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Ready
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4 overflow-y-auto bg-[var(--bg)] px-4 py-4">
|
||||
<div className="shell-docs-radius-surface max-w-[82%] border border-[var(--border)] bg-[var(--bg-surface)] px-4 py-3 text-sm shadow-[var(--shadow-control)]">
|
||||
Hey there. Let's have a fun conversation.
|
||||
</div>
|
||||
|
||||
<div className="shell-docs-radius-surface ml-auto max-w-[78%] bg-[var(--accent)] px-4 py-3 text-sm text-[var(--primary-foreground)] shadow-[var(--shadow-control)]">
|
||||
What changed in 1.8.2?
|
||||
</div>
|
||||
|
||||
<div className="shell-docs-radius-surface max-w-[88%] border border-[var(--border)] bg-[var(--bg-surface)] px-4 py-3 text-sm shadow-[var(--shadow-control)]">
|
||||
The chat UI has refreshed styling, first-class feedback actions,
|
||||
and built-in dark mode support.
|
||||
<div className="mt-3 flex items-center gap-1.5 text-[var(--text-muted)]">
|
||||
<ActionButton label="Thumbs up">
|
||||
<ThumbsUp className="h-3.5 w-3.5" />
|
||||
</ActionButton>
|
||||
<ActionButton label="Thumbs down">
|
||||
<ThumbsDown className="h-3.5 w-3.5" />
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
label={copied ? "Copied" : "Copy"}
|
||||
onClick={() => {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</ActionButton>
|
||||
<ActionButton label="Regenerate">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{suggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
className="shell-docs-radius-control border border-[var(--border)] bg-[var(--bg-surface)] px-3 py-2 text-left text-xs font-medium text-[var(--accent)] transition hover:border-[var(--accent)] hover:bg-[var(--accent-dim)]"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border)] bg-[var(--bg-surface)] p-3">
|
||||
<div className="shell-docs-radius-control flex items-center gap-2 border border-[var(--border)] bg-[var(--bg-elevated)] px-3 py-2">
|
||||
<input
|
||||
readOnly
|
||||
value="Ask anything..."
|
||||
aria-label="Preview chat input"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--text-muted)] outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Send preview message"
|
||||
className="shell-docs-radius-icon flex h-8 w-8 items-center justify-center bg-[var(--accent)] text-[var(--primary-foreground)] transition hover:bg-[var(--accent-strong)]"
|
||||
>
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
children,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className="shell-docs-radius-icon flex h-7 w-7 items-center justify-center transition hover:bg-[var(--accent-dim)] hover:text-[var(--accent)]"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client";
|
||||
|
||||
import { CopilotKitMark } from "@/components/copilotkit-mark";
|
||||
import { getRuntimeConfig } from "@/lib/runtime-config.client";
|
||||
import posthog from "posthog-js";
|
||||
import { useCallback } from "react";
|
||||
|
||||
// Icons inlined as SVG so this component avoids a lucide-react dep
|
||||
// (shell-docs deliberately keeps icon usage minimal — see mdx-registry's
|
||||
// emoji fallbacks for the broader icon-set decision).
|
||||
|
||||
function ArrowRight({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Info({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4" />
|
||||
<path d="M12 8h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export type OpsPlatformCTAVariant = "tile" | "inline" | "card" | "info";
|
||||
export type OpsPlatformCTAAnalyticsEvent =
|
||||
| "try_for_free_clicked"
|
||||
| "talk_to_us_clicked";
|
||||
|
||||
export interface OpsPlatformCTAProps {
|
||||
/** Visual style: tile = full-width hero, inline = mid-page callout, card = footer */
|
||||
variant?: OpsPlatformCTAVariant;
|
||||
/** Headline shown to the user */
|
||||
title: string;
|
||||
/** Body copy under the headline */
|
||||
body?: string;
|
||||
/** Stable identifier for analytics, e.g. "docs:langgraph/quickstart:whats-next".
|
||||
* Flows through to the destination URL as `utm_content` and the PostHog
|
||||
* `location` property so CTA attribution stays consistent across docs
|
||||
* surfaces. */
|
||||
surface: string;
|
||||
/** Optional override for the link label. Defaults to "Get Enterprise Intelligence free" */
|
||||
ctaLabel?: string;
|
||||
/** Optional override for CTAs that should keep the Enterprise styling but point elsewhere. */
|
||||
href?: string;
|
||||
/** PostHog event captured on click. Defaults to the Enterprise Intelligence signup event. */
|
||||
analyticsEvent?: OpsPlatformCTAAnalyticsEvent;
|
||||
/** Optional className override for the outermost element */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function buildHref(surface: string, hrefOverride?: string): string {
|
||||
// Signup URL is read at render time from the runtime config injected
|
||||
// by the root layout — see signup-link.tsx and lib/runtime-config.ts
|
||||
// for the full plumbing rationale. Keeps a single artifact retargetable
|
||||
// across Railway envs without rebuild. `hrefOverride` lets docs keep this
|
||||
// CTA treatment for related Enterprise actions, such as talking to an
|
||||
// engineer about self-hosting.
|
||||
const signupUrl = getRuntimeConfig().intelligenceSignupUrl;
|
||||
const url = new URL(hrefOverride ?? signupUrl);
|
||||
url.searchParams.set("utm_source", "docs");
|
||||
url.searchParams.set("utm_medium", "cta");
|
||||
url.searchParams.set("utm_campaign", "intelligence");
|
||||
url.searchParams.set("utm_content", surface);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function OpsPlatformCTA({
|
||||
variant = "card",
|
||||
title,
|
||||
body,
|
||||
surface,
|
||||
ctaLabel = "Get Enterprise Intelligence free",
|
||||
href: hrefOverride,
|
||||
analyticsEvent = "try_for_free_clicked",
|
||||
className,
|
||||
}: OpsPlatformCTAProps) {
|
||||
const href = buildHref(surface, hrefOverride);
|
||||
const handleClick = useCallback(() => {
|
||||
try {
|
||||
posthog.capture(analyticsEvent, { location: surface });
|
||||
} catch {
|
||||
// PostHog may be blocked by ad blockers; navigation should still work.
|
||||
}
|
||||
}, [analyticsEvent, surface]);
|
||||
|
||||
if (variant === "info") {
|
||||
return (
|
||||
<div
|
||||
className={`shell-docs-radius-surface not-prose my-6 flex gap-3 border border-[var(--border)] bg-[var(--bg-elevated)] p-4 shadow-[var(--shadow-control)] ${className ?? ""}`}
|
||||
>
|
||||
<Info className="h-5 w-5 text-[var(--accent)] mt-0.5 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold text-[var(--text)]">{title}</div>
|
||||
{body ? (
|
||||
<div className="text-sm text-[var(--text-muted)] leading-relaxed mt-1">
|
||||
{body}
|
||||
</div>
|
||||
) : null}
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={handleClick}
|
||||
// HubSpot's analytics tag rewrites the outbound href client-side;
|
||||
// see suppressHydrationWarning note on the card variant below.
|
||||
suppressHydrationWarning
|
||||
className="shell-docs-cta-accent mt-2 inline-flex items-center gap-1 text-sm font-medium no-underline hover:opacity-80"
|
||||
data-cta-surface={surface}
|
||||
data-cta-variant={variant}
|
||||
>
|
||||
{ctaLabel}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "inline") {
|
||||
// Compact sibling of the `card` variant. Same visual language — light
|
||||
// bordered surface, an accent left-edge stripe as the brand signature, the
|
||||
// CopilotKit kite as the authored stamp, and a real text-link CTA in
|
||||
// `--accent`.
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={handleClick}
|
||||
// See suppressHydrationWarning note on the card variant below.
|
||||
suppressHydrationWarning
|
||||
data-cta-surface={surface}
|
||||
data-cta-variant={variant}
|
||||
className={`shell-docs-cta-link shell-docs-radius-surface not-prose group relative my-6 flex flex-col gap-3 overflow-hidden border border-[var(--border)] bg-[var(--bg-surface)] p-4 pl-5 shadow-[var(--shadow-control)] transition-colors duration-150 hover:border-[var(--accent)] sm:flex-row sm:items-center sm:justify-between sm:gap-4 ${className ?? ""}`}
|
||||
>
|
||||
{/* 2px accent stripe — the structural brand signature. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="shell-docs-cta-stripe pointer-events-none absolute left-0 top-0 h-full w-[2px]"
|
||||
/>
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<CopilotKitMark className="mt-0.5 h-5 w-[18px] flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[15px] font-semibold leading-snug text-[var(--text)]">
|
||||
{title}
|
||||
</div>
|
||||
{body ? (
|
||||
<div className="mt-1 text-[13.5px] leading-relaxed text-[var(--text-muted)]">
|
||||
{body}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shell-docs-cta-accent inline-flex items-center gap-1 whitespace-nowrap text-sm font-semibold">
|
||||
{ctaLabel}
|
||||
<ArrowRight className="h-3.5 w-3.5 transition-transform duration-150 group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "tile") {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={handleClick}
|
||||
// See suppressHydrationWarning note on the card variant below.
|
||||
suppressHydrationWarning
|
||||
data-cta-surface={surface}
|
||||
data-cta-variant={variant}
|
||||
className={`shell-docs-cta-link shell-docs-radius-surface not-prose group flex items-start gap-3 border border-[var(--border)] bg-[var(--bg-surface)] p-4 shadow-[var(--shadow-control)] transition-colors duration-150 hover:border-[var(--accent)] ${className ?? ""}`}
|
||||
>
|
||||
<CopilotKitMark className="mt-0.5 h-5 w-[18px] flex-shrink-0" />
|
||||
<div>
|
||||
<div className="font-semibold text-[var(--text)] mb-1">{title}</div>
|
||||
{body ? (
|
||||
<div className="text-sm text-[var(--text-muted)] leading-relaxed">
|
||||
{body}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// variant === "card" (default)
|
||||
//
|
||||
// Professional inline docs CTA — Vercel/Linear/Stripe energy, not a marketing
|
||||
// splash. Light bordered surface (`--bg-surface`), a 2px accent left-edge
|
||||
// stripe as the brand signature, the CopilotKit kite as the authored stamp
|
||||
// in the corner, typography-led structure, and a real text-link CTA in
|
||||
// `--accent`. The whole tile is the anchor; the CTA text is the visible
|
||||
// click target.
|
||||
//
|
||||
// Why this design and not a filled-accent slab:
|
||||
// - The flat-purple-block + white-pill pattern is the AI-template cliché
|
||||
// the user has flagged twice as "vibe coded".
|
||||
// - Real product docs CTAs (Vercel sign-up nudges, Stripe console prompts,
|
||||
// Linear billing callouts) are mostly monochrome and typography-led, with
|
||||
// a single accent touchpoint.
|
||||
// - The kite reads as authored brand, far more credibly than a generic
|
||||
// sparkle icon.
|
||||
// - The accent stripe is the same structural cue Linear uses on important
|
||||
// inline notices — restrained but unmistakable.
|
||||
//
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={handleClick}
|
||||
// HubSpot's analytics tag rewrites the outbound href client-side to
|
||||
// append `__hstc` / `__hssc` / `__hsfp` cross-domain tracking params,
|
||||
// which trips React's hydration diff. Same fix lives on the nav-bar
|
||||
// Intelligence CTA (mobile-top-nav.tsx + brand-nav.tsx).
|
||||
suppressHydrationWarning
|
||||
data-cta-surface={surface}
|
||||
data-cta-variant={variant}
|
||||
className={`shell-docs-cta-link shell-docs-radius-surface not-prose group relative my-8 flex flex-col items-stretch gap-4 overflow-hidden border border-[var(--border)] bg-[var(--bg-surface)] p-5 pl-6 shadow-[var(--shadow-control)] transition-colors duration-150 hover:border-[var(--accent)] sm:flex-row sm:items-center sm:justify-between sm:gap-6 sm:p-6 sm:pl-7 ${className ?? ""}`}
|
||||
>
|
||||
{/* 2px accent stripe — the structural brand signature. Positioned via the
|
||||
parent's relative + overflow-hidden so the stripe sits flush against
|
||||
the rounded corners without bleeding past them. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="shell-docs-cta-stripe pointer-events-none absolute left-0 top-0 h-full w-[2px]"
|
||||
/>
|
||||
<div className="flex items-start gap-4 min-w-0">
|
||||
<CopilotKitMark className="mt-0.5 h-6 w-[22px] flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-lg font-semibold leading-tight tracking-tight text-[var(--text)]">
|
||||
{title}
|
||||
</div>
|
||||
{body ? (
|
||||
<div className="mt-1.5 text-sm leading-relaxed text-[var(--text-muted)]">
|
||||
{body}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shell-docs-cta-accent inline-flex flex-shrink-0 items-center gap-1.5 whitespace-nowrap text-sm font-semibold">
|
||||
{ctaLabel}
|
||||
<ArrowRight className="h-4 w-4 transition-transform duration-150 group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import posthog from "posthog-js";
|
||||
import { useCallback } from "react";
|
||||
import { getRuntimeConfig } from "@/lib/runtime-config.client";
|
||||
|
||||
export interface SignupLinkProps {
|
||||
/** Stable identifier for analytics, e.g. "docs_langgraph_quickstart_step1" */
|
||||
surface: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function buildHref(surface: string): string {
|
||||
// Read the signup URL at render time from the runtime config injected
|
||||
// by the root layout so a single built artifact can point at staging
|
||||
// vs prod by changing the Railway env var. The reader already returns
|
||||
// a fallback when NEXT_PUBLIC_INTELLIGENCE_SIGNUP_URL is unset.
|
||||
const signupUrl = getRuntimeConfig().intelligenceSignupUrl;
|
||||
const url = new URL(signupUrl);
|
||||
url.searchParams.set("utm_source", "docs");
|
||||
url.searchParams.set("utm_medium", "cta");
|
||||
url.searchParams.set("utm_campaign", "intelligence");
|
||||
url.searchParams.set("utm_content", surface);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function SignupLink({ surface, children }: SignupLinkProps) {
|
||||
const handleClick = useCallback(() => {
|
||||
try {
|
||||
posthog.capture("try_for_free_clicked", { location: surface });
|
||||
} catch {
|
||||
// PostHog may be blocked by ad blockers — never let analytics block navigation.
|
||||
}
|
||||
}, [surface]);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={buildHref(surface)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={handleClick}
|
||||
// HubSpot's analytics tag rewrites the dashboard.operations.copilotkit.ai
|
||||
// outbound href client-side to attach `__hstc` / `__hssc` / `__hsfp`
|
||||
// cross-domain tracking params, which trips React's hydration diff.
|
||||
// Same fix on the nav-bar Intelligence CTA + OpsPlatformCTA variants.
|
||||
suppressHydrationWarning
|
||||
data-cta-surface={surface}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import React, {
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
// Local className-joining helper so this component has no external dep.
|
||||
// Mirrors the subset of `classnames` behavior used below (strings + falsy values).
|
||||
function cn(...values: Array<string | false | null | undefined>): string {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
type TailoredContentOptionProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
id: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Declarative child marker for `TailoredContent`. This component intentionally
|
||||
* renders nothing; the parent reads its props (including `children`) directly
|
||||
* and renders the selected option's content itself.
|
||||
*/
|
||||
export function TailoredContentOption(_props: TailoredContentOptionProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
type TailoredContentProps = {
|
||||
children: ReactNode;
|
||||
header?: ReactNode;
|
||||
className?: string;
|
||||
defaultOptionIndex?: number;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type IconElement = React.ReactElement<{ className?: string }>;
|
||||
|
||||
function TailoredContentInner({
|
||||
children,
|
||||
className,
|
||||
defaultOptionIndex = 0,
|
||||
id,
|
||||
header,
|
||||
}: TailoredContentProps) {
|
||||
// All hooks must run unconditionally to satisfy the Rules of Hooks.
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const warnedKeyRef = useRef<string | null>(null);
|
||||
|
||||
// Memoize derived arrays so downstream hook deps have stable identities.
|
||||
const options = useMemo(
|
||||
() =>
|
||||
React.Children.toArray(children).filter((child) =>
|
||||
React.isValidElement(child),
|
||||
) as React.ReactElement<TailoredContentOptionProps>[],
|
||||
[children],
|
||||
);
|
||||
const optionIds = useMemo(
|
||||
() => options.map((option) => option.props.id),
|
||||
[options],
|
||||
);
|
||||
|
||||
// Warn (dev-mode friendly) when duplicate option ids would cause ambiguous
|
||||
// URL <-> selection mapping. Runs only when ids change; warnedKeyRef guards
|
||||
// against duplicate warns for the same set (e.g. StrictMode double-invoke).
|
||||
useEffect(() => {
|
||||
const seen = new Set<string>();
|
||||
const duplicates: string[] = [];
|
||||
for (const oid of optionIds) {
|
||||
if (seen.has(oid) && !duplicates.includes(oid)) {
|
||||
duplicates.push(oid);
|
||||
}
|
||||
seen.add(oid);
|
||||
}
|
||||
if (duplicates.length === 0) return;
|
||||
const warnKey = duplicates.join(",");
|
||||
if (warnedKeyRef.current === warnKey) return;
|
||||
warnedKeyRef.current = warnKey;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`TailoredContent(id=${id}): duplicate option id(s) detected: ${duplicates
|
||||
.map((d) => `"${d}"`)
|
||||
.join(", ")}. Option ids must be unique.`,
|
||||
);
|
||||
}, [optionIds, id]);
|
||||
|
||||
const updateSelection = useCallback(
|
||||
(index: number) => {
|
||||
if (index < 0 || index >= options.length) return;
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
newParams.set(id, optionIds[index]);
|
||||
// Update URL without reload; derived selectedIndex will follow.
|
||||
// Use history entries so browser back/forward can move between options.
|
||||
router.push(`?${newParams.toString()}`, { scroll: false });
|
||||
},
|
||||
[router, searchParams, id, optionIds, options.length],
|
||||
);
|
||||
|
||||
// No hooks below this point — safe to short-circuit when there are no options.
|
||||
if (options.length === 0) return null;
|
||||
|
||||
// Clamp defaultOptionIndex to the valid range.
|
||||
const clampedDefault = Math.min(
|
||||
Math.max(0, defaultOptionIndex),
|
||||
options.length - 1,
|
||||
);
|
||||
|
||||
// Derive selectedIndex from the URL on every render so state stays in sync
|
||||
// with navigation (back/forward, external updates to the search param).
|
||||
const urlParam = searchParams.get(id);
|
||||
const indexFromUrl = urlParam ? optionIds.indexOf(urlParam) : -1;
|
||||
const selectedIndex = indexFromUrl >= 0 ? indexFromUrl : clampedDefault;
|
||||
|
||||
const focusTab = (index: number) => {
|
||||
const el = tabRefs.current[index];
|
||||
if (el) el.focus();
|
||||
};
|
||||
|
||||
const onKeyDown = (
|
||||
e: React.KeyboardEvent<HTMLButtonElement>,
|
||||
index: number,
|
||||
) => {
|
||||
switch (e.key) {
|
||||
case "Enter":
|
||||
case " ":
|
||||
case "Spacebar":
|
||||
e.preventDefault();
|
||||
updateSelection(index);
|
||||
return;
|
||||
case "ArrowRight": {
|
||||
e.preventDefault();
|
||||
const next = (index + 1) % options.length;
|
||||
updateSelection(next);
|
||||
focusTab(next);
|
||||
return;
|
||||
}
|
||||
case "ArrowLeft": {
|
||||
e.preventDefault();
|
||||
const prev = (index - 1 + options.length) % options.length;
|
||||
updateSelection(prev);
|
||||
focusTab(prev);
|
||||
return;
|
||||
}
|
||||
case "Home": {
|
||||
e.preventDefault();
|
||||
updateSelection(0);
|
||||
focusTab(0);
|
||||
return;
|
||||
}
|
||||
case "End": {
|
||||
e.preventDefault();
|
||||
const last = options.length - 1;
|
||||
updateSelection(last);
|
||||
focusTab(last);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const itemCn =
|
||||
"shell-docs-radius-control group relative flex min-h-[5.5rem] flex-1 cursor-pointer items-start gap-3 overflow-hidden border border-[var(--border)] bg-[var(--bg-surface)] p-3.5 text-left text-[var(--text)] shadow-[var(--shadow-control)] transition-colors hover:border-[var(--accent)] hover:bg-[var(--bg-elevated)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] md:min-h-[6rem]";
|
||||
const selectedCn =
|
||||
"selected border-[var(--accent)] bg-[var(--accent-dim)] text-[var(--text)]";
|
||||
const iconCn =
|
||||
"h-4 w-4 shrink-0 text-[var(--text-muted)] opacity-60 transition-colors group-[.selected]:text-[var(--accent)] group-[.selected]:opacity-90";
|
||||
const indicatorCn =
|
||||
"tailored-content-selected-indicator mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border transition-colors";
|
||||
|
||||
const tablistId = `tailored-content-tablist-${id}`;
|
||||
const tabId = (optId: string) => `tailored-content-tab-${id}-${optId}`;
|
||||
const panelId = (optId: string) => `tailored-content-panel-${id}-${optId}`;
|
||||
|
||||
const selectedOption = options[selectedIndex];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={cn("tailored-content-wrapper mt-4", className)}>
|
||||
{header}
|
||||
<div
|
||||
id={tablistId}
|
||||
role="tablist"
|
||||
aria-orientation="horizontal"
|
||||
className="flex flex-col md:flex-row gap-3 mt-2 mb-6 w-full"
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = selectedIndex === index;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={option.props.id}
|
||||
ref={(el) => {
|
||||
tabRefs.current[index] = el;
|
||||
}}
|
||||
id={tabId(option.props.id)}
|
||||
className={cn(itemCn, isSelected && selectedCn)}
|
||||
onClick={() => updateSelection(index)}
|
||||
onKeyDown={(e) => onKeyDown(e, index)}
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
aria-label={`${option.props.title}${
|
||||
isSelected ? ", selected" : ""
|
||||
}`}
|
||||
aria-controls={panelId(option.props.id)}
|
||||
tabIndex={isSelected ? 0 : -1}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
indicatorCn,
|
||||
isSelected
|
||||
? "border-[var(--accent)] bg-[var(--accent)] text-[var(--primary-foreground)]"
|
||||
: "border-[var(--border)] bg-[var(--bg-surface)] text-transparent group-hover:border-[var(--accent)] group-hover:bg-[var(--accent-dim)] group-hover:text-[var(--accent)]",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="mb-1 flex min-w-0 items-center gap-2">
|
||||
{React.isValidElement(option.props.icon)
|
||||
? (() => {
|
||||
const icon = option.props.icon as IconElement;
|
||||
return React.cloneElement(icon, {
|
||||
className: cn(
|
||||
icon.props?.className,
|
||||
iconCn,
|
||||
"my-0",
|
||||
),
|
||||
});
|
||||
})()
|
||||
: null}
|
||||
<span className="block min-w-0 text-base font-semibold leading-snug">
|
||||
{option.props.title}
|
||||
</span>
|
||||
</span>
|
||||
<span className="block text-sm leading-relaxed text-[var(--text-secondary)]">
|
||||
{option.props.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{selectedOption && (
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={panelId(selectedOption.props.id)}
|
||||
aria-labelledby={tabId(selectedOption.props.id)}
|
||||
>
|
||||
{selectedOption.props.children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `TailoredContent` renders a set of tab-like options and the currently
|
||||
* selected option's content. The selection is persisted in the URL via
|
||||
* `?<id>=<optionId>` so links are shareable.
|
||||
*
|
||||
* Next.js App Router requires `useSearchParams()` to be wrapped in a
|
||||
* `<Suspense>` boundary. The exported component wraps the inner
|
||||
* implementation so consumers don't need to do that themselves.
|
||||
*/
|
||||
export function TailoredContent(props: TailoredContentProps) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<TailoredContentInner {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ReferenceVersion } from "@/lib/reference-items";
|
||||
|
||||
export type { ReferenceVersion };
|
||||
|
||||
export type ReferenceVersionOption = {
|
||||
version: ReferenceVersion;
|
||||
href: string;
|
||||
};
|
||||
|
||||
// The selector now switches between SDKs, not just React versions. Labels
|
||||
// are user-facing; keep them in sync with REFERENCE_VERSIONS.
|
||||
const VERSION_LABELS: Record<ReferenceVersion, string> = {
|
||||
v2: "React (V2)",
|
||||
v1: "React (V1)",
|
||||
"react-native": "React Native",
|
||||
vue: "Vue",
|
||||
angular: "Angular",
|
||||
core: "Core (TypeScript)",
|
||||
channels: "Channels",
|
||||
};
|
||||
|
||||
export function ReferenceVersionSelector({
|
||||
activeVersion,
|
||||
options,
|
||||
}: {
|
||||
activeVersion: ReferenceVersion;
|
||||
options: ReferenceVersionOption[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const target = event.target instanceof Node ? event.target : null;
|
||||
if (!target) return;
|
||||
if (
|
||||
panelRef.current?.contains(target) ||
|
||||
buttonRef.current?.contains(target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
const handleKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClick);
|
||||
document.removeEventListener("keydown", handleKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
className="shell-docs-radius-control flex h-12 w-full cursor-pointer items-center gap-2 border border-[var(--nav-control-border)] bg-[var(--accent-dim)] p-1.5 text-[13px] font-medium text-[var(--text)] shadow-[var(--shadow-control)] transition-colors hover:border-[var(--nav-control-border-hover)] hover:bg-[var(--accent-light)]"
|
||||
>
|
||||
<span
|
||||
className="shell-docs-picker-icon-chip h-8 w-8 shrink-0 text-base"
|
||||
aria-hidden="true"
|
||||
>
|
||||
🪁
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-left">
|
||||
<span className="block truncate leading-tight">
|
||||
{VERSION_LABELS[activeVersion]}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[9px] uppercase leading-tight tracking-wider text-[var(--text-faint)]">
|
||||
SDK
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className="mr-0.5 h-3.5 w-3.5 shrink-0 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="listbox"
|
||||
className="shell-docs-radius-surface absolute left-0 right-0 top-full z-50 mt-1 border border-[var(--border)] bg-[var(--bg-surface)] p-2 shadow-[var(--shadow-panel)]"
|
||||
>
|
||||
{options.map(({ version, href }) => {
|
||||
const active = version === activeVersion;
|
||||
return (
|
||||
<Link
|
||||
key={version}
|
||||
href={href}
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onClick={() => setOpen(false)}
|
||||
className={[
|
||||
"shell-docs-radius-control flex w-full items-center gap-2 px-2 py-1.5 text-[13px] transition-colors",
|
||||
active
|
||||
? "bg-[var(--accent-dim)] text-[var(--accent)]"
|
||||
: "text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text)]",
|
||||
].join(" ")}
|
||||
>
|
||||
<span aria-hidden="true" className="shrink-0 text-sm">
|
||||
🪁
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{VERSION_LABELS[version]}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { ArrowRight, Check, ChevronDown, Search, X } from "lucide-react";
|
||||
import searchIndex from "@/data/search-index.json";
|
||||
import { DEFAULT_FRAMEWORK, useFramework } from "./framework-provider";
|
||||
import { frontendFromPathname } from "@/lib/frontend-options";
|
||||
import { FrameworkLogo } from "./icons/framework-icons";
|
||||
import { compareByDisplayOrder } from "@/lib/framework-order";
|
||||
import type { Registry } from "@/lib/registry";
|
||||
import { getRuntimeConfig } from "@/lib/runtime-config.client";
|
||||
import {
|
||||
frameworkDocsHref,
|
||||
normalizeHref,
|
||||
parseDocsHref,
|
||||
parseIntegrationDocsHref,
|
||||
} from "@/lib/search-hrefs";
|
||||
|
||||
// Integrations explorer + per-integration demo pages live on the shell
|
||||
// host (showcase.copilotkit.ai), not on shell-docs. Search results that
|
||||
// surface an integration or one of its demos route there directly. The
|
||||
// shell host is now read at runtime from window.__SHOWCASE_CONFIG__
|
||||
// (set by the root layout) so a single built artifact can serve
|
||||
// staging vs prod without rebuilding — see lib/runtime-config.client.
|
||||
|
||||
type SearchResultType =
|
||||
| "integration"
|
||||
| "feature"
|
||||
| "page"
|
||||
| "reference"
|
||||
| "ag-ui"
|
||||
| "docs";
|
||||
|
||||
interface SearchIndexEntry {
|
||||
type: "page" | "reference" | "ag-ui";
|
||||
title: string;
|
||||
subtitle: string;
|
||||
section?: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface FrameworkOption {
|
||||
slug: string;
|
||||
name: string;
|
||||
logo?: string | null;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
type: SearchResultType;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
section?: string;
|
||||
href: string;
|
||||
frameworkName?: string;
|
||||
frameworkCount?: number;
|
||||
}
|
||||
|
||||
function isExternalHref(href: string): boolean {
|
||||
// Protocol-relative or http(s) URLs, plus non-navigable schemes that
|
||||
// next/router can't handle (mailto/tel/ftp[s]) — all must leave the SPA
|
||||
// via window.location rather than router.push.
|
||||
return /^(https?:)?\/\//i.test(href) || /^(mailto|tel|ftp|ftps):/i.test(href);
|
||||
}
|
||||
|
||||
function dedupeResults(items: SearchResult[]): SearchResult[] {
|
||||
const seen = new Set<string>();
|
||||
const out: SearchResult[] = [];
|
||||
for (const item of items) {
|
||||
const key = `${item.type}::${item.href}::${item.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const DOCS_FOLDER_OVERRIDES: Record<string, string> = {
|
||||
"langgraph-python": "langgraph",
|
||||
"langgraph-typescript": "langgraph",
|
||||
"langgraph-fastapi": "langgraph",
|
||||
"google-adk": "adk",
|
||||
"crewai-crews": "crewai-flows",
|
||||
strands: "aws-strands",
|
||||
"strands-typescript": "aws-strands",
|
||||
"ms-agent-dotnet": "microsoft-agent-framework",
|
||||
"ms-agent-python": "microsoft-agent-framework",
|
||||
};
|
||||
|
||||
function getDocsFolderForSlug(slug: string): string {
|
||||
return DOCS_FOLDER_OVERRIDES[slug] ?? slug;
|
||||
}
|
||||
|
||||
function buildDocsFolderMap(
|
||||
registryData: Registry | null,
|
||||
): Map<string, FrameworkOption[]> {
|
||||
const map = new Map<string, FrameworkOption[]>();
|
||||
for (const integration of registryData?.integrations ?? []) {
|
||||
if (integration.docs_mode === "hidden") continue;
|
||||
const folder = getDocsFolderForSlug(integration.slug);
|
||||
const next = map.get(folder) ?? [];
|
||||
next.push({
|
||||
slug: integration.slug,
|
||||
name: integration.name,
|
||||
logo: integration.logo,
|
||||
});
|
||||
map.set(folder, next);
|
||||
}
|
||||
|
||||
for (const options of map.values()) {
|
||||
options.sort((a, b) => compareByDisplayOrder(a.slug, b.slug));
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
function matchesQuery(
|
||||
fields: Array<string | undefined>,
|
||||
query: string,
|
||||
): boolean {
|
||||
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const haystack = fields.filter(Boolean).join(" ").toLowerCase();
|
||||
return terms.every((term) => haystack.includes(term));
|
||||
}
|
||||
|
||||
function formatType(type: SearchResultType): string {
|
||||
if (type === "ag-ui") return "AG-UI";
|
||||
if (type === "docs") return "Docs";
|
||||
return type;
|
||||
}
|
||||
|
||||
function scoreResult(result: SearchResult, query: string): number {
|
||||
const q = query.toLowerCase();
|
||||
const title = result.title.toLowerCase();
|
||||
const typePriority: Record<SearchResultType, number> = {
|
||||
docs: 0,
|
||||
page: 1,
|
||||
integration: 2,
|
||||
reference: 3,
|
||||
"ag-ui": 4,
|
||||
feature: 6,
|
||||
};
|
||||
|
||||
let score = typePriority[result.type] * 10;
|
||||
if (result.frameworkName) score -= 6;
|
||||
if (title === q) score -= 30;
|
||||
else if (title.startsWith(q)) score -= 18;
|
||||
else if (title.includes(q)) score -= 8;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
export function SearchModal({ onClose }: { onClose: () => void }) {
|
||||
const { effectiveFramework, setStoredFramework } = useFramework();
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [selectedFramework, setSelectedFramework] = useState(
|
||||
effectiveFramework || DEFAULT_FRAMEWORK,
|
||||
);
|
||||
const [frameworkPickerOpen, setFrameworkPickerOpen] = useState(false);
|
||||
const [registryData, setRegistryData] = useState<Registry | null>(null);
|
||||
const [registryError, setRegistryError] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const selectedIndexRef = useRef(0);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname() ?? "";
|
||||
const activeFrontend = frontendFromPathname(pathname);
|
||||
|
||||
// Keep a ref in sync with selectedIndex so the Enter handler never reads
|
||||
// a stale closure value (reset-on-input + key-handler race).
|
||||
useEffect(() => {
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
}, [selectedIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const focusInput = () => {
|
||||
inputRef.current?.focus({ preventScroll: true });
|
||||
inputRef.current?.select();
|
||||
};
|
||||
const frameId = window.requestAnimationFrame(focusInput);
|
||||
const focusId = window.setTimeout(focusInput, 80);
|
||||
let cancelled = false;
|
||||
import("@/data/registry.json")
|
||||
.then((mod) => {
|
||||
if (!cancelled) setRegistryData(mod.default as Registry);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[search-modal] failed to load registry", err);
|
||||
setRegistryError(true);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.cancelAnimationFrame(frameId);
|
||||
window.clearTimeout(focusId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown, { capture: true });
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown, { capture: true });
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const frameworkOptions = useMemo(() => {
|
||||
const integrations = registryData?.integrations ?? [];
|
||||
return integrations
|
||||
.filter((i) => i.docs_mode !== "hidden")
|
||||
.map((i) => ({
|
||||
slug: i.slug,
|
||||
name: i.name,
|
||||
logo: i.logo,
|
||||
}))
|
||||
.sort((a, b) => compareByDisplayOrder(a.slug, b.slug));
|
||||
}, [registryData]);
|
||||
|
||||
const selectedFrameworkOption = useMemo(
|
||||
() =>
|
||||
frameworkOptions.find((option) => option.slug === selectedFramework) ??
|
||||
null,
|
||||
[frameworkOptions, selectedFramework],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (frameworkOptions.length === 0) return;
|
||||
if (frameworkOptions.some((option) => option.slug === selectedFramework)) {
|
||||
return;
|
||||
}
|
||||
const fallback =
|
||||
frameworkOptions.find((option) => option.slug === DEFAULT_FRAMEWORK) ??
|
||||
frameworkOptions[0];
|
||||
setSelectedFramework(fallback.slug);
|
||||
}, [frameworkOptions, selectedFramework]);
|
||||
|
||||
const chooseFramework = useCallback(
|
||||
(slug: string) => {
|
||||
setSelectedFramework(slug);
|
||||
setStoredFramework(slug);
|
||||
setSelectedIndex(0);
|
||||
setFrameworkPickerOpen(false);
|
||||
window.requestAnimationFrame(() => {
|
||||
inputRef.current?.focus({ preventScroll: true });
|
||||
inputRef.current?.select();
|
||||
});
|
||||
},
|
||||
[setStoredFramework],
|
||||
);
|
||||
|
||||
// Read the shell host once per render from the runtime config injected
|
||||
// into window by the root layout. Pulled inside the component (not at
|
||||
// module top) because the value only exists after hydration and the
|
||||
// client reader throws on the server. Threaded into normalizeHref()
|
||||
// and the integration href below so neither one re-reads window.
|
||||
const shellHost = getRuntimeConfig().shellUrl;
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
const q = query.trim();
|
||||
const items: SearchResult[] = [];
|
||||
const docsFolderMap = buildDocsFolderMap(registryData);
|
||||
const selectedFrameworkName =
|
||||
frameworkOptions.find((option) => option.slug === selectedFramework)
|
||||
?.name ?? selectedFramework;
|
||||
const docsGroups = new Map<
|
||||
string,
|
||||
{
|
||||
topic: string;
|
||||
entry: SearchIndexEntry;
|
||||
href: string;
|
||||
frameworkCount?: number;
|
||||
}
|
||||
>();
|
||||
|
||||
// Static search index is available immediately — search it even before
|
||||
// the dynamic registry.json has resolved.
|
||||
// Auto-generated by: npx tsx showcase/scripts/generate-search-index.ts
|
||||
const pages = searchIndex as SearchIndexEntry[];
|
||||
|
||||
for (const p of pages) {
|
||||
const integrationDoc = parseIntegrationDocsHref(p.href);
|
||||
if (integrationDoc) {
|
||||
const options = docsFolderMap.get(integrationDoc.folder) ?? [];
|
||||
const selectedOption = options.find(
|
||||
(option) => option.slug === selectedFramework,
|
||||
);
|
||||
if (!selectedOption) continue;
|
||||
if (
|
||||
!matchesQuery(
|
||||
[
|
||||
p.title,
|
||||
p.subtitle,
|
||||
p.section,
|
||||
selectedOption.name,
|
||||
selectedOption.slug,
|
||||
integrationDoc.topic,
|
||||
],
|
||||
q,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
docsGroups.set(integrationDoc.topic || "overview", {
|
||||
topic: integrationDoc.topic,
|
||||
entry: p,
|
||||
href: frameworkDocsHref(
|
||||
selectedOption.slug,
|
||||
integrationDoc.topic,
|
||||
activeFrontend,
|
||||
),
|
||||
frameworkCount: options.length,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const docsTopic = parseDocsHref(p.href);
|
||||
if (docsTopic !== null) {
|
||||
if (!matchesQuery([p.title, p.subtitle, p.section, docsTopic], q)) {
|
||||
continue;
|
||||
}
|
||||
if (!docsGroups.has(docsTopic)) {
|
||||
docsGroups.set(docsTopic, {
|
||||
topic: docsTopic,
|
||||
entry: p,
|
||||
href: frameworkDocsHref(
|
||||
selectedFramework,
|
||||
docsTopic,
|
||||
activeFrontend,
|
||||
),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (matchesQuery([p.title, p.subtitle, p.section], q)) {
|
||||
items.push({
|
||||
id: p.href,
|
||||
type: p.type,
|
||||
title: p.title,
|
||||
subtitle: p.subtitle,
|
||||
section: p.section,
|
||||
href: normalizeHref(p.href, shellHost),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of docsGroups.values()) {
|
||||
items.push({
|
||||
id: `docs:${group.topic}`,
|
||||
type: "docs",
|
||||
title: group.entry.title,
|
||||
subtitle: group.entry.subtitle,
|
||||
section: group.entry.section || "Framework docs",
|
||||
href: group.href,
|
||||
frameworkName: group.frameworkCount ? selectedFrameworkName : undefined,
|
||||
frameworkCount: group.frameworkCount,
|
||||
});
|
||||
}
|
||||
|
||||
if (registryData) {
|
||||
for (const i of registryData.integrations || []) {
|
||||
if (
|
||||
process.env.NODE_ENV !== "production" &&
|
||||
(!i.description || i.description.trim() === "")
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[search-modal] integration "${i.slug}" has no description — fix upstream in registry`,
|
||||
);
|
||||
}
|
||||
if (matchesQuery([i.name, i.description], q)) {
|
||||
items.push({
|
||||
id: `integration:${i.slug}`,
|
||||
type: "integration",
|
||||
title: i.name,
|
||||
subtitle: (i.description || "").slice(0, 80),
|
||||
href: `${shellHost}/integrations/${i.slug}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const f of registryData.feature_registry?.features || []) {
|
||||
if (matchesQuery([f.name, f.description], q)) {
|
||||
items.push({
|
||||
id: `feature:${f.id}`,
|
||||
type: "feature",
|
||||
title: f.name,
|
||||
subtitle: f.description,
|
||||
href: "/",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeResults(items)
|
||||
.sort((a, b) => scoreResult(a, q) - scoreResult(b, q))
|
||||
.slice(0, 12);
|
||||
}, [
|
||||
query,
|
||||
registryData,
|
||||
selectedFramework,
|
||||
frameworkOptions,
|
||||
shellHost,
|
||||
activeFrontend,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex((idx) =>
|
||||
results.length === 0 ? 0 : Math.min(idx, results.length - 1),
|
||||
);
|
||||
}, [results.length]);
|
||||
|
||||
const navigateTo = useCallback(
|
||||
(href: string) => {
|
||||
setFrameworkPickerOpen(false);
|
||||
if (isExternalHref(href)) {
|
||||
window.location.assign(href);
|
||||
} else {
|
||||
router.push(href);
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
[router, onClose],
|
||||
);
|
||||
|
||||
const onInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
// Don't hijack keys while an IME composition is active — Asian-language
|
||||
// users press Enter to commit a candidate and must not trigger navigation.
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
if (results.length === 0) return;
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.min(i + 1, results.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
if (results.length === 0) return;
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
const idx = selectedIndexRef.current;
|
||||
const chosen = results[idx];
|
||||
if (chosen) {
|
||||
e.preventDefault();
|
||||
navigateTo(chosen.href);
|
||||
}
|
||||
}
|
||||
},
|
||||
[results, navigateTo],
|
||||
);
|
||||
|
||||
const registryLoading = !registryData && !registryError;
|
||||
const hasFrameworkPicker = frameworkOptions.length > 0;
|
||||
const trimmedQuery = query.trim();
|
||||
const hasQuery = trimmedQuery.length > 0;
|
||||
const hasContentBelowScope =
|
||||
registryError ||
|
||||
(hasQuery && registryLoading) ||
|
||||
results.length > 0 ||
|
||||
(hasQuery && results.length === 0 && !registryLoading);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[200] bg-[var(--overlay-backdrop)] backdrop-blur-sm"
|
||||
onMouseDown={onClose}
|
||||
/>
|
||||
<div
|
||||
className="fixed top-[12%] left-1/2 z-[201] w-full max-w-2xl -translate-x-1/2 px-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search documentation"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDownCapture={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="shell-docs-radius-surface overflow-visible border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--shadow-modal)]">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="h-px bg-gradient-to-r from-transparent via-[var(--accent)]/70 to-transparent"
|
||||
/>
|
||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-[var(--border)]">
|
||||
<Search className="h-4 w-4 text-[var(--text-muted)]" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
onKeyDown={onInputKeyDown}
|
||||
placeholder="Search docs, API reference, integrations..."
|
||||
className="min-w-0 flex-1 bg-transparent text-[15px] text-[var(--text)] outline-none placeholder:text-[var(--text-faint)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="shell-docs-radius-control inline-flex h-7 w-7 items-center justify-center text-[var(--text-muted)] transition-colors hover:bg-[var(--bg-elevated)] hover:text-[var(--text)]"
|
||||
aria-label="Close search"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`relative flex items-center gap-2 bg-[var(--bg-elevated)]/45 px-5 py-2.5 text-[12px] text-[var(--text-muted)] ${
|
||||
hasContentBelowScope
|
||||
? "border-b border-[var(--border)]"
|
||||
: "rounded-b-xl"
|
||||
}`}
|
||||
>
|
||||
<span className="shrink-0">Searching docs for</span>
|
||||
<div className="relative min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasFrameworkPicker}
|
||||
onClick={() => setFrameworkPickerOpen((open) => !open)}
|
||||
className="shell-docs-radius-control inline-flex h-8 max-w-[min(56vw,220px)] items-center justify-between gap-2 border border-[var(--border)] bg-[var(--bg-surface)] px-2.5 text-left text-xs font-semibold text-[var(--text)] outline-none transition-colors hover:border-[var(--accent)] hover:bg-[var(--bg-hover)] focus-visible:border-[var(--accent)] disabled:opacity-60"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={frameworkPickerOpen}
|
||||
aria-label={`Choose docs framework. Currently ${
|
||||
selectedFrameworkOption?.name ?? "loading frameworks"
|
||||
}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{selectedFrameworkOption && (
|
||||
<FrameworkLogo
|
||||
slug={selectedFrameworkOption.slug}
|
||||
fallbackSrc={selectedFrameworkOption.logo}
|
||||
className="shrink-0 text-[var(--accent)]"
|
||||
size={14}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{selectedFrameworkOption?.name ?? "Loading frameworks"}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
|
||||
{frameworkPickerOpen && hasFrameworkPicker && (
|
||||
<div
|
||||
role="listbox"
|
||||
className="shell-docs-radius-surface absolute left-0 top-full z-10 mt-2 max-h-[280px] w-[min(360px,calc(100vw-3rem))] overflow-y-auto border border-[var(--border)] bg-[var(--bg-surface)] p-1.5 shadow-[var(--shadow-panel)]"
|
||||
>
|
||||
{frameworkOptions.map((option) => {
|
||||
const selected = option.slug === selectedFramework;
|
||||
return (
|
||||
<button
|
||||
key={option.slug}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onClick={() => chooseFramework(option.slug)}
|
||||
className={`shell-docs-radius-control flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm transition-colors ${
|
||||
selected
|
||||
? "bg-[var(--accent)]/10 text-[var(--text)]"
|
||||
: "text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text)]"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`shell-docs-radius-icon inline-flex h-7 w-7 shrink-0 items-center justify-center border ${
|
||||
selected
|
||||
? "border-[var(--accent)] bg-[var(--accent-dim)] text-[var(--accent)]"
|
||||
: "border-[var(--border)] bg-[var(--bg-elevated)] text-[var(--text-muted)]"
|
||||
}`}
|
||||
>
|
||||
<FrameworkLogo
|
||||
slug={option.slug}
|
||||
fallbackSrc={option.logo}
|
||||
size={16}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{option.name}
|
||||
</span>
|
||||
{selected && (
|
||||
<Check className="h-4 w-4 shrink-0 text-[var(--accent)]" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{registryError && (
|
||||
<div className="px-5 py-2.5 text-[12px] text-[var(--text-muted)] border-b border-[var(--border)] bg-[var(--bg-elevated)]">
|
||||
Search index failed to load. Try refresh.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasQuery && registryLoading && (
|
||||
<div className="px-5 py-2 text-[11px] text-[var(--text-faint)] border-b border-[var(--border)]">
|
||||
Loading integrations and framework docs...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="max-h-[390px] overflow-y-auto p-2">
|
||||
{results.map((r, idx) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
className={`shell-docs-radius-control flex w-full items-center gap-3 px-3 py-3 text-left transition-colors ${
|
||||
idx === selectedIndex
|
||||
? "bg-[var(--bg-elevated)]"
|
||||
: "hover:bg-[var(--bg-hover)]"
|
||||
}`}
|
||||
onClick={() => navigateTo(r.href)}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<span className="text-[10px] font-mono text-[var(--text-faint)] uppercase w-16 shrink-0">
|
||||
{formatType(r.type)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-[13px] font-semibold text-[var(--text)]">
|
||||
{r.title}
|
||||
</span>
|
||||
{r.section && (
|
||||
<span className="hidden shrink-0 text-[11px] font-normal text-[var(--text-faint)] sm:inline">
|
||||
{r.section}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)] truncate">
|
||||
{r.subtitle}
|
||||
</div>
|
||||
{r.frameworkName && (
|
||||
<div className="mt-1 text-[10px] font-medium text-[var(--accent)]">
|
||||
{r.frameworkName}
|
||||
{r.frameworkCount && r.frameworkCount > 1
|
||||
? ` selected from ${r.frameworkCount} backends`
|
||||
: " selected"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ArrowRight
|
||||
className={`h-4 w-4 shrink-0 transition-colors ${
|
||||
idx === selectedIndex
|
||||
? "text-[var(--accent)]"
|
||||
: "text-[var(--text-faint)]"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasQuery && results.length === 0 && !registryLoading && (
|
||||
<div className="px-5 py-8 text-center text-[13px] text-[var(--text-muted)]">
|
||||
No results for “{query}”
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Command, Search } from "lucide-react";
|
||||
import { SearchModal } from "./search-modal";
|
||||
|
||||
const TOGGLE_SEARCH_EVENT = "shell-docs:toggle-search";
|
||||
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!target || !(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
||||
if (target.isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function ShellSearchProvider({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeSearch = useCallback(() => setOpen(false), []);
|
||||
const toggleSearch = useCallback(() => setOpen((prev) => !prev), []);
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
// Don't hijack Cmd/Ctrl+K when the user is typing in an unrelated
|
||||
// input / textarea / contenteditable — only steal the shortcut when
|
||||
// focus is outside an editable element or already inside our own
|
||||
// search modal.
|
||||
const target = e.target as HTMLElement | null;
|
||||
const insideSearchModal =
|
||||
target?.closest?.("[data-search-modal]") != null;
|
||||
if (isEditableTarget(target) && !insideSearchModal) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleSearch();
|
||||
}
|
||||
if (e.key === "Escape") closeSearch();
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", onKeyDown, { capture: true });
|
||||
window.addEventListener(TOGGLE_SEARCH_EVENT, toggleSearch);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown, { capture: true });
|
||||
window.removeEventListener(TOGGLE_SEARCH_EVENT, toggleSearch);
|
||||
};
|
||||
}, [closeSearch, toggleSearch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
{open && <SearchModalWrapper onClose={closeSearch} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function toggleShellSearch() {
|
||||
window.dispatchEvent(new Event(TOGGLE_SEARCH_EVENT));
|
||||
}
|
||||
|
||||
export function SearchTrigger({
|
||||
iconOnly = false,
|
||||
}: { iconOnly?: boolean } = {}) {
|
||||
// Start as null so SSR output matches the initial client render; resolve
|
||||
// after mount to avoid hydration mismatch flashing ⌘K → Ctrl+K on non-Mac.
|
||||
const [isMac, setIsMac] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const mac =
|
||||
typeof navigator !== "undefined" && /mac/i.test(navigator.userAgent);
|
||||
setIsMac(mac);
|
||||
}, []);
|
||||
|
||||
if (iconOnly) {
|
||||
return (
|
||||
<button
|
||||
onClick={toggleShellSearch}
|
||||
className="shell-docs-radius-control flex h-10 w-10 cursor-pointer items-center justify-center border border-[var(--border)] bg-[var(--bg-surface)] text-[var(--text-muted)] shadow-[var(--shadow-control)] transition-colors hover:border-[var(--accent)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text)]"
|
||||
aria-label="Search"
|
||||
title="Search"
|
||||
>
|
||||
<Search className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Mirrors the canonical `search-button.tsx` chrome: same height as the
|
||||
// navbar's right-cluster controls, icon + label on lg+, ⌘K hint on xl+.
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={toggleShellSearch}
|
||||
aria-label="Search"
|
||||
className="shell-docs-radius-control flex h-10 w-10 cursor-pointer items-center gap-2 border border-[var(--border)] bg-[var(--bg-surface)] px-2.5 text-[var(--text-muted)] shadow-[var(--shadow-control)] transition-colors hover:border-[var(--accent)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text)] lg:w-[220px] xl:w-[260px]"
|
||||
>
|
||||
<Search className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
|
||||
<span className="hidden flex-1 text-left text-sm font-medium lg:block">
|
||||
Search
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="shell-docs-radius-control hidden min-w-[3.25rem] items-center justify-center gap-1 border border-[var(--border)] bg-[var(--bg-surface)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--text-faint)] xl:inline-flex"
|
||||
// Reserve horizontal room so the button doesn't reflow when the
|
||||
// shortcut hint appears after hydration.
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{isMac === null ? (
|
||||
"\u00a0"
|
||||
) : isMac ? (
|
||||
<>
|
||||
<Command className="h-3 w-3" aria-hidden="true" />K
|
||||
</>
|
||||
) : (
|
||||
"Ctrl K"
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchModalWrapper({ onClose }: { onClose: () => void }) {
|
||||
// Portal to document.body so the modal's `position: fixed` resolves
|
||||
// against the viewport. The trigger renders inside the navbar's right
|
||||
// cluster, which uses `backdrop-blur-lg` — backdrop-filter creates a
|
||||
// containing block for fixed-position descendants, which would
|
||||
// otherwise clamp the overlay to the cluster's bounding rect instead
|
||||
// of covering the page.
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(
|
||||
<div data-search-modal>
|
||||
<SearchModal onClose={onClose} />
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from "react";
|
||||
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
||||
import type * as PageTree from "fumadocs-core/page-tree";
|
||||
import { MobileTopNav } from "./mobile-top-nav";
|
||||
import { SidebarScrollPreserver } from "./sidebar-scroll-preserver";
|
||||
import { SidebarFolderStatePreserver } from "./sidebar-folder-state-preserver";
|
||||
import { SidebarReactDocsNotice } from "./sidebar-react-docs-notice";
|
||||
import GithubIcon from "./icons/github";
|
||||
import DiscordIcon from "./icons/discord";
|
||||
import { MobileSidebarFooterTalk } from "./mobile-sidebar-footer-talk";
|
||||
import { PrimaryDocsTabs } from "./primary-docs-tabs";
|
||||
|
||||
// Shared Fumadocs `DocsLayout` chrome used by every shell-docs route.
|
||||
// All five callers (home overview, framework root, framework-scoped MDX
|
||||
// via DocsPageView, /reference, /ag-ui) pass the same nav/search/sidebar
|
||||
// config — only `tree` and `sidebar.banner` vary. Centralizing keeps
|
||||
// sidebar behavior, mobile nav slot, and container className from drifting
|
||||
// across routes when one is tweaked.
|
||||
export function ShellDocsLayout({
|
||||
tree,
|
||||
banner,
|
||||
sidebarClassName,
|
||||
children,
|
||||
}: {
|
||||
tree: PageTree.Root;
|
||||
banner?: React.ReactNode;
|
||||
sidebarClassName?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<DocsLayout
|
||||
tree={tree}
|
||||
nav={{ component: <MobileTopNav /> }}
|
||||
searchToggle={{ enabled: false }}
|
||||
// Suppress fumadocs's auto-injected ThemeSwitch — `slots.themeSwitch`
|
||||
// is populated by default even when `themeSwitch` isn't passed, so
|
||||
// we have to pass `enabled: false` explicitly to keep the
|
||||
// `iconLinks.length > 0 || slots.themeSwitch` branch in
|
||||
// `sidebar.js` from rendering the default rounded pill. Our own
|
||||
// single-toggle `<ThemeSwitch>` is mounted in BrandNav instead.
|
||||
themeSwitch={{ enabled: false }}
|
||||
// We intentionally do NOT pass `links` here either. Fumadocs would
|
||||
// funnel `type: "icon"` entries into the same auto-injected pill
|
||||
// we just disabled — but the icons need to live in our custom
|
||||
// footer row anyway. Rendering them inline keeps a single source
|
||||
// of truth (the JSX below) and avoids the auto layout fighting
|
||||
// our custom one.
|
||||
sidebar={{
|
||||
banner: (
|
||||
<div key="shell-docs-sidebar-banner" className="flex flex-col">
|
||||
<PrimaryDocsTabs className="shell-docs-mobile-sidebar-tabs" />
|
||||
{banner}
|
||||
</div>
|
||||
),
|
||||
// Hide Fumadocs's collapse toggle — shell-docs has its own chrome.
|
||||
collapsible: false,
|
||||
className: ["shell-docs-sidebar", sidebarClassName]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
// Note: `key` is required here because fumadocs's Sidebar
|
||||
// passes the `footer` ReactNode into a `jsxs(children: [a, b,
|
||||
// footer])` array, and React's dev-mode warning insists every
|
||||
// top-level child of an array carry a stable key. We don't
|
||||
// control fumadocs's render, so we hand it a keyed element
|
||||
// directly. The key is a literal string — there's only ever
|
||||
// one footer per sidebar.
|
||||
footer: (
|
||||
<div
|
||||
key="shell-docs-sidebar-footer"
|
||||
className="flex w-full flex-col gap-2 sidebar-footer-row md:w-auto md:flex-row md:items-center md:gap-1"
|
||||
>
|
||||
<MobileSidebarFooterTalk />
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href="https://github.com/copilotkit/copilotkit"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
aria-label="GitHub"
|
||||
className="shell-docs-radius-control inline-flex h-7 w-7 items-center justify-center text-[var(--text-muted)] transition-colors hover:bg-[var(--bg-elevated)] hover:text-[var(--text)] [&_svg]:size-4"
|
||||
>
|
||||
<GithubIcon />
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/6dffbvGU3D"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
aria-label="Discord"
|
||||
className="shell-docs-radius-control inline-flex h-7 w-7 items-center justify-center text-[var(--text-muted)] transition-colors hover:bg-[var(--bg-elevated)] hover:text-[var(--text)] [&_svg]:size-4"
|
||||
>
|
||||
<DiscordIcon />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
containerProps={{
|
||||
// The outer .docs-content-wrapper gradient + scroll behavior is
|
||||
// applied to DocsLayout's container so the scroll context, border,
|
||||
// and gradient match what shell-docs has always rendered.
|
||||
className: "docs-content-wrapper",
|
||||
}}
|
||||
>
|
||||
{/* Preserve sidebar scroll across navigations — the sidebar is
|
||||
* rendered per-page (this layout sits inside each page component
|
||||
* rather than in a Next.js layout file), so without explicit
|
||||
* restoration the Radix ScrollAreaViewport resets to 0 every
|
||||
* time the user clicks a link further down the list. */}
|
||||
<SidebarScrollPreserver />
|
||||
{/* Persist sidebar folder open/closed state across navigations —
|
||||
* without this, Fumadocs resets each Radix Collapsible to its
|
||||
* default state on every page mount, undoing the user's
|
||||
* "I want this section hidden" choice. */}
|
||||
<SidebarFolderStatePreserver />
|
||||
<SidebarReactDocsNotice />
|
||||
{children}
|
||||
</DocsLayout>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user