@sanity/assist
Version:
You create the instructions; Sanity AI Assist does the rest.
223 lines (193 loc) • 6.41 kB
text/typescript
import {
BlockContentIcon,
BlockquoteIcon,
DocumentIcon,
ImageIcon,
LinkIcon,
OlistIcon,
StringIcon,
} from '@sanity/icons'
import {extractWithPath} from '@sanity/mutator'
import {type ComponentType, useContext, useMemo} from 'react'
import {
type ArraySchemaType,
isKeySegment,
isObjectSchemaType,
type ObjectSchemaType,
type Path,
pathToString,
type SanityDocumentLike,
type SchemaType,
stringToPath,
} from 'sanity'
import {type PaneRouterContextValue, usePaneRouter} from 'sanity/structure'
import {SelectedFieldContext} from '../assistDocument/components/SelectedFieldContext'
import {isAssistSupported} from '../helpers/assistSupported'
import {isPortableTextArray, isType} from '../helpers/typeUtils'
import {type AssistInspectorRouteParams, documentRootKey, fieldPathParam} from '../types'
export interface FieldRef {
key: string
path: Path
title: string
schemaType: SchemaType
icon: ComponentType
synthetic?: boolean
}
const maxDepth = 6
export function getTypeIcon(schemaType: SchemaType) {
let t: SchemaType | undefined = schemaType
while (t) {
if (t.icon) return t.icon
t = t.type
}
if (isType(schemaType, 'slug')) return LinkIcon
if (isType(schemaType, 'image')) return ImageIcon
if (schemaType.jsonType === 'array' && isPortableTextArray(schemaType)) return BlockContentIcon
if (schemaType.jsonType === 'array') return OlistIcon
if (schemaType.jsonType === 'object') return BlockquoteIcon
if (schemaType.jsonType === 'string') return StringIcon
return DocumentIcon
}
export function asFieldRefsByTypePath(fieldRefs: FieldRef[]): Record<string, FieldRef | undefined> {
const lookup: Record<string, FieldRef | undefined> = fieldRefs.reduce(
(acc, ref) => ({...acc, [ref.key]: ref}),
{},
)
return lookup
}
export function getFieldRefsWithDocument(schemaType: ObjectSchemaType): FieldRef[] {
const fields = getFieldRefs(schemaType)
return [
{
key: documentRootKey,
icon: schemaType.icon ?? DocumentIcon,
title: `The entire document`,
path: [],
schemaType: schemaType,
},
...fields,
]
}
export function getFieldRefs(
schemaType: ObjectSchemaType,
parent?: FieldRef,
depth = 0,
): FieldRef[] {
if (depth >= maxDepth) {
return []
}
return schemaType.fields
.filter((f) => !f.name.startsWith('_'))
.flatMap((field) => {
const path: Path = parent ? [...parent.path, field.name] : [field.name]
const title = field.type.title ?? field.name
const fieldRef: FieldRef = {
key: patchableKey(pathToString(path)),
path,
title: parent ? [parent.title, title].join(' / ') : title,
schemaType: field.type,
icon: getTypeIcon(field.type),
}
const fields =
field.type.jsonType === 'object' ? getFieldRefs(field.type, fieldRef, depth + 1) : []
const syntheticFields =
field.type.jsonType === 'array' ? getSyntheticFields(field.type, fieldRef, depth + 1) : []
if (!isAssistSupported(field.type)) {
return [...fields, ...syntheticFields]
}
return [fieldRef, ...fields, ...syntheticFields]
})
}
function getSyntheticFields(schemaType: ArraySchemaType, parent?: FieldRef, depth = 0) {
if (depth >= maxDepth) {
return []
}
return schemaType.of
.filter((itemType) => !isType(itemType, 'block'))
.flatMap((itemType) => {
const segment = {_key: itemType.name}
const title = itemType.title ?? itemType.name
const path: Path = parent ? [...parent.path, segment] : [segment]
const fieldRef: FieldRef = {
key: patchableKey(pathToString(path)),
path,
title: parent ? [parent.title, title].join(' / ') : title,
schemaType: itemType,
icon: getTypeIcon(itemType),
synthetic: true,
}
const fields =
itemType.jsonType === 'object' ? getFieldRefs(itemType, fieldRef, depth + 1) : []
if (!isAssistSupported(itemType)) {
return fields
}
return [fieldRef, ...fields]
})
}
export function getTypePath(doc: SanityDocumentLike, pathString: string) {
if (!pathString) {
return undefined
}
const path = stringToPath(pathString)
const currentPath: Path = []
let valid = true
const syntheticPath = path.map((segment) => {
currentPath.push(segment)
if (isKeySegment(segment)) {
const match = extractWithPath(pathToString(currentPath), doc)[0]
const value = match?.value
if (match && value && typeof value === 'object' && '_type' in value) {
return {_key: value._type as string}
}
valid = false
}
return segment
})
return valid ? patchableKey(pathToString(syntheticPath)) : undefined
}
/**
* mutator crashes if path contains certain letters
* @param pathKey
*/
function patchableKey(pathKey: string) {
return pathKey.replace(/[=]=/g, ':').replace(/[[\]]/g, '|').replace(/"/g, '')
}
export function useTypePath(doc: SanityDocumentLike, pathString: string) {
return useMemo(() => getTypePath(doc, pathString), [doc, pathString])
}
export function useSelectedField(
documentSchemaType?: SchemaType,
path?: string,
): FieldRef | undefined {
const selectableFields = useMemo(
() =>
documentSchemaType && isObjectSchemaType(documentSchemaType)
? getFieldRefsWithDocument(documentSchemaType)
: [],
[documentSchemaType],
)
return useMemo(() => {
return path ? selectableFields?.find((f) => f.key === path) : undefined
}, [selectableFields, path])
}
export function useSelectedFieldTitle() {
const {params} = useAiPaneRouter()
const {documentSchema} = useContext(SelectedFieldContext) ?? {}
const selectedField = useSelectedField(documentSchema, params[fieldPathParam])
return getFieldTitle(selectedField)
}
export function getFieldTitle(field?: FieldRef) {
const schemaType = field?.schemaType
return field?.title ?? schemaType?.title ?? schemaType?.name ?? 'Untitled'
}
export type AiPaneRouter = Omit<PaneRouterContextValue, 'setParams' | 'params'> & {
params: AssistInspectorRouteParams
setParams: (p: Record<keyof AssistInspectorRouteParams, string | undefined>) => void
}
export function useAiPaneRouter() {
const paneRouter = usePaneRouter()
return useMemo(
() => ({...paneRouter, params: paneRouter.params ?? {}}) as AiPaneRouter,
[paneRouter],
)
}