mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
OpenAPI: support non-RSC environment
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': minor
|
||||
---
|
||||
|
||||
Support non-RSC environment via `createClientAPIPage()`
|
||||
@@ -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/*"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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 }) {
|
||||
@@ -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()} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './full';
|
||||
export type { ApiPageProps, OperationItem, WebhookItem } from './api-page';
|
||||
@@ -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';
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user