@scalar/openapi-to-markdown 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @scalar/openapi-to-markdown
2
2
 
3
+ ## 1.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#10186](https://github.com/scalar/scalar/pull/10186): Support document-defined additional operations in Markdown operation, webhook, and tag selections. Preserve custom method spelling and inherited parameters, servers, and security when copying an operation as Markdown.
8
+
9
+ ### Patch Changes
10
+
11
+ - [#10329](https://github.com/scalar/scalar/pull/10329): Render each shared schema once per page. The first reference to a structured schema expands it and labels it with its name (for example `schema: Account`). Later references print "Schema `Account` is shown above." instead of expanding it again. Model sections for schemas already expanded on the page refer back to them too. True cycles still print `[Circular Reference]`. This keeps Markdown output proportional to the schema graph, rather than to the number of paths through it. Before this change, densely linked descriptions such as Stripe's ran out of memory when rendering a single operation.
12
+
13
+ A node budget per operation and model section adds a visible `[Schema output truncated]` marker as a last resort. Generated examples that would exceed 10,000 values are omitted with a note. References past the depth limit now point to the model's own section instead of being cut off.
14
+
3
15
  ## 1.1.0
4
16
 
5
17
  ### Minor Changes
@@ -15,7 +15,14 @@ type MarkdownExample = {
15
15
  externalValue: string;
16
16
  } | {
17
17
  serializedValue: string;
18
+ } | {
19
+ omitted: true;
18
20
  });
21
+ /**
22
+ * Estimate how many values an example generated from this schema contains, stopping just past the limit.
23
+ * Counts are cached per schema and level, so this is linear in the size of the schema graph.
24
+ */
25
+ export declare const countGeneratedExampleValues: (root: unknown, limit?: number) => number;
19
26
  /** Preserve supplied values; generate a fallback only when examples are not supplied. */
20
27
  export declare const getMarkdownExamples: (source: ExampleSource, mediaType: string, mode?: "read" | "write", openapiVersion?: string) => MarkdownExample[];
21
28
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"get-markdown-examples.d.ts","sourceRoot":"","sources":["../src/get-markdown-examples.ts"],"names":[],"mappings":"AAKA,4FAA4F;AAC5F,MAAM,MAAM,aAAa,GAAG;IAC1B,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACnC,CAAA;AAED,gFAAgF;AAChF,KAAK,eAAe,GAAG;IACrB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,GAAG,CAAC;IAAE,KAAK,EAAE,OAAO,CAAA;CAAE,GAAG;IAAE,aAAa,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,eAAe,EAAE,MAAM,CAAA;CAAE,CAAC,CAAA;AAElF,yFAAyF;AACzF,eAAO,MAAM,mBAAmB,GAC9B,QAAQ,aAAa,EACrB,WAAW,MAAM,EACjB,OAAO,MAAM,GAAG,OAAO,EACvB,uBAAwB,KACvB,eAAe,EA2BjB,CAAA"}
1
+ {"version":3,"file":"get-markdown-examples.d.ts","sourceRoot":"","sources":["../src/get-markdown-examples.ts"],"names":[],"mappings":"AAKA,4FAA4F;AAC5F,MAAM,MAAM,aAAa,GAAG;IAC1B,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACnC,CAAA;AAED,gFAAgF;AAChF,KAAK,eAAe,GAAG;IACrB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,GAAG,CAAC;IAAE,KAAK,EAAE,OAAO,CAAA;CAAE,GAAG;IAAE,aAAa,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,eAAe,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,IAAI,CAAA;CAAE,CAAC,CAAA;AAYtG;;;GAGG;AACH,eAAO,MAAM,2BAA2B,GAAI,MAAM,OAAO,EAAE,cAAoC,KAAG,MA2CjG,CAAA;AAED,yFAAyF;AACzF,eAAO,MAAM,mBAAmB,GAC9B,QAAQ,aAAa,EACrB,WAAW,MAAM,EACjB,OAAO,MAAM,GAAG,OAAO,EACvB,uBAAwB,KACvB,eAAe,EA4BjB,CAAA"}
@@ -1,6 +1,72 @@
1
1
  import { isObject } from '@scalar/helpers/object/is-object';
2
2
  import { getResolvedRef, mergeSiblingReferences } from '@scalar/workspace-store/helpers/get-resolved-ref';
3
3
  import { getExampleFromSchema } from '@scalar/workspace-store/request-example';
