@scalar/openapi-to-markdown 1.3.0 → 1.5.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/CHANGELOG.md +32 -0
- package/README.md +114 -2
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +1 -1
- package/dist/create-markdown-from-openapi.js +1 -1
- package/dist/document-anchors.d.ts +8 -0
- package/dist/document-anchors.d.ts.map +1 -0
- package/dist/document-anchors.js +25 -0
- package/dist/document-examples.d.ts +14 -0
- package/dist/document-examples.d.ts.map +1 -0
- package/dist/document-examples.js +54 -0
- package/dist/get-markdown-examples.d.ts +3 -1
- package/dist/get-markdown-examples.d.ts.map +1 -1
- package/dist/get-markdown-examples.js +12 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/markdown-nodes.d.ts +22 -1
- package/dist/markdown-nodes.d.ts.map +1 -1
- package/dist/markdown-nodes.js +4 -0
- package/dist/parse-description.d.ts +2 -0
- package/dist/parse-description.d.ts.map +1 -1
- package/dist/parse-description.js +28 -1
- package/dist/render-document.d.ts +2 -1
- package/dist/render-document.d.ts.map +1 -1
- package/dist/render-document.js +217 -84
- package/dist/render-examples.d.ts +9 -1
- package/dist/render-examples.d.ts.map +1 -1
- package/dist/render-examples.js +26 -8
- package/dist/render-operation-details.d.ts +3 -2
- package/dist/render-operation-details.d.ts.map +1 -1
- package/dist/render-operation-details.js +8 -5
- package/dist/render-operation.d.ts +16 -1
- package/dist/render-operation.d.ts.map +1 -1
- package/dist/render-operation.js +168 -42
- package/dist/render-schema.d.ts +32 -3
- package/dist/render-schema.d.ts.map +1 -1
- package/dist/render-schema.js +499 -104
- package/dist/render-security.d.ts +7 -3
- package/dist/render-security.d.ts.map +1 -1
- package/dist/render-security.js +61 -12
- package/dist/select-document.d.ts +19 -2
- package/dist/select-document.d.ts.map +1 -1
- package/dist/select-document.js +9 -6
- package/package.json +19 -10
package/dist/render-schema.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { unescapeJsonPointer } from '@scalar/helpers/json/unescape-json-pointer';
|
|
2
1
|
import { isObject } from '@scalar/helpers/object/is-object';
|
|
3
2
|
import { getResolvedRef, mergeSiblingReferences } from '@scalar/workspace-store/helpers/get-resolved-ref';
|
|
4
|
-
import { inlineCode, item, list, paragraph, strong, text } from './markdown-nodes.js';
|
|
3
|
+
import { describe, emphasis, inlineCode, item, link, list, paragraph, safeUrl, strong, text } from './markdown-nodes.js';
|
|
5
4
|
/** Guards against stack exhaustion. Past it, references to models point to their own section instead. */
|
|
6
5
|
const MAX_DEPTH = 64;
|
|
7
6
|
/**
|
|
@@ -26,14 +25,14 @@ const structuralKeywords = new Set([
|
|
|
26
25
|
]);
|
|
27
26
|
/** Link bookkeeping that is not a sibling keyword of a reference. */
|
|
28
27
|
const referenceKeys = new Set(['$ref', '$ref-value', '$global', '$status']);
|
|
29
|
-
|
|
28
|
+
/** The reference string of a Reference Object, if the schema is one. */
|
|
29
|
+
const getRef = (input) => isObject(input) && typeof input.$ref === 'string' ? input.$ref : undefined;
|
|
30
30
|
/** Prefer the component name, which is how model sections and other references identify the schema. */
|
|
31
31
|
const getReferenceName = (ref) => {
|
|
32
|
-
const match = /^#\/components\/schemas\/([^/]+)$/.exec(ref);
|
|
33
|
-
if (!match)
|
|
34
|
-
return ref;
|
|
35
32
|
try {
|
|
36
|
-
|
|
33
|
+
// URI fragments are decoded before JSON Pointer segments and escapes, exactly once.
|
|
34
|
+
const match = /^#\/components\/schemas\/([^/]+)$/.exec(decodeURIComponent(ref));
|
|
35
|
+
return match ? match[1].replaceAll('~1', '/').replaceAll('~0', '~') : ref;
|
|
37
36
|
}
|
|
38
37
|
catch {
|
|
39
38
|
return ref;
|
|
@@ -53,6 +52,17 @@ const getSharedName = (input, view) => {
|
|
|
53
52
|
// Leaf schemas are as short as a reference to them, so they stay in place.
|
|
54
53
|
return structured ? getReferenceName(ref) : undefined;
|
|
55
54
|
};
|
|
55
|
+
/**
|
|
56
|
+
* A named model with structural reference siblings is its own schema, not another
|
|
57
|
+
* occurrence of its target. Otherwise either rendering order can hide properties.
|
|
58
|
+
* Follow the original target otherwise: merging reference siblings creates fresh objects.
|
|
59
|
+
*/
|
|
60
|
+
const getSharedIdentity = (input) => {
|
|
61
|
+
const identity = isObject(input) && '$ref' in input && Object.keys(input).some((key) => structuralKeywords.has(key))
|
|
62
|
+
? input
|
|
63
|
+
: (getResolvedRef(input) ?? input);
|
|
64
|
+
return typeof identity === 'object' && identity !== null ? identity : undefined;
|
|
65
|
+
};
|
|
56
66
|
/** Boolean targets still combine with adjacent schema keywords. */
|
|
57
67
|
const resolveMarkdownSchema = (input) => {
|
|
58
68
|
const target = getResolvedRef(input);
|
|
@@ -67,9 +77,90 @@ const resolveMarkdownSchema = (input) => {
|
|
|
67
77
|
// A false target remains impossible, even when siblings describe a type or annotations.
|
|
68
78
|
return { ...merged, allOf: [false, ...(Array.isArray(merged.allOf) ? merged.allOf : [])] };
|
|
69
79
|
};
|
|
80
|
+
/** Annotations that a wrapper schema contributes on top of the schema it wraps. */
|
|
81
|
+
const annotationKeys = ['title', 'description', 'default', 'readOnly', 'writeOnly', 'deprecated'];
|
|
82
|
+
/** Only annotation-only wrappers can be replaced without losing independent constraints. */
|
|
83
|
+
const wrapperKeys = new Set([...annotationKeys, ...referenceKeys, '__scalar_', 'allOf', 'anyOf', 'oneOf']);
|
|
84
|
+
/** Types that never need their own page: their whole definition fits on one line. */
|
|
85
|
+
const primitiveTypes = new Set(['string', 'number', 'integer', 'boolean', 'null']);
|
|
86
|
+
/** Infer the JSON type of a literal, for `const` and `enum` schemas that do not declare one. */
|
|
87
|
+
const getJsonType = (value) => {
|
|
88
|
+
if (value === null)
|
|
89
|
+
return 'null';
|
|
90
|
+
if (Array.isArray(value))
|
|
91
|
+
return 'array';
|
|
92
|
+
if (typeof value === 'number')
|
|
93
|
+
return Number.isInteger(value) ? 'integer' : 'number';
|
|
94
|
+
return typeof value;
|
|
95
|
+
};
|
|
96
|
+
/** A schema that only allows `null`, as in `anyOf: [X, { type: 'null' }]`. */
|
|
97
|
+
const isNullSchema = (input) => {
|
|
98
|
+
const schema = getResolvedRef(input);
|
|
99
|
+
if (!isObject(schema))
|
|
100
|
+
return false;
|
|
101
|
+
const type = Array.isArray(schema.type) && schema.type.length === 1 ? schema.type[0] : schema.type;
|
|
102
|
+
return (type === 'null' &&
|
|
103
|
+
Object.keys(schema).every((key) => key === 'type' || key === 'title' || key === 'description' || referenceKeys.has(key)));
|
|
104
|
+
};
|
|
105
|
+
/** Wrappers that only annotate one schema, or make it nullable, describe that schema. */
|
|
106
|
+
const getWrapped = (value) => {
|
|
107
|
+
if (typeof value.schema !== 'object' ||
|
|
108
|
+
Object.keys(value.schema).some((key) => !wrapperKeys.has(key)) ||
|
|
109
|
+
value.type !== undefined ||
|
|
110
|
+
value.properties.length ||
|
|
111
|
+
value.required.size ||
|
|
112
|
+
value.items !== undefined ||
|
|
113
|
+
value.additionalProperties !== undefined ||
|
|
114
|
+
value.not !== undefined ||
|
|
115
|
+
value.discriminator ||
|
|
116
|
+
value.enum ||
|
|
117
|
+
value.const !== undefined)
|
|
118
|
+
return undefined;
|
|
119
|
+
const allOf = value.allOf ?? [];
|
|
120
|
+
const anyOf = value.anyOf ?? [];
|
|
121
|
+
const oneOf = value.oneOf ?? [];
|
|
122
|
+
if (allOf.length === 1 && !anyOf.length && !oneOf.length)
|
|
123
|
+
return { core: allOf[0], nullable: false };
|
|
124
|
+
const union = anyOf.length ? anyOf : oneOf;
|
|
125
|
+
if (allOf.length || (anyOf.length && oneOf.length) || union.length !== 2)
|
|
126
|
+
return undefined;
|
|
127
|
+
const core = union.filter((branch) => !isNullSchema(branch));
|
|
128
|
+
return core.length === 1 ? { core: core[0], nullable: true } : undefined;
|
|
129
|
+
};
|
|
130
|
+
/** Join alternatives with `|`, keeping neighbouring plain types in one code span. */
|
|
131
|
+
const joinAlternatives = (alternatives) => {
|
|
132
|
+
// `array of string | null` would read as an array of nullable strings.
|
|
133
|
+
if (alternatives.length > 1)
|
|
134
|
+
alternatives = alternatives.map((alternative) => {
|
|
135
|
+
const only = alternative.length === 1 ? alternative[0] : undefined;
|
|
136
|
+
return only?.type === 'inlineCode' && only.value.startsWith('array of ')
|
|
137
|
+
? [inlineCode(`(${only.value})`)]
|
|
138
|
+
: alternative;
|
|
139
|
+
});
|
|
140
|
+
const nodes = [];
|
|
141
|
+
let plain = [];
|
|
142
|
+
const flushPlain = () => {
|
|
143
|
+
if (plain.length)
|
|
144
|
+
nodes.push(...(nodes.length ? [text(' | ')] : []), inlineCode(plain.join(' | ')));
|
|
145
|
+
plain = [];
|
|
146
|
+
};
|
|
147
|
+
for (const alternative of alternatives) {
|
|
148
|
+
const only = alternative.length === 1 ? alternative[0] : undefined;
|
|
149
|
+
if (only?.type === 'inlineCode') {
|
|
150
|
+
plain.push(only.value);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
flushPlain();
|
|
154
|
+
nodes.push(...(nodes.length ? [text(' | ')] : []), ...alternative);
|
|
155
|
+
}
|
|
156
|
+
flushPlain();
|
|
157
|
+
return nodes;
|
|
158
|
+
};
|
|
70
159
|
/** Keep merged reference siblings and sorted properties stable throughout an export. */
|
|
71
160
|
export const createSchemaRenderer = ({ maxNodes = MAX_NODES } = {}) => {
|
|
72
161
|
const views = new WeakMap();
|
|
162
|
+
/** Wrappers being unwrapped, so a wrapper that refers to itself stops unwrapping. */
|
|
163
|
+
const unwrapping = new Set();
|
|
73
164
|
const view = (input) => {
|
|
74
165
|
const cached = typeof input === 'object' ? views.get(input) : undefined;
|
|
75
166
|
if (cached)
|
|
@@ -82,7 +173,7 @@ export const createSchemaRenderer = ({ maxNodes = MAX_NODES } = {}) => {
|
|
|
82
173
|
const properties = Object.entries(value.properties ?? {})
|
|
83
174
|
.filter(([, child]) => typeof child === 'boolean' || (child !== null && typeof child === 'object'))
|
|
84
175
|
.sort(([a], [b]) => Number(required.has(b)) - Number(required.has(a)) || a.localeCompare(b));
|
|
85
|
-
|
|
176
|
+
let result = {
|
|
86
177
|
...value,
|
|
87
178
|
type: typeof schema === 'boolean' ? (schema ? 'any' : 'never') : value.type,
|
|
88
179
|
schema: schema,
|
|
@@ -90,91 +181,319 @@ export const createSchemaRenderer = ({ maxNodes = MAX_NODES } = {}) => {
|
|
|
90
181
|
properties,
|
|
91
182
|
};
|
|
92
183
|
result.name = getSharedName(input, result);
|
|
184
|
+
const wrapped = typeof input === 'object' && !unwrapping.has(input) ? getWrapped(result) : undefined;
|
|
185
|
+
if (wrapped && typeof input === 'object') {
|
|
186
|
+
unwrapping.add(input);
|
|
187
|
+
const inner = view(wrapped.core);
|
|
188
|
+
unwrapping.delete(input);
|
|
189
|
+
const annotations = Object.fromEntries(annotationKeys.flatMap((key) => (result[key] === undefined ? [] : [[key, result[key]]])));
|
|
190
|
+
result = { ...inner, ...annotations, core: wrapped.core, nullable: wrapped.nullable || inner.nullable };
|
|
191
|
+
}
|
|
93
192
|
if (typeof input === 'object')
|
|
94
193
|
views.set(input, result);
|
|
95
194
|
return result;
|
|
96
195
|
};
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
196
|
+
const primitives = new WeakMap();
|
|
197
|
+
/** Primitives, enums, consts and aliases of them, whose whole definition fits on one line. */
|
|
198
|
+
const isPrimitive = (input) => {
|
|
199
|
+
if (typeof input !== 'object')
|
|
200
|
+
return true;
|
|
201
|
+
const cached = primitives.get(input);
|
|
202
|
+
if (cached !== undefined)
|
|
203
|
+
return cached;
|
|
204
|
+
// Assume a recursive alias is not primitive while it is being checked.
|
|
205
|
+
primitives.set(input, false);
|
|
206
|
+
const value = view(input);
|
|
207
|
+
const types = value.type === undefined ? [] : [value.type].flat();
|
|
208
|
+
const result = value.core !== undefined
|
|
209
|
+
? isPrimitive(value.core)
|
|
210
|
+
: typeof value.schema === 'boolean' ||
|
|
211
|
+
(!value.properties.length &&
|
|
212
|
+
value.items === undefined &&
|
|
213
|
+
(value.additionalProperties === undefined || typeof value.additionalProperties === 'boolean') &&
|
|
214
|
+
!value.allOf?.length &&
|
|
215
|
+
!value.anyOf?.length &&
|
|
216
|
+
!value.oneOf?.length &&
|
|
217
|
+
value.not === undefined &&
|
|
218
|
+
!value.discriminator &&
|
|
219
|
+
types.every((type) => primitiveTypes.has(type)));
|
|
220
|
+
primitives.set(input, result);
|
|
221
|
+
return result;
|
|
222
|
+
};
|
|
223
|
+
/** Whether the one-line summary of a schema has anything besides its type. */
|
|
224
|
+
const hasAnnotations = (value) => value.description !== undefined ||
|
|
225
|
+
value.format !== undefined ||
|
|
226
|
+
value.enum !== undefined ||
|
|
227
|
+
value.const !== undefined ||
|
|
228
|
+
value.default !== undefined ||
|
|
229
|
+
value.readOnly === true ||
|
|
230
|
+
value.writeOnly === true ||
|
|
231
|
+
value.deprecated === true ||
|
|
232
|
+
value.additionalProperties === false ||
|
|
233
|
+
numericAnnotations.some((key) => value[key] !== undefined);
|
|
234
|
+
const numericAnnotations = [
|
|
235
|
+
'minimum',
|
|
236
|
+
'maximum',
|
|
237
|
+
'exclusiveMinimum',
|
|
238
|
+
'exclusiveMaximum',
|
|
239
|
+
'multipleOf',
|
|
240
|
+
'minLength',
|
|
241
|
+
'maxLength',
|
|
242
|
+
'pattern',
|
|
243
|
+
'minProperties',
|
|
244
|
+
'maxProperties',
|
|
245
|
+
'minItems',
|
|
246
|
+
'maxItems',
|
|
247
|
+
'uniqueItems',
|
|
248
|
+
];
|
|
249
|
+
/** The declared types, or the types implied by `const`, `enum`, properties or items. */
|
|
250
|
+
const getTypes = (value) => {
|
|
251
|
+
if (value.type !== undefined)
|
|
252
|
+
return [value.type].flat();
|
|
111
253
|
if (value.const !== undefined)
|
|
112
|
-
|
|
113
|
-
if (value.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
'
|
|
117
|
-
|
|
118
|
-
'
|
|
119
|
-
|
|
120
|
-
'multipleOf',
|
|
121
|
-
'minLength',
|
|
122
|
-
'maxLength',
|
|
123
|
-
'pattern',
|
|
124
|
-
'minProperties',
|
|
125
|
-
'maxProperties',
|
|
126
|
-
])
|
|
127
|
-
add(key, value[key]);
|
|
128
|
-
for (const key of ['readOnly', 'writeOnly']) {
|
|
129
|
-
if (value[key])
|
|
130
|
-
nodes.push(text(`${nodes.length ? ', ' : ''}${key}`));
|
|
131
|
-
}
|
|
132
|
-
if (!hideDescription && value.description)
|
|
133
|
-
nodes.push(text(`${nodes.length ? ' — ' : ''}${value.description}`));
|
|
134
|
-
return nodes;
|
|
254
|
+
return [getJsonType(value.const)];
|
|
255
|
+
if (value.enum?.length)
|
|
256
|
+
return [...new Set(value.enum.map(getJsonType))];
|
|
257
|
+
if (value.properties.length || isObject(value.additionalProperties))
|
|
258
|
+
return ['object'];
|
|
259
|
+
if (value.items !== undefined)
|
|
260
|
+
return ['array'];
|
|
261
|
+
return [];
|
|
135
262
|
};
|
|
136
|
-
const forDocument = (models = {}) => {
|
|
263
|
+
const forDocument = (models = {}, settings = {}, destinations) => {
|
|
264
|
+
const linked = settings.schemaReferences?.mode === 'linked';
|
|
265
|
+
const inlinePrimitives = settings.schemaReferences?.inlinePrimitives !== false;
|
|
137
266
|
/** Shared schemas this document already expanded, with the name later references use. */
|
|
138
267
|
const shown = new Map();
|
|
139
268
|
/** Models that the document renders in their own sections after the operations. */
|
|
140
269
|
const sections = new Set(Object.values(models).map((model) => getResolvedRef(model) ?? model));
|
|
141
270
|
let nodeCount = 0;
|
|
271
|
+
/** The reference a nested schema links to in linked mode, instead of expanding its target. */
|
|
272
|
+
const getLink = (input) => {
|
|
273
|
+
if ((!linked && !destinations) || !isObject(input))
|
|
274
|
+
return undefined;
|
|
275
|
+
const ref = getRef(input);
|
|
276
|
+
if (ref !== undefined) {
|
|
277
|
+
// A primitive alias is shorter than a link to it, and saves the reader a page.
|
|
278
|
+
const resolved = getResolvedRef(input) !== undefined;
|
|
279
|
+
if (!linked && !destinations?.has(getReferenceName(ref)))
|
|
280
|
+
return undefined;
|
|
281
|
+
return resolved && inlinePrimitives && isPrimitive(input) ? undefined : ref;
|
|
282
|
+
}
|
|
283
|
+
const core = view(input).core;
|
|
284
|
+
return core === undefined ? undefined : getLink(core);
|
|
285
|
+
};
|
|
286
|
+
const referenceNode = (ref) => {
|
|
287
|
+
const name = getReferenceName(ref);
|
|
288
|
+
const url = settings.schemaReferences?.resolveUrl
|
|
289
|
+
? settings.schemaReferences.resolveUrl({ ref, name })
|
|
290
|
+
: destinations?.get(name);
|
|
291
|
+
return url && safeUrl(url) ? link(url, name) : inlineCode(name);
|
|
292
|
+
};
|
|
293
|
+
/** Annotations written next to a linked reference; the linked page documents its target. */
|
|
294
|
+
const getLinkAnnotations = (input) => {
|
|
295
|
+
const own = {};
|
|
296
|
+
let current = input;
|
|
297
|
+
while (isObject(current)) {
|
|
298
|
+
for (const [key, entry] of Object.entries(current)) {
|
|
299
|
+
if (!referenceKeys.has(key) &&
|
|
300
|
+
(!structuralKeywords.has(key) ||
|
|
301
|
+
numericAnnotations.some((annotation) => annotation === key) ||
|
|
302
|
+
(key === 'additionalProperties' && entry === false)) &&
|
|
303
|
+
!(key in own))
|
|
304
|
+
own[key] = entry;
|
|
305
|
+
}
|
|
306
|
+
if (getRef(current) !== undefined)
|
|
307
|
+
break;
|
|
308
|
+
current = view(current).core;
|
|
309
|
+
}
|
|
310
|
+
return view(own);
|
|
311
|
+
};
|
|
312
|
+
/** The annotations a nested schema prints next to its type: for a link, only those beside the reference. */
|
|
313
|
+
const getOwnView = (input) => getLink(input) === undefined ? view(input) : getLinkAnnotations(input);
|
|
314
|
+
const getLabel = (input, nested, depth = 0) => {
|
|
315
|
+
const value = view(input);
|
|
316
|
+
const ref = nested || destinations ? getLink(input) : undefined;
|
|
317
|
+
if (ref !== undefined) {
|
|
318
|
+
// A reference to a nullable model is already nullable; a nullable wrapper around one is not.
|
|
319
|
+
const nullable = getRef(input) === undefined && value.nullable;
|
|
320
|
+
const structural = isObject(input) && Object.keys(input).some((key) => structuralKeywords.has(key));
|
|
321
|
+
return {
|
|
322
|
+
nodes: joinAlternatives([[referenceNode(ref)], ...(nullable ? [[inlineCode('null')]] : [])]),
|
|
323
|
+
complete: !structural,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (typeof value.schema === 'boolean')
|
|
327
|
+
return { nodes: [], complete: true };
|
|
328
|
+
const types = getTypes(value);
|
|
329
|
+
const alternatives = [];
|
|
330
|
+
let complete = !value.properties.length &&
|
|
331
|
+
!isObject(value.additionalProperties) &&
|
|
332
|
+
value.not === undefined &&
|
|
333
|
+
!value.discriminator &&
|
|
334
|
+
!value.allOf?.length &&
|
|
335
|
+
!(value.anyOf?.length && value.oneOf?.length);
|
|
336
|
+
const union = value.anyOf?.length ? value.anyOf : value.oneOf;
|
|
337
|
+
if (union?.length) {
|
|
338
|
+
const branches = depth < 8 ? union.map((branch) => getLabel(branch, true, depth + 1)) : [];
|
|
339
|
+
// A union of fully described branches reads as one type, for example `string | integer`.
|
|
340
|
+
if (complete &&
|
|
341
|
+
!types.length &&
|
|
342
|
+
branches.length === union.length &&
|
|
343
|
+
branches.every((branch, index) => branch.complete && branch.nodes.length && !hasAnnotations(getOwnView(union[index]))))
|
|
344
|
+
alternatives.push(...branches.map((branch) => branch.nodes));
|
|
345
|
+
else
|
|
346
|
+
complete = false;
|
|
347
|
+
}
|
|
348
|
+
for (const type of types) {
|
|
349
|
+
if (type !== 'array' || value.items === undefined) {
|
|
350
|
+
alternatives.push([inlineCode(type)]);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
const items = depth < 8 ? getLabel(value.items, true, depth + 1) : undefined;
|
|
354
|
+
if (!items?.complete || !items.nodes.length || hasAnnotations(getOwnView(value.items))) {
|
|
355
|
+
complete = false;
|
|
356
|
+
alternatives.push([inlineCode('array')]);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
const only = items.nodes.length === 1 ? items.nodes[0] : undefined;
|
|
360
|
+
alternatives.push(only?.type === 'inlineCode'
|
|
361
|
+
? [inlineCode(only.value.includes(' | ') ? `array of (${only.value})` : `array of ${only.value}`)]
|
|
362
|
+
: [text('array of '), ...(items.nodes.length > 1 ? [text('('), ...items.nodes, text(')')] : items.nodes)]);
|
|
363
|
+
}
|
|
364
|
+
if (!types.length && value.items !== undefined)
|
|
365
|
+
complete = false;
|
|
366
|
+
// Without a type to name, a nullable schema says so and leaves its structure to the lines below.
|
|
367
|
+
if (value.nullable && !types.includes('null'))
|
|
368
|
+
alternatives.push(alternatives.length || complete ? [inlineCode('null')] : [text('nullable')]);
|
|
369
|
+
// A schema without types or structure accepts any value.
|
|
370
|
+
if (!alternatives.length && complete)
|
|
371
|
+
alternatives.push([inlineCode('any')]);
|
|
372
|
+
return { nodes: joinAlternatives(alternatives), complete };
|
|
373
|
+
};
|
|
374
|
+
/** The one-line summary of a schema: its type, then its annotations. */
|
|
375
|
+
const summarize = (input, { nested, label = getLabel(input, nested), showType = true, }) => {
|
|
376
|
+
const linkedLabel = (nested || destinations !== undefined) && getLink(input) !== undefined;
|
|
377
|
+
const value = linkedLabel ? getLinkAnnotations(input) : view(input);
|
|
378
|
+
if (typeof value.schema === 'boolean')
|
|
379
|
+
return { line: [text(value.schema ? 'any (true schema)' : 'never (false schema)')] };
|
|
380
|
+
const nodes = showType ? [...label.nodes] : [];
|
|
381
|
+
const add = (name, entry) => {
|
|
382
|
+
if (entry !== undefined)
|
|
383
|
+
nodes.push(text(`${nodes.length ? ', ' : ''}${name}: `), inlineCode(entry));
|
|
384
|
+
};
|
|
385
|
+
const flag = (name, entry) => {
|
|
386
|
+
if (entry)
|
|
387
|
+
nodes.push(text(`${nodes.length ? ', ' : ''}${name}`));
|
|
388
|
+
};
|
|
389
|
+
// Later references to a shared schema point back to this name.
|
|
390
|
+
const ref = getRef(input);
|
|
391
|
+
if (!linkedLabel && value.name !== undefined) {
|
|
392
|
+
// In linked mode the expanded schema also has its own page.
|
|
393
|
+
if (linked && ref !== undefined)
|
|
394
|
+
nodes.push(text(`${nodes.length ? ', ' : ''}schema: `), referenceNode(ref));
|
|
395
|
+
else
|
|
396
|
+
add('schema', value.name);
|
|
397
|
+
}
|
|
398
|
+
if (linkedLabel && value.type !== undefined)
|
|
399
|
+
add('type', [value.type].flat().join(' | '));
|
|
400
|
+
add('format', value.format);
|
|
401
|
+
if (value.enum)
|
|
402
|
+
add('possible values', value.enum.map((entry) => JSON.stringify(entry)).join(', '));
|
|
403
|
+
if (value.const !== undefined)
|
|
404
|
+
add('const', JSON.stringify(value.const));
|
|
405
|
+
if (value.default !== undefined)
|
|
406
|
+
add('default', JSON.stringify(value.default));
|
|
407
|
+
for (const key of numericAnnotations)
|
|
408
|
+
add(key, value[key]);
|
|
409
|
+
for (const key of ['readOnly', 'writeOnly', 'deprecated'])
|
|
410
|
+
flag(key, value[key]);
|
|
411
|
+
flag('no additional properties', value.additionalProperties === false);
|
|
412
|
+
// A reference without its own description still says what it is, from the schema it links to.
|
|
413
|
+
return { line: nodes, description: value.description ?? (linkedLabel ? view(input).description : undefined) };
|
|
414
|
+
};
|
|
142
415
|
/** Refer to a schema expanded elsewhere, keeping annotations the reference itself adds. */
|
|
143
|
-
const reference = (input,
|
|
416
|
+
const reference = (input, options, name, location) => {
|
|
144
417
|
const nodes = [];
|
|
145
|
-
const siblings = isObject(input)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
418
|
+
const siblings = isObject(input)
|
|
419
|
+
? Object.fromEntries(Object.entries(input).filter(([key]) => !referenceKeys.has(key)))
|
|
420
|
+
: {};
|
|
421
|
+
if (Object.keys(siblings).length && !options.hideDetails) {
|
|
422
|
+
const { line, description } = summarize(siblings, { nested: false, showType: false });
|
|
423
|
+
if (line.length)
|
|
424
|
+
nodes.push(paragraph(...line));
|
|
425
|
+
if (!options.hideDescription)
|
|
426
|
+
nodes.push(...describe(description));
|
|
150
427
|
}
|
|
151
428
|
nodes.push(paragraph(emphasis(text('Schema '), inlineCode(name), text(` is shown ${location}.`))));
|
|
152
429
|
return nodes;
|
|
153
430
|
};
|
|
431
|
+
/** Print a schema's summary line and description, unless the caller already did. */
|
|
432
|
+
const header = (input, depth, options, showType = true) => {
|
|
433
|
+
if (options.hideDetails)
|
|
434
|
+
return [];
|
|
435
|
+
const { line, description } = summarize(input, { nested: depth > 0, showType });
|
|
436
|
+
return [...(line.length ? [paragraph(...line)] : []), ...(options.hideDescription ? [] : describe(description))];
|
|
437
|
+
};
|
|
154
438
|
const render = (input, depth = 0, ancestors = [], options = {}) => {
|
|
439
|
+
if ((linked || destinations) && isObject(input) && '$ref' in input && typeof input.$ref === 'string') {
|
|
440
|
+
const target = getResolvedRef(input);
|
|
441
|
+
// Reference siblings are independent constraints, not replacements for target keywords.
|
|
442
|
+
const siblings = Object.fromEntries(Object.entries(input).filter(([key]) => !referenceKeys.has(key)));
|
|
443
|
+
const hasSiblings = Object.keys(siblings).length > 0;
|
|
444
|
+
if (((depth > 0 || destinations) && getLink(input) !== undefined) || target === undefined) {
|
|
445
|
+
// The summary links to the model page; only structural siblings still need rendering.
|
|
446
|
+
const structural = Object.fromEntries(Object.entries(siblings).filter(([key]) => structuralKeywords.has(key)));
|
|
447
|
+
return [
|
|
448
|
+
...header(input, Math.max(depth, 1), options),
|
|
449
|
+
...(Object.keys(structural).length
|
|
450
|
+
? render(structural, depth, ancestors, { hideDetails: true })
|
|
451
|
+
: []),
|
|
452
|
+
];
|
|
453
|
+
}
|
|
454
|
+
// An alias with siblings is another reference boundary. Keep its reference visible
|
|
455
|
+
// instead of overwriting it with the outer reference during normalization.
|
|
456
|
+
if (!destinations && depth === 0 && (hasSiblings || (isObject(target) && '$ref' in target))) {
|
|
457
|
+
return [
|
|
458
|
+
...(hasSiblings ? [paragraph(strong(text('All of:')))] : []),
|
|
459
|
+
...render(target, depth + 1, ancestors),
|
|
460
|
+
...(hasSiblings ? render(siblings, depth, ancestors) : []),
|
|
461
|
+
];
|
|
462
|
+
}
|
|
463
|
+
}
|
|
155
464
|
// Follow the original target: merging reference siblings creates fresh objects.
|
|
156
465
|
const identity = getResolvedRef(input) ?? input;
|
|
157
466
|
if (typeof identity === 'object' && ancestors.includes(identity)) {
|
|
158
467
|
return [paragraph(emphasis(text('[Circular Reference]')))];
|
|
159
468
|
}
|
|
160
469
|
const value = view(input);
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
470
|
+
const label = getLabel(input, depth > 0);
|
|
471
|
+
// A wrapper prints its merged summary, then its core schema's structure.
|
|
472
|
+
if (value.core !== undefined) {
|
|
473
|
+
const nodes = header(input, depth, options);
|
|
474
|
+
if (label.complete)
|
|
475
|
+
return nodes;
|
|
476
|
+
return [
|
|
477
|
+
...nodes,
|
|
478
|
+
...render(value.core, depth, ancestors, {
|
|
479
|
+
hideDetails: true,
|
|
480
|
+
hideDescription: true,
|
|
481
|
+
property: options.property,
|
|
482
|
+
}),
|
|
483
|
+
];
|
|
484
|
+
}
|
|
485
|
+
const shared = getSharedIdentity(input);
|
|
167
486
|
const name = options.name ?? value.name;
|
|
168
|
-
if (shared && name !== undefined) {
|
|
487
|
+
if (!destinations && shared && name !== undefined) {
|
|
169
488
|
// Expanding every path through a shared schema grows exponentially, so expand it once.
|
|
170
489
|
const previous = shown.get(shared);
|
|
171
490
|
if (previous !== undefined) {
|
|
172
491
|
// A model section already prints its own annotations above the schema.
|
|
173
492
|
const referenceOptions = options.name === undefined ? options : { ...options, hideDetails: true };
|
|
174
|
-
return reference(input,
|
|
493
|
+
return reference(input, referenceOptions, previous.name, 'above');
|
|
175
494
|
}
|
|
176
495
|
if (value.name !== undefined && options.name === undefined && depth >= MAX_DEPTH && sections.has(shared))
|
|
177
|
-
return reference(input,
|
|
496
|
+
return reference(input, options, value.name, 'below under Schemas');
|
|
178
497
|
}
|
|
179
498
|
if (depth >= MAX_DEPTH)
|
|
180
499
|
return [paragraph(text('[Maximum schema depth reached]'))];
|
|
@@ -182,73 +501,149 @@ export const createSchemaRenderer = ({ maxNodes = MAX_NODES } = {}) => {
|
|
|
182
501
|
return [paragraph(emphasis(text('[Schema output truncated]')))];
|
|
183
502
|
nodeCount++;
|
|
184
503
|
if (shared && name !== undefined && !shown.has(shared))
|
|
185
|
-
shown.set(shared, name);
|
|
504
|
+
shown.set(shared, { name, description: value.description });
|
|
186
505
|
if (typeof value.schema === 'boolean')
|
|
187
|
-
return
|
|
506
|
+
return header(input, depth, options);
|
|
188
507
|
const childAncestors = [...ancestors, identity];
|
|
189
508
|
const nodes = [];
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
509
|
+
// Discriminator values that name a branch are printed beside it instead of in a separate list.
|
|
510
|
+
const mappings = Object.entries(value.discriminator?.mapping ?? {});
|
|
511
|
+
const mapped = new Set();
|
|
512
|
+
let discriminatorShown = false;
|
|
513
|
+
if (!label.complete) {
|
|
514
|
+
for (const [key, title] of [
|
|
515
|
+
['allOf', 'All of:'],
|
|
516
|
+
['anyOf', 'Any of:'],
|
|
517
|
+
['oneOf', 'One of:'],
|
|
518
|
+
]) {
|
|
519
|
+
if (!value[key]?.length)
|
|
520
|
+
continue;
|
|
521
|
+
// One list item per branch keeps the boundary between branches visible.
|
|
522
|
+
const branches = value[key].map((child) => {
|
|
523
|
+
const blocks = render(child, depth + 1, childAncestors, { showType: true });
|
|
524
|
+
const ref = getRef(child);
|
|
525
|
+
const values = key === 'allOf' || ref === undefined ? [] : mappings.filter(([, target]) => target === ref);
|
|
526
|
+
const first = blocks[0];
|
|
527
|
+
if (values.length && first?.type === 'paragraph') {
|
|
528
|
+
for (const [name] of values)
|
|
529
|
+
mapped.add(name);
|
|
530
|
+
first.children.push(text(`${first.children.length ? ', ' : ''}${value.discriminator.propertyName}: `), inlineCode(values.map(([name]) => name).join(', ')));
|
|
531
|
+
}
|
|
532
|
+
return item(...blocks);
|
|
533
|
+
});
|
|
534
|
+
const discriminated = key !== 'allOf' && value.discriminator;
|
|
535
|
+
nodes.push(paragraph(strong(text(title)), ...(discriminated ? [text(' discriminated by '), inlineCode(value.discriminator.propertyName)] : [])), list(branches));
|
|
536
|
+
if (discriminated)
|
|
537
|
+
discriminatorShown = true;
|
|
538
|
+
}
|
|
539
|
+
if (value.not !== undefined)
|
|
540
|
+
nodes.push(paragraph(strong(text('Not:'))), ...render(value.not, depth + 1, childAncestors));
|
|
197
541
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
542
|
+
// Child sections imply a single container type, but never its nullable alternatives.
|
|
543
|
+
const impliedType = !options.showType &&
|
|
544
|
+
!label.complete &&
|
|
545
|
+
((value.type === 'object' && value.properties.length > 0) ||
|
|
546
|
+
(value.type === 'array' && value.items !== undefined));
|
|
547
|
+
nodes.push(...header(input, depth, options, !impliedType));
|
|
548
|
+
if (linked || destinations) {
|
|
549
|
+
// A reference sibling may require a field declared only in the target schema.
|
|
550
|
+
const declared = new Set(value.properties.map(([name]) => name));
|
|
551
|
+
const required = [...value.required].filter((name) => !declared.has(name));
|
|
552
|
+
if (required.length)
|
|
553
|
+
nodes.push(paragraph(strong(text('Required fields:')), text(' '), inlineCode(required.join(', '))));
|
|
208
554
|
}
|
|
209
555
|
if (value.properties.length) {
|
|
210
556
|
const properties = value.properties.map(([name, schema]) => {
|
|
211
|
-
const
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
557
|
+
const childLabel = getLabel(schema, true);
|
|
558
|
+
const { line, description } = summarize(schema, { nested: true, label: childLabel });
|
|
559
|
+
const title = [
|
|
560
|
+
strong(inlineCode(name), ...(value.required.has(name) ? [text(' (required)')] : [])),
|
|
561
|
+
];
|
|
562
|
+
if (line.length)
|
|
563
|
+
title.push(text(': '), ...line);
|
|
564
|
+
const blocks = [paragraph(...title), ...describe(description)];
|
|
565
|
+
if (!childLabel.complete)
|
|
566
|
+
blocks.push(...render(schema, depth + 1, childAncestors, {
|
|
567
|
+
hideDetails: true,
|
|
568
|
+
hideDescription: true,
|
|
569
|
+
property: true,
|
|
570
|
+
}));
|
|
220
571
|
return item(...blocks);
|
|
221
572
|
});
|
|
222
573
|
nodes.push(list(properties));
|
|
223
574
|
}
|
|
224
|
-
if (
|
|
575
|
+
if (!label.complete &&
|
|
576
|
+
value.items !== undefined &&
|
|
577
|
+
(value.type === 'array' || value.type === undefined || [value.type].flat().includes('array'))) {
|
|
225
578
|
nodes.push(paragraph(strong(text(options.property ? 'Items:' : 'Array of:'))), ...render(value.items, depth + 1, childAncestors));
|
|
226
579
|
}
|
|
227
|
-
|
|
228
|
-
if (value.minItems !== undefined)
|
|
229
|
-
constraints.push(item(paragraph(text('Min items: '), inlineCode(value.minItems))));
|
|
230
|
-
if (value.maxItems !== undefined)
|
|
231
|
-
constraints.push(item(paragraph(text('Max items: '), inlineCode(value.maxItems))));
|
|
232
|
-
if (value.uniqueItems !== undefined)
|
|
233
|
-
constraints.push(item(paragraph(text('Unique items: '), inlineCode(value.uniqueItems))));
|
|
234
|
-
if (constraints.length)
|
|
235
|
-
nodes.push(list(constraints));
|
|
236
|
-
if (value.additionalProperties !== undefined)
|
|
580
|
+
if (isObject(value.additionalProperties))
|
|
237
581
|
nodes.push(paragraph(strong(text('Additional properties:'))), ...render(value.additionalProperties, depth + 1, childAncestors));
|
|
238
|
-
|
|
582
|
+
const unmapped = mappings.filter(([name]) => !mapped.has(name));
|
|
583
|
+
if (value.discriminator && (!discriminatorShown || unmapped.length)) {
|
|
239
584
|
nodes.push(paragraph(strong(text('Discriminator:')), text(' '), inlineCode(value.discriminator.propertyName)));
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
nodes.push(list(mappings.map(([name, target]) => item(paragraph(inlineCode(name), text(': '), inlineCode(target))))));
|
|
585
|
+
if (unmapped.length)
|
|
586
|
+
nodes.push(list(unmapped.map(([name, target]) => item(paragraph(inlineCode(name), text(': '), linked || destinations ? referenceNode(target) : inlineCode(target))))));
|
|
243
587
|
}
|
|
244
588
|
return nodes;
|
|
245
589
|
};
|
|
590
|
+
/** Keywords whose values are schemas, and whether each holds a list or a map of them. */
|
|
591
|
+
const schemaKeywords = {
|
|
592
|
+
items: 'one',
|
|
593
|
+
additionalProperties: 'one',
|
|
594
|
+
not: 'one',
|
|
595
|
+
prefixItems: 'list',
|
|
596
|
+
allOf: 'list',
|
|
597
|
+
anyOf: 'list',
|
|
598
|
+
oneOf: 'list',
|
|
599
|
+
properties: 'map',
|
|
600
|
+
patternProperties: 'map',
|
|
601
|
+
};
|
|
602
|
+
const getExampleSchema = (input, root) => {
|
|
603
|
+
if (!isObject(input))
|
|
604
|
+
return input;
|
|
605
|
+
if (!root && getLink(input) !== undefined) {
|
|
606
|
+
const types = getTypes(view(input));
|
|
607
|
+
return types.includes('array') && !types.includes('object') ? { type: 'array', items: {} } : { type: 'object' };
|
|
608
|
+
}
|
|
609
|
+
// Primitive references stay linked; a spread copy would lose the non-enumerable target.
|
|
610
|
+
if (getRef(input) !== undefined) {
|
|
611
|
+
if (!root)
|
|
612
|
+
return input;
|
|
613
|
+
// Merging keeps the `$ref` key, which must not be followed again.
|
|
614
|
+
const merged = getResolvedRef(input, mergeSiblingReferences);
|
|
615
|
+
if (!isObject(merged))
|
|
616
|
+
return merged;
|
|
617
|
+
const { $ref: _ref, ...target } = merged;
|
|
618
|
+
return getExampleSchema(target, true);
|
|
619
|
+
}
|
|
620
|
+
const copy = { ...input };
|
|
621
|
+
for (const [key, kind] of Object.entries(schemaKeywords)) {
|
|
622
|
+
const value = input[key];
|
|
623
|
+
if (kind === 'one')
|
|
624
|
+
copy[key] = getExampleSchema(value, false);
|
|
625
|
+
else if (kind === 'list' && Array.isArray(value))
|
|
626
|
+
copy[key] = value.map((entry) => getExampleSchema(entry, false));
|
|
627
|
+
else if (kind === 'map' && isObject(value))
|
|
628
|
+
copy[key] = Object.fromEntries(Object.entries(value).map(([name, entry]) => [name, getExampleSchema(entry, false)]));
|
|
629
|
+
if (copy[key] === undefined)
|
|
630
|
+
delete copy[key];
|
|
631
|
+
}
|
|
632
|
+
return copy;
|
|
633
|
+
};
|
|
246
634
|
return {
|
|
635
|
+
linked,
|
|
247
636
|
view,
|
|
248
637
|
render,
|
|
638
|
+
exampleSchema: (schema) => (linked ? getExampleSchema(schema, true) : schema),
|
|
639
|
+
summarize: (schema) => summarize(schema, { nested: false }).line,
|
|
249
640
|
beginSection: () => {
|
|
250
641
|
nodeCount = 0;
|
|
251
642
|
},
|
|
643
|
+
shownAs: (schema) => {
|
|
644
|
+
const shared = getSharedIdentity(schema);
|
|
645
|
+
return shared ? shown.get(shared) : undefined;
|
|
646
|
+
},
|
|
252
647
|
forDocument,
|
|
253
648
|
};
|
|
254
649
|
};
|