@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +114 -2
  3. package/dist/browser.d.ts.map +1 -1
  4. package/dist/browser.js +1 -1
  5. package/dist/create-markdown-from-openapi.js +1 -1
  6. package/dist/document-anchors.d.ts +8 -0
  7. package/dist/document-anchors.d.ts.map +1 -0
  8. package/dist/document-anchors.js +25 -0
  9. package/dist/document-examples.d.ts +14 -0
  10. package/dist/document-examples.d.ts.map +1 -0
  11. package/dist/document-examples.js +54 -0
  12. package/dist/get-markdown-examples.d.ts +3 -1
  13. package/dist/get-markdown-examples.d.ts.map +1 -1
  14. package/dist/get-markdown-examples.js +12 -4
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/markdown-nodes.d.ts +22 -1
  18. package/dist/markdown-nodes.d.ts.map +1 -1
  19. package/dist/markdown-nodes.js +4 -0
  20. package/dist/parse-description.d.ts +2 -0
  21. package/dist/parse-description.d.ts.map +1 -1
  22. package/dist/parse-description.js +28 -1
  23. package/dist/render-document.d.ts +2 -1
  24. package/dist/render-document.d.ts.map +1 -1
  25. package/dist/render-document.js +217 -84
  26. package/dist/render-examples.d.ts +9 -1
  27. package/dist/render-examples.d.ts.map +1 -1
  28. package/dist/render-examples.js +26 -8
  29. package/dist/render-operation-details.d.ts +3 -2
  30. package/dist/render-operation-details.d.ts.map +1 -1
  31. package/dist/render-operation-details.js +8 -5
  32. package/dist/render-operation.d.ts +16 -1
  33. package/dist/render-operation.d.ts.map +1 -1
  34. package/dist/render-operation.js +168 -42
  35. package/dist/render-schema.d.ts +32 -3
  36. package/dist/render-schema.d.ts.map +1 -1
  37. package/dist/render-schema.js +499 -104
  38. package/dist/render-security.d.ts +7 -3
  39. package/dist/render-security.d.ts.map +1 -1
  40. package/dist/render-security.js +61 -12
  41. package/dist/select-document.d.ts +19 -2
  42. package/dist/select-document.d.ts.map +1 -1
  43. package/dist/select-document.js +9 -6
  44. package/package.json +19 -10
@@ -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
- 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;
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
- return unescapeJsonPointer(match[1]);
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
- const result = {
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 details = (value, property = false, hideDescription = false, showType = true) => {
98
- if (typeof value.schema === 'boolean')
99
- return [text(value.schema ? 'any (true schema)' : 'never (false schema)')];
100
- const type = Array.isArray(value.type) ? value.type.join(' | ') : value.type;
101
- const nodes = showType && (type || property) ? [inlineCode(type || 'object')] : [];
102
- const add = (label, entry) => {
103
- if (entry !== undefined)
104
- nodes.push(text(`${nodes.length ? ', ' : ''}${label}: `), inlineCode(entry));
105
- };
106
- // Later references to a shared schema point back to this name.
107
- add('schema', value.name);
108
- add('format', value.format);
109
- if (value.enum)
110
- 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();
111
253
  if (value.const !== undefined)
112
- add('const', JSON.stringify(value.const));
113
- if (value.default !== undefined)
114
- add('default', JSON.stringify(value.default));
115
- for (const key of [
116
- 'minimum',
117
- 'maximum',
118
- 'exclusiveMinimum',
119
- 'exclusiveMaximum',
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, value, options, name, location) => {
416
+ const reference = (input, options, name, location) => {
144
417
  const nodes = [];
145
- const siblings = isObject(input) && Object.keys(input).some((key) => !referenceKeys.has(key));
146
- if (siblings && !options.hideDetails) {
147
- const annotations = details({ ...value, name: undefined }, false, options.hideDescription);
148
- if (annotations.length)
149
- 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));
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
- // A named model with structural reference siblings is its own schema, not another
162
- // occurrence of its target. Otherwise either rendering order can hide properties.
163
- const sharedIdentity = isObject(input) && '$ref' in input && Object.keys(input).some((key) => structuralKeywords.has(key))
164
- ? input
165
- : identity;
166
- 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);
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, value, referenceOptions, previous, 'above');
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, value, options, value.name, 'below under Schemas');
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 options.hideDetails ? [] : [paragraph(...details(value))];
506
+ return header(input, depth, options);
188
507
  const childAncestors = [...ancestors, identity];
189
508
  const nodes = [];
190
- for (const [key, label] of [
191
- ['allOf', 'All of:'],
192
- ['anyOf', 'Any of:'],
193
- ['oneOf', 'One of:'],
194
- ]) {
195
- if (value[key]?.length)
196
- nodes.push(paragraph(strong(text(label))), ...value[key].flatMap((child) => render(child, depth + 1, childAncestors)));
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
- if (value.not !== undefined)
199
- nodes.push(paragraph(strong(text('Not:'))), ...render(value.not, depth + 1, childAncestors));
200
- const array = value.type === 'array' || value.items !== undefined;
201
- if (!options.hideDetails) {
202
- // Child sections imply a single container type, but never its nullable alternatives.
203
- const impliedType = (value.type === 'object' && value.properties.length > 0) ||
204
- (value.type === 'array' && value.items !== undefined);
205
- const annotations = details(value, false, options.hideDescription, !impliedType);
206
- if (annotations.length)
207
- nodes.push(paragraph(...annotations));
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 child = view(schema);
212
- const label = [inlineCode(name)];
213
- if (value.required.has(name))
214
- label.push(text(' (required)'));
215
- const blocks = [paragraph(strong(...label)), paragraph(...details(child, true))];
216
- blocks.push(...render(schema, depth + 1, childAncestors, {
217
- hideDetails: true,
218
- property: true,
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 (array && value.items !== undefined) {
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
- const constraints = [];
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
- if (value.discriminator) {
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
- const mappings = Object.entries(value.discriminator.mapping ?? {});
241
- if (mappings.length)
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
  };