4
+ /** Mirrors the depth at which `getExampleFromSchema` stops following nested schemas. */
5
+ const EXAMPLE_DEPTH = 10;
6
+ /**
7
+ * Generated examples repeat every shared schema at each place it is used, up to ten levels deep.
8
+ * A densely shared schema graph therefore produces billions of values, so larger examples are skipped.
9
+ * The largest generated examples in the Stripe, GitHub, and Cloudflare descriptions have about 7,600 values.
10
+ */
11
+ const MAX_GENERATED_EXAMPLE_VALUES = 10_000;
12
+ /**
13
+ * Estimate how many values an example generated from this schema contains, stopping just past the limit.
14
+ * Counts are cached per schema and level, so this is linear in the size of the schema graph.
15
+ */
16
+ export const countGeneratedExampleValues = (root, limit = MAX_GENERATED_EXAMPLE_VALUES) => {
17
+ const counts = new WeakMap();
18
+ const count = (input, level) => {
19
+ if (level > EXAMPLE_DEPTH || !isObject(input))
20
+ return 1;
21
+ const cached = counts.get(input)?.get(level);
22
+ if (cached !== undefined)
23
+ return cached;
24
+ const schema = getResolvedRef(input, mergeSiblingReferences);
25
+ if (!isObject(schema))
26
+ return 1;
27
+ // Supplied examples bypass schema expansion in the generator too.
28
+ if (schema.example !== undefined || (Array.isArray(schema.examples) && schema.examples.length > 0))
29
+ return 1;
30
+ let total = 1;
31
+ const add = (child) => {
32
+ if (total <= limit)
33
+ total += count(child, level + 1);
34
+ };
35
+ if (isObject(schema.properties))
36
+ Object.values(schema.properties).forEach(add);
37
+ if (isObject(schema.patternProperties))
38
+ Object.values(schema.patternProperties).forEach(add);
39
+ if (isObject(schema.additionalProperties))
40
+ add(schema.additionalProperties);
41
+ if (schema.items !== undefined)
42
+ add(schema.items);
43
+ if (Array.isArray(schema.prefixItems))
44
+ schema.prefixItems.forEach(add);
45
+ if (Array.isArray(schema.allOf))
46
+ schema.allOf.forEach(add);
47
+ const variants = Array.isArray(schema.oneOf) ? schema.oneOf : Array.isArray(schema.anyOf) ? schema.anyOf : [];
48
+ // Object and array generation can use the first variant, while other unions skip null.
49
+ // A discriminator can choose any variant, so bound the largest candidate in that case.
50
+ const nonNull = variants.find((variant) => {
51
+ const resolved = getResolvedRef(variant, mergeSiblingReferences);
52
+ return isObject(resolved) && resolved.type !== 'null';
53
+ });
54
+ const candidates = isObject(schema.discriminator) && schema.discriminator.defaultMapping !== undefined
55
+ ? variants
56
+ : [variants[0], nonNull];
57
+ let variantCount = 0;
58
+ for (const candidate of candidates) {
59
+ if (candidate !== undefined && total <= limit && variantCount <= limit)
60
+ variantCount = Math.max(variantCount, count(candidate, level + 1));
61
+ }
62
+ total += variantCount;
63
+ const levels = counts.get(input) ?? new Map();
64
+ levels.set(level, total);
65
+ counts.set(input, levels);
66
+ return total;
67
+ };
68
+ return count(root, 0);
69
+ };
4
70
  /** Preserve supplied values; generate a fallback only when examples are not supplied. */
