OpenAPI: support non-RSC environment

This commit is contained in:
Fuma Nama
2026-03-26 21:23:54 +08:00
parent b8a95e8afe
commit 9b7b5b5294
18 changed files with 442 additions and 225 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': minor
---
Support non-RSC environment via `createClientAPIPage()`
+1
View File
@@ -36,6 +36,7 @@
"./ui": "./dist/ui/index.js",
"./ui/base": "./dist/ui/base.js",
"./ui/client": "./dist/ui/client/index.js",
"./ui/create-client": "./dist/ui/create-client.js",
"./package.json": "./package.json",
"./css/*": "./css/*"
},
+7 -11
View File
@@ -187,13 +187,13 @@ export function useResolvedSchema(raw: ParsedSchema): Exclude<ParsedSchema, bool
if (typeof schema === 'boolean') return anyFields;
if (schema.allOf) {
return fallbackAny(
mergeAllOf(schema, {
dereference(schema) {
return dereference(schema, references);
},
}),
);
const merged = mergeAllOf(schema, {
dereference(schema) {
return dereference(schema, references);
},
});
if (typeof merged === 'boolean') return anyFields;
return merged;
}
return schema;
@@ -209,10 +209,6 @@ function dereference(schema: ParsedSchema, references: Record<string, ParsedSche
return schema;
}
function fallbackAny(schema: ParsedSchema): Exclude<ParsedSchema, boolean> {
return typeof schema === 'boolean' ? anyFields : schema;
}
function getUnion(
schema: Exclude<ParsedSchema, boolean>,
): [readonly ParsedSchema[], UnionField] | undefined {
@@ -21,6 +21,7 @@ import type {
WebhookOutput,
} from '@/utils/pages/builder';
import path from 'node:path';
import { ClientApiPagePayload } from '@/ui/create-client';
declare module 'fumadocs-core/source' {
export interface PageData {
@@ -81,6 +82,7 @@ export function openapiPlugin(): LoaderPlugin {
interface OpenAPIPageData extends PageData {
getAPIPageProps: () => ApiPageProps;
getSchema: () => { id: string } & ProcessedDocument;
getClientPayload: () => Promise<ClientApiPagePayload>;
structuredData: StructuredData;
toc: TOCItemType[];
}
@@ -129,6 +131,12 @@ export async function openapiSource(
getAPIPageProps() {
return props;
},
async getClientPayload() {
return {
bundled: processed.bundled,
proxyUrl: server.options.proxyUrl,
};
},
getSchema() {
return {
id: schemaId,
+2 -5
View File
@@ -1,5 +1,4 @@
import type { OpenAPIV3_2, OpenAPIV3 } from './_openapi/types';
import type { default as Slugger } from 'github-slugger';
import type { NoReference } from '@/utils/schema';
import type { ProcessedDocument } from '@/utils/process-document';
import type { MediaAdapter } from '@/requests/media/adapter';
@@ -38,11 +37,9 @@ export interface RenderContext
extends
Pick<OpenAPIOptions, 'proxyUrl'>,
Omit<
RequireKeys<CreateAPIPageOptions, 'generateTypeScriptDefinitions' | 'renderMarkdown'>,
'renderCodeBlock' | 'renderHeading'
RequireKeys<CreateAPIPageOptions, 'renderMarkdown' | 'generateTypeScriptDefinitions'>,
'renderCodeBlock' | 'renderHeading' | 'generateTypeScriptSchema'
> {
slugger: Slugger;
/**
* dereferenced schema
*/
+1 -2
View File
@@ -1,11 +1,10 @@
import { Operation } from '@/ui/operation';
import type { HttpMethods, RenderContext, ServerObject } from '@/types';
import { createMethod } from '@/utils/schema';
import type { ProcessedDocument } from '@/utils/process-document';
import { ApiProviderLazy, ServerProviderLazy } from './contexts/api.lazy';
export interface ApiPageProps {
document: Promise<ProcessedDocument> | string | ProcessedDocument;
document: string;
showTitle?: boolean;
showDescription?: boolean;
+35 -18
View File
@@ -24,6 +24,7 @@ import type { JSONSchema } from 'json-schema-typed';
import type { BundledTheme, CodeOptionsThemes, CodeToHastOptionsCommon } from 'shiki';
import { highlightHast, type ShikiFactory } from 'fumadocs-core/highlight/shiki';
import type { ExampleRequestItem } from './operation/get-example-requests';
import { compile } from '@fumari/json-schema-ts';
export interface GenerateTypeScriptDefinitionsContext extends RenderContext {
operation: NoReference<MethodInformation>;
@@ -60,10 +61,12 @@ export interface CreateAPIPageOptions {
*
* Pass `false` to disable it.
*/
generateTypeScriptDefinitions?: (
schema: JSONSchema,
ctx: GenerateTypeScriptDefinitionsContext,
) => Awaitable<string | undefined>;
generateTypeScriptDefinitions?:
| ((
schema: JSONSchema,
ctx: GenerateTypeScriptDefinitionsContext,
) => Awaitable<string | undefined>)
| false;
/**
* Generate example code usage for all endpoints.
@@ -73,7 +76,7 @@ export interface CreateAPIPageOptions {
/**
* Generate example code usage for each endpoint.
*/
generateCodeSamples?: (method: MethodInformation) => Awaitable<InlineCodeUsageGenerator[]>;
generateCodeSamples?: (method: MethodInformation) => InlineCodeUsageGenerator[];
shiki: ShikiFactory;
renderMarkdown?: (md: string) => ReactNode;
@@ -172,7 +175,7 @@ export interface CreateAPIPageOptions {
* Info UI for JSON schemas
*/
schemaUI?: {
render?: (options: SchemaUIOptions, ctx: RenderContext) => Awaitable<ReactNode>;
render?: (options: SchemaUIOptions, ctx: RenderContext) => ReactNode;
/**
* Show examples under the generated content of JSON schemas.
@@ -201,15 +204,19 @@ export interface CreateAPIPageOptions {
};
renderHeading?: (props: HTMLAttributes<HTMLHeadingElement>, depth: number) => ReactNode;
renderCodeBlock?: (props: { lang: string; code: string }) => Awaitable<ReactNode>;
renderCodeBlock?: (props: { lang: string; code: string }) => ReactNode;
client?: APIPageClientOptions;
}
export interface ServerApiPageProps extends Omit<ApiPageProps, 'document'> {
document: string | ProcessedDocument;
}
export function createAPIPage(
server: OpenAPIServer,
options: CreateAPIPageOptions,
): FC<ApiPageProps> {
): FC<ServerApiPageProps> {
let processor: ReturnType<typeof createMarkdownProcessor>;
function createMarkdownProcessor() {
@@ -241,7 +248,7 @@ export function createAPIPage(
if (typeof document === 'string') {
processed = await server.getSchema(document);
} else {
processed = await document;
processed = document;
}
const slugger = new Slugger();
@@ -254,7 +261,6 @@ export function createAPIPage(
...defaultAdapters,
...options.mediaAdapters,
},
slugger,
renderHeading(depth, text, props) {
const id = typeof text === 'string' ? slugger.slug(text) : props?.id;
if (!id) throw new Error("missing 'id' for non-string children");
@@ -269,15 +275,26 @@ export function createAPIPage(
</Heading>
);
},
generateTypeScriptDefinitions(schema, ctx) {
const { generateTypeScriptSchema, generateTypeScriptDefinitions } = options;
if (generateTypeScriptSchema && ctx._internal_legacy) {
const { statusCode, contentType } = ctx._internal_legacy;
return generateTypeScriptSchema(ctx.operation, statusCode, contentType, ctx);
}
generateTypeScriptDefinitions:
options.generateTypeScriptDefinitions ??
((schema, ctx) => {
if (options.generateTypeScriptSchema && ctx._internal_legacy) {
const { statusCode, contentType } = ctx._internal_legacy;
return options.generateTypeScriptSchema(ctx.operation, statusCode, contentType, ctx);
}
return generateTypeScriptDefinitions?.(schema, ctx);
},
if (typeof schema !== 'object') return;
try {
return compile(schema, {
name: 'Response',
readOnly: ctx.readOnly,
writeOnly: ctx.writeOnly,
getSchemaId: ctx.schema.getRawRef,
});
} catch (e) {
console.warn('Failed to generate typescript schema:', e);
}
}),
async renderMarkdown(text) {
if (options.renderMarkdown) return options.renderMarkdown(text);
processor ??= createMarkdownProcessor();
@@ -1,6 +1,6 @@
'use client';
import type { ReactNode } from 'react';
import { ClientCodeBlockProvider } from './components/codeblock';
import { ClientCodeBlockProvider } from '../components/codeblock';
import { defaultShikiFactory } from 'fumadocs-core/highlight/shiki/full';
export function FullProvider({ children }: { children: ReactNode }) {
+169
View File
@@ -0,0 +1,169 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- rehype-react without types */
import type { Document, RenderContext } from '@/types';
import type { NoReference } from '@/utils/schema';
import type { ProcessedDocument } from '@/utils/process-document';
import { defaultAdapters } from '@/requests/media/adapter';
import {
Children,
type ComponentProps,
type ReactElement,
useMemo,
type FC,
type ReactNode,
} from 'react';
import { Heading } from 'fumadocs-ui/components/heading';
import { remarkGfm } from 'fumadocs-core/mdx-plugins/remark-gfm';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import { remark } from 'remark';
import remarkRehype from 'remark-rehype';
import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
import * as JsxRuntime from 'react/jsx-runtime';
import { APIPage, type ApiPageProps } from './api-page';
import type { CreateAPIPageOptions } from './base';
import { defaultShikiFactory } from 'fumadocs-core/highlight/shiki/full';
import { compile } from '@fumari/json-schema-ts';
import { ClientCodeBlock, ClientCodeBlockProvider } from './components/codeblock';
import { dereferenceSync } from '@/utils/schema/dereference';
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
import { slug } from 'github-slugger';
export interface ClientApiPageProps extends Omit<ApiPageProps, 'document'> {
payload: ClientApiPagePayload;
}
export interface ClientApiPagePayload {
bundled: Document;
proxyUrl?: string;
}
export type CreateClientAPIPageOptions = Omit<
Partial<CreateAPIPageOptions>,
'generateTypeScriptSchema'
>;
/**
* Create `<APIPage />` for non-RSC environment, note that this may be unstable, and doesn't support the full set of features.
*/
export function createClientAPIPage({
shiki = defaultShikiFactory,
shikiOptions = { themes: { light: 'github-light', dark: 'github-dark' } },
generateTypeScriptDefinitions = (schema, ctx) => {
if (typeof schema !== 'object') return;
try {
return compile(schema, {
name: 'Response',
readOnly: ctx.readOnly,
writeOnly: ctx.writeOnly,
getSchemaId: ctx.schema.getRawRef,
});
} catch (e) {
console.warn('Failed to generate typescript schema:', e);
}
},
...options
}: CreateClientAPIPageOptions = {}): FC<ClientApiPageProps> {
let processor: ReturnType<typeof createMarkdownProcessor>;
const mdxComponents = {
...defaultMdxComponents,
img: undefined,
pre: MarkdownPre,
};
function createMarkdownProcessor() {
function rehypeReact(this: any) {
this.compiler = (tree: any, file: any) => {
return toJsxRuntime(tree, {
development: false,
filePath: file.path,
...JsxRuntime,
components: mdxComponents,
});
};
}
return remark().use(remarkGfm).use(remarkRehype).use(rehypeReact);
}
return function ClientAPIPage({ payload, ...props }) {
const processed = useMemo<ProcessedDocument>(() => {
const dereferenceMap = new Map<object, string>();
return {
bundled: payload.bundled,
dereferenced: dereferenceSync(payload.bundled as JSONSchema, (schema, ref) => {
dereferenceMap.set(schema as object, ref);
}) as NoReference<Document>,
getRawRef(obj) {
return dereferenceMap.get(obj);
},
};
}, [payload.bundled]);
const ctx: RenderContext = useMemo(
() => ({
schema: processed,
proxyUrl: payload.proxyUrl,
shiki,
shikiOptions,
generateTypeScriptDefinitions,
...options,
mediaAdapters: {
...defaultAdapters,
...options.mediaAdapters,
},
renderHeading(depth, text, props) {
const id = typeof text === 'string' ? slug(text) : props?.id;
if (!id) throw new Error("missing 'id' for non-string children");
if (options.renderHeading) {
return options.renderHeading({ id, children: text, ...props }, depth);
}
return (
<Heading id={id} key={id} as={`h${depth}` as `h1`} {...props}>
{text}
</Heading>
);
},
renderMarkdown(text) {
if (options.renderMarkdown) return options.renderMarkdown(text);
processor ??= createMarkdownProcessor();
return processor.processSync({
value: text,
}).result as ReactNode;
},
renderCodeBlock(lang, code) {
if (options.renderCodeBlock) {
return options.renderCodeBlock({ lang, code });
}
return <ClientCodeBlock lang={lang} code={code} />;
},
}),
[payload.proxyUrl, processed],
);
return (
<ClientCodeBlockProvider factory={shiki}>
<APIPage {...props} ctx={ctx} />
</ClientCodeBlockProvider>
);
};
}
function MarkdownPre(props: ComponentProps<'pre'>) {
const code = Children.only(props.children) as ReactElement;
const codeProps = code.props as ComponentProps<'code'>;
const content = codeProps.children;
if (typeof content !== 'string') return null;
const lang =
codeProps.className
?.split(' ')
.find((v) => v.startsWith('language-'))
?.slice('language-'.length) ?? 'text';
return <ClientCodeBlock lang={lang} code={content.trimEnd()} />;
}
-38
View File
@@ -1,38 +0,0 @@
import type { OpenAPIServer } from '@/server';
import * as base from './base';
import type { ApiPageProps } from './api-page';
import { defaultShikiFactory } from 'fumadocs-core/highlight/shiki/full';
import { FullProvider } from './full.client';
export type CreateAPIPageOptions = Partial<base.CreateAPIPageOptions>;
export function createAPIPage(server: OpenAPIServer, options: CreateAPIPageOptions = {}) {
const APIPage = base.createAPIPage(server, {
shiki: defaultShikiFactory,
shikiOptions: { themes: { light: 'github-light', dark: 'github-dark' } },
async generateTypeScriptDefinitions(schema, ctx) {
if (typeof schema !== 'object') return;
const { compile } = await import('@fumari/json-schema-ts');
try {
return compile(schema, {
name: 'Response',
readOnly: ctx.readOnly,
writeOnly: ctx.writeOnly,
getSchemaId: ctx.schema.getRawRef,
});
} catch (e) {
console.warn('Failed to generate typescript schema:', e);
}
},
...options,
});
return function APIPageFull(props: ApiPageProps) {
return (
<FullProvider>
<APIPage {...props} />
</FullProvider>
);
};
}
-2
View File
@@ -1,2 +0,0 @@
export * from './full';
export type { ApiPageProps, OperationItem, WebhookItem } from './api-page';
+24
View File
@@ -0,0 +1,24 @@
import type { OpenAPIServer } from '@/server';
import * as base from './base';
import { defaultShikiFactory } from 'fumadocs-core/highlight/shiki/full';
import { FullProvider } from './client/full';
export type CreateAPIPageOptions = Partial<base.CreateAPIPageOptions>;
export function createAPIPage(server: OpenAPIServer, options: CreateAPIPageOptions = {}) {
const APIPage = base.createAPIPage(server, {
shiki: defaultShikiFactory,
shikiOptions: { themes: { light: 'github-light', dark: 'github-dark' } },
...options,
});
return function APIPageFull(props: base.ServerApiPageProps) {
return (
<FullProvider>
<APIPage {...props} />
</FullProvider>
);
};
}
export type { ApiPageProps, OperationItem, WebhookItem } from './api-page';
+99 -63
View File
@@ -1,12 +1,13 @@
import { ComponentProps, Fragment, type ReactNode } from 'react';
import { type ComponentProps, Fragment, use, useMemo, type ReactNode } from 'react';
import type {
CallbackObject,
MediaTypeObject,
MethodInformation,
RenderContext,
SecuritySchemeObject,
ServerObject,
} from '@/types';
import { createMethod, methodKeys, type NoReference, type ResolvedSchema } from '@/utils/schema';
import { createMethod, methodKeys, type NoReference } from '@/utils/schema';
import { idToTitle } from '@/utils/id-to-title';
import { Schema } from '../schema';
import { UsageTabs } from '@/ui/operation/usage-tabs';
@@ -59,6 +60,7 @@ export function Operation({
let authNode: ReactNode = null;
let responseNode: ReactNode = null;
let callbacksNode: ReactNode = null;
const exampleRequests = useMemo(() => getExampleRequests(path, method, ctx), [ctx, method, path]);
if (showTitle) {
const title = method.summary || (method.operationId ? idToTitle(method.operationId) : path);
@@ -89,34 +91,14 @@ export function Operation({
)}
</div>
{body.description && ctx.renderMarkdown(body.description)}
{contentTypes.map(async ([type, content]) => {
{contentTypes.map(([type, content]) => {
if (!isMediaTypeSupported(type, ctx.mediaAdapters)) {
throw new Error(`Media type ${type} is not supported (in ${path})`);
}
const ts = content.schema
? await ctx.generateTypeScriptDefinitions(content.schema, {
operation: method,
readOnly: false,
writeOnly: true,
...ctx,
})
: undefined;
return (
<SelectTab key={type} value={type}>
{ts && <CopyTypeScriptPanel name="request body" code={ts} className="mt-4" />}
<Schema
client={{
name: 'body',
as: 'body',
required: body.required,
}}
root={(content.schema ?? {}) as ResolvedSchema}
readOnly={method.method === 'GET'}
writeOnly={method.method !== 'GET'}
ctx={ctx}
/>
<RequestBodyContentItem content={content} method={method} ctx={ctx} />
</SelectTab>
);
})}
@@ -263,7 +245,6 @@ export function Operation({
}
let { renderOperationLayout, renderWebhookLayout } = ctx.content ?? {};
const exampleRequests = getExampleRequests(path, method, ctx);
if (type === 'operation') {
renderOperationLayout ??= (slots) => {
@@ -360,6 +341,46 @@ export function Operation({
}
}
function RequestBodyContentItem({
content,
method,
ctx,
}: {
content: NoReference<MediaTypeObject>;
method: MethodInformation;
ctx: RenderContext;
}) {
let ts = useMemo(() => {
if (!content.schema || !ctx.generateTypeScriptDefinitions) return;
return ctx.generateTypeScriptDefinitions(content.schema, {
operation: method,
readOnly: false,
writeOnly: true,
...ctx,
});
}, [content.schema, ctx, method]);
if (ts instanceof Promise) ts = use(ts);
return (
<>
{ts && <CopyTypeScriptPanel name="request body" code={ts} className="mt-4" />}
{content.schema && (
<Schema
client={{
name: 'body',
as: 'body',
required: method.requestBody?.required,
}}
root={content.schema}
readOnly={method.method === 'GET'}
writeOnly={method.method !== 'GET'}
ctx={ctx}
/>
)}
</>
);
}
function ResponseAccordion({
status,
operation,
@@ -399,45 +420,63 @@ function ResponseAccordion({
{response.description && (
<div className="prose-no-margin mb-2">{ctx.renderMarkdown(response.description)}</div>
)}
{contentTypes.map(async ([type, resType]) => {
const schema = resType.schema;
const ts = schema
? await ctx.generateTypeScriptDefinitions(schema, {
readOnly: true,
writeOnly: false,
operation,
_internal_legacy: {
statusCode: status,
contentType: type,
},
...ctx,
})
: undefined;
return (
<SelectTab key={type} value={type} className="mb-2">
{ts && <CopyTypeScriptPanel name="response body" code={ts} />}
{schema && (
<div className="border px-3 py-2 rounded-lg">
<Schema
client={{
name: 'response',
as: 'body',
}}
root={schema}
readOnly
ctx={ctx}
/>
</div>
)}
</SelectTab>
);
})}
{contentTypes.map(([type, item]) => (
<SelectTab key={type} value={type} className="mb-2">
<RepsonseAccordionItem type={type} item={item} operation={operation} ctx={ctx} />
</SelectTab>
))}
</AccordionContent>
</AccordionItem>,
);
}
function RepsonseAccordionItem({
type,
operation,
item: { schema },
ctx,
}: {
type: string;
operation: MethodInformation;
item: NoReference<MediaTypeObject>;
ctx: RenderContext;
}) {
let ts = useMemo(() => {
if (!schema || !ctx.generateTypeScriptDefinitions) return;
return ctx.generateTypeScriptDefinitions(schema, {
readOnly: true,
writeOnly: false,
operation,
_internal_legacy: {
statusCode: status,
contentType: type,
},
...ctx,
});
}, [ctx, operation, schema, type]);
// assume it is on server component when returned async
if (ts instanceof Promise) ts = use(ts);
return (
<>
{ts && <CopyTypeScriptPanel name="response body" code={ts} />}
{schema && (
<div className="border px-3 py-2 rounded-lg">
<Schema
client={{
name: 'response',
as: 'body',
}}
root={schema}
readOnly
ctx={ctx}
/>
</div>
)}
</>
);
}
function WebhookCallback({
callback,
ctx,
@@ -447,11 +486,9 @@ function WebhookCallback({
ctx: RenderContext;
headingLevel: number;
}) {
const pathItems = Object.entries(callback);
return (
<Accordions type="single" collapsible>
{pathItems.map(([path, pathItem]) => {
{Object.entries(callback).map(([path, pathItem]) => {
const pathNodes = methodKeys.map((method) => {
const operation = pathItem[method];
if (!operation) return null;
@@ -555,7 +592,6 @@ function AuthProperty({
type: ReactNode;
deprecated?: boolean;
scopes?: string[];
children?: ReactNode;
}) {
return (
<div className={cn('text-sm border-t my-4 first:border-t-0', className)}>
@@ -9,7 +9,7 @@ import {
} from '@/ui/components/accordion';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
import { sample } from 'openapi-sampler';
import type { ReactNode } from 'react';
import { useMemo, type ReactNode } from 'react';
import { I18nLabel } from '@/ui/client/i18n';
export interface ResponseTab {
@@ -48,48 +48,52 @@ export function ResponseTabs({
operation: NoReference<MethodInformation>;
ctx: RenderContext;
}) {
if (!operation.responses) return null;
const tabs: ResponseTab[] = [];
const tabs = useMemo(() => {
const tabs: ResponseTab[] = [];
if (!operation.responses) return tabs;
for (const [code, response] of Object.entries(operation.responses)) {
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,
};
const tab: ResponseTab = {
code,
response,
mediaType: media as string | null,
};
if (responseOfType?.examples) {
tab.examples ??= [];
if (responseOfType?.examples) {
tab.examples ??= [];
for (const [key, sample] of Object.entries(responseOfType.examples)) {
for (const [key, sample] of Object.entries(responseOfType.examples)) {
tab.examples.push({
label: sample?.summary ?? <I18nLabel label="responseTabName" replacements={{ key }} />,
sample: sample.value,
description: sample?.description,
});
}
} else if (responseOfType?.example || responseOfType?.schema) {
tab.examples ??= [];
tab.examples.push({
label: sample?.summary ?? <I18nLabel label="responseTabName" replacements={{ key }} />,
sample: sample.value,
description: sample?.description,
label: <I18nLabel label="responseTabNameDefault" />,
sample: responseOfType.example ?? sample(responseOfType.schema as object),
});
}
} else if (responseOfType?.example || responseOfType?.schema) {
tab.examples ??= [];
tab.examples.push({
label: <I18nLabel label="responseTabNameDefault" />,
sample: responseOfType.example ?? sample(responseOfType.schema as object),
});
tabs.push(tab);
}
tabs.push(tab);
}
return tabs;
}, [operation.responses]);
if (tabs.length === 0) return null;
const { renderResponseTabs = renderResponseTabsDefault } = ctx.content ?? {};
return renderResponseTabs(tabs, ctx);
}
function renderResponseTabsDefault(
tabs: ResponseTab[],
ctx: RenderContext,
): ReactNode | Promise<ReactNode> {
function renderResponseTabsDefault(tabs: ResponseTab[], ctx: RenderContext): ReactNode {
function renderExampleContent(example: ResponseExample) {
return (
<>
@@ -99,37 +103,37 @@ function renderResponseTabsDefault(
);
}
async function renderResponse(tab: ResponseTab) {
const { examples = [] } = tab;
let slot: ReactNode = <I18nLabel label="empty" />;
if (examples.length > 1) {
slot = (
<Accordions type="single" className="pt-2" defaultValue="0">
{examples.map((example, i) => (
<AccordionItem key={i} value={i.toString()}>
<AccordionHeader>
<AccordionTrigger>{example.label}</AccordionTrigger>
</AccordionHeader>
<AccordionContent className="prose-no-margin">
{renderExampleContent(example)}
</AccordionContent>
</AccordionItem>
))}
</Accordions>
);
} else if (examples.length === 1) {
slot = renderExampleContent(examples[0]);
}
return <Tab value={tab.code}>{slot}</Tab>;
}
if (tabs.length === 0) return null;
return (
<Tabs groupId="fumadocs_openapi_responses" items={tabs.map((tab) => tab.code)}>
{tabs.map(renderResponse)}
{tabs.map((tab) => {
const { examples = [] } = tab;
let slot: ReactNode = <I18nLabel label="empty" />;
if (examples.length > 1) {
slot = (
<Accordions type="single" className="pt-2" defaultValue="0">
{examples.map((example, i) => (
<AccordionItem key={i} value={i.toString()}>
<AccordionHeader>
<AccordionTrigger>{example.label}</AccordionTrigger>
</AccordionHeader>
<AccordionContent className="prose-no-margin">
{renderExampleContent(example)}
</AccordionContent>
</AccordionItem>
))}
</Accordions>
);
} else if (examples.length === 1) {
slot = renderExampleContent(examples[0]);
}
return (
<Tab key={tab.code} value={tab.code}>
{slot}
</Tab>
);
})}
</Tabs>
);
}
@@ -12,14 +12,9 @@ import {
import { UsageTabsSelectorLazy, UsageTabLazy } from './lazy';
import { ResponseTabs } from '../response-tabs';
import { registerDefault } from '@/requests/generators/all';
import { useMemo } from 'react';
export async function UsageTabs({
method,
ctx,
}: {
method: MethodInformation;
ctx: RenderContext;
}) {
export function UsageTabs({ method, ctx }: { method: MethodInformation; ctx: RenderContext }) {
let { renderAPIExampleUsageTabs, renderAPIExampleLayout } = ctx.content ?? {};
renderAPIExampleLayout ??= (slots) => {
@@ -54,23 +49,28 @@ export async function UsageTabs({
);
};
let registry: CodeUsageGeneratorRegistry;
if (ctx.codeUsages) {
registry = createCodeUsageGeneratorRegistry(ctx.codeUsages);
} else {
registry = createCodeUsageGeneratorRegistry();
registerDefault(registry);
}
const registry = useMemo(() => {
let registry: CodeUsageGeneratorRegistry;
for (const gen of (await ctx.generateCodeSamples?.(method)) ?? []) {
registry.addInline(gen);
}
if (method['x-codeSamples']) {
for (const sample of method['x-codeSamples']) {
registry.addInline(sample);
if (ctx.codeUsages) {
registry = createCodeUsageGeneratorRegistry(ctx.codeUsages);
} else {
registry = createCodeUsageGeneratorRegistry();
registerDefault(registry);
}
}
for (const gen of ctx.generateCodeSamples?.(method) ?? []) {
registry.addInline(gen);
}
if (method['x-codeSamples']) {
for (const sample of method['x-codeSamples']) {
registry.addInline(sample);
}
}
return registry;
}, [ctx, method]);
return renderAPIExampleLayout(
{
+4 -2
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from 'react';
import { useMemo, type ReactNode } from 'react';
import type { ResolvedSchema } from '@/utils/schema';
import type { RenderContext } from '@/types';
import { FormatFlags, schemaToString } from '@/utils/schema/to-string';
@@ -88,7 +88,9 @@ export function Schema({
return ctx.schemaUI.render(options, ctx);
}
return <SchemaUILazy {...options.client} generated={generateSchemaUI(options, ctx)} />;
// eslint-disable-next-line react-hooks/rules-of-hooks -- assume options unchanged
const generated = useMemo(() => generateSchemaUI(options, ctx), [ctx, options]);
return <SchemaUILazy {...options.client} generated={generated} />;
}
export function generateSchemaUI(
-2
View File
@@ -1,7 +1,6 @@
import { RenderContext } from '@/types';
import { processDocument } from '@/utils/process-document';
import { defaultShikiFactory } from 'fumadocs-core/highlight/shiki/full';
import Slugger from 'github-slugger';
export async function renderContextFrom(input: string): Promise<RenderContext> {
const schema = await processDocument(input);
@@ -23,6 +22,5 @@ export async function renderContextFrom(input: string): Promise<RenderContext> {
return text;
},
schema,
slugger: new Slugger(),
};
}
+2 -1
View File
@@ -6,8 +6,9 @@ export default defineConfig({
target: 'es2023',
entry: [
'./src/{index,i18n}.ts',
'./src/ui/index.ts',
'./src/ui/index.tsx',
'./src/ui/base.tsx',
'./src/ui/create-client.tsx',
'./src/ui/client/index.tsx',
'./src/playground/{index,client}.tsx',
'./src/scalar/index.tsx',