mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
OpenAPI: Support meta.json file generation
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': minor
|
||||
---
|
||||
|
||||
Support `meta.json` file generation
|
||||
Vendored
+3
@@ -11,6 +11,9 @@
|
||||
"files.associations": {
|
||||
"**/packages/create-app/template/**/*": "plaintext"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
},
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
|
||||
@@ -3,9 +3,9 @@ import * as path from 'node:path';
|
||||
import { generateDocument, type PagesToTextOptions, toText } from './utils/pages/to-text';
|
||||
import type { ProcessedDocument } from '@/utils/process-document';
|
||||
import type { OpenAPIServer } from '@/server';
|
||||
import { createGetUrl, getSlugs } from 'fumadocs-core/source';
|
||||
import { createGetUrl, getSlugs, PathUtils } from 'fumadocs-core/source';
|
||||
import { createAutoPreset, type SchemaToPagesOptions } from '@/utils/pages/preset-auto';
|
||||
import { fromSchema, type OutputEntry } from '@/utils/pages/builder';
|
||||
import { fromSchema, type OutputGroup, type OutputEntry } from '@/utils/pages/builder';
|
||||
|
||||
export interface OutputFile {
|
||||
path: string;
|
||||
@@ -30,6 +30,7 @@ interface IndexConfig {
|
||||
}
|
||||
|
||||
interface IndexItem {
|
||||
/** output path of index page */
|
||||
path: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
@@ -37,7 +38,7 @@ interface IndexItem {
|
||||
/**
|
||||
* Specify linked pages:
|
||||
* - items in `inputs` to include all generated pages of a specific schema.
|
||||
* - specific Markdown/MDX files.
|
||||
* - file paths (using forward slash).
|
||||
*/
|
||||
only?: string[];
|
||||
}
|
||||
@@ -58,12 +59,23 @@ interface GenerateFilesConfig extends PagesToTextOptions {
|
||||
*/
|
||||
index?: IndexConfig;
|
||||
|
||||
/**
|
||||
* Generate `meta.json` files.
|
||||
*
|
||||
* Note: for flexibility, it's recommended to define them on your own.
|
||||
*/
|
||||
meta?: boolean | MetaOptions;
|
||||
|
||||
/**
|
||||
* Can add/change/remove output files before writing to file system
|
||||
**/
|
||||
beforeWrite?: (this: BeforeWriteContext, files: OutputFile[]) => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface MetaOptions {
|
||||
groupStyle?: 'folder' | 'separator';
|
||||
}
|
||||
|
||||
export type Config = SchemaToPagesOptions & GenerateFilesConfig;
|
||||
|
||||
interface BeforeWriteContext {
|
||||
@@ -106,15 +118,20 @@ export async function generateFilesOnly(
|
||||
const schemaFiles = (generated[id] ??= []);
|
||||
|
||||
generatedEntries[id] = entries;
|
||||
for (const entry of entries) {
|
||||
const file: OutputFile = {
|
||||
function scan(entry: OutputEntry) {
|
||||
if (entry.type === 'group') {
|
||||
for (const child of entry.entries) scan(child);
|
||||
return;
|
||||
}
|
||||
|
||||
schemaFiles.push({
|
||||
path: entry.path,
|
||||
content: toText(entry, schema, options),
|
||||
};
|
||||
|
||||
schemaFiles.push(file);
|
||||
files.push(file);
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of entries) scan(entry);
|
||||
files.push(...schemaFiles);
|
||||
}
|
||||
|
||||
const context: BeforeWriteContext = {
|
||||
@@ -124,21 +141,82 @@ export async function generateFilesOnly(
|
||||
};
|
||||
|
||||
if (options.index) {
|
||||
writeIndexFiles(files, context, options);
|
||||
files.push(...writeIndexFiles(context, options));
|
||||
}
|
||||
|
||||
if (options.meta) {
|
||||
files.push(...generateMeta(context, options.meta === true ? {} : options.meta));
|
||||
}
|
||||
|
||||
await options.beforeWrite?.call(context, files);
|
||||
return files;
|
||||
}
|
||||
|
||||
function generateMeta(context: BeforeWriteContext, options: MetaOptions): OutputFile[] {
|
||||
const files: OutputFile[] = [];
|
||||
const { groupStyle = 'folder' } = options;
|
||||
|
||||
function scan(entries: OutputEntry[], parent?: OutputGroup) {
|
||||
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') {
|
||||
scan(entry.entries, entry);
|
||||
|
||||
if (groupStyle === 'folder') {
|
||||
pages.push(relativePath);
|
||||
} else {
|
||||
pages.push(`---${entry.info.title}---`, `...${relativePath}`);
|
||||
}
|
||||
} else {
|
||||
pages.push(relativePath.slice(0, -path.extname(entry.path).length));
|
||||
}
|
||||
}
|
||||
|
||||
if (pages.length === 0) return;
|
||||
files.push({
|
||||
path: parent ? path.join(parent.path, 'meta.json') : 'meta.json',
|
||||
content: JSON.stringify(
|
||||
{
|
||||
title: parent?.info.title,
|
||||
description: parent?.info.description,
|
||||
pages,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
for (const entries of Object.values(context.generatedEntries)) {
|
||||
scan(entries);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function writeIndexFiles(
|
||||
files: OutputFile[],
|
||||
context: BeforeWriteContext,
|
||||
options: SchemaToPagesOptions & Omit<GenerateFilesConfig, 'output'>,
|
||||
) {
|
||||
): OutputFile[] {
|
||||
const files: OutputFile[] = [];
|
||||
const { generatedEntries } = context;
|
||||
const pathToEntry = new Map<string, OutputEntry>();
|
||||
const { items, url } = options.index!;
|
||||
|
||||
function indexEntry(entry: OutputEntry) {
|
||||
pathToEntry.set(PathUtils.slash(entry.path), entry);
|
||||
if (entry.type === 'group') {
|
||||
for (const child of entry.entries) {
|
||||
indexEntry(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let urlFn: (path: string) => string;
|
||||
if (typeof url === 'object') {
|
||||
const getUrl = createGetUrl(url.baseUrl);
|
||||
@@ -148,43 +226,37 @@ function writeIndexFiles(
|
||||
urlFn = url;
|
||||
}
|
||||
|
||||
function findEntryByPath(path: string) {
|
||||
for (const entries of Object.values(generatedEntries)) {
|
||||
const match = entries.find((entry) => entry.path === path);
|
||||
|
||||
if (match) return match;
|
||||
}
|
||||
}
|
||||
|
||||
function fileContent(index: IndexItem): string {
|
||||
const content: string[] = [];
|
||||
content.push('<Cards>');
|
||||
const pathToEntry = new Map<string, OutputEntry>();
|
||||
const outputEntries: OutputEntry[] = [];
|
||||
const only = index.only ?? Object.keys(context.generated);
|
||||
|
||||
for (const item of only) {
|
||||
if (generatedEntries[item]) {
|
||||
for (const entry of generatedEntries[item]) {
|
||||
pathToEntry.set(entry.path, entry);
|
||||
outputEntries.push(entry);
|
||||
}
|
||||
} else {
|
||||
const match = findEntryByPath(item);
|
||||
const match = pathToEntry.get(item);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`${item} does not exist on "input", available: ${Object.keys(generatedEntries).join(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
pathToEntry.set(match.path, match);
|
||||
outputEntries.push(match);
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of pathToEntry.values()) {
|
||||
const descriptionAttr = file.info.description
|
||||
? `description=${JSON.stringify(file.info.description)} `
|
||||
for (const entry of outputEntries) {
|
||||
// cannot link to groups
|
||||
if (entry.type === 'group') continue;
|
||||
const descriptionAttr = entry.info.description
|
||||
? `description=${JSON.stringify(entry.info.description)} `
|
||||
: '';
|
||||
content.push(
|
||||
`<Card href="${urlFn(file.path)}" title=${JSON.stringify(file.info.title)} ${descriptionAttr}/>`,
|
||||
`<Card href="${urlFn(entry.path)}" title=${JSON.stringify(entry.info.title)} ${descriptionAttr}/>`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -199,10 +271,16 @@ function writeIndexFiles(
|
||||
);
|
||||
}
|
||||
|
||||
for (const list of Object.values(context.generatedEntries)) {
|
||||
for (const item of list) indexEntry(item);
|
||||
}
|
||||
|
||||
for (const item of typeof items === 'function' ? items(context) : items) {
|
||||
files.push({
|
||||
path: path.extname(item.path).length === 0 ? `${item.path}.mdx` : item.path,
|
||||
content: fileContent(item),
|
||||
});
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { MethodLabel } from '@/ui/components/method-label';
|
||||
import type {
|
||||
LoaderPlugin,
|
||||
PageData,
|
||||
PageTreeTransformer,
|
||||
Source,
|
||||
VirtualFile,
|
||||
import {
|
||||
PathUtils,
|
||||
type LoaderPlugin,
|
||||
type MetaData,
|
||||
type PageData,
|
||||
type PageTreeTransformer,
|
||||
type Source,
|
||||
type VirtualFile,
|
||||
} from 'fumadocs-core/source';
|
||||
import type { OpenAPIServer } from '@/server/create';
|
||||
import type { SchemaToPagesOptions } from '@/utils/pages/preset-auto';
|
||||
@@ -12,6 +14,13 @@ import type { ApiPageProps } from '@/ui/api-page';
|
||||
import type { StructuredData } from 'fumadocs-core/mdx-plugins';
|
||||
import type { TOCItemType } from 'fumadocs-core/toc';
|
||||
import type { ProcessedDocument } from '@/utils/process-document';
|
||||
import type {
|
||||
OperationOutput,
|
||||
OutputEntry,
|
||||
PageOutput,
|
||||
WebhookOutput,
|
||||
} from '@/utils/pages/builder';
|
||||
import path from 'node:path';
|
||||
|
||||
declare module 'fumadocs-core/source' {
|
||||
export interface PageData {
|
||||
@@ -76,6 +85,10 @@ interface OpenAPIPageData extends PageData {
|
||||
toc: TOCItemType[];
|
||||
}
|
||||
|
||||
interface MetaOptions {
|
||||
folderStyle?: 'folder' | 'separator';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate virtual pages for Fumadocs Source API
|
||||
*/
|
||||
@@ -83,29 +96,30 @@ export async function openapiSource(
|
||||
server: OpenAPIServer,
|
||||
options: SchemaToPagesOptions & {
|
||||
baseDir?: string;
|
||||
/** Generate `meta.json` files */
|
||||
meta?: boolean | MetaOptions;
|
||||
} = {},
|
||||
): Promise<
|
||||
Source<{
|
||||
metaData: never;
|
||||
metaData: MetaData;
|
||||
pageData: OpenAPIPageData;
|
||||
}>
|
||||
> {
|
||||
const { baseDir = '' } = options;
|
||||
const { baseDir = '', meta = false } = options;
|
||||
const { createAutoPreset } = await import('@/utils/pages/preset-auto');
|
||||
const { fromServer } = await import('@/utils/pages/builder');
|
||||
const { toBody } = await import('@/utils/pages/to-body');
|
||||
const { toStaticData } = await import('@/utils/pages/to-static-data');
|
||||
const files: VirtualFile<{
|
||||
pageData: OpenAPIPageData;
|
||||
metaData: never;
|
||||
metaData: MetaData;
|
||||
}>[] = [];
|
||||
|
||||
const entries = await fromServer(server, createAutoPreset(options));
|
||||
for (const [schemaId, list] of Object.entries(entries)) {
|
||||
const processed = await server.getSchema(schemaId);
|
||||
for (const entry of list) {
|
||||
const props = toBody(entry);
|
||||
props.showDescription ??= true;
|
||||
|
||||
function onEntry(entry: PageOutput | OperationOutput | WebhookOutput) {
|
||||
const props = getProps(entry);
|
||||
|
||||
files.push({
|
||||
type: 'page',
|
||||
@@ -132,6 +146,54 @@ export async function openapiSource(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -139,6 +201,29 @@ export async function openapiSource(
|
||||
};
|
||||
}
|
||||
|
||||
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()`
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ProcessedDocument } from '@/utils/process-document';
|
||||
import type { OpenAPIServer } from '@/server';
|
||||
import type { OperationItem, WebhookItem } from '@/ui/api-page';
|
||||
import type { Document, OperationObject, PathItemObject, TagObject } from '@/types';
|
||||
import type { OperationObject, PathItemObject, TagObject } from '@/types';
|
||||
import { getTagDisplayName, methodKeys, type NoReference } from '@/utils/schema';
|
||||
import { idToTitle } from '@/utils/id-to-title';
|
||||
|
||||
@@ -24,21 +24,22 @@ export interface WebhookOutput extends BaseEntry {
|
||||
item: WebhookItem;
|
||||
}
|
||||
|
||||
export interface TagOutput extends BaseEntry {
|
||||
type: 'tag';
|
||||
tag: string;
|
||||
rawTag: TagObject;
|
||||
export interface PageOutput extends BaseEntry {
|
||||
type: 'page';
|
||||
operations: OperationItem[];
|
||||
webhooks: WebhookItem[];
|
||||
/** tag info if the page is generated from a tag. */
|
||||
tag?: TagObject;
|
||||
}
|
||||
|
||||
export interface OutputGroup extends BaseEntry {
|
||||
type: 'group';
|
||||
operations: OperationItem[];
|
||||
webhooks: WebhookItem[];
|
||||
entries: OutputEntry[];
|
||||
/** tag info if the group is generated from a tag. */
|
||||
tag?: TagObject;
|
||||
}
|
||||
|
||||
export type OutputEntry = TagOutput | OperationOutput | WebhookOutput | OutputGroup;
|
||||
export type OutputEntry = PageOutput | OperationOutput | WebhookOutput | OutputGroup;
|
||||
|
||||
export interface PagesBuilderConfig {
|
||||
toPages: (builder: PagesBuilder) => void;
|
||||
@@ -52,8 +53,6 @@ export interface PagesBuilder {
|
||||
document: ProcessedDocument;
|
||||
/**
|
||||
* add output entry.
|
||||
*
|
||||
* When the `path` property is unspecified, it will generate one.
|
||||
*/
|
||||
create: (entry: OutputEntry) => void;
|
||||
|
||||
@@ -132,7 +131,39 @@ export function fromSchema(
|
||||
create(entry) {
|
||||
files.push(entry);
|
||||
},
|
||||
extract: () => extractInfo(dereferenced),
|
||||
extract() {
|
||||
const result: ExtractedInfo = { webhooks: [], operations: [] };
|
||||
|
||||
for (const [path, pathItem] of Object.entries(dereferenced.paths ?? {})) {
|
||||
if (!pathItem) continue;
|
||||
|
||||
for (const methodKey of methodKeys) {
|
||||
if (!pathItem[methodKey]) continue;
|
||||
|
||||
result.operations.push({
|
||||
method: methodKey,
|
||||
path,
|
||||
tags: pathItem[methodKey]?.tags,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, pathItem] of Object.entries(dereferenced.webhooks ?? {})) {
|
||||
if (!pathItem) continue;
|
||||
|
||||
for (const methodKey of methodKeys) {
|
||||
if (!pathItem[methodKey]) continue;
|
||||
|
||||
result.webhooks.push({
|
||||
method: methodKey,
|
||||
name,
|
||||
tags: pathItem[methodKey]?.tags,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
routePathToFilePath(path) {
|
||||
return path
|
||||
.toLowerCase()
|
||||
@@ -195,37 +226,3 @@ export function fromSchema(
|
||||
|
||||
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,
|
||||
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,
|
||||
name,
|
||||
tags: pathItem[methodKey]?.tags,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ import type {
|
||||
OperationOutput,
|
||||
OutputEntry,
|
||||
OutputGroup,
|
||||
PageOutput,
|
||||
PagesBuilder,
|
||||
PagesBuilderConfig,
|
||||
TagOutput,
|
||||
WebhookOutput,
|
||||
} from '@/utils/pages/builder';
|
||||
import { isUrl } from '@/utils/url';
|
||||
import type { DistributiveOmit } from '@/types';
|
||||
|
||||
interface OperationConfig extends BaseConfig {
|
||||
/**
|
||||
@@ -26,7 +27,11 @@ interface OperationConfig extends BaseConfig {
|
||||
*
|
||||
* @defaultValue 'none'
|
||||
*/
|
||||
groupBy?: 'tag' | 'route' | 'none' | ((entry: OperationOutput | WebhookOutput) => string);
|
||||
groupBy?:
|
||||
| 'tag'
|
||||
| 'route'
|
||||
| 'none'
|
||||
| ((entry: DistributiveOmit<OperationOutput | WebhookOutput, 'path'>) => string);
|
||||
|
||||
/**
|
||||
* Specify name for output file
|
||||
@@ -43,7 +48,7 @@ interface TagConfig extends BaseConfig {
|
||||
/**
|
||||
* Specify name for output file
|
||||
*/
|
||||
name?: NameFn<TagOutput> | NameFnOptions;
|
||||
name?: NameFn<PageOutput> | NameFnOptions;
|
||||
}
|
||||
|
||||
interface SchemaConfig extends BaseConfig {
|
||||
@@ -55,7 +60,7 @@ interface SchemaConfig extends BaseConfig {
|
||||
/**
|
||||
* Specify name for output file
|
||||
*/
|
||||
name?: NameFn<OutputGroup> | NameFnOptions;
|
||||
name?: NameFn<PageOutput> | NameFnOptions;
|
||||
}
|
||||
|
||||
export type SchemaToPagesOptions =
|
||||
@@ -66,9 +71,14 @@ export type SchemaToPagesOptions =
|
||||
per: 'custom';
|
||||
} & PagesBuilderConfig);
|
||||
|
||||
type NameFn<Entry> = (
|
||||
type NameFn<
|
||||
Entry extends OperationOutput | WebhookOutput | PageOutput =
|
||||
| OperationOutput
|
||||
| WebhookOutput
|
||||
| PageOutput,
|
||||
> = (
|
||||
this: PagesBuilder,
|
||||
output: Entry,
|
||||
output: DistributiveOmit<Entry, 'path'>,
|
||||
document: ProcessedDocument['dereferenced'],
|
||||
) => string;
|
||||
|
||||
@@ -100,19 +110,16 @@ export function createAutoPreset(options: SchemaToPagesOptions): PagesBuilderCon
|
||||
return s.replace(/\s+/g, '-').toLowerCase();
|
||||
},
|
||||
} = options;
|
||||
let nameFn: NameFn<OutputEntry>;
|
||||
let nameFn: NameFn;
|
||||
|
||||
if (typeof options.name === 'function') {
|
||||
nameFn = options.name as NameFn<OutputEntry>;
|
||||
nameFn = options.name as NameFn;
|
||||
} else {
|
||||
const { algorithm = 'v2' } = options.name ?? {};
|
||||
|
||||
nameFn = function (result, document) {
|
||||
if (result.type === 'tag') {
|
||||
return slugify(result.tag);
|
||||
}
|
||||
|
||||
if (result.type === 'group') {
|
||||
if (result.type === 'page') {
|
||||
if (result.tag) return slugify(result.tag.name!);
|
||||
const schemaId = result.schemaId;
|
||||
|
||||
return isUrl(schemaId) ? 'index' : path.basename(schemaId, path.extname(schemaId));
|
||||
@@ -141,44 +148,114 @@ export function createAutoPreset(options: SchemaToPagesOptions): PagesBuilderCon
|
||||
};
|
||||
}
|
||||
|
||||
function groupOutput(builder: PagesBuilder, entry: OperationOutput | WebhookOutput): string[] {
|
||||
function group(
|
||||
builder: PagesBuilder,
|
||||
entries: DistributiveOmit<OperationOutput | WebhookOutput, 'path'>[],
|
||||
): OutputEntry[] {
|
||||
const groups = new Map<string, OutputGroup>();
|
||||
const rest: OutputEntry[] = [];
|
||||
const { dereferenced } = builder.document;
|
||||
const { groupBy = 'none' } = options as OperationConfig;
|
||||
|
||||
if (groupBy === 'route') {
|
||||
return [
|
||||
path.join(
|
||||
builder.routePathToFilePath(
|
||||
for (const entry of entries) {
|
||||
switch (groupBy) {
|
||||
case 'route': {
|
||||
const groupName = 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;
|
||||
let group = groups.get(groupName);
|
||||
if (!group) {
|
||||
group = {
|
||||
type: 'group',
|
||||
info: { title: groupName },
|
||||
entries: [],
|
||||
schemaId: builder.id,
|
||||
path: groupName,
|
||||
};
|
||||
groups.set(groupName, group);
|
||||
}
|
||||
|
||||
if (!tags || tags.length === 0) {
|
||||
console.warn(
|
||||
'When `groupBy` is set to `tag`, make sure a `tags` is defined for every operation schema.',
|
||||
);
|
||||
group.entries.push({
|
||||
...entry,
|
||||
path: path.join(groupName, `${entry.item.method.toLowerCase()}.mdx`),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'tag': {
|
||||
let tags =
|
||||
entry.type === 'operation'
|
||||
? dereferenced.paths![entry.item.path]![entry.item.method]!.tags
|
||||
: dereferenced.webhooks![entry.item.name][entry.item.method]!.tags;
|
||||
|
||||
tags = ['unknown'];
|
||||
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'];
|
||||
}
|
||||
|
||||
for (const tag of tags) {
|
||||
const groupName = slugify(tag);
|
||||
const { displayName, info } = builder.fromTagName(tag)!;
|
||||
let group = groups.get(groupName);
|
||||
if (!group) {
|
||||
group = {
|
||||
type: 'group',
|
||||
info: { title: displayName, description: info.description },
|
||||
tag: info,
|
||||
entries: [],
|
||||
schemaId: builder.id,
|
||||
path: groupName,
|
||||
};
|
||||
groups.set(groupName, group);
|
||||
}
|
||||
|
||||
group.entries.push({
|
||||
...entry,
|
||||
path: path.join(groupName, `${nameFn.call(builder, entry, dereferenced)}.mdx`),
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const fileName = `${nameFn.call(builder, entry, dereferenced)}.mdx`;
|
||||
|
||||
if (typeof groupBy === 'function') {
|
||||
const groupDisplayName = groupBy(entry);
|
||||
const groupName = slugify(groupDisplayName);
|
||||
|
||||
let group = groups.get(groupName);
|
||||
if (!group) {
|
||||
group = {
|
||||
type: 'group',
|
||||
info: { title: groupDisplayName },
|
||||
entries: [],
|
||||
schemaId: builder.id,
|
||||
path: groupName,
|
||||
};
|
||||
groups.set(groupName, group);
|
||||
}
|
||||
|
||||
group.entries.push({
|
||||
...entry,
|
||||
path: path.join(groupName, fileName),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
rest.push({
|
||||
...entry,
|
||||
path: fileName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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`];
|
||||
rest.push(...groups.values());
|
||||
return rest;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -187,8 +264,8 @@ export function createAutoPreset(options: SchemaToPagesOptions): PagesBuilderCon
|
||||
const items = builder.extract();
|
||||
|
||||
if (options.per === 'file') {
|
||||
const entry: OutputGroup = {
|
||||
type: 'group',
|
||||
const entry: PageOutput = {
|
||||
type: 'page',
|
||||
schemaId: builder.id,
|
||||
path: '',
|
||||
info: {
|
||||
@@ -197,17 +274,18 @@ export function createAutoPreset(options: SchemaToPagesOptions): PagesBuilderCon
|
||||
},
|
||||
...items,
|
||||
};
|
||||
entry.path = nameFn.call(builder, entry, dereferenced) + '.mdx';
|
||||
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',
|
||||
const entry: PageOutput = {
|
||||
type: 'page',
|
||||
path: '',
|
||||
schemaId: builder.id,
|
||||
info: {
|
||||
@@ -216,40 +294,35 @@ export function createAutoPreset(options: SchemaToPagesOptions): PagesBuilderCon
|
||||
},
|
||||
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,
|
||||
tag,
|
||||
};
|
||||
|
||||
entry.path = nameFn.call(builder, entry, dereferenced) + '.mdx';
|
||||
entry.path = `${nameFn.call(builder, entry, dereferenced)}.mdx`;
|
||||
builder.create(entry);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const entries: DistributiveOmit<OperationOutput | WebhookOutput, 'path'>[] = [];
|
||||
for (const op of items.operations) {
|
||||
const { pathItem, operation, displayName } = builder.fromExtractedOperation(op)!;
|
||||
|
||||
const entry: OperationOutput = {
|
||||
entries.push({
|
||||
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 = {
|
||||
entries.push({
|
||||
type: 'webhook',
|
||||
schemaId: builder.id,
|
||||
info: {
|
||||
@@ -257,12 +330,11 @@ export function createAutoPreset(options: SchemaToPagesOptions): PagesBuilderCon
|
||||
description: operation.description ?? pathItem.description,
|
||||
},
|
||||
item: webhook,
|
||||
path: '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
for (const outputPath of groupOutput(builder, entry)) {
|
||||
builder.create({ ...entry, path: outputPath });
|
||||
}
|
||||
for (const entry of group(builder, entries)) {
|
||||
builder.create(entry);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { ApiPageProps } from '@/ui/api-page';
|
||||
import type { OutputEntry } from '@/utils/pages/builder';
|
||||
|
||||
export function toBody(entry: OutputEntry): ApiPageProps {
|
||||
if (entry.type === 'operation')
|
||||
return {
|
||||
document: entry.schemaId,
|
||||
operations: [entry.item],
|
||||
};
|
||||
if (entry.type === 'webhook')
|
||||
return {
|
||||
document: entry.schemaId,
|
||||
webhooks: [entry.item],
|
||||
};
|
||||
|
||||
return {
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
document: entry.schemaId,
|
||||
operations: entry.operations,
|
||||
webhooks: entry.webhooks,
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { ProcessedDocument } from '@/utils/process-document';
|
||||
import type { TagObject } from '@/types';
|
||||
import { dump } from 'js-yaml';
|
||||
import { removeUndefined } from '@/utils/remove-undefined';
|
||||
import type { OutputEntry } from '@/utils/pages/builder';
|
||||
import type { OperationOutput, PageOutput, WebhookOutput } from '@/utils/pages/builder';
|
||||
import type { InternalOpenAPIMeta } from '@/server/source-api';
|
||||
import { toStaticData } from '@/utils/pages/to-static-data';
|
||||
|
||||
@@ -48,7 +48,7 @@ export interface PagesToTextOptions {
|
||||
}
|
||||
|
||||
export function toText(
|
||||
entry: OutputEntry,
|
||||
entry: PageOutput | OperationOutput | WebhookOutput,
|
||||
processed: ProcessedDocument,
|
||||
options: PagesToTextOptions = {},
|
||||
) {
|
||||
@@ -68,7 +68,7 @@ export function toText(
|
||||
type: 'operation',
|
||||
},
|
||||
);
|
||||
case 'group':
|
||||
case 'page':
|
||||
return generatePage(
|
||||
entry.schemaId,
|
||||
processed,
|
||||
@@ -81,27 +81,14 @@ export function toText(
|
||||
...options,
|
||||
...entry.info,
|
||||
},
|
||||
{
|
||||
type: 'file',
|
||||
},
|
||||
);
|
||||
case 'tag':
|
||||
return generatePage(
|
||||
entry.schemaId,
|
||||
processed,
|
||||
{
|
||||
operations: entry.operations,
|
||||
webhooks: entry.webhooks,
|
||||
showTitle: true,
|
||||
},
|
||||
{
|
||||
...options,
|
||||
...entry.info,
|
||||
},
|
||||
{
|
||||
type: 'tag',
|
||||
tag: entry.rawTag,
|
||||
},
|
||||
entry.tag
|
||||
? {
|
||||
type: 'tag',
|
||||
tag: entry.tag,
|
||||
}
|
||||
: {
|
||||
type: 'file',
|
||||
},
|
||||
);
|
||||
case 'webhook':
|
||||
return generatePage(
|
||||
|
||||
@@ -129,6 +129,35 @@ describe('Generate documents', () => {
|
||||
|
||||
await expect(stringifyOutput(out)).toMatchFileSnapshot('./out/products-with-index.md');
|
||||
});
|
||||
|
||||
test('Generate Files - with meta', async () => {
|
||||
const out = await generateFilesOnly({
|
||||
input: createOpenAPI({
|
||||
input: () => ({
|
||||
products: path.join(cwd, './fixtures/products.yaml'),
|
||||
}),
|
||||
}),
|
||||
per: 'operation',
|
||||
meta: true,
|
||||
});
|
||||
|
||||
await expect(stringifyOutput(out)).toMatchFileSnapshot('./out/products-with-meta.md');
|
||||
});
|
||||
|
||||
test('Generate Files - with meta + groupBy', async () => {
|
||||
const out = await generateFilesOnly({
|
||||
input: createOpenAPI({
|
||||
input: () => ({
|
||||
products: path.join(cwd, './fixtures/products.yaml'),
|
||||
}),
|
||||
}),
|
||||
per: 'operation',
|
||||
groupBy: 'tag',
|
||||
meta: true,
|
||||
});
|
||||
|
||||
await expect(stringifyOutput(out)).toMatchFileSnapshot('./out/products-with-meta+groupby.md');
|
||||
});
|
||||
});
|
||||
|
||||
function stringifyOutput(output: OutputFile[]) {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
```mdx title="inventory/getInventoryForProduct.mdx"
|
||||
---
|
||||
title: Get product inventory
|
||||
description: Retrieve inventory details for a specific product.
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: Retrieve inventory details for a specific product.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
<APIPage document={"products"} operations={[{"path":"/inventory/{productId}","method":"get"}]} />
|
||||
```
|
||||
|
||||
```json title="inventory/meta.json"
|
||||
{
|
||||
"title": "Inventory",
|
||||
"description": "Operations related to inventory.",
|
||||
"pages": [
|
||||
"getInventoryForProduct"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```json title="meta.json"
|
||||
{
|
||||
"pages": [
|
||||
"products",
|
||||
"inventory"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```mdx title="products/getInventoryForProduct.mdx"
|
||||
---
|
||||
title: Get product inventory
|
||||
description: Retrieve inventory details for a specific product.
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: Retrieve inventory details for a specific product.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
<APIPage document={"products"} operations={[{"path":"/inventory/{productId}","method":"get"}]} />
|
||||
```
|
||||
|
||||
```mdx title="products/getProductDetails.mdx"
|
||||
---
|
||||
title: Get product details
|
||||
description: Retrieve details of a product by its ID.
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: Retrieve details of a product by its ID.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
<APIPage document={"products"} operations={[{"path":"/products/{productId}","method":"get"}]} />
|
||||
```
|
||||
|
||||
```json title="products/meta.json"
|
||||
{
|
||||
"title": "Products",
|
||||
"description": "Operations related to products.",
|
||||
"pages": [
|
||||
"getProductDetails",
|
||||
"getInventoryForProduct"
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,46 @@
|
||||
```mdx title="getInventoryForProduct.mdx"
|
||||
---
|
||||
title: Get product inventory
|
||||
description: Retrieve inventory details for a specific product.
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: Retrieve inventory details for a specific product.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
<APIPage document={"products"} operations={[{"path":"/inventory/{productId}","method":"get"}]} />
|
||||
```
|
||||
|
||||
```mdx title="getProductDetails.mdx"
|
||||
---
|
||||
title: Get product details
|
||||
description: Retrieve details of a product by its ID.
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: Retrieve details of a product by its ID.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
<APIPage document={"products"} operations={[{"path":"/products/{productId}","method":"get"}]} />
|
||||
```
|
||||
|
||||
```json title="meta.json"
|
||||
{
|
||||
"pages": [
|
||||
"getProductDetails",
|
||||
"getInventoryForProduct"
|
||||
]
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user