5
71
  export const getMarkdownExamples = (source, mediaType, mode, openapiVersion = '3.2.0') => {
6
72
  if (source.example !== undefined)
@@ -29,6 +95,8 @@ export const getMarkdownExamples = (source, mediaType, mode, openapiVersion = '3
29
95
  const schema = getResolvedRef(source.schema);
30
96
  if (!isObject(schema))
31
97
  return [];
98
+ if (countGeneratedExampleValues(source.schema) > MAX_GENERATED_EXAMPLE_VALUES)
99
+ return [{ omitted: true }];
32
100
  const value = getExampleFromSchema(getResolvedRef(source.schema, mergeSiblingReferences), {
33
101
  xml: mediaType.includes('xml'),
34
102
  mode,
@@ -1 +1 @@
1
- {"version":3,"file":"render-document.d.ts","sourceRoot":"","sources":["../src/render-document.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAmB,MAAM,8DAA8D,CAAA;AAepH,2FAA2F;AAC3F,eAAO,MAAM,sBAAsB,QAAO,CAAC,CAAC,QAAQ,EAAE,eAAe,KAAK,OAAO,CAAC,MAAM,CAAC,CAyIxF,CAAA"}
1
+ {"version":3,"file":"render-document.d.ts","sourceRoot":"","sources":["../src/render-document.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAmB,MAAM,8DAA8D,CAAA;AAepH,2FAA2F;AAC3F,eAAO,MAAM,sBAAsB,QAAO,CAAC,CAAC,QAAQ,EAAE,eAAe,KAAK,OAAO,CAAC,MAAM,CAAC,CA6IxF,CAAA"}
@@ -13,9 +13,11 @@ const serializer = unified().use(remarkGfm).use(remarkStringify, { bullet: '-' }
13
13
  /** Build Markdown directly, retaining caches only for this immutable document snapshot. */
14
14
  export const createDocumentRenderer = () => {
15
15
  const descriptions = createDescriptionParser();
16
- const schemas = createSchemaRenderer();
16
+ const schemaRenderer = createSchemaRenderer();
17
17
  return async (document) => {
18
18
  const description = descriptions();
19
+ // Each page expands a shared schema once, then refers back to it.
20
+ const schemas = schemaRenderer.forDocument(document.components?.schemas);
19
21
  const { info } = document;
20
22
  const metadata = [
21
23
  field('OpenAPI Version', inlineCode(document.openapi)),
@@ -85,6 +87,7 @@ export const createDocumentRenderer = () => {
85
87
  nodes.push(heading(2, text(group.title)));
86
88
  hasOperations = true;
87
89
  }
90
+ schemas.beginSection();
88
91
  nodes.push(...(await renderOperation(document, path, method, pathItem, operation, group.webhook, {
89
92
  description,
90
93
  schemas,
@@ -97,12 +100,13 @@ export const createDocumentRenderer = () => {
97
100
  if (models.length)
98
101
  nodes.push(heading(2, text('Schemas')));
99
102
  for (const [name, schema] of models) {
103
+ schemas.beginSection();
100
104
  const view = schemas.view(schema);
101
105
  nodes.push(heading(3, text(view.title ?? name)), list([
102
106
  view.type
103
107
  ? field('Type', inlineCode(Array.isArray(view.type) ? view.type.join(' | ') : view.type))
104
108
  : item(paragraph(strong(text('Type:')))),
105
- ]), ...(await description(view.description)), ...schemas.render(schema, 0, [], { hideDescription: true }));
109
+ ]), ...(await description(view.description)), ...schemas.render(schema, 0, [], { hideDescription: true, name }));
106
110
  if (view.type === 'object')
107
111
  nodes.push(...(await renderExamples({ schema }, description, 'application/json', undefined, document['x-original-oas-version'] ?? document.openapi)));
108
112
  flush();
@@ -1 +1 @@
1
- {"version":3,"file":"render-examples.d.ts","sourceRoot":"","sources":["../src/render-examples.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,OAAO,CAAA;AAExC,OAAO,EAAE,KAAK,aAAa,EAAuB,MAAM,yBAAyB,CAAA;AAEjF,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AAE5D,+EAA+E;AAC/E,eAAO,MAAM,cAAc,GACzB,QAAQ,aAAa,EACrB,aAAa,iBAAiB,EAC9B,kBAA8B,EAC9B,OAAO,MAAM,GAAG,OAAO,EACvB,uBAAwB,KACvB,OAAO,CAAC,WAAW,EAAE,CAiCvB,CAAA"}
1
+ {"version":3,"file":"render-examples.d.ts","sourceRoot":"","sources":["../src/render-examples.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,OAAO,CAAA;AAExC,OAAO,EAAE,KAAK,aAAa,EAAuB,MAAM,yBAAyB,CAAA;AAEjF,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AAE5D,+EAA+E;AAC/E,eAAO,MAAM,cAAc,GACzB,QAAQ,aAAa,EACrB,aAAa,iBAAiB,EAC9B,kBAA8B,EAC9B,OAAO,MAAM,GAAG,OAAO,EACvB,uBAAwB,KACvB,OAAO,CAAC,WAAW,EAAE,CAqCvB,CAAA"}
@@ -6,6 +6,10 @@ export const renderExamples = async (source, description, mediaType = 'applicati
6
6
  const nodes = [];
7
7
  for (const example of getMarkdownExamples(source, mediaType, mode, openapiVersion)) {
8
8
  nodes.push(paragraph(strong(text(example.name ? `Example: ${example.name}` : 'Example:'))));
9
+ if ('omitted' in example) {
10
+ nodes.push(paragraph(text('[Generated example omitted because it is too large]')));
11
+ continue;
12
+ }
9
13
  if (example.summary)
10
14
  nodes.push(paragraph(text(example.summary)));
11
15
  nodes.push(...(await description(example.description)));
@@ -1 +1 @@
1
- {"version":3,"file":"render-operation.d.ts","sourceRoot":"","sources":["../src/render-operation.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,eAAe,EACf,eAAe,EAEf,cAAc,EAGf,MAAM,8DAA8D,CAAA;AACrE,OAAO,KAAK,EAAY,WAAW,EAAE,MAAM,OAAO,CAAA;AAGlD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AAG5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAGrD,oEAAoE;AACpE,KAAK,aAAa,GAAG;IACnB,WAAW,EAAE,iBAAiB,CAAA;IAC9B,OAAO,EAAE,cAAc,CAAA;CACxB,CAAA;AAED,iFAAiF;AACjF,eAAO,MAAM,eAAe,GAC1B,UAAU,eAAe,EACzB,MAAM,MAAM,EACZ,QAAQ,MAAM,EACd,UAAU,cAAc,EACxB,WAAW,eAAe,EAC1B,SAAS,OAAO,EAChB,0BAA0B,aAAa,KACtC,OAAO,CAAC,WAAW,EAAE,CA2GvB,CAAA"}
1
+ {"version":3,"file":"render-operation.d.ts","sourceRoot":"","sources":["../src/render-operation.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,eAAe,EACf,eAAe,EAEf,cAAc,EAGf,MAAM,8DAA8D,CAAA;AACrE,OAAO,KAAK,EAAY,WAAW,EAAE,MAAM,OAAO,CAAA;AAGlD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AAG5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAGrD,oEAAoE;AACpE,KAAK,aAAa,GAAG;IACnB,WAAW,EAAE,iBAAiB,CAAA;IAC9B,OAAO,EAAE,cAAc,CAAA;CACxB,CAAA;AAED,iFAAiF;AACjF,eAAO,MAAM,eAAe,GAC1B,UAAU,eAAe,EACzB,MAAM,MAAM,EACZ,QAAQ,MAAM,EACd,UAAU,cAAc,EACxB,WAAW,eAAe,EAC1B,SAAS,OAAO,EAChB,0BAA0B,aAAa,KACtC,OAAO,CAAC,WAAW,EAAE,CAyGvB,CAAA"}
@@ -1,3 +1,4 @@
1
+ import { isHttpMethod } from '@scalar/helpers/http/is-http-method';
1
2
  import { getResolvedRef, mergeSiblingReferences } from '@scalar/workspace-store/helpers/get-resolved-ref';
2
3
  import { field, heading, inlineCode, item, list, paragraph, strong, text } from './markdown-nodes.js';
3
4
  import { renderExamples } from './render-examples.js';
@@ -5,14 +6,12 @@ import { renderEncoding, renderHeaders, renderResponseLinks } from './render-ope
5
6
  import { renderSecurity } from './render-security.js';
6
7
  /** Render effective operation context without mutating the prepared document. */
7
8
  export const renderOperation = async (document, path, method, pathItem, operation, webhook, { description, schemas }) => {
9
+ const displayMethod = method === method.toLowerCase() && isHttpMethod(method) ? method.toUpperCase() : method;
8
10
  const openapiVersion = document['x-original-oas-version'] ?? document.openapi;
9
11
  const stability = operation['x-scalar-stability'];
10
- const title = (operation.summary || `${method.toUpperCase()} ${path}`) +
12
+ const title = (operation.summary || `${displayMethod} ${path}`) +
11
13
  (stability ? ` (${stability})` : operation.deprecated ? ' ⚠️ Deprecated' : '');
12
- const metadata = [
13
- field('Method', inlineCode(method.toUpperCase())),
14
- field(webhook ? 'Webhook' : 'Path', inlineCode(path)),
15
- ];
14
+ const metadata = [field('Method', inlineCode(displayMethod)), field(webhook ? 'Webhook' : 'Path', inlineCode(path))];
16
15
  if (operation.operationId)
17
16
  metadata.push(field('Operation ID', inlineCode(operation.operationId)));
18
17
  if (operation.tags)
@@ -38,18 +38,36 @@ type SchemaView = {
38
38
  propertyName: string;
39
39
  mapping?: Record<string, string>;
40
40
  };
41
+ /** Set for references to a structured schema that is rendered once per document and referred to afterwards. */
42
+ name?: string;
41
43
  };
42
44
  type RenderOptions = {
43
45
  hideDescription?: boolean;
44
46
  hideDetails?: boolean;
45
47
  property?: boolean;
48
+ /** Record this root under the given name, for example in its own model section, even if it is not a reference. */
49
+ name?: string;
46
50
  };
47
- /** Schema normalization is cached separately from ancestry-dependent expansion. */
51
+ /**
52
+ * Schema normalization is cached separately from ancestry-dependent expansion.
53
+ *
54
+ * A renderer also tracks which shared schemas one output document already shows, so each one is
55
+ * expanded once and referred to afterwards. Use `forDocument` for every document: renders can
56
+ * run concurrently, and each needs its own record.
57
+ */
48
58
  export type SchemaRenderer = {
49
59
  view: (schema: MarkdownSchema) => SchemaView;
50
60
  render: (schema: MarkdownSchema, depth?: number, ancestors?: readonly unknown[], options?: RenderOptions) => RootContent[];
61
+ /** Restore the node budget, for example for each operation or model section. */
62
+ beginSection: () => void;
63
+ /** Start a document that shares normalized views, given the models it renders in its own sections. */
64
+ forDocument: (models?: Record<string, MarkdownSchema>) => SchemaRenderer;
65
+ };
66
+ type SchemaRendererOptions = {
67
+ /** Upper bound on expanded schema nodes per section, so no description can make a page unbounded. */
68
+ maxNodes?: number;
51
69
  };
52
70
  /** Keep merged reference siblings and sorted properties stable throughout an export. */
53
- export declare const createSchemaRenderer: () => SchemaRenderer;
71
+ export declare const createSchemaRenderer: ({ maxNodes }?: SchemaRendererOptions) => SchemaRenderer;
54
72
  export {};
55
73
  //# sourceMappingURL=render-schema.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"render-schema.d.ts","sourceRoot":"","sources":["../src/render-schema.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,oDAAoD,CAAA;AAC5G,OAAO,KAAK,EAA6B,WAAW,EAAE,MAAM,OAAO,CAAA;AAInE,uFAAuF;AACvF,KAAK,cAAc,GAAG,oBAAoB,GAAG,OAAO,CAAA;AAEpD,KAAK,UAAU,GAAG;IAChB,MAAM,EAAE,YAAY,GAAG,OAAO,CAAA;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IACxB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,OAAO,EAAE,CAAA;IAChB,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,KAAK,CAAC,EAAE,cAAc,EAAE,CAAA;IACxB,KAAK,CAAC,EAAE,cAAc,EAAE,CAAA;IACxB,KAAK,CAAC,EAAE,cAAc,EAAE,CAAA;IACxB,GAAG,CAAC,EAAE,cAAc,CAAA;IACpB,UAAU,EAAE,CAAC,MAAM,EAAE,cAAc,CAAC,EAAE,CAAA;IACtC,QAAQ,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;IAC7B,KAAK,CAAC,EAAE,cAAc,CAAA;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,oBAAoB,CAAC,EAAE,cAAc,CAAA;IACrC,aAAa,CAAC,EAAE;QAAE,YAAY,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE,CAAA;CAC3E,CAAA;AAED,KAAK,aAAa,GAAG;IACnB,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB,CAAA;AAED,mFAAmF;AACnF,MAAM,MAAM,cAAc,GAAG;IAC3B,IAAI,EAAE,CAAC,MAAM,EAAE,cAAc,KAAK,UAAU,CAAA;IAC5C,MAAM,EAAE,CACN,MAAM,EAAE,cAAc,EACtB,KAAK,CAAC,EAAE,MAAM,EACd,SAAS,CAAC,EAAE,SAAS,OAAO,EAAE,EAC9B,OAAO,CAAC,EAAE,aAAa,KACpB,WAAW,EAAE,CAAA;CACnB,CAAA;AAiBD,wFAAwF;AACxF,eAAO,MAAM,oBAAoB,QAAO,cA0IvC,CAAA"}
1
+ {"version":3,"file":"render-schema.d.ts","sourceRoot":"","sources":["../src/render-schema.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,58 @@
1
+ import { unescapeJsonPointer } from '@scalar/helpers/json/unescape-json-pointer';
1
2
  import { isObject } from '@scalar/helpers/object/is-object';
2
3
  import { getResolvedRef, mergeSiblingReferences } from '@scalar/workspace-store/helpers/get-resolved-ref';
3
4
  import { inlineCode, item, list, paragraph, strong, text } from './markdown-nodes.js';
5
+ /** Guards against stack exhaustion. Past it, references to models point to their own section instead. */
6
+ const MAX_DEPTH = 64;
7
+ /**
8
+ * Deduplicated output is proportional to the schema graph, so only a pathological description
9
+ * (for example a YAML alias graph without references) reaches this.
10
+ */
11
+ const MAX_NODES = 100_000;
12
+ /** Keywords that `render` expands, as opposed to annotations that `details` prints on one line. */
13
+ const structuralKeywords = new Set([
14
+ 'allOf',
15
+ 'anyOf',
16
+ 'oneOf',
17
+ 'not',
18
+ 'properties',
19
+ 'required',
20
+ 'items',
21
+ 'additionalProperties',
22
+ 'discriminator',
23
+ 'minItems',
24
+ 'maxItems',
25
+ 'uniqueItems',
26
+ ]);
27
+ /** Link bookkeeping that is not a sibling keyword of a reference. */
28
+ const referenceKeys = new Set(['$ref', '$ref-value', '$global', '$status']);
29
+ const emphasis = (...children) => ({ type: 'emphasis', children });
30
+ /** Prefer the component name, which is how model sections and other references identify the schema. */
31
+ const getReferenceName = (ref) => {
32
+ const match = /^#\/components\/schemas\/([^/]+)$/.exec(ref);
33
+ if (!match)
34
+ return ref;
35
+ try {
36
+ return unescapeJsonPointer(match[1]);
37
+ }
38
+ catch {
39
+ return ref;
40
+ }
41
+ };
42
+ /** Only references that add nothing structural can be replaced by the schema they point to. */
43
+ const getSharedName = (input, view) => {
44
+ const ref = isObject(input) ? input.$ref : undefined;
45
+ if (typeof ref !== 'string' || typeof view.schema !== 'object')
46
+ return undefined;
47
+ if (Object.keys(input).some((key) => structuralKeywords.has(key)))
48
+ return undefined;
49
+ const structured = Boolean(view.allOf?.length || view.anyOf?.length || view.oneOf?.length || view.properties.length) ||
50
+ view.not !== undefined ||
51
+ view.items !== undefined ||
52
+ view.additionalProperties !== undefined;
53
+ // Leaf schemas are as short as a reference to them, so they stay in place.
54
+ return structured ? getReferenceName(ref) : undefined;
55
+ };
4
56
  /** Boolean targets still combine with adjacent schema keywords. */
5
57
  const resolveMarkdownSchema = (input) => {
6
58
  const target = getResolvedRef(input);
@@ -16,7 +68,7 @@ const resolveMarkdownSchema = (input) => {
16
68
  return { ...merged, allOf: [false, ...(Array.isArray(merged.allOf) ? merged.allOf : [])] };
17
69
  };
18
70
  /** Keep merged reference siblings and sorted properties stable throughout an export. */
19
- export const createSchemaRenderer = () => {
71
+ export const createSchemaRenderer = ({ maxNodes = MAX_NODES } = {}) => {
20
72
  const views = new WeakMap();
21
73
  const view = (input) => {
22
74
  const cached = typeof input === 'object' ? views.get(input) : undefined;
@@ -37,6 +89,7 @@ export const createSchemaRenderer = () => {
37
89
  required,
38
90
  properties,
39
91
  };
92
+ result.name = getSharedName(input, result);
40
93
  if (typeof input === 'object')
41
94
  views.set(input, result);
42
95
  return result;
@@ -50,6 +103,8 @@ export const createSchemaRenderer = () => {
50
103
  if (entry !== undefined)
51
104
  nodes.push(text(`${nodes.length ? ', ' : ''}${label}: `), inlineCode(entry));
52
105
  };
106
+ // Later references to a shared schema point back to this name.
107
+ add('schema', value.name);
53
108
  add('format', value.format);
54
109
  if (value.enum)
55
110
  add('possible values', value.enum.map((entry) => JSON.stringify(entry)).join(', '));
@@ -78,71 +133,124 @@ export const createSchemaRenderer = () => {
78
133
  nodes.push(text(`${nodes.length ? ' — ' : ''}${value.description}`));
79
134
  return nodes;
80
135
  };
81
- const render = (input, depth = 0, ancestors = [], options = {}) => {
82
- // Follow the original target: merging reference siblings creates fresh objects.
83
- const identity = getResolvedRef(input) ?? input;
84
- if (typeof identity === 'object' && ancestors.includes(identity)) {
85
- return [paragraph({ type: 'emphasis', children: [text('[Circular Reference]')] })];
86
- }
87
- if (depth >= 64)
88
- return [paragraph(text('[Maximum schema depth reached]'))];
89
- const value = view(input);
90
- if (typeof value.schema === 'boolean')
91
- return options.hideDetails ? [] : [paragraph(...details(value))];
92
- const childAncestors = [...ancestors, identity];
93
- const nodes = [];
94
- for (const [key, label] of [
95
- ['allOf', 'All of:'],
96
- ['anyOf', 'Any of:'],
97
- ['oneOf', 'One of:'],
98
- ]) {
99
- if (value[key]?.length)
100
- nodes.push(paragraph(strong(text(label))), ...value[key].flatMap((child) => render(child, depth + 1, childAncestors)));
101
- }
102
- if (value.not !== undefined)
103
- nodes.push(paragraph(strong(text('Not:'))), ...render(value.not, depth + 1, childAncestors));
104
- const array = value.type === 'array' || value.items !== undefined;
105
- if (!options.hideDetails) {
106
- // Child sections imply a single container type, but never its nullable alternatives.
107
- const impliedType = (value.type === 'object' && value.properties.length > 0) ||
108
- (value.type === 'array' && value.items !== undefined);
109
- const annotations = details(value, false, options.hideDescription, !impliedType);
110
- if (annotations.length)
111
- nodes.push(paragraph(...annotations));
112
- }
113
- if (value.properties.length) {
114
- const properties = value.properties.map(([name, schema]) => {
115
- const child = view(schema);
116
- const label = [inlineCode(name)];
117
- if (value.required.has(name))
118
- label.push(text(' (required)'));
119
- const blocks = [paragraph(strong(...label)), paragraph(...details(child, true))];
120
- blocks.push(...render(schema, depth + 1, childAncestors, { hideDetails: true, property: true }));
121
- return item(...blocks);
122
- });
123
- nodes.push(list(properties));
124
- }
125
- if (array && value.items !== undefined) {
126
- nodes.push(paragraph(strong(text(options.property ? 'Items:' : 'Array of:'))), ...render(value.items, depth + 1, childAncestors));
127
- }
128
- const constraints = [];
129
- if (value.minItems !== undefined)
130
- constraints.push(item(paragraph(text('Min items: '), inlineCode(value.minItems))));
131
- if (value.maxItems !== undefined)
132
- constraints.push(item(paragraph(text('Max items: '), inlineCode(value.maxItems))));
133
- if (value.uniqueItems !== undefined)
134
- constraints.push(item(paragraph(text('Unique items: '), inlineCode(value.uniqueItems))));
135
- if (constraints.length)
136
- nodes.push(list(constraints));
137
- if (value.additionalProperties !== undefined)
138
- nodes.push(paragraph(strong(text('Additional properties:'))), ...render(value.additionalProperties, depth + 1, childAncestors));
139
- if (value.discriminator) {
140
- nodes.push(paragraph(strong(text('Discriminator:')), text(' '), inlineCode(value.discriminator.propertyName)));
141
- const mappings = Object.entries(value.discriminator.mapping ?? {});
142
- if (mappings.length)
143
- nodes.push(list(mappings.map(([name, target]) => item(paragraph(inlineCode(name), text(': '), inlineCode(target))))));
144
- }
145
- return nodes;
136
+ const forDocument = (models = {}) => {
137
+ /** Shared schemas this document already expanded, with the name later references use. */
138
+ const shown = new Map();
139
+ /** Models that the document renders in their own sections after the operations. */
140
+ const sections = new Set(Object.values(models).map((model) => getResolvedRef(model) ?? model));
141
+ let nodeCount = 0;
142
+ /** Refer to a schema expanded elsewhere, keeping annotations the reference itself adds. */
143
+ const reference = (input, value, options, name, location) => {
144
+ 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));
150
+ }
151
+ nodes.push(paragraph(emphasis(text('Schema '), inlineCode(name), text(` is shown ${location}.`))));
152
+ return nodes;
153
+ };
154
+ const render = (input, depth = 0, ancestors = [], options = {}) => {
155
+ // Follow the original target: merging reference siblings creates fresh objects.
156
+ const identity = getResolvedRef(input) ?? input;
157
+ if (typeof identity === 'object' && ancestors.includes(identity)) {
158
+ return [paragraph(emphasis(text('[Circular Reference]')))];
159
+ }
160
+ 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;
167
+ const name = options.name ?? value.name;
168
+ if (shared && name !== undefined) {
169
+ // Expanding every path through a shared schema grows exponentially, so expand it once.
170
+ const previous = shown.get(shared);
171
+ if (previous !== undefined) {
172
+ // A model section already prints its own annotations above the schema.
173
+ const referenceOptions = options.name === undefined ? options : { ...options, hideDetails: true };
174
+ return reference(input, value, referenceOptions, previous, 'above');
175
+ }
176
+ if (value.name !== undefined && options.name === undefined && depth >= MAX_DEPTH && sections.has(shared))
177
+ return reference(input, value, options, value.name, 'below under Schemas');
178
+ }
179
+ if (depth >= MAX_DEPTH)
180
+ return [paragraph(text('[Maximum schema depth reached]'))];
181
+ if (nodeCount >= maxNodes)
182
+ return [paragraph(emphasis(text('[Schema output truncated]')))];
183
+ nodeCount++;
184
+ if (shared && name !== undefined && !shown.has(shared))
185
+ shown.set(shared, name);
186
+ if (typeof value.schema === 'boolean')
187
+ return options.hideDetails ? [] : [paragraph(...details(value))];
188
+ const childAncestors = [...ancestors, identity];
189
+ 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)));
197
+ }
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));
208
+ }
209
+ if (value.properties.length) {
210
+ 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
+ }));
220
+ return item(...blocks);
221
+ });
222
+ nodes.push(list(properties));
223
+ }
224
+ if (array && value.items !== undefined) {
225
+ nodes.push(paragraph(strong(text(options.property ? 'Items:' : 'Array of:'))), ...render(value.items, depth + 1, childAncestors));
226
+ }
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)
237
+ nodes.push(paragraph(strong(text('Additional properties:'))), ...render(value.additionalProperties, depth + 1, childAncestors));
238
+ if (value.discriminator) {
239
+ 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))))));
243
+ }
244
+ return nodes;
245
+ };
246
+ return {
247
+ view,
248
+ render,
249
+ beginSection: () => {
250
+ nodeCount = 0;
251
+ },
252
+ forDocument,
253
+ };
146
254
  };
147
- return { view, render };
255
+ return forDocument();
148
256
  };
@@ -1,9 +1,9 @@
1
- import { type HttpMethod } from '@scalar/helpers/http/http-methods';
1
+ import type { OperationMethod } from '@scalar/workspace-store/schemas/navigation';
2
2
  import type { OpenApiDocument } from '@scalar/workspace-store/schemas/v3.2/strict/openapi-document';
3
3
  /** Identify one operation by path and method, operation ID, or JSON pointer. */
4
4
  export type OperationSelector = {
5
5
  path: string;
6
- method: HttpMethod | Uppercase<HttpMethod>;
6
+ method: OperationMethod;
7
7
  } | {
8
8
  operationId: string;
9
9
  } | {
@@ -19,7 +19,7 @@ type PageSelectors = {
19
19
  model: string;
20
20
  webhook: {
21
21
  name: string;
22
- method: HttpMethod | Uppercase<HttpMethod>;
22
+ method: OperationMethod;
23
23
  };
24
24
  introduction: true;
25
25
  };
@@ -1 +1 @@
1
- {"version":3,"file":"select-document.d.ts","sourceRoot":"","sources":["../src/select-document.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,UAAU,EAAE,MAAM,mCAAmC,CAAA;AAIjF,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,8DAA8D,CAAA;AAEnH,gFAAgF;AAChF,MAAM,MAAM,iBAAiB,GACzB;IACE,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,UAAU,GAAG,SAAS,CAAC,UAAU,CAAC,CAAA;CAC3C,GACD;IACE,WAAW,EAAE,MAAM,CAAA;CACpB,GACD;IACE,OAAO,EAAE,MAAM,CAAA;CAChB,CAAA;AACL,2EAA2E;AAC3E,MAAM,MAAM,oBAAoB,GAC5B;KACG,GAAG,IAAI,MAAM,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,aAAa,EAAE,GAAG,CAAC,EAAE,KAAK,CAAC,CAAC,GAAG,IAAI,CAAC,aAAa,EAAE,GAAG,CAAC;CACnH,CAAC,MAAM,aAAa,CAAC,GACtB,OAAO,CAAC,MAAM,CAAC,MAAM,aAAa,EAAE,KAAK,CAAC,CAAC,CAAA;AAE/C,KAAK,aAAa,GAAG;IACnB,SAAS,EAAE,iBAAiB,CAAA;IAC5B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAAC,UAAU,CAAC,CAAA;KAAE,CAAA;IACrE,YAAY,EAAE,IAAI,CAAA;CACnB,CAAA;AAkKD,sEAAsE;AACtE,eAAO,MAAM,cAAc,GAAI,UAAU,eAAe,EAAE,UAAS,oBAAyB,KAAG,eA+M9F,CAAA"}
1
+ {"version":3,"file":"select-document.d.ts","sourceRoot":"","sources":["../src/select-document.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4CAA4C,CAAA;AACjF,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,8DAA8D,CAAA;AAEnH,gFAAgF;AAChF,MAAM,MAAM,iBAAiB,GACzB;IACE,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,eAAe,CAAA;CACxB,GACD;IACE,WAAW,EAAE,MAAM,CAAA;CACpB,GACD;IACE,OAAO,EAAE,MAAM,CAAA;CAChB,CAAA;AACL,2EAA2E;AAC3E,MAAM,MAAM,oBAAoB,GAC5B;KACG,GAAG,IAAI,MAAM,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,aAAa,EAAE,GAAG,CAAC,EAAE,KAAK,CAAC,CAAC,GAAG,IAAI,CAAC,aAAa,EAAE,GAAG,CAAC;CACnH,CAAC,MAAM,aAAa,CAAC,GACtB,OAAO,CAAC,MAAM,CAAC,MAAM,aAAa,EAAE,KAAK,CAAC,CAAC,CAAA;AAE/C,KAAK,aAAa,GAAG;IACnB,SAAS,EAAE,iBAAiB,CAAA;IAC5B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,eAAe,CAAA;KAAE,CAAA;IAClD,YAAY,EAAE,IAAI,CAAA;CACnB,CAAA;AAyKD,sEAAsE;AACtE,eAAO,MAAM,cAAc,GAAI,UAAU,eAAe,EAAE,UAAS,oBAAyB,KAAG,eAuN9F,CAAA"}
@@ -1,6 +1,6 @@
1
1
  import { HTTP_METHODS } from '@scalar/helpers/http/http-methods';
2
2
  import { isObject } from '@scalar/helpers/object/is-object';
3
- import { getPathItemOperation, getResolvedPathItem } from '@scalar/workspace-store/helpers/for-each-path-item-operation';
3
+ import { forEachPathItemOperation, getPathItemOperation, getResolvedPathItem, setPathItemOperation, } from '@scalar/workspace-store/helpers/for-each-path-item-operation';
4
4
  import { getResolvedRef } from '@scalar/workspace-store/helpers/get-resolved-ref';
5
5
  const HTTP_METHOD_SET = new Set(HTTP_METHODS);
6
6
  const normalizeHttpMethod = (method) => {
@@ -28,17 +28,18 @@ const parseJsonPointer = (pointer) => normalizeJsonPointer(pointer)
28
28
  .map((segment) => segment.replaceAll('~1', '/').replaceAll('~0', '~'));
29
29
  const getOperationSelectorFromPointer = (pointer) => {
30
30
  const segments = parseJsonPointer(pointer);
31
- if (segments.length !== 3 || segments[0] !== 'paths') {
31
+ if (segments[0] !== 'paths' ||
32
+ !(segments.length === 3 || (segments.length === 4 && segments[2] === 'additionalOperations'))) {
32
33
  throw new Error(`JSON pointer "${pointer}" must target an operation object under "/paths/{path}/{method}"`);
33
34
  }
34
35
  const path = segments[1];
35
- const method = segments[2];
36
- if (!path || !method) {
36
+ const method = segments.length === 4 ? segments[3] : segments[2];
37
+ if (!path || !method || (segments.length === 3 ? !HTTP_METHOD_SET.has(method) : HTTP_METHOD_SET.has(method))) {
37
38
  throw new Error(`JSON pointer "${pointer}" must target an operation object under "/paths/{path}/{method}"`);
38
39
  }
39
40
  return {
40
41
  path,
41
- method: method,
42
+ method,
42
43
  };
43
44
  };
44
45
  const getPathEntries = (document) => {
@@ -51,12 +52,20 @@ const getPathEntries = (document) => {
51
52
  return pathItem ? [[path, pathItem]] : [];
52
53
  });
53
54
  };
54
- const filterPathItemToSingleOperation = (pathItem, selectedMethod) => Object.fromEntries(Object.entries(pathItem).filter(([key]) => {
55
- const method = normalizeHttpMethod(key);
56
- return !method || method === selectedMethod;
57
- }));
55
+ /** Keep path metadata while excluding every unselected fixed or additional operation. */
56
+ const filterPathItemOperations = (pathItem, methods) => {
57
+ const selected = Object.fromEntries(Object.entries(pathItem).filter(([key]) => !HTTP_METHOD_SET.has(key) && key !== 'additionalOperations'));
58
+ forEachPathItemOperation(pathItem, (method, operation) => {
59
+ if (methods.includes(method)) {
60
+ setPathItemOperation(selected, method, operation);
61
+ }
62
+ });
63
+ return selected;
64
+ };
65
+ /** Exact authored methods take precedence over the legacy uppercase fixed-method aliases. */
66
+ const resolveMethod = (pathItem, method) => getPathItemOperation(pathItem, method) ? method : normalizeHttpMethod(method);
58
67
  const findOperationByPathAndMethod = (document, selector) => {
59
- const method = normalizeHttpMethod(selector.method);
68
+ const method = resolveMethod(getResolvedPathItem(document.paths?.[selector.path]), selector.method);
60
69
  if (!method) {
61
70
  throw new Error(`Invalid HTTP method "${selector.method}". Supported methods: ${HTTP_METHODS.join(', ')}`);
62
71
  }
@@ -69,20 +78,22 @@ const findOperationByPathAndMethod = (document, selector) => {
69
78
  method,
70
79
  };
71
80
  };
72
- const findOperationsByOperationId = (document, operationId) => getPathEntries(document).flatMap(([path, pathItem]) => Object.entries(pathItem).flatMap(([methodKey, operation]) => {
73
- const method = normalizeHttpMethod(methodKey);
74
- if (!method || !isObject(operation)) {
75
- return [];
76
- }
77
- const candidateOperationId = 'operationId' in operation && typeof operation.operationId === 'string' ? operation.operationId : undefined;
78
- if (candidateOperationId !== operationId) {
79
- return [];
80
- }
81
- return [{ path, method }];
82
- }));
81
+ const findOperationsByOperationId = (document, operationId) => getPathEntries(document).flatMap(([path, pathItem]) => {
82
+ const matches = [];
83
+ forEachPathItemOperation(pathItem, (method, operation) => {
84
+ if (getResolvedRef(operation)?.operationId === operationId) {
85
+ matches.push({ path, method });
86
+ }
87
+ });
88
+ return matches;
89
+ });
83
90
  const resolveOperationMatch = (document, selector) => {
84
91
  if ('pointer' in selector) {
85
- return findOperationByPathAndMethod(document, getOperationSelectorFromPointer(selector.pointer));
92
+ const match = getOperationSelectorFromPointer(selector.pointer);
93
+ if (!getPathItemOperation(document.paths?.[match.path], match.method)) {
94
+ throw new Error(`Operation not found at JSON pointer "${selector.pointer}"`);
95
+ }
96
+ return match;
86
97
  }
87
98
  if ('operationId' in selector) {
88
99
  const matches = findOperationsByOperationId(document, selector.operationId);
@@ -106,7 +117,7 @@ const filterDocumentByOperation = (document, selector) => {
106
117
  return {
107
118
  ...document,
108
119
  paths: {
109
- [match.path]: filterPathItemToSingleOperation(pathItem, match.method),
120
+ [match.path]: filterPathItemOperations(pathItem, [match.method]),
110
121
  },
111
122
  };
112
123
  };
@@ -157,9 +168,14 @@ export const selectDocument = (document, options = {}) => {
157
168
  }
158
169
  selected.tags = metadata.length ? metadata : [{ name: options.tag }];
159
170
  for (const [path, item] of getPathEntries(document)) {
160
- const methods = HTTP_METHODS.filter((method) => getPathItemOperation(item, method)?.tags?.includes(options.tag));
171
+ const methods = [];
172
+ forEachPathItemOperation(item, (method, operation) => {
173
+ if (getResolvedRef(operation)?.tags?.includes(options.tag)) {
174
+ methods.push(method);
175
+ }
176
+ });
161
177
  if (methods.length) {
162
- selected.paths[path] = Object.fromEntries(Object.entries(item).filter(([key]) => !HTTP_METHOD_SET.has(key) || methods.includes(key)));
178
+ selected.paths[path] = filterPathItemOperations(item, methods);
163
179
  }
164
180
  }
165
181
  if (!metadata.length && !Object.keys(selected.paths ?? {}).length) {
@@ -182,26 +198,31 @@ export const selectDocument = (document, options = {}) => {
182
198
  Object.keys(selector).length !== 2) {
183
199
  throw new Error('Invalid webhook selector. Use { name, method }');
184
200
  }
185
- const method = normalizeHttpMethod(selector.method);
201
+ const item = getResolvedPathItem(document.webhooks?.[selector.name]);
202
+ const method = resolveMethod(item, selector.method);
186
203
  if (!method) {
187
204
  throw new Error(`Invalid HTTP method "${selector.method}"`);
188
205
  }
189
- const item = getResolvedPathItem(document.webhooks?.[selector.name]);
190
206
  if (!item || !getPathItemOperation(item, method)) {
191
207
  throw new Error(`Webhook "${selector.name}" with method "${method.toUpperCase()}" was not found`);
192
208
  }
193
- selected.webhooks = { [selector.name]: filterPathItemToSingleOperation(item, method) };
209
+ selected.webhooks = { [selector.name]: filterPathItemOperations(item, [method]) };
194
210
  }
195
211
  const securityNames = new Set();
196
212
  const tagNames = new Set();
197
213
  for (const items of [selected.paths, selected.webhooks]) {
198
214
  for (const [path, itemRef] of Object.entries(items ?? {})) {
199
215
  const item = getResolvedPathItem(itemRef);
200
- const scoped = { ...item, parameters: undefined, servers: undefined };
201
- for (const method of HTTP_METHODS) {
202
- const operation = getPathItemOperation(item, method);
216
+ const scoped = {
217
+ ...item,
218
+ additionalOperations: item.additionalOperations ? { ...item.additionalOperations } : undefined,
219
+ parameters: undefined,
220
+ servers: undefined,
221
+ };
222
+ forEachPathItemOperation(item, (method, operationRef) => {
223
+ const operation = getResolvedRef(operationRef);
203
224
  if (!operation) {
204
- continue;
225
+ return;
205
226
  }
206
227
  const parameters = new Map();
207
228
  for (const ref of [...(item.parameters ?? []), ...(operation.parameters ?? [])]) {
@@ -219,14 +240,14 @@ export const selectDocument = (document, options = {}) => {
219
240
  for (const name of operation.tags ?? []) {
220
241
  tagNames.add(name);
221
242
  }
222
- scoped[method] = {
243
+ setPathItemOperation(scoped, method, {
223
244
  ...operation,
224
245
  parameters: [...parameters.values()],
225
246
  servers: operation.servers ?? item.servers ?? document.servers,
226
247
  security,
227
248
  tags: options.tag !== undefined ? [options.tag] : operation.tags,
228
- };
229
- }
249
+ });
250
+ });
230
251
  items[path] = scoped;
231
252
  }
232
253
  }
package/package.json CHANGED
@@ -16,7 +16,7 @@
16
16
  "llm",
17
17
  "swagger"
18
18
  ],
19
- "version": "1.1.0",
19
+ "version": "1.2.0",
20
20
  "engines": {
21
21
  "node": ">=22"
22
22
  },
@@ -41,10 +41,10 @@
41
41
  ],
42
42
  "dependencies": {
43
43
  "@scalar/code-highlight": "0.4.7",
44
- "@scalar/helpers": "0.13.0",
45
- "@scalar/json-magic": "0.15.0",
46
- "@scalar/openapi-upgrader": "0.3.0",
47
- "@scalar/workspace-store": "0.65.0",
44
+ "@scalar/helpers": "0.14.0",
45
+ "@scalar/json-magic": "0.15.1",
46
+ "@scalar/openapi-upgrader": "0.3.1",
47
+ "@scalar/workspace-store": "0.66.0",
48
48
  "rehype-parse": "^9.0.1",
49
49
  "rehype-remark": "^10.0.1",
50
50
  "rehype-sanitize": "^6.0.0",