OpenAPI: Support /ui/base for reduced bundle size

This commit is contained in:
Fuma Nama
2026-01-31 22:57:02 +08:00
parent 85cc22f901
commit c03f8c3893
27 changed files with 423 additions and 340 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
---
"@fumadocs/base-ui": patch
"@fumadocs/ui": patch
'@fumadocs/base-ui': patch
'@fumadocs/ui': patch
---
support `.core` exports for dynamic codeblocks
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': minor
---
Support `/ui/base` for reduced bundle size
+4 -4
View File
@@ -1,7 +1,7 @@
import * as Base from 'fumadocs-ui/components/codeblock';
import { getHighlighter, hastToJsx } from 'fumadocs-core/highlight';
import { cn } from '@/lib/cn';
import type { BundledLanguage } from 'shiki';
import { getHighlighter, hastToJsx } from 'fumadocs-core/highlight/core';
import { shikiConfig } from '@/lib/shiki';
export interface CodeBlockProps {
code: string;
@@ -9,13 +9,13 @@ export interface CodeBlockProps {
lang: string;
}
const highlighter = await getHighlighter('js', {
const highlighter = await getHighlighter(shikiConfig, {
langs: ['js', 'ts', 'jsx', 'tsx'],
themes: ['vesper', 'github-light'],
});
export async function CodeBlock({ code, lang, wrapper }: CodeBlockProps) {
await highlighter.loadLanguage(lang as BundledLanguage);
await highlighter.loadLanguage(lang as never);
const hast = highlighter.codeToHast(code, {
lang,
defaultColor: false,
+3
View File
@@ -0,0 +1,3 @@
import { withJSEngine } from 'fumadocs-core/highlight/full/config';
export const shikiConfig = withJSEngine;
+10 -2
View File
@@ -1,4 +1,9 @@
import type { BundledHighlighterOptions, BundledLanguage, BundledTheme } from 'shiki';
import type {
BundledLanguage,
BundledTheme,
LanguageRegistration,
ThemeRegistrationAny,
} from 'shiki';
import type { ReactNode } from 'react';
import type { Root } from 'hast';
import type { DistributiveOmit } from '@/types';
@@ -30,7 +35,10 @@ export async function highlightHast(code: string, options: HighlightOptions): Pr
*/
export async function getHighlighter(
engineType: 'js' | 'oniguruma',
options: Omit<BundledHighlighterOptions<BundledLanguage, BundledTheme>, 'engine' | 'langAlias'>,
options?: {
langs?: (BundledLanguage | LanguageRegistration)[];
themes?: (BundledTheme | ThemeRegistrationAny)[];
},
) {
return base.getHighlighter(engineType === 'js' ? withJSEngine : withWASMEngine, options);
}
@@ -1,4 +1,4 @@
import { type ShikiTransformer } from 'shiki';
import type { ShikiTransformer } from 'shiki';
export type CodeBlockIcon =
| {
@@ -14,15 +14,15 @@
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
"lucide-react": "^0.563.0",
"next": "16.1.4",
"react": "^19.2.3",
"react-dom": "^19.2.3"
"next": "16.1.6",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/node": "^25.0.10",
"@types/react": "^19.2.9",
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.6",
"serve": "^14.2.5",
@@ -13,16 +13,16 @@
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
"lucide-react": "^0.563.0",
"next": "16.1.4",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"next": "16.1.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/node": "^25.0.10",
"@types/react": "^19.2.9",
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
@@ -10,27 +10,27 @@
},
"dependencies": {
"@orama/orama": "^3.1.18",
"@react-router/node": "^7.12.0",
"@react-router/node": "^7.13.0",
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
"isbot": "^5.1.33",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-router": "^7.12.0"
"isbot": "^5.1.34",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router": "^7.13.0"
},
"devDependencies": {
"@react-router/dev": "^7.12.0",
"@react-router/dev": "^7.13.0",
"@tailwindcss/vite": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/node": "^25.0.10",
"@types/react": "^19.2.9",
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"react-router-devtools": "^6.2.0",
"serve": "^14.2.5",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-tsconfig-paths": "^6.0.4"
"vite-tsconfig-paths": "^6.0.5"
}
}
@@ -9,27 +9,27 @@
"types:check": "react-router typegen && fumadocs-mdx && tsc --noEmit"
},
"dependencies": {
"@react-router/node": "^7.12.0",
"@react-router/serve": "^7.12.0",
"@react-router/node": "^7.13.0",
"@react-router/serve": "^7.13.0",
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
"isbot": "^5.1.33",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-router": "^7.12.0"
"isbot": "^5.1.34",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router": "^7.13.0"
},
"devDependencies": {
"@react-router/dev": "^7.12.0",
"@react-router/dev": "^7.13.0",
"@tailwindcss/vite": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/node": "^25.0.10",
"@types/react": "^19.2.9",
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"react-router-devtools": "^6.2.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-tsconfig-paths": "^6.0.4"
"vite-tsconfig-paths": "^6.0.5"
}
}
@@ -7,33 +7,35 @@
"dev": "vite dev",
"build": "vite build",
"start": "serve dist/client",
"preview": "vite preview",
"types:check": "fumadocs-mdx && tsc --noEmit"
},
"dependencies": {
"@orama/orama": "^3.1.18",
"@tanstack/react-router": "1.154.13",
"@tanstack/react-router-devtools": "1.154.13",
"@tanstack/react-start": "1.154.13",
"@tanstack/start-static-server-functions": "1.154.13",
"@tanstack/react-router": "1.157.16",
"@tanstack/react-router-devtools": "1.157.16",
"@tanstack/react-start": "1.157.16",
"@tanstack/start-static-server-functions": "1.157.16",
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
"lucide-react": "^0.563.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"tailwind-merge": "^3.4.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"vite": "^7.3.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/node": "^25.0.10",
"@types/react": "^19.2.9",
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"nitro": "npm:[email protected]",
"serve": "^14.2.5",
"srvx": "^0.10.1",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^6.0.4"
"vite-tsconfig-paths": "^6.0.5"
}
}
@@ -4,6 +4,7 @@ import { defineConfig } from 'vite';
import tsConfigPaths from 'vite-tsconfig-paths';
import tailwindcss from '@tailwindcss/vite';
import mdx from 'fumadocs-mdx/vite';
import { nitro } from 'nitro/vite';
export default defineConfig({
server: {
@@ -35,5 +36,7 @@ export default defineConfig({
],
}),
react(),
// please see https://tanstack.com/start/latest/docs/framework/react/guide/hosting#nitro for guides on hosting
nitro(),
],
});
@@ -7,30 +7,32 @@
"dev": "vite dev",
"build": "vite build",
"start": "node .output/server/index.mjs",
"preview": "vite preview",
"types:check": "fumadocs-mdx && tsc --noEmit"
},
"dependencies": {
"@tanstack/react-router": "1.154.13",
"@tanstack/react-router-devtools": "1.154.13",
"@tanstack/react-start": "1.154.13",
"@tanstack/react-router": "1.157.16",
"@tanstack/react-router-devtools": "1.157.16",
"@tanstack/react-start": "1.157.16",
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
"lucide-react": "^0.563.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"tailwind-merge": "^3.4.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"vite": "^7.3.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/node": "^25.0.10",
"@types/react": "^19.2.9",
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"nitro": "npm:[email protected]",
"srvx": "^0.10.1",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^6.0.4"
"vite-tsconfig-paths": "^6.0.5"
}
}
@@ -4,6 +4,7 @@ import { defineConfig } from 'vite';
import tsConfigPaths from 'vite-tsconfig-paths';
import tailwindcss from '@tailwindcss/vite';
import mdx from 'fumadocs-mdx/vite';
import { nitro } from 'nitro/vite';
export default defineConfig({
server: {
@@ -21,5 +22,12 @@ export default defineConfig({
},
}),
react(),
// please see https://tanstack.com/start/latest/docs/framework/react/guide/hosting#nitro for guides on hosting
nitro({
preset: 'vercel',
vercel: {
entryFormat: 'node',
},
}),
],
});
@@ -15,20 +15,20 @@
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
"lucide-react": "^0.563.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-server-dom-webpack": "^19.2.3",
"waku": "1.0.0-alpha.2"
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-server-dom-webpack": "^19.2.4",
"waku": "1.0.0-alpha.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/node": "^25.0.10",
"@types/react": "^19.2.9",
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-tsconfig-paths": "^6.0.4"
"vite-tsconfig-paths": "^6.0.5"
}
}
+6 -2
View File
@@ -27,6 +27,10 @@
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.js"
},
"./ui/base": {
"types": "./dist/ui/base.d.ts",
"import": "./dist/ui/base.js"
},
"./ui/client": {
"types": "./dist/ui/client/index.d.ts",
"import": "./dist/ui/client/index.js"
@@ -99,8 +103,8 @@
"peerDependencies": {
"@scalar/api-client-react": "*",
"@types/react": "*",
"fumadocs-core": "^16.2.0",
"fumadocs-ui": "^16.2.0",
"fumadocs-core": "^16.5.0",
"fumadocs-ui": "^16.5.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
+1 -1
View File
@@ -15,7 +15,7 @@ import type { FetchResult } from '@/playground/fetcher';
import type { ParameterField, SecurityEntry } from '@/playground/index';
import { getStatusInfo } from './status-info';
import { joinURL, resolveRequestData, resolveServerUrl, withBase } from '@/utils/url';
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock.core';
import { MethodLabel } from '@/ui/components/method-label';
import { useQuery } from '@/utils/use-query';
import {
+4 -2
View File
@@ -4,7 +4,7 @@ import type { NoReference } from '@/utils/schema';
import type { ProcessedDocument } from '@/utils/process-document';
import type { MediaAdapter } from '@/requests/media/adapter';
import type { OpenAPIOptions } from '@/server';
import type { CreateAPIPageOptions } from './ui/api-page';
import type { CreateAPIPageOptions } from './ui/base';
import type { CodeUsageGenerator } from './ui/operation/usage-tabs';
import type { HTMLAttributes, ReactNode } from 'react';
@@ -27,7 +27,8 @@ export type MethodInformation = NoReference<OperationObject> & {
'x-exclusiveCodeSample'?: string;
};
export interface RenderContext extends Pick<OpenAPIOptions, 'proxyUrl'>, CreateAPIPageOptions {
export interface RenderContext
extends Pick<OpenAPIOptions, 'proxyUrl'>, Omit<CreateAPIPageOptions, 'renderMarkdown'> {
servers: NoReference<ServerObject>[];
slugger: Slugger;
@@ -48,3 +49,4 @@ export interface RenderContext extends Pick<OpenAPIOptions, 'proxyUrl'>, CreateA
}
export type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
export type Awaitable<T> = T | Promise<T>;
+3 -258
View File
@@ -1,175 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- rehype-react without types */
import Slugger from 'github-slugger';
import { Operation } from '@/ui/operation';
import type { DistributiveOmit, MethodInformation, RenderContext } from '@/types';
import { createMethod, type NoReference } from '@/utils/schema';
import type { RenderContext } from '@/types';
import { createMethod } from '@/utils/schema';
import type { OpenAPIV3_1 } from 'openapi-types';
import type { ProcessedDocument } from '@/utils/process-document';
import { defaultAdapters, MediaAdapter } from '@/requests/media/adapter';
import type { FC, ReactNode } from 'react';
import { highlight, type HighlightOptions } from 'fumadocs-core/highlight';
import type { OpenAPIServer } from '@/server';
import type { APIPageClientOptions } from './client';
import type { CodeUsageGenerator } from './operation/usage-tabs';
import { ApiProviderLazy } from './contexts/api.lazy';
import { Heading } from 'fumadocs-ui/components/heading';
import { rehypeCode, type RehypeCodeOptions } from 'fumadocs-core/mdx-plugins/rehype-code';
import { remarkGfm } from 'fumadocs-core/mdx-plugins/remark-gfm';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import { remark } from 'remark';
import remarkRehype from 'remark-rehype';
import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
import * as JsxRuntime from 'react/jsx-runtime';
import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock';
import type { SchemaUIOptions } from './schema';
import type { ResponseTab } from './operation/response-tabs';
import type { ExampleRequestItem } from './operation/request-tabs';
type Awaitable<T> = T | Promise<T>;
export interface CreateAPIPageOptions {
/**
* 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 example code usage for endpoints.
*/
generateCodeSamples?: (method: MethodInformation) => Awaitable<CodeUsageGenerator[]>;
shikiOptions?: DistributiveOmit<HighlightOptions, 'lang' | 'components'>;
/**
* Show full response schema instead of only example response & Typescript definitions.
*
* @default true
*/
showResponseSchema?: boolean;
/**
* Support other media types (for server-side generation).
*/
mediaAdapters?: Record<string, MediaAdapter>;
/**
* Customise page content
*/
content?: {
renderResponseTabs?: (tabs: ResponseTab[], ctx: RenderContext) => Awaitable<ReactNode>;
renderRequestTabs?: (
items: ExampleRequestItem[],
ctx: RenderContext & {
route: string;
operation: NoReference<MethodInformation>;
},
) => Awaitable<ReactNode>;
renderAPIExampleLayout?: (
slots: {
selector: ReactNode;
usageTabs: ReactNode;
responseTabs: ReactNode;
},
ctx: RenderContext,
) => Awaitable<ReactNode>;
/**
* @param generators - codegens for API example usages
*/
renderAPIExampleUsageTabs?: (
generators: CodeUsageGenerator[],
ctx: RenderContext,
) => Awaitable<ReactNode>;
/**
* renderer of the entire page's layout (containing all operations & webhooks UI)
*/
renderPageLayout?: (
slots: {
operations?: {
item: OperationItem;
children: ReactNode;
}[];
webhooks?: {
item: WebhookItem;
children: ReactNode;
}[];
},
ctx: RenderContext,
) => Awaitable<ReactNode>;
renderOperationLayout?: (
slots: {
header: ReactNode;
description: ReactNode;
apiExample: ReactNode;
apiPlayground: ReactNode;
authSchemes: ReactNode;
paremeters: ReactNode;
body: ReactNode;
responses: ReactNode;
callbacks: ReactNode;
},
ctx: RenderContext,
method: NoReference<MethodInformation>,
) => Awaitable<ReactNode>;
renderWebhookLayout?: (slots: {
header: ReactNode;
description: ReactNode;
authSchemes: ReactNode;
paremeters: ReactNode;
body: ReactNode;
requests: ReactNode;
responses: ReactNode;
callbacks: ReactNode;
}) => Awaitable<ReactNode>;
};
/**
* Info UI for JSON schemas
*/
schemaUI?: {
render?: (options: SchemaUIOptions, ctx: RenderContext) => Awaitable<ReactNode>;
/**
* Show examples under the generated content of JSON schemas.
*
* @defaultValue false
*/
showExample?: boolean;
};
/**
* Customise API playground
*/
playground?: {
/**
* @defaultValue true
*/
enabled?: boolean;
/**
* replace the server-side renderer
*/
render?: (props: {
path: string;
method: MethodInformation;
ctx: RenderContext;
}) => Awaitable<ReactNode>;
};
client?: APIPageClientOptions;
}
export interface ApiPageProps {
document: Promise<ProcessedDocument> | string | ProcessedDocument;
@@ -203,96 +37,7 @@ export interface OperationItem {
method: OpenAPIV3_1.HttpMethods;
}
export function createAPIPage(
server: OpenAPIServer,
options: CreateAPIPageOptions = {},
): FC<ApiPageProps> {
let processor: ReturnType<typeof createMarkdownProcessor>;
function createMarkdownProcessor() {
function rehypeReact(this: any) {
this.compiler = (tree: any, file: any) => {
return toJsxRuntime(tree, {
development: false,
filePath: file.path,
...JsxRuntime,
components: defaultMdxComponents,
});
};
}
return remark()
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeCode, {
langs: [],
lazy: true,
} satisfies Partial<RehypeCodeOptions>)
.use(rehypeReact);
}
return async function APIPageWrapper({ document, ...props }) {
let processed: ProcessedDocument;
if (typeof document === 'string') {
processed = await server.getSchema(document);
} else {
processed = await document;
}
const { dereferenced } = processed;
const servers =
dereferenced.servers && dereferenced.servers.length > 0
? dereferenced.servers
: [{ url: '/' }];
const slugger = new Slugger();
const ctx: RenderContext = {
schema: processed,
proxyUrl: server.options.proxyUrl,
...options,
servers,
mediaAdapters: {
...defaultAdapters,
...options.mediaAdapters,
},
slugger,
renderHeading(depth, text, props) {
const id = slugger.slug(text);
return (
<Heading id={id} key={id} as={`h${depth}` as `h1`} {...props}>
{text}
</Heading>
);
},
async renderMarkdown(text) {
processor ??= createMarkdownProcessor();
const out = await processor.process({
value: text,
});
return out.result as ReactNode;
},
async renderCodeBlock(lang, code) {
const rendered = await highlight(code, {
lang,
...options.shikiOptions,
components: {
pre: (props) => <Pre {...props} />,
},
});
return <CodeBlock className="my-0">{rendered}</CodeBlock>;
},
};
return <APIPage {...props} ctx={ctx} />;
};
}
async function APIPage({
export async function APIPage({
showTitle: hasHead = false,
showDescription,
operations,
+267
View File
@@ -0,0 +1,267 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- rehype-react without types */
import Slugger from 'github-slugger';
import type { Awaitable, DistributiveOmit, MethodInformation, RenderContext } from '@/types';
import type { NoReference } from '@/utils/schema';
import type { ProcessedDocument } from '@/utils/process-document';
import { defaultAdapters, MediaAdapter } from '@/requests/media/adapter';
import type { FC, ReactNode } from 'react';
import { highlight, type CoreHighlightOptions } from 'fumadocs-core/highlight/core';
import type { OpenAPIServer } from '@/server';
import type { APIPageClientOptions } from './client';
import type { CodeUsageGenerator } from './operation/usage-tabs';
import { Heading } from 'fumadocs-ui/components/heading';
import { createRehypeCode } from 'fumadocs-core/mdx-plugins/rehype-code.min';
import { remarkGfm } from 'fumadocs-core/mdx-plugins/remark-gfm';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import { remark } from 'remark';
import remarkRehype from 'remark-rehype';
import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
import * as JsxRuntime from 'react/jsx-runtime';
import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock';
import type { SchemaUIOptions } from './schema';
import type { ResponseTab } from './operation/response-tabs';
import type { ExampleRequestItem } from './operation/request-tabs';
import type { ResolvedShikiConfig } from 'fumadocs-core/highlight/config';
import { APIPage, type ApiPageProps, type OperationItem, type WebhookItem } from './api-page';
export interface CreateAPIPageOptions {
/**
* 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,
contentType: string,
ctx: RenderContext,
) => Awaitable<string | undefined>)
| false;
/**
* Generate example code usage for endpoints.
*/
generateCodeSamples?: (method: MethodInformation) => Awaitable<CodeUsageGenerator[]>;
shiki: ResolvedShikiConfig;
renderMarkdown?: (md: string) => Awaitable<ReactNode>;
shikiOptions?: DistributiveOmit<CoreHighlightOptions, 'config' | 'lang' | 'components'>;
/**
* Show full response schema instead of only example response & Typescript definitions.
*
* @default true
*/
showResponseSchema?: boolean;
/**
* Support other media types (for server-side generation).
*/
mediaAdapters?: Record<string, MediaAdapter>;
/**
* Customise page content
*/
content?: {
renderResponseTabs?: (tabs: ResponseTab[], ctx: RenderContext) => Awaitable<ReactNode>;
renderRequestTabs?: (
items: ExampleRequestItem[],
ctx: RenderContext & {
route: string;
operation: NoReference<MethodInformation>;
},
) => Awaitable<ReactNode>;
renderAPIExampleLayout?: (
slots: {
selector: ReactNode;
usageTabs: ReactNode;
responseTabs: ReactNode;
},
ctx: RenderContext,
) => Awaitable<ReactNode>;
/**
* @param generators - codegens for API example usages
*/
renderAPIExampleUsageTabs?: (
generators: CodeUsageGenerator[],
ctx: RenderContext,
) => Awaitable<ReactNode>;
/**
* renderer of the entire page's layout (containing all operations & webhooks UI)
*/
renderPageLayout?: (
slots: {
operations?: {
item: OperationItem;
children: ReactNode;
}[];
webhooks?: {
item: WebhookItem;
children: ReactNode;
}[];
},
ctx: RenderContext,
) => Awaitable<ReactNode>;
renderOperationLayout?: (
slots: {
header: ReactNode;
description: ReactNode;
apiExample: ReactNode;
apiPlayground: ReactNode;
authSchemes: ReactNode;
paremeters: ReactNode;
body: ReactNode;
responses: ReactNode;
callbacks: ReactNode;
},
ctx: RenderContext,
method: NoReference<MethodInformation>,
) => Awaitable<ReactNode>;
renderWebhookLayout?: (slots: {
header: ReactNode;
description: ReactNode;
authSchemes: ReactNode;
paremeters: ReactNode;
body: ReactNode;
requests: ReactNode;
responses: ReactNode;
callbacks: ReactNode;
}) => Awaitable<ReactNode>;
};
/**
* Info UI for JSON schemas
*/
schemaUI?: {
render?: (options: SchemaUIOptions, ctx: RenderContext) => Awaitable<ReactNode>;
/**
* Show examples under the generated content of JSON schemas.
*
* @defaultValue false
*/
showExample?: boolean;
};
/**
* Customise API playground
*/
playground?: {
/**
* @defaultValue true
*/
enabled?: boolean;
/**
* replace the server-side renderer
*/
render?: (props: {
path: string;
method: MethodInformation;
ctx: RenderContext;
}) => Awaitable<ReactNode>;
};
client?: APIPageClientOptions;
}
export function createAPIPage(
server: OpenAPIServer,
options: CreateAPIPageOptions,
): FC<ApiPageProps> {
let processor: ReturnType<typeof createMarkdownProcessor>;
function createMarkdownProcessor() {
function rehypeReact(this: any) {
this.compiler = (tree: any, file: any) => {
return toJsxRuntime(tree, {
development: false,
filePath: file.path,
...JsxRuntime,
components: defaultMdxComponents,
});
};
}
return remark()
.use(remarkGfm)
.use(remarkRehype)
.use(createRehypeCode(options.shiki), {
langs: [],
lazy: true,
})
.use(rehypeReact);
}
return async function APIPageWrapper({ document, ...props }) {
let processed: ProcessedDocument;
if (typeof document === 'string') {
processed = await server.getSchema(document);
} else {
processed = await document;
}
const { dereferenced } = processed;
const servers =
dereferenced.servers && dereferenced.servers.length > 0
? dereferenced.servers
: [{ url: '/' }];
const slugger = new Slugger();
const ctx: RenderContext = {
schema: processed,
proxyUrl: server.options.proxyUrl,
...options,
servers,
mediaAdapters: {
...defaultAdapters,
...options.mediaAdapters,
},
slugger,
renderHeading(depth, text, props) {
const id = slugger.slug(text);
return (
<Heading id={id} key={id} as={`h${depth}` as `h1`} {...props}>
{text}
</Heading>
);
},
async renderMarkdown(text) {
if (options.renderMarkdown) return options.renderMarkdown(text);
processor ??= createMarkdownProcessor();
const out = await processor.process({
value: text,
});
return out.result as ReactNode;
},
async renderCodeBlock(lang, code) {
const rendered = await highlight(code, {
lang,
...options.shikiOptions,
config: options.shiki,
components: {
pre: (props) => <Pre {...props} />,
},
});
return <CodeBlock className="my-0">{rendered}</CodeBlock>;
},
};
return <APIPage {...props} ctx={ctx} />;
};
}
+9
View File
@@ -0,0 +1,9 @@
'use client';
import * as base from 'fumadocs-core/highlight/core/client';
import { withJSEngine } from 'fumadocs-core/highlight/full/config';
import type { ReactNode } from 'react';
export function ShikiConfigProvider({ children }: { children: ReactNode }) {
const config = base.useShikiConfigOptional() ?? withJSEngine;
return <base.ShikiConfigProvider config={config}>{children}</base.ShikiConfigProvider>;
}
+28
View File
@@ -0,0 +1,28 @@
import type { OpenAPIServer } from '@/server';
import * as base from './base';
import { withJSEngine } from 'fumadocs-core/highlight/full/config';
import { getTypescriptSchema } from '@/utils/get-typescript-schema';
import { ApiPageProps } from './api-page';
import { ShikiConfigProvider } from './full.client';
export type CreateAPIPageOptions = Partial<base.CreateAPIPageOptions>;
export function createAPIPage(server: OpenAPIServer, options: CreateAPIPageOptions = {}) {
const APIPage = base.createAPIPage(server, {
...options,
shiki: withJSEngine,
generateTypeScriptSchema(method, statusCode, contentType, ctx) {
const schema = method.responses?.[statusCode]?.content?.[contentType];
if (!schema) return;
return getTypescriptSchema(schema, ctx);
},
});
return function APIPageFull(props: ApiPageProps) {
return (
<ShikiConfigProvider>
<APIPage {...props} />
</ShikiConfigProvider>
);
};
}
+1 -1
View File
@@ -1 +1 @@
export * from '@/ui/api-page';
export * from './full';
+2 -6
View File
@@ -10,7 +10,6 @@ import { idToTitle } from '@/utils/id-to-title';
import { Schema } from '../schema';
import { UsageTabs } from '@/ui/operation/usage-tabs';
import { MethodLabel } from '@/ui/components/method-label';
import { getTypescriptSchema } from '@/utils/get-typescript-schema';
import { CopyResponseTypeScript, SelectTab, SelectTabs, SelectTabTrigger } from './client';
import {
AccordionContent,
@@ -338,7 +337,6 @@ async function ResponseAccordion({
ctx: RenderContext;
}) {
const response = operation.responses![status];
const { generateTypeScriptSchema } = ctx;
const contentTypes = response.content ? Object.entries(response.content) : [];
let wrapper = (children: ReactNode) => children;
let selectorNode: ReactNode = null;
@@ -372,10 +370,8 @@ async function ResponseAccordion({
const schema = resType.schema;
let ts: string | undefined;
if (generateTypeScriptSchema) {
ts = await generateTypeScriptSchema(operation, status);
} else if (generateTypeScriptSchema === undefined && schema) {
ts = await getTypescriptSchema(schema, ctx);
if (ctx.generateTypeScriptSchema) {
ts = await ctx.generateTypeScriptSchema(operation, status, type, ctx);
}
return (
@@ -8,7 +8,7 @@ import {
SelectContent,
SelectItem,
} from '@/ui/components/select';
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock.core';
import { useState, useEffect, useMemo, createContext, ReactNode, useRef, use } from 'react';
import type { CodeUsageGenerator } from '.';
import type { ExampleRequestItem } from '../request-tabs';
+1
View File
@@ -6,6 +6,7 @@ export default defineConfig({
entry: [
'./src/index.ts',
'./src/ui/index.ts',
'./src/ui/base.tsx',
'./src/ui/client/index.tsx',
'./src/playground/{index,client}.tsx',
'./src/scalar/index.tsx',
+5 -5
View File
@@ -3,12 +3,12 @@ import type { Code } from 'mdast';
import { fromMarkdown } from 'mdast-util-from-markdown';
import { gfmFromMarkdown } from 'mdast-util-gfm';
import { defaultHandlers, toHast } from 'mdast-util-to-hast';
import {
ShikiError,
type ShikiTransformer,
type ShikiTransformerContext,
type ShikiTransformerContextCommon,
import type {
ShikiTransformer,
ShikiTransformerContext,
ShikiTransformerContextCommon,
} from 'shiki';
import { ShikiError } from 'shiki/core';
import {
createTransformerFactory,
rendererRich,