@scalar/json-magic 0.8.2 → 0.8.3

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 (84) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/bundle/index.d.ts +1 -0
  3. package/dist/bundle/index.d.ts.map +1 -1
  4. package/dist/bundle/index.js.map +1 -1
  5. package/dist/bundle/plugins/browser.js.map +1 -1
  6. package/dist/bundle/plugins/node.d.ts +1 -1
  7. package/dist/bundle/plugins/node.js +1 -1
  8. package/dist/bundle/plugins/node.js.map +1 -1
  9. package/dist/dereference/index.d.ts.map +1 -1
  10. package/dist/dereference/index.js.map +2 -2
  11. package/dist/diff/index.d.ts +1 -1
  12. package/dist/diff/index.d.ts.map +1 -1
  13. package/dist/diff/index.js +1 -1
  14. package/dist/diff/index.js.map +2 -2
  15. package/dist/helpers/escape-json-pointer.d.ts +1 -1
  16. package/dist/helpers/escape-json-pointer.js.map +1 -1
  17. package/dist/magic-proxy/index.d.ts.map +1 -1
  18. package/dist/magic-proxy/index.js.map +2 -2
  19. package/dist/magic-proxy/proxy.d.ts +0 -1
  20. package/dist/magic-proxy/proxy.d.ts.map +1 -1
  21. package/dist/magic-proxy/proxy.js +1 -2
  22. package/dist/magic-proxy/proxy.js.map +2 -2
  23. package/package.json +12 -13
  24. package/.turbo/turbo-build.log +0 -10
  25. package/esbuild.ts +0 -15
  26. package/src/bundle/bundle.test.ts +0 -2917
  27. package/src/bundle/bundle.ts +0 -916
  28. package/src/bundle/create-limiter.test.ts +0 -28
  29. package/src/bundle/create-limiter.ts +0 -52
  30. package/src/bundle/index.ts +0 -3
  31. package/src/bundle/plugins/browser.ts +0 -4
  32. package/src/bundle/plugins/fetch-urls/index.test.ts +0 -141
  33. package/src/bundle/plugins/fetch-urls/index.ts +0 -105
  34. package/src/bundle/plugins/node.ts +0 -5
  35. package/src/bundle/plugins/parse-json/index.test.ts +0 -24
  36. package/src/bundle/plugins/parse-json/index.ts +0 -32
  37. package/src/bundle/plugins/parse-yaml/index.test.ts +0 -26
  38. package/src/bundle/plugins/parse-yaml/index.ts +0 -34
  39. package/src/bundle/plugins/read-files/index.test.ts +0 -36
  40. package/src/bundle/plugins/read-files/index.ts +0 -58
  41. package/src/bundle/value-generator.test.ts +0 -165
  42. package/src/bundle/value-generator.ts +0 -143
  43. package/src/dereference/dereference.test.ts +0 -142
  44. package/src/dereference/dereference.ts +0 -84
  45. package/src/dereference/index.ts +0 -2
  46. package/src/diff/apply.test.ts +0 -262
  47. package/src/diff/apply.ts +0 -83
  48. package/src/diff/diff.test.ts +0 -328
  49. package/src/diff/diff.ts +0 -93
  50. package/src/diff/index.test.ts +0 -150
  51. package/src/diff/index.ts +0 -5
  52. package/src/diff/merge.test.ts +0 -1109
  53. package/src/diff/merge.ts +0 -136
  54. package/src/diff/trie.test.ts +0 -30
  55. package/src/diff/trie.ts +0 -113
  56. package/src/diff/utils.test.ts +0 -169
  57. package/src/diff/utils.ts +0 -111
  58. package/src/helpers/convert-to-local-ref.test.ts +0 -211
  59. package/src/helpers/convert-to-local-ref.ts +0 -43
  60. package/src/helpers/escape-json-pointer.test.ts +0 -13
  61. package/src/helpers/escape-json-pointer.ts +0 -8
  62. package/src/helpers/get-schemas.test.ts +0 -356
  63. package/src/helpers/get-schemas.ts +0 -80
  64. package/src/helpers/get-segments-from-path.test.ts +0 -17
  65. package/src/helpers/get-segments-from-path.ts +0 -17
  66. package/src/helpers/get-value-by-path.test.ts +0 -338
  67. package/src/helpers/get-value-by-path.ts +0 -44
  68. package/src/helpers/is-json-object.ts +0 -31
  69. package/src/helpers/is-object.test.ts +0 -27
  70. package/src/helpers/is-object.ts +0 -4
  71. package/src/helpers/is-yaml.ts +0 -18
  72. package/src/helpers/json-path-utils.test.ts +0 -57
  73. package/src/helpers/json-path-utils.ts +0 -50
  74. package/src/helpers/normalize.test.ts +0 -92
  75. package/src/helpers/normalize.ts +0 -35
  76. package/src/helpers/unescape-json-pointer.test.ts +0 -23
  77. package/src/helpers/unescape-json-pointer.ts +0 -9
  78. package/src/magic-proxy/index.ts +0 -2
  79. package/src/magic-proxy/proxy.test.ts +0 -1987
  80. package/src/magic-proxy/proxy.ts +0 -323
  81. package/src/types.ts +0 -1
  82. package/tsconfig.build.json +0 -12
  83. package/tsconfig.json +0 -16
  84. package/vite.config.ts +0 -8
