@scalar/json-magic 0.1.0

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