@scalar/openapi-to-markdown 1.5.4 → 1.6.1

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,29 @@
1
1
  # @scalar/openapi-to-markdown
2
2
 
3
+ ## 1.6.1
4
+
5
+ ## 1.6.0
6
+
7
+ ### Minor Changes
8
+
9
+ - [#10586](https://github.com/scalar/scalar/pull/10586): Add AsyncAPI Markdown conversion with reusable renderers and individual reference pages. Share Markdown description parsing between the OpenAPI and AsyncAPI converters.
10
+
11
+ Include shared model links, contents navigation, tag pages, generated examples, and an interactive playground.
12
+
13
+ Preserve discriminators and linked schema descriptions, generate examples from primitive aliases, and compact property annotations.
14
+
15
+ Include full-export tag documentation, preserve XML schema examples, and normalize annotated primitive aliases.
16
+
17
+ Infer enum and const type labels and honor primitive-link settings in generated model examples.
18
+
19
+ Preserve AsyncAPI application and schema metadata, explain security alternatives, and render reply and unknown-address semantics accurately.
20
+
21
+ ### Patch Changes
22
+
23
+ - [#10606](https://github.com/scalar/scalar/pull/10606): Block private-network document requests by default and require an explicit basePath for local files. Apply the same permissions to external references, with an opt-in for trusted private-network inputs.
24
+ - [#10598](https://github.com/scalar/scalar/pull/10598): Preserve OpenAPI 3.2 tag ancestry in Markdown exports with parent-first breadcrumb headings.
25
+ - [#10614](https://github.com/scalar/scalar/pull/10614): Clean up lint warnings while preserving runtime behavior, and document intentional schema and type syntax.
26
+
3
27
  ## 1.5.4
4
28
 
5
29
  ### Patch Changes
package/README.md CHANGED
@@ -68,6 +68,27 @@ const operationMarkdownByPointer = await createMarkdownFromOpenApi(content, {
68
68
  })
69
69
  ```
70
70
 
71
+ ### Document loading permissions
72
+
73
+ URL inputs and external references block private, loopback, and link-local addresses by default.
74
+ Protected requests also reject redirects. File access is disabled unless you provide an approved
75
+ `basePath`; paths and symlinks outside that directory are rejected.
76
+
77
+ ```ts
78
+ const markdown = await createMarkdownFromOpenApi('./descriptions/openapi.yaml', {
79
+ documentLoading: { basePath: './descriptions' },
80
+ })
81
+
82
+ const renderer = await createOpenApiMarkdownRenderer('./descriptions/openapi.yaml', {
83
+ basePath: './descriptions',
84
+ })
85
+ ```
86
+
87
+ For trusted local development inputs, set `allowPrivateNetworks: true` in the same loading options.
88
+ This also permits redirects. Keep these permissions under application control, rather than taking
89
+ them from uploaded API descriptions or request parameters. An approved directory applies to all
90
+ files loaded during that call, including references in remote documents.
91
+
71
92
  ### Render multiple pages
72
93
 
73
94
  Create a reusable renderer when exporting several pages from the same API description.
@@ -144,7 +165,12 @@ schema expansion still tracks ancestors and stops at a depth of ten. Output may
144
165
  list spacing and normalized Markdown escaping compared with earlier versions.
145
166
 
146
167
  For OpenAPI 3.2 descriptions, tag headings use `summary` when present and otherwise
147
- use `name`. Tag selectors and operation tag metadata continue to use `name`.
168
+ use `name`. Nested tags appear parent-first with breadcrumb headings such as
169
+ `### Accounts › Billing › Invoices`. Every tag uses the same heading level, including
170
+ deeply nested tags. Parent relationships use tag names, while breadcrumb labels use
171
+ summaries when available. Missing parents and circular relationships fall back to flat
172
+ headings. Tag selectors and operation tag metadata continue to use `name`; selecting
173
+ one tag still exports only that tag and its directly tagged operations.
148
174
 
149
175
  The package only generates Markdown. To produce HTML, pass the Markdown output to a
150
176
  Markdown renderer in your application.
@@ -1,4 +1,5 @@
1
1
  import type { OpenApiDocument } from '@scalar/workspace-store/schemas/v3.2/strict/openapi-document';
2
+ import { type DocumentLoadOptions } from './load-document.js';
2
3
  import { type OpenApiRenderOptions } from './select-document.js';
3
4
  type AnyDocument = OpenApiDocument | Record<string, unknown> | string;
4
5
  /** A resolved API description that can render multiple pages without loading it again. */
@@ -9,8 +10,10 @@ export type OpenApiMarkdownRenderer = {
9
10
  * Load and resolve an API description once, then render any number of selections.
10
11
  * Each renderer owns its document; create a new renderer to pick up source changes.
11
12
  */
12
- export declare const createOpenApiMarkdownRenderer: (input: AnyDocument) => Promise<OpenApiMarkdownRenderer>;
13
+ export declare const createOpenApiMarkdownRenderer: (input: AnyDocument, options?: DocumentLoadOptions) => Promise<OpenApiMarkdownRenderer>;
13
14
  /** Generate Markdown from an API description, optionally scoped to a single page. */
14
- export declare const createMarkdownFromOpenApi: (input: AnyDocument, options?: OpenApiRenderOptions) => Promise<string>;
15
+ export declare const createMarkdownFromOpenApi: (input: AnyDocument, options?: OpenApiRenderOptions & {
16
+ documentLoading?: DocumentLoadOptions;
17
+ }) => Promise<string>;
15
18
  export {};
16
19
  //# sourceMappingURL=create-markdown-from-openapi.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"create-markdown-from-openapi.d.ts","sourceRoot":"","sources":["../src/create-markdown-from-openapi.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,8DAA8D,CAAA;AAInG,OAAO,EAAE,KAAK,oBAAoB,EAAwC,MAAM,mBAAmB,CAAA;AAEnG,KAAK,WAAW,GAAG,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAA;AACrE,0FAA0F;AAC1F,MAAM,MAAM,uBAAuB,GAAG;IACpC,MAAM,EAAE,CAAC,OAAO,CAAC,EAAE,oBAAoB,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;CAC5D,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,6BAA6B,GAAU,OAAO,WAAW,KAAG,OAAO,CAAC,uBAAuB,CAQvG,CAAA;AAED,qFAAqF;AACrF,eAAO,MAAM,yBAAyB,GACpC,OAAO,WAAW,EAClB,UAAU,oBAAoB,KAC7B,OAAO,CAAC,MAAM,CAGhB,CAAA"}
1
+ {"version":3,"file":"create-markdown-from-openapi.d.ts","sourceRoot":"","sources":["../src/create-markdown-from-openapi.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,8DAA8D,CAAA;AAEnG,OAAO,EAAE,KAAK,mBAAmB,EAAgB,MAAM,iBAAiB,CAAA;AAExE,OAAO,EAAE,KAAK,oBAAoB,EAAwC,MAAM,mBAAmB,CAAA;AAEnG,KAAK,WAAW,GAAG,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAA;AACrE,0FAA0F;AAC1F,MAAM,MAAM,uBAAuB,GAAG;IACpC,MAAM,EAAE,CAAC,OAAO,CAAC,EAAE,oBAAoB,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;CAC5D,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,6BAA6B,GACxC,OAAO,WAAW,EAClB,UAAU,mBAAmB,KAC5B,OAAO,CAAC,uBAAuB,CAQjC,CAAA;AAED,qFAAqF;AACrF,eAAO,MAAM,yBAAyB,GACpC,OAAO,WAAW,EAClB,UAAU,oBAAoB,GAAG;IAAE,eAAe,CAAC,EAAE,mBAAmB,CAAA;CAAE,KACzE,OAAO,CAAC,MAAM,CAOhB,CAAA"}
@@ -5,8 +5,8 @@ import { createDocumentLookup, selectDocument } from './select-document.js';
5
5
  * Load and resolve an API description once, then render any number of selections.
6
6
  * Each renderer owns its document; create a new renderer to pick up source changes.
7
7
  */
8
- export const createOpenApiMarkdownRenderer = async (input) => {
9
- const content = await loadDocument(input);
8
+ export const createOpenApiMarkdownRenderer = async (input, options) => {
9
+ const content = await loadDocument(input, options);
10
10
  const renderDocument = createDocumentRenderer();
11
11
  const lookup = createDocumentLookup(content);
12
12
  const render = async (options) => await renderDocument(selectDocument(content, options, lookup), options);
@@ -14,6 +14,10 @@ export const createOpenApiMarkdownRenderer = async (input) => {
14
14
  };
15
15
  /** Generate Markdown from an API description, optionally scoped to a single page. */
16
16
  export const createMarkdownFromOpenApi = async (input, options) => {
17
- const renderer = await createOpenApiMarkdownRenderer(input);
17
+ const renderer = await createOpenApiMarkdownRenderer(input, options?.documentLoading);
18
+ if (options && 'documentLoading' in options) {
19
+ const { documentLoading: _, ...renderOptions } = options;
20
+ return renderer.render(renderOptions);
21
+ }
18
22
  return renderer.render(options);
19
23
  };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export type { HttpMethod } from '@scalar/helpers/http/http-methods';
2
2
  export type { OpenApiMarkdownRenderer } from './create-markdown-from-openapi.js';
3
3
  export { createMarkdownFromOpenApi, createOpenApiMarkdownRenderer, } from './create-markdown-from-openapi.js';
4
+ export type { DocumentLoadOptions } from './load-document.js';
4
5
  export type { OpenApiRenderOptions, OperationSelector, SchemaReferenceOptions } from './select-document.js';
5
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,UAAU,EAAE,MAAM,mCAAmC,CAAA;AAEnE,YAAY,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAA;AAC7E,OAAO,EACL,yBAAyB,EACzB,6BAA6B,GAC9B,MAAM,gCAAgC,CAAA;AACvC,YAAY,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,UAAU,EAAE,MAAM,mCAAmC,CAAA;AAEnE,YAAY,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAA;AAC7E,OAAO,EACL,yBAAyB,EACzB,6BAA6B,GAC9B,MAAM,gCAAgC,CAAA;AACvC,YAAY,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAA;AAC1D,YAAY,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAA"}
@@ -1,4 +1,11 @@
1
1
  import { type OpenApiDocument } from '@scalar/workspace-store/schemas/v3.2/strict/openapi-document';
2
+ /** Permissions for loading an API description and its external references. */
3
+ export type DocumentLoadOptions = {
4
+ /** Allow private-network requests only for trusted input. Defaults to false. */
5
+ allowPrivateNetworks?: boolean;
6
+ /** Approved directory for local files and references. File access is disabled when omitted. */
7
+ basePath?: string;
8
+ };
2
9
  /** Coerce one plain document and keep references linked to shared targets. */
3
- export declare const loadDocument: (input: OpenApiDocument | Record<string, unknown> | string) => Promise<OpenApiDocument>;
10
+ export declare const loadDocument: (input: OpenApiDocument | Record<string, unknown> | string, options?: DocumentLoadOptions) => Promise<OpenApiDocument>;
4
11
  //# sourceMappingURL=load-document.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"load-document.d.ts","sourceRoot":"","sources":["../src/load-document.ts"],"names":[],"mappings":"AAWA,OAAO,EAEL,KAAK,eAAe,EAErB,MAAM,8DAA8D,CAAA;AAiMrE,8EAA8E;AAC9E,eAAO,MAAM,YAAY,GACvB,OAAO,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,KACxD,OAAO,CAAC,eAAe,CAgGzB,CAAA"}
1
+ {"version":3,"file":"load-document.d.ts","sourceRoot":"","sources":["../src/load-document.ts"],"names":[],"mappings":"AAWA,OAAO,EAEL,KAAK,eAAe,EAErB,MAAM,8DAA8D,CAAA;AAiMrE,8EAA8E;AAC9E,MAAM,MAAM,mBAAmB,GAAG;IAChC,gFAAgF;IAChF,oBAAoB,CAAC,EAAE,OAAO,CAAA;IAC9B,+FAA+F;IAC/F,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB,CAAA;AAED,8EAA8E;AAC9E,eAAO,MAAM,YAAY,GACvB,OAAO,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,EACzD,UAAS,mBAAwB,KAChC,OAAO,CAAC,eAAe,CAoGzB,CAAA"}
@@ -153,9 +153,12 @@ const attachRefValues = (document, schemas = getSchemas(document), { detectOnly
153
153
  return hasExternalReferences;
154
154
  };
155
155
  /** Coerce one plain document and keep references linked to shared targets. */
156
- export const loadDocument = async (input) => {
156
+ export const loadDocument = async (input, options = {}) => {
157
157
  let raw;
158
158
  let origin;
159
+ const { fetchUrls, readFiles } = await import('@scalar/json-magic/bundle/plugins/node');
160
+ const urlLoader = fetchUrls({ blockPrivateNetworks: !options.allowPrivateNetworks });
161
+ const fileLoader = options.basePath === undefined ? undefined : readFiles({ basePath: options.basePath });
159
162
  // Reference links are attached below, so never mutate the caller's document.
160
163
  if (typeof input !== 'string') {
161
164
  raw = deepClone(getRaw(input));
@@ -167,17 +170,24 @@ export const loadDocument = async (input) => {
167
170
  raw = normalized;
168
171
  }
169
172
  else if (/^https?:\/\//i.test(input)) {
170
- const response = await fetch(input);
171
- if (!response.ok) {
172
- throw new Error(`Failed to load OpenAPI document (HTTP ${response.status})`);
173
+ const result = await urlLoader.exec(input);
174
+ if (!result.ok) {
175
+ throw new Error('Failed to load OpenAPI document');
173
176
  }
174
- raw = normalize(await response.text());
177
+ raw = result.data;
175
178
  origin = input;
176
179
  }
177
180
  else {
178
- const [{ resolve }, { readFile }] = await Promise.all([import('node:path'), import('node:fs/promises')]);
181
+ if (!fileLoader) {
182
+ throw new Error('Local file access requires an explicit basePath');
183
+ }
184
+ const { resolve } = await import('node:path');
179
185
  origin = resolve(input);
180
- raw = normalize(await readFile(origin, 'utf8'));
186
+ const result = await fileLoader.exec(origin);
187
+ if (!result.ok) {
188
+ throw new Error('Failed to load OpenAPI document');
189
+ }
190
+ raw = result.data;
181
191
  }
182
192
  }
183
193
  if (!isObject(raw)) {
@@ -193,12 +203,7 @@ export const loadDocument = async (input) => {
193
203
  // Resolve external targets only when the initial local-reference pass finds them.
194
204
  if (hasExternalReferences) {
195
205
  const errors = [];
196
- const plugins = typeof process === 'undefined'
197
- ? [(await import('@scalar/json-magic/bundle/plugins/browser')).fetchUrls()]
198
- : [
199
- (await import('@scalar/json-magic/bundle/plugins/node')).fetchUrls(),
200
- (await import('@scalar/json-magic/bundle/plugins/node')).readFiles(),
201
- ];
206
+ const plugins = fileLoader ? [urlLoader, fileLoader] : [urlLoader];
202
207
  document = await bundle(upgraded, {
203
208
  plugins,
204
209
  origin,
@@ -0,0 +1,5 @@
1
+ /** Format-neutral Markdown building blocks shared by API description renderers. */
2
+ export { anchor, createDocumentAnchors } from '../document-anchors.js';
3
+ export { describe, field, heading, inlineCode, item, link, list, paragraph, strong, text } from '../markdown-nodes.js';
4
+ export { createDescriptionParser, expandDescriptions } from '../parse-description.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/markdown/index.ts"],"names":[],"mappings":"AAAA,mFAAmF;AAEnF,OAAO,EAAE,MAAM,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAA;AACnE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,mBAAmB,CAAA;AACnH,OAAO,EAAE,uBAAuB,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAA"}
@@ -0,0 +1,4 @@
1
+ /** Format-neutral Markdown building blocks shared by API description renderers. */
2
+ export { anchor, createDocumentAnchors } from '../document-anchors.js';
3
+ export { describe, field, heading, inlineCode, item, link, list, paragraph, strong, text } from '../markdown-nodes.js';
4
+ export { createDescriptionParser, expandDescriptions } from '../parse-description.js';
@@ -1 +1 @@
1
- {"version":3,"file":"markdown-nodes.d.ts","sourceRoot":"","sources":["../src/markdown-nodes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EACR,OAAO,EACP,UAAU,EACV,IAAI,EACJ,IAAI,EACJ,QAAQ,EACR,IAAI,EACJ,SAAS,EACT,eAAe,EACf,MAAM,EACN,IAAI,EACL,MAAM,OAAO,CAAA;AAEd;;;GAGG;AACH,KAAK,sBAAsB,GAAG,IAAI,GAAG;IAAE,IAAI,EAAE,wBAAwB,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAA;AAEtF,OAAO,QAAQ,OAAO,CAAC;IACrB,UAAU,eAAe;QACvB,sBAAsB,EAAE,sBAAsB,CAAA;KAC/C;IACD,UAAU,cAAc;QACtB,sBAAsB,EAAE,sBAAsB,CAAA;KAC/C;CACF;AAED,gGAAgG;AAChG,eAAO,MAAM,IAAI,GAAI,OAAO,OAAO,KAAG,IAGpC,CAAA;AACF,uDAAuD;AACvD,eAAO,MAAM,UAAU,GAAI,OAAO,OAAO,KAAG,UAAkE,CAAA;AAC9G,iDAAiD;AACjD,eAAO,MAAM,SAAS,GAAI,GAAG,UAAU,eAAe,EAAE,KAAG,SAA8C,CAAA;AACzG,qCAAqC;AACrC,eAAO,MAAM,MAAM,GAAI,GAAG,UAAU,eAAe,EAAE,KAAG,MAAwC,CAAA;AAChG,oEAAoE;AACpE,eAAO,MAAM,QAAQ,GAAI,GAAG,UAAU,eAAe,EAAE,KAAG,QAA4C,CAAA;AACtG,mCAAmC;AACnC,eAAO,MAAM,OAAO,GAAI,OAAO,OAAO,CAAC,OAAO,CAAC,EAAE,GAAG,UAAU,eAAe,EAAE,KAAG,OAIhF,CAAA;AACF,4DAA4D;AAC5D,eAAO,MAAM,IAAI,GAAI,GAAG,UAAU,QAAQ,CAAC,UAAU,CAAC,KAAG,QAA2D,CAAA;AACpH,mCAAmC;AACnC,eAAO,MAAM,IAAI,GAAI,UAAU,QAAQ,EAAE,KAAG,IAAmE,CAAA;AAC/G;;;GAGG;AACH,eAAO,MAAM,OAAO,GAAI,KAAK,MAAM,KAAG,MAIrC,CAAA;AACD,8EAA8E;AAC9E,eAAO,MAAM,IAAI,GAAI,KAAK,MAAM,EAAE,OAAO,MAAM,KAAG,IAAsE,CAAA;AACxH,oFAAoF;AACpF,eAAO,MAAM,KAAK,GAAI,OAAO,MAAM,EAAE,OAAO,eAAe,KAAG,QACK,CAAA;AACnE,0FAA0F;AAC1F,eAAO,MAAM,QAAQ,GAAI,OAAO,OAAO,KAAG,sBAAsB,EAC8B,CAAA"}
1
+ {"version":3,"file":"markdown-nodes.d.ts","sourceRoot":"","sources":["../src/markdown-nodes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EACR,OAAO,EACP,UAAU,EACV,IAAI,EACJ,IAAI,EACJ,QAAQ,EACR,IAAI,EACJ,SAAS,EACT,eAAe,EACf,MAAM,EACN,IAAI,EACL,MAAM,OAAO,CAAA;AAEd;;;GAGG;AACH,KAAK,sBAAsB,GAAG,IAAI,GAAG;IAAE,IAAI,EAAE,wBAAwB,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAA;AAEtF,OAAO,QAAQ,OAAO,CAAC;IACrB,UAAU,eAAe;QACvB,sBAAsB,EAAE,sBAAsB,CAAA;KAC/C;IACD,UAAU,cAAc;QACtB,sBAAsB,EAAE,sBAAsB,CAAA;KAC/C;CACF;AAED,gGAAgG;AAChG,eAAO,MAAM,IAAI,GAAI,OAAO,OAAO,KAAG,IAGpC,CAAA;AACF,uDAAuD;AACvD,eAAO,MAAM,UAAU,GAAI,OAAO,OAAO,KAAG,UAAkE,CAAA;AAC9G,iDAAiD;AACjD,eAAO,MAAM,SAAS,GAAI,GAAG,UAAU,eAAe,EAAE,KAAG,SAA8C,CAAA;AACzG,qCAAqC;AACrC,eAAO,MAAM,MAAM,GAAI,GAAG,UAAU,eAAe,EAAE,KAAG,MAAwC,CAAA;AAChG,oEAAoE;AACpE,eAAO,MAAM,QAAQ,GAAI,GAAG,UAAU,eAAe,EAAE,KAAG,QAA4C,CAAA;AACtG,mCAAmC;AACnC,eAAO,MAAM,OAAO,GAAI,OAAO,OAAO,CAAC,OAAO,CAAC,EAAE,GAAG,UAAU,eAAe,EAAE,KAAG,OAIhF,CAAA;AACF,4DAA4D;AAC5D,eAAO,MAAM,IAAI,GAAI,GAAG,UAAU,QAAQ,CAAC,UAAU,CAAC,KAAG,QAA2D,CAAA;AACpH,mCAAmC;AACnC,eAAO,MAAM,IAAI,GAAI,UAAU,QAAQ,EAAE,KAAG,IAAmE,CAAA;AAC/G;;;GAGG;AACH,eAAO,MAAM,OAAO,GAAI,KAAK,MAAM,KAAG,MAKrC,CAAA;AACD,8EAA8E;AAC9E,eAAO,MAAM,IAAI,GAAI,KAAK,MAAM,EAAE,OAAO,MAAM,KAAG,IAAsE,CAAA;AACxH,oFAAoF;AACpF,eAAO,MAAM,KAAK,GAAI,OAAO,MAAM,EAAE,OAAO,eAAe,KAAG,QACK,CAAA;AACnE,0FAA0F;AAC1F,eAAO,MAAM,QAAQ,GAAI,OAAO,OAAO,KAAG,sBAAsB,EAC8B,CAAA"}
@@ -26,6 +26,7 @@ export const list = (children) => ({ type: 'list', ordered: false, spread: false
26
26
  * additional schemes and excludes IRC/XMPP, so it is not compatible here.
27
27
  */
28
28
  export const safeUrl = (url) => {
29
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Control characters are intentionally matched to validate or sanitize input.
29
30
  const normalized = url.replace(/[\u0000-\u0020\u007f-\u009f]/g, '');
30
31
  const protocol = /^([^/?#]*):/.exec(normalized)?.[1];
31
32
  return protocol && !['http', 'https', 'irc', 'ircs', 'mailto', 'xmpp'].includes(protocol.toLowerCase()) ? '' : url;
@@ -1 +1 @@
1
- {"version":3,"file":"render-document.d.ts","sourceRoot":"","sources":["../src/render-document.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EACV,eAAe,EAGhB,MAAM,8DAA8D,CAAA;AAcrE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAA;AAwD7D,2FAA2F;AAC3F,eAAO,MAAM,sBAAsB,QAAO,CAAC,CACzC,QAAQ,EAAE,eAAe,EACzB,OAAO,CAAC,EAAE,oBAAoB,KAC3B,OAAO,CAAC,MAAM,CAAC,CA0RnB,CAAA"}
1
+ {"version":3,"file":"render-document.d.ts","sourceRoot":"","sources":["../src/render-document.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EACV,eAAe,EAGhB,MAAM,8DAA8D,CAAA;AAerE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAA;AAwD7D,2FAA2F;AAC3F,eAAO,MAAM,sBAAsB,QAAO,CAAC,CACzC,QAAQ,EAAE,eAAe,EACzB,OAAO,CAAC,EAAE,oBAAoB,KAC3B,OAAO,CAAC,MAAM,CAAC,CAyRnB,CAAA"}
@@ -12,6 +12,7 @@ import { renderExamples } from './render-examples.js';
12
12
  import { formatOperationMethod, renderOperation } from './render-operation.js';
13
13
  import { createSchemaRenderer } from './render-schema.js';
14
14
  import { renderSecurity } from './render-security.js';
15
+ import { getTagSections } from './render-tags.js';
15
16
  const serializer = unified()
16
17
  .use(remarkGfm)
17
18
  .use(remarkStringify, {
@@ -185,8 +186,7 @@ export const createDocumentRenderer = () => {
185
186
  nodes.push(...(await renderSecurity(document.security, document.components?.securitySchemes, description, 2)));
186
187
  if (document.tags?.length) {
187
188
  nodes.push(heading(2, text('Tags')));
188
- for (const tag of document.tags) {
189
- const title = /^3\.2\./.test(openapiVersion) ? (tag.summary ?? tag.name) : tag.name;
189
+ for (const { tag, title } of getTagSections(document.tags, openapiVersion)) {
190
190
  nodes.push(heading(3, text(title)), ...(await description(tag.description)));
191
191
  if (tag.externalDocs) {
192
192
  nodes.push(paragraph(link(tag.externalDocs.url, tag.externalDocs.description ?? tag.externalDocs.url)));
@@ -0,0 +1,7 @@
1
+ import type { TagObject } from '@scalar/workspace-store/schemas/v3.2/strict/tag';
2
+ /** Keep tag ancestry visible without exceeding Markdown's six heading levels. */
3
+ export declare const getTagSections: (tags: TagObject[], openapiVersion: string) => {
4
+ tag: TagObject;
5
+ title: string;
6
+ }[];
7
+ //# sourceMappingURL=render-tags.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render-tags.d.ts","sourceRoot":"","sources":["../src/render-tags.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iDAAiD,CAAA;AAEhF,iFAAiF;AACjF,eAAO,MAAM,cAAc,GAAI,MAAM,SAAS,EAAE,EAAE,gBAAgB,MAAM,KAAG;IAAE,GAAG,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,EAwE3G,CAAA"}
@@ -0,0 +1,73 @@
1
+ /** Keep tag ancestry visible without exceeding Markdown's six heading levels. */
2
+ export const getTagSections = (tags, openapiVersion) => {
3
+ const nested = /^3\.2\./.test(openapiVersion);
4
+ const label = (tag) => (nested ? (tag.summary ?? tag.name) : tag.name);
5
+ const byName = new Map(tags.map((tag) => [tag.name, tag]));
6
+ // Duplicate names cannot identify a parent unambiguously. Preserve every declaration.
7
+ if (!nested || byName.size !== tags.length) {
8
+ return tags.map((tag) => ({ tag, title: label(tag) }));
9
+ }
10
+ const titles = new Map();
11
+ const parents = new Map();
12
+ const invalid = new Set();
13
+ for (const tag of tags) {
14
+ const chain = [];
15
+ const seen = new Set();
16
+ const pending = [tag];
17
+ while (pending.length) {
18
+ const current = pending.pop();
19
+ if (seen.has(current.name) || invalid.has(current.name)) {
20
+ // Invalid cycles and chains leading into them stay flat, like reference navigation.
21
+ for (const member of chain) {
22
+ titles.set(member.name, label(member));
23
+ invalid.add(member.name);
24
+ }
25
+ break;
26
+ }
27
+ if (titles.has(current.name)) {
28
+ break;
29
+ }
30
+ seen.add(current.name);
31
+ chain.push(current);
32
+ const parent = current.parent === undefined ? undefined : byName.get(current.parent);
33
+ if (parent) {
34
+ pending.push(parent);
35
+ }
36
+ }
37
+ for (const current of chain.reverse()) {
38
+ if (titles.has(current.name)) {
39
+ continue;
40
+ }
41
+ const parentTitle = current.parent === undefined ? undefined : titles.get(current.parent);
42
+ titles.set(current.name, parentTitle === undefined ? label(current) : `${parentTitle} › ${label(current)}`);
43
+ if (parentTitle !== undefined && current.parent !== undefined) {
44
+ parents.set(current.name, current.parent);
45
+ }
46
+ }
47
+ }
48
+ const children = new Map();
49
+ const roots = [];
50
+ for (const tag of tags) {
51
+ const parent = parents.get(tag.name);
52
+ if (parent === undefined) {
53
+ roots.push(tag);
54
+ }
55
+ else {
56
+ const siblings = children.get(parent) ?? [];
57
+ siblings.push(tag);
58
+ children.set(parent, siblings);
59
+ }
60
+ }
61
+ const sections = [];
62
+ const pending = roots.reverse();
63
+ while (pending.length) {
64
+ const tag = pending.pop();
65
+ sections.push({ tag, title: titles.get(tag.name) });
66
+ // Use a stack instead of recursion so deeply nested descriptions cannot exhaust the call stack.
67
+ const siblings = children.get(tag.name) ?? [];
68
+ for (const child of [...siblings].reverse()) {
69
+ pending.push(child);
70
+ }
71
+ }
72
+ return sections;
73
+ };
package/package.json CHANGED
@@ -16,7 +16,7 @@
16
16
  "llm",
17
17
  "swagger"
18
18
  ],
19
- "version": "1.5.4",
19
+ "version": "1.6.1",
20
20
  "engines": {
21
21
  "node": ">=22"
22
22
  },
@@ -33,6 +33,11 @@
33
33
  "import": "./dist/browser.js",
34
34
  "types": "./dist/browser.d.ts",
35
35
  "default": "./dist/browser.js"
36
+ },
37
+ "./markdown": {
38
+ "import": "./dist/markdown/index.js",
39
+ "types": "./dist/markdown/index.d.ts",
40
+ "default": "./dist/markdown/index.js"
36
41
  }
37
42
  },
38
43
  "files": [
@@ -40,11 +45,11 @@
40
45
  "CHANGELOG.md"
41
46
  ],
42
47
  "dependencies": {
43
- "@scalar/code-highlight": "0.4.9",
44
- "@scalar/helpers": "0.16.3",
45
- "@scalar/json-magic": "0.15.7",
46
- "@scalar/openapi-upgrader": "0.4.4",
47
- "@scalar/workspace-store": "0.68.3",
48
+ "@scalar/code-highlight": "0.4.10",
49
+ "@scalar/helpers": "0.16.4",
50
+ "@scalar/json-magic": "0.16.1",
51
+ "@scalar/openapi-upgrader": "0.4.5",
52
+ "@scalar/workspace-store": "0.68.5",
48
53
  "rehype-parse": "^9.0.1",
49
54
  "rehype-remark": "^10.0.1",
50
55
  "rehype-sanitize": "^6.0.0",
@@ -54,9 +59,9 @@
54
59
  "unified": "^11.0.5"
55
60
  },
56
61
  "devDependencies": {
57
- "@scalar/components": "0.31.1",
58
- "@scalar/galaxy": "0.7.1",
59
- "@scalar/themes": "0.19.1",
62
+ "@scalar/components": "0.32.1",
63
+ "@scalar/galaxy": "0.7.2",
64
+ "@scalar/themes": "0.19.2",
60
65
  "@tailwindcss/vite": "^4.3.3",
61
66
  "@types/mdast": "^4.0.4",
62
67
  "@vitejs/plugin-vue": "^6.0.8",