@sanity/util
Version:
Utilities shared across projects of Sanity
280 lines (231 loc) • 8 kB
text/typescript
import {
type IndexTuple,
isIndexSegment,
isIndexTuple,
isKeySegment,
type KeyedSegment,
type Path,
type PathSegment,
} from '@sanity/types'
const rePropName =
/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g
const reKeySegment = /_key\s*==\s*['"](.*)['"]/
const EMPTY_PATH: Path = []
export const FOCUS_TERMINATOR = '$'
// Fields named as GROQ data types cannot be accessed using dot notation. These fields must instead
// be serialized using square bracket notation.
const GROQ_DATA_TYPE_VALUES = ['true', 'false', 'null']
export function get<R>(obj: unknown, path: Path | string): R | undefined
export function get<R>(obj: unknown, path: Path | string, defaultValue: R): R
export function get(obj: unknown, path: Path | string, defaultVal?: unknown): unknown {
const select = typeof path === 'string' ? fromString(path) : path
if (!Array.isArray(select)) {
throw new Error('Path must be an array or a string')
}
let acc: unknown | undefined = obj
for (let i = 0; i < select.length; i++) {
const segment = select[i]
if (isIndexSegment(segment)) {
if (!Array.isArray(acc)) {
return defaultVal
}
acc = acc[segment]
}
if (isKeySegment(segment)) {
if (!Array.isArray(acc)) {
return defaultVal
}
acc = acc.find((item) => item._key === segment._key)
}
if (typeof segment === 'string') {
acc =
typeof acc === 'object' && acc !== null
? ((acc as Record<string, unknown>)[segment] as Record<string, unknown>)
: undefined
}
if (typeof acc === 'undefined') {
return defaultVal
}
}
return acc
}
const pathsMemo = new Map<string, Path>()
export function pathFor(path: Path): Path {
if (path.length === 0) {
return EMPTY_PATH
}
const asString = toString(path)
if (pathsMemo.has(asString)) {
return pathsMemo.get(asString)!
}
pathsMemo.set(asString, path)
Object.freeze(path)
return path
}
export function isEqual(path: Path, otherPath: Path): boolean {
return (
path.length === otherPath.length &&
path.every((segment, i) => isSegmentEqual(segment, otherPath[i]))
)
}
export function numEqualSegments(path: Path, otherPath: Path): number {
const length = Math.min(path.length, otherPath.length)
for (let i = 0; i < length; i++) {
if (!isSegmentEqual(path[i], otherPath[i])) {
return i
}
}
return length
}
export function isSegmentEqual(segmentA: PathSegment, segmentB: PathSegment): boolean {
if (isKeySegment(segmentA) && isKeySegment(segmentB)) {
return segmentA._key === segmentB._key
}
if (isIndexSegment(segmentA)) {
return Number(segmentA) === Number(segmentB)
}
if (isIndexTuple(segmentA) && isIndexTuple(segmentB)) {
return segmentA[0] === segmentB[0] && segmentA[1] === segmentB[1]
}
return segmentA === segmentB
}
export function hasFocus(focusPath: Path, path: Path): boolean {
const withoutTerminator =
focusPath[focusPath.length - 1] === FOCUS_TERMINATOR ? focusPath.slice(0, -1) : focusPath
return isEqual(withoutTerminator, path)
}
export function hasItemFocus(focusPath: Path, item: PathSegment): boolean {
return focusPath.length === 1 && isSegmentEqual(focusPath[0], item)
}
export function isExpanded(segment: PathSegment, focusPath: Path): boolean {
const [head, ...tail] = focusPath
return tail.length > 0 && isSegmentEqual(segment, head)
}
export function startsWith(prefix: Path, path: Path): boolean {
return prefix.every((segment, i) => isSegmentEqual(segment, path[i]))
}
export function trimLeft(prefix: Path, path: Path): Path {
if (prefix.length === 0 || path.length === 0) {
return path
}
const [prefixHead, ...prefixTail] = prefix
const [pathHead, ...pathTail] = path
if (!isSegmentEqual(prefixHead, pathHead)) {
return path
}
return pathFor(trimLeft(prefixTail, pathTail))
}
export function trimRight(suffix: Path, path: Path): Path {
const sufLen = suffix.length
const pathLen = path.length
if (sufLen === 0 || pathLen === 0) {
return path
}
let i = 0
while (
i < sufLen &&
i < pathLen &&
isSegmentEqual(path[pathLen - i - 1], suffix[sufLen - i - 1])
) {
i++
}
return pathFor(path.slice(0, pathLen - i))
}
export function trimChildPath(path: Path, childPath: Path): Path {
return startsWith(path, childPath) ? trimLeft(path, childPath) : EMPTY_PATH
}
export function toString(path: Path): string {
if (!Array.isArray(path)) {
throw new Error('Path is not an array')
}
return path.reduce<string>((target, segment, i) => {
const isHead = i === 0
if (typeof segment === 'number') {
return `${target}[${segment}]`
}
if (typeof segment === 'string') {
if (isHead) {
return segment
}
if (GROQ_DATA_TYPE_VALUES.includes(segment)) {
return `${target}["${segment}"]`
}
return `${target}.${segment}`
}
if (isKeySegment(segment) && segment._key) {
return `${target}[_key=="${segment._key}"]`
}
if (Array.isArray(segment)) {
const [from, to] = segment
return `${target}[${from}:${to}]`
}
throw new Error(`Unsupported path segment \`${JSON.stringify(segment)}\``)
}, '')
}
export function _resolveKeyedPath(value: unknown, path: Path): Path {
if (path.length === 0) {
return path
}
const [next, ...rest] = path
if (typeof next === 'number') {
if (!Array.isArray(value) || !(next in value)) {
return []
}
const item = value[next]
const key = item?._key
return [typeof key === 'string' ? {_key: item._key} : next, ..._resolveKeyedPath(item, rest)]
}
const nextVal = get(value, [next])
return [next, ..._resolveKeyedPath(nextVal, rest)]
}
/**
* Takes a value and a path that may include numeric indices and attempts to replace numeric indices with keyed paths
*
* @param value - any json value
* @param path - a Path that may include numeric indices
* @returns a path where numeric indices has been replaced by keyed segments (e.g. `{_key: <key>}`)
* Will do as good attempt as possible, but in case of missing array items, it will return the best possible match:
* - `resolveKeyedPath([0, 'bar'], [])` will return [] since array has no value at index 0
* - `resolveKeyedPath([0, 'foo'], [{_key: 'xyz', 'foo': 'bar'}, {_key: 'abc'}])` will return `[{_key: 'xyz'}, 'foo']` since array has no value at index 0
* - `resolveKeyedPath([0, 'foo', 'bar'], [{_key: 'xyz'}])` will return `[{_key: 'xyz'}, 'foo', 'bar']` since array has no value at index 0
* Object keys will be preserved as-is, e.g. `resolveKeyedPath(['foo', 'bar'], undefined)` will return `['foo', 'bar']`
*/
export function resolveKeyedPath(value: unknown, path: Path): Path {
if (!Array.isArray(path)) {
throw new Error('Path is not an array')
}
return pathFor(_resolveKeyedPath(value, path))
}
export function fromString(path: string): Path {
if (typeof path !== 'string') {
throw new Error('Path is not a string')
}
const segments = path.match(rePropName)
if (!segments) {
throw new Error('Invalid path string')
}
return segments.map(normalizePathSegment)
}
function normalizePathSegment(segment: string): PathSegment {
if (isIndexSegment(segment)) {
return normalizeIndexSegment(segment)
}
if (isKeySegment(segment)) {
return normalizeKeySegment(segment)
}
if (isIndexTuple(segment)) {
return normalizeIndexTupleSegment(segment)
}
return segment
}
function normalizeIndexSegment(segment: string): PathSegment {
return Number(segment.replace(/[^\d]/g, ''))
}
function normalizeKeySegment(segment: string): KeyedSegment {
const segments = segment.match(reKeySegment)
return {_key: segments![1]}
}
function normalizeIndexTupleSegment(segment: string): IndexTuple {
const [from, to] = segment.split(':').map((seg) => (seg === '' ? seg : Number(seg)))
return [from, to]
}