OpenAPI: move to tsc
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': patch
|
||||
---
|
||||
|
||||
Move to `tsc` for building package
|
||||
@@ -1,11 +1,8 @@
|
||||
import { DocsLayout, type DocsLayoutProps } from 'fumadocs-ui/layouts/docs';
|
||||
import type { ReactNode } from 'react';
|
||||
import { MessageCircle } from 'lucide-react';
|
||||
import { baseOptions, linkItems } from '@/app/layout.config';
|
||||
import { source } from '@/lib/source';
|
||||
import { Trigger } from '@/components/ai/search-ai';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
const docsOptions: DocsLayoutProps = {
|
||||
...baseOptions,
|
||||
@@ -37,20 +34,5 @@ const docsOptions: DocsLayoutProps = {
|
||||
};
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<DocsLayout {...docsOptions}>
|
||||
{children}
|
||||
<Trigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'secondary',
|
||||
}),
|
||||
'fixed bottom-4 right-4 z-10 gap-2 rounded-xl bg-secondary/50 text-fd-secondary-foreground/80 shadow-lg backdrop-blur-lg md:bottom-8 md:right-8',
|
||||
)}
|
||||
>
|
||||
<MessageCircle className="size-4" />
|
||||
Ask AI
|
||||
</Trigger>
|
||||
</DocsLayout>
|
||||
);
|
||||
return <DocsLayout {...docsOptions}>{children}</DocsLayout>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'katex/dist/katex.min.css';
|
||||
import './global.css';
|
||||
import type { Viewport } from 'next';
|
||||
import { GeistSans } from 'geist/font/sans';
|
||||
@@ -6,6 +5,11 @@ import { GeistMono } from 'geist/font/mono';
|
||||
import { baseUrl, createMetadata } from '@/lib/metadata';
|
||||
import { Body } from '@/app/layout.client';
|
||||
import { Provider } from './provider';
|
||||
import { AISearchTrigger } from '@/components/ai';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { MessageCircle } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export const metadata = createMetadata({
|
||||
title: {
|
||||
@@ -23,11 +27,7 @@ export const viewport: Viewport = {
|
||||
],
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
@@ -35,7 +35,20 @@ export default function RootLayout({
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<Body>
|
||||
<Provider>{children}</Provider>
|
||||
<Provider>
|
||||
{children}
|
||||
<AISearchTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'secondary',
|
||||
}),
|
||||
'fixed bottom-4 right-4 z-10 gap-2 rounded-xl bg-secondary/50 text-fd-secondary-foreground/80 shadow-lg backdrop-blur-lg md:bottom-8 md:right-8',
|
||||
)}
|
||||
>
|
||||
<MessageCircle className="size-4" />
|
||||
Ask AI
|
||||
</AISearchTrigger>
|
||||
</Provider>
|
||||
</Body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
export interface Engine {
|
||||
prompt: (
|
||||
text: string,
|
||||
onUpdate?: (full: string) => void,
|
||||
onEnd?: (full: string) => void,
|
||||
) => Promise<void>;
|
||||
|
||||
abortAnswer: () => void;
|
||||
getHistory: () => MessageRecord[];
|
||||
clearHistory: () => void;
|
||||
regenerateLast: (
|
||||
onUpdate?: (full: string) => void,
|
||||
onEnd?: (full: string) => void,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface MessageRecord {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
|
||||
suggestions?: string[];
|
||||
references?: MessageReference[];
|
||||
}
|
||||
|
||||
export interface MessageReference {
|
||||
breadcrumbs?: string[];
|
||||
title: string;
|
||||
description?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
type EngineType = 'inkeep';
|
||||
|
||||
export const Context = createContext<{
|
||||
engine?: Engine;
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
}>({
|
||||
loading: false,
|
||||
setLoading: () => undefined,
|
||||
});
|
||||
|
||||
export function AIProvider({
|
||||
type,
|
||||
children,
|
||||
loadEngine = false,
|
||||
}: {
|
||||
type: EngineType;
|
||||
children: ReactNode;
|
||||
loadEngine?: boolean;
|
||||
}) {
|
||||
const pendingRef = useRef(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [engine, setEngine] = useState<Engine>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadEngine || pendingRef.current) return;
|
||||
pendingRef.current = true;
|
||||
// preload processor
|
||||
void import('./markdown-processor');
|
||||
|
||||
if (type === 'inkeep') {
|
||||
void import('./engines/inkeep').then(async (res) => {
|
||||
setEngine(await res.createInkeepEngine());
|
||||
});
|
||||
}
|
||||
}, [type, loadEngine]);
|
||||
|
||||
return (
|
||||
<Context
|
||||
value={useMemo(
|
||||
() => ({
|
||||
loading,
|
||||
setLoading,
|
||||
engine,
|
||||
}),
|
||||
[engine, loading],
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Context>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Engine, MessageRecord } from '@/components/ai/search-ai';
|
||||
import { InkeepAI } from '@inkeep/ai-api/sdk';
|
||||
import type { Engine, MessageRecord } from '@/components/ai/context';
|
||||
|
||||
const integrationId = process.env.NEXT_PUBLIC_INKEEP_INTEGRATION_ID;
|
||||
const apiKey = process.env.NEXT_PUBLIC_INKEEP_API_KEY;
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import type {
|
||||
Engine,
|
||||
MessageRecord,
|
||||
MessageReference,
|
||||
} from '@/components/ai/search-ai';
|
||||
import { OramaClient } from '@oramacloud/client';
|
||||
|
||||
const context =
|
||||
'The user is a web developer who knows some Next.js and React.js, but is new to Fumadocs.';
|
||||
const endpoint = process.env.NEXT_PUBLIC_ORAMA_ENDPOINT;
|
||||
const apiKey = process.env.NEXT_PUBLIC_ORAMA_API_KEY;
|
||||
|
||||
export async function createOramaEngine(): Promise<Engine> {
|
||||
if (!endpoint || !apiKey) throw new Error('Failed to find api keys');
|
||||
const client = new OramaClient({
|
||||
endpoint,
|
||||
api_key: apiKey,
|
||||
});
|
||||
|
||||
const instance = client.createAnswerSession({
|
||||
userContext: context,
|
||||
inferenceType: 'documentation',
|
||||
events: {
|
||||
onSourceChange(sources) {
|
||||
const last = instance.getMessages().at(-1);
|
||||
|
||||
if (last) {
|
||||
(last as MessageRecord).references = (
|
||||
sources as unknown as typeof sources.hits
|
||||
).map((result) => result.document as MessageReference);
|
||||
}
|
||||
},
|
||||
onRelatedQueries(queries) {
|
||||
const last = instance.getMessages().at(-1);
|
||||
|
||||
if (last) {
|
||||
(last as MessageRecord).suggestions = queries;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
async prompt(text, onUpdate, onEnd) {
|
||||
let v = '';
|
||||
const stream = await instance.askStream({
|
||||
term: text,
|
||||
});
|
||||
|
||||
for await (const block of stream) {
|
||||
v = block;
|
||||
onUpdate?.(block);
|
||||
}
|
||||
onEnd?.(v);
|
||||
},
|
||||
abortAnswer() {
|
||||
instance.abortAnswer();
|
||||
},
|
||||
getHistory() {
|
||||
return instance.getMessages();
|
||||
},
|
||||
clearHistory() {
|
||||
instance.clearSession();
|
||||
},
|
||||
async regenerateLast(onUpdate, onEnd) {
|
||||
const result = await instance.regenerateLast({ stream: true });
|
||||
let v = '';
|
||||
|
||||
for await (const block of result) {
|
||||
v = block;
|
||||
onUpdate?.(block);
|
||||
}
|
||||
onEnd?.(v);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
import { type ButtonHTMLAttributes, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
// lazy load the dialog
|
||||
const Dialog = dynamic(() => import('./search'));
|
||||
|
||||
/**
|
||||
* The trigger component for AI search dialog.
|
||||
*
|
||||
* Use it like a normal button component.
|
||||
*/
|
||||
export function AISearchTrigger(
|
||||
props: ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
) {
|
||||
const [open, setOpen] = useState<boolean>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<button {...props} onClick={() => setOpen(true)} />
|
||||
{open !== undefined ? (
|
||||
<Dialog open={open} onOpenChange={setOpen} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +1,33 @@
|
||||
'use client';
|
||||
import {
|
||||
type ButtonHTMLAttributes,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
type TextareaHTMLAttributes,
|
||||
use,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@radix-ui/react-dialog';
|
||||
import { Loader2, RefreshCw, Send, X } from 'lucide-react';
|
||||
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { buttonVariants } from '../../../../packages/ui/src/components/ui/button';
|
||||
import type { Processor } from './markdown-processor';
|
||||
import Link from 'fumadocs-core/link';
|
||||
|
||||
export interface Engine {
|
||||
prompt: (
|
||||
text: string,
|
||||
onUpdate?: (full: string) => void,
|
||||
onEnd?: (full: string) => void,
|
||||
) => Promise<void>;
|
||||
|
||||
abortAnswer: () => void;
|
||||
getHistory: () => MessageRecord[];
|
||||
clearHistory: () => void;
|
||||
regenerateLast: (
|
||||
onUpdate?: (full: string) => void,
|
||||
onEnd?: (full: string) => void,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface MessageRecord {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
|
||||
suggestions?: string[];
|
||||
references?: MessageReference[];
|
||||
}
|
||||
|
||||
export interface MessageReference {
|
||||
breadcrumbs?: string[];
|
||||
title: string;
|
||||
description?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
type EngineType = 'orama' | 'inkeep';
|
||||
|
||||
const Context = createContext<{
|
||||
engine?: Engine;
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
}>({
|
||||
loading: false,
|
||||
setLoading: () => undefined,
|
||||
});
|
||||
import {
|
||||
AIProvider,
|
||||
Context,
|
||||
type MessageRecord,
|
||||
} from '@/components/ai/context';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
type DialogProps,
|
||||
DialogTitle,
|
||||
} from '@radix-ui/react-dialog';
|
||||
|
||||
const listeners: (() => void)[] = [];
|
||||
|
||||
@@ -75,7 +35,7 @@ function onUpdate() {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function AIDialog() {
|
||||
function View() {
|
||||
const [_, update] = useState(0);
|
||||
const shouldFocus = useRef(false); // should focus on input on next render
|
||||
const { loading, setLoading, engine } = use(Context);
|
||||
@@ -410,59 +370,11 @@ function Markdown({ text }: { text: string }) {
|
||||
return rendered ?? text;
|
||||
}
|
||||
|
||||
function AIProvider({
|
||||
type,
|
||||
children,
|
||||
loadEngine = false,
|
||||
}: {
|
||||
type: EngineType;
|
||||
children: ReactNode;
|
||||
loadEngine?: boolean;
|
||||
}) {
|
||||
const pendingRef = useRef(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [engine, setEngine] = useState<Engine>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadEngine || pendingRef.current) return;
|
||||
pendingRef.current = true;
|
||||
// preload processor
|
||||
void import('./markdown-processor');
|
||||
|
||||
if (type === 'orama') {
|
||||
void import('./engines/orama').then(async (res) => {
|
||||
setEngine(await res.createOramaEngine());
|
||||
});
|
||||
} else if (type === 'inkeep') {
|
||||
void import('./engines/inkeep').then(async (res) => {
|
||||
setEngine(await res.createInkeepEngine());
|
||||
});
|
||||
}
|
||||
}, [type, loadEngine]);
|
||||
|
||||
export default function AISearch(props: DialogProps) {
|
||||
return (
|
||||
<Context
|
||||
value={useMemo(
|
||||
() => ({
|
||||
loading,
|
||||
setLoading,
|
||||
engine,
|
||||
}),
|
||||
[engine, loading],
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Context>
|
||||
);
|
||||
}
|
||||
|
||||
export function Trigger(props: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger {...props} />
|
||||
<AIProvider type="inkeep" loadEngine={open}>
|
||||
<Dialog {...props}>
|
||||
{props.children}
|
||||
<AIProvider type="inkeep" loadEngine={props.open}>
|
||||
<DialogPortal>
|
||||
<DialogOverlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm data-[state=closed]:animate-fd-fade-out data-[state=open]:animate-fd-fade-in" />
|
||||
<DialogContent
|
||||
@@ -489,7 +401,7 @@ export function Trigger(props: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
>
|
||||
<X className="size-4" />
|
||||
</DialogClose>
|
||||
<AIDialog />
|
||||
<View />
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</AIProvider>
|
||||
@@ -18,7 +18,7 @@ export const registry: Registry = {
|
||||
components: [
|
||||
{
|
||||
name: 'search-ai',
|
||||
files: ['ai/search-ai.tsx'],
|
||||
files: ['ai/index.tsx'],
|
||||
mapImportPath: {
|
||||
'../../../packages/ui/src/components/ui/button.tsx': {
|
||||
type: 'component',
|
||||
|
||||
@@ -15,7 +15,7 @@ Previously, this was only available on larger devices. Now, it's collapsed to a
|
||||
|
||||
<div className='mx-auto max-w-[400px]'>
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ The recommended design is:
|
||||
|
||||
<div className='mx-auto max-w-[400px]'>
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ They are not necessarily related to the docs, placing them at the top of our scr
|
||||
|
||||
All these links are now moved to sidebar, allowing a clean, minimal view of docs.
|
||||
|
||||

|
||||

|
||||
|
||||
I also noticed the docs start to look messy as your content grows. To ameliorate this, the sidebar now includes a border and background by default.
|
||||
This helps readers to distinguish navigation elements and content easier.
|
||||
@@ -33,7 +33,7 @@ In the past, you need to implement a navigation element to switch between page t
|
||||
Now, you can use the `<RootToggle />` component directly, and docs has a better explanation of it.
|
||||
|
||||
<div className="flex flex-row justify-center mx-auto max-w-[300px]">
|
||||

|
||||

|
||||
</div>
|
||||
|
||||
### Page Tree Post Processors
|
||||
|
||||
@@ -239,7 +239,7 @@ The navigation menu on Home layout is redesigned, with better animation and flex
|
||||
|
||||
See the [new API](/docs/ui/layouts/links).
|
||||
|
||||

|
||||

|
||||
|
||||
### Link Styles
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ You can just organize all items to a single, clean sidebar, but people instead a
|
||||
|
||||
<div className='mx-auto max-w-[400px]'>
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
@@ -52,6 +52,7 @@ export default defineConfig({
|
||||
return {
|
||||
rehypeCodeOptions: {
|
||||
lazy: true,
|
||||
experimentalJSEngine: true,
|
||||
langs: ['ts', 'js', 'html', 'tsx', 'mdx'],
|
||||
inline: 'tailing-curly-colon',
|
||||
themes: {
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function generateJS(
|
||||
const imports: ImportInfo[] = [
|
||||
{
|
||||
type: 'named',
|
||||
names: ['toRuntime', 'toRuntimeAsync'],
|
||||
names: ['toRuntime'],
|
||||
specifier: 'fumadocs-mdx',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -35,9 +35,9 @@
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bunchee",
|
||||
"build": "tsc --project tsconfig.build.json && tsc-alias -p tsconfig.build.json",
|
||||
"clean": "rimraf dist",
|
||||
"dev": "bunchee --watch",
|
||||
"dev": "concurrently \"tsc -w --project tsconfig.build.json\" \"tsc-alias -w -p tsconfig.build.json\"",
|
||||
"lint": "eslint .",
|
||||
"types:check": "tsc --noEmit"
|
||||
},
|
||||
@@ -53,6 +53,7 @@
|
||||
"fumadocs-ui": "workspace:*",
|
||||
"github-slugger": "^2.0.0",
|
||||
"hast-util-to-jsx-runtime": "^2.3.2",
|
||||
"lucide-react": "^0.474.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"next-themes": "^0.4.4",
|
||||
"openapi-sampler": "^1.6.1",
|
||||
@@ -68,12 +69,11 @@
|
||||
"@types/node": "22.13.0",
|
||||
"@types/openapi-sampler": "^1.0.3",
|
||||
"@types/react": "^19.0.8",
|
||||
"bunchee": "^6.3.2",
|
||||
"eslint-config-custom": "workspace:*",
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "15.1.6",
|
||||
"openapi-types": "^12.1.3",
|
||||
"tailwindcss": "^4.0.2",
|
||||
"tsc-alias": "^1.8.10",
|
||||
"tsconfig": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"sourceMap": false
|
||||
},
|
||||
"include": ["./src/**/*"]
|
||||
}
|
||||