OpenAPI: Support per: custom

This commit is contained in:
Fuma Nama
2025-10-28 22:48:31 +08:00
parent a148db521f
commit ef73516ac0
17 changed files with 612 additions and 488 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': minor
---
Support `per: custom`
-54
View File
@@ -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;
}
+8 -10
View File
@@ -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;
}
+9
View File
@@ -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;
}) {
+4 -44
View File
@@ -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()`
*/
+4 -5
View File
@@ -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;
/**
+5 -2
View File
@@ -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]),
);
+3 -2
View File
@@ -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({
+247
View File
@@ -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 -1
View File
@@ -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,
+2 -2
View File
@@ -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();
}
+9
View File
@@ -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
+4
View File
@@ -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);
+1 -3
View File
@@ -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 () => {