OpenAPI: move to tsc

This commit is contained in:
Fuma Nama
2025-02-01 22:20:18 +08:00
parent 7b18075cc9
commit a127dc48da
24 changed files with 197 additions and 884 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Move to `tsc` for building package
+2 -20
View File
@@ -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>;
}
+20 -7
View File
@@ -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>
);
+93
View File
@@ -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 -1
View File
@@ -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;
-76
View File
@@ -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);
},
};
}
+26
View File
@@ -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>
+1 -1
View File
@@ -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',
+2 -2
View File
@@ -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]'>
![Preview](./toc-popover.png)
![Preview](/blog/toc-popover.png)
</div>
@@ -30,7 +30,7 @@ The recommended design is:
<div className='mx-auto max-w-[400px]'>
![Preview](./links-menu.png)
![Preview](/blog/links-menu.png)
</div>
+2 -2
View File
@@ -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.
![Preview](./v12.png)
![Preview](/blog/v12.png)
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]">
![Preview](./v12-root-toggle.png)
![Preview](/blog/v12-root-toggle.png)
</div>
### Page Tree Post Processors
+1 -1
View File
@@ -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).
![New Navbar](./v14-navbar.png)
![New Navbar](/blog/v14-navbar.png)
### Link Styles
+1 -1
View File
@@ -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]'>
![Next.js Docs](./img.png)
![Next.js Docs](/blog/img.png)
</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

+1
View File
@@ -52,6 +52,7 @@ export default defineConfig({
return {
rehypeCodeOptions: {
lazy: true,
experimentalJSEngine: true,
langs: ['ts', 'js', 'html', 'tsx', 'mdx'],
inline: 'tailing-curly-colon',
themes: {
+1 -1
View File
@@ -19,7 +19,7 @@ export async function generateJS(
const imports: ImportInfo[] = [
{
type: 'named',
names: ['toRuntime', 'toRuntimeAsync'],
names: ['toRuntime'],
specifier: 'fumadocs-mdx',
},
];
+4 -4
View File
@@ -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": {
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"sourceMap": false
},
"include": ["./src/**/*"]
}
+7 -660
View File
File diff suppressed because it is too large Load Diff