@sanity/sdk
Version:
758 lines (669 loc) • 23.6 kB
text/typescript
import {applyPatches, parsePatch} from '@sanity/diff-match-patch'
import {getIndexForKey, jsonMatch, slicePath, stringifyPath} from '@sanity/json-match'
import {
type IndexTuple,
type InsertPatch,
isKeyedObject,
isKeySegment,
type PathSegment,
} from '@sanity/types'
type SingleValuePath = Exclude<PathSegment, IndexTuple>[]
type ToNumber<TInput extends string> = TInput extends `${infer TNumber extends number}`
? TNumber
: TInput
/**
* Parse a single "segment" that may include bracket parts.
*
* For example, the literal
*
* ```
* "friends[0][1]"
* ```
*
* is parsed as:
*
* ```
* ["friends", 0, 1]
* ```
*/
type ParseSegment<TInput extends string> = TInput extends `${infer TProp}[${infer TRest}`
? TProp extends ''
? [...ParseBracket<`[${TRest}`>] // no property name before '['
: [TProp, ...ParseBracket<`[${TRest}`>]
: TInput extends ''
? []
: [TInput]
/**
* Parse one or more bracketed parts from a segment.
*
* It recursively "peels off" a bracketed part and then continues.
*
* For example, given the string:
*
* ```
* "[0][foo]"
* ```
*
* it produces:
*
* ```
* [ToNumber<"0">, "foo"]
* ```
*/
type ParseBracket<TInput extends string> = TInput extends `[${infer TPart}]${infer TRest}`
? [ToNumber<TPart>, ...ParseSegment<TRest>]
: [] // no leading bracket → end of this segment
/**
* Split the entire path string on dots "outside" of any brackets.
*
* For example:
* ```
* "friends[0].name"
* ```
*
* becomes:
*
* ```
* [...ParseSegment<"friends[0]">, ...ParseSegment<"name">]
* ```
*
* (We use a simple recursion that splits on the first dot.)
*/
type PathParts<TPath extends string> = TPath extends `${infer Head}.${infer Tail}`
? [Head, ...PathParts<Tail>]
: TPath extends ''
? []
: [TPath]
/**
* Given a type T and an array of "access keys" Parts, recursively index into T.
*
* If a part is a key, it looks up that property.
* If T is an array and the part is a number, it "indexes" into the element type.
*/
type DeepGet<TValue, TPath extends readonly (string | number)[]> = TPath extends []
? TValue
: TPath extends readonly [infer THead, ...infer TTail]
? // Handle traversing into optional properties
DeepGet<
TValue extends undefined | null
? undefined // Stop traversal if current value is null/undefined
: THead extends keyof TValue // Access property if key exists
? TValue[THead]
: // Handle array indexing
THead extends number
? TValue extends readonly (infer TElement)[]
? TElement | undefined // Array element or undefined if out of bounds
: undefined // Cannot index non-array with number
: undefined, // Key/index doesn't exist
TTail extends readonly (string | number)[] ? TTail : [] // Continue with the rest of the path
>
: never // Should be unreachable
/**
* Given a document type TDocument and a JSON Match path string TPath,
* compute the type found at that path.
* @beta
*/
export type JsonMatch<TDocument, TPath extends string> = DeepGet<TDocument, PathParts<TPath>>
// this is a similar array key to the studio:
// https://github.com/sanity-io/sanity/blob/v3.74.1/packages/sanity/src/core/form/inputs/arrays/ArrayOfObjectsInput/createProtoArrayValue.ts
function generateArrayKey(length: number = 12): string {
// Each byte gives two hex characters, so generate enough bytes.
const numBytes = Math.ceil(length / 2)
const bytes = crypto.getRandomValues(new Uint8Array(numBytes))
// Convert each byte to a 2-digit hex string and join them.
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0'))
.join('')
.slice(0, length)
}
function memoize<TFunction extends (input: unknown) => unknown>(fn: TFunction): TFunction {
const cache = new WeakMap<object, unknown>()
return ((input) => {
if (!input || typeof input !== 'object') return fn(input)
const cached = cache.get(input)
if (cached) return cached
const result = fn(input)
cache.set(input, result)
return result
}) as TFunction
}
/**
* Recursively traverse a value. When an array is encountered, ensure that
* each object item has a _key property. Memoized such that sub-objects that
* have not changed aren't re-computed.
*/
export const ensureArrayKeysDeep = memoize(<R>(input: R): R => {
if (!input || typeof input !== 'object') return input
if (Array.isArray(input)) {
// if the array is empty then just return the input
if (!input.length) return input
const first = input[0]
// if the first input in the array isn't an object (null is allowed) then
// assume that this is an array of primitives, just return the input
if (typeof first !== 'object') return input
// if all the items already have a key, then return the input
if (input.every(isKeyedObject)) return input
// otherwise return a new object item with a new key
return input.map((item: unknown) => {
if (!item || typeof item !== 'object') return item
if (isKeyedObject(item)) return ensureArrayKeysDeep(item)
const next = ensureArrayKeysDeep(item)
return {...next, _key: generateArrayKey()}
}) as R
}
const entries = Object.entries(input).map(([key, value]) => [key, ensureArrayKeysDeep(value)])
if (entries.every(([key, value]) => input[key as keyof typeof input] === value)) {
return input
}
return Object.fromEntries(entries) as R
})
/**
* Given an input object and a record of path expressions to values, this
* function will set each match with the given value.
*
* ```js
* const output = set(
* {name: {first: 'initial', last: 'initial'}},
* {'name.first': 'changed'}
* );
*
* // { name: { first: 'changed', last: 'initial' } }
* console.log(output);
* ```
*/
export function set<R>(input: unknown, pathExpressionValues: Record<string, unknown>): R
export function set(input: unknown, pathExpressionValues: Record<string, unknown>): unknown {
const result = Object.entries(pathExpressionValues)
.flatMap(([pathExpression, replacementValue]) =>
Array.from(jsonMatch(input, pathExpression)).map((matchEntry) => ({
...matchEntry,
replacementValue,
})),
)
.reduce((acc, {path, replacementValue}) => setDeep(acc, path, replacementValue), input)
return ensureArrayKeysDeep(result)
}
/**
* Given an input object and a record of path expressions to values, this
* function will set each match with the given value **if the value at the current
* path is missing** (i.e. `null` or `undefined`).
*
* ```js
* const output = setIfMissing(
* {items: [null, 'initial']},
* {'items[:]': 'changed'}
* );
*
* // { items: ['changed', 'initial'] }
* console.log(output);
* ```
*/
export function setIfMissing<R>(input: unknown, pathExpressionValues: Record<string, unknown>): R
export function setIfMissing(
input: unknown,
pathExpressionValues: Record<string, unknown>,
): unknown {
const result = Object.entries(pathExpressionValues)
.flatMap(([pathExpression, replacementValue]) => {
return Array.from(jsonMatch(input, pathExpression)).map((matchEntry) => ({
...matchEntry,
replacementValue,
}))
})
.filter((matchEntry) => matchEntry.value === null || matchEntry.value === undefined)
.reduce((acc, {path, replacementValue}) => setDeep(acc, path, replacementValue), input)
return ensureArrayKeysDeep(result)
}
/**
* Given an input object and an array of path expressions, this function will
* remove each match from the input object.
*
* ```js
* const output = unset(
* {name: {first: 'one', last: 'two'}},
* ['name.last']
* );
*
* // { name: { first: 'one' } }
* console.log(output);
* ```
*/
export function unset<R>(input: unknown, pathExpressions: string[]): R
export function unset(input: unknown, pathExpressions: string[]): unknown {
const result = pathExpressions
.flatMap((pathExpression) => Array.from(jsonMatch(input, pathExpression)))
// ensure that we remove in the reverse order the paths were found in
// this is necessary for array unsets so the indexes don't change as we unset
.reverse()
.reduce((acc, {path}) => unsetDeep(acc, path), input)
return ensureArrayKeysDeep(result)
}
/**
* Given an input object, a path expression (inside the insert patch object), and an array of items,
* this function will insert or replace the matched items.
*
* **Insert before:**
*
* ```js
* const input = {some: {array: ['a', 'b', 'c']}}
* const output = insert(
* input,
* {
* before: 'some.array[1]',
* items: ['!']
* }
* );
* // { some: { array: ['a', '!', 'b', 'c'] } }
* console.log(output);
* ```
*
* **Insert before with negative index (append):**
*
* ```js
* const input = {some: {array: ['a', 'b', 'c']}}
* const output = insert(
* input,
* {
* before: 'some.array[-1]',
* items: ['!']
* }
* );
* // { some: { array: ['a', 'b', 'c', '!'] } }
* console.log(output);
* ```
*
* **Insert after:**
*
* ```js
* const input = {some: {array: ['a', 'b', 'c']}}
* const output = insert(
* input,
* {
* after: 'some.array[1]',
* items: ['!']
* }
* );
* // { some: { array: ['a', 'b', '!', 'c'] } }
* console.log(output);
* ```
*
* **Replace:**
*
* ```js
* const output = insert(
* { some: { array: ['a', 'b', 'c'] } },
* {
* replace: 'some.array[1]',
* items: ['!']
* }
* );
* // { some: { array: ['a', '!', 'c'] } }
* console.log(output);
* ```
*
* **Replace many:**
*
* ```js
* const input = {some: {array: ['a', 'b', 'c', 'd']}}
* const output = insert(
* input,
* {
* replace: 'some.array[1:3]',
* items: ['!', '?']
* }
* );
* // { some: { array: ['a', '!', '?', 'd'] } }
* console.log(output);
* ```
*/
export function insert<R>(input: unknown, insertPatch: InsertPatch): R
export function insert(input: unknown, {items, ...insertPatch}: InsertPatch): unknown {
let operation
let pathExpression
// behavior observed from content-lake when inserting:
// 1. if the operation is before, out of all the matches, it will use the
// insert the items before the first match that appears in the array
// 2. if the operation is after, it will insert the items after the first
// match that appears in the array
// 3. if the operation is replace, then insert the items before the first
// match and then delete the rest
if ('before' in insertPatch) {
operation = 'before' as const
pathExpression = insertPatch.before
} else if ('after' in insertPatch) {
operation = 'after' as const
pathExpression = insertPatch.after
} else if ('replace' in insertPatch) {
operation = 'replace' as const
pathExpression = insertPatch.replace
}
if (!operation) return input
if (typeof pathExpression !== 'string') return input
// in order to do an insert patch, you need to provide at least one path segment
if (!pathExpression.length) return input
const arrayPath = slicePath(pathExpression, 0, -1)
const positionPath = slicePath(pathExpression, -1)
let result = input
for (const {path, value} of jsonMatch(input, arrayPath)) {
if (!Array.isArray(value)) continue
let arr = value
switch (operation) {
case 'replace': {
const indexesToRemove = new Set<number>()
let position = Infinity
for (const itemMatch of jsonMatch(arr, positionPath)) {
// there should only be one path segment for an insert patch, invalid otherwise
if (itemMatch.path.length !== 1) continue
const [segment] = itemMatch.path
if (typeof segment === 'string') continue
let index
if (typeof segment === 'number') index = segment
if (typeof index === 'number' && index < 0) index = arr.length + index
if (isKeySegment(segment)) index = getIndexForKey(arr, segment._key)
if (typeof index !== 'number') continue
if (index < 0) index = arr.length + index
indexesToRemove.add(index)
if (index < position) position = index
}
if (position === Infinity) continue
// remove all other indexes
arr = arr
.map((item, index) => ({item, index}))
.filter(({index}) => !indexesToRemove.has(index))
.map(({item}) => item)
// insert at the min index
arr = [...arr.slice(0, position), ...items, ...arr.slice(position, arr.length)]
break
}
case 'before': {
let position = Infinity
for (const itemMatch of jsonMatch(arr, positionPath)) {
if (itemMatch.path.length !== 1) continue
const [segment] = itemMatch.path
if (typeof segment === 'string') continue
let index
if (typeof segment === 'number') index = segment
if (typeof index === 'number' && index < 0) index = arr.length + index
if (isKeySegment(segment)) index = getIndexForKey(arr, segment._key)
if (typeof index !== 'number') continue
if (index < 0) index = arr.length - index
if (index < position) position = index
}
if (position === Infinity) continue
arr = [...arr.slice(0, position), ...items, ...arr.slice(position, arr.length)]
break
}
case 'after': {
let position = -Infinity
for (const itemMatch of jsonMatch(arr, positionPath)) {
if (itemMatch.path.length !== 1) continue
const [segment] = itemMatch.path
if (typeof segment === 'string') continue
let index
if (typeof segment === 'number') index = segment
if (typeof index === 'number' && index < 0) index = arr.length + index
if (isKeySegment(segment)) index = getIndexForKey(arr, segment._key)
if (typeof index !== 'number') continue
if (index > position) position = index
}
if (position === -Infinity) continue
arr = [...arr.slice(0, position + 1), ...items, ...arr.slice(position + 1, arr.length)]
break
}
default: {
continue
}
}
result = setDeep(result, path, arr)
}
return ensureArrayKeysDeep(result)
}
/**
* Given an input object and a record of path expressions to numeric values,
* this function will increment each match with the given value.
*
* ```js
* const output = inc(
* {foo: {first: 3, second: 4.5}},
* {'foo.first': 3, 'foo.second': 4}
* );
*
* // { foo: { first: 6, second: 8.5 } }
* console.log(output);
* ```
*/
export function inc<R>(input: unknown, pathExpressionValues: Record<string, number>): R
export function inc(input: unknown, pathExpressionValues: Record<string, number>): unknown {
const result = Object.entries(pathExpressionValues)
.flatMap(([pathExpression, valueToAdd]) =>
Array.from(jsonMatch(input, pathExpression)).map((matchEntry) => ({
...matchEntry,
valueToAdd,
})),
)
.filter(
<T extends {value: unknown}>(matchEntry: T): matchEntry is T & {value: number} =>
typeof matchEntry.value === 'number',
)
.reduce((acc, {path, value, valueToAdd}) => setDeep(acc, path, value + valueToAdd), input)
return ensureArrayKeysDeep(result)
}
/**
* Given an input object and a record of path expressions to numeric values,
* this function will decrement each match with the given value.
*
* ```js
* const output = dec(
* {foo: {first: 3, second: 4.5}},
* {'foo.first': 3, 'foo.second': 4}
* );
*
* // { foo: { first: 0, second: 0.5 } }
* console.log(output);
* ```
*/
export function dec<R>(input: unknown, pathExpressionValues: Record<string, number>): R
export function dec(input: unknown, pathExpressionValues: Record<string, number>): unknown {
const result = inc(
input,
Object.fromEntries(
Object.entries(pathExpressionValues)
.filter(([, value]) => typeof value === 'number')
.map(([key, value]) => [key, -value]),
),
)
return ensureArrayKeysDeep(result)
}
/**
* Given an input object and a record of paths to [diff match patches][0], this
* function will apply the diff match patch for the string at each match.
*
* [0]: https://www.sanity.io/docs/http-patches#aTbJhlAJ
*
* ```js
* const output = diffMatchPatch(
* {foo: 'the quick brown fox'},
* {'foo': '@@ -13,7 +13,7 @@\n own \n-fox\n+cat\n'}
* );
*
* // { foo: 'the quick brown cat' }
* console.log(output);
* ```
*/
export function diffMatchPatch<R>(input: unknown, pathExpressionValues: Record<string, string>): R
export function diffMatchPatch(
input: unknown,
pathExpressionValues: Record<string, string>,
): unknown {
const result = Object.entries(pathExpressionValues)
.flatMap(([pathExpression, dmp]) =>
Array.from(jsonMatch(input, pathExpression)).map((m) => ({...m, dmp})),
)
.filter((i) => i.value !== undefined)
.map(({path, value, dmp}) => {
if (typeof value !== 'string') {
throw new Error(
`Can't diff-match-patch \`${JSON.stringify(value)}\` at path \`${stringifyPath(path)}\`, because it is not a string`,
)
}
const [nextValue] = applyPatches(parsePatch(dmp), value)
return {path, value: nextValue}
})
.reduce((acc, {path, value}) => setDeep(acc, path, value), input)
return ensureArrayKeysDeep(result)
}
/**
* Simply checks if the given document input has a `_rev` that matches the given
* `revisionId` and throws otherwise.
*
* (No code example provided.)
*/
export function ifRevisionID<R>(input: unknown, revisionId: string): R
export function ifRevisionID(input: unknown, revisionId: string): unknown {
const inputRev =
typeof input === 'object' && !!input && '_rev' in input && typeof input._rev === 'string'
? input._rev
: undefined
if (typeof inputRev !== 'string') {
throw new Error(`Patch specified \`ifRevisionID\` but could not find document's revision ID.`)
}
if (revisionId !== inputRev) {
throw new Error(
`Patch's \`ifRevisionID\` \`${revisionId}\` does not match document's revision ID \`${inputRev}\``,
)
}
return input
}
/**
* Gets a value deep inside of an object given a path. If the path does not
* exist in the object, `undefined` will be returned.
*/
export function getDeep<R>(input: unknown, path: SingleValuePath): R
export function getDeep(input: unknown, path: SingleValuePath): unknown {
const [currentSegment, ...restOfPath] = path
if (currentSegment === undefined) return input
if (typeof input !== 'object' || input === null) return undefined
let key
if (isKeySegment(currentSegment)) {
key = getIndexForKey(input, currentSegment._key)
} else if (typeof currentSegment === 'string') {
key = currentSegment
} else if (typeof currentSegment === 'number') {
key = currentSegment
}
if (key === undefined) return undefined
// Use .at() to support negative indexes on arrays.
const nestedInput =
typeof key === 'number' && Array.isArray(input)
? input.at(key)
: (input as Record<string, unknown>)[key]
return getDeep(nestedInput, restOfPath)
}
/**
* Sets a value deep inside of an object given a path. If the path does not
* exist in the object, it will be created.
*/
export function setDeep<R>(input: unknown, path: SingleValuePath, value: unknown): R
export function setDeep(input: unknown, path: SingleValuePath, value: unknown): unknown {
const [currentSegment, ...restOfPath] = path
if (currentSegment === undefined) return value
// If the current input is not an object, create a new container.
if (typeof input !== 'object' || input === null) {
if (typeof currentSegment === 'string') {
return {[currentSegment]: setDeep(null, restOfPath, value)}
}
let index: number | undefined
if (isKeySegment(currentSegment)) {
// When creating a new array via a keyed segment, use index 0.
index = 0
} else if (typeof currentSegment === 'number' && currentSegment >= 0) {
index = currentSegment
} else {
// For negative numbers in a non‐object we simply return input.
return input
}
return [
// fill until index
...Array.from({length: index}).fill(null),
// then set deep here
setDeep(null, restOfPath, value),
]
}
// When input is an array…
if (Array.isArray(input)) {
let index: number | undefined
if (isKeySegment(currentSegment)) {
index = getIndexForKey(input, currentSegment._key) ?? input.length
} else if (typeof currentSegment === 'number') {
// Support negative indexes by computing the proper positive index.
index = currentSegment < 0 ? input.length + currentSegment : currentSegment
}
if (index === undefined) return input
if (index in input) {
// Update the element at the resolved index.
return input.map((nestedInput, i) =>
i === index ? setDeep(nestedInput, restOfPath, value) : nestedInput,
)
}
// Expand the array if needed.
return [
...input,
...Array.from({length: index - input.length}).fill(null),
setDeep(null, restOfPath, value),
]
}
// For keyed segments that aren't arrays, do nothing.
if (typeof currentSegment === 'object') return input
// For plain objects, update an existing property if it exists…
if (currentSegment in input) {
return Object.fromEntries(
Object.entries(input).map(([key, nestedInput]) =>
key === currentSegment
? [key, setDeep(nestedInput, restOfPath, value)]
: [key, nestedInput],
),
)
}
// ...otherwise create the new nested path.
return {...input, [currentSegment]: setDeep(null, restOfPath, value)}
}
/**
* Given an object and an exact path as an array, this unsets the value at the
* given path.
*/
export function unsetDeep<R>(input: unknown, path: SingleValuePath): R
export function unsetDeep(input: unknown, path: SingleValuePath): unknown {
const [currentSegment, ...restOfPath] = path
if (currentSegment === undefined) return input
if (typeof input !== 'object' || input === null) return input
let _segment: string | number | undefined
if (isKeySegment(currentSegment)) {
_segment = getIndexForKey(input, currentSegment._key)
} else if (typeof currentSegment === 'string' || typeof currentSegment === 'number') {
_segment = currentSegment
}
if (_segment === undefined) return input
// For numeric segments in arrays, compute the positive index.
let segment: string | number = _segment
if (typeof segment === 'number' && Array.isArray(input)) {
segment = segment < 0 ? input.length + segment : segment
}
if (!(segment in input)) return input
// If we're at the final segment, remove the property/element.
if (!restOfPath.length) {
if (Array.isArray(input)) {
return input.filter((_nestedInput, index) => index !== segment)
}
return Object.fromEntries(Object.entries(input).filter(([key]) => key !== segment.toString()))
}
// Otherwise, recurse into the nested value.
if (Array.isArray(input)) {
return input.map((nestedInput, index) =>
index === segment ? unsetDeep(nestedInput, restOfPath) : nestedInput,
)
}
return Object.fromEntries(
Object.entries(input).map(([key, value]) =>
key === segment ? [key, unsetDeep(value, restOfPath)] : [key, value],
),
)
}