UNPKG

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
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 }