@scalar/json-magic 0.12.20 → 0.13.2

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/README.md CHANGED
@@ -1,11 +1,10 @@
1
- # json-magic
1
+ # @scalar/json-magic
2
2
 
3
3
  [![Version](https://img.shields.io/npm/v/%40scalar/json-magic)](https://www.npmjs.com/package/@scalar/json-magic)
4
4
  [![Downloads](https://img.shields.io/npm/dm/%40scalar/json-magic)](https://www.npmjs.com/package/@scalar/json-magic)
5
5
  [![License](https://img.shields.io/npm/l/%40scalar%2Fjson-magic)](https://www.npmjs.com/package/@scalar/json-magic)
6
6
  [![Discord](https://img.shields.io/discord/1135330207960678410?style=flat&color=5865F2)](https://discord.gg/scalar)
7
7
 
8
-
9
8
  A collection of utilities for working with JSON objects, including diffing, conflict resolution, bundling and more.
10
9
 
11
10
  ---
@@ -21,9 +20,41 @@ Scalar is an open-source API platform for teams who want beautiful developer int
21
20
 
22
21
  ---
23
22
 
23
+ ## Installation
24
+
25
+ ```bash
26
+ npm add @scalar/json-magic
27
+ ```
28
+
29
+ ## Entry points
30
+
31
+ There is no root export. Every module is imported from its own entry point, so you only pay for what you use.
32
+
33
+ | Entry point | Exports |
34
+ | --- | --- |
35
+ | `@scalar/json-magic/bundle` | `bundle`, `isLocalRef`, `prefixInternalRef`, `prefixInternalRefRecursive`, `resolveAndCopyReferences`, plus the `Plugin`, `LoaderPlugin`, `LifecyclePlugin` and `ResolveResult` types |
36
+ | `@scalar/json-magic/bundle/plugins/browser` | `fetchUrls`, `parseJson`, `parseYaml` |
37
+ | `@scalar/json-magic/bundle/plugins/node` | `fetchUrls`, `parseJson`, `parseYaml`, `readFiles` |
38
+ | `@scalar/json-magic/bundle/value-generator` | `getHash`, `generateUniqueValue`, `uniqueValueGeneratorFactory` |
39
+ | `@scalar/json-magic/dereference` | `dereference` |
40
+ | `@scalar/json-magic/diff` | `diff`, `merge`, `apply`, the `Difference` type |
41
+ | `@scalar/json-magic/magic-proxy` | `createMagicProxy`, `getRaw` |
42
+ | `@scalar/json-magic/helpers/*` | Small standalone helpers, see [Helpers](#helpers) |
43
+
44
+ ## Which module do I need?
45
+
46
+ | I want to … | Use |
47
+ | --- | --- |
48
+ | Pull external `$ref` documents into a single self-contained document | [`bundle`](#bundle) |
49
+ | Read through `$ref` pointers without rewriting the document | [`magic-proxy`](#magic-proxy) |
50
+ | Resolve every `$ref`, internal and external, in one call | [`dereference`](#dereference) |
51
+ | Compare two documents and merge concurrent edits | [`diff`](#diff) |
52
+
24
53
  ## bundle
25
54
 
26
- Bundle external references in a json object
55
+ `bundle` walks a JSON object, resolves every external `$ref` (URLs, local files, or anything a custom loader plugin can handle) and embeds the result into the document itself. The original `$ref` values are rewritten to point at the embedded copies, so the output is a single self-contained document.
56
+
57
+ External documents are stored under the `x-ext` key, and the mapping between the generated keys and their original URLs is stored under `x-ext-urls`. Both keys are configurable, see [Options](#options).
27
58
 
28
59
  ### Quick start
29
60
 
@@ -31,23 +62,36 @@ Bundle external references in a json object
31
62
  import { bundle } from '@scalar/json-magic/bundle'
32
63
  import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
33
64
 
34
- const result = await bundle({
35
- $ref: 'http://example.com/document.json'
36
- }, {
37
- plugins: [fetchUrls()],
38
- treeShake: false,
39
- })
65
+ const result = await bundle(
66
+ { $ref: 'http://example.com/document.json' },
67
+ {
68
+ plugins: [fetchUrls()],
69
+ treeShake: false,
70
+ },
71
+ )
40
72
 
41
- // get the bundled json object
73
+ // The bundled document
42
74
  console.log(result)
43
75
  ```
44
76
 
45
- #### Plugins
77
+ When the input is an object it is modified in place, and the same object is returned. When the input is a string it is resolved with the given plugins first.
78
+
79
+ ### Loaders
80
+
81
+ A loader plugin teaches the bundler how to read one kind of reference. Import loaders from `@scalar/json-magic/bundle/plugins/browser` in the browser, or from `@scalar/json-magic/bundle/plugins/node` in Node.js. The Node entry point is a superset: it adds `readFiles`, which needs filesystem access.
82
+
83
+ | Loader | Handles | Available in |
84
+ | --- | --- | --- |
85
+ | `fetchUrls` | `http://` and `https://` references | browser, node |
86
+ | `readFiles` | Local file paths | node |
87
+ | `parseJson` | A raw JSON string passed as the input | browser, node |
88
+ | `parseYaml` | A raw YAML string passed as the input | browser, node |
46
89
 
47
- If you are on a browser environment import plugins from `@scalar/json-magic/bundle/plugins/browser` while if you are on a node environment you can import from `@scalar/json-magic/bundle/plugins/node`
90
+ You can combine as many loaders as you need. The first one whose `validate` returns `true` wins.
48
91
 
49
- ##### fetchUrls
50
- This plugins handles all external urls. It works for both node.js and browser environment
92
+ #### fetchUrls
93
+
94
+ Resolves remote documents over HTTP. It works in both Node.js and the browser.
51
95
 
52
96
  ```ts
53
97
  import { bundle } from '@scalar/json-magic/bundle'
@@ -59,102 +103,104 @@ const document = {
59
103
  paths: {},
60
104
  components: {
61
105
  schemas: {
62
- User: { $ref: 'https://example.com/user-schema.json#' }
63
- }
64
- }
106
+ User: { $ref: 'https://example.com/user-schema.json#' },
107
+ },
108
+ },
65
109
  }
66
110
 
67
- // This will bundle all external documents and turn all references from external into internal
111
+ // This bundles all external documents and turns external references into internal ones
68
112
  await bundle(document, {
69
113
  plugins: [fetchUrls()],
70
- treeShake: true // <------ This flag will try to remove any unused part of the external document
114
+ // Removes the parts of the external documents that are not referenced
115
+ treeShake: true,
71
116
  })
72
117
 
73
118
  console.log(document)
74
119
  ```
75
120
 
76
- ###### Limiting the number of concurrent requests
121
+ ##### Limiting the number of concurrent requests
77
122
 
78
123
  ```ts
124
+ import { bundle } from '@scalar/json-magic/bundle'
125
+ import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
126
+
79
127
  await bundle(document, {
80
128
  plugins: [
81
129
  fetchUrls({
82
- limit: 10, // it should run at most 10 requests at the same time
130
+ // Run at most 10 requests at the same time
131
+ limit: 10,
83
132
  }),
84
133
  ],
85
- treeShake: false
134
+ treeShake: false,
86
135
  })
87
-
88
136
  ```
89
137
 
90
- ###### Custom headers
91
- To pass custom headers to requests for specific domains you can configure the fetch plugin like the example
138
+ ##### Custom headers
139
+
140
+ Headers are matched against the host of the reference, so a token is only ever sent to the domains you list.
92
141
 
93
142
  ```ts
94
- await bundle(
95
- document,
96
- {
97
- plugins: [
98
- fetchUrls({
99
- // Pass custom headers
100
- // The header will only be attached to the list of domains
101
- headers: [
102
- {
103
- domains: ['example.com'],
104
- headers: {
105
- 'Authorization': 'Bearer <TOKEN>'
106
- }
107
- }
108
- ]
109
- }),
110
- readFiles(),
111
- ],
112
- treeShake: false
113
- },
114
- )
143
+ import { bundle } from '@scalar/json-magic/bundle'
144
+ import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
145
+
146
+ await bundle(document, {
147
+ plugins: [
148
+ fetchUrls({
149
+ headers: [
150
+ {
151
+ domains: ['example.com'],
152
+ headers: {
153
+ Authorization: 'Bearer <TOKEN>',
154
+ },
155
+ },
156
+ ],
157
+ }),
158
+ ],
159
+ treeShake: false,
160
+ })
115
161
  ```
116
162
 
117
- ###### Custom fetch function
118
- For advanced use cases like proxying requests or implementing custom network logic, you can provide your own fetch implementation. This allows you to handle things like CORS restrictions, custom authentication flows, or request/response transformations.
163
+ ##### Custom fetch function
164
+
165
+ For advanced use cases like proxying requests or implementing custom network logic, you can provide your own fetch implementation. This allows you to handle things like CORS restrictions, custom authentication flows, or request and response transformations.
119
166
 
120
167
  ```ts
121
- await bundle(
122
- document,
123
- {
124
- plugins: [
125
- fetchUrls({
126
- // Custom fetcher function
127
- fetch: async (input, init) => {
128
- console.log('Custom fetch logic')
129
- return fetch(input, init)
130
- },
131
- })
132
- readFiles(),
133
- ],
134
- treeShake: false
135
- },
136
- )
168
+ import { bundle } from '@scalar/json-magic/bundle'
169
+ import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
170
+
171
+ await bundle(document, {
172
+ plugins: [
173
+ fetchUrls({
174
+ fetch: async (input, init) => {
175
+ console.log('Custom fetch logic')
176
+ return fetch(input, init)
177
+ },
178
+ }),
179
+ ],
180
+ treeShake: false,
181
+ })
137
182
  ```
138
183
 
139
- ###### Bundle from remote url
184
+ ##### Bundle from a remote URL
185
+
186
+ The input itself can be a URL, as long as a loader can handle it.
140
187
 
141
188
  ```ts
142
- const result = await bundle(
143
- 'https://example.com/openapi.json',
144
- {
145
- plugins: [
146
- fetchUrls(),
147
- ],
148
- treeShake: false
149
- },
150
- )
189
+ import { bundle } from '@scalar/json-magic/bundle'
190
+ import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
151
191
 
152
- // Bundled document
192
+ const result = await bundle('https://example.com/openapi.json', {
193
+ plugins: [fetchUrls()],
194
+ treeShake: false,
195
+ })
196
+
197
+ // The bundled document
153
198
  console.log(result)
154
199
  ```
155
200
 
156
- ##### readFiles
157
- This plugins handles local files. Only works on node.js environment
201
+ #### readFiles
202
+
203
+ Resolves local files. This loader only works in a Node.js environment.
158
204
 
159
205
  ```ts
160
206
  import { bundle } from '@scalar/json-magic/bundle'
@@ -166,91 +212,137 @@ const document = {
166
212
  paths: {},
167
213
  components: {
168
214
  schemas: {
169
- User: { $ref: './user-schema.json#' }
170
- }
171
- }
215
+ User: { $ref: './user-schema.json#' },
216
+ },
217
+ },
172
218
  }
173
219
 
174
- // This will bundle all external documents and turn all references from external into internal
175
220
  await bundle(document, {
176
221
  plugins: [readFiles()],
177
- treeShake: false
222
+ treeShake: false,
178
223
  })
179
224
 
180
225
  console.log(document)
181
226
  ```
182
227
 
183
- ###### Bundle from local file
184
- You can pass the file path directly but make sure to have the correct plugins to handle reading from the local files
228
+ ##### Bundle from a local file
229
+
230
+ You can pass the file path directly, as long as `readFiles` is registered to read it.
185
231
 
186
232
  ```ts
187
- const result = await bundle(
188
- './input.json',
189
- {
190
- plugins: [
191
- readFiles(),
192
- ],
193
- treeShake: false
194
- },
195
- )
233
+ import { bundle } from '@scalar/json-magic/bundle'
234
+ import { readFiles } from '@scalar/json-magic/bundle/plugins/node'
196
235
 
197
- // Bundled document
236
+ const result = await bundle('./input.json', {
237
+ plugins: [readFiles()],
238
+ treeShake: false,
239
+ })
240
+
241
+ // The bundled document
198
242
  console.log(result)
199
243
  ```
200
244
 
201
- ##### parseJson
245
+ A document that mixes remote and local references needs both loaders:
246
+
247
+ ```ts
248
+ import { bundle } from '@scalar/json-magic/bundle'
249
+ import { fetchUrls, readFiles } from '@scalar/json-magic/bundle/plugins/node'
250
+
251
+ await bundle('./openapi.json', {
252
+ plugins: [readFiles(), fetchUrls()],
253
+ treeShake: false,
254
+ })
255
+ ```
256
+
257
+ #### parseJson
258
+
259
+ Accepts a raw JSON string as the input.
202
260
 
203
- You can pass raw json string as input
204
261
  ```ts
205
262
  import { bundle } from '@scalar/json-magic/bundle'
206
263
  import { parseJson } from '@scalar/json-magic/bundle/plugins/browser'
207
264
 
208
- const result = await bundle(
209
- '{ "openapi": "3.1.1" }',
210
- {
211
- plugins: [
212
- parseJson(),
213
- ],
214
- treeShake: false
215
- },
216
- )
265
+ const result = await bundle('{ "openapi": "3.1.1" }', {
266
+ plugins: [parseJson()],
267
+ treeShake: false,
268
+ })
217
269
 
218
- // Bundled document
270
+ // The bundled document
219
271
  console.log(result)
220
272
  ```
221
273
 
222
- ##### parseYaml
274
+ #### parseYaml
275
+
276
+ Accepts a raw YAML string as the input.
223
277
 
224
- You can pass raw yaml string as input
225
278
  ```ts
226
279
  import { bundle } from '@scalar/json-magic/bundle'
227
280
  import { parseYaml } from '@scalar/json-magic/bundle/plugins/browser'
228
281
 
229
- const result = await bundle(
230
- 'openapi: "3.1.1"\n',
231
- {
232
- plugins: [
233
- parseYaml(),
234
- ],
235
- treeShake: false
236
- },
237
- )
282
+ const result = await bundle('openapi: "3.1.1"\n', {
283
+ plugins: [parseYaml()],
284
+ treeShake: false,
285
+ })
238
286
 
239
- // Bundled document
287
+ // The bundled document
240
288
  console.log(result)
241
289
  ```
242
290
 
243
- #### Bundler Options
291
+ #### Writing a custom loader
292
+
293
+ A loader is a plain object, so you can resolve references from anywhere: a database, an in-memory map, or a custom protocol.
294
+
295
+ ```ts
296
+ import { bundle, type LoaderPlugin } from '@scalar/json-magic/bundle'
297
+
298
+ const workspaceFiles: LoaderPlugin = {
299
+ type: 'loader',
300
+ validate: (value) => value.startsWith('workspace:'),
301
+ exec: async (value) => {
302
+ const raw = await readFromWorkspace(value.replace('workspace:', ''))
303
+
304
+ if (raw === undefined) {
305
+ return { ok: false }
306
+ }
307
+
308
+ return { ok: true, data: JSON.parse(raw), raw }
309
+ },
310
+ }
311
+
312
+ await bundle(document, {
313
+ plugins: [workspaceFiles],
314
+ treeShake: false,
315
+ })
316
+ ```
317
+
318
+ `exec` returns `{ ok: true, data, raw }` on success and `{ ok: false }` on failure. A failed resolution is reported through the `onResolveError` hook and logged as a warning, it does not throw.
319
+
320
+ ### Options
321
+
322
+ | Option | Type | Default | Description |
323
+ | --- | --- | --- | --- |
324
+ | `plugins` | `Plugin[]` | — | Loader and lifecycle plugins used during bundling. Required. |
325
+ | `treeShake` | `boolean` | — | Only keep the parts of external documents that are actually referenced. Required. |
326
+ | `depth` | `number` | unlimited | How deeply nested `$ref` pointers are followed. See the note below. |
327
+ | `root` | `UnknownObject` | the input | Base document to write external documents into, used for partial bundling. |
328
+ | `origin` | `string` | the input | Base path used to resolve relative references. |
329
+ | `cache` | `Map<string, Promise<ResolveResult>>` | new map | Cache of in-flight resolutions, reused across `bundle` calls to avoid refetching. |
330
+ | `visitedNodes` | `Set<unknown>` | new set | Nodes that have already been bundled, used to avoid re-bundling during partial bundles. |
331
+ | `urlMap` | `boolean` | `false` | Keep the mapping of generated keys to their original URLs in the output. |
332
+ | `externalDocumentsKey` | `string` | `'x-ext'` | Key that holds the bundled external documents. |
333
+ | `externalDocumentsMappingsKey` | `string` | `'x-ext-urls'` | Key that holds the mapping between generated keys and original URLs. |
334
+ | `compress` | `(value: string) => string \| Promise<string>` | hash | Function used to shorten URLs and file paths into document keys. |
335
+ | `hooks` | `object` | — | Lifecycle hooks, see [Hooks](#hooks). |
244
336
 
245
- ##### depth
337
+ #### depth
246
338
 
247
- The `depth` option controls how deeply the bundler will resolve `$ref` references. When you set `depth` to a number, the bundler will only follow references up to that level of nesting. This is useful for creating partial bundles or limiting resource usage.
339
+ The `depth` option controls how deeply the bundler resolves `$ref` references. When you set `depth` to a number, the bundler only follows references up to that level of nesting. This is useful for creating partial bundles or limiting resource usage.
248
340
 
249
- **Note:** When using `depth`, the resulting bundle may not be fully self-contained—some nested references deeper than the specified depth may remain unresolved. If you use `depth` together with the `visitedNodes` option, be aware that parent nodes may be marked as visited even if their child references have not been fully resolved yet. Use this option with care if you require a complete bundle.
341
+ **Note:** When using `depth`, the resulting bundle may not be fully self-contained, since nested references deeper than the given depth stay unresolved. If you use `depth` together with `visitedNodes`, be aware that parent nodes may be marked as visited even if their child references have not been fully resolved yet. Use this option with care if you require a complete bundle.
250
342
 
251
343
  ```ts
252
- import { bundle } from '@scalar/openapi-parser'
253
- import { fetchUrls } from '@scalar/openapi-parser/plugins-browser'
344
+ import { bundle } from '@scalar/json-magic/bundle'
345
+ import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
254
346
 
255
347
  await bundle(input, {
256
348
  plugins: [fetchUrls()],
@@ -259,13 +351,13 @@ await bundle(input, {
259
351
  })
260
352
  ```
261
353
 
262
- ##### externalDocumentsKey
354
+ #### externalDocumentsKey
263
355
 
264
- The `externalDocumentsKey` option controls the key used to store external references. This key will contain all bundled external documents. The key is used to maintain a clean separation between the main OpenAPI document and its bundled external references. **Defaults** to `x-ext`.
356
+ The `externalDocumentsKey` option controls the key used to store external references. This key contains all bundled external documents, which keeps a clean separation between the main document and its bundled references. **Defaults** to `x-ext`.
265
357
 
266
358
  ```ts
267
- import { bundle } from '@scalar/openapi-parser'
268
- import { fetchUrls } from '@scalar/openapi-parser/plugins-browser'
359
+ import { bundle } from '@scalar/json-magic/bundle'
360
+ import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
269
361
 
270
362
  await bundle(input, {
271
363
  plugins: [fetchUrls()],
@@ -274,65 +366,140 @@ await bundle(input, {
274
366
  })
275
367
  ```
276
368
 
277
- ##### externalDocumentsMappingsKey
369
+ #### externalDocumentsMappingsKey
278
370
 
279
- The `externalDocumentsMappingsKey` option controls the key used to maintain a mapping between hashed keys and their original URLs. This mapping is essential for tracking the source of bundled references. **Defaults** to `x-ext-urls`.
371
+ The `externalDocumentsMappingsKey` option controls the key used to maintain a mapping between generated keys and their original URLs. This mapping is essential for tracking the source of bundled references. It is removed from the output of a full bundle unless `urlMap` is enabled. **Defaults** to `x-ext-urls`.
280
372
 
281
373
  ```ts
282
- import { bundle } from '@scalar/openapi-parser'
283
- import { fetchUrls } from '@scalar/openapi-parser/plugins-browser'
374
+ import { bundle } from '@scalar/json-magic/bundle'
375
+ import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
284
376
 
285
377
  await bundle(input, {
286
378
  plugins: [fetchUrls()],
287
379
  treeShake: false,
288
380
  urlMap: true,
289
- externalDocumentsKey: 'x-external-urls',
381
+ externalDocumentsMappingsKey: 'x-external-urls',
382
+ })
383
+ ```
384
+
385
+ ### Hooks
386
+
387
+ Hooks let you observe and extend the bundling process, which is handy for progress reporting and error tracking.
388
+
389
+ | Hook | Called when |
390
+ | --- | --- |
391
+ | `onResolveStart` | The bundler starts resolving a `$ref` |
392
+ | `onResolveSuccess` | A `$ref` was resolved successfully |
393
+ | `onResolveError` | A `$ref` could not be resolved |
394
+ | `onBeforeNodeProcess` | Before a node is processed, may be async |
395
+ | `onAfterNodeProcess` | After a node is processed, may be async |
396
+
397
+ ```ts
398
+ import { bundle } from '@scalar/json-magic/bundle'
399
+ import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
400
+
401
+ const errors: string[] = []
402
+
403
+ await bundle(document, {
404
+ plugins: [fetchUrls()],
405
+ treeShake: true,
406
+ hooks: {
407
+ onResolveStart: (node) => console.log('Resolving:', node.$ref),
408
+ onResolveSuccess: (node) => console.log('Resolved:', node.$ref),
409
+ onResolveError: (node) => errors.push(`Failed to resolve ${node.$ref}`),
410
+ },
411
+ })
412
+ ```
413
+
414
+ The same hooks can be shipped as a reusable lifecycle plugin, which is then passed alongside the loaders:
415
+
416
+ ```ts
417
+ import { bundle, type LifecyclePlugin } from '@scalar/json-magic/bundle'
418
+ import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
419
+
420
+ const logProgress: LifecyclePlugin = {
421
+ type: 'lifecycle',
422
+ onResolveSuccess: (node) => console.log('Resolved:', node.$ref),
423
+ }
424
+
425
+ await bundle(document, {
426
+ plugins: [fetchUrls(), logProgress],
427
+ treeShake: false,
290
428
  })
291
429
  ```
292
430
 
293
431
  ## dereference
294
432
 
295
- Dereference all `$ref` pointers in a JSON object, resolving both internal and external references.
433
+ `dereference` resolves the `$ref` pointers of a document and returns the result wrapped in a [magic proxy](#magic-proxy), so referenced values are read through the `$ref-value` property.
296
434
 
297
- The `dereference` function can operate in two modes:
435
+ It works in two modes:
298
436
 
299
- - **Synchronous (`sync: true`)**: Only internal references (within the same object) are resolved. The result is wrapped in a magic proxy for reactive access. No network requests are made.
300
- - **Asynchronous (`sync: false` or omitted)**: Both internal and external references (e.g., URLs) are resolved. The function returns a Promise that resolves to the fully dereferenced object, also wrapped in a magic proxy.
437
+ - **Synchronous (`sync: true`)**: only internal references are resolved, no network requests are made, and the result is returned directly.
438
+ - **Asynchronous (`sync: false`, the default)**: external references are bundled into the document first, so both internal and external references resolve. A Promise is returned.
301
439
 
302
440
  ### Options
303
441
 
304
- - `sync` (`boolean`):
305
- - If `true`, resolves only internal references synchronously.
306
- - If `false` (default), resolves both internal and external references asynchronously and returns a Promise.
442
+ | Option | Type | Default | Description |
443
+ | --- | --- | --- | --- |
444
+ | `sync` | `boolean` | `false` | Resolve internal references only, without returning a Promise. |
445
+ | `plugins` | `Plugin[]` | `[fetchUrls()]` | Loaders used to resolve external references in async mode. |
307
446
 
308
- The result is an object with a `success` property. If dereferencing fails (e.g., due to unresolved external references), the result will include an `errors` array describing the issues encountered.
447
+ The result is an object with a `success` property. On success it carries the dereferenced `data`, and on failure it carries an `errors` array describing the references that could not be resolved.
309
448
 
310
449
  ```ts
311
450
  import { dereference } from '@scalar/json-magic/dereference'
312
451
 
313
452
  const result = dereference({ a: 'hello', b: { $ref: '#/a' } }, { sync: true })
314
453
 
315
- // Resolve internal references synchronously
316
- console.log(result)
454
+ if (result.success) {
455
+ // 'hello'
456
+ console.log(result.data.b['$ref-value'])
457
+ }
317
458
  ```
318
459
 
319
- To resolve also external references you need to set `sync: false`
460
+ To resolve external references as well, drop `sync` (or set it to `false`) and await the result.
320
461
 
321
462
  ```ts
322
463
  import { dereference } from '@scalar/json-magic/dereference'
323
464
 
324
- const result = await dereference({ a: 'hello', b: { $ref: 'http://example.com/document.json#/somepath' } }, { sync: false })
465
+ const result = await dereference({
466
+ a: 'hello',
467
+ b: { $ref: 'http://example.com/document.json#/somepath' },
468
+ })
325
469
 
326
- // Result with all internal and external references resolved
327
- console.log(result)
470
+ if (result.success) {
471
+ console.log(result.data)
472
+ } else {
473
+ console.error(result.errors)
474
+ }
475
+ ```
476
+
477
+ To resolve references from somewhere other than HTTP, pass your own loaders:
478
+
479
+ ```ts
480
+ import { dereference } from '@scalar/json-magic/dereference'
481
+ import { fetchUrls, readFiles } from '@scalar/json-magic/bundle/plugins/node'
482
+
483
+ const result = await dereference(document, {
484
+ plugins: [readFiles(), fetchUrls()],
485
+ })
328
486
  ```
329
487
 
330
488
  ## diff
331
489
 
332
- This package provides a way to compare two json objects and get the differences, resolve conflicts and return conflicts that need to be resolved manually.
490
+ Compare two JSON objects, merge changes made in parallel, and surface the conflicts that need to be resolved manually.
333
491
 
334
- ### Quickstart
492
+ | Function | Description |
493
+ | --- | --- |
494
+ | `diff(base, updated)` | Returns the list of `add`, `update` and `delete` changes that turn `base` into `updated` |
495
+ | `merge(diffA, diffB)` | Combines two changesets made against the same base into `{ diffs, conflicts }` |
496
+ | `apply(base, diffs)` | Applies a changeset to a document and returns it |
335
497
 
498
+ These three functions work on the documents themselves, not on copies of them. `apply` mutates the document it is given, `merge` merges one changeset into the entries of the other, and the changes a diff carries are live references into the documents that were compared. That means an applied document keeps sharing subtrees with the document it was diffed against, and a merge writes into the document behind its second changeset. Deep clone the documents before diffing them whenever you need the originals to stay untouched — cloning only the document you apply to is not enough.
499
+
500
+ `apply` throws an `InvalidChangesDetectedError` when a change points at a path that does not exist, when a change targets the document itself (an empty path, which a diff produces whenever the two documents differ at the root), and when a path reaches the prototype chain through `__proto__`, `constructor` or `prototype`.
501
+
502
+ ### Quickstart
336
503
 
337
504
  ```ts
338
505
  import { apply, diff, merge } from '@scalar/json-magic/diff'
@@ -366,19 +533,30 @@ const objectV2 = {
366
533
  }
367
534
 
368
535
  // Merge the changes of both versions with the same parent object
369
- const { diffs, conflicts } = merge(
370
- diff(baseObject, objectV1),
371
- diff(baseObject, objectV2),
372
- )
536
+ const { diffs, conflicts } = merge(diff(baseObject, objectV1), diff(baseObject, objectV2))
373
537
 
374
538
  // Apply changes from v1 and v2 to the parent object to get the final object
375
539
  const finalDocument = apply(baseObject, diffs)
376
540
  ```
377
541
 
542
+ ### Conflicts
543
+
544
+ `merge` only returns the changes it can combine safely. When both sides touch the same path, the pair is returned in `conflicts` for you to resolve, and it is left out of `diffs`.
545
+
546
+ ```ts
547
+ import { diff, merge } from '@scalar/json-magic/diff'
548
+
549
+ const { diffs, conflicts } = merge(diff(base, mine), diff(base, theirs))
550
+
551
+ for (const [mineChanges, theirChanges] of conflicts) {
552
+ // Decide which side wins, then push the winner onto diffs
553
+ diffs.push(...mineChanges)
554
+ }
555
+ ```
378
556
 
379
557
  ## magic-proxy
380
558
 
381
- A javascript proxy which resolves internal references when accessing a property
559
+ A JavaScript proxy that resolves internal references as you access properties. Nothing is copied or rewritten: the underlying document keeps its `$ref` pointers, and referenced values are read on demand through the virtual `$ref-value` property. Resolved values are cached, and proxies are stable for the same target object, which makes it safe to use with reactive frameworks like Vue.
382
560
 
383
561
  ### Quick start
384
562
 
@@ -388,30 +566,71 @@ import { createMagicProxy, getRaw } from '@scalar/json-magic/magic-proxy'
388
566
  const result = createMagicProxy({
389
567
  a: 'hello',
390
568
  b: {
391
- $ref: '#/a'
392
- }
569
+ $ref: '#/a',
570
+ },
393
571
  })
394
572
 
395
- /**
396
- * Output:
397
- * {
398
- * a: 'hello',
399
- * b: {
400
- * $ref: '#/a',
401
- * '$ref-value': 'hello'
402
- * }
403
- * }
404
- */
405
- console.log(result)
573
+ // 'hello', resolved on access
574
+ console.log(result.b['$ref-value'])
575
+
576
+ // '#/a', the original pointer is still there
577
+ console.log(result.b.$ref)
406
578
 
407
- const rawObject = getRaw(result)
408
579
  /**
580
+ * getRaw returns the untouched object:
409
581
  * {
410
- * a: 'hello',
411
- * b: {
412
- * $ref: '#/a'
413
- * }
582
+ * a: 'hello',
583
+ * b: {
584
+ * $ref: '#/a'
585
+ * }
414
586
  * }
415
587
  */
588
+ const rawObject = getRaw(result)
416
589
  console.log(rawObject)
417
- ```
590
+ ```
591
+
592
+ Deeply nested values are proxied too, so `$ref-value` works at any depth. Writes pass through to the underlying object, and writing to `$ref-value` updates the value the reference points at.
593
+
594
+ Properties prefixed with `__scalar_` are treated as internal: they read as `undefined`, they are left out of `Object.keys`, and `in` checks return `false`. Pass `{ showInternal: true }` to expose them.
595
+
596
+ ```ts
597
+ const proxy = createMagicProxy({ __scalar_meta: 'hidden' }, { showInternal: true })
598
+
599
+ // 'hidden'
600
+ console.log(proxy.__scalar_meta)
601
+ ```
602
+
603
+ ## Helpers
604
+
605
+ Standalone helpers, each with its own entry point.
606
+
607
+ | Helper | Description |
608
+ | --- | --- |
609
+ | `@scalar/json-magic/helpers/escape-json-pointer` | Escape `~` and `/` in a JSON pointer segment |
610
+ | `@scalar/json-magic/helpers/unescape-json-pointer` | Reverse of `escapeJsonPointer` |
611
+ | `@scalar/json-magic/helpers/get-segments-from-path` | Split a JSON pointer into unescaped segments |
612
+ | `@scalar/json-magic/helpers/get-value-by-path` | Read the value at a list of path segments |
613
+ | `@scalar/json-magic/helpers/set-value-at-path` | Write a value at a JSON pointer, creating missing nodes |
614
+ | `@scalar/json-magic/helpers/is-file-path` | Check whether a string looks like a file path |
615
+ | `@scalar/json-magic/helpers/is-http-url` | Check whether a string is an HTTP or HTTPS URL |
616
+ | `@scalar/json-magic/helpers/is-json-object` | Check whether a string is a JSON object |
617
+ | `@scalar/json-magic/helpers/is-yaml` | Check whether a string is valid YAML |
618
+ | `@scalar/json-magic/helpers/normalize` | Parse a JSON or YAML string into an object |
619
+
620
+ ```ts
621
+ import { getValueByPath } from '@scalar/json-magic/helpers/get-value-by-path'
622
+
623
+ const { value } = getValueByPath({ components: { schemas: { User: { type: 'object' } } } }, [
624
+ 'components',
625
+ 'schemas',
626
+ 'User',
627
+ ])
628
+ ```
629
+
630
+ ## Community
631
+
632
+ We are API nerds. You too? Let's chat on Discord: <https://discord.gg/scalar>
633
+
634
+ ## License
635
+
636
+ The source code in this repository is licensed under [MIT](https://github.com/scalar/scalar/blob/main/LICENSE).