@scalar/openapi-to-markdown 1.4.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +66 -5
  3. package/dist/document-anchors.d.ts +8 -0
  4. package/dist/document-anchors.d.ts.map +1 -0
  5. package/dist/document-anchors.js +25 -0
  6. package/dist/document-examples.d.ts +14 -0
  7. package/dist/document-examples.d.ts.map +1 -0
  8. package/dist/document-examples.js +54 -0
  9. package/dist/get-markdown-examples.d.ts +2 -0
  10. package/dist/get-markdown-examples.d.ts.map +1 -1
  11. package/dist/get-markdown-examples.js +11 -12
  12. package/dist/markdown-nodes.d.ts +22 -1
  13. package/dist/markdown-nodes.d.ts.map +1 -1
  14. package/dist/markdown-nodes.js +4 -0
  15. package/dist/parse-description.d.ts +2 -0
  16. package/dist/parse-description.d.ts.map +1 -1
  17. package/dist/parse-description.js +28 -1
  18. package/dist/render-document.d.ts +2 -2
  19. package/dist/render-document.d.ts.map +1 -1
  20. package/dist/render-document.js +217 -84
  21. package/dist/render-examples.d.ts +9 -1
  22. package/dist/render-examples.d.ts.map +1 -1
  23. package/dist/render-examples.js +26 -8
  24. package/dist/render-operation-details.d.ts +3 -2
  25. package/dist/render-operation-details.d.ts.map +1 -1
  26. package/dist/render-operation-details.js +8 -5
  27. package/dist/render-operation.d.ts +16 -1
  28. package/dist/render-operation.d.ts.map +1 -1
  29. package/dist/render-operation.js +168 -42
  30. package/dist/render-schema.d.ts +30 -3
  31. package/dist/render-schema.d.ts.map +1 -1
  32. package/dist/render-schema.js +474 -111
  33. package/dist/render-security.d.ts +7 -3
  34. package/dist/render-security.d.ts.map +1 -1
  35. package/dist/render-security.js +61 -12
  36. package/dist/select-document.d.ts +5 -0
  37. package/dist/select-document.d.ts.map +1 -1
  38. package/dist/select-document.js +3 -2
  39. package/package.json +16 -7
@@ -1,6 +1,6 @@
1
1
  import { isObject } from '@scalar/helpers/object/is-object';
2
2
  import { getResolvedRef, mergeSiblingReferences } from '@scalar/workspace-store/helpers/get-resolved-ref';
3
- import { inlineCode, item, link, list, paragraph, safeUrl, strong, text } from './markdown-nodes.js';
3
+ import { describe, emphasis, inlineCode, item, link, list, paragraph, safeUrl, strong, text } from './markdown-nodes.js';
4
4
  /** Guards against stack exhaustion. Past it, references to models point to their own section instead. */
5
5
  const MAX_DEPTH = 64;
6
6
  /**
@@ -25,7 +25,8 @@ const structuralKeywords = new Set([
25
25
  ]);
26
26
  /** Link bookkeeping that is not a sibling keyword of a reference. */
27
27
  const referenceKeys = new Set(['$ref', '$ref-value', '$global', '$status']);
28
- const emphasis = (...children) => ({ type: 'emphasis', children });
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;
29
30
  /** Prefer the component name, which is how model sections and other references identify the schema. */
