sanity
Version:
Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches
195 lines (172 loc) • 6 kB
text/typescript
import {type ResponseQueryOptions} from '@sanity/client'
import {match, type Path} from 'path-to-regexp'
import {useEffect, useRef, useState} from 'react'
import {useClient, usePerspective} from 'sanity'
import {type RouterState, useRouter} from 'sanity/router'
import {useEffectEvent} from 'use-effect-event'
import {API_VERSION} from './constants'
import {
type DocumentResolver,
type DocumentResolverContext,
type MainDocument,
type MainDocumentState,
type PresentationNavigate,
type PreviewUrlOption,
} from './types'
// Helper function to "unwrap" a result when it is either explicitly provided or
// returned as the result of a passed function
function fnOrObj<T, U>(arg: T | ((ctx: U) => T), context: U): T {
return arg instanceof Function ? arg(context) : arg
}
function getQueryFromResult(
resolver: DocumentResolver,
context: DocumentResolverContext,
): string | undefined {
if (resolver.resolve) {
const filter = resolver.resolve(context)?.filter
return filter
? `// groq
*[${filter}][0]{_id, _type}`
: undefined
}
if ('type' in resolver) {
return `// groq
*[_type == "${resolver.type}"][0]{_id, _type}`
}
return `// groq
*[${fnOrObj(resolver.filter, context)}][0]{_id, _type}`
}
function getParamsFromResult(
resolver: DocumentResolver,
context: DocumentResolverContext,
): Record<string, string> {
if (resolver.resolve) {
return resolver.resolve(context)?.params ?? context.params
}
if ('type' in resolver) {
return {}
}
return fnOrObj(resolver.params, context) ?? context.params
}
export function getRouteContext(route: Path, url: URL): DocumentResolverContext | undefined {
const routes = Array.isArray(route) ? route : [route]
for (route of routes) {
let origin: DocumentResolverContext['origin'] = undefined
let path = route
// Handle absolute URLs
if (typeof route === 'string') {
try {
const absolute = new URL(route)
origin = absolute.origin
path = absolute.pathname
} catch {
// Ignore, as we assume a relative path
}
}
// If an origin has been explicitly provided, check that it matches
if (origin && url.origin !== origin) continue
try {
const matcher = match<Record<string, string>>(path, {decode: decodeURIComponent})
const result = matcher(url.pathname)
if (result) {
// eslint-disable-next-line @typescript-eslint/no-shadow
const {params, path} = result
return {origin, params, path}
}
} catch (e) {
throw new Error(`"${route}" is not a valid route pattern`)
}
}
return undefined
}
export function useMainDocument(props: {
navigate?: PresentationNavigate
navigationHistory: RouterState[]
path?: string
previewUrl?: PreviewUrlOption
resolvers?: DocumentResolver[]
}): MainDocumentState | undefined {
const {navigate, navigationHistory, path, previewUrl, resolvers = []} = props
const {state: routerState} = useRouter()
const {perspectiveStack} = usePerspective()
const client = useClient({apiVersion: API_VERSION})
const relativeUrl =
path || routerState._searchParams?.find(([key]) => key === 'preview')?.[1] || ''
const [mainDocumentState, setMainDocumentState] = useState<MainDocumentState | undefined>(
undefined,
)
const mainDocumentIdRef = useRef<string | undefined>(undefined)
const handleResponse = useEffectEvent((doc: MainDocument | undefined, url: URL) => {
if (!doc || mainDocumentIdRef.current !== doc._id) {
setMainDocumentState({
document: doc,
path: url.pathname,
})
mainDocumentIdRef.current = doc?._id
// We only want to force a navigation to the main document if
// the path changed but the document ID did not. An explicit
// document navigation should take precedence over displaying
// the main document. We determine if an explicit document
// navigation has occured by comparing the IDs of the last two
// resultant navigation states.
if (navigationHistory.at(-1)?.id === navigationHistory.at(-2)?.id) {
navigate?.({
id: doc?._id,
type: doc?._type,
})
}
}
})
useEffect(() => {
const base =
// eslint-disable-next-line no-nested-ternary
typeof previewUrl === 'string'
? previewUrl
: typeof previewUrl === 'object'
? previewUrl?.origin || location.origin
: location.origin
const url = new URL(relativeUrl, base)
if (resolvers.length) {
let result:
| {
context: DocumentResolverContext
resolver: DocumentResolver
}
| undefined
for (const resolver of resolvers) {
const context = getRouteContext(resolver.route, url)
if (context) {
result = {context, resolver}
break
}
}
if (result) {
const query = getQueryFromResult(result.resolver, result.context)
const params = getParamsFromResult(result.resolver, result.context)
if (query) {
const controller = new AbortController()
const options: ResponseQueryOptions = {
perspective: perspectiveStack,
signal: controller.signal,
tag: 'use-main-document',
}
client
.fetch<MainDocument | undefined>(query, params, options)
.then((doc) => handleResponse(doc, url))
.catch((e) => {
if (e instanceof Error && e.name === 'AbortError') return
setMainDocumentState({document: undefined, path: url.pathname})
mainDocumentIdRef.current = undefined
})
return () => {
controller.abort()
}
}
}
}
setMainDocumentState(undefined)
mainDocumentIdRef.current = undefined
return undefined
}, [client, previewUrl, relativeUrl, resolvers, perspectiveStack])
return mainDocumentState
}