OpenAPI: Unify input id and output documents

This commit is contained in:
Fuma Nama
2025-08-28 15:03:57 +08:00
parent 302cdc2fde
commit e1b61ea6e2
7 changed files with 99 additions and 68 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Unify input id and output documents
@@ -234,7 +234,7 @@ The server to render pages.
### Input
- File Paths (including wildcards)
- File Paths
- External URLs
- A function (see below)
+84 -58
View File
@@ -111,10 +111,19 @@ interface IndexItem {
path: string;
title?: string;
description?: string;
/**
* Only include items from specific input schema ids
* Specify linked pages:
* - items in `inputs` to include all generated pages of a specific schema.
* - specific Markdown/MDX files.
*/
only?: string[];
only?: (string | OutputFile)[];
}
interface HookContext {
files: OutputFile[];
readonly generated: Record<string, OutputFile[]>;
readonly documents: Record<string, ProcessedDocument>;
}
interface BaseConfig extends GenerateOptions {
@@ -139,7 +148,7 @@ interface BaseConfig extends GenerateOptions {
* Generate index files with cards linking to generated pages.
*/
index?: {
items: IndexItem[];
items: IndexItem[] | ((ctx: HookContext) => IndexItem[]);
/**
* Generate URLs for cards
@@ -158,7 +167,10 @@ interface BaseConfig extends GenerateOptions {
/**
* Can add/change/remove output files before writing to file system
**/
beforeWrite?: (files: OutputFile[]) => void | Promise<void>;
beforeWrite?: (
this: HookContext,
files: OutputFile[],
) => void | Promise<void>;
}
export async function generateFiles(options: Config): Promise<void> {
@@ -181,46 +193,59 @@ export async function generateFilesOnly(
typeof options.input === 'string' ? [options.input] : options.input;
let schemas: Record<string, ProcessedDocument> = {};
if (Array.isArray(input)) {
const targets: string[] = [];
const patterns: string[] = [];
for (const item of input) {
if (isUrl(item)) targets.push(item);
else patterns.push(item);
async function resolveInput(item: string) {
if (isUrl(item)) {
schemas[item] = await processDocumentCached(item);
return;
}
if (patterns.length > 0) targets.push(...(await glob(patterns, { cwd })));
const resolved = await glob(item, { cwd, absolute: true });
if (resolved.length > 1) {
console.warn(
'glob patterns in `input` are deprecated, please specify your schemas explicitly.',
);
await Promise.all(
targets.map(async (item) => {
schemas[item] = await processDocumentCached(path.join(cwd, item));
}),
);
for (let i = 0; i < resolved.length; i++) {
schemas[`${item}[${i}]`] = await processDocumentCached(item);
}
} else if (resolved.length === 1) {
schemas[item] = await processDocumentCached(resolved[0]);
} else {
throw new Error(`input not found: ${item}`);
}
}
if (Array.isArray(input)) {
await Promise.all(input.map(resolveInput));
} else {
schemas = await input.getSchemas();
}
const resolvedSchemas = Object.entries(schemas);
if (resolvedSchemas.length === 0) {
throw new Error('No input files found.');
}
const documentFiles = new Map<string, OutputFile[]>();
const generated: Record<string, OutputFile[]> = {};
const files: OutputFile[] = [];
for (const [id, schema] of resolvedSchemas) {
const entries = Object.entries(schemas);
if (entries.length === 0) {
throw new Error('No input files found.');
}
for (const [id, schema] of entries) {
const result = generateFromDocument(id, schema, options);
files.push(...result);
documentFiles.set(id, result);
generated[id] = result;
}
const context: HookContext = {
files,
generated,
documents: schemas,
};
if (options.index) {
files.push(...generateIndexFiles(documentFiles, options));
writeIndexFiles(context, options);
}
beforeWrite?.(files);
return files;
await beforeWrite?.call(context, context.files);
return context.files;
}
function generateFromDocument(
@@ -411,13 +436,9 @@ function getOutputPathFromRoute(path: string): string {
);
}
function generateIndexFiles(
generatedFiles: Map<string, OutputFile[]>,
options: Config,
): OutputFile[] {
const files: OutputFile[] = [];
function writeIndexFiles(context: HookContext, options: Config) {
const { index, output, cwd = process.cwd() } = options;
if (!index) return files;
if (!index) return;
const { items, url } = index;
let urlFn: (path: string) => string;
@@ -430,26 +451,31 @@ function generateIndexFiles(
urlFn = url;
}
function fileContent(item: IndexItem): string {
function fileContent(index: IndexItem): string {
const generatedPages = context.generated;
const content: string[] = [];
content.push('<Cards>');
const files: OutputFile[] = [];
if (item.only) {
for (let id of item.only) {
if (id.startsWith('./')) id = id.slice(2);
const files = new Map<string, OutputFile>();
const only = index.only ?? Object.keys(context.generated);
const result = generatedFiles.get(id);
if (!result)
throw new Error(
`${id} does not exist on "input", available: ${Array.from(generatedFiles.keys())}.`,
);
files.push(...result);
for (const item of only) {
if (typeof item === 'object') {
files.set(item.path, item);
continue;
}
const result = generatedPages[item];
if (!result)
throw new Error(
`${item} does not exist on "input", available: ${Object.keys(generatedPages).join(', ')}.`,
);
for (const file of result) {
files.set(file.path, file);
}
} else {
for (const value of generatedFiles.values()) files.push(...value);
}
for (const file of files) {
for (const file of files.values()) {
const isContent = file.path.endsWith('.mdx') || file.path.endsWith('.md');
if (!isContent) continue;
const { data } = matter(file.content);
@@ -463,8 +489,8 @@ function generateIndexFiles(
content.push('</Cards>');
return generateDocument(
{
title: item.title ?? 'Overview',
description: item.description,
title: index.title ?? 'Overview',
description: index.description,
},
content.join('\n'),
options,
@@ -473,17 +499,17 @@ function generateIndexFiles(
const outputDir = path.join(cwd, output);
for (const item of items) {
files.push({
path: path.join(
outputDir,
path.extname(item.path).length === 0 ? `${item.path}.mdx` : item.path,
),
for (const item of typeof items === 'function' ? items(context) : items) {
const outPath = path.join(
outputDir,
path.extname(item.path).length === 0 ? `${item.path}.mdx` : item.path,
);
context.files.push({
path: outPath,
content: fileContent(item),
});
}
return files;
}
function defaultSlugify(s: string): string {
+2 -2
View File
@@ -85,7 +85,7 @@ describe('Generate documents', () => {
cwd,
}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: No input files found.]`,
`[Error: input not found: ./fixtures/non-existent-*.yaml]`,
);
await expect(
@@ -102,7 +102,7 @@ describe('Generate documents', () => {
},
}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: No input files found.]`,
`[Error: input not found: ./fixtures/non-existent-1.yaml]`,
);
});
+2 -2
View File
@@ -1,10 +1,10 @@
[
{
"path": "~out/museum.mdx",
"content": "---\ntitle: Redocly Museum API\ndescription: >-\n An imaginary, but delightful Museum API for interacting with museum services\n and information. Built with love by Redocly.\nfull: true\n_openapi:\n toc:\n - depth: 2\n title: Get museum hours\n url: '#get-museum-hours'\n - depth: 2\n title: List special events\n url: '#list-special-events'\n - depth: 2\n title: Create special events\n url: '#create-special-events'\n - depth: 2\n title: Get special event\n url: '#get-special-event'\n - depth: 2\n title: Update special event\n url: '#update-special-event'\n - depth: 2\n title: Delete special event\n url: '#delete-special-event'\n - depth: 2\n title: Buy museum tickets\n url: '#buy-museum-tickets'\n - depth: 2\n title: Get ticket QR code\n url: '#get-ticket-qr-code'\n structuredData:\n headings:\n - content: Get museum hours\n id: get-museum-hours\n - content: List special events\n id: list-special-events\n - content: Create special events\n id: create-special-events\n - content: Get special event\n id: get-special-event\n - content: Update special event\n id: update-special-event\n - content: Delete special event\n id: delete-special-event\n - content: Buy museum tickets\n id: buy-museum-tickets\n - content: Get ticket QR code\n id: get-ticket-qr-code\n contents:\n - content: Get upcoming museum operating hours\n heading: get-museum-hours\n - content: Return a list of upcoming special events at the museum.\n heading: list-special-events\n - content: Creates a new special event for the museum.\n heading: create-special-events\n - content: Get details about a special event.\n heading: get-special-event\n - content: Update the details of a special event\n heading: update-special-event\n - content: >-\n Delete a special event from the collection. Allows museum to cancel\n planned events.\n heading: delete-special-event\n - content: Purchase museum tickets for general entry or special events.\n heading: buy-museum-tickets\n - content: >-\n Return an image of your ticket with scannable QR code. Used for event\n entry.\n heading: get-ticket-qr-code\n---\n\n{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}\n\n<APIPage document={\"fixtures/museum.yaml\"} operations={[{\"path\":\"/museum-hours\",\"method\":\"get\"},{\"path\":\"/special-events\",\"method\":\"get\"},{\"path\":\"/special-events\",\"method\":\"post\"},{\"path\":\"/special-events/{eventId}\",\"method\":\"get\"},{\"path\":\"/special-events/{eventId}\",\"method\":\"patch\"},{\"path\":\"/special-events/{eventId}\",\"method\":\"delete\"},{\"path\":\"/tickets\",\"method\":\"post\"},{\"path\":\"/tickets/{ticketId}/qr\",\"method\":\"get\"}]} webhooks={[]} hasHead={true} />"
"content": "---\ntitle: Redocly Museum API\ndescription: >-\n An imaginary, but delightful Museum API for interacting with museum services\n and information. Built with love by Redocly.\nfull: true\n_openapi:\n toc:\n - depth: 2\n title: Get museum hours\n url: '#get-museum-hours'\n - depth: 2\n title: List special events\n url: '#list-special-events'\n - depth: 2\n title: Create special events\n url: '#create-special-events'\n - depth: 2\n title: Get special event\n url: '#get-special-event'\n - depth: 2\n title: Update special event\n url: '#update-special-event'\n - depth: 2\n title: Delete special event\n url: '#delete-special-event'\n - depth: 2\n title: Buy museum tickets\n url: '#buy-museum-tickets'\n - depth: 2\n title: Get ticket QR code\n url: '#get-ticket-qr-code'\n structuredData:\n headings:\n - content: Get museum hours\n id: get-museum-hours\n - content: List special events\n id: list-special-events\n - content: Create special events\n id: create-special-events\n - content: Get special event\n id: get-special-event\n - content: Update special event\n id: update-special-event\n - content: Delete special event\n id: delete-special-event\n - content: Buy museum tickets\n id: buy-museum-tickets\n - content: Get ticket QR code\n id: get-ticket-qr-code\n contents:\n - content: Get upcoming museum operating hours\n heading: get-museum-hours\n - content: Return a list of upcoming special events at the museum.\n heading: list-special-events\n - content: Creates a new special event for the museum.\n heading: create-special-events\n - content: Get details about a special event.\n heading: get-special-event\n - content: Update the details of a special event\n heading: update-special-event\n - content: >-\n Delete a special event from the collection. Allows museum to cancel\n planned events.\n heading: delete-special-event\n - content: Purchase museum tickets for general entry or special events.\n heading: buy-museum-tickets\n - content: >-\n Return an image of your ticket with scannable QR code. Used for event\n entry.\n heading: get-ticket-qr-code\n---\n\n{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}\n\n<APIPage document={\"./fixtures/museum.yaml\"} operations={[{\"path\":\"/museum-hours\",\"method\":\"get\"},{\"path\":\"/special-events\",\"method\":\"get\"},{\"path\":\"/special-events\",\"method\":\"post\"},{\"path\":\"/special-events/{eventId}\",\"method\":\"get\"},{\"path\":\"/special-events/{eventId}\",\"method\":\"patch\"},{\"path\":\"/special-events/{eventId}\",\"method\":\"delete\"},{\"path\":\"/tickets\",\"method\":\"post\"},{\"path\":\"/tickets/{ticketId}/qr\",\"method\":\"get\"}]} webhooks={[]} hasHead={true} />"
},
{
"path": "~out/petstore.mdx",
"content": "---\ntitle: Swagger Petstore\nfull: true\n_openapi:\n toc:\n - depth: 2\n title: List all pets\n url: '#list-all-pets'\n - depth: 2\n title: Create a pet\n url: '#create-a-pet'\n - depth: 2\n title: Info for a specific pet\n url: '#info-for-a-specific-pet'\n structuredData:\n headings:\n - content: List all pets\n id: list-all-pets\n - content: Create a pet\n id: create-a-pet\n - content: Info for a specific pet\n id: info-for-a-specific-pet\n contents: []\n---\n\n{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}\n\n<APIPage document={\"fixtures/petstore.yaml\"} operations={[{\"path\":\"/pets\",\"method\":\"get\"},{\"path\":\"/pets\",\"method\":\"post\"},{\"path\":\"/pets/{petId}\",\"method\":\"get\"}]} webhooks={[]} hasHead={true} />"
"content": "---\ntitle: Swagger Petstore\nfull: true\n_openapi:\n toc:\n - depth: 2\n title: List all pets\n url: '#list-all-pets'\n - depth: 2\n title: Create a pet\n url: '#create-a-pet'\n - depth: 2\n title: Info for a specific pet\n url: '#info-for-a-specific-pet'\n structuredData:\n headings:\n - content: List all pets\n id: list-all-pets\n - content: Create a pet\n id: create-a-pet\n - content: Info for a specific pet\n id: info-for-a-specific-pet\n contents: []\n---\n\n{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}\n\n<APIPage document={\"./fixtures/petstore.yaml\"} operations={[{\"path\":\"/pets\",\"method\":\"get\"},{\"path\":\"/pets\",\"method\":\"post\"},{\"path\":\"/pets/{petId}\",\"method\":\"get\"}]} webhooks={[]} hasHead={true} />"
}
]
@@ -1,14 +1,14 @@
[
{
"path": "~out/products/products/productid.mdx",
"content": "---\ntitle: Get product details\ndescription: Retrieve details of a product by its ID.\nfull: true\n_openapi:\n method: GET\n route: /products/{productId}\n toc: []\n structuredData:\n headings: []\n contents:\n - content: Retrieve details of a product by its ID.\n---\n\n{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}\n\n<APIPage document={\"fixtures/products.yaml\"} operations={[{\"path\":\"/products/{productId}\",\"method\":\"get\"}]} webhooks={[]} hasHead={false} />"
"content": "---\ntitle: Get product details\ndescription: Retrieve details of a product by its ID.\nfull: true\n_openapi:\n method: GET\n route: /products/{productId}\n toc: []\n structuredData:\n headings: []\n contents:\n - content: Retrieve details of a product by its ID.\n---\n\n{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}\n\n<APIPage document={\"./fixtures/products.yaml\"} operations={[{\"path\":\"/products/{productId}\",\"method\":\"get\"}]} webhooks={[]} hasHead={false} />"
},
{
"path": "~out/inventory/inventory/productid.mdx",
"content": "---\ntitle: Get product inventory\ndescription: Retrieve inventory details for a specific product.\nfull: true\n_openapi:\n method: GET\n route: /inventory/{productId}\n toc: []\n structuredData:\n headings: []\n contents:\n - content: Retrieve inventory details for a specific product.\n---\n\n{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}\n\n<APIPage document={\"fixtures/products.yaml\"} operations={[{\"path\":\"/inventory/{productId}\",\"method\":\"get\"}]} webhooks={[]} hasHead={false} />"
"content": "---\ntitle: Get product inventory\ndescription: Retrieve inventory details for a specific product.\nfull: true\n_openapi:\n method: GET\n route: /inventory/{productId}\n toc: []\n structuredData:\n headings: []\n contents:\n - content: Retrieve inventory details for a specific product.\n---\n\n{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}\n\n<APIPage document={\"./fixtures/products.yaml\"} operations={[{\"path\":\"/inventory/{productId}\",\"method\":\"get\"}]} webhooks={[]} hasHead={false} />"
},
{
"path": "~out/products/inventory/productid.mdx",
"content": "---\ntitle: Get product inventory\ndescription: Retrieve inventory details for a specific product.\nfull: true\n_openapi:\n method: GET\n route: /inventory/{productId}\n toc: []\n structuredData:\n headings: []\n contents:\n - content: Retrieve inventory details for a specific product.\n---\n\n{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}\n\n<APIPage document={\"fixtures/products.yaml\"} operations={[{\"path\":\"/inventory/{productId}\",\"method\":\"get\"}]} webhooks={[]} hasHead={false} />"
"content": "---\ntitle: Get product inventory\ndescription: Retrieve inventory details for a specific product.\nfull: true\n_openapi:\n method: GET\n route: /inventory/{productId}\n toc: []\n structuredData:\n headings: []\n contents:\n - content: Retrieve inventory details for a specific product.\n---\n\n{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}\n\n<APIPage document={\"./fixtures/products.yaml\"} operations={[{\"path\":\"/inventory/{productId}\",\"method\":\"get\"}]} webhooks={[]} hasHead={false} />"
}
]
@@ -1,11 +1,11 @@
[
{
"path": "~out/products/productid.mdx",
"content": "---\ntitle: Get product details\ndescription: Retrieve details of a product by its ID.\nfull: true\n_openapi:\n method: GET\n route: /products/{productId}\n toc: []\n structuredData:\n headings: []\n contents:\n - content: Retrieve details of a product by its ID.\n---\n\n{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}\n\n<APIPage document={\"fixtures/products.yaml\"} operations={[{\"path\":\"/products/{productId}\",\"method\":\"get\"}]} webhooks={[]} hasHead={false} />"
"content": "---\ntitle: Get product details\ndescription: Retrieve details of a product by its ID.\nfull: true\n_openapi:\n method: GET\n route: /products/{productId}\n toc: []\n structuredData:\n headings: []\n contents:\n - content: Retrieve details of a product by its ID.\n---\n\n{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}\n\n<APIPage document={\"./fixtures/products.yaml\"} operations={[{\"path\":\"/products/{productId}\",\"method\":\"get\"}]} webhooks={[]} hasHead={false} />"
},
{
"path": "~out/inventory/productid.mdx",
"content": "---\ntitle: Get product inventory\ndescription: Retrieve inventory details for a specific product.\nfull: true\n_openapi:\n method: GET\n route: /inventory/{productId}\n toc: []\n structuredData:\n headings: []\n contents:\n - content: Retrieve inventory details for a specific product.\n---\n\n{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}\n\n<APIPage document={\"fixtures/products.yaml\"} operations={[{\"path\":\"/inventory/{productId}\",\"method\":\"get\"}]} webhooks={[]} hasHead={false} />"
"content": "---\ntitle: Get product inventory\ndescription: Retrieve inventory details for a specific product.\nfull: true\n_openapi:\n method: GET\n route: /inventory/{productId}\n toc: []\n structuredData:\n headings: []\n contents:\n - content: Retrieve inventory details for a specific product.\n---\n\n{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}\n\n<APIPage document={\"./fixtures/products.yaml\"} operations={[{\"path\":\"/inventory/{productId}\",\"method\":\"get\"}]} webhooks={[]} hasHead={false} />"
},
{
"path": "~out/index.mdx",