OpenAPI: Redesign schema display UI

This commit is contained in:
Fuma Nama
2025-05-23 23:09:30 +08:00
parent 25879bc8ed
commit b0c02a05b6
6 changed files with 354 additions and 251 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': minor
---
Redesign schema display UI
+1 -1
View File
@@ -48,7 +48,7 @@ declare module 'vfile' {
export async function buildMDX(
cacheKey: string,
source: string,
options: MDXOptions = {},
options: MDXOptions,
): Promise<VFile> {
const { filePath, frontmatter, data, ...rest } = options;
+13 -20
View File
@@ -90,20 +90,19 @@ export function Operation({
{heading(headingLevel, 'Request Body', ctx)}
<div className="mb-4 p-3 bg-fd-card rounded-xl border flex flex-row items-center justify-between gap-2">
<code>{type}</code>
<span className="text-xs">
{body.required ? 'Required' : 'Optional'}
</span>
{!body.required && (
<span className="text-xs text-fd-muted-foreground">Optional</span>
)}
</div>
{body.description ? <Markdown text={body.description} /> : null}
<Schema
name="body"
as="body"
schema={(body.content[type].schema ?? {}) as ResolvedSchema}
required={body.required}
ctx={{
readOnly: method.method === 'GET',
writeOnly: method.method !== 'GET',
render: ctx,
}}
readOnly={method.method === 'GET'}
writeOnly={method.method !== 'GET'}
ctx={ctx}
/>
</>
);
@@ -151,13 +150,10 @@ export function Operation({
(param.schema?.deprecated ?? false),
} as ResolvedSchema
}
parseObject={false}
required={param.required}
ctx={{
readOnly: method.method === 'GET',
writeOnly: method.method !== 'GET',
render: ctx,
}}
readOnly={method.method === 'GET'}
writeOnly={method.method !== 'GET'}
ctx={ctx}
/>
))}
</div>
@@ -267,12 +263,9 @@ async function ResponseTab({
<Schema
name="response"
schema={responseOfType.schema as ResolvedSchema}
required
ctx={{
render: ctx,
writeOnly: false,
readOnly: true,
}}
as="body"
readOnly
ctx={ctx}
/>
)}
</Tab>
+1
View File
@@ -37,6 +37,7 @@ export interface PropertyProps {
required?: boolean;
deprecated?: boolean;
children?: ReactNode;
nested?: boolean;
}
export interface ObjectCollapsibleProps {
+307 -203
View File
@@ -1,255 +1,334 @@
import { type ReactNode } from 'react';
import type { ParsedSchema, ResolvedSchema } from '@/utils/schema';
import { Fragment, type ReactNode } from 'react';
import type { ResolvedSchema } from '@/utils/schema';
import type { RenderContext } from '@/types';
import { combineSchema } from '@/utils/combine-schema';
import { Markdown } from './markdown';
import { schemaToString } from '@/utils/schema-to-string';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from 'fumadocs-ui/components/tabs';
const keys: {
[T in keyof Exclude<ResolvedSchema, boolean>]: string;
} = {
const keys = {
default: 'Default',
minimum: 'Minimum',
maximum: 'Maximum',
minLength: 'Minimum length',
maxLength: 'Maximum length',
pattern: 'Pattern',
format: 'Format',
multipleOf: 'Multiple of',
} satisfies {
[T in keyof Exclude<ResolvedSchema, boolean>]: string;
};
interface Context {
readOnly: boolean;
writeOnly: boolean;
stack?: SchemaStack;
render: RenderContext;
stack: SchemaStack;
}
export function Schema({
name,
schema,
required = false,
parseObject = true,
ctx,
readOnly = false,
writeOnly = false,
as = 'property',
ctx: { renderer },
}: {
name: string;
required?: boolean;
schema: ResolvedSchema;
as?: 'property' | 'body';
/**
* Render the full object
*
* @defaultValue true
* */
parseObject?: boolean;
ctx: Context;
readOnly?: boolean;
writeOnly?: boolean;
ctx: RenderContext;
}): ReactNode {
const {
render: { renderer },
stack = schemaStack(),
} = ctx;
if (schema === true) {
return <renderer.Property name={name} type="any" />;
} else if (schema === false) {
return <renderer.Property name={name} type="never" />;
}
if (
(schema.readOnly && !ctx.readOnly) ||
(schema.writeOnly && !ctx.writeOnly)
)
return null;
if (Array.isArray(schema.type) && schema.type.length === 1) {
return (
<Schema
name={name}
required={required}
parseObject={parseObject}
schema={{
function propertyBody(
schema: Exclude<ResolvedSchema, boolean>,
renderPrimitive: (
child: Exclude<ResolvedSchema, boolean>,
ctx: Context,
) => ReactNode,
ctx: Context,
) {
if (Array.isArray(schema.type)) {
const items = schema.type.flatMap((type) => {
const composed = {
...schema,
type: schema.type[0],
}}
ctx={{
...ctx,
stack: stack.next(schema),
}}
/>
);
type,
};
if (!isComplexType(composed)) return [];
return composed;
});
if (items.length === 0) return;
if (items.length === 1)
return propertyBody(items[0], renderPrimitive, ctx);
return (
<Tabs defaultValue={items[0].type}>
<TabsList>
{items.map((item) => (
<TabsTrigger key={item.type} value={item.type}>
{schemaToString(item)}
</TabsTrigger>
))}
</TabsList>
{items.map((item) => (
<TabsContent key={item.type} value={item.type}>
{item.description && <Markdown text={item.description} />}
{propertyInfo(item)}
{renderPrimitive(item, ctx)}
</TabsContent>
))}
</Tabs>
);
}
if (schema.oneOf) {
const oneOf = schema.oneOf.filter((item) =>
isComplexType(item),
) as Exclude<ResolvedSchema, boolean>[];
if (oneOf.length === 0) return;
if (oneOf.length === 1) {
return propertyBody(oneOf[0], renderPrimitive, ctx);
}
return (
<Tabs defaultValue="0">
<TabsList>
{oneOf.map((item, i) => (
<TabsTrigger key={i} value={i.toString()}>
{schemaToString(item)}
</TabsTrigger>
))}
</TabsList>
{oneOf.map((item, i) => (
<TabsContent key={i} value={i.toString()}>
{item.description && <Markdown text={item.description} />}
{propertyInfo(item)}
{propertyBody(
item,
(child, ctx) => primitiveBody(child, ctx, false, true),
ctx,
)}
</TabsContent>
))}
</Tabs>
);
}
const of = schema.allOf ?? schema.anyOf;
if (of) {
const arr = of.filter((item) => !ctx.stack.has(item));
if (arr.length === 0) return;
const combined = combineSchema(arr);
if (typeof combined === 'boolean') return;
return renderPrimitive(combined, ctx);
}
return renderPrimitive(schema, ctx);
}
if (schema.allOf || schema.anyOf) {
const arr = ((schema.allOf ?? schema.anyOf) as ParsedSchema[]).filter(
(item) => !stack.has(item),
function propertyInfo(schema: Exclude<ResolvedSchema, boolean>) {
const fields: {
key: string;
value: string;
}[] = [];
for (const key in keys) {
if (key in schema) {
fields.push({
key: keys[key as keyof object],
value: JSON.stringify(schema[key as keyof ResolvedSchema]),
});
}
}
let range = getRange(
'value',
schema.minimum,
schema.exclusiveMinimum,
schema.maximum,
schema.exclusiveMaximum,
);
if (arr.length === 0) return;
if (range)
fields.push({
key: 'Range',
value: range,
});
return (
<Schema
name={name}
parseObject={parseObject}
required={required}
schema={combineSchema(arr)}
ctx={{
...ctx,
stack: stack.next(schema),
}}
/>
range = getRange(
'length',
schema.minLength,
undefined,
schema.maxLength,
undefined,
);
}
if (range)
fields.push({
key: 'Length',
value: range,
});
// object type
if (schema.type === 'object' && parseObject) {
const {
additionalProperties,
patternProperties = {},
properties = {},
} = schema;
range = getRange(
'properties',
schema.minProperties,
undefined,
schema.maxProperties,
undefined,
);
if (range)
fields.push({
key: 'Properties',
value: range,
});
if (schema.enum) {
fields.push({
key: 'Value in',
value: schema.enum.map((value) => JSON.stringify(value)).join(' | '),
});
}
if (fields.length === 0) return;
return (
<div className="flex flex-col gap-4">
{Object.entries(properties).map(([key, value]) => {
return (
<Schema
key={key}
name={key}
schema={value}
parseObject={false}
required={schema.required?.includes(key) ?? false}
ctx={{
...ctx,
stack: stack.next(schema),
}}
/>
);
})}
{Object.entries(patternProperties).map(([key, value]) => {
return (
<Schema
key={key}
name={key}
schema={value}
parseObject={false}
ctx={{
...ctx,
stack: stack.next(schema),
}}
/>
);
})}
{additionalProperties && (
<Schema
name="[key: string]"
schema={additionalProperties}
parseObject={false}
ctx={{
...ctx,
stack: stack.next(schema),
}}
/>
)}
<div className="flex flex-col gap-2">
{fields.map((field) => (
<span key={field.key}>
{field.key}: <code>{field.value}</code>
</span>
))}
</div>
);
}
let footer: ReactNode;
const fields: {
key: string;
value: string;
}[] = [];
function primitiveBody(
schema: Exclude<ResolvedSchema, boolean>,
ctx: Context,
collapsible: boolean,
nested: boolean,
) {
if (schema.type === 'object') {
if (ctx.stack.has(schema)) return <p>Recursive</p>;
const props = Object.entries(schema.properties ?? {});
const patternProps = Object.entries(schema.patternProperties ?? {});
const next = {
...ctx,
stack: ctx.stack.next(schema),
};
for (const [key, value] of Object.entries(keys)) {
if (key in schema) {
fields.push({
key: value,
value: JSON.stringify(schema[key as keyof ResolvedSchema]),
});
}
}
if (props.length === 0 && patternProps.length === 0)
return <p>Empty Object</p>;
if (schema.enum) {
fields.push({
key: 'Value in',
value: schema.enum.map((value) => JSON.stringify(value)).join(' | '),
});
}
const children = (
<div className="flex flex-col gap-4">
{props.map(([key, value]) => (
<Fragment key={key}>
{property(key, value, next, {
required: schema.required?.includes(key) ?? false,
nested,
})}
</Fragment>
))}
{patternProps.map(([key, value]) => (
<Fragment key={key}>
{property(key, value, next, { nested })}
</Fragment>
))}
{schema.additionalProperties &&
property('[key: string]', schema.additionalProperties, next, {
nested,
})}
</div>
);
if (schema.type === 'object' && !parseObject) {
if (!stack.has(schema))
footer = (
if (!collapsible) return children;
return (
<renderer.ObjectCollapsible name="Show Attributes">
<Schema name={name} schema={schema} ctx={ctx} />
{children}
</renderer.ObjectCollapsible>
);
} else {
let mentionedSchemas: ParsedSchema[] = [];
if (Array.isArray(schema.type)) {
mentionedSchemas.push(
...schema.type.map((type) => ({
...schema,
type,
})),
);
}
if (schema.oneOf) mentionedSchemas.push(...schema.oneOf);
if (schema.not) mentionedSchemas.push(schema.not);
if (schema.type === 'array' && schema.items)
mentionedSchemas.push(schema.items);
if (schema.type === 'array') {
const items = schema.items;
if (!items || !isComplexType(items) || ctx.stack.has(items)) return;
mentionedSchemas = mentionedSchemas.filter(
(s) => isComplexType(s) && !stack.has(s),
);
if (mentionedSchemas.length > 0) {
footer = (
<div className="flex flex-col gap-2">
{mentionedSchemas.map((s, idx) => {
let title = typeof s === 'object' ? s.title : null;
title ??=
mentionedSchemas.length === 1
? 'Show Attributes'
: `Object ${idx + 1}`;
return (
<renderer.ObjectCollapsible key={idx} name={title}>
<Schema
name="element"
schema={s}
ctx={{
...ctx,
stack: stack.next(schema),
}}
/>
</renderer.ObjectCollapsible>
);
})}
const children = (
<div className="flex flex-col gap-4">
{items.description && <Markdown text={items.description} />}
{propertyBody(
items,
(child, ctx) => primitiveBody(child, ctx, false, true),
{
...ctx,
stack: ctx.stack.next(schema),
},
)}
</div>
);
if (collapsible)
return (
<renderer.ObjectCollapsible name="Array Item">
{children}
</renderer.ObjectCollapsible>
);
return children;
}
}
return (
<renderer.Property
name={name}
type={schemaToString(schema)}
required={required}
deprecated={schema.deprecated}
>
{schema.description ? <Markdown text={schema.description} /> : null}
{fields.length > 0 ? (
<div className="flex flex-col gap-2">
{fields.map((field) => (
<span key={field.key}>
{field.key}: <code>{field.value}</code>
</span>
))}
</div>
) : null}
{footer}
</renderer.Property>
function property(
key: string,
schema: ResolvedSchema,
ctx: Context,
props?: {
required?: boolean;
nested?: boolean;
},
) {
if (schema === true) {
return <renderer.Property name={key} type="any" {...props} />;
} else if (schema === false) {
return <renderer.Property name={key} type="never" {...props} />;
}
if ((schema.readOnly && !readOnly) || (schema.writeOnly && !writeOnly))
return;
return (
<renderer.Property
name={key}
type={schemaToString(schema)}
deprecated={schema.deprecated}
{...props}
>
{schema.description && <Markdown text={schema.description} />}
{propertyInfo(schema)}
{propertyBody(
schema,
(child, ctx) => primitiveBody(child, ctx, true, true),
ctx,
)}
</renderer.Property>
);
}
const context: Context = {
stack: schemaStack(),
};
if (
typeof schema === 'boolean' ||
as === 'property' ||
!isComplexType(schema)
)
return property(name, schema, context, { required });
return propertyBody(
schema,
(child, ctx) => primitiveBody(child, ctx, false, false),
context,
);
}
@@ -290,7 +369,9 @@ function schemaStack(parent?: SchemaStack): SchemaStack {
/**
* Check if the schema needs another collapsible to explain
*/
function isComplexType(schema: ResolvedSchema): boolean {
function isComplexType(
schema: ResolvedSchema,
): schema is Exclude<ResolvedSchema, boolean> {
if (typeof schema === 'boolean') return false;
const arr = schema.anyOf ?? schema.oneOf ?? schema.allOf;
if (arr && arr.some(isComplexType)) return true;
@@ -302,3 +383,26 @@ function isComplexType(schema: ResolvedSchema): boolean {
isComplexType(schema.items))
);
}
function getRange(
value: string,
min: number | undefined,
exclusiveMin: number | undefined,
max: number | undefined,
exclusiveMax: number | undefined,
) {
const out = [];
if (min) {
out.push(`${min} <=`);
} else if (exclusiveMin) {
out.push(`${exclusiveMin} <`);
}
out.push(value);
if (max) {
out.push(`<= ${max}`);
} else if (exclusiveMax) {
out.push(`< ${exclusiveMax}`);
}
if (out.length > 1) return out.join(' ');
}
+27 -27
View File
@@ -19,13 +19,7 @@ export function Root({
...props
}: RootProps & HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'flex flex-col gap-24 text-sm text-fd-muted-foreground',
className,
)}
{...props}
>
<div className={cn('flex flex-col gap-24 text-sm', className)} {...props}>
<ApiProvider
mediaAdapters={
Object.fromEntries(
@@ -81,27 +75,31 @@ export function Property({
type,
required,
deprecated,
children,
nested,
...props
}: PropertyProps) {
return (
<div className="rounded-xl border bg-fd-card p-3 prose-no-margin">
<div className="flex flex-row flex-wrap items-center gap-4 mb-2">
<code>{name}</code>
{required ? (
<Badge color="red" className="text-xs">
Required
</Badge>
) : null}
{deprecated ? (
<div
className={cn(
'text-sm prose-no-margin',
nested ? 'py-3' : 'p-3 border rounded-xl bg-fd-card',
)}
>
<div className="flex flex-wrap items-center gap-3 mb-3">
<span className="px-1 py-0.5 border rounded-md border-fd-primary/10 bg-fd-primary/10 font-mono text-xs text-fd-primary sm:text-[13px]">
{name}
{required === false && '?'}
</span>
<span className="text-xs me-auto font-mono text-fd-muted-foreground">
{type}
</span>
{deprecated && (
<Badge color="yellow" className="text-xs">
Deprecated
</Badge>
) : null}
<span className="ms-auto text-xs font-mono text-fd-muted-foreground">
{type}
</span>
)}
</div>
{children}
{props.children}
</div>
);
}
@@ -128,15 +126,17 @@ export function ObjectCollapsible(props: {
<Collapsible {...props}>
<CollapsibleTrigger
className={cn(
buttonVariants({ color: 'outline', size: 'sm' }),
'group rounded-full px-2 py-1.5 text-fd-muted-foreground',
buttonVariants({ color: 'secondary', size: 'sm' }),
'group px-3 py-2 data-[state=open]:rounded-b-none data-[state=open]:border-b-0',
)}
>
{props.name}
<ChevronDown className="size-4 group-data-[state=open]:rotate-180" />
<ChevronDown className="size-4 text-fd-muted-foreground group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="-mx-2">
<div className="flex flex-col gap-2 p-2">{props.children}</div>
<CollapsibleContent className="-me-3">
<div className="flex flex-col gap-8 border-s border-y rounded-b-lg px-3">
{props.children}
</div>
</CollapsibleContent>
</Collapsible>
);