mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-26 23:49:51 +00:00
OpenAPI: Redesign schema UI to leverage CSR
This commit is contained in:
@@ -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.
|
||||
@@ -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}
|
||||
|
||||
@@ -45,7 +45,7 @@ export interface APIInfoProps {
|
||||
|
||||
export interface PropertyProps {
|
||||
name: string;
|
||||
type: string;
|
||||
type: ReactNode;
|
||||
required?: boolean;
|
||||
deprecated?: boolean;
|
||||
children?: ReactNode;
|
||||
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user