OpenAPI: Redesign schema UI to leverage CSR

This commit is contained in:
Fuma Nama
2025-10-18 18:04:35 +08:00
parent a6f5e4c490
commit 8ebd28f381
11 changed files with 627 additions and 539 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'fumadocs-openapi': minor
---
*Redesign schema UI to leverage CSR*
Fumadocs OpenAPI now uses CSR to render recursive components, hugely reducing the size of page for highly nested JSON schemas & improve performance.
+2 -2
View File
@@ -26,7 +26,7 @@ import { getDefaultValue } from './get-default-values';
import { cn } from 'fumadocs-ui/utils/cn';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { combineSchema } from '@/utils/combine-schema';
import { schemaToString } from '@/utils/schema-to-string';
import { FormatFlags, schemaToString } from '@/utils/schema-to-string';
import {
anyFields,
useFieldInfo,
@@ -401,7 +401,7 @@ export function FieldSet({
>
{union.map((item, i) => (
<option key={i} value={i}>
{schemaToString(item)}
{schemaToString(item, undefined, FormatFlags.UseAlias)}
</option>
))}
</select>
@@ -116,7 +116,7 @@ export function Operation({
<Schema
name="body"
as="body"
schema={(content.schema ?? {}) as ResolvedSchema}
root={(content.schema ?? {}) as ResolvedSchema}
required={body.required}
readOnly={method.method === 'GET'}
writeOnly={method.method !== 'GET'}
@@ -159,7 +159,7 @@ export function Operation({
<Schema
key={param.name}
name={param.name}
schema={
root={
{
...param.schema,
description: param.description ?? param.schema?.description,
@@ -326,7 +326,7 @@ async function ResponseAccordion({
<div className="border px-3 py-2 rounded-lg overflow-auto max-h-[400px]">
<Schema
name="response"
schema={schema as ResolvedSchema}
root={schema as ResolvedSchema}
as="body"
readOnly
ctx={ctx}
+1 -1
View File
@@ -45,7 +45,7 @@ export interface APIInfoProps {
export interface PropertyProps {
name: string;
type: string;
type: ReactNode;
required?: boolean;
deprecated?: boolean;
children?: ReactNode;
-441
View File
@@ -1,441 +0,0 @@
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';
interface Context {
stack: SchemaStack;
}
export function Schema({
name,
schema,
required = false,
readOnly = false,
writeOnly = false,
as = 'property',
ctx: renderContext,
}: {
name: string;
required?: boolean;
schema: ResolvedSchema;
as?: 'property' | 'body';
readOnly?: boolean;
writeOnly?: boolean;
ctx: RenderContext;
}): ReactNode {
const { renderer, content: { showExampleInFields = false } = {} } =
renderContext;
function propertyBody(
schema: Exclude<ResolvedSchema, boolean>,
renderPrimitive: (
child: Exclude<ResolvedSchema, boolean>,
ctx: Context,
) => ReactNode,
ctx: Context,
) {
if (ctx.stack.has(schema)) return;
const next = {
...ctx,
stack: ctx.stack.next(schema),
};
if (Array.isArray(schema.type)) {
const items = schema.type.flatMap((type) => {
const composed = {
...schema,
type,
};
if (!isComplexType(composed)) return [];
return composed;
});
if (items.length === 0) return;
if (items.length === 1)
return propertyBody(items[0], renderPrimitive, next);
return (
<Tabs defaultValue={items[0].type}>
<TabsList>
{items.map((item) => (
<TabsTrigger key={item.type} value={item.type}>
{schemaToString(item, renderContext.schema)}
</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 || oneOf.some((item) => ctx.stack.has(item)))
return;
if (oneOf.length === 1) {
return propertyBody(oneOf[0], renderPrimitive, next);
}
return (
<Tabs defaultValue="0">
<TabsList>
{oneOf.map((item, i) => (
<TabsTrigger key={i} value={i.toString()}>
{schemaToString(item, renderContext.schema)}
</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),
next,
)}
</TabsContent>
))}
</Tabs>
);
}
const of = schema.allOf ?? schema.anyOf;
if (of) {
if (of.length === 0) return;
if (of.some((item) => typeof item === 'object' && ctx.stack.has(item)))
return;
const combined = combineSchema(of as ResolvedSchema[]);
if (typeof combined === 'boolean') return;
return renderPrimitive(combined, ctx);
}
return renderPrimitive(schema, ctx);
}
function propertyInfo(schema: Exclude<ResolvedSchema, boolean>) {
const fields: ReactNode[] = [];
function field(key: string, value: ReactNode) {
return (
<div className="bg-fd-secondary border rounded-lg text-xs p-1.5 shadow-md">
<span className="font-medium me-2">{key}</span>
<code className="text-fd-muted-foreground">{value}</code>
</div>
);
}
if (schema.default !== undefined) {
fields.push(field('Default', JSON.stringify(schema.default)));
}
if (schema.pattern) {
fields.push(field('Match', schema.pattern));
}
if (schema.format) {
fields.push(field('Format', schema.format));
}
if (schema.multipleOf) {
fields.push(field('Multiple Of', schema.multipleOf));
}
let range = getRange(
'value',
schema.minimum,
schema.exclusiveMinimum,
schema.maximum,
schema.exclusiveMaximum,
);
if (range) fields.push(field('Range', range));
range = getRange(
'length',
schema.minLength,
undefined,
schema.maxLength,
undefined,
);
if (range) fields.push(field('Length', range));
range = getRange(
'properties',
schema.minProperties,
undefined,
schema.maxProperties,
undefined,
);
if (range) fields.push(field('Properties', range));
range = getRange(
'items',
schema.minItems,
undefined,
schema.maxItems,
undefined,
);
if (range) fields.push(field('Items', range));
if (schema.enum) {
fields.push(
field(
'Value in',
schema.enum.map((value) => JSON.stringify(value)).join(' | '),
),
);
}
if (showExampleInFields && schema.examples) {
for (const example of schema.examples) {
fields.push(field('Example', JSON.stringify(example, null, 2)));
}
}
if (fields.length === 0) return;
return (
<div className="flex flex-wrap gap-2 not-prose">
{fields.map((field, i) => (
<Fragment key={i}>{field}</Fragment>
))}
</div>
);
}
function primitiveBody(
schema: Exclude<ResolvedSchema, boolean>,
ctx: Context,
collapsible: boolean,
nested: boolean,
) {
if (ctx.stack.has(schema)) return <p>Recursive</p>;
if (schema.type === 'object') {
const props = Object.entries(schema.properties ?? {});
const patternProps = Object.entries(schema.patternProperties ?? {});
const next = {
...ctx,
stack: ctx.stack.next(schema),
};
if (props.length === 0 && patternProps.length === 0)
return <p>Empty Object</p>;
const children = (
<>
{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,
})}
</>
);
if (!collapsible) return children;
return (
<renderer.ObjectCollapsible name="Show Attributes">
{children}
</renderer.ObjectCollapsible>
);
}
if (schema.type === 'array') {
const items = schema.items;
if (!items || typeof items === 'boolean' || ctx.stack.has(items)) return;
return (
<renderer.ObjectCollapsible name="Array Item">
<div className="text-sm border-t p-3 border-x prose-no-margin bg-fd-card last:rounded-b-xl first:rounded-tr-xl last:border-b empty:hidden">
<Markdown text={items.description ?? 'No Description'} />
{propertyInfo(items)}
</div>
{propertyBody(
items,
(child, ctx) => primitiveBody(child, ctx, false, true),
{
...ctx,
stack: ctx.stack.next(schema),
},
)}
</renderer.ObjectCollapsible>
);
}
}
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 (ctx.stack.has(schema)) return;
if ((schema.readOnly && !readOnly) || (schema.writeOnly && !writeOnly))
return;
return (
<renderer.Property
name={key}
type={schemaToString(schema, renderContext.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(renderContext),
};
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,
);
}
interface SchemaStack {
history: ResolvedSchema[];
next(...schema: ResolvedSchema[]): SchemaStack;
add(schema: ResolvedSchema): void;
has(schema: Exclude<ResolvedSchema, boolean>): boolean;
}
function schemaStack(
renderContext: RenderContext,
parent?: SchemaStack,
): SchemaStack {
const ids = new Set<string>();
function getId(schema: ResolvedSchema) {
if (typeof schema !== 'object') return;
if (schema.title) return schema.title;
return renderContext.schema.getRawRef(schema);
}
return {
history: parent ? [...parent.history] : [],
next(...schemas) {
const child = schemaStack(renderContext, this);
for (const item of schemas) {
child.add(item);
}
return child;
},
add(schema) {
if (typeof schema !== 'object') return;
const id = getId(schema);
if (id) ids.add(id);
this.history.push(schema);
},
has(schema) {
if (this.history.length > 30) {
console.warn(
`[Fumadocs OpenAPI] schema depth exceeded 30, this might be unexpected.`,
);
// stopping at here
return true;
}
if (parent && parent.has(schema)) return true;
const id = getId(schema);
if (id) return ids.has(id);
return this.history.includes(schema);
},
};
}
/**
* Check if the schema needs another collapsible to explain
*/
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;
return (
schema.type === 'object' ||
(schema.type === 'array' && schema.items != null)
);
}
function getRange(
value: string,
min: number | undefined,
exclusiveMin: number | undefined,
max: number | undefined,
exclusiveMax: number | undefined,
) {
const out = [];
if (min !== undefined) {
out.push(`${min} <=`);
} else if (exclusiveMin !== undefined) {
out.push(`${exclusiveMin} <`);
}
out.push(value);
if (max !== undefined) {
out.push(`<= ${max}`);
} else if (exclusiveMax !== undefined) {
out.push(`< ${exclusiveMax}`);
}
if (out.length > 1) return out.join(' ');
}
@@ -0,0 +1,154 @@
'use client';
import { createContext, Fragment, type ReactNode, use } from 'react';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from 'fumadocs-ui/components/tabs';
import type { SchemaUIData } from '@/render/schema/server';
import { ObjectCollapsible, Property } from './ui';
interface UIContext extends SchemaUIData {
readOnly?: boolean;
writeOnly?: boolean;
}
const Context = createContext<UIContext | null>(null);
export function SchemaUIProvider(props: {
value: UIContext;
children: ReactNode;
}) {
return <Context value={props.value}>{props.children}</Context>;
}
function useData() {
return use(Context)!;
}
export interface SchemaUIProps {
name: string;
required?: boolean;
as?: 'property' | 'body';
}
export function SchemaUI({
name,
required = false,
as = 'property',
}: SchemaUIProps) {
const { $root } = useData();
return (
<SchemaUIProperty
name={name}
$type={$root}
required={required}
variant={as === 'property' ? 'default' : 'ghost'}
/>
);
}
function SchemaUIProperty({
$type,
variant = 'default',
nested = false,
...rest
}: {
name: string;
$type: string;
required?: boolean;
variant?: 'default' | 'ghost';
nested?: boolean;
}) {
const { refs, readOnly, writeOnly } = useData();
const schema = refs[$type];
if ((schema.readOnly && !readOnly) || (schema.writeOnly && !writeOnly))
return;
let child = (
<>
{schema.description}
<div className="flex flex-row gap-2 flex-wrap">
{schema.infoTags?.map((tag, i) => (
<Fragment key={i}>{tag}</Fragment>
))}
</div>
</>
);
if (schema.type === 'or' && schema.items.length > 0) {
const items = schema.items;
child = (
<>
{child}
<Tabs>
<TabsList>
{items.map((item) => (
<TabsTrigger key={item.$type} value={item.$type}>
{item.name}
</TabsTrigger>
))}
</TabsList>
{items.map((item) => (
<TabsContent
key={item.$type}
value={item.$type}
forceMount={undefined}
>
<SchemaUIProperty variant="ghost" {...item} />
</TabsContent>
))}
</Tabs>
</>
);
}
if (schema.type === 'object' && schema.props.length > 0) {
child = (
<>
{child}
{variant === 'ghost' ? (
schema.props.map((prop) => (
<SchemaUIProperty key={prop.name} {...prop} nested={nested} />
))
) : (
<ObjectCollapsible name="Show Attributes">
{schema.props.map((prop) => (
<SchemaUIProperty key={prop.name} {...prop} nested />
))}
</ObjectCollapsible>
)}
</>
);
}
if (schema.type === 'array') {
const item = schema.item;
child = (
<>
{child}
<ObjectCollapsible name="Array item">
<SchemaUIProperty name="item" {...item} variant="ghost" nested />
</ObjectCollapsible>
</>
);
}
if (variant === 'ghost') return child;
return (
<Property
type={schema.typeName}
deprecated={schema.deprecated}
nested={nested}
{...rest}
>
{child}
</Property>
);
}
@@ -0,0 +1,28 @@
import { generateSchemaUI, type SchemaUIOptions } from '@/render/schema/server';
import {
SchemaUI,
type SchemaUIProps,
SchemaUIProvider,
} from '@/render/schema/client';
export function Schema(
props: SchemaUIOptions &
SchemaUIProps & {
readOnly?: boolean;
writeOnly?: boolean;
},
) {
const data = generateSchemaUI(props);
return (
<SchemaUIProvider
value={{
...data,
readOnly: props.readOnly,
writeOnly: props.writeOnly,
}}
>
<SchemaUI name={props.name} required={props.required} as={props.as} />
</SchemaUIProvider>
);
}
@@ -0,0 +1,322 @@
import { type ReactNode } from 'react';
import type { ResolvedSchema } from '@/utils/schema';
import type { RenderContext } from '@/types';
import { FormatFlags, schemaToString } from '@/utils/schema-to-string';
import { Markdown } from '@/render/markdown';
import { combineSchema } from '@/utils/combine-schema';
export interface FieldBase {
description?: ReactNode;
infoTags?: ReactNode[];
typeName: string;
deprecated?: boolean;
writeOnly?: boolean;
readOnly?: boolean;
}
export type SchemaRef = FieldBase &
(
| {
type: 'primitive';
}
| {
type: 'any';
}
| {
type: 'object';
props: {
name: string;
$type: string;
required: boolean;
}[];
}
| {
type: 'array';
item: {
description?: ReactNode;
$type: string;
};
}
| {
type: 'or';
items: {
name: string;
$type: string;
}[];
}
);
export interface SchemaUIOptions {
root: ResolvedSchema;
ctx: RenderContext;
}
export interface SchemaUIData {
$root: string;
refs: Record<string, SchemaRef>;
}
export function generateSchemaUI({ ctx, root }: SchemaUIOptions): SchemaUIData {
const refs: Record<string, SchemaRef> = {};
const { content: { showExampleInFields = false } = {} } = ctx;
function generateInfoTags(schema: Exclude<ResolvedSchema, boolean>) {
const fields: ReactNode[] = [];
function field(key: string, value: ReactNode) {
return (
<div className="bg-fd-secondary border rounded-lg text-xs p-1.5 shadow-md">
<span className="font-medium me-2">{key}</span>
<code className="text-fd-muted-foreground">{value}</code>
</div>
);
}
if (schema.default !== undefined) {
fields.push(field('Default', JSON.stringify(schema.default)));
}
if (schema.pattern) {
fields.push(field('Match', schema.pattern));
}
if (schema.format) {
fields.push(field('Format', schema.format));
}
if (schema.multipleOf) {
fields.push(field('Multiple Of', schema.multipleOf));
}
let range = getRange(
'value',
schema.minimum,
schema.exclusiveMinimum,
schema.maximum,
schema.exclusiveMaximum,
);
if (range) fields.push(field('Range', range));
range = getRange(
'length',
schema.minLength,
undefined,
schema.maxLength,
undefined,
);
if (range) fields.push(field('Length', range));
range = getRange(
'properties',
schema.minProperties,
undefined,
schema.maxProperties,
undefined,
);
if (range) fields.push(field('Properties', range));
range = getRange(
'items',
schema.minItems,
undefined,
schema.maxItems,
undefined,
);
if (range) fields.push(field('Items', range));
if (schema.enum) {
fields.push(
field(
'Value in',
schema.enum.map((value) => JSON.stringify(value)).join(' | '),
),
);
}
if (showExampleInFields && schema.examples) {
for (const example of schema.examples) {
fields.push(field('Example', JSON.stringify(example, null, 2)));
}
}
return fields;
}
let _counter = 0;
const autoIds = new WeakMap();
function getSchemaId(schema: ResolvedSchema) {
if (typeof schema === 'boolean') return String(schema);
const raw = ctx.schema.getRawRef(schema);
if (raw) return raw;
const prev = autoIds.get(schema);
if (prev) return prev;
const generated = `__${_counter++}`;
autoIds.set(schema, generated);
return generated;
}
function base(schema: ResolvedSchema): FieldBase {
if (typeof schema === 'boolean')
return {
typeName: schema ? 'any' : 'never',
};
return {
description: schema.description && <Markdown text={schema.description} />,
infoTags: generateInfoTags(schema),
typeName: schemaToString(schema, ctx.schema),
deprecated: schema.deprecated,
readOnly: schema.readOnly,
writeOnly: schema.writeOnly,
};
}
function scanRefs(id: string, schema: ResolvedSchema) {
if (id in refs) return;
if (typeof schema === 'boolean') {
refs[id] = {
type: 'any',
...base(schema),
};
return;
}
if (Array.isArray(schema.type)) {
const out: SchemaRef = {
type: 'or',
items: [],
...base(schema),
};
refs[id] = out;
for (const type of schema.type) {
const item = {
...schema,
type,
};
const key = `${id}_type:${type}`;
scanRefs(key, item);
out.items.push({
name: type,
$type: key,
});
}
return;
}
if (schema.oneOf) {
const out: SchemaRef = {
type: 'or',
items: [],
...base(schema),
};
refs[id] = out;
for (const item of schema.oneOf) {
const $type = getSchemaId(item);
scanRefs($type, item);
out.items.push({
name: schemaToString(item, ctx.schema, FormatFlags.UseAlias),
$type,
});
}
return;
}
const of = schema.allOf ?? schema.anyOf;
if (of) {
const combined = combineSchema(of as ResolvedSchema[]);
scanRefs(id, combined);
return;
}
if (schema.type === 'object') {
const out: SchemaRef = {
type: 'object',
props: [],
...base(schema),
};
refs[id] = out;
const props = Object.entries(schema.properties ?? {});
if (schema.patternProperties)
props.push(...Object.entries(schema.patternProperties));
for (const [key, prop] of props) {
const $type = getSchemaId(prop);
scanRefs($type, prop);
out.props.push({
$type,
name: key,
required: schema.required?.includes(key) ?? false,
});
}
if (schema.additionalProperties) {
const $type = getSchemaId(schema.additionalProperties);
scanRefs($type, schema.additionalProperties);
out.props.push({
$type,
name: '[key: string]',
required: false,
});
}
return;
}
if (schema.type === 'array') {
const items = schema.items ?? true;
const $type = getSchemaId(items);
refs[id] = {
type: 'array',
item: {
$type,
},
...base(schema),
};
scanRefs($type, items);
return;
}
refs[id] = {
type: 'primitive',
...base(schema),
};
}
const $root = getSchemaId(root);
scanRefs($root, root);
return {
refs,
$root,
};
}
function getRange(
value: string,
min: number | undefined,
exclusiveMin: number | undefined,
max: number | undefined,
exclusiveMax: number | undefined,
) {
const out = [];
if (min !== undefined) {
out.push(`${min} <=`);
} else if (exclusiveMin !== undefined) {
out.push(`${exclusiveMin} <`);
}
out.push(value);
if (max !== undefined) {
out.push(`<= ${max}`);
} else if (exclusiveMax !== undefined) {
out.push(`< ${exclusiveMax}`);
}
if (out.length > 1) return out.join(' ');
}
+73
View File
@@ -0,0 +1,73 @@
import type { ReactNode } from 'react';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from 'fumadocs-ui/components/ui/collapsible';
import { cn } from 'fumadocs-ui/utils/cn';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { ChevronDown } from 'fumadocs-ui/internal/icons';
import type { PropertyProps } from '@/render/renderer';
import { Badge } from '@/ui/components/method-label';
export function ObjectCollapsible(props: {
name: string;
children: ReactNode;
}) {
return (
<Collapsible className="my-2" {...props}>
<CollapsibleTrigger
className={cn(
buttonVariants({ color: 'secondary', size: 'sm' }),
'group px-3 py-2 data-[state=open]:rounded-b-none',
)}
>
{props.name}
<ChevronDown className="size-4 text-fd-muted-foreground group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="-mt-px *:bg-fd-card">
{props.children}
</CollapsibleContent>
</Collapsible>
);
}
export function Property({
name,
type,
required,
deprecated,
nested = false,
...props
}: PropertyProps) {
return (
<div
className={cn(
'text-sm border-t',
nested
? 'p-3 border-x bg-fd-card last:rounded-b-xl first:rounded-tr-xl last:border-b'
: 'py-4 first:border-t-0',
)}
>
<div className="flex flex-wrap items-center gap-2 not-prose">
<span className="font-medium font-mono text-fd-primary">
{name}
{required === false && (
<span className="text-fd-muted-foreground">?</span>
)}
</span>
<span className="me-auto text-xs font-mono text-fd-muted-foreground">
{type}
</span>
{deprecated && (
<Badge color="yellow" className="text-xs">
Deprecated
</Badge>
)}
</div>
<div className="prose-no-margin pt-2.5 empty:hidden">
{props.children}
</div>
</div>
);
}
+3 -72
View File
@@ -1,15 +1,7 @@
import { type HTMLAttributes, type ReactNode } from 'react';
import { Badge } from '@/ui/components/method-label';
import type { PropertyProps, RootProps } from '@/render/renderer';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from 'fumadocs-ui/components/ui/collapsible';
import { ChevronDown } from 'lucide-react';
import type { HTMLAttributes } from 'react';
import type { RootProps } from '@/render/renderer';
import { ApiProvider } from '@/ui/lazy';
import { cn } from 'fumadocs-ui/utils/cn';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import type { MediaAdapter } from '@/media/adapter';
export function Root({
@@ -70,46 +62,6 @@ export function API({ children, ...props }: HTMLAttributes<HTMLDivElement>) {
);
}
export function Property({
name,
type,
required,
deprecated,
nested,
...props
}: PropertyProps) {
return (
<div
className={cn(
'text-sm border-t',
nested
? 'p-3 border-x bg-fd-card last:rounded-b-xl first:rounded-tr-xl last:border-b'
: 'py-4 first:border-t-0',
)}
>
<div className="flex flex-wrap items-center gap-2 not-prose">
<span className="font-medium font-mono text-fd-primary">
{name}
{required === false && (
<span className="text-fd-muted-foreground">?</span>
)}
</span>
<span className="me-auto text-xs font-mono text-fd-muted-foreground">
{type}
</span>
{deprecated && (
<Badge color="yellow" className="text-xs">
Deprecated
</Badge>
)}
</div>
<div className="prose-no-margin pt-2.5 empty:hidden">
{props.children}
</div>
</div>
);
}
export function APIExample(props: HTMLAttributes<HTMLDivElement>) {
return (
<div
@@ -124,26 +76,5 @@ export function APIExample(props: HTMLAttributes<HTMLDivElement>) {
);
}
export function ObjectCollapsible(props: {
name: string;
children: ReactNode;
}) {
return (
<Collapsible className="my-2" {...props}>
<CollapsibleTrigger
className={cn(
buttonVariants({ color: 'secondary', size: 'sm' }),
'group px-3 py-2 data-[state=open]:rounded-b-none',
)}
>
{props.name}
<ChevronDown className="size-4 text-fd-muted-foreground group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="-mt-px *:bg-fd-card">
{props.children}
</CollapsibleContent>
</Collapsible>
);
}
export { Property, ObjectCollapsible } from '@/render/schema/ui';
export { APIPage, type ApiPageProps } from '@/render/api-page';
+34 -20
View File
@@ -1,16 +1,27 @@
import { type ResolvedSchema } from '@/utils/schema';
import type { ProcessedDocument } from '@/utils/process-document';
export enum FormatFlags {
None = 0,
UseAlias = 1 << 0,
}
export function schemaToString(
value: ResolvedSchema,
ctx?: ProcessedDocument,
flags?: FormatFlags,
): string {
function union(union: readonly ResolvedSchema[], sep: string) {
function union(
union: readonly ResolvedSchema[],
sep: string,
flags: FormatFlags,
) {
const members = new Set();
let nullable = false;
for (const item of union) {
const result = run(item);
const result = run(item, flags | FormatFlags.UseAlias);
if (result === 'null') {
nullable = true;
} else if (result !== 'unknown') {
@@ -22,38 +33,41 @@ export function schemaToString(
return nullable ? `${result} | null` : result;
}
function run(schema: ResolvedSchema): string {
function run(
schema: ResolvedSchema,
flags: FormatFlags = FormatFlags.None,
): string {
if (schema === true) return 'any';
else if (schema === false) return 'never';
if (schema.title) return schema.title;
const rawRef = ctx?.getRawRef(schema);
if (rawRef) return rawRef.split('/').at(-1)!;
if ((flags & FormatFlags.UseAlias) === FormatFlags.UseAlias) {
if (schema.title) return schema.title;
const ref = ctx?.getRawRef(schema);
if (ref) return ref.split('/').at(-1)!;
}
if (Array.isArray(schema.type)) {
const members = new Set();
const types = schema.type;
for (const type of types) {
schema.type = type;
const str = run(schema);
schema.type = types;
if (str !== 'unknown') members.add(str);
}
return Array.from(members).join(' | ');
return union(
schema.type.map((type) => ({
...schema,
type,
})),
' | ',
flags,
);
}
if (schema.type === 'array')
return `array<${schema.items ? run(schema.items) : 'unknown'}>`;
if (schema.oneOf) {
return union(schema.oneOf, ' | ');
return union(schema.oneOf, ' | ', flags);
}
const combinedOf = schema.anyOf ?? schema.allOf;
if (combinedOf) {
return union(combinedOf, ' & ');
return union(combinedOf, ' & ', flags);
}
if (schema.not) return `not ${run(schema.not)}`;
@@ -70,5 +84,5 @@ export function schemaToString(
return 'unknown';
}
return run(value);
return run(value, flags);
}