mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
OpenAPI: Add Source API methods & Dynamic Source support to server
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': patch
|
||||
---
|
||||
|
||||
Add Source API methods & Dynamic Source support to OpenAPI server
|
||||
@@ -115,7 +115,6 @@ You can also use it without generating real files by integrating into [Loader AP
|
||||
|
||||
```ts title="lib/source.ts"
|
||||
import { loader } from 'fumadocs-core/source';
|
||||
import { openapiPlugin, openapiSource } from 'fumadocs-openapi/server';
|
||||
import { docs } from 'collections/server';
|
||||
import { openapi } from '@/lib/openapi';
|
||||
|
||||
@@ -123,19 +122,19 @@ export const source = loader(
|
||||
// [!code ++:6]
|
||||
{
|
||||
docs: docs.toFumadocsSource(),
|
||||
openapi: await openapiSource(openapi, {
|
||||
openapi: await openapi.staticSource({
|
||||
baseDir: 'openapi',
|
||||
}),
|
||||
},
|
||||
{
|
||||
baseUrl: '/docs',
|
||||
plugins: [openapiPlugin()],
|
||||
plugins: [openapi.loaderPlugin()],
|
||||
// ...
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
`openapiSource()` is a server-side API that generates pages directly to your `loader()`, hence it allows dynamic content generation, such as re-generating page tree as schema changes.
|
||||
`staticSource()` is a server-side API that generates pages directly to your `loader()`, hence it allows dynamic content generation, such as re-generating page tree as schema changes.
|
||||
|
||||
**It will change the type of your pages**, make sure to update all references to your `source`. For example, in your page component:
|
||||
|
||||
|
||||
@@ -62,7 +62,6 @@ You can generate pages dynamically by integrating into [Loader API](/docs/headle
|
||||
|
||||
```ts title="lib/source.ts"
|
||||
import { loader } from 'fumadocs-core/source';
|
||||
import { openapiPlugin, openapiSource } from 'fumadocs-openapi/server';
|
||||
import { docs } from 'collections/server';
|
||||
import { openapi } from '@/lib/openapi';
|
||||
|
||||
@@ -70,13 +69,13 @@ export const source = loader(
|
||||
// [!code ++:6]
|
||||
{
|
||||
docs: docs.toFumadocsSource(),
|
||||
openapi: await openapiSource(openapi, {
|
||||
openapi: await openapi.staticSource({
|
||||
baseDir: 'openapi',
|
||||
}),
|
||||
},
|
||||
{
|
||||
baseUrl: '/docs',
|
||||
plugins: [openapiPlugin()],
|
||||
plugins: [openapi.loaderPlugin()],
|
||||
// ...
|
||||
},
|
||||
);
|
||||
@@ -84,7 +83,7 @@ export const source = loader(
|
||||
|
||||
### Update References to `source` [step]
|
||||
|
||||
`openapiSource()` is a server-side API that generates pages directly to your `loader()`, hence allowing dynamic generation (e.g. different page tree as schema changes).
|
||||
`staticSource()` is a server-side API that generates pages directly to your `loader()`, hence allowing dynamic generation (e.g. different page tree as schema changes).
|
||||
|
||||
But **it will change the type of your pages**, explicit handling of OpenAPI pages is necessary.
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ Pass a record of source objects instead
|
||||
|
||||
```ts
|
||||
import { loader } from 'fumadocs-core/source';
|
||||
import { openapiPlugin, openapiSource } from 'fumadocs-openapi/server';
|
||||
import { blog, docs } from 'collections/server';
|
||||
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { type LoaderPlugin, loader } from 'fumadocs-core/source';
|
||||
import { openapiPlugin, openapiSource } from 'fumadocs-openapi/server';
|
||||
import { blog as blogPosts, docs } from 'collections/server';
|
||||
import { toFumadocsSource } from 'fumadocs-mdx/runtime/server';
|
||||
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
|
||||
@@ -8,7 +7,7 @@ import { openapi } from '@/lib/openapi';
|
||||
export const source = loader(
|
||||
{
|
||||
docs: docs.toFumadocsSource(),
|
||||
openapi: await openapiSource(openapi, {
|
||||
openapi: await openapi.staticSource({
|
||||
baseDir: 'openapi/(generated)',
|
||||
meta: {
|
||||
folderStyle: 'separator',
|
||||
@@ -18,7 +17,7 @@ export const source = loader(
|
||||
},
|
||||
{
|
||||
baseUrl: '/docs',
|
||||
plugins: [pageTreeCodeTitles(), lucideIconsPlugin(), openapiPlugin()],
|
||||
plugins: [pageTreeCodeTitles(), lucideIconsPlugin(), openapi.loaderPlugin()],
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { loader } from 'fumadocs-core/source';
|
||||
import { openapiPlugin, openapiSource } from 'fumadocs-openapi/server';
|
||||
import { docs } from 'collections/server';
|
||||
import { openapi } from './openapi';
|
||||
|
||||
export const source = loader(
|
||||
{
|
||||
docs: docs.toFumadocsSource(),
|
||||
openapi: await openapiSource(openapi, {
|
||||
openapi: await openapi.staticSource({
|
||||
groupBy: 'tag',
|
||||
}),
|
||||
},
|
||||
{
|
||||
baseUrl: '/docs',
|
||||
plugins: [openapiPlugin()],
|
||||
plugins: [openapi.loaderPlugin()],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,19 +2,18 @@ import { loader } from 'fumadocs-core/source';
|
||||
import { docs } from 'collections/server';
|
||||
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
|
||||
import { docsRoute } from './shared';
|
||||
import { openapiPlugin, openapiSource } from 'fumadocs-openapi/server';
|
||||
import { openapi } from './openapi';
|
||||
|
||||
export const source = loader(
|
||||
{
|
||||
docs: docs.toFumadocsSource(),
|
||||
openapi: await openapiSource(openapi, {
|
||||
openapi: await openapi.staticSource({
|
||||
baseDir: 'openapi',
|
||||
}),
|
||||
},
|
||||
{
|
||||
baseUrl: docsRoute,
|
||||
plugins: [lucideIconsPlugin(), openapiPlugin()],
|
||||
plugins: [lucideIconsPlugin(), openapi.loaderPlugin()],
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { createProxy } from '@/server/proxy';
|
||||
import { processDocument, type ProcessedDocument } from '@/utils/document/process';
|
||||
import type { Document } from '@/types';
|
||||
import type { InlineCodeUsageGenerator } from '@/requests/generators';
|
||||
import fs from 'node:fs';
|
||||
|
||||
/**
|
||||
* schema id -> file path, URL, or downloaded schema object
|
||||
*/
|
||||
type SchemaMap = Record<string, string | Document>;
|
||||
type ProcessedSchemaMap = Record<string, ProcessedDocument>;
|
||||
|
||||
export interface OpenAPIOptions {
|
||||
/**
|
||||
* Schema files, can be:
|
||||
* - URL
|
||||
* - file path
|
||||
* - a function returning records of downloaded schemas.
|
||||
*/
|
||||
input?: string[] | (() => SchemaMap | Promise<SchemaMap>);
|
||||
|
||||
disableCache?: boolean;
|
||||
|
||||
/**
|
||||
* The url of proxy to avoid CORS issues
|
||||
*/
|
||||
proxyUrl?: string;
|
||||
}
|
||||
|
||||
export interface OpenAPIServer {
|
||||
createProxy: typeof createProxy;
|
||||
getSchemas: () => Promise<ProcessedSchemaMap>;
|
||||
getSchema: (document: string) => Promise<ProcessedDocument>;
|
||||
/** @private internal API */
|
||||
_getWatchPaths: () => string[];
|
||||
readonly options: OpenAPIOptions;
|
||||
}
|
||||
|
||||
export function createOpenAPI(options: OpenAPIOptions = {}): OpenAPIServer {
|
||||
const { input = [], disableCache = false } = options;
|
||||
let schemas: Promise<ProcessedSchemaMap> | undefined;
|
||||
|
||||
async function getSchemas(): Promise<ProcessedSchemaMap> {
|
||||
if (Array.isArray(input)) {
|
||||
const entries = await Promise.all(
|
||||
input.map(async (item) => [item, await processDocument(item)]),
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
} else {
|
||||
const entries = await Promise.all(
|
||||
Object.entries(await input()).map(async ([k, v]) => [k, await processDocument(v)]),
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
options,
|
||||
createProxy,
|
||||
_getWatchPaths() {
|
||||
const keys = Array.isArray(input) ? input : Object.keys(input);
|
||||
return keys.filter((key) => !URL.canParse(key) && fs.existsSync(key));
|
||||
},
|
||||
async getSchema(document) {
|
||||
const schemas = await this.getSchemas();
|
||||
if (document in schemas) return schemas[document];
|
||||
|
||||
console.warn(
|
||||
`[Fumadocs OpenAPI] the document "${document}" is not listed in the input array, this may not be expected.`,
|
||||
);
|
||||
// do not cache unlisted documents
|
||||
return processDocument(document);
|
||||
},
|
||||
async getSchemas() {
|
||||
if (disableCache) return getSchemas();
|
||||
|
||||
return (schemas ??= getSchemas());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export function createCodeSample<T>(
|
||||
options: InlineCodeUsageGenerator<T>,
|
||||
): InlineCodeUsageGenerator<T> {
|
||||
return options;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './create';
|
||||
export * from './source-api';
|
||||
@@ -0,0 +1,341 @@
|
||||
import { createProxy } from '@/server/proxy';
|
||||
import { processDocument, type ProcessedDocument } from '@/utils/document/process';
|
||||
import type { Document } from '@/types';
|
||||
import type { InlineCodeUsageGenerator } from '@/requests/generators';
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
type DynamicSource,
|
||||
type LoaderPlugin,
|
||||
type PageData,
|
||||
type PageTreeTransformer,
|
||||
PathUtils,
|
||||
type MetaData,
|
||||
type Source,
|
||||
type VirtualFile,
|
||||
} from 'fumadocs-core/source';
|
||||
import {
|
||||
getPageProps,
|
||||
type OperationOutput,
|
||||
type OutputEntry,
|
||||
type PageOutput,
|
||||
type WebhookOutput,
|
||||
} from '@/utils/pages/builder';
|
||||
import { toStaticData } from '@/utils/pages/to-static-data';
|
||||
import path from 'node:path';
|
||||
import type { DereferencedDocument } from '@/utils/document/dereference';
|
||||
import type { ApiPageProps } from '@/ui/api-page';
|
||||
import type { ClientApiPageProps } from '@/ui/create-client';
|
||||
import type { StructuredData } from 'fumadocs-core/mdx-plugins/remark-structure';
|
||||
import type { TOCItemType } from 'fumadocs-core/toc';
|
||||
import type { SchemaToPagesOptions } from '@/utils/pages/preset-auto';
|
||||
import { MethodLabel } from '@/ui/components/method-label';
|
||||
|
||||
/**
|
||||
* schema id -> file path, URL, or downloaded schema object
|
||||
*/
|
||||
type SchemaMap = Record<string, string | Document>;
|
||||
type ProcessedSchemaMap = Record<string, ProcessedDocument>;
|
||||
|
||||
export interface OpenAPIOptions {
|
||||
/**
|
||||
* Schema files, can be:
|
||||
* - URL
|
||||
* - file path
|
||||
* - a function returning records of downloaded schemas.
|
||||
*/
|
||||
input?: string[] | (() => SchemaMap | Promise<SchemaMap>);
|
||||
|
||||
disableCache?: boolean;
|
||||
|
||||
/**
|
||||
* The url of proxy to avoid CORS issues
|
||||
*/
|
||||
proxyUrl?: string;
|
||||
}
|
||||
|
||||
export interface OpenAPIServer {
|
||||
createProxy: typeof createProxy;
|
||||
getSchemas: () => Promise<ProcessedSchemaMap>;
|
||||
getSchema: (document: string) => Promise<ProcessedDocument>;
|
||||
readonly options: OpenAPIOptions;
|
||||
|
||||
/**
|
||||
* Generate virtual pages for Fumadocs Source API
|
||||
*/
|
||||
staticSource: (
|
||||
options?: OpenAPISourceOptions,
|
||||
) => Promise<Source<{ metaData: MetaData; pageData: OpenAPIPageData }>>;
|
||||
|
||||
/**
|
||||
* Generate virtual pages for Fumadocs Source API
|
||||
*/
|
||||
dynamicSource: (
|
||||
options?: OpenAPISourceOptions,
|
||||
) => DynamicSource<{ metaData: MetaData; pageData: OpenAPIPageData }>;
|
||||
|
||||
/**
|
||||
* Fumadocs Source API integration, pass this to `plugins` array in `loader()`.
|
||||
*/
|
||||
loaderPlugin: () => LoaderPlugin;
|
||||
|
||||
/** @private internal API */
|
||||
_getWatchPaths: () => string[];
|
||||
}
|
||||
|
||||
export interface OpenAPIPageData extends PageData {
|
||||
getAPIPageProps: () => ApiPageProps;
|
||||
getSchema: () => { id: string } & DereferencedDocument;
|
||||
getClientAPIPageProps: () => Promise<ClientApiPageProps>;
|
||||
structuredData: StructuredData;
|
||||
toc: TOCItemType[];
|
||||
}
|
||||
|
||||
export type OpenAPISourceOptions = SchemaToPagesOptions & {
|
||||
baseDir?: string;
|
||||
/** Generate `meta.json` files */
|
||||
meta?: boolean | { folderStyle?: 'folder' | 'separator' };
|
||||
};
|
||||
|
||||
export function createOpenAPI(options: OpenAPIOptions = {}): OpenAPIServer {
|
||||
const { input = [], disableCache = false } = options;
|
||||
let schemas: Promise<ProcessedSchemaMap> | undefined;
|
||||
|
||||
async function getSchemas(): Promise<ProcessedSchemaMap> {
|
||||
if (Array.isArray(input)) {
|
||||
const entries = await Promise.all(
|
||||
input.map(async (item) => [item, await processDocument(item)]),
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
} else {
|
||||
const entries = await Promise.all(
|
||||
Object.entries(await input()).map(async ([k, v]) => [k, await processDocument(v)]),
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
}
|
||||
|
||||
async function getVirtualFiles(server: OpenAPIServer, options: OpenAPISourceOptions) {
|
||||
const { baseDir = '', meta = false } = options;
|
||||
const { createAutoPreset } = await import('@/utils/pages/preset-auto');
|
||||
const { fromServer } = await import('@/utils/pages/builder');
|
||||
const files: VirtualFile<{
|
||||
pageData: OpenAPIPageData;
|
||||
metaData: MetaData;
|
||||
}>[] = [];
|
||||
|
||||
const entries = await fromServer(server, createAutoPreset(options));
|
||||
for (const [schemaId, list] of Object.entries(entries)) {
|
||||
const processed = await server.getSchema(schemaId);
|
||||
|
||||
function onEntry(entry: PageOutput | OperationOutput | WebhookOutput) {
|
||||
const props = getPageProps(entry);
|
||||
|
||||
files.push({
|
||||
type: 'page',
|
||||
path: `${baseDir}/${entry.path}`,
|
||||
data: {
|
||||
...entry.info,
|
||||
getAPIPageProps() {
|
||||
return props;
|
||||
},
|
||||
async getClientAPIPageProps() {
|
||||
return {
|
||||
payload: {
|
||||
bundled: processed.bundled,
|
||||
proxyUrl: server.options.proxyUrl,
|
||||
},
|
||||
...props,
|
||||
};
|
||||
},
|
||||
getSchema() {
|
||||
return {
|
||||
id: schemaId,
|
||||
...processed,
|
||||
};
|
||||
},
|
||||
...toStaticData(props, processed.dereferenced),
|
||||
_openapi: {
|
||||
method:
|
||||
entry.type === 'operation' || entry.type === 'webhook'
|
||||
? entry.item.method
|
||||
: undefined,
|
||||
webhook: entry.type === 'webhook',
|
||||
deprecated: entry.info.deprecated,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onEntries(entries: OutputEntry[], parent?: OutputEntry) {
|
||||
if (!meta) {
|
||||
for (const entry of entries) {
|
||||
if (entry.type === 'group') {
|
||||
onEntries(entry.entries, entry);
|
||||
} else {
|
||||
onEntry(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { folderStyle = 'folder' } = meta === true ? {} : meta;
|
||||
const pages: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const relativePath = PathUtils.slash(
|
||||
parent ? path.relative(parent.path, entry.path) : entry.path,
|
||||
);
|
||||
|
||||
if (entry.type === 'group') {
|
||||
onEntries(entry.entries, entry);
|
||||
if (folderStyle === 'folder') {
|
||||
pages.push(relativePath);
|
||||
} else {
|
||||
pages.push(`---${entry.info.title}---`, `...${relativePath}`);
|
||||
}
|
||||
} else {
|
||||
onEntry(entry);
|
||||
pages.push(relativePath.slice(0, -path.extname(entry.path).length));
|
||||
}
|
||||
}
|
||||
|
||||
if (pages.length === 0) return;
|
||||
files.push({
|
||||
type: 'meta',
|
||||
path: path.join(baseDir, parent?.path ?? '', 'meta.json'),
|
||||
data: {
|
||||
title: parent?.info.title,
|
||||
description: parent?.info.description,
|
||||
pages,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onEntries(list);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
return {
|
||||
options,
|
||||
createProxy,
|
||||
_getWatchPaths() {
|
||||
const keys = Array.isArray(input) ? input : Object.keys(input);
|
||||
return keys.filter((key) => !URL.canParse(key) && fs.existsSync(key));
|
||||
},
|
||||
async getSchema(document) {
|
||||
const schemas = await this.getSchemas();
|
||||
if (document in schemas) return schemas[document];
|
||||
|
||||
console.warn(
|
||||
`[Fumadocs OpenAPI] the document "${document}" is not listed in the input array, this may not be expected.`,
|
||||
);
|
||||
// do not cache unlisted documents
|
||||
return processDocument(document);
|
||||
},
|
||||
async getSchemas() {
|
||||
if (disableCache) return getSchemas();
|
||||
|
||||
return (schemas ??= getSchemas());
|
||||
},
|
||||
async staticSource(options = {}) {
|
||||
return {
|
||||
files: await getVirtualFiles(this, options),
|
||||
};
|
||||
},
|
||||
dynamicSource(options = {}) {
|
||||
return {
|
||||
files: () => getVirtualFiles(this, options),
|
||||
};
|
||||
},
|
||||
loaderPlugin() {
|
||||
return openapiPlugin();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export function createCodeSample<T>(
|
||||
options: InlineCodeUsageGenerator<T>,
|
||||
): InlineCodeUsageGenerator<T> {
|
||||
return options;
|
||||
}
|
||||
|
||||
declare module 'fumadocs-core/source' {
|
||||
export interface PageData {
|
||||
/**
|
||||
* Added by Fumadocs OpenAPI
|
||||
*/
|
||||
_openapi?: InternalOpenAPIMeta;
|
||||
}
|
||||
}
|
||||
|
||||
export interface InternalOpenAPIMeta {
|
||||
method?: string;
|
||||
webhook?: boolean;
|
||||
deprecated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fumadocs Source API integration, pass this to `plugins` array in `loader()`.
|
||||
*/
|
||||
export function openapiPlugin(): LoaderPlugin {
|
||||
return {
|
||||
name: 'fumadocs:openapi',
|
||||
enforce: 'pre',
|
||||
transformPageTree: {
|
||||
file(node, filePath) {
|
||||
if (!filePath) return node;
|
||||
const file = this.storage.read(filePath);
|
||||
if (!file || file.format !== 'page') return node;
|
||||
|
||||
const openApiData = file.data._openapi;
|
||||
if (!openApiData || typeof openApiData !== 'object') return node;
|
||||
|
||||
if (openApiData.deprecated) {
|
||||
node.name = <span className="fd-page-tree-item-name line-through">{node.name}</span>;
|
||||
}
|
||||
|
||||
if (openApiData.webhook) {
|
||||
node.name = (
|
||||
<>
|
||||
{node.name}{' '}
|
||||
<span className="ms-auto border border-current px-1 rounded-lg text-xs text-nowrap font-mono">
|
||||
Webhook
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
} else if (openApiData.method) {
|
||||
node.name = (
|
||||
<>
|
||||
{node.name}{' '}
|
||||
<MethodLabel className="ms-auto text-xs text-nowrap">
|
||||
{openApiData.method}
|
||||
</MethodLabel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return node;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate virtual pages for Fumadocs Source API
|
||||
*/
|
||||
export async function openapiSource(server: OpenAPIServer, options: OpenAPISourceOptions = {}) {
|
||||
return server.staticSource(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `openapiPlugin()`
|
||||
*/
|
||||
export function transformerOpenAPI(): PageTreeTransformer {
|
||||
return openapiPlugin().transformPageTree!;
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
import { MethodLabel } from '@/ui/components/method-label';
|
||||
import {
|
||||
PathUtils,
|
||||
type Source,
|
||||
type LoaderPlugin,
|
||||
type MetaData,
|
||||
type PageData,
|
||||
type PageTreeTransformer,
|
||||
type VirtualFile,
|
||||
} from 'fumadocs-core/source';
|
||||
import type { OpenAPIServer } from '@/server/create';
|
||||
import type { SchemaToPagesOptions } from '@/utils/pages/preset-auto';
|
||||
import type { ApiPageProps } from '@/ui/api-page';
|
||||
import type { StructuredData } from 'fumadocs-core/mdx-plugins';
|
||||
import type { TOCItemType } from 'fumadocs-core/toc';
|
||||
import type { DereferencedDocument } from '@/utils/document/dereference';
|
||||
import type {
|
||||
OperationOutput,
|
||||
OutputEntry,
|
||||
PageOutput,
|
||||
WebhookOutput,
|
||||
} from '@/utils/pages/builder';
|
||||
import path from 'node:path';
|
||||
import type { ClientApiPageProps } from '@/ui/create-client';
|
||||
import { toStaticData } from '@/utils/pages/to-static-data';
|
||||
|
||||
declare module 'fumadocs-core/source' {
|
||||
export interface PageData {
|
||||
/**
|
||||
* Added by Fumadocs OpenAPI
|
||||
*/
|
||||
_openapi?: InternalOpenAPIMeta;
|
||||
}
|
||||
}
|
||||
|
||||
export interface InternalOpenAPIMeta {
|
||||
method?: string;
|
||||
webhook?: boolean;
|
||||
deprecated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fumadocs Source API integration, pass this to `plugins` array in `loader()`.
|
||||
*/
|
||||
export function openapiPlugin(): LoaderPlugin {
|
||||
return {
|
||||
name: 'fumadocs:openapi',
|
||||
enforce: 'pre',
|
||||
transformPageTree: {
|
||||
file(node, filePath) {
|
||||
if (!filePath) return node;
|
||||
const file = this.storage.read(filePath);
|
||||
if (!file || file.format !== 'page') return node;
|
||||
|
||||
const openApiData = file.data._openapi;
|
||||
if (!openApiData || typeof openApiData !== 'object') return node;
|
||||
|
||||
if (openApiData.deprecated) {
|
||||
node.name = <span className="fd-page-tree-item-name line-through">{node.name}</span>;
|
||||
}
|
||||
|
||||
if (openApiData.webhook) {
|
||||
node.name = (
|
||||
<>
|
||||
{node.name}{' '}
|
||||
<span className="ms-auto border border-current px-1 rounded-lg text-xs text-nowrap font-mono">
|
||||
Webhook
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
} else if (openApiData.method) {
|
||||
node.name = (
|
||||
<>
|
||||
{node.name}{' '}
|
||||
<MethodLabel className="ms-auto text-xs text-nowrap">
|
||||
{openApiData.method}
|
||||
</MethodLabel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return node;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenAPIPageData extends PageData {
|
||||
getAPIPageProps: () => ApiPageProps;
|
||||
getSchema: () => { id: string } & DereferencedDocument;
|
||||
getClientAPIPageProps: () => Promise<ClientApiPageProps>;
|
||||
structuredData: StructuredData;
|
||||
toc: TOCItemType[];
|
||||
}
|
||||
|
||||
export type OpenAPISourceOptions = SchemaToPagesOptions & {
|
||||
baseDir?: string;
|
||||
/** Generate `meta.json` files */
|
||||
meta?: boolean | { folderStyle?: 'folder' | 'separator' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate virtual pages for Fumadocs Source API
|
||||
*/
|
||||
export async function openapiSource(
|
||||
server: OpenAPIServer,
|
||||
options: OpenAPISourceOptions = {},
|
||||
): Promise<
|
||||
Source<{
|
||||
metaData: MetaData;
|
||||
pageData: OpenAPIPageData;
|
||||
}>
|
||||
> {
|
||||
const { baseDir = '', meta = false } = options;
|
||||
const { createAutoPreset } = await import('@/utils/pages/preset-auto');
|
||||
const { fromServer } = await import('@/utils/pages/builder');
|
||||
const files: VirtualFile<{
|
||||
pageData: OpenAPIPageData;
|
||||
metaData: MetaData;
|
||||
}>[] = [];
|
||||
|
||||
const entries = await fromServer(server, createAutoPreset(options));
|
||||
for (const [schemaId, list] of Object.entries(entries)) {
|
||||
const processed = await server.getSchema(schemaId);
|
||||
|
||||
function onEntry(entry: PageOutput | OperationOutput | WebhookOutput) {
|
||||
const props = getProps(entry);
|
||||
|
||||
files.push({
|
||||
type: 'page',
|
||||
path: `${baseDir}/${entry.path}`,
|
||||
data: {
|
||||
...entry.info,
|
||||
getAPIPageProps() {
|
||||
return props;
|
||||
},
|
||||
async getClientAPIPageProps() {
|
||||
return {
|
||||
payload: {
|
||||
bundled: processed.bundled,
|
||||
proxyUrl: server.options.proxyUrl,
|
||||
},
|
||||
...props,
|
||||
};
|
||||
},
|
||||
getSchema() {
|
||||
return {
|
||||
id: schemaId,
|
||||
...processed,
|
||||
};
|
||||
},
|
||||
...toStaticData(props, processed.dereferenced),
|
||||
_openapi: {
|
||||
method:
|
||||
entry.type === 'operation' || entry.type === 'webhook'
|
||||
? entry.item.method
|
||||
: undefined,
|
||||
webhook: entry.type === 'webhook',
|
||||
deprecated: entry.info.deprecated,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onEntries(entries: OutputEntry[], parent?: OutputEntry) {
|
||||
if (!meta) {
|
||||
for (const entry of entries) {
|
||||
if (entry.type === 'group') {
|
||||
onEntries(entry.entries, entry);
|
||||
} else {
|
||||
onEntry(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { folderStyle = 'folder' } = meta === true ? {} : meta;
|
||||
const pages: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const relativePath = PathUtils.slash(
|
||||
parent ? path.relative(parent.path, entry.path) : entry.path,
|
||||
);
|
||||
|
||||
if (entry.type === 'group') {
|
||||
onEntries(entry.entries, entry);
|
||||
if (folderStyle === 'folder') {
|
||||
pages.push(relativePath);
|
||||
} else {
|
||||
pages.push(`---${entry.info.title}---`, `...${relativePath}`);
|
||||
}
|
||||
} else {
|
||||
onEntry(entry);
|
||||
pages.push(relativePath.slice(0, -PathUtils.extname(entry.path).length));
|
||||
}
|
||||
}
|
||||
|
||||
if (pages.length === 0) return;
|
||||
files.push({
|
||||
type: 'meta',
|
||||
path: path.join(baseDir, parent?.path ?? '', 'meta.json'),
|
||||
data: {
|
||||
title: parent?.info.title,
|
||||
description: parent?.info.description,
|
||||
pages,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onEntries(list);
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
function getProps(entry: PageOutput | OperationOutput | WebhookOutput): ApiPageProps {
|
||||
if (entry.type === 'operation')
|
||||
return {
|
||||
document: entry.schemaId,
|
||||
operations: [entry.item],
|
||||
showDescription: true,
|
||||
};
|
||||
if (entry.type === 'webhook')
|
||||
return {
|
||||
document: entry.schemaId,
|
||||
webhooks: [entry.item],
|
||||
showDescription: true,
|
||||
};
|
||||
|
||||
return {
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
document: entry.schemaId,
|
||||
operations: entry.operations,
|
||||
webhooks: entry.webhooks,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `openapiPlugin()`
|
||||
*/
|
||||
export function transformerOpenAPI(): PageTreeTransformer {
|
||||
return openapiPlugin().transformPageTree!;
|
||||
}
|
||||
@@ -35,15 +35,7 @@ export function Badge({
|
||||
...props
|
||||
}: Omit<HTMLAttributes<HTMLSpanElement>, 'color'> & VariantProps<typeof badgeVariants>) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
badgeVariants({
|
||||
color,
|
||||
className,
|
||||
}),
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className={cn(badgeVariants({ color }), className)} {...props}>
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DereferencedDocument } from '@/utils/document/dereference';
|
||||
import type { OpenAPIServer } from '@/server';
|
||||
import type { OperationItem, WebhookItem } from '@/ui/api-page';
|
||||
import type { ApiPageProps, OperationItem, WebhookItem } from '@/ui/api-page';
|
||||
import type { OperationObject, PathItemObject, TagObject } from '@/types';
|
||||
import { getTagDisplayName, methodKeys, type NoReference } from '@/utils/schema';
|
||||
import { idToTitle } from '@/utils/id-to-title';
|
||||
@@ -227,3 +227,26 @@ export function fromSchema(
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export function getPageProps(entry: PageOutput | OperationOutput | WebhookOutput): ApiPageProps {
|
||||
if (entry.type === 'operation')
|
||||
return {
|
||||
document: entry.schemaId,
|
||||
operations: [entry.item],
|
||||
showDescription: true,
|
||||
};
|
||||
if (entry.type === 'webhook')
|
||||
return {
|
||||
document: entry.schemaId,
|
||||
webhooks: [entry.item],
|
||||
showDescription: true,
|
||||
};
|
||||
|
||||
return {
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
document: entry.schemaId,
|
||||
operations: entry.operations,
|
||||
webhooks: entry.webhooks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@ import type { DereferencedDocument } from '@/utils/document/dereference';
|
||||
import type { TagObject } from '@/types';
|
||||
import { dump } from 'js-yaml';
|
||||
import { removeUndefined } from '@/utils/remove-undefined';
|
||||
import type { OperationOutput, PageOutput, WebhookOutput } from '@/utils/pages/builder';
|
||||
import {
|
||||
getPageProps,
|
||||
type OperationOutput,
|
||||
type PageOutput,
|
||||
type WebhookOutput,
|
||||
} from '@/utils/pages/builder';
|
||||
import type { InternalOpenAPIMeta } from '@/server/source-api';
|
||||
import { toStaticData } from '@/utils/pages/to-static-data';
|
||||
import { doubleQuote } from '@/requests/string-utils';
|
||||
@@ -31,7 +36,7 @@ export interface PagesToTextOptions {
|
||||
/**
|
||||
* Add description to document body.
|
||||
*
|
||||
* We recommend but don't enable it by default because some OpenAPI schemas have invalid description that breaks MDX syntax.
|
||||
* We recommend to enable it, it is disabled by default because some OpenAPI schemas have invalid description that breaks Markdown syntax.
|
||||
*
|
||||
* @defaultValue false
|
||||
*/
|
||||
@@ -53,40 +58,49 @@ export function toText(
|
||||
processed: DereferencedDocument,
|
||||
options: PagesToTextOptions = {},
|
||||
) {
|
||||
switch (entry.type) {
|
||||
case 'operation':
|
||||
return generatePage(
|
||||
entry.schemaId,
|
||||
processed,
|
||||
{
|
||||
operations: [entry.item],
|
||||
},
|
||||
options,
|
||||
entry,
|
||||
);
|
||||
case 'page':
|
||||
return generatePage(
|
||||
entry.schemaId,
|
||||
processed,
|
||||
{
|
||||
operations: entry.operations,
|
||||
webhooks: entry.webhooks,
|
||||
showTitle: true,
|
||||
},
|
||||
options,
|
||||
entry,
|
||||
);
|
||||
case 'webhook':
|
||||
return generatePage(
|
||||
entry.schemaId,
|
||||
processed,
|
||||
{
|
||||
webhooks: [entry.item],
|
||||
},
|
||||
options,
|
||||
entry,
|
||||
);
|
||||
const { frontmatter, includeDescription = false } = options;
|
||||
const extend = frontmatter?.(
|
||||
entry.info.title,
|
||||
entry.info.description,
|
||||
entry.type === 'page'
|
||||
? entry.tag
|
||||
? { type: 'tag', tag: entry.tag }
|
||||
: { type: 'file' }
|
||||
: { type: 'operation' },
|
||||
);
|
||||
const pageProps = getPageProps(entry);
|
||||
if (!includeDescription) {
|
||||
pageProps.showDescription = false;
|
||||
}
|
||||
|
||||
let meta: InternalOpenAPIMeta | undefined;
|
||||
if (entry.type === 'operation' || entry.type === 'webhook') {
|
||||
const operation = entry.item;
|
||||
|
||||
meta = {
|
||||
method: operation.method.toUpperCase(),
|
||||
webhook: entry.type === 'webhook',
|
||||
deprecated: entry.info.deprecated,
|
||||
};
|
||||
}
|
||||
|
||||
const data = toStaticData(pageProps, processed.dereferenced);
|
||||
|
||||
return generateDocument(
|
||||
{
|
||||
title: entry.info.title,
|
||||
description: !includeDescription ? entry.info.description : undefined,
|
||||
full: true,
|
||||
...extend,
|
||||
_openapi: {
|
||||
...meta,
|
||||
...data,
|
||||
...(extend?._openapi as object | undefined),
|
||||
},
|
||||
},
|
||||
pageContent(pageProps),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function generateDocument(
|
||||
@@ -135,69 +149,6 @@ export type DocumentContext =
|
||||
type: 'file';
|
||||
};
|
||||
|
||||
function generatePage(
|
||||
schemaId: string,
|
||||
processed: DereferencedDocument,
|
||||
pageProps: Omit<ApiPageProps, 'document'>,
|
||||
options: PagesToTextOptions,
|
||||
entry: PageOutput | OperationOutput | WebhookOutput,
|
||||
): string {
|
||||
const { frontmatter, includeDescription = false } = options;
|
||||
const extend = frontmatter?.(
|
||||
entry.info.title,
|
||||
entry.info.description,
|
||||
entry.type === 'page'
|
||||
? entry.tag
|
||||
? { type: 'tag', tag: entry.tag }
|
||||
: { type: 'file' }
|
||||
: { type: 'operation' },
|
||||
);
|
||||
const page: ApiPageProps = {
|
||||
...pageProps,
|
||||
document: schemaId,
|
||||
};
|
||||
|
||||
let meta: InternalOpenAPIMeta | undefined;
|
||||
if (page.operations?.length === 1) {
|
||||
const operation = page.operations[0];
|
||||
|
||||
meta = {
|
||||
method: operation.method.toUpperCase(),
|
||||
deprecated: entry.info.deprecated,
|
||||
};
|
||||
} else if (page.webhooks?.length === 1) {
|
||||
const webhook = page.webhooks[0];
|
||||
|
||||
meta = {
|
||||
method: webhook.method.toUpperCase(),
|
||||
webhook: true,
|
||||
deprecated: entry.info.deprecated,
|
||||
};
|
||||
}
|
||||
|
||||
const data = toStaticData(page, processed.dereferenced);
|
||||
const content: string[] = [];
|
||||
|
||||
if (entry.info.description && includeDescription) content.push(entry.info.description);
|
||||
content.push(pageContent(page));
|
||||
|
||||
return generateDocument(
|
||||
{
|
||||
title: entry.info.title,
|
||||
description: !includeDescription ? entry.info.description : undefined,
|
||||
full: true,
|
||||
...extend,
|
||||
_openapi: {
|
||||
...meta,
|
||||
...data,
|
||||
...(extend?._openapi as object | undefined),
|
||||
},
|
||||
},
|
||||
content.join('\n\n'),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
function pageContent({
|
||||
showTitle,
|
||||
showDescription,
|
||||
|
||||
@@ -58,7 +58,6 @@ title: Operations
|
||||
description: Operational information about the museum.
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
toc:
|
||||
- depth: 2
|
||||
title: Get museum hours
|
||||
|
||||
@@ -4,6 +4,7 @@ title: Create a pet
|
||||
full: true
|
||||
_openapi:
|
||||
method: POST
|
||||
webhook: false
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
@@ -21,6 +22,7 @@ title: List all pets
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
webhook: false
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
@@ -38,6 +40,7 @@ title: Info for a specific pet
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
webhook: false
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
|
||||
@@ -5,6 +5,7 @@ description: Retrieve inventory details for a specific product.
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
webhook: false
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
@@ -24,6 +25,7 @@ description: Retrieve inventory details for a specific product.
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
webhook: false
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
@@ -43,6 +45,7 @@ description: Retrieve details of a product by its ID.
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
webhook: false
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
|
||||
@@ -19,6 +19,7 @@ description: Retrieve inventory details for a specific product.
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
webhook: false
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
@@ -38,6 +39,7 @@ description: Retrieve details of a product by its ID.
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
webhook: false
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
|
||||
@@ -5,6 +5,7 @@ description: Retrieve inventory details for a specific product.
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
webhook: false
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
@@ -43,6 +44,7 @@ description: Retrieve inventory details for a specific product.
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
webhook: false
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
@@ -62,6 +64,7 @@ description: Retrieve details of a product by its ID.
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
webhook: false
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
|
||||
@@ -5,6 +5,7 @@ description: Retrieve inventory details for a specific product.
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
webhook: false
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
@@ -24,6 +25,7 @@ description: Retrieve details of a product by its ID.
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
webhook: false
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
|
||||
@@ -14,7 +14,7 @@ export default defineConfig({
|
||||
'./src/ui/client/index.tsx',
|
||||
'./src/playground/client.tsx',
|
||||
'./src/scalar/index.tsx',
|
||||
'./src/server/index.ts',
|
||||
'./src/server/index.tsx',
|
||||
'./src/requests/generators/*.ts',
|
||||
],
|
||||
unbundle: true,
|
||||
|
||||
Reference in New Issue
Block a user