fumadocs-openapi 5.0.3 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -221,6 +221,7 @@ interface GenerateOptions {
221
221
  * A `full: true` property will be added by default.
222
222
  */
223
223
  frontmatter?: (title: string, description: string | undefined, context: DocumentContext) => Record<string, unknown>;
224
+ cwd?: string;
224
225
  }
225
226
  interface GenerateTagOutput {
226
227
  tag: string;
@@ -246,10 +247,10 @@ interface Config extends GenerateOptions {
246
247
  output: string;
247
248
  /**
248
249
  * tag: Generate a page for each tag
249
- *
250
250
  * file: Generate a page for each schema
251
+ * operation: Generate a page for each API endpoint/operation
251
252
  *
252
- * @defaultValue file
253
+ * @defaultValue 'operation'
253
254
  */
254
255
  per?: 'tag' | 'file' | 'operation';
255
256
  /**
@@ -269,8 +270,7 @@ interface Config extends GenerateOptions {
269
270
  * @defaultValue 'none'
270
271
  */
271
272
  groupBy?: 'tag' | 'route' | 'none';
272
- cwd?: string;
273
273
  }
274
- declare function generateFiles({ input, output, name: nameFn, per, cwd, groupBy, ...options }: Config): Promise<void>;
274
+ declare function generateFiles(options: Config): Promise<void>;
275
275
 
276
276
  export { type Config, type DocumentContext, type GenerateOperationOutput, type GenerateOptions, type GenerateTagOutput, type MethodInformation, type RenderContext, type RouteInformation, generateAll, generateFiles, generateOperations, generateTags };
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
+ import { resolve, join, parse, dirname } from 'node:path';
1
2
  import Parser from '@apidevtools/json-schema-ref-parser';
2
- import Slugger from 'github-slugger';
3
3
  import { dump } from 'js-yaml';
4
+ import Slugger from 'github-slugger';
4
5
  import { mkdir, writeFile } from 'node:fs/promises';
5
- import { join, parse, dirname } from 'node:path';
6
6
  import fg from 'fast-glob';
7
7
 
8
8
  function createMethod(method, operation) {
@@ -55,41 +55,92 @@ const methodKeys = [
55
55
  return map;
56
56
  }
57
57
 
58
- function generateDocument(content, options, frontmatter) {
58
+ function idToTitle(id) {
59
+ let result = [];
60
+ for (const c of id){
61
+ if (result.length === 0) result.push(c.toLocaleUpperCase());
62
+ else if (c === '.') result = [];
63
+ else if (/^[A-Z]$/.test(c) && result.at(-1) !== ' ') result.push(' ', c);
64
+ else if (c === '-') result.push(' ');
65
+ else result.push(c);
66
+ }
67
+ return result.join('');
68
+ }
69
+
70
+ function generateDocument(options) {
71
+ const { frontmatter } = options;
59
72
  const out = [];
73
+ const extend = frontmatter?.(options.title, options.description, options.context);
74
+ let meta;
75
+ if (options.context.type === 'operation') {
76
+ meta = {
77
+ method: options.context.endpoint.method,
78
+ route: options.context.route.path
79
+ };
80
+ }
81
+ const data = generateStaticData(options.dereferenced, options.page);
60
82
  const banner = dump({
61
- title: frontmatter.title,
62
- description: frontmatter.description,
83
+ title: options.title,
84
+ description: options.description,
63
85
  full: true,
64
- ...frontmatter.context.type === 'operation' ? {
65
- method: frontmatter.context.endpoint.method,
66
- route: frontmatter.context.route.path
67
- } : undefined,
68
- ...options.frontmatter?.(frontmatter.title, frontmatter.description, frontmatter.context)
86
+ ...extend,
87
+ _openapi: {
88
+ ...meta,
89
+ ...data,
90
+ ...extend?._openapi
91
+ }
69
92
  }).trim();
70
93
  if (banner.length > 0) out.push(`---\n${banner}\n---`);
71
94
  const imports = options.imports?.map((item)=>`import { ${item.names.join(', ')} } from ${JSON.stringify(item.from)};`).join('\n');
72
95
  if (imports) {
73
96
  out.push(imports);
74
97
  }
75
- out.push(content);
98
+ out.push(pageContent(options.page));
76
99
  return out.join('\n\n');
77
100
  }
78
-
79
- function idToTitle(id) {
80
- let result = [];
81
- for (const c of id){
82
- if (result.length === 0) result.push(c.toLocaleUpperCase());
83
- else if (c === '.') result = [];
84
- else if (/^[A-Z]$/.test(c) && result.at(-1) !== ' ') result.push(' ', c);
85
- else if (c === '-') result.push(' ');
86
- else result.push(c);
101
+ function generateStaticData(dereferenced, props) {
102
+ const slugger = new Slugger();
103
+ const toc = [];
104
+ const structuredData = {
105
+ headings: [],
106
+ contents: []
107
+ };
108
+ for (const item of props.operations){
109
+ const operation = dereferenced.paths[item.path]?.[item.method];
110
+ if (!operation) continue;
111
+ if (props.hasHead && operation.operationId) {
112
+ const title = operation.summary ?? (operation.operationId ? idToTitle(operation.operationId) : item.path);
113
+ const id = slugger.slug(title);
114
+ toc.push({
115
+ depth: 2,
116
+ title,
117
+ url: `#${id}`
118
+ });
119
+ structuredData.headings.push({
120
+ content: title,
121
+ id
122
+ });
123
+ }
124
+ if (operation.description) structuredData.contents.push({
125
+ content: operation.description,
126
+ heading: structuredData.headings.at(-1)?.id
127
+ });
87
128
  }
88
- return result.join('');
129
+ return {
130
+ toc,
131
+ structuredData
132
+ };
133
+ }
134
+ function pageContent(props) {
135
+ return `<APIPage document={${JSON.stringify(props.document)}} operations={${JSON.stringify(props.operations)}} hasHead={${JSON.stringify(props.hasHead)}} />`;
89
136
  }
90
137
 
138
+ async function dereference(pathOrDocument, options) {
139
+ return await Parser.dereference(// resolve paths
140
+ typeof pathOrDocument === 'string' && !pathOrDocument.startsWith('http://') && !pathOrDocument.startsWith('https://') ? resolve(options.cwd ?? process.cwd(), pathOrDocument) : pathOrDocument);
141
+ }
91
142
  async function generateAll(pathOrDocument, options = {}) {
92
- const document = await Parser.dereference(pathOrDocument);
143
+ const document = await dereference(pathOrDocument, options);
93
144
  const routes = buildRoutes(document).get('all') ?? [];
94
145
  const operations = [];
95
146
  for (const route of routes){
@@ -100,12 +151,16 @@ async function generateAll(pathOrDocument, options = {}) {
100
151
  });
101
152
  }
102
153
  }
103
- return generateDocument(pageContent(document, {
104
- operations,
105
- hasHead: true
106
- }), options, {
154
+ return generateDocument({
155
+ ...options,
156
+ dereferenced: document,
107
157
  title: document.info.title,
108
158
  description: document.info.description,
159
+ page: {
160
+ operations,
161
+ hasHead: true,
162
+ document: pathOrDocument
163
+ },
109
164
  context: {
110
165
  type: 'file',
111
166
  routes
@@ -113,20 +168,24 @@ async function generateAll(pathOrDocument, options = {}) {
113
168
  });
114
169
  }
115
170
  async function generateOperations(pathOrDocument, options = {}) {
116
- const document = await Parser.dereference(pathOrDocument);
171
+ const document = await dereference(pathOrDocument, options);
117
172
  const routes = buildRoutes(document).get('all') ?? [];
118
173
  return routes.flatMap((route)=>{
119
174
  return route.methods.map((method)=>{
120
175
  if (!method.operationId) throw new Error('Operation ID is required for generating docs.');
121
- const content = generateDocument(pageContent(document, {
122
- operations: [
123
- {
124
- path: route.path,
125
- method: method.method.toLowerCase()
126
- }
127
- ],
128
- hasHead: false
129
- }), options, {
176
+ const content = generateDocument({
177
+ ...options,
178
+ page: {
179
+ operations: [
180
+ {
181
+ path: route.path,
182
+ method: method.method.toLowerCase()
183
+ }
184
+ ],
185
+ hasHead: false,
186
+ document: pathOrDocument
187
+ },
188
+ dereferenced: document,
130
189
  title: method.summary ?? idToTitle(method.operationId),
131
190
  description: method.description,
132
191
  context: {
@@ -144,7 +203,7 @@ async function generateOperations(pathOrDocument, options = {}) {
144
203
  });
145
204
  }
146
205
  async function generateTags(pathOrDocument, options = {}) {
147
- const document = await Parser.dereference(pathOrDocument);
206
+ const document = await dereference(pathOrDocument, options);
148
207
  const tags = Array.from(buildRoutes(document).entries());
149
208
  return tags.filter(([tag])=>tag !== 'all').map(([tag, routes])=>{
150
209
  const info = document.tags?.find((t)=>t.name === tag);
@@ -159,10 +218,14 @@ async function generateTags(pathOrDocument, options = {}) {
159
218
  }
160
219
  return {
161
220
  tag,
162
- content: generateDocument(pageContent(document, {
163
- operations,
164
- hasHead: true
165
- }), options, {
221
+ content: generateDocument({
222
+ ...options,
223
+ page: {
224
+ document: pathOrDocument,
225
+ operations,
226
+ hasHead: true
227
+ },
228
+ dereferenced: document,
166
229
  title: idToTitle(tag),
167
230
  description: info?.description,
168
231
  context: {
@@ -174,74 +237,41 @@ async function generateTags(pathOrDocument, options = {}) {
174
237
  };
175
238
  });
176
239
  }
177
- function pageContent(doc, props) {
178
- const slugger = new Slugger();
179
- const toc = [];
180
- const structuredData = {
181
- headings: [],
182
- contents: []
183
- };
184
- for (const item of props.operations){
185
- const operation = doc.paths[item.path]?.[item.method];
186
- if (!operation) continue;
187
- if (props.hasHead && operation.operationId) {
188
- const title = operation.summary ?? (operation.operationId ? idToTitle(operation.operationId) : item.path);
189
- const id = slugger.slug(title);
190
- toc.push({
191
- depth: 2,
192
- title,
193
- url: `#${id}`
194
- });
195
- structuredData.headings.push({
196
- content: title,
197
- id
198
- });
199
- }
200
- if (operation.description) structuredData.contents.push({
201
- content: operation.description,
202
- heading: structuredData.headings.at(-1)?.id
203
- });
204
- }
205
- // modify toc and structured data if possible
206
- // it may not be compatible with other content sources except Fumadocs MDX
207
- // TODO: Maybe add to frontmatter and let developers to handle them?
208
- return `<APIPage operations={${JSON.stringify(props.operations)}} hasHead={${JSON.stringify(props.hasHead)}} />
209
-
210
- export function startup() {
211
- if (typeof toc !== 'undefined') {
212
- // toc might be immutable
213
- while (toc.length > 0) toc.pop()
214
- toc.push(...${JSON.stringify(toc)})
215
- }
216
-
217
- if (typeof structuredData !== 'undefined') {
218
- structuredData.headings = ${JSON.stringify(structuredData.headings)}
219
- structuredData.contents = ${JSON.stringify(structuredData.contents)}
220
- }
221
- }
222
240
 
223
- {startup()}`;
224
- }
225
-
226
- async function generateFiles({ input, output, name: nameFn, per = 'file', cwd = process.cwd(), groupBy = 'none', ...options }) {
241
+ async function generateFiles(options) {
242
+ const { input, output, name: nameFn, per = 'operation', groupBy = 'none', cwd = process.cwd() } = options;
227
243
  const outputDir = join(cwd, output);
228
- const resolvedInputs = await fg.glob(input, {
229
- absolute: true,
230
- cwd
231
- });
232
- await Promise.all(resolvedInputs.map(async (path)=>{
244
+ const urlInputs = [];
245
+ const fileInputs = [];
246
+ for (const v of typeof input === 'string' ? [
247
+ input
248
+ ] : input){
249
+ if (isUrl(v)) {
250
+ urlInputs.push(v);
251
+ } else {
252
+ fileInputs.push(v);
253
+ }
254
+ }
255
+ const resolvedInputs = [
256
+ ...await fg.glob(fileInputs, {
257
+ cwd,
258
+ absolute: false
259
+ }),
260
+ ...urlInputs
261
+ ];
262
+ await Promise.all(resolvedInputs.map(async (pathOrUrl)=>{
233
263
  if (per === 'file') {
234
- let filename = parse(path).name;
264
+ let filename = isUrl(pathOrUrl) ? 'index' : parse(pathOrUrl).name;
235
265
  if (nameFn) filename = nameFn('file', filename);
236
266
  const outPath = join(outputDir, `${filename}.mdx`);
237
- const result = await generateAll(path, options);
267
+ const result = await generateAll(pathOrUrl, options);
238
268
  await write(outPath, result);
239
269
  console.log(`Generated: ${outPath}`);
240
270
  return;
241
271
  }
242
272
  if (per === 'operation') {
243
273
  const metaFiles = new Set();
244
- const results = await generateOperations(path, options);
274
+ const results = await generateOperations(pathOrUrl, options);
245
275
  await Promise.all(results.map(async (result)=>{
246
276
  let outPath;
247
277
  if (!result.method.operationId) return;
@@ -267,7 +297,7 @@ async function generateFiles({ input, output, name: nameFn, per = 'file', cwd =
267
297
  }));
268
298
  return;
269
299
  }
270
- const results = await generateTags(path, options);
300
+ const results = await generateTags(pathOrUrl, options);
271
301
  for (const result of results){
272
302
  let tagName = result.tag;
273
303
  tagName = nameFn?.('tag', tagName) ?? getFilename(tagName);
@@ -277,6 +307,9 @@ async function generateFiles({ input, output, name: nameFn, per = 'file', cwd =
277
307
  }
278
308
  }));
279
309
  }
310
+ function isUrl(input) {
311
+ return input.startsWith('https://') || input.startsWith('http://');
312
+ }
280
313
  function getFilenameFromRoute(path) {
281
314
  return path.replaceAll('.', '/').split('/').filter((v)=>!v.startsWith('{') && !v.endsWith('}')).at(-1) ?? '';
282
315
  }
@@ -181,25 +181,29 @@ interface RenderContext {
181
181
  }
182
182
 
183
183
  interface ApiPageProps extends Pick<RenderContext, 'generateCodeSamples' | 'generateTypeScriptSchema'> {
184
- document: OpenAPIV3.Document;
184
+ document: string | OpenAPIV3.Document;
185
185
  /**
186
- * An array of operation
186
+ * An array of operations
187
187
  */
188
- operations: {
189
- path: string;
190
- method: OpenAPIV3.HttpMethods;
191
- }[];
188
+ operations: Operation[];
192
189
  hasHead: boolean;
193
190
  renderer?: Partial<Renderer>;
194
191
  }
192
+ interface Operation {
193
+ path: string;
194
+ method: OpenAPIV3.HttpMethods;
195
+ }
195
196
 
196
197
  interface OpenAPIOptions extends Omit<Partial<ApiPageProps>, 'document'> {
197
- documentOrPath: string | OpenAPIV3.Document;
198
+ /**
199
+ * @deprecated Pass document to `APIPage` instead
200
+ */
201
+ documentOrPath?: string | OpenAPIV3.Document;
198
202
  }
199
203
  interface OpenAPIServer {
200
- APIPage: FC<Omit<ApiPageProps, 'document'>>;
204
+ APIPage: FC<ApiPageProps>;
201
205
  }
202
- declare function createOpenAPI(options: OpenAPIOptions): OpenAPIServer;
206
+ declare function createOpenAPI(options?: OpenAPIOptions): OpenAPIServer;
203
207
 
204
208
  /**
205
209
  * Source API Integration
@@ -1,6 +1,6 @@
1
1
  import { jsx, jsxs, Fragment as Fragment$1 } from 'react/jsx-runtime';
2
- import Parser from '@apidevtools/json-schema-ref-parser';
3
2
  import Slugger from 'github-slugger';
3
+ import Parser from '@apidevtools/json-schema-ref-parser';
4
4
  import { createElement, Fragment, useMemo } from 'react';
5
5
  import { sample } from 'openapi-sampler';
6
6
  import { compile } from 'json-schema-to-typescript';
@@ -1001,8 +1001,17 @@ const defaultRenderer = {
1001
1001
  APIPlayground
1002
1002
  };
1003
1003
 
1004
- function APIPage(props) {
1005
- const { operations, document, hasHead = true } = props;
1004
+ const cache = new Map();
1005
+ async function APIPage(props) {
1006
+ const { operations, hasHead = true } = props;
1007
+ let document;
1008
+ if (typeof props.document === 'string') {
1009
+ const cached = cache.get(props.document);
1010
+ document = cached ?? await Parser.dereference(props.document);
1011
+ cache.set(props.document, document);
1012
+ } else {
1013
+ document = await Parser.dereference(props.document);
1014
+ }
1006
1015
  const ctx = getContext(document, props);
1007
1016
  return /*#__PURE__*/ jsx(ctx.renderer.Root, {
1008
1017
  baseUrl: ctx.baseUrl,
@@ -1033,12 +1042,11 @@ function getContext(document, options) {
1033
1042
  };
1034
1043
  }
1035
1044
 
1036
- function createOpenAPI(options) {
1037
- const document = Parser.dereference(options.documentOrPath);
1045
+ function createOpenAPI(options = {}) {
1038
1046
  return {
1039
- APIPage: async (props)=>{
1047
+ APIPage (props) {
1040
1048
  return /*#__PURE__*/ jsx(APIPage, {
1041
- document: await document,
1049
+ ...options,
1042
1050
  ...props
1043
1051
  });
1044
1052
  }
@@ -1078,8 +1086,13 @@ function getBadgeColor(method) {
1078
1086
  */ const attachFile = (node, file)=>{
1079
1087
  if (!file) return node;
1080
1088
  const data = file.data.data;
1081
- if ('method' in data && typeof data.method === 'string') {
1082
- const color = getBadgeColor(data.method);
1089
+ let method;
1090
+ if ('_openapi' in data && typeof data._openapi === 'object') {
1091
+ const meta = data._openapi;
1092
+ method = meta.method;
1093
+ }
1094
+ if (method) {
1095
+ const color = getBadgeColor(method);
1083
1096
  node.name = /*#__PURE__*/ jsxs(Fragment$1, {
1084
1097
  children: [
1085
1098
  node.name,
@@ -1089,7 +1102,7 @@ function getBadgeColor(method) {
1089
1102
  className: 'ms-auto text-nowrap',
1090
1103
  color
1091
1104
  }),
1092
- children: data.method
1105
+ children: method
1093
1106
  })
1094
1107
  ]
1095
1108
  });
@@ -64,7 +64,7 @@ function useSchemaContext() {
64
64
  return ctx;
65
65
  }
66
66
 
67
- const APIPlayground = dynamic(()=>import('./playground-client-CjCikhf6.js').then((mod)=>mod.APIPlayground));
67
+ const APIPlayground = dynamic(()=>import('./playground-client-itvkUjgt.js').then((mod)=>mod.APIPlayground));
68
68
  function Root({ children, baseUrl, className, ...props }) {
69
69
  return /*#__PURE__*/ jsx("div", {
70
70
  className: cn('flex flex-col gap-24 text-sm text-fd-muted-foreground', className),
@@ -87,6 +87,7 @@ function CopyRouteButton({ className, route, ...props }) {
87
87
  className
88
88
  })),
89
89
  onClick: onCopy,
90
+ "aria-label": "Copy route path",
90
91
  ...props,
91
92
  children: checked ? /*#__PURE__*/ jsx(Check, {
92
93
  className: "size-3"
package/dist/ui/index.js CHANGED
@@ -3,8 +3,8 @@ import { cn } from 'fumadocs-ui/components/api';
3
3
  import { Fragment } from 'react';
4
4
  import { Accordions, Accordion } from 'fumadocs-ui/components/accordion';
5
5
  import { cva } from 'class-variance-authority';
6
- import { C as CopyRouteButton } from './client-client-BIwAR59x.js';
7
- export { A as APIPlayground, R as Root, u as useSchemaContext } from './client-client-BIwAR59x.js';
6
+ import { C as CopyRouteButton } from './client-client-34yX5eij.js';
7
+ export { A as APIPlayground, R as Root, u as useSchemaContext } from './client-client-34yX5eij.js';
8
8
 
9
9
  const badgeVariants = cva('rounded border px-1.5 py-1 text-xs font-medium leading-[12px]', {
10
10
  variants: {
@@ -6,7 +6,7 @@ import { FormProvider, Controller, useFormContext, useFieldArray, useForm, useWa
6
6
  import useSWRImmutable from 'swr/immutable';
7
7
  import { Accordions, Accordion } from 'fumadocs-ui/components/accordion';
8
8
  import { cn, buttonVariants } from 'fumadocs-ui/components/api';
9
- import { u as useSchemaContext, a as useApiContext, S as SchemaContext } from './client-client-BIwAR59x.js';
9
+ import { u as useSchemaContext, a as useApiContext, S as SchemaContext } from './client-client-34yX5eij.js';
10
10
  import { Slot } from '@radix-ui/react-slot';
11
11
  import { cva } from 'class-variance-authority';
12
12
  import { CircleCheckIcon, CircleXIcon, ChevronDown, ChevronUp, Check, Trash2, Plus } from 'lucide-react';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fumadocs-openapi",
3
- "version": "5.0.3",
3
+ "version": "5.2.0",
4
4
  "description": "Generate MDX docs for your OpenAPI spec",
5
5
  "keywords": [
6
6
  "NextJs",
@@ -44,7 +44,7 @@
44
44
  "dist"
45
45
  ],
46
46
  "dependencies": {
47
- "@apidevtools/json-schema-ref-parser": "^11.6.4",
47
+ "@apidevtools/json-schema-ref-parser": "^11.7.0",
48
48
  "@mdx-js/mdx": "^3.0.1",
49
49
  "@radix-ui/react-select": "^2.1.1",
50
50
  "@radix-ui/react-slot": "^1.1.0",
@@ -54,21 +54,21 @@
54
54
  "github-slugger": "^2.0.0",
55
55
  "js-yaml": "^4.1.0",
56
56
  "json-schema-to-typescript": "^15.0.0",
57
- "lucide-react": "^0.414.0",
57
+ "lucide-react": "^0.427.0",
58
58
  "openapi-sampler": "^1.5.1",
59
- "react-hook-form": "^7.52.1",
59
+ "react-hook-form": "^7.52.2",
60
60
  "remark": "^15.0.0",
61
- "shiki": "^1.11.1",
61
+ "shiki": "^1.12.1",
62
62
  "swr": "^2.2.5",
63
- "fumadocs-core": "13.2.0",
64
- "fumadocs-ui": "13.2.0"
63
+ "fumadocs-core": "13.2.2",
64
+ "fumadocs-ui": "13.2.2"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@types/js-yaml": "^4.0.9",
68
68
  "@types/node": "20.14.12",
69
69
  "@types/openapi-sampler": "^1.0.3",
70
70
  "@types/react": "^18.3.3",
71
- "bunchee": "^5.3.1",
71
+ "bunchee": "^5.3.2",
72
72
  "next": "^14.2.5",
73
73
  "openapi-types": "^12.1.3",
74
74
  "eslint-config-custom": "0.0.0",