mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-26 10:55:57 +00:00
448 lines
11 KiB
TypeScript
448 lines
11 KiB
TypeScript
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 } = 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: {
|
|
key: string;
|
|
value: string;
|
|
}[] = [];
|
|
|
|
if (schema.default !== undefined) {
|
|
fields.push({
|
|
key: 'Default',
|
|
value: JSON.stringify(schema.default),
|
|
});
|
|
}
|
|
|
|
if (schema.pattern) {
|
|
fields.push({
|
|
key: 'Match',
|
|
value: schema.pattern,
|
|
});
|
|
}
|
|
|
|
if (schema.format) {
|
|
fields.push({
|
|
key: 'Format',
|
|
value: schema.format,
|
|
});
|
|
}
|
|
|
|
if (schema.multipleOf) {
|
|
fields.push({
|
|
key: 'Multiple Of',
|
|
value: String(schema.multipleOf),
|
|
});
|
|
}
|
|
|
|
let range = getRange(
|
|
'value',
|
|
schema.minimum,
|
|
schema.exclusiveMinimum,
|
|
schema.maximum,
|
|
schema.exclusiveMaximum,
|
|
);
|
|
if (range)
|
|
fields.push({
|
|
key: 'Range',
|
|
value: range,
|
|
});
|
|
|
|
range = getRange(
|
|
'length',
|
|
schema.minLength,
|
|
undefined,
|
|
schema.maxLength,
|
|
undefined,
|
|
);
|
|
if (range)
|
|
fields.push({
|
|
key: 'Length',
|
|
value: range,
|
|
});
|
|
|
|
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-wrap gap-2 not-prose">
|
|
{fields.map((field) => (
|
|
<div
|
|
key={field.key}
|
|
className="bg-fd-secondary border rounded-lg text-xs p-1.5 shadow-md"
|
|
>
|
|
<span className="font-medium me-2">{field.key}</span>
|
|
<code className="text-fd-muted-foreground">{field.value}</code>
|
|
</div>
|
|
))}
|
|
</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(' ');
|
|
}
|