mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
OpenAPI: isolate API page & server
This commit is contained in:
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
|
||||
+1
-1
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
+39
-1
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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(' ');
|
||||
}
|
||||
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user