port mintlify to fumadocs

This commit is contained in:
miloschwartz
2026-09-25 15:31:51 -04:00
parent dc54fb1017
commit 63199a588c
373 changed files with 13533 additions and 4577 deletions
+86
View File
@@ -0,0 +1,86 @@
import { source } from '@/lib/source';
import {
DocsBody,
DocsDescription,
DocsPage,
DocsTitle,
MarkdownCopyButton,
ViewOptionsPopover,
} from 'fumadocs-ui/layouts/notebook/page';
import { notFound } from 'next/navigation';
import { getMDXComponents } from '@/components/mdx';
import type { Metadata } from 'next';
import { createRelativeLink } from 'fumadocs-ui/mdx';
import { getPageImageUrl, getPageMarkdownUrl, gitConfig, siteUrl } from '@/lib/shared';
import { AskAIAboutPage } from '@/components/ai/ask-page';
import { SiteFooter } from '@/components/site-footer';
import { AskBar } from '@/components/ai/ask-bar';
export default async function Page(props: PageProps<'/[[...slug]]'>) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
const MDX = page.data.body;
const markdownUrl = getPageMarkdownUrl(page).url;
return (
<DocsPage
toc={page.data.toc}
full={page.data.full}
tableOfContent={{ style: 'clerk' }}
// rendered after the previous / next page links
footer={{
children: (
<>
<SiteFooter />
<AskBar />
</>
),
}}
>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription className="mb-0">{page.data.description}</DocsDescription>
<div className="flex flex-row flex-wrap gap-2 items-center border-b pb-6">
<MarkdownCopyButton markdownUrl={markdownUrl} />
<ViewOptionsPopover
markdownUrl={markdownUrl}
pageUrl={`${siteUrl}${page.url}`}
githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/${page.path}`}
/>
<AskAIAboutPage title={page.data.title} />
</div>
<DocsBody>
<MDX
components={getMDXComponents({
// this allows you to link to other pages with relative file paths
a: createRelativeLink(source, page),
})}
/>
</DocsBody>
</DocsPage>
);
}
export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(props: PageProps<'/[[...slug]]'>): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
const isHome = page.slugs.length === 0;
return {
title: isHome ? { absolute: `${page.data.title} - Pangolin Docs` } : page.data.title,
description: page.data.description,
alternates: {
canonical: page.url,
types: { 'text/markdown': getPageMarkdownUrl(page).url },
},
openGraph: {
images: isHome ? '/images/home-social-graph.png' : getPageImageUrl(page).url,
},
};
}
+65
View File
@@ -0,0 +1,65 @@
import { DocsLayout } from 'fumadocs-ui/layouts/notebook';
import type { LayoutTab } from 'fumadocs-ui/layouts/shared';
import { Banner } from 'fumadocs-ui/components/banner';
import Link from 'fumadocs-core/link';
import { baseOptions } from '@/lib/layout.shared';
import { getNavigationTree, navTabs } from '@/lib/navigation';
import { banner } from '@/lib/shared';
import { source } from '@/lib/source';
import type { CSSProperties } from 'react';
import { AISearch, AISearchPanel } from '@/components/ai/search';
import { headerSearchSlot } from '@/components/header-search';
import { SidebarAnchors } from '@/components/sidebar-anchors';
const tabs: LayoutTab[] = [
// every docs page counts as the "Documentation" tab
{ title: 'Documentation', url: '/', urls: new Set(source.getPages().map((page) => page.url)) },
...navTabs.map((tab) => ({
title: tab.label,
url: tab.href,
props: { target: '_blank', rel: 'noreferrer noopener' },
})),
];
/** Mintlify-style grid, defined as `--pg-grid` in app/global.css (responsive) */
const containerStyle = { gridTemplate: 'var(--pg-grid)' } as CSSProperties;
export default function Layout({ children }: LayoutProps<'/'>) {
return (
<>
<Banner id={banner.id} className="pg-banner">
<p>
<strong>AI Gateway</strong> is now available
<span className="max-md:hidden">
: identity-aware access to any AI provider, eliminate API keys, and tunnel to
self-hosted models
</span>
.{' '}
<Link href={banner.link.href} className="underline underline-offset-2 font-medium">
{banner.link.label}
</Link>
</p>
</Banner>
{/* chat state wraps the whole layout so the header "Ask AI" button can open it */}
<AISearch>
<DocsLayout
{...baseOptions()}
slots={{ searchTrigger: headerSearchSlot }}
tree={getNavigationTree()}
nav={{ ...baseOptions().nav, mode: 'top' }}
tabMode="navbar"
tabs={tabs}
sidebar={{
defaultOpenLevel: 0,
collapsible: false,
banner: SidebarAnchors,
}}
containerProps={{ style: containerStyle }}
>
<AISearchPanel />
{children}
</DocsLayout>
</AISearch>
</>
);
}
+80
View File
@@ -0,0 +1,80 @@
import {
convertToModelMessages,
createUIMessageStreamResponse,
stepCountIs,
streamText,
toUIMessageStream,
type SystemModelMessage,
} from 'ai';
import type { ChatUIMessage } from '@/components/ai/search';
import { AIConfigError, getModel } from '@/lib/ai/model';
import { getSystemPrompt } from '@/lib/ai/prompt';
import { checkRateLimit, clientKey } from '@/lib/ai/rate-limit';
import { tools } from '@/lib/ai/tools';
export const maxDuration = 120;
const MAX_MESSAGES = 30;
const MAX_MESSAGE_CHARS = 8_000;
function json(status: number, message: string, headers?: HeadersInit) {
return Response.json({ error: message }, { status, headers });
}
export async function POST(req: Request) {
const limit = checkRateLimit(clientKey(req));
if (!limit.ok) {
return json(429, `Too many questions, try again in ${limit.retryAfter}s.`, {
'Retry-After': String(limit.retryAfter),
});
}
let config;
try {
config = getModel();
} catch (e) {
if (e instanceof AIConfigError) return json(503, e.message);
throw e;
}
const body = (await req.json().catch(() => null)) as { messages?: ChatUIMessage[] } | null;
const messages = (body?.messages ?? []).slice(-MAX_MESSAGES);
const tooLong = messages.some((m) =>
m.parts.some((p) => p.type === 'text' && p.text.length > MAX_MESSAGE_CHARS),
);
if (messages.length === 0 || tooLong) return json(400, 'Invalid or too long message.');
const instructions: SystemModelMessage = {
role: 'system',
content: getSystemPrompt(),
// the system prompt is large and identical across requests
providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } },
};
const result = streamText({
model: config.model,
stopWhen: stepCountIs(8),
tools,
toolChoice: 'auto',
instructions,
messages: await convertToModelMessages<ChatUIMessage>(messages, {
convertDataPart(part) {
if (part.type === 'data-client')
return {
type: 'text',
text: `[Client Context: ${JSON.stringify(part.data)}]`,
};
},
}),
onError({ error }) {
console.error('[api/chat]', error);
},
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({
stream: result.stream,
onError: () => 'The assistant hit an error. Please try again.',
}),
});
}
+3
View File
@@ -0,0 +1,3 @@
import { searchServer } from '@/lib/search';
export const { GET } = searchServer;
+1023
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
import { RootProvider } from 'fumadocs-ui/provider/next';
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import { Analytics } from '@/components/analytics';
import { appName, enableDarkMode, siteDescription, siteUrl } from '@/lib/shared';
import './global.css';
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
});
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: { template: `%s - ${appName}`, default: appName },
description: siteDescription,
icons: { icon: '/favicon.png' },
openGraph: { siteName: appName, images: '/images/home-social-graph.png' },
twitter: { card: 'summary_large_image' },
};
export const viewport: Viewport = {
themeColor: '#FAF9F2',
};
export default function Layout({ children }: LayoutProps<'/'>) {
return (
<html lang="en" className={inter.variable} suppressHydrationWarning>
<body className="flex flex-col min-h-screen">
<RootProvider
theme={
enableDarkMode
? { defaultTheme: 'light' }
: { forcedTheme: 'light', defaultTheme: 'light' }
}
>
{children}
</RootProvider>
<Analytics />
</body>
</html>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { getPageText, source } from '@/lib/source';
import { getOrderedPagePaths } from '@/lib/navigation';
export const revalidate = false;
export async function GET() {
const pages = source.getPages();
const byPath = new Map(pages.map((page) => [page.slugs.join('/') || 'index', page]));
// sidebar order first, then everything that isn't listed in the sidebar
const ordered = getOrderedPagePaths()
.map((path) => byPath.get(path))
.filter((page) => page !== undefined);
const rest = pages.filter((page) => !ordered.includes(page));
const texts = await Promise.all([...ordered, ...rest].map(getPageText));
return new Response(texts.join('\n\n---\n\n'), {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
+24
View File
@@ -0,0 +1,24 @@
import { docsLlms, source } from '@/lib/source';
import { getPageMarkdownUrl } from '@/lib/shared';
import { notFound } from 'next/navigation';
export const revalidate = false;
export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/[[...slug]]'>) {
const { slug } = await params;
// last segment is always `content.md`
const page = source.getPage(slug?.slice(0, -1));
if (!page) notFound();
return new Response(await docsLlms.page(page), {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
},
});
}
export function generateStaticParams() {
return source.getPages().map((page) => ({
slug: getPageMarkdownUrl(page).segments,
}));
}
+62
View File
@@ -0,0 +1,62 @@
import { source } from '@/lib/source';
import { getNavigationTree } from '@/lib/navigation';
import { appName, siteDescription, siteUrl } from '@/lib/shared';
import type * as PageTree from 'fumadocs-core/page-tree';
export const revalidate = false;
/** https://llmstxt.org — follows the sidebar structure, plus pages not listed in it */
export async function GET() {
const tree = getNavigationTree();
const lines: string[] = [
`# ${appName}`,
'',
`> ${siteDescription}`,
'',
`The full documentation as a single file is available at ${siteUrl}/llms-full.txt. Append \`.md\` to any page URL to get its Markdown source.`,
'',
];
const listed = new Set<string>();
function descriptionOf(url: string) {
const page = source.getPages().find((p) => p.url === url);
return page?.data.description;
}
function item(node: PageTree.Item, depth: number) {
listed.add(node.url);
const description = descriptionOf(node.url);
lines.push(
`${' '.repeat(depth)}- [${String(node.name)}](${siteUrl}${node.url}.md)${description ? `: ${description}` : ''}`,
);
}
function walk(nodes: PageTree.Node[], depth: number) {
for (const node of nodes) {
if (node.type === 'separator') {
lines.push('', `## ${String(node.name)}`, '');
} else if (node.type === 'page') {
item(node, depth);
} else {
lines.push(`${' '.repeat(depth)}- ${String(node.name)}`);
if (node.index) item(node.index, depth + 1);
walk(node.children, depth + 1);
}
}
}
walk(tree.children, 0);
const optional = source.getPages().filter((page) => !listed.has(page.url));
if (optional.length > 0) {
lines.push('', '## Optional', '');
for (const page of optional) {
lines.push(
`- [${page.data.title}](${siteUrl}${page.url}.md)${page.data.description ? `: ${page.data.description}` : ''}`,
);
}
}
return new Response(lines.join('\n').replace(/\/\.md\)/g, '/index.md)') + '\n', {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
+29
View File
@@ -0,0 +1,29 @@
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import { registerSearchTool, registerSourceTools } from 'fumadocs-core/mcp';
import { docsLlms, source } from '@/lib/source';
import { searchServer } from '@/lib/search';
import { appName } from '@/lib/shared';
const handler = createMcpHandler(() => {
const mcp = new McpServer({
name: appName,
version: '1.0.0',
});
registerSourceTools(mcp, source, docsLlms);
registerSearchTool(mcp, searchServer);
return mcp;
});
export function GET(request: Request) {
return handler.fetch(request);
}
export function POST(request: Request) {
return handler.fetch(request);
}
export function DELETE(request: Request) {
return handler.fetch(request);
}
+26
View File
@@ -0,0 +1,26 @@
import { source } from '@/lib/source';
import { notFound } from 'next/navigation';
import { generateOGImage } from 'fumadocs-ui/og';
import { appName, getPageImageUrl } from '@/lib/shared';
export const revalidate = false;
export async function GET(_req: Request, { params }: RouteContext<'/og/[...slug]'>) {
const { slug } = await params;
const page = source.getPage(slug.slice(0, -1));
if (!page) notFound();
return generateOGImage({
title: page.data.title,
description: page.data.description,
site: appName,
primaryColor: 'rgba(243, 97, 23, 0.35)',
primaryTextColor: 'rgb(243, 97, 23)',
});
}
export function generateStaticParams() {
return source.getPages().map((page) => ({
slug: getPageImageUrl(page).segments,
}));
}