UNPKG

fumadocs-openapi

Version:

Generate MDX docs for your OpenAPI spec

248 lines (247 loc) 9.34 kB
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(); }