fumadocs-openapi 5.0.3 → 5.1.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;
@@ -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,110 @@ 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(data, options.page));
76
99
  return out.join('\n\n');
77
100
  }
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
+ });
128
+ }
129
+ return {
130
+ toc,
131
+ structuredData
132
+ };
133
+ }
134
+ function pageContent(data, props) {
135
+ // modify toc and structured data if possible
136
+ // it may not be compatible with other content sources except Fumadocs MDX
137
+ // TODO: Maybe add to frontmatter and let developers to handle them?
138
+ return `<APIPage document={${JSON.stringify(props.document)}} operations={${JSON.stringify(props.operations)}} hasHead={${JSON.stringify(props.hasHead)}} />
78
139
 
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);
140
+ export function startup() {
141
+ if (typeof toc !== 'undefined') {
142
+ // toc might be immutable
143
+ while (toc.length > 0) toc.pop()
144
+ toc.push(...${JSON.stringify(data.toc)})
145
+ }
146
+
147
+ if (typeof structuredData !== 'undefined') {
148
+ structuredData.headings = ${JSON.stringify(data.structuredData.headings)}
149
+ structuredData.contents = ${JSON.stringify(data.structuredData.contents)}
87
150
  }
88
- return result.join('');
89
151
  }
90
152
 
153
+ {startup()}`;
154
+ }
155
+
156
+ async function dereference(pathOrDocument, options) {
157
+ return await Parser.dereference(// resolve paths
158
+ typeof pathOrDocument === 'string' && !pathOrDocument.startsWith('http://') && !pathOrDocument.startsWith('https://') ? resolve(options.cwd ?? process.cwd(), pathOrDocument) : pathOrDocument);
159
+ }
91
160
  async function generateAll(pathOrDocument, options = {}) {
92
- const document = await Parser.dereference(pathOrDocument);
161
+ const document = await dereference(pathOrDocument, options);
93
162
  const routes = buildRoutes(document).get('all') ?? [];
94
163
  const operations = [];
95
164
  for (const route of routes){
@@ -100,12 +169,16 @@ async function generateAll(pathOrDocument, options = {}) {
100
169
  });
101
170
  }
102
171
  }
