@sanity/sdk 2.0.2 → 2.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.
- package/dist/index.d.ts +17 -29
- package/dist/index.js +25 -117
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/src/_exports/index.ts +9 -1
- package/src/document/documentStore.ts +7 -3
- package/src/document/patchOperations.test.ts +8 -342
- package/src/document/patchOperations.ts +13 -259
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import {applyPatches, parsePatch} from '@sanity/diff-match-patch'
|
|
2
|
+
import {getIndexForKey, jsonMatch, slicePath, stringifyPath} from '@sanity/json-match'
|
|
2
3
|
import {
|
|
3
4
|
type IndexTuple,
|
|
4
5
|
type InsertPatch,
|
|
5
6
|
isKeyedObject,
|
|
6
7
|
isKeySegment,
|
|
7
|
-
type KeyedSegment,
|
|
8
|
-
type Path,
|
|
9
8
|
type PathSegment,
|
|
10
9
|
} from '@sanity/types'
|
|
11
10
|
|
|
@@ -113,235 +112,6 @@ type DeepGet<TValue, TPath extends readonly (string | number)[]> = TPath extends
|
|
|
113
112
|
*/
|
|
114
113
|
export type JsonMatch<TDocument, TPath extends string> = DeepGet<TDocument, PathParts<TPath>>
|
|
115
114
|
|
|
116
|
-
function parseBracketContent(content: string): PathSegment {
|
|
117
|
-
// 1) Range match: ^(\d*):(\d*)$
|
|
118
|
-
// - start or end can be empty (meaning "start" or "end" of array)
|
|
119
|
-
const rangeMatch = content.match(/^(\d*):(\d*)$/)
|
|
120
|
-
if (rangeMatch) {
|
|
121
|
-
const startStr = rangeMatch[1]
|
|
122
|
-
const endStr = rangeMatch[2]
|
|
123
|
-
const start: number | '' = startStr === '' ? '' : parseInt(startStr, 10)
|
|
124
|
-
const end: number | '' = endStr === '' ? '' : parseInt(endStr, 10)
|
|
125
|
-
return [start, end]
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// 2) Keyed segment match: ^_key==["'](.*)["']$
|
|
129
|
-
// (We allow either double or single quotes for the value)
|
|
130
|
-
const keyedMatch = content.match(/^_key==["'](.+)["']$/)
|
|
131
|
-
if (keyedMatch) {
|
|
132
|
-
return {_key: keyedMatch[1]}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// 3) Single index (positive or negative)
|
|
136
|
-
const index = parseInt(content, 10)
|
|
137
|
-
if (!isNaN(index)) {
|
|
138
|
-
return index
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
throw new Error(`Invalid bracket content: "[${content}]"`)
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function parseSegment(segment: string): PathSegment[] {
|
|
145
|
-
// Each "segment" can contain:
|
|
146
|
-
// - A leading property name (optional).
|
|
147
|
-
// - Followed by zero or more bracket expressions, e.g. foo[1][_key=="bar"][2:9].
|
|
148
|
-
//
|
|
149
|
-
// We'll collect these into an array of path segments.
|
|
150
|
-
|
|
151
|
-
const segments: PathSegment[] = []
|
|
152
|
-
let idx = 0
|
|
153
|
-
|
|
154
|
-
// Helper to push a string if it's not empty
|
|
155
|
-
function pushIfNotEmpty(text: string) {
|
|
156
|
-
if (text) {
|
|
157
|
-
segments.push(text)
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
while (idx < segment.length) {
|
|
162
|
-
// Look for the next '['
|
|
163
|
-
const openIndex = segment.indexOf('[', idx)
|
|
164
|
-
if (openIndex === -1) {
|
|
165
|
-
// No more brackets – whatever remains is a plain string key
|
|
166
|
-
const remaining = segment.slice(idx)
|
|
167
|
-
pushIfNotEmpty(remaining)
|
|
168
|
-
break
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Push text before this bracket (as a string key) if not empty
|
|
172
|
-
const before = segment.slice(idx, openIndex)
|
|
173
|
-
pushIfNotEmpty(before)
|
|
174
|
-
|
|
175
|
-
// Find the closing bracket
|
|
176
|
-
const closeIndex = segment.indexOf(']', openIndex)
|
|
177
|
-
if (closeIndex === -1) {
|
|
178
|
-
throw new Error(`Unmatched "[" in segment: "${segment}"`)
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Extract the bracket content
|
|
182
|
-
const bracketContent = segment.slice(openIndex + 1, closeIndex)
|
|
183
|
-
segments.push(parseBracketContent(bracketContent))
|
|
184
|
-
|
|
185
|
-
// Move past the bracket
|
|
186
|
-
idx = closeIndex + 1
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return segments
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
export function parsePath(path: string): Path {
|
|
193
|
-
// We want to split on '.' outside of brackets. A simple approach is
|
|
194
|
-
// to track "are we in bracket or not?" while scanning.
|
|
195
|
-
const result: Path = []
|
|
196
|
-
let buffer = ''
|
|
197
|
-
let bracketDepth = 0
|
|
198
|
-
|
|
199
|
-
for (let i = 0; i < path.length; i++) {
|
|
200
|
-
const ch = path[i]
|
|
201
|
-
if (ch === '[') {
|
|
202
|
-
bracketDepth++
|
|
203
|
-
buffer += ch
|
|
204
|
-
} else if (ch === ']') {
|
|
205
|
-
bracketDepth--
|
|
206
|
-
buffer += ch
|
|
207
|
-
} else if (ch === '.' && bracketDepth === 0) {
|
|
208
|
-
// We hit a dot at the top level → this ends one segment
|
|
209
|
-
if (buffer) {
|
|
210
|
-
result.push(...parseSegment(buffer))
|
|
211
|
-
buffer = ''
|
|
212
|
-
}
|
|
213
|
-
} else {
|
|
214
|
-
buffer += ch
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// If there's anything left in the buffer, parse it
|
|
219
|
-
if (buffer) {
|
|
220
|
-
result.push(...parseSegment(buffer))
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
return result
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
export function stringifyPath(path: Path): string {
|
|
227
|
-
let result = ''
|
|
228
|
-
for (let i = 0; i < path.length; i++) {
|
|
229
|
-
const segment = path[i]
|
|
230
|
-
|
|
231
|
-
if (typeof segment === 'string') {
|
|
232
|
-
// If not the first segment and the previous segment was
|
|
233
|
-
// not a bracket form, we add a dot
|
|
234
|
-
if (result) {
|
|
235
|
-
result += '.'
|
|
236
|
-
}
|
|
237
|
-
result += segment
|
|
238
|
-
} else if (typeof segment === 'number') {
|
|
239
|
-
// Single index
|
|
240
|
-
result += `[${segment}]`
|
|
241
|
-
} else if (Array.isArray(segment)) {
|
|
242
|
-
// Index tuple
|
|
243
|
-
const [start, end] = segment
|
|
244
|
-
const startStr = start === '' ? '' : String(start)
|
|
245
|
-
const endStr = end === '' ? '' : String(end)
|
|
246
|
-
result += `[${startStr}:${endStr}]`
|
|
247
|
-
} else {
|
|
248
|
-
// Keyed segment
|
|
249
|
-
// e.g. {_key: "someValue"} => [_key=="someValue"]
|
|
250
|
-
result += `[_key=="${segment._key}"]`
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
return result
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
type MatchEntry<T = unknown> = {
|
|
257
|
-
value: T
|
|
258
|
-
path: SingleValuePath
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* A very simplified implementation of [JSONMatch][0] that only supports:
|
|
263
|
-
* - descent e.g. `friend.name`
|
|
264
|
-
* - array index e.g. `items[-1]`
|
|
265
|
-
* - array matching with `_key` e.g. `items[_key=="dd9efe09"]`
|
|
266
|
-
* - array matching with a range e.g. `items[4:]`
|
|
267
|
-
*
|
|
268
|
-
* E.g. `friends[_key=="dd9efe09"].address.zip`
|
|
269
|
-
*
|
|
270
|
-
* [0]: https://www.sanity.io/docs/json-match
|
|
271
|
-
*
|
|
272
|
-
* @beta
|
|
273
|
-
*/
|
|
274
|
-
export function jsonMatch<TDocument, TPath extends string>(
|
|
275
|
-
input: TDocument,
|
|
276
|
-
path: TPath,
|
|
277
|
-
): MatchEntry<JsonMatch<TDocument, TPath>>[]
|
|
278
|
-
/** @beta */
|
|
279
|
-
export function jsonMatch<TValue>(input: unknown, path: string): MatchEntry<TValue>[]
|
|
280
|
-
/** @beta */
|
|
281
|
-
export function jsonMatch(input: unknown, pathExpression: string): MatchEntry[] {
|
|
282
|
-
return matchRecursive(input, parsePath(pathExpression), [])
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
function matchRecursive(value: unknown, path: Path, currentPath: SingleValuePath): MatchEntry[] {
|
|
286
|
-
// If we've consumed the entire path, return the final match
|
|
287
|
-
if (path.length === 0) {
|
|
288
|
-
return [{value, path: currentPath}]
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const [head, ...rest] = path
|
|
292
|
-
|
|
293
|
-
// 1) String segment => object property
|
|
294
|
-
if (typeof head === 'string') {
|
|
295
|
-
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
296
|
-
const obj = value as Record<string, unknown>
|
|
297
|
-
const nextValue = obj[head]
|
|
298
|
-
return matchRecursive(nextValue, rest, [...currentPath, head])
|
|
299
|
-
}
|
|
300
|
-
// If not an object with that property, no match
|
|
301
|
-
return []
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// 2) Numeric segment => array index
|
|
305
|
-
if (typeof head === 'number') {
|
|
306
|
-
if (Array.isArray(value)) {
|
|
307
|
-
const nextValue = value.at(head)
|
|
308
|
-
return matchRecursive(nextValue, rest, [...currentPath, head])
|
|
309
|
-
}
|
|
310
|
-
// If not an array, no match
|
|
311
|
-
return []
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// 3) Index tuple => multiple indices
|
|
315
|
-
if (Array.isArray(head)) {
|
|
316
|
-
// This is a range: [start, end]
|
|
317
|
-
if (!Array.isArray(value)) {
|
|
318
|
-
// If not an array, no match
|
|
319
|
-
return []
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const [start, end] = head
|
|
323
|
-
// Convert empty strings '' to the start/end of the array
|
|
324
|
-
const startIndex = start === '' ? 0 : start
|
|
325
|
-
const endIndex = end === '' ? value.length : end
|
|
326
|
-
|
|
327
|
-
// We'll accumulate all matches from each index in the range
|
|
328
|
-
return value
|
|
329
|
-
.slice(startIndex, endIndex)
|
|
330
|
-
.flatMap((item, i) => matchRecursive(item, rest, [...currentPath, i + startIndex]))
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// 4) Keyed segment => find index in array
|
|
334
|
-
// e.g. {_key: 'foo'}
|
|
335
|
-
const keyed = head as KeyedSegment
|
|
336
|
-
const arrIndex = getIndexForKey(value, keyed._key)
|
|
337
|
-
if (arrIndex === undefined || !Array.isArray(value)) {
|
|
338
|
-
return []
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const nextVal = value[arrIndex]
|
|
342
|
-
return matchRecursive(nextVal, rest, [...currentPath, {_key: keyed._key}])
|
|
343
|
-
}
|
|
344
|
-
|
|
345
115
|
// this is a similar array key to the studio:
|
|
346
116
|
// https://github.com/sanity-io/sanity/blob/v3.74.1/packages/sanity/src/core/form/inputs/arrays/ArrayOfObjectsInput/createProtoArrayValue.ts
|
|
347
117
|
function generateArrayKey(length: number = 12): string {
|
|
@@ -423,7 +193,7 @@ export function set<R>(input: unknown, pathExpressionValues: Record<string, unkn
|
|
|
423
193
|
export function set(input: unknown, pathExpressionValues: Record<string, unknown>): unknown {
|
|
424
194
|
const result = Object.entries(pathExpressionValues)
|
|
425
195
|
.flatMap(([pathExpression, replacementValue]) =>
|
|
426
|
-
jsonMatch(input, pathExpression).map((matchEntry) => ({
|
|
196
|
+
Array.from(jsonMatch(input, pathExpression)).map((matchEntry) => ({
|
|
427
197
|
...matchEntry,
|
|
428
198
|
replacementValue,
|
|
429
199
|
})),
|
|
@@ -455,7 +225,7 @@ export function setIfMissing(
|
|
|
455
225
|
): unknown {
|
|
456
226
|
const result = Object.entries(pathExpressionValues)
|
|
457
227
|
.flatMap(([pathExpression, replacementValue]) => {
|
|
458
|
-
return jsonMatch(input, pathExpression).map((matchEntry) => ({
|
|
228
|
+
return Array.from(jsonMatch(input, pathExpression)).map((matchEntry) => ({
|
|
459
229
|
...matchEntry,
|
|
460
230
|
replacementValue,
|
|
461
231
|
}))
|
|
@@ -483,7 +253,7 @@ export function setIfMissing(
|
|
|
483
253
|
export function unset<R>(input: unknown, pathExpressions: string[]): R
|
|
484
254
|
export function unset(input: unknown, pathExpressions: string[]): unknown {
|
|
485
255
|
const result = pathExpressions
|
|
486
|
-
.flatMap((pathExpression) => jsonMatch(input, pathExpression))
|
|
256
|
+
.flatMap((pathExpression) => Array.from(jsonMatch(input, pathExpression)))
|
|
487
257
|
// ensure that we remove in the reverse order the paths were found in
|
|
488
258
|
// this is necessary for array unsets so the indexes don't change as we unset
|
|
489
259
|
.reverse()
|
|
@@ -595,18 +365,15 @@ export function insert(input: unknown, {items, ...insertPatch}: InsertPatch): un
|
|
|
595
365
|
if (!operation) return input
|
|
596
366
|
if (typeof pathExpression !== 'string') return input
|
|
597
367
|
|
|
598
|
-
const parsedPath = parsePath(pathExpression)
|
|
599
368
|
// in order to do an insert patch, you need to provide at least one path segment
|
|
600
|
-
if (!
|
|
369
|
+
if (!pathExpression.length) return input
|
|
601
370
|
|
|
602
|
-
const arrayPath =
|
|
603
|
-
const positionPath =
|
|
604
|
-
|
|
605
|
-
const arrayMatches = jsonMatch<unknown[]>(input, arrayPath)
|
|
371
|
+
const arrayPath = slicePath(pathExpression, 0, -1)
|
|
372
|
+
const positionPath = slicePath(pathExpression, -1)
|
|
606
373
|
|
|
607
374
|
let result = input
|
|
608
375
|
|
|
609
|
-
for (const {path, value} of
|
|
376
|
+
for (const {path, value} of jsonMatch(input, arrayPath)) {
|
|
610
377
|
if (!Array.isArray(value)) continue
|
|
611
378
|
let arr = value
|
|
612
379
|
|
|
@@ -725,7 +492,7 @@ export function inc<R>(input: unknown, pathExpressionValues: Record<string, numb
|
|
|
725
492
|
export function inc(input: unknown, pathExpressionValues: Record<string, number>): unknown {
|
|
726
493
|
const result = Object.entries(pathExpressionValues)
|
|
727
494
|
.flatMap(([pathExpression, valueToAdd]) =>
|
|
728
|
-
jsonMatch(input, pathExpression).map((matchEntry) => ({
|
|
495
|
+
Array.from(jsonMatch(input, pathExpression)).map((matchEntry) => ({
|
|
729
496
|
...matchEntry,
|
|
730
497
|
valueToAdd,
|
|
731
498
|
})),
|
|
@@ -789,7 +556,9 @@ export function diffMatchPatch(
|
|
|
789
556
|
pathExpressionValues: Record<string, string>,
|
|
790
557
|
): unknown {
|
|
791
558
|
const result = Object.entries(pathExpressionValues)
|
|
792
|
-
.flatMap(([pathExpression, dmp]) =>
|
|
559
|
+
.flatMap(([pathExpression, dmp]) =>
|
|
560
|
+
Array.from(jsonMatch(input, pathExpression)).map((m) => ({...m, dmp})),
|
|
561
|
+
)
|
|
793
562
|
.filter((i) => i.value !== undefined)
|
|
794
563
|
.map(({path, value, dmp}) => {
|
|
795
564
|
if (typeof value !== 'string') {
|
|
@@ -832,21 +601,6 @@ export function ifRevisionID(input: unknown, revisionId: string): unknown {
|
|
|
832
601
|
return input
|
|
833
602
|
}
|
|
834
603
|
|
|
835
|
-
const indexCache = new WeakMap<KeyedSegment[], Record<string, number | undefined>>()
|
|
836
|
-
export function getIndexForKey(input: unknown, key: string): number | undefined {
|
|
837
|
-
if (!Array.isArray(input)) return undefined
|
|
838
|
-
const cached = indexCache.get(input)
|
|
839
|
-
if (cached) return cached[key]
|
|
840
|
-
|
|
841
|
-
const lookup = input.reduce<Record<string, number | undefined>>((acc, next, index) => {
|
|
842
|
-
if (typeof next?._key === 'string') acc[next._key] = index
|
|
843
|
-
return acc
|
|
844
|
-
}, {})
|
|
845
|
-
|
|
846
|
-
indexCache.set(input, lookup)
|
|
847
|
-
return lookup[key]
|
|
848
|
-
}
|
|
849
|
-
|
|
850
604
|
/**
|
|
851
605
|
* Gets a value deep inside of an object given a path. If the path does not
|
|
852
606
|
* exist in the object, `undefined` will be returned.
|
|
@@ -915,7 +669,7 @@ export function setDeep(input: unknown, path: SingleValuePath, value: unknown):
|
|
|
915
669
|
if (Array.isArray(input)) {
|
|
916
670
|
let index: number | undefined
|
|
917
671
|
if (isKeySegment(currentSegment)) {
|
|
918
|
-
index = getIndexForKey(input, currentSegment._key)
|
|
672
|
+
index = getIndexForKey(input, currentSegment._key) ?? input.length
|
|
919
673
|
} else if (typeof currentSegment === 'number') {
|
|
920
674
|
// Support negative indexes by computing the proper positive index.
|
|
921
675
|
index = currentSegment < 0 ? input.length + currentSegment : currentSegment
|