mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-26 06:31:49 +00:00
OpenAPI: Support per: custom
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': minor
|
||||
---
|
||||
|
||||
Support `per: custom`
|
||||
@@ -1,54 +0,0 @@
|
||||
import { type Document } from '@/types';
|
||||
import type { NoReference } from '@/utils/schema';
|
||||
import type { OperationItem, WebhookItem } from '@/render/api-page';
|
||||
import type { OpenAPIV3_1 } from 'openapi-types';
|
||||
|
||||
export const methodKeys = [
|
||||
'get',
|
||||
'post',
|
||||
'patch',
|
||||
'delete',
|
||||
'head',
|
||||
'put',
|
||||
] as const;
|
||||
|
||||
type Result = {
|
||||
webhooks: (WebhookItem & { tags?: string[] })[];
|
||||
operations: (OperationItem & {
|
||||
tags?: string[];
|
||||
})[];
|
||||
};
|
||||
|
||||
export function getAPIPageItems(document: NoReference<Document>): Result {
|
||||
const result: Result = { webhooks: [], operations: [] };
|
||||
|
||||
for (const [path, pathItem] of Object.entries(document.paths ?? {})) {
|
||||
if (!pathItem) continue;
|
||||
|
||||
for (const methodKey of methodKeys) {
|
||||
if (!pathItem[methodKey]) continue;
|
||||
|
||||
result.operations.push({
|
||||
method: methodKey as OpenAPIV3_1.HttpMethods,
|
||||
path,
|
||||
tags: pathItem[methodKey]?.tags,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, pathItem] of Object.entries(document.webhooks ?? {})) {
|
||||
if (!pathItem) continue;
|
||||
|
||||
for (const methodKey of methodKeys) {
|
||||
if (!pathItem[methodKey]) continue;
|
||||
|
||||
result.webhooks.push({
|
||||
method: methodKey as OpenAPIV3_1.HttpMethods,
|
||||
name,
|
||||
tags: pathItem[methodKey]?.tags,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -14,10 +14,10 @@ import type { OpenAPIServer } from '@/server';
|
||||
import { createGetUrl, getSlugs } from 'fumadocs-core/source';
|
||||
import matter from 'gray-matter';
|
||||
import {
|
||||
isUrl,
|
||||
schemaToPages,
|
||||
type SchemaToPagesOptions,
|
||||
} from '@/utils/schema-to-pages';
|
||||
} from '@/utils/pages/generate';
|
||||
import { isUrl } from '@/utils/url';
|
||||
|
||||
export interface OutputFile {
|
||||
path: string;
|
||||
@@ -149,14 +149,12 @@ export async function generateFilesOnly(
|
||||
throw new Error('No input files found.');
|
||||
}
|
||||
for (const [id, schema] of entries) {
|
||||
const result = schemaToPages(
|
||||
id,
|
||||
schema,
|
||||
options as SchemaToPagesOptions,
|
||||
).map<OutputFile>((page) => ({
|
||||
path: page.path,
|
||||
content: toText(page, schema, options),
|
||||
}));
|
||||
const result = (await schemaToPages(id, schema, options)).map<OutputFile>(
|
||||
(page) => ({
|
||||
path: page.path,
|
||||
content: toText(page, schema, options),
|
||||
}),
|
||||
);
|
||||
files.push(...result);
|
||||
generated[id] = result;
|
||||
}
|
||||
|
||||
@@ -24,12 +24,21 @@ export interface ApiPageProps extends SharedOpenAPIOptions {
|
||||
}
|
||||
|
||||
export interface WebhookItem {
|
||||
/**
|
||||
* webhook name in `webhooks`
|
||||
*/
|
||||
name: string;
|
||||
method: OpenAPIV3_1.HttpMethods;
|
||||
}
|
||||
|
||||
export interface OperationItem {
|
||||
/**
|
||||
* the path of operation in `paths`
|
||||
*/
|
||||
path: string;
|
||||
/**
|
||||
* the HTTP method of operation
|
||||
*/
|
||||
method: OpenAPIV3_1.HttpMethods;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,16 @@ import type {
|
||||
RenderContext,
|
||||
SecuritySchemeObject,
|
||||
} from '@/types';
|
||||
import { createMethod, type ResolvedSchema } from '@/utils/schema';
|
||||
import {
|
||||
createMethod,
|
||||
methodKeys,
|
||||
type NoReference,
|
||||
type ResolvedSchema,
|
||||
} from '@/utils/schema';
|
||||
import { idToTitle } from '@/utils/id-to-title';
|
||||
import { Markdown } from '../markdown';
|
||||
import { heading } from '../heading';
|
||||
import { Schema } from '../schema';
|
||||
import { methodKeys } from '@/build-routes';
|
||||
import {
|
||||
APIExample,
|
||||
APIExampleProvider,
|
||||
@@ -349,7 +353,7 @@ function WebhookCallback({
|
||||
ctx,
|
||||
headingLevel,
|
||||
}: {
|
||||
callback: CallbackObject;
|
||||
callback: NoReference<CallbackObject>;
|
||||
ctx: RenderContext;
|
||||
headingLevel: number;
|
||||
}) {
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { MethodLabel } from '@/ui/components/method-label';
|
||||
import type * as PageTree from 'fumadocs-core/page-tree';
|
||||
import {
|
||||
import type {
|
||||
LoaderPlugin,
|
||||
MetaData,
|
||||
PageData,
|
||||
PageFile,
|
||||
PageTreeTransformer,
|
||||
Source,
|
||||
VirtualFile,
|
||||
} from 'fumadocs-core/source';
|
||||
import type { OpenAPIServer } from '@/server/create';
|
||||
import type { SchemaToPagesOptions } from '@/utils/schema-to-pages';
|
||||
import { ApiPageProps } from '@/render/api-page';
|
||||
import type { SchemaToPagesOptions } from '@/utils/pages/generate';
|
||||
import type { ApiPageProps } from '@/render/api-page';
|
||||
|
||||
declare module 'fumadocs-core/source' {
|
||||
export interface PageData {
|
||||
@@ -80,7 +78,7 @@ export async function openapiSource(
|
||||
}>
|
||||
> {
|
||||
const { baseDir = '' } = options;
|
||||
const { serverToPages } = await import('@/utils/schema-to-pages');
|
||||
const { serverToPages } = await import('@/utils/pages/generate');
|
||||
const { toBody } = await import('@/utils/pages/to-body');
|
||||
const files: VirtualFile<{
|
||||
pageData: OpenAPIPageData;
|
||||
@@ -110,44 +108,6 @@ export async function openapiSource(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Source API Integration, add this to page tree builder options.
|
||||
*
|
||||
* @deprecated use `openapiPlugin()`
|
||||
*/
|
||||
export const attachFile = (
|
||||
node: PageTree.Item,
|
||||
file: PageFile | undefined,
|
||||
): PageTree.Item => {
|
||||
if (!file) return node;
|
||||
let data = file.data as object;
|
||||
// backward compatible with older versions with `_openapi` is located in `data.data`
|
||||
if ('data' in data && data.data && typeof data === 'object') data = data.data;
|
||||
|
||||
let method: string | undefined;
|
||||
|
||||
if ('_openapi' in data && typeof data._openapi === 'object') {
|
||||
const meta = data._openapi as {
|
||||
method?: string;
|
||||
};
|
||||
|
||||
method = meta.method;
|
||||
}
|
||||
|
||||
if (method) {
|
||||
node.name = (
|
||||
<>
|
||||
{node.name}{' '}
|
||||
<MethodLabel className="ms-auto text-xs text-nowrap">
|
||||
{method}
|
||||
</MethodLabel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated use `openapiPlugin()`
|
||||
*/
|
||||
|
||||
@@ -9,14 +9,13 @@ import type { SharedOpenAPIOptions } from '@/server';
|
||||
export type Document = V3_1.Document;
|
||||
export type OperationObject = V3_1.OperationObject;
|
||||
export type ParameterObject = V3_1.ParameterObject;
|
||||
export type SecurityRequirementObject = V3_1.SecurityRequirementObject;
|
||||
export type SecuritySchemeObject = V3_1.SecuritySchemeObject;
|
||||
export type ReferenceObject = V3_1.ReferenceObject;
|
||||
export type PathItemObject = V3_1.PathItemObject;
|
||||
export type TagObject = V3_1.TagObject;
|
||||
export type ServerObject = NoReference<V3_1.ServerObject>;
|
||||
export type CallbackObject = NoReference<V3_1.CallbackObject>;
|
||||
export type ServerVariableObject = NoReference<V3_1.ServerVariableObject>;
|
||||
export type ServerObject = V3_1.ServerObject;
|
||||
export type CallbackObject = V3_1.CallbackObject;
|
||||
export type ServerVariableObject = V3_1.ServerVariableObject;
|
||||
|
||||
export type MethodInformation = NoReference<OperationObject> & {
|
||||
method: string;
|
||||
@@ -24,7 +23,7 @@ export type MethodInformation = NoReference<OperationObject> & {
|
||||
|
||||
export interface RenderContext extends SharedOpenAPIOptions {
|
||||
renderer: Renderer;
|
||||
servers: ServerObject[];
|
||||
servers: NoReference<ServerObject>[];
|
||||
slugger: Slugger;
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from 'react';
|
||||
import type { RenderContext, ServerObject } from '@/types';
|
||||
import { defaultAdapters, type MediaAdapter } from '@/requests/media/adapter';
|
||||
import type { NoReference } from '@/utils/schema';
|
||||
|
||||
export interface ApiProviderProps
|
||||
extends Omit<ApiContextType, 'mediaAdapters'> {
|
||||
@@ -26,7 +27,7 @@ export interface SelectedServer {
|
||||
}
|
||||
|
||||
interface ApiContextType {
|
||||
servers: ServerObject[];
|
||||
servers: NoReference<ServerObject>[];
|
||||
shikiOptions: RenderContext['shikiOptions'];
|
||||
mediaAdapters: Record<string, MediaAdapter>;
|
||||
}
|
||||
@@ -137,7 +138,9 @@ export function ApiProvider({
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultValues(server: ServerObject): Record<string, string> {
|
||||
function getDefaultValues(
|
||||
server: NoReference<ServerObject>,
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(server.variables ?? {}).map(([k, v]) => [k, v.default]),
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { resolveServerUrl, withBase } from '@/utils/url';
|
||||
import { FormProvider, useController, useForm } from 'react-hook-form';
|
||||
import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event';
|
||||
import type { ServerVariableObject } from '@/types';
|
||||
import type { NoReference } from '@/utils/schema';
|
||||
|
||||
export default function ServerSelect(props: HTMLAttributes<HTMLDivElement>) {
|
||||
const { servers } = useApiContext();
|
||||
@@ -88,7 +89,7 @@ function ServerSelectContent({
|
||||
}: {
|
||||
defaultValues: Record<string, string>;
|
||||
onChange: (values: Record<string, string>) => void;
|
||||
schema: Record<string, ServerVariableObject>;
|
||||
schema: Record<string, NoReference<ServerVariableObject>>;
|
||||
}) {
|
||||
const form = useForm({
|
||||
defaultValues,
|
||||
@@ -136,7 +137,7 @@ function Field({
|
||||
fieldName,
|
||||
variable,
|
||||
}: {
|
||||
variable: ServerVariableObject;
|
||||
variable: NoReference<ServerVariableObject>;
|
||||
fieldName: string;
|
||||
}) {
|
||||
const { field } = useController({
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { ProcessedDocument } from '@/utils/process-document';
|
||||
import type { OpenAPIServer } from '@/server';
|
||||
import type { OperationItem, WebhookItem } from '@/render/api-page';
|
||||
import type {
|
||||
Document,
|
||||
OperationObject,
|
||||
PathItemObject,
|
||||
TagObject,
|
||||
} from '@/types';
|
||||
import {
|
||||
getTagDisplayName,
|
||||
methodKeys,
|
||||
type NoReference,
|
||||
} from '@/utils/schema';
|
||||
import type { OpenAPIV3_1 } from 'openapi-types';
|
||||
import { idToTitle } from '@/utils/id-to-title';
|
||||
|
||||
interface BaseEntry {
|
||||
path: string;
|
||||
schemaId: string;
|
||||
info: {
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OperationOutput extends BaseEntry {
|
||||
type: 'operation';
|
||||
item: OperationItem;
|
||||
}
|
||||
|
||||
export interface WebhookOutput extends BaseEntry {
|
||||
type: 'webhook';
|
||||
item: WebhookItem;
|
||||
}
|
||||
|
||||
export interface TagOutput extends BaseEntry {
|
||||
type: 'tag';
|
||||
tag: string;
|
||||
rawTag: TagObject;
|
||||
operations: OperationItem[];
|
||||
webhooks: WebhookItem[];
|
||||
}
|
||||
|
||||
export interface OutputGroup extends BaseEntry {
|
||||
type: 'group';
|
||||
operations: OperationItem[];
|
||||
webhooks: WebhookItem[];
|
||||
}
|
||||
|
||||
export type OutputEntry =
|
||||
| TagOutput
|
||||
| OperationOutput
|
||||
| WebhookOutput
|
||||
| OutputGroup;
|
||||
|
||||
export interface BuilderConfig {
|
||||
toPages: (builder: PagesBuilder) => void;
|
||||
}
|
||||
|
||||
export interface PagesBuilder {
|
||||
/**
|
||||
* the input ID in OpenAPI server
|
||||
*/
|
||||
id: string;
|
||||
document: ProcessedDocument;
|
||||
/**
|
||||
* add output entry.
|
||||
*
|
||||
* When the `path` property is unspecified, it will generate one.
|
||||
*/
|
||||
create: (entry: OutputEntry) => void;
|
||||
|
||||
/**
|
||||
* get file path from operation path, useful for generating output paths.
|
||||
*/
|
||||
routePathToFilePath: (path: string) => string;
|
||||
|
||||
/**
|
||||
* Extract useful info for rendering
|
||||
*/
|
||||
extract: () => ExtractedInfo;
|
||||
fromExtractedWebhook: (item: WebhookItem) =>
|
||||
| {
|
||||
get displayName(): string;
|
||||
pathItem: NoReference<PathItemObject>;
|
||||
operation: NoReference<OperationObject>;
|
||||
}
|
||||
| undefined;
|
||||
fromExtractedOperation: (item: OperationItem) =>
|
||||
| {
|
||||
get displayName(): string;
|
||||
pathItem: NoReference<PathItemObject>;
|
||||
operation: NoReference<OperationObject>;
|
||||
}
|
||||
| undefined;
|
||||
fromTag: (tag: TagObject) => {
|
||||
get displayName(): string;
|
||||
};
|
||||
fromTagName: (tag: string) =>
|
||||
| {
|
||||
info: TagObject;
|
||||
get displayName(): string;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
interface ExtractedInfo {
|
||||
webhooks: (WebhookItem & { tags?: string[] })[];
|
||||
operations: (OperationItem & {
|
||||
tags?: string[];
|
||||
})[];
|
||||
}
|
||||
|
||||
export async function fromServer(
|
||||
server: OpenAPIServer,
|
||||
config: BuilderConfig,
|
||||
): Promise<Record<string, OutputEntry[]>> {
|
||||
const schemas = await server.getSchemas();
|
||||
const generated: Record<string, OutputEntry[]> = {};
|
||||
|
||||
const entries = Object.entries(schemas);
|
||||
if (entries.length === 0) {
|
||||
throw new Error('No input files found.');
|
||||
}
|
||||
|
||||
for (const [id, schema] of entries) {
|
||||
generated[id] = fromSchema(id, schema, config);
|
||||
}
|
||||
|
||||
return generated;
|
||||
}
|
||||
|
||||
export function fromSchema(
|
||||
schemaId: string,
|
||||
processed: ProcessedDocument,
|
||||
config: BuilderConfig,
|
||||
): OutputEntry[] {
|
||||
const files: OutputEntry[] = [];
|
||||
const { toPages } = config;
|
||||
const { dereferenced } = processed;
|
||||
|
||||
toPages({
|
||||
id: schemaId,
|
||||
document: processed,
|
||||
create(entry) {
|
||||
files.push(entry);
|
||||
},
|
||||
extract: () => extractInfo(dereferenced),
|
||||
routePathToFilePath(path) {
|
||||
return path
|
||||
.toLowerCase()
|
||||
.replaceAll('.', '-')
|
||||
.split('/')
|
||||
.flatMap((v) => {
|
||||
if (v.startsWith('{') && v.endsWith('}')) return v.slice(1, -1);
|
||||
if (v.length === 0) return [];
|
||||
return v;
|
||||
})
|
||||
.join('/');
|
||||
},
|
||||
fromExtractedWebhook(item) {
|
||||
const pathItem = dereferenced.webhooks?.[item.name];
|
||||
if (!pathItem) return;
|
||||
const operation = pathItem?.[item.method];
|
||||
if (!operation) return;
|
||||
return {
|
||||
pathItem,
|
||||
operation,
|
||||
get displayName() {
|
||||
return operation.summary ?? pathItem.summary ?? idToTitle(item.name);
|
||||
},
|
||||
};
|
||||
},
|
||||
fromExtractedOperation(item) {
|
||||
const pathItem = dereferenced.paths?.[item.path];
|
||||
if (!pathItem) return;
|
||||
const operation = pathItem?.[item.method];
|
||||
if (!operation) return;
|
||||
return {
|
||||
pathItem,
|
||||
operation,
|
||||
get displayName() {
|
||||
return (
|
||||
operation.summary ??
|
||||
pathItem.summary ??
|
||||
(operation.operationId
|
||||
? idToTitle(operation.operationId)
|
||||
: item.path)
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
fromTag(tag) {
|
||||
return {
|
||||
get displayName() {
|
||||
return getTagDisplayName(tag);
|
||||
},
|
||||
};
|
||||
},
|
||||
fromTagName(name) {
|
||||
const tag = dereferenced.tags?.find((item) => item.name === name);
|
||||
if (!tag) return;
|
||||
|
||||
return {
|
||||
info: tag,
|
||||
...this.fromTag(tag),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function extractInfo(document: NoReference<Document>): ExtractedInfo {
|
||||
const result: ExtractedInfo = { webhooks: [], operations: [] };
|
||||
|
||||
for (const [path, pathItem] of Object.entries(document.paths ?? {})) {
|
||||
if (!pathItem) continue;
|
||||
|
||||
for (const methodKey of methodKeys) {
|
||||
if (!pathItem[methodKey]) continue;
|
||||
|
||||
result.operations.push({
|
||||
method: methodKey as OpenAPIV3_1.HttpMethods,
|
||||
path,
|
||||
tags: pathItem[methodKey]?.tags,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, pathItem] of Object.entries(document.webhooks ?? {})) {
|
||||
if (!pathItem) continue;
|
||||
|
||||
for (const methodKey of methodKeys) {
|
||||
if (!pathItem[methodKey]) continue;
|
||||
|
||||
result.webhooks.push({
|
||||
method: methodKey as OpenAPIV3_1.HttpMethods,
|
||||
name,
|
||||
tags: pathItem[methodKey]?.tags,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import * as path from 'node:path';
|
||||
import type { ProcessedDocument } from '@/utils/process-document';
|
||||
import type { OpenAPIServer } from '@/server';
|
||||
import {
|
||||
type BuilderConfig,
|
||||
fromSchema,
|
||||
fromServer,
|
||||
type OperationOutput,
|
||||
type OutputEntry,
|
||||
type OutputGroup,
|
||||
type PagesBuilder,
|
||||
type TagOutput,
|
||||
type WebhookOutput,
|
||||
} from '@/utils/pages/builder';
|
||||
import { isUrl } from '@/utils/url';
|
||||
|
||||
interface OperationConfig extends BaseConfig {
|
||||
/**
|
||||
* Generate a page for each API endpoint/operation (default).
|
||||
*/
|
||||
per?: 'operation';
|
||||
|
||||
/**
|
||||
* Group output using folders (Only works on `operation` mode)
|
||||
* - tag: `{tag}/{file}`
|
||||
* - route: `{endpoint}/{method}` (it will ignore the `name` option)
|
||||
* - none: `{file}` (default)
|
||||
* - a function that aligns group name (folder path) to each entry
|
||||
*
|
||||
* @defaultValue 'none'
|
||||
*/
|
||||
groupBy?:
|
||||
| 'tag'
|
||||
| 'route'
|
||||
| 'none'
|
||||
| ((entry: OperationOutput | WebhookOutput) => string);
|
||||
|
||||
/**
|
||||
* Specify name for output file
|
||||
*/
|
||||
name?: NameFn<OperationOutput | WebhookOutput> | NameFnOptions;
|
||||
}
|
||||
|
||||
interface TagConfig extends BaseConfig {
|
||||
/**
|
||||
* Generate a page for each tag.
|
||||
*/
|
||||
per: 'tag';
|
||||
|
||||
/**
|
||||
* Specify name for output file
|
||||
*/
|
||||
name?: NameFn<TagOutput> | NameFnOptions;
|
||||
}
|
||||
|
||||
interface SchemaConfig extends BaseConfig {
|
||||
/**
|
||||
* Generate a page for each schema file.
|
||||
*/
|
||||
per: 'file';
|
||||
|
||||
/**
|
||||
* Specify name for output file
|
||||
*/
|
||||
name?: NameFn<OutputGroup> | NameFnOptions;
|
||||
}
|
||||
|
||||
export type SchemaToPagesOptions =
|
||||
| SchemaConfig
|
||||
| TagConfig
|
||||
| OperationConfig
|
||||
| ({
|
||||
per: 'custom';
|
||||
} & BuilderConfig);
|
||||
|
||||
type NameFn<Entry> = (
|
||||
this: PagesBuilder,
|
||||
output: Entry,
|
||||
document: ProcessedDocument['dereferenced'],
|
||||
) => string;
|
||||
|
||||
interface NameFnOptions {
|
||||
/**
|
||||
* The version of algorithm used to generate file paths.
|
||||
*
|
||||
* v1: Fumadocs OpenAPI v8
|
||||
* v2: Fumadocs OpenAPI v9
|
||||
*
|
||||
* @defaultValue v2
|
||||
*/
|
||||
algorithm?: 'v2' | 'v1';
|
||||
}
|
||||
|
||||
interface BaseConfig {
|
||||
/**
|
||||
* Custom function to convert names into file names.
|
||||
*
|
||||
* By default, it only escapes whitespaces and upper case (English) characters
|
||||
*/
|
||||
slugify?: (name: string) => string;
|
||||
}
|
||||
|
||||
export async function serverToPages(
|
||||
server: OpenAPIServer,
|
||||
options: SchemaToPagesOptions,
|
||||
) {
|
||||
return fromServer(server, createAutoPreset(options));
|
||||
}
|
||||
|
||||
export async function schemaToPages(
|
||||
schemaId: string,
|
||||
processed: ProcessedDocument,
|
||||
options: SchemaToPagesOptions,
|
||||
) {
|
||||
return fromSchema(schemaId, processed, createAutoPreset(options));
|
||||
}
|
||||
|
||||
function createAutoPreset(options: SchemaToPagesOptions): BuilderConfig {
|
||||
if (options.per === 'custom') return options;
|
||||
const {
|
||||
slugify = (s) => {
|
||||
return s.replace(/\s+/g, '-').toLowerCase();
|
||||
},
|
||||
} = options;
|
||||
let nameFn: NameFn<OutputEntry>;
|
||||
|
||||
if (typeof options.name === 'function') {
|
||||
nameFn = options.name as NameFn<OutputEntry>;
|
||||
} else {
|
||||
const { algorithm = 'v2' } = options.name ?? {};
|
||||
|
||||
nameFn = function (result, document) {
|
||||
if (result.type === 'tag') {
|
||||
return slugify(result.tag);
|
||||
}
|
||||
|
||||
if (result.type === 'group') {
|
||||
const schemaId = result.schemaId;
|
||||
|
||||
return isUrl(schemaId)
|
||||
? 'index'
|
||||
: path.basename(schemaId, path.extname(schemaId));
|
||||
}
|
||||
|
||||
if (result.type === 'operation') {
|
||||
const operation =
|
||||
document.paths![result.item.path]![result.item.method]!;
|
||||
|
||||
if (algorithm === 'v2' && operation.operationId) {
|
||||
return operation.operationId;
|
||||
}
|
||||
|
||||
return path.join(
|
||||
this.routePathToFilePath(result.item.path),
|
||||
result.item.method.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
const hook = document.webhooks![result.item.name][result.item.method]!;
|
||||
|
||||
if (algorithm === 'v2' && hook.operationId) {
|
||||
return hook.operationId;
|
||||
}
|
||||
|
||||
return slugify(result.item.name);
|
||||
};
|
||||
}
|
||||
|
||||
function groupOutput(
|
||||
builder: PagesBuilder,
|
||||
entry: OperationOutput | WebhookOutput,
|
||||
): string[] {
|
||||
const { dereferenced } = builder.document;
|
||||
const { groupBy = 'none' } = options as OperationConfig;
|
||||
|
||||
if (groupBy === 'route') {
|
||||
return [
|
||||
path.join(
|
||||
builder.routePathToFilePath(
|
||||
entry.type === 'operation' ? entry.item.path : entry.item.name,
|
||||
),
|
||||
`${entry.item.method.toLowerCase()}.mdx`,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const file = nameFn.call(builder, entry, dereferenced);
|
||||
if (groupBy === 'tag') {
|
||||
let tags =
|
||||
entry.type === 'operation'
|
||||
? dereferenced.paths![entry.item.path]![entry.item.method]!.tags
|
||||
: dereferenced.webhooks![entry.item.name][entry.item.method]!.tags;
|
||||
|
||||
if (!tags || tags.length === 0) {
|
||||
console.warn(
|
||||
'When `groupBy` is set to `tag`, make sure a `tags` is defined for every operation schema.',
|
||||
);
|
||||
|
||||
tags = ['unknown'];
|
||||
}
|
||||
|
||||
return tags.map((tag) => path.join(slugify(tag), `${file}.mdx`));
|
||||
}
|
||||
|
||||
if (typeof groupBy === 'function') {
|
||||
return [path.join(slugify(groupBy(entry)), `${file}.mdx`)];
|
||||
}
|
||||
|
||||
return [`${file}.mdx`];
|
||||
}
|
||||
|
||||
return {
|
||||
toPages(builder) {
|
||||
const { dereferenced } = builder.document;
|
||||
const items = builder.extract();
|
||||
|
||||
if (options.per === 'file') {
|
||||
const entry: OutputGroup = {
|
||||
type: 'group',
|
||||
schemaId: builder.id,
|
||||
path: '',
|
||||
info: {
|
||||
title: dereferenced.info.title,
|
||||
description: dereferenced.info.description,
|
||||
},
|
||||
...items,
|
||||
};
|
||||
entry.path = nameFn.call(builder, entry, dereferenced) + '.mdx';
|
||||
builder.create(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.per === 'tag') {
|
||||
const tags = dereferenced.tags ?? [];
|
||||
for (const tag of tags) {
|
||||
const { displayName } = builder.fromTag(tag);
|
||||
const entry: TagOutput = {
|
||||
type: 'tag',
|
||||
path: '',
|
||||
schemaId: builder.id,
|
||||
info: {
|
||||
title: displayName,
|
||||
description: tag.description,
|
||||
},
|
||||
webhooks: items.webhooks.filter((webhook) =>
|
||||
webhook.tags?.includes(tag.name),
|
||||
),
|
||||
operations: items.operations.filter((op) =>
|
||||
op.tags?.includes(tag.name),
|
||||
),
|
||||
tag: tag.name,
|
||||
rawTag: tag,
|
||||
};
|
||||
|
||||
entry.path = nameFn.call(builder, entry, dereferenced) + '.mdx';
|
||||
builder.create(entry);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (const op of items.operations) {
|
||||
const { pathItem, operation, displayName } =
|
||||
builder.fromExtractedOperation(op)!;
|
||||
|
||||
const entry: OperationOutput = {
|
||||
type: 'operation',
|
||||
schemaId: builder.id,
|
||||
item: op,
|
||||
path: '',
|
||||
info: {
|
||||
title: displayName,
|
||||
description: operation.description ?? pathItem.description,
|
||||
},
|
||||
};
|
||||
|
||||
for (const outputPath of groupOutput(builder, entry)) {
|
||||
builder.create({ ...entry, path: outputPath });
|
||||
}
|
||||
}
|
||||
|
||||
for (const webhook of items.webhooks) {
|
||||
const { pathItem, operation, displayName } =
|
||||
builder.fromExtractedWebhook(webhook)!;
|
||||
|
||||
const entry: WebhookOutput = {
|
||||
type: 'webhook',
|
||||
schemaId: builder.id,
|
||||
info: {
|
||||
title: displayName,
|
||||
description: operation.description ?? pathItem.description,
|
||||
},
|
||||
item: webhook,
|
||||
path: '',
|
||||
};
|
||||
|
||||
for (const outputPath of groupOutput(builder, entry)) {
|
||||
builder.create({ ...entry, path: outputPath });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { OutputEntry } from '@/utils/schema-to-pages';
|
||||
import type { ApiPageProps } from '@/render/api-page';
|
||||
import type { OpenAPIServer } from '@/server';
|
||||
import type { OutputEntry } from '@/utils/pages/builder';
|
||||
|
||||
export function toBody(
|
||||
server: OpenAPIServer,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { dump } from 'js-yaml';
|
||||
import type { NoReference } from '@/utils/schema';
|
||||
import Slugger from 'github-slugger';
|
||||
import { removeUndefined } from '@/utils/remove-undefined';
|
||||
import type { OutputEntry } from '@/utils/schema-to-pages';
|
||||
import type { OutputEntry } from '@/utils/pages/builder';
|
||||
|
||||
export interface PagesToTextOptions {
|
||||
/**
|
||||
@@ -76,7 +76,7 @@ export function toText(
|
||||
type: 'operation',
|
||||
},
|
||||
);
|
||||
case 'schema':
|
||||
case 'group':
|
||||
return generatePage(
|
||||
entry.schemaId,
|
||||
processed,
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
import * as path from 'node:path';
|
||||
import { type ProcessedDocument } from '@/utils/process-document';
|
||||
import type { OpenAPIServer } from '@/server';
|
||||
import type { OperationItem, WebhookItem } from '@/render/api-page';
|
||||
import { idToTitle } from '@/utils/id-to-title';
|
||||
import { getAPIPageItems } from '@/build-routes';
|
||||
import type { TagObject } from '@/types';
|
||||
import { getTagDisplayName } from '@/utils/schema';
|
||||
|
||||
interface BaseEntry {
|
||||
path: string;
|
||||
schemaId: string;
|
||||
info: {
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OutputOperationEntry extends BaseEntry {
|
||||
type: 'operation';
|
||||
item: OperationItem;
|
||||
}
|
||||
|
||||
export interface OutputWebhookEntry extends BaseEntry {
|
||||
type: 'webhook';
|
||||
item: WebhookItem;
|
||||
}
|
||||
|
||||
export interface OutputTagEntry extends BaseEntry {
|
||||
type: 'tag';
|
||||
tag: string;
|
||||
rawTag: TagObject;
|
||||
operations: OperationItem[];
|
||||
webhooks: WebhookItem[];
|
||||
}
|
||||
|
||||
export interface OutputSchemaEntry extends BaseEntry {
|
||||
type: 'schema';
|
||||
operations: OperationItem[];
|
||||
webhooks: WebhookItem[];
|
||||
}
|
||||
|
||||
export type OutputEntry =
|
||||
| OutputTagEntry
|
||||
| OutputOperationEntry
|
||||
| OutputWebhookEntry
|
||||
| OutputSchemaEntry;
|
||||
|
||||
interface OperationConfig extends BaseConfig {
|
||||
/**
|
||||
* Generate a page for each API endpoint/operation (default).
|
||||
*/
|
||||
per?: 'operation';
|
||||
|
||||
/**
|
||||
* Group output using folders (Only works on `operation` mode)
|
||||
* - tag: `{tag}/{file}`
|
||||
* - route: `{endpoint}/{method}` (it will ignore the `name` option)
|
||||
* - none: `{file}` (default)
|
||||
* - a function that aligns group name (folder path) to each entry
|
||||
*
|
||||
* @defaultValue 'none'
|
||||
*/
|
||||
groupBy?:
|
||||
| 'tag'
|
||||
| 'route'
|
||||
| 'none'
|
||||
| ((entry: OutputOperationEntry | OutputWebhookEntry) => string);
|
||||
|
||||
/**
|
||||
* Specify name for output file
|
||||
*/
|
||||
name?: NameFn<OutputOperationEntry | OutputWebhookEntry> | NameFnOptions;
|
||||
}
|
||||
|
||||
interface TagConfig extends BaseConfig {
|
||||
/**
|
||||
* Generate a page for each tag.
|
||||
*/
|
||||
per: 'tag';
|
||||
|
||||
/**
|
||||
* Specify name for output file
|
||||
*/
|
||||
name?: NameFn<OutputTagEntry> | NameFnOptions;
|
||||
}
|
||||
|
||||
interface SchemaConfig extends BaseConfig {
|
||||
/**
|
||||
* Generate a page for each schema file.
|
||||
*/
|
||||
per: 'file';
|
||||
|
||||
/**
|
||||
* Specify name for output file
|
||||
*/
|
||||
name?: NameFn<OutputSchemaEntry> | NameFnOptions;
|
||||
}
|
||||
|
||||
export type SchemaToPagesOptions = SchemaConfig | TagConfig | OperationConfig;
|
||||
|
||||
type NameFn<Entry> = (
|
||||
output: Entry,
|
||||
document: ProcessedDocument['dereferenced'],
|
||||
) => string;
|
||||
|
||||
interface NameFnOptions {
|
||||
/**
|
||||
* The version of algorithm used to generate file paths.
|
||||
*
|
||||
* v1: Fumadocs OpenAPI v8
|
||||
* v2: Fumadocs OpenAPI v9
|
||||
*
|
||||
* @defaultValue v2
|
||||
*/
|
||||
algorithm?: 'v2' | 'v1';
|
||||
}
|
||||
|
||||
interface BaseConfig {
|
||||
/**
|
||||
* Custom function to convert names into file names.
|
||||
*
|
||||
* By default, it only escapes whitespaces and upper case (English) characters
|
||||
*/
|
||||
slugify?: (name: string) => string;
|
||||
}
|
||||
|
||||
export async function serverToPages(
|
||||
server: OpenAPIServer,
|
||||
options: SchemaToPagesOptions,
|
||||
): Promise<Record<string, OutputEntry[]>> {
|
||||
const schemas = await server.getSchemas();
|
||||
const generated: Record<string, OutputEntry[]> = {};
|
||||
|
||||
const entries = Object.entries(schemas);
|
||||
if (entries.length === 0) {
|
||||
throw new Error('No input files found.');
|
||||
}
|
||||
|
||||
for (const [id, schema] of entries) {
|
||||
generated[id] = schemaToPages(id, schema, options);
|
||||
}
|
||||
|
||||
return generated;
|
||||
}
|
||||
|
||||
export function schemaToPages(
|
||||
schemaId: string,
|
||||
processed: ProcessedDocument,
|
||||
options: SchemaToPagesOptions,
|
||||
): OutputEntry[] {
|
||||
const files: OutputEntry[] = [];
|
||||
const { dereferenced } = processed;
|
||||
const { slugify = defaultSlugify } = options;
|
||||
|
||||
let nameFn: NameFn<OutputEntry>;
|
||||
|
||||
if (!options.name || typeof options.name !== 'function') {
|
||||
const algorithm = options.name?.algorithm;
|
||||
|
||||
nameFn = createDefaultNameFn(options, algorithm);
|
||||
} else {
|
||||
nameFn = options.name as typeof nameFn;
|
||||
}
|
||||
|
||||
function getOutputPaths(
|
||||
groupBy: OperationConfig['groupBy'] = 'none',
|
||||
entry: OutputOperationEntry | OutputWebhookEntry,
|
||||
): string[] {
|
||||
if (groupBy === 'route') {
|
||||
return [
|
||||
path.join(
|
||||
getOutputPathFromRoute(
|
||||
entry.type === 'operation' ? entry.item.path : entry.item.name,
|
||||
),
|
||||
`${entry.item.method.toLowerCase()}.mdx`,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const file = nameFn(entry, dereferenced);
|
||||
if (groupBy === 'tag') {
|
||||
let tags =
|
||||
entry.type === 'operation'
|
||||
? dereferenced.paths![entry.item.path]![entry.item.method]!.tags
|
||||
: dereferenced.webhooks![entry.item.name][entry.item.method]!.tags;
|
||||
|
||||
if (!tags || tags.length === 0) {
|
||||
console.warn(
|
||||
'When `groupBy` is set to `tag`, make sure a `tags` is defined for every operation schema.',
|
||||
);
|
||||
|
||||
tags = ['unknown'];
|
||||
}
|
||||
|
||||
return tags.map((tag) => path.join(slugify(tag), `${file}.mdx`));
|
||||
}
|
||||
|
||||
if (typeof groupBy === 'function') {
|
||||
return [path.join(slugify(groupBy(entry)), `${file}.mdx`)];
|
||||
}
|
||||
|
||||
return [`${file}.mdx`];
|
||||
}
|
||||
|
||||
if (options.per === 'file') {
|
||||
const items = getAPIPageItems(dereferenced);
|
||||
const entry: OutputSchemaEntry = {
|
||||
type: 'schema',
|
||||
schemaId,
|
||||
path: '',
|
||||
info: {
|
||||
title: dereferenced.info.title,
|
||||
description: dereferenced.info.description,
|
||||
},
|
||||
...items,
|
||||
};
|
||||
entry.path = nameFn(entry, dereferenced) + '.mdx';
|
||||
|
||||
files.push(entry);
|
||||
return files;
|
||||
}
|
||||
|
||||
if (options.per === 'tag') {
|
||||
const items = getAPIPageItems(dereferenced);
|
||||
const tags = dereferenced.tags ?? [];
|
||||
for (const tag of tags) {
|
||||
const entry: OutputTagEntry = {
|
||||
type: 'tag',
|
||||
path: '',
|
||||
schemaId,
|
||||
info: {
|
||||
title: getTagDisplayName(tag),
|
||||
description: tag.description,
|
||||
},
|
||||
webhooks: items.webhooks.filter((webhook) =>
|
||||
webhook.tags?.includes(tag.name),
|
||||
),
|
||||
operations: items.operations.filter((op) =>
|
||||
op.tags?.includes(tag.name),
|
||||
),
|
||||
tag: tag.name,
|
||||
rawTag: tag,
|
||||
};
|
||||
|
||||
entry.path = nameFn(entry, dereferenced) + '.mdx';
|
||||
files.push(entry);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
const results = getAPIPageItems(dereferenced);
|
||||
|
||||
for (const op of results.operations) {
|
||||
const pathItem = dereferenced.paths![op.path]!;
|
||||
const operation = pathItem[op.method]!;
|
||||
|
||||
const entry: OutputOperationEntry = {
|
||||
type: 'operation',
|
||||
schemaId,
|
||||
item: op,
|
||||
path: '',
|
||||
info: {
|
||||
title:
|
||||
operation.summary ??
|
||||
pathItem.summary ??
|
||||
idToTitle(operation.operationId ?? 'unknown'),
|
||||
description: operation.description ?? pathItem.description,
|
||||
},
|
||||
};
|
||||
|
||||
for (const outputPath of getOutputPaths(options.groupBy, entry)) {
|
||||
files.push({ ...entry, path: outputPath });
|
||||
}
|
||||
}
|
||||
|
||||
for (const webhook of results.webhooks) {
|
||||
const pathItem = dereferenced.webhooks![webhook.name]!;
|
||||
const operation = pathItem[webhook.method]!;
|
||||
|
||||
const entry: OutputWebhookEntry = {
|
||||
type: 'webhook',
|
||||
schemaId,
|
||||
info: {
|
||||
title: operation.summary ?? pathItem.summary ?? idToTitle(webhook.name),
|
||||
description: operation.description ?? pathItem.description,
|
||||
},
|
||||
item: webhook,
|
||||
path: '',
|
||||
};
|
||||
|
||||
for (const outputPath of getOutputPaths(options.groupBy, entry)) {
|
||||
files.push({ ...entry, path: outputPath });
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function createDefaultNameFn(
|
||||
options: SchemaToPagesOptions,
|
||||
algorithm: 'v2' | 'v1' = 'v2',
|
||||
): NameFn<OutputEntry> {
|
||||
const { slugify = defaultSlugify } = options;
|
||||
|
||||
return (result, document) => {
|
||||
if (result.type === 'tag') {
|
||||
return slugify(result.tag);
|
||||
}
|
||||
|
||||
if (result.type === 'schema') {
|
||||
const schemaId = result.schemaId;
|
||||
|
||||
return isUrl(schemaId)
|
||||
? 'index'
|
||||
: path.basename(schemaId, path.extname(schemaId));
|
||||
}
|
||||
|
||||
if (result.type === 'operation') {
|
||||
const operation = document.paths![result.item.path]![result.item.method]!;
|
||||
|
||||
if (algorithm === 'v2' && operation.operationId) {
|
||||
return operation.operationId;
|
||||
}
|
||||
|
||||
return path.join(
|
||||
getOutputPathFromRoute(result.item.path),
|
||||
result.item.method.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
const hook = document.webhooks![result.item.name][result.item.method]!;
|
||||
|
||||
if (algorithm === 'v2' && hook.operationId) {
|
||||
return hook.operationId;
|
||||
}
|
||||
|
||||
return slugify(result.item.name);
|
||||
};
|
||||
}
|
||||
|
||||
export function isUrl(schemaId: string): boolean {
|
||||
return schemaId.startsWith('https://') || schemaId.startsWith('http://');
|
||||
}
|
||||
|
||||
function getOutputPathFromRoute(path: string): string {
|
||||
return path
|
||||
.toLowerCase()
|
||||
.replaceAll('.', '-')
|
||||
.split('/')
|
||||
.flatMap((v) => {
|
||||
if (v.startsWith('{') && v.endsWith('}')) return v.slice(1, -1);
|
||||
if (v.length === 0) return [];
|
||||
return v;
|
||||
})
|
||||
.join('/');
|
||||
}
|
||||
|
||||
function defaultSlugify(s: string): string {
|
||||
return s.replace(/\s+/g, '-').toLowerCase();
|
||||
}
|
||||
@@ -8,6 +8,15 @@ import type {
|
||||
} from '@/types';
|
||||
import { idToTitle } from '@/utils/id-to-title';
|
||||
|
||||
export const methodKeys = [
|
||||
'get',
|
||||
'post',
|
||||
'patch',
|
||||
'delete',
|
||||
'head',
|
||||
'put',
|
||||
] as const;
|
||||
|
||||
export type NoReference<T> = T extends (infer I)[]
|
||||
? NoReference<I>[]
|
||||
: T extends ReferenceObject
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { RequestData } from '@/requests/types';
|
||||
|
||||
export function isUrl(schemaId: string): boolean {
|
||||
return schemaId.startsWith('https://') || schemaId.startsWith('http://');
|
||||
}
|
||||
|
||||
export function joinURL(base: string, pathname: string): string {
|
||||
if (pathname.startsWith('/')) pathname = pathname.slice(1);
|
||||
if (base.endsWith('/')) base = base.slice(0, -1);
|
||||
|
||||
@@ -89,9 +89,7 @@ describe('Generate documents', () => {
|
||||
algorithm: 'v1',
|
||||
},
|
||||
}),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`[Error: input not found: ./fixtures/non-existent-1.yaml]`,
|
||||
);
|
||||
).rejects.toThrow(/input not found/);
|
||||
});
|
||||
|
||||
test('Generate Files - groupBy tag per operation', async () => {
|
||||
|
||||
Reference in New Issue
Block a user