OpenAPI: isolate API page & server

This commit is contained in:
Fuma Nama
2025-11-04 21:11:09 +08:00
committed by Fuma Nama
parent ea2656049c
commit aa4e1ad991
27 changed files with 747 additions and 666 deletions
+57
View File
@@ -0,0 +1,57 @@
---
'fumadocs-openapi': major
---
**Redesign `createOpenAPI` usage**
1. Isolate API page and API server.
Before:
```ts
// lib/openapi.ts
import { createOpenAPI } from 'fumadocs-openapi/server';
import path from 'node:path';
export const openapi = createOpenAPI({
input: [path.resolve('./scalar.yaml')],
proxyUrl: '/api/proxy',
mediaAdapters: { ... },
shikiOptions: {
themes: {
dark: 'vesper',
light: 'vitesse-light',
},
},
});
```
After:
```ts
// lib/openapi.ts
import { createOpenAPI } from 'fumadocs-openapi/server';
import path from 'node:path';
export const openapi = createOpenAPI({
input: [path.resolve('./scalar.yaml')],
proxyUrl: '/api/proxy',
});
```
```ts
// components/api-page.tsx
import { openapi } from '@/lib/openapi';
import { createAPIPage } from 'fumadocs-openapi/ui';
export const APIPage = createAPIPage(openapi, {
mediaAdapters: { ... },
shikiOptions: {
themes: {
dark: 'vesper',
light: 'vitesse-light',
},
},
});
```
+1 -1
View File
@@ -28,7 +28,7 @@ import { Customisation } from '@/components/preview/customisation';
import { DocsBody, DocsPage } from 'fumadocs-ui/page';
import { NotFound } from '@/components/not-found';
import { getSuggestions } from '@/app/docs/[...slug]/suggestions';
import { APIPage } from 'fumadocs-openapi/ui';
import { APIPage } from '@/components/api-page';
import { PathUtils } from 'fumadocs-core/source';
function PreviewRenderer({ preview }: { preview: string }): ReactNode {
+11
View File
@@ -0,0 +1,11 @@
import { openapi } from '@/lib/openapi';
import { createAPIPage } from 'fumadocs-openapi/ui';
export const APIPage = createAPIPage(openapi, {
shikiOptions: {
themes: {
dark: 'vesper',
light: 'vitesse-light',
},
},
});
@@ -3,12 +3,5 @@ import path from 'node:path';
export const openapi = createOpenAPI({
input: [path.resolve('./scalar.yaml')],
proxyUrl: '/api/proxy',
shikiOptions: {
themes: {
dark: 'vesper',
light: 'vitesse-light',
},
},
});
+9 -4
View File
@@ -26,7 +26,12 @@ import {
} from 'react-hook-form';
import { useApiContext, useServerSelectContext } from '@/ui/contexts/api';
import type { FetchResult } from '@/playground/fetcher';
import { FieldInput, FieldSet, JsonInput, ObjectInput } from './inputs';
import {
FieldInput,
FieldSet,
JsonInput,
ObjectInput,
} from './components/inputs';
import type {
ParameterField,
RequestSchema,
@@ -71,6 +76,7 @@ import {
import { labelVariants } from '@/ui/components/input';
import type { ParsedSchema } from '@/utils/schema';
import type { RequestData } from '@/requests/types';
import ServerSelect from './components/server-select';
interface FormValues {
path: Record<string, unknown>;
@@ -129,14 +135,13 @@ export interface ClientProps extends HTMLAttributes<HTMLFormElement> {
const AuthPrefix = '__fumadocs_auth';
const ServerSelect = lazy(() => import('@/ui/server-select'));
const OauthDialog = lazy(() =>
import('./auth/oauth-dialog').then((mod) => ({
import('./components/oauth-dialog').then((mod) => ({
default: mod.OauthDialog,
})),
);
const OauthDialogTrigger = lazy(() =>
import('./auth/oauth-dialog').then((mod) => ({
import('./components/oauth-dialog').then((mod) => ({
default: mod.OauthDialogTrigger,
})),
);
@@ -22,7 +22,7 @@ import {
} from '@/ui/components/select';
import type { RequestSchema } from '@/playground/index';
import { Input, labelVariants } from '@/ui/components/input';
import { getDefaultValue } from './get-default-values';
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';
+9 -101
View File
@@ -1,104 +1,18 @@
import type { ApiPageProps } from '@/ui/api-page';
import { createProxy } from '@/server/proxy';
import type { CodeSample } from '@/ui/operation';
import type { Renderer } from '@/ui/renderer';
import type { NoReference } from '@/utils/schema';
import type {
HighlightOptionsCommon,
HighlightOptionsThemes,
} from 'fumadocs-core/highlight';
import type { MediaAdapter } from '@/requests/media/adapter';
import type { MethodInformation, RenderContext } from '@/types';
import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
import {
processDocument,
processDocumentCached,
type ProcessedDocument,
} from '@/utils/process-document';
import type { ReactNode } from 'react';
type Awaitable<T> = T | Promise<T>;
/**
* schema id -> downloaded schema object
*/
type SchemaMap = Record<string, OpenAPIV3_1.Document | OpenAPIV3.Document>;
type ProcessedSchemaMap = Record<string, ProcessedDocument>;
export interface SharedOpenAPIOptions {
/**
* The url of proxy to avoid CORS issues
*/
proxyUrl?: string;
renderer?: Partial<Renderer>;
/**
* Disable API Playground
*
* @defaultValue false
*/
disablePlayground?: boolean;
/**
* Generate TypeScript definitions from response schema.
*
* Pass `false` to disable it.
*
* @param method - the operation object
* @param statusCode - status code
*/
generateTypeScriptSchema?:
| ((
method: NoReference<MethodInformation>,
statusCode: string,
) => Awaitable<string>)
| false;
/**
* Generate code samples for endpoint.
*/
generateCodeSamples?: (method: MethodInformation) => Awaitable<CodeSample[]>;
shikiOptions?: Omit<HighlightOptionsCommon, 'lang' | 'components'> &
HighlightOptionsThemes;
/**
* Show full response schema instead of only example response & Typescript definitions
*
* @default true
*/
showResponseSchema?: boolean;
mediaAdapters?: Record<string, MediaAdapter>;
/**
* Customise page content
*/
content?: {
/**
* Show examples under the generated content of JSON schemas.
*
* @defaultValue false
*/
showExampleInFields?: boolean;
};
/**
* Customise API playground
*/
playground?: {
/**
* replace the server-side renderer
*/
render?: (props: {
path: string;
method: MethodInformation;
ctx: RenderContext;
}) => ReactNode | Promise<ReactNode>;
};
}
export interface OpenAPIOptions extends SharedOpenAPIOptions {
export interface OpenAPIOptions {
/**
* Schema files, can be:
* - URL
@@ -108,16 +22,21 @@ export interface OpenAPIOptions extends SharedOpenAPIOptions {
input?: string[] | (() => Promise<SchemaMap>);
disableCache?: boolean;
/**
* The url of proxy to avoid CORS issues
*/
proxyUrl?: string;
}
export interface OpenAPIServer {
getAPIPageProps: (from: ApiPageProps) => ApiPageProps;
createProxy: typeof createProxy;
getSchemas: () => Promise<ProcessedSchemaMap>;
readonly options: OpenAPIOptions;
}
export function createOpenAPI(options: OpenAPIOptions = {}): OpenAPIServer {
const { input = [], disableCache = false, ...shared } = options;
const { input = [], disableCache = false } = options;
let schemas: Promise<ProcessedSchemaMap> | undefined;
async function getSchemas() {
@@ -141,24 +60,13 @@ export function createOpenAPI(options: OpenAPIOptions = {}): OpenAPIServer {
}
return {
options,
createProxy,
async getSchemas() {
if (disableCache) return getSchemas();
return (schemas ??= getSchemas());
},
getAPIPageProps({ document, ...props }) {
return {
...shared,
...props,
document:
typeof document === 'string'
? this.getSchemas().then((map) => {
return map[document] ?? processDocumentCached(document);
})
: document,
};
},
};
}
+1 -1
View File
@@ -93,7 +93,7 @@ export async function openapiSource(
path: `${baseDir}/${entry.path}`,
data: {
...entry.info,
getAPIPageProps: () => toBody(from, entry),
getAPIPageProps: () => toBody(entry),
_openapi: {
method:
entry.type === 'operation' || entry.type === 'webhook'
+5 -2
View File
@@ -4,7 +4,8 @@ import { type Renderer } from '@/ui/renderer';
import type { NoReference } from '@/utils/schema';
import type { ProcessedDocument } from '@/utils/process-document';
import type { MediaAdapter } from '@/requests/media/adapter';
import type { SharedOpenAPIOptions } from '@/server';
import type { OpenAPIOptions } from '@/server';
import type { CreateAPIPageOptions } from './ui/api-page';
export type Document = V3_1.Document;
export type OperationObject = V3_1.OperationObject;
@@ -21,7 +22,9 @@ export type MethodInformation = NoReference<OperationObject> & {
method: string;
};
export interface RenderContext extends SharedOpenAPIOptions {
export interface RenderContext
extends Pick<OpenAPIOptions, 'proxyUrl'>,
CreateAPIPageOptions {
renderer: Renderer;
servers: NoReference<ServerObject>[];
slugger: Slugger;
+137 -50
View File
@@ -1,19 +1,95 @@
import Slugger from 'github-slugger';
import { Operation } from '@/ui/operation';
import type { RenderContext } from '@/types';
import { createMethod } from '@/utils/schema';
import { CodeSample, Operation } from '@/ui/operation';
import type { MethodInformation, RenderContext } from '@/types';
import { createMethod, NoReference } from '@/utils/schema';
import { createRenders } from '@/ui/renderer';
import type { OpenAPIV3_1 } from 'openapi-types';
import {
processDocumentCached,
type ProcessedDocument,
} from '@/utils/process-document';
import { defaultAdapters } from '@/requests/media/adapter';
import type { SharedOpenAPIOptions } from '@/server';
import type { ProcessedDocument } from '@/utils/process-document';
import { defaultAdapters, MediaAdapter } from '@/requests/media/adapter';
import { FC, ReactNode } from 'react';
import type {
HighlightOptionsCommon,
HighlightOptionsThemes,
} from 'fumadocs-core/highlight';
import { OpenAPIServer } from '@/server';
export interface ApiPageProps extends SharedOpenAPIOptions {
type Awaitable<T> = T | Promise<T>;
export interface CreateAPIPageOptions {
/**
* Disable API Playground
*
* @defaultValue false
* @deprecated Use `playground.enabled` instead
*/
disablePlayground?: boolean;
/**
* Generate TypeScript definitions from response schema.
*
* Pass `false` to disable it.
*
* @param method - the operation object
* @param statusCode - status code
*/
generateTypeScriptSchema?:
| ((
method: NoReference<MethodInformation>,
statusCode: string,
) => Awaitable<string>)
| false;
/**
* Generate code samples for endpoint.
*/
generateCodeSamples?: (method: MethodInformation) => Awaitable<CodeSample[]>;
shikiOptions?: Omit<HighlightOptionsCommon, 'lang' | 'components'> &
HighlightOptionsThemes;
/**
* Show full response schema instead of only example response & Typescript definitions
*
* @default true
*/
showResponseSchema?: boolean;
mediaAdapters?: Record<string, MediaAdapter>;
/**
* Customise page content
*/
content?: {
/**
* Show examples under the generated content of JSON schemas.
*
* @defaultValue false
*/
showExampleInFields?: boolean;
};
/**
* Customise API playground
*/
playground?: {
/**
* @defaultValue true
*/
enabled?: boolean;
/**
* replace the server-side renderer
*/
render?: (props: {
path: string;
method: MethodInformation;
ctx: RenderContext;
}) => ReactNode | Promise<ReactNode>;
};
}
export interface ApiPageProps {
document: Promise<ProcessedDocument> | string | ProcessedDocument;
hasHead: boolean;
hasHead?: boolean;
/**
* An array of operations
@@ -42,14 +118,56 @@ export interface OperationItem {
method: OpenAPIV3_1.HttpMethods;
}
export async function APIPage(props: ApiPageProps) {
const { operations, hasHead = true, webhooks } = props;
const processed =
typeof props.document === 'string'
? await processDocumentCached(props.document)
: await props.document;
const ctx = await getContext(processed, props);
const { dereferenced } = processed;
export function createAPIPage(
server: OpenAPIServer,
options: CreateAPIPageOptions = {},
): FC<ApiPageProps> {
return async function APIPageWrapper({ document, ...props }) {
let processed: ProcessedDocument;
if (typeof document === 'string') {
processed = (await server.getSchemas())[document];
} else {
processed = await document;
}
const { dereferenced } = processed;
const servers =
dereferenced.servers && dereferenced.servers.length > 0
? dereferenced.servers
: [{ url: '/' }];
const ctx: RenderContext = {
schema: processed,
proxyUrl: server.options.proxyUrl,
disablePlayground: options.disablePlayground,
showResponseSchema: options.showResponseSchema,
renderer: {
...createRenders(),
},
shikiOptions: options.shikiOptions,
generateTypeScriptSchema: options.generateTypeScriptSchema,
generateCodeSamples: options.generateCodeSamples,
servers,
mediaAdapters: {
...defaultAdapters,
...options.mediaAdapters,
},
slugger: new Slugger(),
};
return <APIPage {...props} ctx={ctx} />;
};
}
async function APIPage({
hasHead = false,
operations,
webhooks,
ctx,
}: Omit<ApiPageProps, 'document'> & {
ctx: RenderContext;
}) {
const { dereferenced } = ctx.schema;
return (
<ctx.renderer.Root ctx={ctx}>
@@ -107,34 +225,3 @@ export async function APIPage(props: ApiPageProps) {
</ctx.renderer.Root>
);
}
export async function getContext(
schema: ProcessedDocument,
options: SharedOpenAPIOptions = {},
): Promise<RenderContext> {
const document = schema.dereferenced;
const servers =
document.servers && document.servers.length > 0
? document.servers
: [{ url: '/' }];
return {
schema,
proxyUrl: options.proxyUrl,
disablePlayground: options.disablePlayground,
showResponseSchema: options.showResponseSchema,
renderer: {
...createRenders(),
...options.renderer,
},
shikiOptions: options.shikiOptions,
generateTypeScriptSchema: options.generateTypeScriptSchema,
generateCodeSamples: options.generateCodeSamples,
servers,
mediaAdapters: {
...defaultAdapters,
...options.mediaAdapters,
},
slugger: new Slugger(),
};
}
-40
View File
@@ -1,40 +0,0 @@
'use client';
import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { cn } from 'fumadocs-ui/utils/cn';
import { Check, Copy } from 'lucide-react';
export function CopyResponseTypeScript({ code }: { code: string }) {
const [isChecked, onCopy] = useCopyButton(() => {
void navigator.clipboard.writeText(code);
});
return (
<div className="flex items-start justify-between gap-2 bg-fd-card text-fd-card-foreground border rounded-xl p-3 not-prose mb-4 last:mb-0">
<div>
<p className="font-medium text-sm mb-2">TypeScript Definitions</p>
<p className="text-xs text-fd-muted-foreground">
Use the response body type in TypeScript.
</p>
</div>
<button
onClick={onCopy}
className={cn(
buttonVariants({
color: 'secondary',
className: 'p-2 gap-2',
size: 'sm',
}),
)}
>
{isChecked ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
Copy
</button>
</div>
);
}
+1 -2
View File
@@ -76,5 +76,4 @@ export function APIExample(props: HTMLAttributes<HTMLDivElement>) {
);
}
export { Property, ObjectCollapsible } from '@/ui/schema/ui';
export { APIPage, type ApiPageProps } from '@/ui/api-page';
export * from '@/ui/api-page';
@@ -1,8 +1,8 @@
import type { MethodInformation, RenderContext } from '@/types';
import { type ReactNode } from 'react';
import { Markdown } from '@/ui/markdown';
import { Markdown } from '@/ui/components/server/markdown';
import { type CodeSample } from '@/ui/operation/index';
import { CodeBlock } from '@/ui/codeblock';
import { CodeBlock } from '@/ui/components/server/codeblock';
import { CodeExample, CodeExampleProvider } from '@/ui/lazy';
import { getPreferredType, type NoReference } from '@/utils/schema';
import { getRequestData } from '@/ui/operation/get-request-data';
@@ -1,4 +1,9 @@
'use client';
import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { cn } from 'fumadocs-ui/utils/cn';
import { Check, Copy } from 'lucide-react';
import {
type ComponentProps,
createContext,
@@ -14,7 +19,40 @@ import {
SelectTrigger,
SelectValue,
} from '@/ui/components/select';
import { cn } from 'fumadocs-ui/utils/cn';
export function CopyResponseTypeScript({ code }: { code: string }) {
const [isChecked, onCopy] = useCopyButton(() => {
void navigator.clipboard.writeText(code);
});
return (
<div className="flex items-start justify-between gap-2 bg-fd-card text-fd-card-foreground border rounded-xl p-3 not-prose mb-4 last:mb-0">
<div>
<p className="font-medium text-sm mb-2">TypeScript Definitions</p>
<p className="text-xs text-fd-muted-foreground">
Use the response body type in TypeScript.
</p>
</div>
<button
onClick={onCopy}
className={cn(
buttonVariants({
color: 'secondary',
className: 'p-2 gap-2',
size: 'sm',
}),
)}
>
{isChecked ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
Copy
</button>
</div>
);
}
const Context = createContext<{
type: string | null;
+57 -12
View File
@@ -1,4 +1,9 @@
import { Fragment, type ReactElement, type ReactNode } from 'react';
import {
ComponentProps,
Fragment,
type ReactElement,
type ReactNode,
} from 'react';
import type {
CallbackObject,
MethodInformation,
@@ -12,8 +17,8 @@ import {
type ResolvedSchema,
} from '@/utils/schema';
import { idToTitle } from '@/utils/id-to-title';
import { Markdown } from '../markdown';
import { heading } from '../heading';
import { Markdown } from '../components/server/markdown';
import { heading } from '../components/server/heading';
import { Schema } from '../schema';
import {
APIExample,
@@ -23,8 +28,12 @@ import {
import { MethodLabel } from '@/ui/components/method-label';
import { type SampleGenerator } from '@/requests/types';
import { getTypescriptSchema } from '@/utils/get-typescript-schema';
import { CopyResponseTypeScript } from '@/ui/client';
import { SelectTab, SelectTabs, SelectTabTrigger } from '@/ui/select-tabs';
import {
CopyResponseTypeScript,
SelectTab,
SelectTabs,
SelectTabTrigger,
} from './client';
import {
AccordionContent,
AccordionHeader,
@@ -33,6 +42,7 @@ import {
AccordionTrigger,
} from '@/ui/components/accordion';
import { isMediaTypeSupported } from '@/requests/media/adapter';
import { cn } from 'fumadocs-ui/utils/cn';
export interface CodeSample<T = unknown> {
lang: string;
@@ -399,7 +409,6 @@ function WebhookCallback({
function AuthScheme({
scheme: schema,
scopes,
ctx: { renderer },
}: {
scheme: SecuritySchemeObject;
scopes: string[];
@@ -414,7 +423,7 @@ function AuthScheme({
if (schema.type === 'http' || schema.type === 'oauth2') {
return (
<renderer.Property
<Property
name="Authorization"
type={
schema.type === 'http' && schema.scheme === 'basic'
@@ -428,28 +437,64 @@ function AuthScheme({
In: <code>header</code>
</p>
{scopeElement}
</renderer.Property>
</Property>
);
}
if (schema.type === 'apiKey') {
return (
<renderer.Property name={schema.name} type="<token>">
<Property name={schema.name} type="<token>">
{schema.description && <Markdown text={schema.description} />}
<p>
In: <code>{schema.in}</code>
{scopeElement}
</p>
</renderer.Property>
</Property>
);
}
if (schema.type === 'openIdConnect') {
return (
<renderer.Property name="OpenID Connect" type="<token>" required>
<Property name="OpenID Connect" type="<token>" required>
{schema.description && <Markdown text={schema.description} />}
{scopeElement}
</renderer.Property>
</Property>
);
}
}
function Property({
name,
type,
required = false,
...props
}: ComponentProps<'div'> & {
name: string;
type: ReactNode;
required?: boolean;
}) {
return (
<div
className={cn('text-sm border-t py-4 first:border-t-0', props.className)}
>
<div className="flex flex-wrap items-center gap-3 not-prose">
<span className="font-medium font-mono text-fd-primary">
{name}
{required === false && (
<span className="text-fd-muted-foreground">?</span>
)}
</span>
{typeof type === 'string' ? (
<span className="text-sm font-mono text-fd-muted-foreground">
{type}
</span>
) : (
type
)}
</div>
<div className="prose-no-margin pt-2.5 empty:hidden">
{props.children}
</div>
</div>
);
}
+1 -15
View File
@@ -7,14 +7,7 @@ import {
Accordions,
AccordionTrigger,
} from '@/ui/components/accordion';
import {
API,
APIExample,
APIInfo,
ObjectCollapsible,
Property,
Root,
} from '@/ui';
import { API, APIExample, APIInfo, Root } from '@/ui';
import type { RenderContext } from '@/types';
import { APIPlayground, type APIPlaygroundProps } from '@/playground';
import { CodeExampleSelector } from '@/ui/lazy';
@@ -95,11 +88,6 @@ export interface Renderer {
ResponseTypes: ComponentType<{ defaultValue?: string; children: ReactNode }>;
ResponseType: ComponentType<ResponseTypeProps>;
/**
* Collapsible to show object schemas
*/
ObjectCollapsible: ComponentType<ObjectCollapsibleProps>;
Property: ComponentType<PropertyProps>;
APIPlayground: ComponentType<APIPlaygroundProps>;
}
@@ -137,8 +125,6 @@ export function createRenders(): Renderer {
</AccordionContent>
</AccordionItem>
),
Property,
ObjectCollapsible,
Requests: ({ items, children }) => (
<CodeBlockTabs
groupId="fumadocs_openapi_requests"
+84 -2
View File
@@ -16,8 +16,15 @@ import {
TabsList,
TabsTrigger,
} from 'fumadocs-ui/components/tabs';
import type { SchemaData, SchemaUIData } from '@/ui/schema/server';
import { ObjectCollapsible, Property, type PropertyProps } from './ui';
import type { SchemaData, SchemaUIData } from '@/ui/schema';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from 'fumadocs-ui/components/ui/collapsible';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { ChevronDown } from 'lucide-react';
import { Badge } from '@/ui/components/method-label';
import {
Popover,
PopoverContent,
@@ -367,6 +374,81 @@ function LinkRef({
);
}
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>
);
}
interface PropertyProps {
name: ReactNode;
type: ReactNode;
required?: boolean;
deprecated?: boolean;
nested?: boolean;
children?: ReactNode;
className?: string;
}
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',
props.className,
)}
>
<div className="flex flex-wrap items-center gap-3 not-prose">
<span className="font-medium font-mono text-fd-primary">
{name}
{required === false && (
<span className="text-fd-muted-foreground">?</span>
)}
</span>
{typeof type === 'string' ? (
<span className="text-sm font-mono text-fd-muted-foreground">
{type}
</span>
) : (
type
)}
{deprecated && (
<Badge color="yellow" className="ms-auto text-xs">
Deprecated
</Badge>
)}
</div>
<div className="prose-no-margin pt-2.5 empty:hidden">
{props.children}
</div>
</div>
);
}
function isExpandable(schema: SchemaData) {
return schema.type !== 'primitive';
}
+325 -1
View File
@@ -1,10 +1,65 @@
import { generateSchemaUI, type SchemaUIOptions } from '@/ui/schema/server';
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 '@/ui/components/server/markdown';
import { combineSchema } from '@/utils/combine-schema';
import {
SchemaUI,
type SchemaUIProps,
SchemaUIProvider,
} from '@/ui/schema/client';
export interface FieldBase {
description?: ReactNode;
infoTags?: ReactNode[];
typeName: string;
aliasName: string;
deprecated?: boolean;
writeOnly?: boolean;
readOnly?: boolean;
}
export type SchemaData = FieldBase &
(
| {
type: 'primitive';
}
| {
type: 'object';
props: {
name: string;
$type: string;
required: boolean;
}[];
}
| {
type: 'array';
item: {
$type: string;
};
}
| {
type: 'or';
items: {
name: string;
$type: string;
}[];
}
);
export interface SchemaUIOptions {
root: ResolvedSchema;
ctx: RenderContext;
}
export interface SchemaUIData {
$root: string;
refs: Record<string, SchemaData>;
}
export function Schema(
props: SchemaUIOptions &
SchemaUIProps & {
@@ -26,3 +81,272 @@ export function Schema(
</SchemaUIProvider>
);
}
function generateSchemaUI({ ctx, root }: SchemaUIOptions): SchemaUIData {
const refs: Record<string, SchemaData> = {};
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') {
const name = schema ? 'any' : 'never';
return {
typeName: name,
aliasName: name,
};
}
return {
description: schema.description && <Markdown text={schema.description} />,
infoTags: generateInfoTags(schema),
typeName: schemaToString(schema, ctx.schema),
aliasName: schemaToString(schema, ctx.schema, FormatFlags.UseAlias),
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: 'primitive',
...base(schema),
};
return;
}
if (Array.isArray(schema.type)) {
const out: SchemaData = {
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: SchemaData = {
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: SchemaData = {
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(' ');
}
-325
View File
@@ -1,325 +0,0 @@
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 '@/ui/markdown';
import { combineSchema } from '@/utils/combine-schema';
export interface FieldBase {
description?: ReactNode;
infoTags?: ReactNode[];
typeName: string;
aliasName: string;
deprecated?: boolean;
writeOnly?: boolean;
readOnly?: boolean;
}
export type SchemaData = FieldBase &
(
| {
type: 'primitive';
}
| {
type: 'object';
props: {
name: string;
$type: string;
required: boolean;
}[];
}
| {
type: 'array';
item: {
$type: string;
};
}
| {
type: 'or';
items: {
name: string;
$type: string;
}[];
}
);
export interface SchemaUIOptions {
root: ResolvedSchema;
ctx: RenderContext;
}
export interface SchemaUIData {
$root: string;
refs: Record<string, SchemaData>;
}
export function generateSchemaUI({ ctx, root }: SchemaUIOptions): SchemaUIData {
const refs: Record<string, SchemaData> = {};
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') {
const name = schema ? 'any' : 'never';
return {
typeName: name,
aliasName: name,
};
}
return {
description: schema.description && <Markdown text={schema.description} />,
infoTags: generateInfoTags(schema),
typeName: schemaToString(schema, ctx.schema),
aliasName: schemaToString(schema, ctx.schema, FormatFlags.UseAlias),
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: 'primitive',
...base(schema),
};
return;
}
if (Array.isArray(schema.type)) {
const out: SchemaData = {
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: SchemaData = {
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: SchemaData = {
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(' ');
}
-88
View File
@@ -1,88 +0,0 @@
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 { 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 interface PropertyProps {
name: ReactNode;
type: ReactNode;
required?: boolean;
deprecated?: boolean;
nested?: boolean;
children?: ReactNode;
className?: string;
}
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',
props.className,
)}
>
<div className="flex flex-wrap items-center gap-3 not-prose">
<span className="font-medium font-mono text-fd-primary">
{name}
{required === false && (
<span className="text-fd-muted-foreground">?</span>
)}
</span>
{typeof type === 'string' ? (
<span className="text-sm font-mono text-fd-muted-foreground">
{type}
</span>
) : (
type
)}
{deprecated && (
<Badge color="yellow" className="ms-auto text-xs">
Deprecated
</Badge>
)}
</div>
<div className="prose-no-margin pt-2.5 empty:hidden">
{props.children}
</div>
</div>
);
}
+7 -11
View File
@@ -1,28 +1,24 @@
import type { ApiPageProps } from '@/ui/api-page';
import type { OpenAPIServer } from '@/server';
import type { OutputEntry } from '@/utils/pages/builder';
export function toBody(
server: OpenAPIServer,
entry: OutputEntry,
): ApiPageProps {
export function toBody(entry: OutputEntry): ApiPageProps {
if (entry.type === 'operation')
return server.getAPIPageProps({
return {
hasHead: false,
document: entry.schemaId,
operations: [entry.item],
});
};
if (entry.type === 'webhook')
return server.getAPIPageProps({
return {
hasHead: false,
document: entry.schemaId,
webhooks: [entry.item],
});
};
return server.getAPIPageProps({
return {
hasHead: true,
document: entry.schemaId,
operations: entry.operations,
webhooks: entry.webhooks,
});
};
}