fumadocs-openapi
Version:
Generate MDX docs for your OpenAPI spec
248 lines (247 loc) • 9.34 kB
JavaScript
import { mkdir, writeFile } from 'node:fs/promises';
import * as path from 'node:path';
import { glob } from 'tinyglobby';
import { generateAll, generateDocument, generatePages, generateTags, } from './generate.js';
import { processDocumentCached, } from './utils/process-document.js';
import { createGetUrl, getSlugs } from 'fumadocs-core/source';
import matter from 'gray-matter';
export async function generateFiles(options) {
const files = await generateFilesOnly(options);
await Promise.all(files.map(async (file) => {
await mkdir(path.dirname(file.path), { recursive: true });
await writeFile(file.path, file.content);
console.log(`Generated: ${file.path}`);
}));
}
export async function generateFilesOnly(options) {
const { cwd = process.cwd(), beforeWrite } = options;
const input = typeof options.input === 'string' ? [options.input] : options.input;
let schemas = {};
async function resolveInput(item) {
if (isUrl(item)) {
schemas[item] = await processDocumentCached(item);
return;
}
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.');
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 generated = {};
const files = [];
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);
generated[id] = result;
}
const context = {
files,
generated,
documents: schemas,
};
if (options.index) {
writeIndexFiles(context, options);
}
await beforeWrite?.call(context, context.files);
return context.files;
}
function generateFromDocument(schemaId, processed, options) {
const files = [];
const { dereferenced } = processed;
const { output, cwd = process.cwd(), slugify = defaultSlugify } = options;
const outputDir = path.join(cwd, output);
let nameFn;
if (!options.name || typeof options.name !== 'function') {
const algorithm = options.name?.algorithm;
nameFn = (out, doc) => defaultNameFn(schemaId, out, doc, options, algorithm);
}
else {
nameFn = options.name;
}
function getOutputPaths(groupBy = 'none', result) {
if (groupBy === 'route') {
return [
path.join(getOutputPathFromRoute(result.type === 'operation' ? result.item.path : result.item.name), `${result.item.method.toLowerCase()}.mdx`),
];
}
const file = nameFn(result, dereferenced);
if (groupBy === 'tag') {
let tags = result.type === 'operation'
? dereferenced.paths[result.item.path][result.item.method].tags
: dereferenced.webhooks[result.item.name][result.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`));
}
return [`${file}.mdx`];
}
if (options.per === 'file') {
const result = generateAll(schemaId, processed, options);
const filename = nameFn({
pathOrUrl: schemaId,
content: result,
}, dereferenced);
files.push({
path: path.join(outputDir, `${filename}.mdx`),
content: result,
});
return files;
}
if (options.per === 'tag') {
const results = generateTags(schemaId, processed, options);
for (const result of results) {
const filename = nameFn(result, dereferenced);
files.push({
path: path.join(outputDir, `${filename}.mdx`),
content: result.content,
});
}
return files;
}
const results = generatePages(schemaId, processed, options);
const mapping = new Map();
for (const result of results) {
for (const outputPath of getOutputPaths(options.groupBy, result)) {
mapping.set(outputPath, result);
}
}
for (const [key, output] of mapping.entries()) {
let outputPath = key;
// v1 will remove nested directories
if (typeof options.name === 'object' && options.name.algorithm === 'v1') {
const isSharedDir = Array.from(mapping.keys()).some((item) => item !== outputPath &&
path.dirname(item) === path.dirname(outputPath));
if (!isSharedDir && path.dirname(outputPath) !== '.') {
outputPath = path.join(path.dirname(outputPath) + '.mdx');
}
}
files.push({
path: path.join(outputDir, outputPath),
content: output.content,
});
}
return files;
}
function defaultNameFn(schemaId, output, document, options, algorithm = 'v2') {
const { slugify = defaultSlugify } = options;
if (options.per === 'tag') {
const result = output;
return slugify(result.tag);
}
if (options.per === 'file') {
return isUrl(schemaId)
? 'index'
: path.basename(schemaId, path.extname(schemaId));
}
const result = output;
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);
}
function isUrl(input) {
return input.startsWith('https://') || input.startsWith('http://');
}
function getOutputPathFromRoute(path) {
return (path
.toLowerCase()
.replaceAll('.', '-')
.split('/')
.map((v) => {
if (v.startsWith('{') && v.endsWith('}'))
return v.slice(1, -1);
return v;
})
.join('/') ?? '');
}
function writeIndexFiles(context, options) {
const { index, output, cwd = process.cwd() } = options;
if (!index)
return;
const { items, url } = index;
let urlFn;
if (typeof url === 'object') {
const getUrl = createGetUrl(url.baseUrl);
const contentDir = path.resolve(cwd, url.contentDir);
urlFn = (file) => getUrl(getSlugs(path.relative(contentDir, file)));
}
else {
urlFn = url;
}
function fileContent(index) {
const generatedPages = context.generated;
const content = [];
content.push('<Cards>');
const files = new Map();
const only = index.only ?? Object.keys(context.generated);
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);
}
}
for (const file of files.values()) {
const isContent = file.path.endsWith('.mdx') || file.path.endsWith('.md');
if (!isContent)
continue;
const { data } = matter(file.content);
if (typeof data.title !== 'string')
continue;
const descriptionAttr = data.description
? `description=${JSON.stringify(data.description)} `
: '';
content.push(`<Card href="${urlFn(file.path)}" title=${JSON.stringify(data.title)} ${descriptionAttr}/>`);
}
content.push('</Cards>');
return generateDocument({
title: index.title ?? 'Overview',
description: index.description,
}, content.join('\n'), options);
}
const outputDir = path.join(cwd, output);
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),
});
}
}
function defaultSlugify(s) {
return s.replace(/\s+/g, '-').toLowerCase();
}