103
- return generateDocument(pageContent(document, {
104
- operations,
105
- hasHead: true
106
- }), options, {
172
+ return generateDocument({
173
+ ...options,
174
+ dereferenced: document,
107
175
  title: document.info.title,
108
176
  description: document.info.description,
177
+ page: {
178
+ operations,
179
+ hasHead: true,
180
+ document: pathOrDocument
181
+ },
109
182
  context: {
110
183
  type: 'file',
111
184
  routes
@@ -113,20 +186,24 @@ async function generateAll(pathOrDocument, options = {}) {
113
186
  });
114
187
  }
115
188
  async function generateOperations(pathOrDocument, options = {}) {
116
- const document = await Parser.dereference(pathOrDocument);
189
+ const document = await dereference(pathOrDocument, options);
117
190
  const routes = buildRoutes(document).get('all') ?? [];
118
191
  return routes.flatMap((route)=>{
119
192
  return route.methods.map((method)=>{
120
193
  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, {
194
+ const content = generateDocument({
195
+ ...options,
196
+ page: {
197
+ operations: [
198
+ {
199
+ path: route.path,
200
+ method: method.method.toLowerCase()
201
+ }
202
+ ],
203
+ hasHead: false,
204
+ document: pathOrDocument
205
+ },
206
+ dereferenced: document,
130
207
  title: method.summary ?? idToTitle(method.operationId),
131
208
  description: method.description,
132
209
  context: {
@@ -144,7 +221,7 @@ async function generateOperations(pathOrDocument, options = {}) {
144
221
  });
145
222
  }
146
223
  async function generateTags(pathOrDocument, options = {}) {
147
- const document = await Parser.dereference(pathOrDocument);
224
+ const document = await dereference(pathOrDocument, options);
148
225
  const tags = Array.from(buildRoutes(document).entries());
149
226
  return tags.filter(([tag])=>tag !== 'all').map(([tag, routes])=>{
150
227
  const info = document.tags?.find((t)=>t.name === tag);
@@ -159,10 +236,14 @@ async function generateTags(pathOrDocument, options = {}) {
159
236
  }
160
237
  return {
161
238
  tag,
162
- content: generateDocument(pageContent(document, {
163
- operations,
164
- hasHead: true
165
- }), options, {
239
+ content: generateDocument({
240
+ ...options,
241
+ page: {
242
+ document: pathOrDocument,
243
+ operations,
244
+ hasHead: true
245
+ },
246
+ dereferenced: document,
166
247
  title: idToTitle(tag),
167
248
  description: info?.description,
168
249
  context: {
@@ -174,74 +255,41 @@ async function generateTags(pathOrDocument, options = {}) {
174
255
  };
175
256
  });
176
257
  }
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
258
 
223
- {startup()}`;
224
- }
225
-
226
- async function generateFiles({ input, output, name: nameFn, per = 'file', cwd = process.cwd(), groupBy = 'none', ...options }) {
259
+ async function generateFiles(options) {
260
+ const { input, output, name: nameFn, per = 'file', groupBy = 'none', cwd = process.cwd() } = options;
227
261
  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)=>{
262
+ const urlInputs = [];
263
+ const fileInputs = [];
264
+ for (const v of typeof input === 'string' ? [
265
+ input
266
+ ] : input){
267
+ if (isUrl(v)) {
268
+ urlInputs.push(v);
269
+ } else {
270
+ fileInputs.push(v);
271
+ }
272
+ }
273
+ const resolvedInputs = [
274
+ ...await fg.glob(fileInputs, {
275
+ cwd,
276
+ absolute: false
277
+ }),
278
+ ...urlInputs
279
+ ];
280
+ await Promise.all(resolvedInputs.map(async (pathOrUrl)=>{
233
281
  if (per === 'file') {
234
- let filename = parse(path).name;
282
+ let filename = isUrl(pathOrUrl) ? 'index' : parse(pathOrUrl).name;
235
283
  if (nameFn) filename = nameFn('file', filename);
236
284
  const outPath = join(outputDir, `${filename}.mdx`);
237
- const result = await generateAll(path, options);
285
+ const result = await generateAll(pathOrUrl, options);
238
286
  await write(outPath, result);
239
287
  console.log(`Generated: ${outPath}`);
240
288
  return;
241
289
  }
242
290
  if (per === 'operation') {
243
291
  const metaFiles = new Set();
244
- const results = await generateOperations(path, options);
292
+ const results = await generateOperations(pathOrUrl, options);
245
293
  await Promise.all(results.map(async (result)=>{
246
294
  let outPath;
247
295
  if (!result.method.operationId) return;
@@ -267,7 +315,7 @@ async function generateFiles({ input, output, name: nameFn, per = 'file', cwd =
267
315
  }));
268
316
  return;
269
317
  }
270
- const results = await generateTags(path, options);
318
+ const results = await generateTags(pathOrUrl, options);
271
319
  for (const result of results){
272
320
  let tagName = result.tag;
273
321
  tagName = nameFn?.('tag', tagName) ?? getFilename(tagName);
@@ -277,6 +325,9 @@ async function generateFiles({ input, output, name: nameFn, per = 'file', cwd =
277
325
  }
278
326
  }));
279
327
  }
328
+ function isUrl(input) {
329
+ return input.startsWith('https://') || input.startsWith('http://');
330
+ }
280
331
  function getFilenameFromRoute(path) {
281
332
  return path.replaceAll('.', '/').split('/').filter((v)=>!v.startsWith('{') && !v.endsWith('}')).at(-1) ?? '';
282
333
  }
@@ -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,15 @@ 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
+ } else if ('method' in data && typeof data.method === 'string') {
1094
+ method = data.method;
1095
+ }
1096
+ if (method) {
1097
+ const color = getBadgeColor(method);
1083
1098
  node.name = /*#__PURE__*/ jsxs(Fragment$1, {
1084
1099
  children: [
1085
1100
  node.name,
@@ -1089,7 +1104,7 @@ function getBadgeColor(method) {
1089
1104
  className: 'ms-auto text-nowrap',
1090
1105
  color
1091
1106
  }),
1092
- children: data.method
1107
+ children: method
1093
1108
  })
1094
1109
  ]
1095
1110
  });
@@ -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.1.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.1",
64
+ "fumadocs-ui": "13.2.1"
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",