30
31
  const getReferenceName = (ref) => {
31
32
  try {
@@ -51,6 +52,17 @@ const getSharedName = (input, view) => {
51
52
  // Leaf schemas are as short as a reference to them, so they stay in place.
52
53
  return structured ? getReferenceName(ref) : undefined;
53
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
+ };
54
66
  /** Boolean targets still combine with adjacent schema keywords. */
55
67
  const resolveMarkdownSchema = (input) => {
56
68
  const target = getResolvedRef(input);
@@ -65,9 +77,90 @@ const resolveMarkdownSchema = (input) => {
65
77
  // A false target remains impossible, even when siblings describe a type or annotations.
66
78
  return { ...merged, allOf: [false, ...(Array.isArray(merged.allOf) ? merged.allOf : [])] };
67
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
+ };
68
159
  /** Keep merged reference siblings and sorted properties stable throughout an export. */
69
160
  export const createSchemaRenderer = ({ maxNodes = MAX_NODES } = {}) => {
70
161
  const views = new WeakMap();
162
+ /** Wrappers being unwrapped, so a wrapper that refers to itself stops unwrapping. */
163
+ const unwrapping = new Set();
71
164
  const view = (input) => {
72
165
  const cached = typeof input === 'object' ? views.get(input) : undefined;
73
166
  if (cached)
@@ -80,7 +173,7 @@ export const createSchemaRenderer = ({ maxNodes = MAX_NODES } = {}) => {
80
173
  const properties = Object.entries(value.properties ?? {})
81
174
  .filter(([, child]) => typeof child === 'boolean' || (child !== null && typeof child === 'object'))
82
175
  .sort(([a], [b]) => Number(required.has(b)) - Number(required.has(a)) || a.localeCompare(b));
83
- const result = {
176
+ let result = {
84
177
  ...value,
85
178
  type: typeof schema === 'boolean' ? (schema ? 'any' : 'never') : value.type,
86
179
  schema: schema,
@@ -88,87 +181,279 @@ export const createSchemaRenderer = ({ maxNodes = MAX_NODES } = {}) => {
88
181
  properties,
89
182
  };
90
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
+ }
91
192
  if (typeof input === 'object')
92
193
  views.set(input, result);
93
194
  return result;
94
195
  };
95
- const details = (value, property = false, hideDescription = false, showType = true) => {
96
- if (typeof value.schema === 'boolean')
97
- return [text(value.schema ? 'any (true schema)' : 'never (false schema)')];
98
- const type = Array.isArray(value.type) ? value.type.join(' | ') : value.type;
99
- const nodes = showType && (type || property) ? [inlineCode(type || 'object')] : [];
100
- const add = (label, entry) => {
101
- if (entry !== undefined)
102
- nodes.push(text(`${nodes.length ? ', ' : ''}${label}: `), inlineCode(entry));
103
- };
104
- // Later references to a shared schema point back to this name.
105
- add('schema', value.name);
106
- add('format', value.format);
107
- if (value.enum)
108
- add('possible values', value.enum.map((entry) => JSON.stringify(entry)).join(', '));
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();
109
253
  if (value.const !== undefined)
110
- add('const', JSON.stringify(value.const));
111
- if (value.default !== undefined)
112
- add('default', JSON.stringify(value.default));
113
- for (const key of [
114
- 'minimum',
115
- 'maximum',
116
- 'exclusiveMinimum',
117
- 'exclusiveMaximum',
118
- 'multipleOf',
119
- 'minLength',
120
- 'maxLength',
121
- 'pattern',
122
- 'minProperties',
123
- 'maxProperties',
124
- ])
125
- add(key, value[key]);
126
- for (const key of ['readOnly', 'writeOnly']) {
127
- if (value[key])
128
- nodes.push(text(`${nodes.length ? ', ' : ''}${key}`));
129
- }
130
- if (!hideDescription && value.description)
131
- nodes.push(text(`${nodes.length ? ' — ' : ''}${value.description}`));
132
- 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 [];
133
262
  };
134
- const forDocument = (models = {}, settings = {}) => {
263
+ const forDocument = (models = {}, settings = {}, destinations) => {
135
264
  const linked = settings.schemaReferences?.mode === 'linked';
265
+ const inlinePrimitives = settings.schemaReferences?.inlinePrimitives !== false;
136
266
  /** Shared schemas this document already expanded, with the name later references use. */
137
267
  const shown = new Map();
138
268
  /** Models that the document renders in their own sections after the operations. */
139
269
  const sections = new Set(Object.values(models).map((model) => getResolvedRef(model) ?? model));
140
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
+ };
141
415
  /** Refer to a schema expanded elsewhere, keeping annotations the reference itself adds. */
142
- const reference = (input, value, options, name, location) => {
416
+ const reference = (input, options, name, location) => {
143
417
  const nodes = [];
144
- const siblings = isObject(input) && Object.keys(input).some((key) => !referenceKeys.has(key));
145
- if (siblings && !options.hideDetails) {
146
- const annotations = details({ ...value, name: undefined }, false, options.hideDescription);
147
- if (annotations.length)
148
- nodes.push(paragraph(...annotations));
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));
149
427
  }
150
428
  nodes.push(paragraph(emphasis(text('Schema '), inlineCode(name), text(` is shown ${location}.`))));
151
429
  return nodes;
152
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
+ };
153
438
  const render = (input, depth = 0, ancestors = [], options = {}) => {
154
- if (linked && isObject(input) && '$ref' in input && typeof input.$ref === 'string') {
439
+ if ((linked || destinations) && isObject(input) && '$ref' in input && typeof input.$ref === 'string') {
155
440
  const target = getResolvedRef(input);
156
441
  // Reference siblings are independent constraints, not replacements for target keywords.
157
442
  const siblings = Object.fromEntries(Object.entries(input).filter(([key]) => !referenceKeys.has(key)));
158
443
  const hasSiblings = Object.keys(siblings).length > 0;
159
- if (depth > 0 || target === undefined) {
160
- const name = getReferenceName(input.$ref);
161
- const url = settings.schemaReferences?.resolveUrl?.({ ref: input.$ref, name });
162
- const nodes = [
163
- paragraph(text('Schema: '), url && safeUrl(url) ? link(url, name) : inlineCode(name)),
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
+ : []),
164
452
  ];
165
- if (hasSiblings)
166
- nodes.push(...render(siblings, depth, ancestors));
167
- return nodes;
168
453
  }
169
454
  // An alias with siblings is another reference boundary. Keep its reference visible
170
455
  // instead of overwriting it with the outer reference during normalization.
171
- if (hasSiblings || (isObject(target) && '$ref' in target)) {
456
+ if (!destinations && depth === 0 && (hasSiblings || (isObject(target) && '$ref' in target))) {
172
457
  return [
173
458
  ...(hasSiblings ? [paragraph(strong(text('All of:')))] : []),
174
459
  ...render(target, depth + 1, ancestors),
@@ -182,23 +467,33 @@ export const createSchemaRenderer = ({ maxNodes = MAX_NODES } = {}) => {
182
467
  return [paragraph(emphasis(text('[Circular Reference]')))];
183
468
  }
184
469
  const value = view(input);
185
- // A named model with structural reference siblings is its own schema, not another
186
- // occurrence of its target. Otherwise either rendering order can hide properties.
187
- const sharedIdentity = isObject(input) && '$ref' in input && Object.keys(input).some((key) => structuralKeywords.has(key))
188
- ? input
189
- : identity;
190
- const shared = typeof sharedIdentity === 'object' && sharedIdentity !== null ? sharedIdentity : undefined;
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);
191
486
  const name = options.name ?? value.name;
192
- if (shared && name !== undefined) {
487
+ if (!destinations && shared && name !== undefined) {
193
488
  // Expanding every path through a shared schema grows exponentially, so expand it once.
194
489
  const previous = shown.get(shared);
195
490
  if (previous !== undefined) {
196
491
  // A model section already prints its own annotations above the schema.
197
492
  const referenceOptions = options.name === undefined ? options : { ...options, hideDetails: true };
198
- return reference(input, value, referenceOptions, previous, 'above');
493
+ return reference(input, referenceOptions, previous.name, 'above');
199
494
  }
200
495
  if (value.name !== undefined && options.name === undefined && depth >= MAX_DEPTH && sections.has(shared))
201
- return reference(input, value, options, value.name, 'below under Schemas');
496
+ return reference(input, options, value.name, 'below under Schemas');
202
497
  }
203
498
  if (depth >= MAX_DEPTH)
204
499
  return [paragraph(text('[Maximum schema depth reached]'))];
@@ -206,31 +501,51 @@ export const createSchemaRenderer = ({ maxNodes = MAX_NODES } = {}) => {
206
501
  return [paragraph(emphasis(text('[Schema output truncated]')))];
207
502
  nodeCount++;
208
503
  if (shared && name !== undefined && !shown.has(shared))
209
- shown.set(shared, name);
504
+ shown.set(shared, { name, description: value.description });
210
505
  if (typeof value.schema === 'boolean')
211
- return options.hideDetails ? [] : [paragraph(...details(value))];
506
+ return header(input, depth, options);
212
507
  const childAncestors = [...ancestors, identity];
213
508
  const nodes = [];
214
- for (const [key, label] of [
215
- ['allOf', 'All of:'],
216
- ['anyOf', 'Any of:'],
217
- ['oneOf', 'One of:'],
218
- ]) {
219
- if (value[key]?.length)
220
- nodes.push(paragraph(strong(text(label))), ...value[key].flatMap((child) => render(child, depth + 1, childAncestors)));
221
- }
222
- if (value.not !== undefined)
223
- nodes.push(paragraph(strong(text('Not:'))), ...render(value.not, depth + 1, childAncestors));
224
- const array = value.type === 'array' || value.items !== undefined;
225
- if (!options.hideDetails) {
226
- // Child sections imply a single container type, but never its nullable alternatives.
227
- const impliedType = (value.type === 'object' && value.properties.length > 0) ||
228
- (value.type === 'array' && value.items !== undefined);
229
- const annotations = details(value, false, options.hideDescription, !impliedType);
230
- if (annotations.length)
231
- nodes.push(paragraph(...annotations));
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));
232
541
  }
233
- if (linked) {
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) {
234
549
  // A reference sibling may require a field declared only in the target schema.
235
550
  const declared = new Set(value.properties.map(([name]) => name));
236
551
  const required = [...value.required].filter((name) => !declared.has(name));
@@ -239,48 +554,96 @@ export const createSchemaRenderer = ({ maxNodes = MAX_NODES } = {}) => {
239
554
  }
240
555
  if (value.properties.length) {
241
556
  const properties = value.properties.map(([name, schema]) => {
242
- const child = view(schema);
243
- const label = [inlineCode(name)];
244
- if (value.required.has(name))
245
- label.push(text(' (required)'));
246
- const blocks = [paragraph(strong(...label)), paragraph(...details(child, true))];
247
- blocks.push(...render(schema, depth + 1, childAncestors, {
248
- hideDetails: true,
249
- property: true,
250
- }));
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
+ }));
251
571
  return item(...blocks);
252
572
  });
253
573
  nodes.push(list(properties));
254
574
  }
255
- if (array && value.items !== undefined) {
575
+ if (!label.complete &&
576
+ value.items !== undefined &&
577
+ (value.type === 'array' || value.type === undefined || [value.type].flat().includes('array'))) {
256
578
  nodes.push(paragraph(strong(text(options.property ? 'Items:' : 'Array of:'))), ...render(value.items, depth + 1, childAncestors));
257
579
  }
258
- const constraints = [];
259
- if (value.minItems !== undefined)
260
- constraints.push(item(paragraph(text('Min items: '), inlineCode(value.minItems))));
261
- if (value.maxItems !== undefined)
262
- constraints.push(item(paragraph(text('Max items: '), inlineCode(value.maxItems))));
263
- if (value.uniqueItems !== undefined)
264
- constraints.push(item(paragraph(text('Unique items: '), inlineCode(value.uniqueItems))));
265
- if (constraints.length)
266
- nodes.push(list(constraints));
267
- if (value.additionalProperties !== undefined)
580
+ if (isObject(value.additionalProperties))
268
581
  nodes.push(paragraph(strong(text('Additional properties:'))), ...render(value.additionalProperties, depth + 1, childAncestors));
269
- if (value.discriminator) {
582
+ const unmapped = mappings.filter(([name]) => !mapped.has(name));
583
+ if (value.discriminator && (!discriminatorShown || unmapped.length)) {
270
584
  nodes.push(paragraph(strong(text('Discriminator:')), text(' '), inlineCode(value.discriminator.propertyName)));
271
- const mappings = Object.entries(value.discriminator.mapping ?? {});
272
- if (mappings.length)
273
- 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))))));
274
587
  }
275
588
  return nodes;
276
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
+ };
277
634
  return {
278
635
  linked,
279
636
  view,
280
637
  render,
638
+ exampleSchema: (schema) => (linked ? getExampleSchema(schema, true) : schema),
639
+ summarize: (schema) => summarize(schema, { nested: false }).line,
281
640
  beginSection: () => {
282
641
  nodeCount = 0;
283
642
  },
643
+ shownAs: (schema) => {
644
+ const shared = getSharedIdentity(schema);
645
+ return shared ? shown.get(shared) : undefined;
646
+ },
284
647
  forDocument,
285
648
  };
286
649
  };