OpenAPI: Drop renderer API

This commit is contained in:
Fuma Nama
2025-11-04 21:11:09 +08:00
committed by Fuma Nama
parent 297088544f
commit 87cdffa2da
15 changed files with 583 additions and 662 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'fumadocs-openapi': major
---
**Drop `renderer` API**
Fumadocs OpenAPI now expects per-feature customizations, dropping the old centralized `renderer` API.
+23 -21
View File
@@ -61,10 +61,7 @@ import {
SchemaProvider,
useResolvedSchema,
} from '@/playground/schema';
import {
useRequestDataUpdater,
useRequestInitialData,
} from '@/ui/contexts/code-example';
import { useOperationContext } from '@/ui/contexts/operation';
import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event';
import {
Select,
@@ -169,8 +166,11 @@ export default function Client({
...rest
}: ClientProps) {
const { server } = useServerSelectContext();
const { key: requestDataKey, data: requestData } = useRequestInitialData();
const updater = useRequestDataUpdater();
const {
example: exampleId,
examples,
setExampleData,
} = useOperationContext();
const fieldInfoMap = useMemo(() => new Map<string, FieldInfo>(), []);
const { mediaAdapters } = useApiContext();
const [securityId, setSecurityId] = useState(0);
@@ -179,16 +179,19 @@ export default function Client({
transformAuthInputs,
);
const defaultValues: FormValues = useMemo(
() => ({
const defaultValues: FormValues = useMemo(() => {
const requestData = examples.find(
(example) => example.id === exampleId,
)!.data;
return {
path: requestData.path,
query: requestData.query,
header: requestData.header,
body: requestData.body,
cookie: requestData.cookie,
}),
[requestData],
);
};
}, [examples, exampleId]);
const form = useForm<FormValues>({
defaultValues,
@@ -238,7 +241,7 @@ export default function Client({
bodyMediaType: body?.mediaType,
};
values._encoded ??= encodeRequestData(data, mediaAdapters, parameters);
updater.setData(data, values._encoded);
setExampleData(data, values._encoded);
});
useEffect(() => {
@@ -264,6 +267,13 @@ export default function Client({
// eslint-disable-next-line react-hooks/exhaustive-deps -- mounted once only
}, []);
useEffect(() => {
form.reset(initAuthValues(defaultValues, inputs));
return () => fieldInfoMap.clear();
// eslint-disable-next-line react-hooks/exhaustive-deps -- ignore other parts
}, [defaultValues]);
useEffect(() => {
form.reset((values) => initAuthValues(values, inputs));
@@ -276,17 +286,9 @@ export default function Client({
return values;
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- mounted once only
// eslint-disable-next-line react-hooks/exhaustive-deps -- ignore other parts
}, [inputs]);
useEffect(() => {
return () => {
fieldInfoMap.clear();
form.reset(initAuthValues(defaultValues, inputs));
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- for on change
}, [requestDataKey]);
const onSubmit = form.handleSubmit((value) => {
testQuery.start(mapInputs(value));
});
@@ -1,38 +1,44 @@
import type { CodeSample } from '@/ui/operation';
import * as CURL from './curl';
import * as JS from './javascript';
import * as Go from './go';
import * as Python from './python';
import * as Java from './java';
import * as CSharp from './csharp';
import type { CodeUsageGenerator } from '@/ui/operation/api-example';
export const defaultSamples: CodeSample[] = [
export const defaultSamples: CodeUsageGenerator[] = [
{
id: 'curl',
label: 'cURL',
source: CURL.generator,
lang: 'bash',
},
{
id: 'js',
label: 'JavaScript',
source: JS.generator,
lang: 'js',
},
{
id: 'go',
label: 'Go',
source: Go.generator,
lang: 'go',
},
{
id: 'python',
label: 'Python',
source: Python.generator,
lang: 'python',
},
{
id: 'java',
label: 'Java',
source: Java.generator,
lang: 'java',
},
{
id: 'csharp',
label: 'C#',
source: CSharp.generator,
lang: 'csharp',
+11 -3
View File
@@ -1,10 +1,10 @@
import { createProxy } from '@/server/proxy';
import type { CodeSample } from '@/ui/operation';
import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
import {
processDocument,
type ProcessedDocument,
} from '@/utils/process-document';
import type { CodeUsageGenerator } from '@/ui/operation/api-example';
/**
* schema id -> file path, URL, or downloaded schema object
@@ -73,6 +73,14 @@ export function createOpenAPI(options: OpenAPIOptions = {}): OpenAPIServer {
};
}
export function createCodeSample<T>(options: CodeSample<T>): CodeSample {
return options as CodeSample;
export function createCodeSample<T>(
options: Partial<CodeUsageGenerator<T>>,
): CodeUsageGenerator {
const {
lang = 'unknown',
id = lang,
...rest
} = options as Partial<CodeUsageGenerator>;
return { id, lang, ...rest };
}
+5 -2
View File
@@ -1,11 +1,11 @@
import type { OpenAPIV3_1 as V3_1 } from 'openapi-types';
import type { default as Slugger } from 'github-slugger';
import { type Renderer } from '@/ui/renderer';
import type { NoReference } from '@/utils/schema';
import type { ProcessedDocument } from '@/utils/process-document';
import type { MediaAdapter } from '@/requests/media/adapter';
import type { OpenAPIOptions } from '@/server';
import type { CreateAPIPageOptions } from './ui/api-page';
import type { CodeUsageGenerator } from './ui/operation/api-example';
export type Document = V3_1.Document;
export type OperationObject = V3_1.OperationObject;
@@ -17,15 +17,18 @@ export type TagObject = V3_1.TagObject;
export type ServerObject = V3_1.ServerObject;
export type CallbackObject = V3_1.CallbackObject;
export type ServerVariableObject = V3_1.ServerVariableObject;
export type ResponseObject = V3_1.ResponseObject;
export type MethodInformation = NoReference<OperationObject> & {
method: string;
'x-codeSamples'?: CodeUsageGenerator[];
'x-selectedCodeSample'?: string;
'x-exclusiveCodeSample'?: string;
};
export interface RenderContext
extends Pick<OpenAPIOptions, 'proxyUrl'>,
CreateAPIPageOptions {
renderer: Renderer;
servers: NoReference<ServerObject>[];
slugger: Slugger;
+60 -11
View File
@@ -1,18 +1,20 @@
import Slugger from 'github-slugger';
import { CodeSample, Operation } from '@/ui/operation';
import { Operation } from '@/ui/operation';
import type { MethodInformation, RenderContext } from '@/types';
import { createMethod, NoReference } from '@/utils/schema';
import { createRenders } from '@/ui/renderer';
import type { OpenAPIV3_1 } from 'openapi-types';
import type { ProcessedDocument } from '@/utils/process-document';
import { defaultAdapters, MediaAdapter } from '@/requests/media/adapter';
import { FC, ReactNode } from 'react';
import { ComponentProps, FC, ReactNode } from 'react';
import type {
HighlightOptionsCommon,
HighlightOptionsThemes,
} from 'fumadocs-core/highlight';
import type { OpenAPIServer } from '@/server';
import type { APIPageClientOptions } from './client';
import { cn } from 'fumadocs-ui/utils/cn';
import type { CodeUsageGenerator, ResponseTab } from './operation/api-example';
import { ApiProvider } from './contexts/api';
type Awaitable<T> = T | Promise<T>;
@@ -33,9 +35,11 @@ export interface CreateAPIPageOptions {
| false;
/**
* Generate code samples for endpoint.
* Generate example code usage for endpoints.
*/
generateCodeSamples?: (method: MethodInformation) => Awaitable<CodeSample[]>;
generateCodeSamples?: (
method: MethodInformation,
) => Awaitable<CodeUsageGenerator[]>;
shikiOptions?: Omit<HighlightOptionsCommon, 'lang' | 'components'> &
HighlightOptionsThemes;
@@ -59,6 +63,28 @@ export interface CreateAPIPageOptions {
* @defaultValue false
*/
showExampleInFields?: boolean;
renderResponseTabs?: (
tabs: ResponseTab[],
ctx: RenderContext,
) => Awaitable<ReactNode>;
renderAPIExampleLayout?: (
slots: {
selector: ReactNode;
usageTabs: ReactNode;
responseTabs: ReactNode;
},
ctx: RenderContext,
) => Awaitable<ReactNode>;
/**
* @param generators - codegens for API example usages
*/
renderAPIExampleUsageTabs?: (
generators: CodeUsageGenerator<unknown>[],
ctx: RenderContext,
) => Awaitable<ReactNode>;
};
/**
@@ -76,7 +102,7 @@ export interface CreateAPIPageOptions {
path: string;
method: MethodInformation;
ctx: RenderContext;
}) => ReactNode | Promise<ReactNode>;
}) => Awaitable<ReactNode>;
};
client?: APIPageClientOptions;
@@ -135,9 +161,6 @@ export function createAPIPage(
schema: processed,
proxyUrl: server.options.proxyUrl,
showResponseSchema: options.showResponseSchema,
renderer: {
...createRenders(),
},
shikiOptions: options.shikiOptions,
generateTypeScriptSchema: options.generateTypeScriptSchema,
generateCodeSamples: options.generateCodeSamples,
@@ -153,6 +176,32 @@ export function createAPIPage(
};
}
function Root({
children,
className,
ctx,
...props
}: { ctx: RenderContext } & ComponentProps<'div'>) {
const mediaAdapters: Record<string, MediaAdapter> = {};
for (const k in ctx.mediaAdapters) {
const adapter = ctx.mediaAdapters[k];
if (adapter.client) mediaAdapters[k] = adapter.client;
}
return (
<div className={cn('flex flex-col gap-24 text-sm', className)} {...props}>
<ApiProvider
mediaAdapters={mediaAdapters}
servers={ctx.servers}
shikiOptions={ctx.shikiOptions}
>
{children}
</ApiProvider>
</div>
);
}
async function APIPage({
hasHead = false,
operations,
@@ -164,7 +213,7 @@ async function APIPage({
const { dereferenced } = ctx.schema;
return (
<ctx.renderer.Root ctx={ctx}>
<Root ctx={ctx}>
{operations?.map((item) => {
const pathItem = dereferenced.paths?.[item.path];
if (!pathItem)
@@ -216,6 +265,6 @@ async function APIPage({
/>
);
})}
</ctx.renderer.Root>
</Root>
);
}
+3
View File
@@ -1,7 +1,10 @@
'use client';
import type { PlaygroundClientOptions } from '@/playground/client';
import type { OperationClientOptions } from '../operation/client';
export interface APIPageClientOptions {
playground?: PlaygroundClientOptions;
operation?: OperationClientOptions;
}
export function defineClientConfig(
@@ -1,215 +0,0 @@
'use client';
import {
createContext,
type HTMLAttributes,
type ReactNode,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useApiContext, useServerSelectContext } from '@/ui/contexts/api';
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
import type { CodeSample } from '@/ui/operation';
import type { SamplesProps } from '@/ui/renderer';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/ui/components/select';
import {
joinURL,
resolveRequestData,
resolveServerUrl,
withBase,
} from '@/utils/url';
import type { RawRequestData, RequestData } from '@/requests/types';
type UpdateListener = (data: RawRequestData, encoded: RequestData) => void;
const CodeExampleContext = createContext<{
route: string;
getExample: (
key: string,
) => { data: RawRequestData; encoded: RequestData } | undefined;
key: string;
setKey: (key: string) => void;
setData: (data: RawRequestData, encoded: RequestData) => void;
addListener: (listener: UpdateListener) => void;
removeListener: (listener: UpdateListener) => void;
} | null>(null);
export function CodeExampleProvider({
route,
examples,
initialKey,
children,
}: {
route: string;
examples: {
key: string;
data: RawRequestData;
encoded: RequestData;
}[];
initialKey?: string;
children: ReactNode;
}) {
const [key, setKey] = useState(initialKey ?? examples[0].key);
const listeners = useRef<UpdateListener[]>([]);
const examplesRef = useRef(examples);
examplesRef.current = examples;
return (
<CodeExampleContext
value={useMemo(
() => ({
key,
route,
setKey: (newKey: string) => {
const example = examplesRef.current.find(
(example) => example.key === newKey,
);
if (!example) return;
setKey(newKey);
for (const listener of listeners.current) {
listener(example.data, example.encoded);
}
},
getExample: (key) => {
return examplesRef.current.find((example) => example.key === key);
},
setData: (data, encoded) => {
for (const example of examplesRef.current) {
if (example.key === key) {
// persistent changes
example.data = data;
example.encoded = encoded;
break;
}
}
for (const listener of listeners.current) {
listener(data, encoded);
}
},
removeListener: (listener) => {
listeners.current = listeners.current.filter(
(item) => item !== listener,
);
},
addListener: (listener) => {
// initial call to listeners to ensure their data is the latest
// this is necessary to avoid race conditions between `useEffect()`
const example = examplesRef.current.find(
(example) => example.key === key,
)!;
listener(example.data, example.encoded);
listeners.current.push(listener);
},
}),
[key, route],
)}
>
{children}
</CodeExampleContext>
);
}
export function CodeExample(sample: CodeSample) {
const { shikiOptions, mediaAdapters } = useApiContext();
const { getExample, key, route, addListener, removeListener } =
useContext(CodeExampleContext)!;
const { server } = useServerSelectContext();
const [data, setData] = useState(() => getExample(key)!.encoded);
useEffect(() => {
const listener: UpdateListener = (_, encoded) => setData(encoded);
addListener(listener);
return () => {
removeListener(listener);
};
}, [addListener, removeListener]);
const code = useMemo(() => {
if (!sample.source) return;
if (typeof sample.source === 'string') return sample.source;
return sample.source(
joinURL(
withBase(
server ? resolveServerUrl(server.url, server.variables) : '/',
typeof window !== 'undefined'
? window.location.origin
: 'https://loading',
),
resolveRequestData(route, data),
),
data,
{
server: sample.serverContext,
mediaAdapters,
},
);
}, [mediaAdapters, sample, server, route, data]);
if (!code || !sample) return null;
return (
<DynamicCodeBlock lang={sample.lang} code={code} options={shikiOptions} />
);
}
export function CodeExampleSelector({ items }: SamplesProps) {
const { key, setKey } = useContext(CodeExampleContext)!;
const item = items.find((item) => item.value === key);
return (
<Select value={key} onValueChange={setKey}>
<SelectTrigger className="not-prose mb-2">
<SelectValue asChild>
{item ? <SelectDisplay item={item} /> : null}
</SelectValue>
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<SelectDisplay item={item} />
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function SelectDisplay({
item,
...props
}: HTMLAttributes<HTMLDivElement> & {
item: SamplesProps['items'][number];
}) {
return (
<div {...props}>
<span className="font-medium text-sm">{item.title}</span>
<span className="text-fd-muted-foreground">{item.description}</span>
</div>
);
}
export function useRequestInitialData() {
const { getExample, key } = useContext(CodeExampleContext)!;
return { key, data: getExample(key)!.data };
}
export function useRequestDataUpdater() {
const { setData } = useContext(CodeExampleContext)!;
return { setData };
}
@@ -0,0 +1,105 @@
'use client';
import {
createContext,
type ReactNode,
use,
useMemo,
useRef,
useState,
} from 'react';
import type { APIExampleItem } from '@/ui/operation/api-example';
import type { RawRequestData, RequestData } from '@/requests/types';
import type { OperationClientOptions } from '../operation/client';
export type ExampleUpdateListener = (
data: RawRequestData,
encoded: RequestData,
) => void;
const OperationContext = createContext<{
route: string;
examples: APIExampleItem[];
client?: OperationClientOptions;
example: string;
setExample: (id: string) => void;
setExampleData: (data: RawRequestData, encoded: RequestData) => void;
addListener: (listener: ExampleUpdateListener) => void;
removeListener: (listener: ExampleUpdateListener) => void;
} | null>(null);
export function OperationProvider({
route,
examples,
defaultExampleId,
children,
client,
}: {
route: string;
examples: APIExampleItem[];
defaultExampleId?: string;
client?: OperationClientOptions;
children: ReactNode;
}) {
const [example, setExample] = useState(
() => defaultExampleId ?? examples[0].id,
);
const listeners = useRef<ExampleUpdateListener[]>([]);
return (
<OperationContext
value={useMemo(
() => ({
example,
route,
client,
setExample: (newKey: string) => {
const example = examples.find((example) => example.id === newKey);
if (!example) return;
setExample(newKey);
for (const listener of listeners.current) {
listener(example.data, example.encoded);
}
},
examples,
setExampleData: (data, encoded) => {
for (const item of examples) {
if (item.id === example) {
// persistent changes
item.data = data;
item.encoded = encoded;
break;
}
}
for (const listener of listeners.current) {
listener(data, encoded);
}
},
removeListener: (listener) => {
listeners.current = listeners.current.filter(
(item) => item !== listener,
);
},
addListener: (listener) => {
// initial call to listeners to ensure their data is the latest
// this is necessary to avoid race conditions between `useEffect()`
const active = examples.find((item) => item.id === example)!;
listener(active.data, active.encoded);
listeners.current.push(listener);
},
}),
[example, route, client, examples],
)}
>
{children}
</OperationContext>
);
}
export function useOperationContext() {
return use(OperationContext)!;
}
-78
View File
@@ -1,79 +1 @@
import type { HTMLAttributes } from 'react';
import type { RootProps } from '@/ui/renderer';
import { ApiProvider } from '@/ui/lazy';
import { cn } from 'fumadocs-ui/utils/cn';
import type { MediaAdapter } from '@/requests/media/adapter';
export function Root({
children,
className,
ctx,
...props
}: RootProps & HTMLAttributes<HTMLDivElement>) {
const mediaAdapters: Record<string, MediaAdapter> = {};
for (const k in ctx.mediaAdapters) {
const adapter = ctx.mediaAdapters[k];
if (adapter.client) mediaAdapters[k] = adapter.client;
}
return (
<div className={cn('flex flex-col gap-24 text-sm', className)} {...props}>
<ApiProvider
mediaAdapters={mediaAdapters}
servers={ctx.servers}
shikiOptions={ctx.shikiOptions}
>
{children}
</ApiProvider>
</div>
);
}
export function APIInfo({
className,
...props
}: HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn('min-w-0 flex-1', className)} {...props}>
{props.children}
</div>
);
}
export function API({ children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
{...props}
className={cn(
'flex flex-col gap-x-6 gap-y-4 xl:flex-row xl:items-start',
props.className,
)}
style={
{
'--fd-api-info-top':
'calc(12px + var(--fd-nav-height) + var(--fd-banner-height) + var(--fd-tocnav-height, 0px))',
...props.style,
} as object
}
>
{children}
</div>
);
}
export function APIExample(props: HTMLAttributes<HTMLDivElement>) {
return (
<div
{...props}
className={cn(
'prose-no-margin md:sticky md:top-(--fd-api-info-top) xl:w-[400px]',
props.className,
)}
>
{props.children}
</div>
);
}
export * from '@/ui/api-page';
-30
View File
@@ -11,34 +11,4 @@ function wrap<Props>(V: ComponentType<Props>) {
};
}
export const CodeExampleProvider = wrap(
lazy(() =>
import('./contexts/code-example').then((mod) => ({
default: mod.CodeExampleProvider,
})),
),
);
export const CodeExample = wrap(
lazy(() =>
import('./contexts/code-example').then((mod) => ({
default: mod.CodeExample,
})),
),
);
export const CodeExampleSelector = wrap(
lazy(() =>
import('./contexts/code-example').then((mod) => ({
default: mod.CodeExampleSelector,
})),
),
);
export const ClientLazy = wrap(lazy(() => import('@/playground/client')));
export const ApiProvider = wrap(
lazy(() =>
import('./contexts/api').then((mod) => ({ default: mod.ApiProvider })),
),
);
+206 -119
View File
@@ -1,73 +1,107 @@
import type { MethodInformation, RenderContext } from '@/types';
import { type ReactNode } from 'react';
import type { MethodInformation, RenderContext, ResponseObject } from '@/types';
import type { ReactNode } from 'react';
import { Markdown } from '@/ui/components/server/markdown';
import { type CodeSample } from '@/ui/operation/index';
import { CodeBlock } from '@/ui/components/server/codeblock';
import { CodeExample, CodeExampleProvider } from '@/ui/lazy';
import { getPreferredType, type NoReference } from '@/utils/schema';
import { getRequestData } from '@/ui/operation/get-request-data';
import { sample } from 'openapi-sampler';
import type { RawRequestData, RequestData } from '@/requests/types';
import type {
RawRequestData,
RequestData,
SampleGenerator,
} from '@/requests/types';
import { defaultSamples } from '@/requests/generators';
import { encodeRequestData } from '@/requests/media/encode';
import {
AccordionContent,
AccordionHeader,
AccordionItem,
Accordions,
AccordionTrigger,
} from '../components/accordion';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
import {
CodeBlockTab,
CodeBlockTabs,
CodeBlockTabsList,
CodeBlockTabsTrigger,
} from 'fumadocs-ui/components/codeblock';
import { APIExampleSelector, APIExampleUsageTab } from './client';
interface CustomProperty {
'x-codeSamples'?: CodeSample[];
'x-selectedCodeSample'?: string;
'x-exclusiveCodeSample'?: string;
/**
* Generate code example for given programming language
*/
export interface CodeUsageGenerator<T = unknown> {
id: string;
lang: string;
label?: string;
/**
* either:
* - code
* - a function imported from a file with "use client" directive
* - false (disabled)
*/
source?: string | SampleGenerator<T> | false;
/**
* Pass extra context to client-side source generator
*/
serverContext?: T;
}
interface CodeExampleItem {
key: string;
export interface APIExampleItem {
id: string;
name: string;
description?: string;
data: RawRequestData;
encoded: RequestData;
}
export function APIExampleProvider({
examples,
method,
children,
route,
}: {
examples: CodeExampleItem[];
method: MethodInformation & CustomProperty;
route: string;
children: ReactNode;
}) {
const exclusiveSampleKey = method['x-exclusiveCodeSample'];
export interface ResponseTab {
/**
* HTTP response code
*/
code: string;
return (
<CodeExampleProvider
initialKey={exclusiveSampleKey}
route={route}
examples={examples}
>
{children}
</CodeExampleProvider>
);
response: NoReference<ResponseObject>;
/**
* media type of response
*/
mediaType: string | null;
examples?: {
/**
* generated/defined example data
*/
sample: unknown;
label: string;
/**
* description (in Markdown)
*/
description?: string;
}[];
}
export function getAPIExamples(
path: string,
method: MethodInformation,
ctx: RenderContext,
): CodeExampleItem[] {
): APIExampleItem[] {
const media = method.requestBody
? getPreferredType(method.requestBody.content)
: null;
const bodyOfType = media ? method.requestBody?.content[media] : null;
if (bodyOfType?.examples) {
const result: CodeExampleItem[] = [];
const result: APIExampleItem[] = [];
for (const [key, value] of Object.entries(bodyOfType.examples)) {
const data = getRequestData(path, method, key, ctx);
result.push({
key,
id: key,
name: value.summary ?? key,
description: value.description,
data,
@@ -85,7 +119,7 @@ export function getAPIExamples(
const data = getRequestData(path, method, null, ctx);
return [
{
key: '_default',
id: '_default',
name: 'Default',
description: bodyOfType?.schema?.description,
data,
@@ -100,62 +134,74 @@ export function getAPIExamples(
export async function APIExample({
method,
examples,
ctx,
}: {
examples: CodeExampleItem[];
method: MethodInformation & CustomProperty;
method: MethodInformation;
ctx: RenderContext;
}) {
const renderer = ctx.renderer;
let { renderAPIExampleUsageTabs, renderAPIExampleLayout } = ctx.content ?? {};
renderAPIExampleLayout ??= (slots) => {
return (
<div className="prose-no-margin md:sticky md:top-(--fd-api-info-top) xl:w-[400px]">
{slots.selector}
{slots.usageTabs}
{slots.responseTabs}
</div>
);
};
renderAPIExampleUsageTabs ??= (generators) => {
if (generators.length === 0) return null;
return (
<CodeBlockTabs
groupId="fumadocs_openapi_requests"
defaultValue={generators[0].id}
>
<CodeBlockTabsList>
{generators.map((item) => (
<CodeBlockTabsTrigger key={item.id} value={item.id}>
{item.label ?? item.lang}
</CodeBlockTabsTrigger>
))}
</CodeBlockTabsList>
{generators.map((item) => (
<CodeBlockTab key={item.id} value={item.id}>
<APIExampleUsageTab {...item} />
</CodeBlockTab>
))}
</CodeBlockTabs>
);
};
const generators = dedupe([
...defaultSamples,
...(ctx.generateCodeSamples ? await ctx.generateCodeSamples(method) : []),
...(method['x-codeSamples'] ?? []),
]).filter((generator) => generator.source !== false);
const exclusiveSampleKey = method['x-exclusiveCodeSample'];
return (
<renderer.APIExample>
{examples.length > 1 && !exclusiveSampleKey && (
<renderer.CodeExampleSelector
items={examples.map((sample) => ({
title: sample.name,
description: sample.description ? (
<Markdown text={sample.description} />
) : null,
value: sample.key,
}))}
/>
)}
{generators.length > 0 && (
<renderer.Requests items={generators.map((s) => s.label ?? s.lang)}>
{generators.map((generator, i) => (
<renderer.Request key={i} name={generator.label ?? generator.lang}>
<CodeExample {...generator} />
</renderer.Request>
))}
</renderer.Requests>
)}
<ResponseTabs operation={method} ctx={ctx} />
</renderer.APIExample>
return renderAPIExampleLayout(
{
selector: method['x-exclusiveCodeSample'] ? null : <APIExampleSelector />,
usageTabs: await renderAPIExampleUsageTabs(generators, ctx),
responseTabs: <ResponseTabs operation={method} ctx={ctx} />,
},
ctx,
);
}
/**
* Remove duplicated labels
* Remove duplicated ids
*/
function dedupe(samples: CodeSample[]): CodeSample[] {
function dedupe(samples: CodeUsageGenerator[]): CodeUsageGenerator[] {
const set = new Set<string>();
const out: CodeSample[] = [];
const out: CodeUsageGenerator[] = [];
for (let i = samples.length - 1; i >= 0; i--) {
const item = samples[i];
if (item.label) {
if (set.has(item.label)) continue;
set.add(item.label);
}
if (set.has(item.id)) continue;
set.add(item.id);
out.unshift(item);
}
@@ -170,64 +216,105 @@ function ResponseTabs({
operation: NoReference<MethodInformation>;
ctx: RenderContext;
}) {
const { renderer } = ctx;
if (!operation.responses) return null;
const tabs: ResponseTab[] = [];
async function renderResponse(code: string) {
const response = operation.responses?.[code];
const media = response?.content ? getPreferredType(response.content) : null;
const responseOfType = media ? response?.content?.[media] : null;
for (const [code, response] of Object.entries(operation.responses)) {
const media = response.content ? getPreferredType(response.content) : null;
const responseOfType = media ? response.content?.[media] : null;
const tab: ResponseTab = {
code,
response,
mediaType: media as string | null,
};
if (responseOfType?.examples) {
tab.examples ??= [];
for (const [key, sample] of Object.entries(responseOfType.examples)) {
const title = sample?.summary ?? `Example ${key}`;
tab.examples.push({
label: title,
sample: sample.value,
description: sample?.description,
});
}
} else if (responseOfType?.example || responseOfType?.schema) {
tab.examples ??= [];
tab.examples.push({
label: 'Example',
sample:
responseOfType.example ?? sample(responseOfType.schema as object),
});
}
tabs.push(tab);
}
const { renderResponseTabs = renderResponseTabsDefault } = ctx.content ?? {};
return renderResponseTabs(tabs, ctx);
}
function renderResponseTabsDefault(
tabs: ResponseTab[],
ctx: RenderContext,
): ReactNode | Promise<ReactNode> {
async function renderResponse(tab: ResponseTab) {
const { examples = [] } = tab;
let slot: ReactNode = 'Empty';
if (responseOfType?.examples) {
const values: string[] = [];
const children = Object.entries(responseOfType.examples).map(
([key, sample]) => {
const title = sample?.summary ?? `Example ${key}`;
values.push(title);
return (
<renderer.ResponseType key={key} label={title}>
{sample?.description && <Markdown text={sample.description} />}
<CodeBlock
lang="json"
code={JSON.stringify(sample.value, null, 2)}
ctx={ctx}
/>
</renderer.ResponseType>
);
},
if (examples.length > 1) {
slot = (
<Accordions
type="single"
className="pt-2"
defaultValue={examples[0].label}
>
{examples.map((example, i) => (
<AccordionItem key={i} value={example.label}>
<AccordionHeader>
<AccordionTrigger>{example.label}</AccordionTrigger>
</AccordionHeader>
<AccordionContent className="prose-no-margin">
{example.description && <Markdown text={example.description} />}
<CodeBlock
lang="json"
code={JSON.stringify(example.sample, null, 2)}
ctx={ctx}
/>
</AccordionContent>
</AccordionItem>
))}
</Accordions>
);
} else if (examples.length === 1) {
const example = examples[0];
slot = (
<renderer.ResponseTypes defaultValue={values[0]}>
{children}
</renderer.ResponseTypes>
);
} else if (responseOfType?.example || responseOfType?.schema) {
slot = (
<CodeBlock
lang="json"
code={JSON.stringify(
responseOfType.example ?? sample(responseOfType.schema as object),
null,
2,
)}
ctx={ctx}
/>
<>
{example.description && <Markdown text={example.description} />}
<CodeBlock
lang="json"
code={JSON.stringify(example.sample, null, 2)}
ctx={ctx}
/>
</>
);
}
return <renderer.Response value={code}>{slot}</renderer.Response>;
return <Tab value={tab.code}>{slot}</Tab>;
}
const codes = Object.keys(operation.responses);
if (codes.length === 0) return null;
if (tabs.length === 0) return null;
return (
<renderer.Responses items={codes}>
{codes.map((code) => renderResponse(code))}
</renderer.Responses>
<Tabs
groupId="fumadocs_openapi_responses"
items={tabs.map((tab) => tab.code)}
>
{tabs.map(renderResponse)}
</Tabs>
);
}
@@ -7,8 +7,10 @@ import { Check, Copy } from 'lucide-react';
import {
type ComponentProps,
createContext,
type FC,
type ReactNode,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
@@ -19,6 +21,28 @@ import {
SelectTrigger,
SelectValue,
} from '@/ui/components/select';
import {
type ExampleUpdateListener,
useOperationContext,
} from '../contexts/operation';
import type { CodeUsageGenerator, APIExampleItem } from './api-example';
import { useApiContext, useServerSelectContext } from '../contexts/api';
import {
joinURL,
withBase,
resolveServerUrl,
resolveRequestData,
} from '@/utils/url';
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
export interface OperationClientOptions {
APIExampleSelector?: FC<{
items: APIExampleItem[];
value: string;
onValueChange: (id: string) => void;
}>;
}
export function CopyResponseTypeScript({ code }: { code: string }) {
const [isChecked, onCopy] = useCopyButton(() => {
@@ -111,3 +135,94 @@ export function SelectTabTrigger({
</Select>
);
}
export function APIExampleSelector() {
const {
example: key,
setExample: setKey,
examples,
client: { APIExampleSelector: Override } = {},
} = useOperationContext();
if (Override) {
return <Override items={examples} value={key} onValueChange={setKey} />;
}
function renderItem(item: APIExampleItem) {
return (
<div>
<span className="font-medium text-sm">{item.name}</span>
<span className="text-fd-muted-foreground">{item.description}</span>
</div>
);
}
if (examples.length === 1) return null;
const selected = examples.find((item) => item.id === key);
return (
<Select value={key} onValueChange={setKey}>
<SelectTrigger className="not-prose mb-2">
{selected && <SelectValue asChild>{renderItem(selected)}</SelectValue>}
</SelectTrigger>
<SelectContent>
{examples.map((item) => (
<SelectItem key={item.id} value={item.id}>
{renderItem(item)}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export function APIExampleUsageTab(sample: CodeUsageGenerator) {
const { shikiOptions, mediaAdapters } = useApiContext();
const {
examples,
example: key,
route,
addListener,
removeListener,
} = useOperationContext();
const { server } = useServerSelectContext();
const [data, setData] = useState(
() => examples.find((example) => example.id === key)!.encoded,
);
useEffect(() => {
const listener: ExampleUpdateListener = (_, encoded) => setData(encoded);
addListener(listener);
return () => {
removeListener(listener);
};
}, [addListener, removeListener]);
const code = useMemo(() => {
if (!sample.source) return;
if (typeof sample.source === 'string') return sample.source;
return sample.source(
joinURL(
withBase(
server ? resolveServerUrl(server.url, server.variables) : '/',
typeof window !== 'undefined'
? window.location.origin
: 'https://loading',
),
resolveRequestData(route, data),
),
data,
{
server: sample.serverContext,
mediaAdapters,
},
);
}, [mediaAdapters, sample, server, route, data]);
if (!code || !sample) return null;
return (
<DynamicCodeBlock lang={sample.lang} code={code} options={shikiOptions} />
);
}
+40 -31
View File
@@ -20,13 +20,8 @@ import { idToTitle } from '@/utils/id-to-title';
import { Markdown } from '../components/server/markdown';
import { heading } from '../components/server/heading';
import { Schema } from '../schema';
import {
APIExample,
APIExampleProvider,
getAPIExamples,
} from '@/ui/operation/api-example';
import { APIExample, getAPIExamples } from '@/ui/operation/api-example';
import { MethodLabel } from '@/ui/components/method-label';
import { type SampleGenerator } from '@/requests/types';
import { getTypescriptSchema } from '@/utils/get-typescript-schema';
import {
CopyResponseTypeScript,
@@ -43,23 +38,8 @@ import {
} from '@/ui/components/accordion';
import { isMediaTypeSupported } from '@/requests/media/adapter';
import { cn } from 'fumadocs-ui/utils/cn';
export interface CodeSample<T = unknown> {
lang: string;
label?: string;
/**
* either:
* - code
* - a function imported from a file with "use client" directive
* - false (disabled)
*/
source?: string | SampleGenerator<T> | false;
/**
* Pass extra context to client-side source generator
*/
serverContext?: T;
}
import { APIPlayground } from '@/playground';
import { OperationProvider } from '../contexts/operation';
const ParamTypes = {
path: 'Path Parameters',
@@ -258,10 +238,11 @@ export function Operation({
const playgroundEnabled = ctx.playground?.enabled ?? true;
const info = (
<ctx.renderer.APIInfo head={headNode} method={method.method} route={path}>
<div className="min-w-0 flex-1">
{headNode}
{type === 'operation' &&
(playgroundEnabled ? (
<ctx.renderer.APIPlayground path={path} method={method} ctx={ctx} />
<APIPlayground path={path} method={method} ctx={ctx} />
) : (
<div className="flex flex-row items-center gap-2.5 p-3 rounded-xl border bg-fd-card text-fd-card-foreground not-prose">
<MethodLabel className="text-xs">{method.method}</MethodLabel>
@@ -275,25 +256,53 @@ export function Operation({
{bodyNode}
{responseNode}
{callbacksNode}
</ctx.renderer.APIInfo>
</div>
);
if (type === 'operation') {
const examples = getAPIExamples(path, method, ctx);
return (
<ctx.renderer.API>
<APIExampleProvider route={path} examples={examples} method={method}>
<OperationContainer>
<OperationProvider
defaultExampleId={
method['x-exclusiveCodeSample'] ?? method['x-selectedCodeSample']
}
route={path}
examples={examples}
client={ctx.client?.operation}
>
{info}
<APIExample examples={examples} method={method} ctx={ctx} />
</APIExampleProvider>
</ctx.renderer.API>
<APIExample method={method} ctx={ctx} />
</OperationProvider>
</OperationContainer>
);
} else {
return info;
}
}
function OperationContainer({ children, ...props }: ComponentProps<'div'>) {
return (
<div
{...props}
className={cn(
'flex flex-col gap-x-6 gap-y-4 xl:flex-row xl:items-start',
props.className,
)}
style={
{
'--fd-api-info-top':
'calc(12px + var(--fd-nav-height) + var(--fd-banner-height) + var(--fd-tocnav-height, 0px))',
...props.style,
} as object
}
>
{children}
</div>
);
}
async function ResponseAccordion({
status,
operation,
-150
View File
@@ -1,150 +0,0 @@
import type { ComponentType, ReactNode } from 'react';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
import {
AccordionContent,
AccordionHeader,
AccordionItem,
Accordions,
AccordionTrigger,
} from '@/ui/components/accordion';
import { API, APIExample, APIInfo, Root } from '@/ui';
import type { RenderContext } from '@/types';
import { APIPlayground, type APIPlaygroundProps } from '@/playground';
import { CodeExampleSelector } from '@/ui/lazy';
import {
CodeBlockTab,
CodeBlockTabs,
CodeBlockTabsList,
CodeBlockTabsTrigger,
} from 'fumadocs-ui/components/codeblock';
export interface ResponsesProps {
items: string[];
children: ReactNode;
}
export interface ResponseProps {
value: string;
children: ReactNode;
}
export interface APIInfoProps {
method: string;
route: string;
head: ReactNode;
children: ReactNode;
}
export interface PropertyProps {
name: string;
type: ReactNode;
required?: boolean;
deprecated?: boolean;
children?: ReactNode;
nested?: boolean;
}
export interface ObjectCollapsibleProps {
name: string;
children: ReactNode;
}
export interface RequestProps {
name: string;
children: ReactNode;
}
export interface SamplesProps {
items: {
title: string;
description?: ReactNode;
value: string;
}[];
}
export interface ResponseTypeProps {
label: string;
children: ReactNode;
}
export interface RootProps {
ctx: RenderContext;
children: ReactNode;
}
export interface Renderer {
Root: ComponentType<RootProps>;
API: ComponentType<{ children: ReactNode }>;
APIInfo: ComponentType<APIInfoProps>;
APIExample: ComponentType<{ children: ReactNode }>;
Responses: ComponentType<ResponsesProps>;
Response: ComponentType<ResponseProps>;
CodeExampleSelector: ComponentType<SamplesProps>;
Requests: ComponentType<{ items: string[]; children: ReactNode }>;
Request: ComponentType<RequestProps>;
ResponseTypes: ComponentType<{ defaultValue?: string; children: ReactNode }>;
ResponseType: ComponentType<ResponseTypeProps>;
APIPlayground: ComponentType<APIPlaygroundProps>;
}
export function createRenders(): Renderer {
return {
Root,
API,
APIInfo: ({ children, head }) => (
<APIInfo>
{head}
{children}
</APIInfo>
),
APIExample,
Responses: (props) => (
<Tabs {...props} groupId="fumadocs_openapi_responses" />
),
Response: Tab,
ResponseTypes: (props) => (
<Accordions
type="single"
className="pt-2"
defaultValue={props.defaultValue}
>
{props.children}
</Accordions>
),
ResponseType: (props) => (
<AccordionItem value={props.label}>
<AccordionHeader>
<AccordionTrigger>{props.label}</AccordionTrigger>
</AccordionHeader>
<AccordionContent className="prose-no-margin">
{props.children}
</AccordionContent>
</AccordionItem>
),
Requests: ({ items, children }) => (
<CodeBlockTabs
groupId="fumadocs_openapi_requests"
defaultValue={items[0]}
>
<CodeBlockTabsList>
{items.map((item) => (
<CodeBlockTabsTrigger key={item} value={item}>
{item}
</CodeBlockTabsTrigger>
))}
</CodeBlockTabsList>
{children}
</CodeBlockTabs>
),
CodeExampleSelector,
Request: (props) => (
<CodeBlockTab value={props.name}>{props.children}</CodeBlockTab>
),
APIPlayground,
};
}