@@ -1,916 +0,0 @@
1
- import { path } from '@scalar/helpers/node/path'
2
-
3
- import { convertToLocalRef } from '@/helpers/convert-to-local-ref'
4
- import { getId, getSchemas } from '@/helpers/get-schemas'
5
- import { getValueByPath } from '@/helpers/get-value-by-path'
6
- import type { UnknownObject } from '@/types'
7
-
8
- import { escapeJsonPointer } from '../helpers/escape-json-pointer'
9
- import { getSegmentsFromPath } from '../helpers/get-segments-from-path'
10
- import { isJsonObject } from '../helpers/is-json-object'
11
- import { isObject } from '../helpers/is-object'
12
- import { isYaml } from '../helpers/is-yaml'
13
- import { getHash, uniqueValueGeneratorFactory } from './value-generator'
14
-
15
- /**
16
- * Checks if a string is a remote URL (starts with http:// or https://)
17
- * @param value - The URL string to check
18
- * @returns true if the string is a remote URL, false otherwise
19
- * @example
20
- * ```ts
21
- * isRemoteUrl('https://example.com/schema.json') // true
22
- * isRemoteUrl('http://api.example.com/schemas/user.json') // true
23
- * isRemoteUrl('#/components/schemas/User') // false
24
- * isRemoteUrl('./local-schema.json') // false
25
- * ```
26
- */
27
- export function isRemoteUrl(value: string) {
28
- try {
29
- const url = new URL(value)
30
- return url.protocol === 'http:' || url.protocol === 'https:'
31
- } catch {
32
- return false
33
- }
34
- }
35
-
36
- /**
37
- * Checks if a string represents a file path by ensuring it's not a remote URL,
38
- * YAML content, or JSON content.
39
- *
40
- * @param value - The string to check
41
- * @returns true if the string appears to be a file path, false otherwise
42
- * @example
43
- * ```ts
44
- * isFilePath('./schemas/user.json') // true
45
- * isFilePath('https://example.com/schema.json') // false
46
- * isFilePath('{"type": "object"}') // false
47
- * isFilePath('type: object') // false
48
- * ```
49
- */
50
- export function isFilePath(value: string) {
51
- return !isRemoteUrl(value) && !isYaml(value) && !isJsonObject(value)
52
- }
53
-
54
- /**
55
- * Checks if a string is a local reference (starts with #)
56
- * @param value - The reference string to check
57
- * @returns true if the string is a local reference, false otherwise
58
- * @example
59
- * ```ts
60
- * isLocalRef('#/components/schemas/User') // true
61
- * isLocalRef('https://example.com/schema.json') // false
62
- * isLocalRef('./local-schema.json') // false
63
- * ```
64
- */
65
- export function isLocalRef(value: string): boolean {
66
- return value.startsWith('#')
67
- }
68
-
69
- export type ResolveResult = { ok: true; data: unknown; raw: string } | { ok: false }
70
-
71
- /**
72
- * Resolves a string by finding and executing the appropriate plugin.
73
- * @param value - The string to resolve (URL, file path, etc)
74
- * @param plugins - Array of plugins that can handle different types of strings
75
- * @returns A promise that resolves to either the content or an error result
76
- * @example
77
- * // Using a URL plugin
78
- * await resolveContents('https://example.com/schema.json', [urlPlugin])
79
- * // Using a file plugin
80
- * await resolveContents('./schemas/user.json', [filePlugin])
81
- * // No matching plugin returns { ok: false }
82
- * await resolveContents('#/components/schemas/User', [urlPlugin, filePlugin])
83
- */
84
- function resolveContents(value: string, plugins: LoaderPlugin[]): Promise<ResolveResult> {
85
- const plugin = plugins.find((p) => p.validate(value))
86
-
87
- if (plugin) {
88
- return plugin.exec(value)
89
- }
90
-
91
- return Promise.resolve({
92
- ok: false,
93
- })
94
- }
95
-
96
- /**
97
- * Sets a value at a specified path in an object, creating intermediate objects/arrays as needed.
98
- * This function traverses the object structure and creates any missing intermediate objects
99
- * or arrays based on the path segments. If the next segment is a numeric string, it creates
100
- * an array instead of an object.
101
- *
102
- * ⚠️ Warning: Be careful with object keys that look like numbers (e.g. "123") as this function
103
- * will interpret them as array indices and create arrays instead of objects. If you need to
104
- * use numeric-looking keys, consider prefixing them with a non-numeric character.
105
- *
106
- * @param obj - The target object to set the value in
107
- * @param path - The JSON pointer path where the value should be set
108
- * @param value - The value to set at the specified path
109
- * @throws {Error} If attempting to set a value at the root path ('')
110
- *
111
- * @example
112
- * const obj = {}
113
- * setValueAtPath(obj, '/foo/bar/0', 'value')
114
- * // Result:
115
- * // {
116
- * // foo: {
117
- * // bar: ['value']
118
- * // }
119
- * // }
120
- *
121
- * @example
122
- * const obj = { existing: { path: 'old' } }
123
- * setValueAtPath(obj, '/existing/path', 'new')
124
- * // Result:
125
- * // {
126
- * // existing: {
127
- * // path: 'new'
128
- * // }
129
- * // }
130
- *
131
- * @example
132
- * // ⚠️ Warning: This will create an array instead of an object with key "123"
133
- * setValueAtPath(obj, '/foo/123/bar', 'value')
134
- * // Result:
135
- * // {
136
- * // foo: [
137
- * // undefined,
138
- * // undefined,
139
- * // undefined,
140
- * // { bar: 'value' }
141
- * // ]
142
- * // }
143
- */
144
- export function setValueAtPath(obj: any, path: string, value: any): void {
145
- if (path === '') {
146
- throw new Error("Cannot set value at root ('') pointer")
147
- }
148
-
149
- const parts = getSegmentsFromPath(path)
150
-
151
- let current = obj
152
-
153
- for (let i = 0; i < parts.length; i++) {
154
- const key = parts[i]
155
- const isLast = i === parts.length - 1
156
-
157
- const nextKey = parts[i + 1]
158
- const shouldBeArray = /^\d+$/.test(nextKey ?? '')
159
-
160
- if (isLast) {
161
- current[key] = value
162
- } else {
163
- if (!(key in current) || typeof current[key] !== 'object') {
164
- current[key] = shouldBeArray ? [] : {}
165
- }
166
- current = current[key]
167
- }
168
- }
169
- }
170
-
171
- /**
172
- * Resolves a reference path by combining a base path with a relative path.
173
- * Handles both remote URLs and local file paths.
174
- *
175
- * @param base - The base path (can be a URL or local file path)
176
- * @param relativePath - The relative path to resolve against the base
177
- * @returns The resolved absolute path
178
- * @example
179
- * // Resolve remote URL
180
- * resolveReferencePath('https://example.com/api/schema.json', 'user.json')
181
- * // Returns: 'https://example.com/api/user.json'
182
- *
183
- * // Resolve local path
184
- * resolveReferencePath('/path/to/schema.json', 'user.json')
185
- * // Returns: '/path/to/user.json'
186
- */
187
- function resolveReferencePath(base: string, relativePath: string) {
188
- if (isRemoteUrl(relativePath)) {
189
- return relativePath
190
- }
191
-
192
- if (isRemoteUrl(base)) {
193
- const url = new URL(base)
194
-
195
- // If the url stars with a / we want it to resolve from the origin so we replace the pathname
196
- if (relativePath.startsWith('/')) {
197
- url.pathname = relativePath
198
- return url.toString()
199
- }
200
-
201
- const mergedPath = path.join(path.dirname(url.pathname), relativePath)
202
- return new URL(mergedPath, base).toString()
203
- }
204
-
205
- return path.join(path.dirname(base), relativePath)
206
- }
207
-
208
- /**
209
- * Prefixes an internal JSON reference with a given path prefix.
210
- * Takes a local reference (starting with #) and prepends the provided prefix segments.
211
- *
212
- * @param input - The internal reference string to prefix (must start with #)
213
- * @param prefix - Array of path segments to prepend to the reference
214
- * @returns The prefixed reference string
215
- * @throws Error if input is not a local reference
216
- * @example
217
- * prefixInternalRef('#/components/schemas/User', ['definitions'])
218
- * // Returns: '#/definitions/components/schemas/User'
219
- */
220
- export function prefixInternalRef(input: string, prefix: string[]) {
221
- if (!isLocalRef(input)) {
222
- throw 'Please provide an internal ref'
223
- }
224
-
225
- return `#/${prefix.map(escapeJsonPointer).join('/')}${input.substring(1)}`
226
- }
227
-
228
- /**
229
- * Updates internal references in an object by adding a prefix to their paths.
230
- * Recursively traverses the input object and modifies any local $ref references
231
- * by prepending the given prefix to their paths. This is used when embedding external
232
- * documents to maintain correct reference paths relative to the main document.
233
- *
234
- * @param input - The object to update references in
235
- * @param prefix - Array of path segments to prepend to internal reference paths
236
- * @returns void
237
- * @example
238
- * ```ts
239
- * const input = {
240
- * foo: {
241
- * $ref: '#/components/schemas/User'
242
- * }
243
- * }
244
- * prefixInternalRefRecursive(input, ['definitions'])
245
- * // Result:
246
- * // {
247
- * // foo: {
248
- * // $ref: '#/definitions/components/schemas/User'
249
- * // }
250
- * // }
251
- * ```
252
- */
253
- export function prefixInternalRefRecursive(input: unknown, prefix: string[]) {
254
- if (!isObject(input)) {
255
- return
256
- }
257
-
258
- Object.values(input).forEach((el) => prefixInternalRefRecursive(el, prefix))
259
-
260
- if (typeof input === 'object' && '$ref' in input && typeof input['$ref'] === 'string') {
261
- const ref = input['$ref']
262
-
263
- if (!isLocalRef(ref)) {
264
- return
265
- }
266
-
267
- input['$ref'] = prefixInternalRef(ref, prefix)
268
- }
269
- }
270
-
271
- /**
272
- * Resolves and copies referenced values from a source document to a target document.
273
- * This function traverses the document and copies referenced values to the target document,
274
- * while tracking processed references to avoid duplicates. It only processes references
275
- * that belong to the same external document.
276
- *
277
- * @param targetDocument - The document to copy referenced values to
278
- * @param sourceDocument - The source document containing the references
279
- * @param referencePath - The JSON pointer path to the reference
280
- * @param externalRefsKey - The key used for external references (e.g. 'x-ext')
281
- * @param documentKey - The key identifying the external document
282
- * @param bundleLocalRefs - Also bundles the local refs
283
- * @param processedNodes - Set of already processed nodes to prevent duplicates
284
- * @example
285
- * ```ts
286
- * const source = {
287
- * components: {
288
- * schemas: {
289
- * User: {
290
- * $ref: '#/x-ext/users~1schema/definitions/Person'
291
- * }
292
- * }
293
- * }
294
- * }
295
- *
296
- * const target = {}
297
- * resolveAndCopyReferences(
298
- * target,
299
- * source,
300
- * '/components/schemas/User',
301
- * 'x-ext',
302
- * 'users/schema'
303
- * )
304
- * // Result: target will contain the User schema with resolved references
305
- * ```
306
- */
307
- export const resolveAndCopyReferences = (
308
- targetDocument: unknown,
309
- sourceDocument: unknown,
310
- referencePath: string,
311
- externalRefsKey: string,
312
- documentKey: string,
313
- bundleLocalRefs = false,
314
- processedNodes = new Set(),
315
- ) => {
316
- const referencedValue = getValueByPath(sourceDocument, getSegmentsFromPath(referencePath)).value
317
-
318
- if (processedNodes.has(referencedValue)) {
319
- return
320
- }
321
- processedNodes.add(referencedValue)
322
-
323
- setValueAtPath(targetDocument, referencePath, referencedValue)
324
-
325
- // Do the same for each local ref
326
- const traverse = (node: unknown) => {
327
- if (!node || typeof node !== 'object') {
328
- return
329
- }
330
-
331
- if ('$ref' in node && typeof node['$ref'] === 'string') {
332
- // We only process references from the same external document because:
333
- // 1. Other documents will be handled in separate recursive branches
334
- // 2. The source document only contains the current document's content
335
- // This prevents undefined behavior and maintains proper document boundaries
336
- if (node['$ref'].startsWith(`#/${externalRefsKey}/${escapeJsonPointer(documentKey)}`)) {
337
- resolveAndCopyReferences(
338
- targetDocument,
339
- sourceDocument,
340
- node['$ref'].substring(1),
341
- externalRefsKey,
342
- documentKey,
343
- bundleLocalRefs,
344
- processedNodes,
345
- )
346
- }
347
- // Bundle the local refs as well
348
- else if (bundleLocalRefs) {
349
- resolveAndCopyReferences(
350
- targetDocument,
351
- sourceDocument,
352
- node['$ref'].substring(1),
353
- externalRefsKey,
354
- documentKey,
355
- bundleLocalRefs,
356
- processedNodes,
357
- )
358
- }
359
- }
360
-
361
- for (const value of Object.values(node)) {
362
- traverse(value)
363
- }
364
- }
365
-
366
- traverse(referencedValue)
367
- }
368
-
369
- /**
370
- * A loader plugin for resolving external references during bundling.
371
- * Loader plugins are responsible for handling specific types of external references,
372
- * such as files, URLs, or custom protocols. Each loader plugin must provide:
373
- *
374
- * - A `validate` function to determine if the plugin can handle a given reference string.
375
- * - An `exec` function to asynchronously fetch and resolve the referenced data,
376
- * returning a Promise that resolves to a `ResolveResult`.
377
- *
378
- * Loader plugins enable extensible support for different reference sources in the bundler.
379
- *
380
- * @property type - The plugin type, always 'loader' for loader plugins.
381
- * @property validate - Function to check if the plugin can handle a given reference value.
382
- * @property exec - Function to fetch and resolve the reference, returning the resolved data.
383
- */
384
- export type LoaderPlugin = {
385
- type: 'loader'
386
- // Returns true if this plugin can handle the given reference value
387
- validate: (value: string) => boolean
388
- // Asynchronously fetches and resolves the reference, returning the resolved data
389
- exec: (value: string) => Promise<ResolveResult>
390
- }
391
-
392
- /**
393
- * Context information for a node during traversal or processing.
394
- *
395
- * Note: The `path` parameter represents the path to the current node being processed.
396
- * If you are performing a partial bundle (i.e., providing a custom root), this path will be relative
397
- * to the root you provide, not the absolute root of the original document. You may need to prefix
398
- * it with your own base path if you want to construct a full path from the absolute document root.
399
- *
400
- * - `path`: The JSON pointer path (as an array of strings) from the document root to the current node.
401
- * - `resolutionCache`: A cache for storing promises of resolved references.
402
- */
403
- type NodeProcessContext = {
404
- path: readonly string[]
405
- resolutionCache: Map<string, Promise<Readonly<ResolveResult>>>
406
- parentNode: UnknownObject | null
407
- rootNode: UnknownObject
408
- loaders: LoaderPlugin[]
409
- }
410
-
411
- /**
412
- * A plugin type for lifecycle hooks, allowing custom logic to be injected into the bundler's process.
413
- * This type extends the Config['hooks'] interface and is identified by type: 'lifecycle'.
414
- */
415
- export type LifecyclePlugin = { type: 'lifecycle' } & Config['hooks']
416
-
417
- /**
418
- * Represents a plugin used by the bundler for extensibility.
419
- *
420
- * Plugins can be either:
421
- * - Loader plugins: Responsible for resolving and loading external references (e.g., from files, URLs, or custom sources).
422
- * - Lifecycle plugins: Provide lifecycle hooks to customize or extend the bundling process.
423
- *
424
- * Loader plugins must implement:
425
- * - `validate`: Checks if the plugin can handle a given reference value.
426
- * - `exec`: Asynchronously resolves and returns the referenced data.
427
- *
428
- * Lifecycle plugins extend the bundler's lifecycle hooks for custom logic.
429
- */
430
- export type Plugin = LoaderPlugin | LifecyclePlugin
431
-
432
- /**
433
- * Configuration options for the bundler.
434
- * Controls how external references are resolved and processed during bundling.
435
- */
436
- type Config = {
437
- /**
438
- * Array of plugins that handle resolving references from different sources.
439
- * Each plugin is responsible for fetching and processing data from specific sources
440
- * like URLs or the filesystem.
441
- */
442
- plugins: Plugin[]
443
-
444
- /**
445
- * Optional root object that serves as the base document when bundling a subpart.
446
- * This allows resolving references relative to the root document's location,
447
- * ensuring proper path resolution for nested references.
448
- */
449
- root?: UnknownObject
450
-
451
- /**
452
- * Optional maximum depth for reference resolution.
453
- * Limits how deeply the bundler will follow and resolve nested $ref pointers.
454
- * Useful for preventing infinite recursion or excessive resource usage.
455
- */
456
- depth?: number
457
-
458
- /**
459
- * Optional origin path for the bundler.
460
- * Used to resolve relative paths in references, especially when the input is a string URL or file path.
461
- * If not provided, the bundler will use the input value as the origin.
462
- */
463
- origin?: string
464
-
465
- /**
466
- * Optional cache to store promises of resolved references.
467
- * Helps avoid duplicate fetches/reads of the same resource by storing
468
- * the resolution promises for reuse.
469
- */
470
- cache?: Map<string, Promise<ResolveResult>>
471
-
472
- /**
473
- * Cache of visited nodes during partial bundling.
474
- * Used to prevent re-bundling the same tree multiple times when doing partial bundling,
475
- * improving performance by avoiding redundant processing of already bundled sections.
476
- */
477
- visitedNodes?: Set<unknown>
478
-
479
- /**
480
- * Enable tree shaking to optimize the bundle size.
481
- * When enabled, only the parts of external documents that are actually referenced
482
- * will be included in the final bundle.
483
- */
484
- treeShake: boolean
485
-
486
- /**
487
- * Optional flag to generate a URL map.
488
- * When enabled, tracks the original source URLs of bundled references
489
- * in an x-ext-urls section for reference mapping.
490
- */
491
- urlMap?: boolean
492
-
493
- /**
494
- * Optional function to compress input URLs or file paths before bundling.
495
- * Returns either a Promise resolving to the compressed string or the compressed string directly.
496
- */
497
- compress?: (value: string) => Promise<string> | string
498
-
499
- /**
500
- * Optional hooks to monitor the bundler's lifecycle.
501
- * Allows tracking the progress and status of reference resolution.
502
- */
503
- hooks?: Partial<{
504
- /**
505
- * Optional hook called when the bundler starts resolving a $ref.
506
- * Useful for tracking or logging the beginning of a reference resolution.
507
- */
508
- onResolveStart: (node: UnknownObject & Record<'$ref', unknown>) => void
509
- /**
510
- * Optional hook called when the bundler fails to resolve a $ref.
511
- * Can be used for error handling, logging, or custom error reporting.
512
- */
513
- onResolveError: (node: UnknownObject & Record<'$ref', unknown>) => void
514
- /**
515
- * Optional hook called when the bundler successfully resolves a $ref.
516
- * Useful for tracking successful resolutions or custom post-processing.
517
- */
518
- onResolveSuccess: (node: UnknownObject & Record<'$ref', unknown>) => void
519
- /**
520
- * Optional hook invoked before processing a node.
521
- * Can be used for preprocessing, mutation, or custom logic before the node is handled by the bundler.
522
- */
523
- onBeforeNodeProcess: (node: UnknownObject, context: NodeProcessContext) => void | Promise<void>
524
- /**
525
- * Optional hook invoked after processing a node.
526
- * Useful for postprocessing, cleanup, or custom logic after the node has been handled by the bundler.
527
- */
528
- onAfterNodeProcess: (node: UnknownObject, context: NodeProcessContext) => void | Promise<void>
529
- }>
530
- }
531
-
532
- /**
533
- * Extension keys used for bundling external references in OpenAPI documents.
534
- * These custom extensions help maintain the structure and traceability of bundled documents.
535
- */
536
- const extensions = {
537
- /**
538
- * Custom OpenAPI extension key used to store external references.
539
- * This key will contain all bundled external documents.
540
- * The x-ext key is used to maintain a clean separation between the main
541
- * OpenAPI document and its bundled external references.
542
- */
543
- externalDocuments: 'x-ext',
544
-
545
- /**
546
- * Custom OpenAPI extension key used to maintain a mapping between
547
- * hashed keys and their original URLs in x-ext.
548
- * This mapping is essential for tracking the source of bundled references
549
- */
550
- externalDocumentsMappings: 'x-ext-urls',
551
- } as const
552
-
553
- /**
554
- * Bundles an OpenAPI specification by resolving all external references.
555
- * This function traverses the input object recursively and embeds external $ref
556
- * references into an x-ext section. External references can be URLs or local files.
557
- * The original $refs are updated to point to their embedded content in the x-ext section.
558
- * If the input is an object, it will be modified in place by adding an x-ext
559
- * property to store resolved external references.
560
- *
561
- * @param input - The OpenAPI specification to bundle. Can be either an object or string.
562
- * If a string is provided, it will be resolved using the provided plugins.
563
- * If no plugin can process the input, the onReferenceError hook will be invoked
564
- * and an error will be emitted to the console.
565
- * @param config - Configuration object containing plugins and options for bundling OpenAPI specifications
566
- * @returns A promise that resolves to the bundled specification with all references embedded
567
- * @example
568
- * // Example with object input
569
- * const spec = {
570
- * paths: {
571
- * '/users': {
572
- * $ref: 'https://example.com/schemas/users.yaml'
573
- * }
574
- * }
575
- * }
576
- *
577
- * const bundled = await bundle(spec, {
578
- * plugins: [fetchUrls()],
579
- * treeShake: true,
580
- * urlMap: true,
581
- * hooks: {
582
- * onResolveStart: (ref) => console.log('Resolving:', ref.$ref),
583
- * onResolveSuccess: (ref) => console.log('Resolved:', ref.$ref),
584
- * onResolveError: (ref) => console.log('Failed to resolve:', ref.$ref)
585
- * }
586
- * })
587
- * // Result:
588
- * // {
589
- * // paths: {
590
- * // '/users': {
591
- * // $ref: '#/x-ext/abc123'
592
- * // }
593
- * // },
594
- * // 'x-ext': {
595
- * // 'abc123': {
596
- * // // Resolved content from users.yaml
597
- * // }
598
- * // },
599
- * // 'x-ext-urls': {
600
- * // 'https://example.com/schemas/users.yaml': 'abc123'
601
- * // }
602
- * // }
603
- *
604
- * // Example with URL input
605
- * const bundledFromUrl = await bundle('https://example.com/openapi.yaml', {
606
- * plugins: [fetchUrls()],
607
- * treeShake: true,
608
- * urlMap: true,
609
- * hooks: {
610
- * onResolveStart: (ref) => console.log('Resolving:', ref.$ref),
611
- * onResolveSuccess: (ref) => console.log('Resolved:', ref.$ref),
612
- * onResolveError: (ref) => console.log('Failed to resolve:', ref.$ref)
613
- * }
614
- * })
615
- * // The function will first fetch the OpenAPI spec from the URL,
616
- * // then bundle all its external references into the x-ext section
617
- */
618
- export async function bundle(input: UnknownObject | string, config: Config) {
619
- // Cache for storing promises of resolved external references (URLs and local files)
620
- // to avoid duplicate fetches/reads of the same resource
621
- const cache = config.cache ?? new Map<string, Promise<ResolveResult>>()
622
-
623
- const loaderPlugins = config.plugins.filter((it) => it.type === 'loader')
624
- const lifecyclePlugin = config.plugins.filter((it) => it.type === 'lifecycle')
625
-
626
- /**
627
- * Resolves the input value by either returning it directly if it's not a string,
628
- * or attempting to resolve it using the provided plugins if it is a string.
629
- * @returns The resolved input data or throws an error if resolution fails
630
- */
631
- const resolveInput = async () => {
632
- if (typeof input !== 'string') {
633
- return input
634
- }
635
- const result = await resolveContents(input, loaderPlugins)
636
-
637
- if (result.ok && typeof result.data === 'object') {
638
- return result.data
639
- }
640
-
641
- throw new Error(
642
- 'Failed to resolve input: Please provide a valid string value or pass a loader to process the input',
643
- )
644
- }
645
-
646
- // Resolve the input specification, which could be either a direct object or a string URL/path
647
- const rawSpecification = await resolveInput()
648
-
649
- // Document root used to write all external documents
650
- // We need this when we want to do a partial bundle of a document
651
- const documentRoot = config.root ?? rawSpecification
652
-
653
- // Extract all $id and $anchor values from the document to identify local schemas
654
- const schemas = getSchemas(documentRoot)
655
-
656
- // Determines if the bundling operation is partial.
657
- // Partial bundling occurs when:
658
- // - A root document is provided that is different from the raw specification being bundled, or
659
- // - A maximum depth is specified in the config.
660
- // In these cases, only a subset of the document may be bundled.
661
- const isPartialBundling =
662
- (config.root !== undefined && config.root !== rawSpecification) || config.depth !== undefined
663
-
664
- // Set of nodes that have already been processed during bundling to prevent duplicate processing
665
- const processedNodes = config.visitedNodes ?? new Set()
666
-
667
- // Determines the initial origin path for the bundler based on the input type.
668
- // For string inputs that are URLs or file paths, uses the input as the origin.
669
- // For non-string inputs or other string types, returns an empty string.
670
- const defaultOrigin = () => {
671
- if (config.origin) {
672
- return config.origin
673
- }
674
-
675
- if (typeof input !== 'string') {
676
- return ''
677
- }
678
-
679
- if (isRemoteUrl(input) || isFilePath(input)) {
680
- return input
681
- }
682
-
683
- return ''
684
- }
685
-
686
- // Create the cache to store the compressed values to their map values
687
- if (documentRoot[extensions.externalDocumentsMappings] === undefined) {
688
- documentRoot[extensions.externalDocumentsMappings] = {}
689
- }
690
- const { generate } = uniqueValueGeneratorFactory(
691
- config.compress ?? getHash,
692
- documentRoot[extensions.externalDocumentsMappings],
693
- )
694
-
695
- const bundler = async (
696
- root: unknown,
697
- origin: string = defaultOrigin(),
698
- isChunkParent = false,
699
- depth = 0,
700
- currentPath: readonly string[] = [],
701
- parent: UnknownObject = null,
702
- ) => {
703
- // If a maximum depth is set in the config, stop bundling when the current depth reaches or exceeds it
704
- if (config.depth !== undefined && depth > config.depth) {
705
- return
706
- }
707
-
708
- if (!isObject(root) && !Array.isArray(root)) {
709
- return
710
- }
711
-
712
- // Skip if this node has already been processed to prevent infinite recursion
713
- // and duplicate processing of the same node
714
- if (processedNodes.has(root)) {
715
- return
716
- }
717
- // Mark this node as processed before continuing
718
- processedNodes.add(root)
719
-
720
- // Invoke the onBeforeNodeProcess hook for the current node before any further processing
721
- await config.hooks?.onBeforeNodeProcess?.(root as UnknownObject, {
722
- path: currentPath,
723
- resolutionCache: cache,
724
- parentNode: parent,
725
- rootNode: documentRoot as UnknownObject,
726
- loaders: loaderPlugins,
727
- })
728
- // Invoke onBeforeNodeProcess hooks from all registered lifecycle plugins
729
- for (const plugin of lifecyclePlugin) {
730
- await plugin.onBeforeNodeProcess?.(root as UnknownObject, {
731
- path: currentPath,
732
- resolutionCache: cache,
733
- parentNode: parent,
734
- rootNode: documentRoot as UnknownObject,
735
- loaders: loaderPlugins,
736
- })
737
- }
738
-
739
- const id = getId(root)
740
-
741
- if (typeof root === 'object' && '$ref' in root && typeof root['$ref'] === 'string') {
742
- const ref = root['$ref']
743
- const isChunk = '$global' in root && typeof root['$global'] === 'boolean' && root['$global']
744
-
745
- // Try to convert the reference to a local reference if possible
746
- // This handles cases where the reference points to a local schema using $id or $anchor
747
- // If it can be converted to a local reference, we do not need to bundle it
748
- // and can skip further processing for this reference
749
- // In case of partial bundling, we still need to ensure that all dependencies
750
- // of the local reference are bundled to create a complete and self-contained partial bundle
751
- // This is important to maintain the integrity of the partial bundle
752
- const localRef = convertToLocalRef(ref, id ?? origin, schemas)
753
-
754
- if (localRef !== undefined) {
755
- if (isPartialBundling) {
756
- const segments = getSegmentsFromPath(`/${localRef}`)
757
- const parent = segments.length > 0 ? getValueByPath(documentRoot, segments.slice(0, -1)).value : undefined
758
-
759
- const targetValue = getValueByPath(documentRoot, segments)
760
-
761
- // When doing partial bundling, we need to recursively bundle all dependencies
762
- // referenced by this local reference to ensure the partial bundle is complete.
763
- // This includes not just the direct reference but also all its dependencies,
764
- // creating a complete and self-contained partial bundle.
765
- await bundler(targetValue.value, targetValue.context, isChunkParent, depth + 1, segments, parent)
766
- }
767
- return
768
- }
769
-
770
- const [prefix, path = ''] = ref.split('#', 2)
771
-
772
- // Combine the current origin with the new path to resolve relative references
773
- // correctly within the context of the external file being processed
774
- const resolvedPath = resolveReferencePath(id ?? origin, prefix)
775
-
776
- // Generate a unique compressed path for the external document
777
- // This is used as a key to store and reference the bundled external document
778
- // The compression helps reduce the overall file size of the bundled document
779
- const compressedPath = await generate(resolvedPath)
780
-
781
- const seen = cache.has(resolvedPath)
782
-
783
- if (!seen) {
784
- cache.set(resolvedPath, resolveContents(resolvedPath, loaderPlugins))
785
- }
786
-
787
- config?.hooks?.onResolveStart?.(root)
788
- lifecyclePlugin.forEach((it) => it.onResolveStart?.(root))
789
-
790
- // Resolve the remote document
791
- const result = await cache.get(resolvedPath)
792
-
793
- if (result.ok) {
794
- // Process the result only once to avoid duplicate processing and prevent multiple prefixing
795
- // of internal references, which would corrupt the reference paths
796
- if (!seen) {
797
- // Skip prefixing for chunks since they are meant to be self-contained and their
798
- // internal references should remain relative to their original location. Chunks
799
- // are typically used for modular components that need to maintain their own
800
- // reference context without being affected by the main document's structure.
801
- if (!isChunk) {
802
- // Update internal references in the resolved document to use the correct base path.
803
- // When we embed external documents, their internal references need to be updated to
804
- // maintain the correct path context relative to the main document. This is crucial
805
- // because internal references in the external document are relative to its original
806
- // location, but when embedded, they need to be relative to their new location in
807
- // the main document's x-ext section. Without this update, internal references
808
- // would point to incorrect locations and break the document structure.
809
- prefixInternalRefRecursive(result.data, [extensions.externalDocuments, compressedPath])
810
- }
811
-
812
- // Recursively process the resolved content
813
- // to handle any nested references it may contain. We pass the resolvedPath as the new origin
814
- // to ensure any relative references within this content are resolved correctly relative to
815
- // their new location in the bundled document.
816
- await bundler(result.data, isChunk ? origin : resolvedPath, isChunk, depth + 1, [
817
- extensions.externalDocuments,
818
- compressedPath,
819
- documentRoot[extensions.externalDocumentsMappings],
820
- ])
821
-
822
- // Store the mapping between hashed keys and original URLs in x-ext-urls
823
- // This allows tracking which external URLs were bundled and their corresponding locations
824
- setValueAtPath(
825
- documentRoot,
826
- `/${extensions.externalDocumentsMappings}/${escapeJsonPointer(compressedPath)}`,
827
- resolvedPath,
828
- )
829
- }
830
-
831
- if (config.treeShake === true) {
832
- // Store only the subtree that is actually used
833
- // This optimizes the bundle size by only including the parts of the external document
834
- // that are referenced, rather than the entire document
835
- resolveAndCopyReferences(
836
- documentRoot,
837
- { [extensions.externalDocuments]: { [compressedPath]: result.data } },
838
- prefixInternalRef(`#${path}`, [extensions.externalDocuments, compressedPath]).substring(1),
839
- extensions.externalDocuments,
840
- compressedPath,
841
- )
842
- } else if (!seen) {
843
- // Store the external document in the main document's x-ext key
844
- // When tree shaking is disabled, we include the entire external document
845
- // This preserves all content and is faster since we don't need to analyze and copy
846
- // specific parts. This approach is ideal when storing the result in memory
847
- // as it avoids the overhead of tree shaking operations
848
- setValueAtPath(documentRoot, `/${extensions.externalDocuments}/${compressedPath}`, result.data)
849
- }
850
-
851
- // Update the $ref to point to the embedded document in x-ext
852
- // This is necessary because we need to maintain the correct path context
853
- // for the embedded document while preserving its internal structure
854
- root.$ref = prefixInternalRef(`#${path}`, [extensions.externalDocuments, compressedPath])
855
-
856
- config?.hooks?.onResolveSuccess?.(root)
857
- lifecyclePlugin.forEach((it) => it.onResolveSuccess?.(root))
858
-
859
- return
860
- }
861
-
862
- config?.hooks?.onResolveError?.(root)
863
- lifecyclePlugin.forEach((it) => it.onResolveError?.(root))
864
-
865
- return console.warn(
866
- `Failed to resolve external reference "${resolvedPath}". The reference may be invalid, inaccessible, or missing a loader for this type of reference.`,
867
- )
868
- }
869
-
870
- // Recursively traverse all child properties of the current object to resolve nested $ref references.
871
- // This step ensures that any $refs located deeper within the object hierarchy are discovered and processed.
872
- // We explicitly skip the extension keys (x-ext and x-ext-urls) to avoid reprocessing already bundled or mapped content.
873
- await Promise.all(
874
- Object.entries(root).map(async ([key, value]) => {
875
- if (key === extensions.externalDocuments || key === extensions.externalDocumentsMappings) {
876
- return
877
- }
878
-
879
- await bundler(value, id ?? origin, isChunkParent, depth + 1, [...currentPath, key], root as UnknownObject)
880
- }),
881
- )
882
-
883
- // Invoke the optional onAfterNodeProcess hook from the config, if provided.
884
- // This allows for custom post-processing logic after a node has been handled by the bundler.
885
- await config.hooks?.onAfterNodeProcess?.(root as UnknownObject, {
886
- path: currentPath,
887
- resolutionCache: cache,
888
- parentNode: parent,
889
- rootNode: documentRoot as UnknownObject,
890
- loaders: loaderPlugins,
891
- })
892
-
893
- // Iterate through all lifecycle plugins and invoke their onAfterNodeProcess hooks, if defined.
894
- // This enables plugins to perform additional post-processing or cleanup after the node is processed.
895
- for (const plugin of lifecyclePlugin) {
896
- await plugin.onAfterNodeProcess?.(root as UnknownObject, {
897
- path: currentPath,
898
- resolutionCache: cache,
899
- parentNode: parent,
900
- rootNode: documentRoot as UnknownObject,
901
- loaders: loaderPlugins,
902
- })
903
- }
904
- }
905
-
906
- await bundler(rawSpecification)
907
-
908
- // Keep urlMappings when doing partial bundling to track hash values and handle collisions
909
- // For full bundling without urlMap config, remove the mappings to clean up the output
910
- if (!config.urlMap && !isPartialBundling) {
911
- // Remove the external document mappings from the output when doing a full bundle without urlMap config
912
- delete documentRoot[extensions.externalDocumentsMappings]
913
- }
914
-
915
- return rawSpecification
916
- }