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

1 lines • 87 kB
{"version":3,"file":"router.mjs","sources":["../src/router/useRouter.ts","../src/router/useLink.ts","../src/router/useIntentLink.ts","../src/router/IntentLink.tsx","../src/router/Link.tsx","../src/router/_parseRoute.ts","../src/router/utils/arrayify.ts","../src/router/_findMatchingRoutes.ts","../src/router/encodeURIComponentExcept.ts","../src/router/utils/debug.ts","../src/router/_resolvePathFromState.ts","../src/router/utils/parseScopedParams.ts","../src/router/_resolveStateFromPath.ts","../src/router/utils/base64url.ts","../src/router/utils/jsonParamsEncoding.ts","../src/router/utils/paramsEncoding.ts","../src/router/route.ts","../src/router/stickyParams.ts","../src/router/types.ts","../src/router/RouterProvider.tsx","../src/router/RouteScope.tsx","../src/router/useStateLink.ts","../src/router/StateLink.tsx","../src/router/useRouterState.ts","../src/router/withRouter.tsx"],"sourcesContent":["import {useContext} from 'react'\nimport {RouterContext} from 'sanity/_singletons'\n\nimport {type RouterContextValue} from './types'\n\n/**\n * Returns the router context value.\n * @public\n *\n * @returns The router context value.\n * {@link RouterContextValue}\n * @throws An error if the router context value is missing.\n *\n * @example\n * ```tsx\n * const router = useRouter()\n * ```\n */\nexport function useRouter(): RouterContextValue {\n const router = useContext(RouterContext)\n\n if (!router) {\n throw new Error('Router: missing context value')\n }\n\n return router\n}\n","import {useCallback} from 'react'\n\nimport {useRouter} from './useRouter'\n\nfunction isLeftClickEvent(event: React.MouseEvent) {\n return event.button === 0\n}\n\nfunction isModifiedEvent(event: React.MouseEvent) {\n return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)\n}\n\n/**\n * @public\n */\nexport interface UseLinkOptions {\n /**\n * The URL that the link should navigate to.\n */\n href?: string\n\n /**\n * The event handler function that should be called when the link is clicked.\n */\n onClick?: React.MouseEventHandler<HTMLElement>\n\n /**\n * Whether the link should replace the current URL in the browser history.\n */\n replace?: boolean\n\n /**\n * The target window or frame that the linked document will open in.\n */\n target?: string\n}\n\n/**\n * Returns an object with an `onClick` function that can be used as a click handler for a link.\n *\n * @public\n *\n * @param options - An object containing the properties for the link.\n * See {@link UseLinkOptions}\n *\n * @returns An object with an `onClick` function.\n *\n * @example\n * ```tsx\n * const linkProps = useLink({\n * href: 'https://www.sanity.io',\n * target: '_blank'\n * })\n *\n * <a {...linkProps}>Link</a>\n * ```\n */\nexport function useLink(options: UseLinkOptions): {onClick: React.MouseEventHandler<HTMLElement>} {\n const {onClick: onClickProp, href, target, replace = false} = options\n const {navigateUrl} = useRouter()\n\n const onClick = useCallback(\n (event: React.MouseEvent<HTMLElement>): void => {\n if (event.isDefaultPrevented()) {\n return\n }\n\n if (!href) return\n\n if (onClickProp) {\n onClickProp(event)\n }\n\n if (isModifiedEvent(event) || !isLeftClickEvent(event)) {\n return\n }\n\n // If target prop is set (e.g. to \"_blank\") let browser handle link.\n if (target) {\n return\n }\n\n event.preventDefault()\n\n navigateUrl({path: href, replace})\n },\n [href, navigateUrl, onClickProp, replace, target],\n )\n\n return {onClick: onClick}\n}\n","import {useMemo} from 'react'\n\nimport {type IntentParameters, type SearchParam} from './types'\nimport {useLink} from './useLink'\nimport {useRouter} from './useRouter'\n\n/**\n * @public\n */\nexport interface UseIntentLinkOptions {\n /**\n * The name of the intent to trigger.\n */\n intent: string\n\n /**\n * An optional click event handler.\n */\n onClick?: React.MouseEventHandler<HTMLElement>\n\n /**\n * Optional parameters to pass to the intent. See {@link IntentParameters}\n */\n params?: IntentParameters\n\n /**\n * Whether to replace the current URL in the browser history.\n */\n replace?: boolean\n\n /**\n * The target window or frame to open the link in.\n */\n target?: string\n searchParams?: SearchParam[]\n}\n\n/**\n *\n * Returns props for an anchor element that will trigger an intent when clicked.\n *\n * @example\n * ```tsx\n * const {onClick, href} = useIntentLink({\n * intent: 'edit',\n * params: {id: 'foo'}\n * })\n *\n * <a href={href} onClick={onClick}>Link to \"foo\" editor</a>\n * ```\n *\n * @public\n *\n * @param options - Options to use for the link\n * {@link UseIntentLinkOptions}\n *\n * @returns - An object with `onClick` and `href` props to use for the link\n */\nexport function useIntentLink(options: UseIntentLinkOptions): {\n onClick: React.MouseEventHandler<HTMLElement>\n href: string\n} {\n const {intent, onClick: onClickProp, params, replace, target, searchParams} = options\n const {resolveIntentLink} = useRouter()\n const href = useMemo(\n () => resolveIntentLink(intent, params, searchParams),\n [intent, params, searchParams, resolveIntentLink],\n )\n const {onClick} = useLink({href, onClick: onClickProp, replace, target})\n\n return {onClick, href}\n}\n","import {type ForwardedRef, forwardRef, type HTMLProps} from 'react'\n\nimport {type IntentParameters, type SearchParam} from './types'\nimport {useIntentLink} from './useIntentLink'\n\n/**\n * Props for the {@link IntentLink} component.\n *\n * @public\n */\nexport interface IntentLinkProps {\n /**\n * The name of the intent.\n */\n intent: string\n\n /**\n * The parameters to include in the intent.\n * {@link IntentParameters}\n */\n params?: IntentParameters\n\n /**\n * Whether to replace the current URL in the browser history instead of adding a new entry.\n */\n replace?: boolean\n\n /**\n * search params to include in the intent.\n */\n searchParams?: SearchParam[]\n}\n\n/**\n * @public\n *\n * @param props - Props to pass to `IntentLink` component.\n * See {@link IntentLinkProps}\n *\n * @example\n * ```tsx\n * function MyComponent() {\n * return <IntentLink intent=\"edit\" params={{id: 'abc123'}}>Edit</IntentLink>\n * }\n * ```\n */\nexport const IntentLink = forwardRef(function IntentLink(\n props: IntentLinkProps & HTMLProps<HTMLAnchorElement>,\n ref: ForwardedRef<HTMLAnchorElement>,\n) {\n const {intent, params, target, searchParams, ...restProps} = props\n const {onClick, href} = useIntentLink({\n intent,\n params,\n target,\n onClick: props.onClick,\n searchParams,\n })\n\n return <a {...restProps} href={href} onClick={onClick} ref={ref} target={target} />\n})\n","import {type ForwardedRef, forwardRef, type HTMLProps} from 'react'\n\nimport {useLink} from './useLink'\n\n/**\n * Props for the {@link Link} component.\n *\n * @public\n */\nexport interface LinkProps {\n /**\n * Whether to replace the current URL in the browser history instead of adding a new entry.\n */\n replace?: boolean\n}\n\n/**\n * A component that creates an HTML anchor element.\n *\n * @public\n *\n * @param props - Props to pass to the `Link` component.\n * See {@link LinkProps}\n *\n * @example\n * ```tsx\n * function MyComponent() {\n * return (\n * <Link href=\"https://www.sanity.io\" target=\"_blank\" replace>\n * Go to Sanity\n * </Link>\n * )\n * }\n * ```\n */\nexport const Link = forwardRef(function Link(\n props: LinkProps & HTMLProps<HTMLAnchorElement>,\n ref: ForwardedRef<HTMLAnchorElement>,\n) {\n const {onClick: onClickProp, href, target, replace, ...restProps} = props\n const {onClick} = useLink({onClick: onClickProp, href, target, replace})\n\n return <a {...restProps} onClick={onClick} href={href} target={target} ref={ref} />\n})\n","import {type Route, type RouteSegment} from './types'\n\nconst VALID_PARAM_SEGMENT = /^[a-zA-Z0-9_-]+$/\n\nfunction createSegment(segment: string): RouteSegment | null {\n if (!segment) {\n return null\n }\n\n if (segment.startsWith(':')) {\n const paramName = segment.slice(1)\n\n if (!VALID_PARAM_SEGMENT.test(paramName)) {\n const addendum = segment.includes('*')\n ? ' Splats are not supported. Consider using child routes instead'\n : ''\n console.error(\n new Error(`Warning: Param segments \"${segment}\" includes invalid characters.${addendum}`),\n )\n }\n\n return {type: 'param', name: paramName}\n }\n\n return {type: 'dir', name: segment}\n}\n\n/** @internal */\nexport function _parseRoute(route: string): Route {\n const [pathname] = route.split('?')\n\n const segments = pathname.split('/').map(createSegment).filter(Boolean) as RouteSegment[]\n\n return {\n raw: route,\n segments: segments,\n }\n}\n","export function arrayify<T>(val: Array<T> | T): Array<T> {\n if (Array.isArray(val)) {\n return val\n }\n\n return val ? [val] : []\n}\n","import {difference, intersection, isPlainObject, pick} from 'lodash'\n\nimport {\n type InternalSearchParam,\n type MatchError,\n type MatchOk,\n type MatchResult,\n type RouterNode,\n type RouterState,\n} from './types'\nimport {arrayify} from './utils/arrayify'\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return isPlainObject(value)\n}\n\nfunction createMatchError(\n node: RouterNode,\n missingKeys: string[],\n unmappableStateKeys: string[],\n): MatchError {\n return {type: 'error', node, missingKeys, unmappableStateKeys}\n}\n\nfunction createMatchOk(\n node: RouterNode,\n matchedState: Record<string, string>,\n searchParams: InternalSearchParam[],\n child?: MatchOk | undefined,\n): MatchOk {\n return {type: 'ok', node, matchedState, searchParams, child}\n}\n\n/** @internal */\nexport function _findMatchingRoutes(node: RouterNode, _state?: RouterState): MatchResult {\n if (!_state) {\n return createMatchOk(node, {}, [])\n }\n\n const scopedState = node.scope ? (_state[node.scope] as RouterState) : _state\n\n const {_searchParams: searchParams = [], ...state} = scopedState || {}\n\n const requiredParams = node.route.segments\n .filter((seg) => seg.type === 'param')\n .map((seg) => seg.name)\n\n const stateKeys = isRecord(state) ? Object.keys(state) : []\n\n // These are params found in both the state and the route definition\n const consumedParams = intersection(stateKeys, requiredParams)\n\n // these are params found in the route definition but not in the state, can't map them to a route\n const missingParams = difference(requiredParams, consumedParams)\n\n // these are params found in the state but not in the route definition\n const remainingParams = difference(stateKeys, consumedParams)\n\n if (missingParams.length > 0) {\n return createMatchError(node, missingParams, [])\n }\n\n const scopedParams = searchParams.map(([key, value]): InternalSearchParam => [[key], value])\n\n const consumedState = pick(state, consumedParams) as Record<string, string>\n\n if (remainingParams.length === 0) {\n return createMatchOk(node, consumedState, scopedParams)\n }\n\n const children = arrayify(\n (typeof node.children === 'function'\n ? node.children(isRecord(state) ? state : {})\n : node.children) || [],\n )\n\n if (remainingParams.length > 0 && children.length === 0) {\n // our state includes extra keys that's not consumed by child routes\n return createMatchError(node, [], remainingParams)\n }\n\n const remainingState = pick(state, remainingParams)\n\n const childResult = children.map((childNode) => _findMatchingRoutes(childNode, remainingState))\n\n // Look for a matching route\n const found = childResult.find((res): res is MatchOk => res.type === 'ok')\n return found\n ? createMatchOk(node, consumedState, scopedParams, found)\n : createMatchError(node, [], remainingParams)\n}\n","/**\n * Like encodeURIComponent, but supports a custom set of unescaped characters.\n * @param uriComponent - A value representing an unencoded URI component.\n * @param unescaped - a string containing characters to not escape\n */\nexport function encodeURIComponentExcept(\n uriComponent: string | number | boolean,\n unescaped: string,\n): string {\n const chars = [...String(uriComponent)]\n let res = ''\n for (let i = 0; i < chars.length; i++) {\n const char = chars[i]\n if (unescaped.includes(char)) {\n res += char\n } else {\n res += encodeURIComponent(char)\n }\n }\n return res\n}\n","import Debug from 'debug'\n\nexport const debug = Debug('state-router')\n","import {_findMatchingRoutes} from './_findMatchingRoutes'\nimport {encodeURIComponentExcept} from './encodeURIComponentExcept'\nimport {type InternalSearchParam, type MatchOk, type RouterNode, type RouterState} from './types'\nimport {debug} from './utils/debug'\n\n/** @internal */\nexport function _resolvePathFromState(node: RouterNode, _state: RouterState): string {\n debug('Resolving path from state %o', _state)\n\n const match = _findMatchingRoutes(node, _state)\n if (match.type === 'error') {\n const unmappable = match.unmappableStateKeys\n if (unmappable.length > 0) {\n throw new Error(\n `Unable to find matching route for state. Could not map the following state key${\n unmappable.length == 1 ? '' : 's'\n } to a valid url: ${unmappable.map(quote).join(', ')}`,\n )\n }\n const missingKeys = match.missingKeys\n throw new Error(\n `Unable to find matching route for state. State object is missing the following key${\n missingKeys.length == 1 ? '' : 's'\n } defined in route: ${missingKeys.map(quote).join(', ')}`,\n )\n }\n\n const {path, searchParams} = pathFromMatchResult(match)\n\n const search = searchParams.length > 0 ? encodeParams(searchParams) : ''\n\n return `/${path.join('/')}${search ? `?${search}` : ''}`\n}\n\nfunction bracketify(value: string): string {\n return `[${value}]`\n}\n\nfunction encodeParams(params: InternalSearchParam[]): string {\n return params\n .flatMap(([key, value]) => {\n if (value === undefined) {\n return []\n }\n return [encodeSearchParamKey(serializeScopedPath(key)), encodeSearchParamValue(value)].join(\n '=',\n )\n })\n .join('&')\n}\n\nfunction serializeScopedPath(scopedPath: string[]): string {\n const [head, ...tail] = scopedPath\n\n return tail.length > 0 ? [head, ...tail.map(bracketify)].join('') : head\n}\n\nfunction encodeSearchParamValue(value: string): string {\n return encodeURIComponentExcept(value, '/')\n}\n\nfunction encodeSearchParamKey(value: string): string {\n return encodeURIComponentExcept(value, '[]')\n}\n\nfunction pathFromMatchResult(match: MatchOk): {\n path: string[]\n searchParams: InternalSearchParam[]\n} {\n const matchedState = match.matchedState\n\n const base = match.node.route.segments.map((segment) => {\n if (segment.type === 'dir') {\n return segment.name\n }\n\n const transform = match.node.transform && match.node.transform[segment.name]\n\n return transform\n ? transform.toPath(matchedState[segment.name] as any)\n : matchedState[segment.name]\n })\n\n const childMatch = match.child ? pathFromMatchResult(match.child) : undefined\n\n const searchParams = childMatch?.searchParams\n ? [...match.searchParams, ...childMatch.searchParams]\n : match.searchParams\n\n return {\n searchParams: addNodeScope(match.node, searchParams),\n path: [...(base || []), ...(childMatch?.path || [])],\n }\n}\n\nfunction addNodeScope(\n node: RouterNode,\n searchParams: InternalSearchParam[],\n): InternalSearchParam[] {\n const scope = node.scope\n return scope && !node.__unsafe_disableScopedSearchParams\n ? searchParams.map(([namespaces, value]) => [[scope, ...namespaces], value])\n : searchParams\n}\n\nfunction quote(value: string): string {\n return `\"${value}\"`\n}\n","import {type InternalSearchParam} from '../types'\n\nexport function parseScopedParams(params: [key: string, value: string][]): InternalSearchParam[] {\n return params.map(([key, value]) => [parse(key), value])\n}\n\nconst OPEN = 1\nconst CLOSED = 0\n\nfunction parse(str: string) {\n const result = []\n let i = 0\n let state = CLOSED\n while (i < str.length) {\n const nextBracketIdx = str.indexOf('[', i)\n if (nextBracketIdx === -1) {\n result.push(str.slice(i, str.length))\n break\n }\n if (state === OPEN) {\n throw new Error('Nested brackets not supported')\n }\n state = OPEN\n if (nextBracketIdx > i) {\n result.push(str.slice(i, nextBracketIdx))\n i = nextBracketIdx\n }\n\n const nextClosing = str.indexOf(']', nextBracketIdx)\n if (nextClosing === -1) {\n if (state === OPEN) {\n throw new Error('Unclosed bracket')\n }\n break\n }\n state = CLOSED\n result.push(str.slice(i + 1, nextClosing))\n i = nextClosing + 1\n }\n return result\n}\n","import {\n type InternalSearchParam,\n type RouterNode,\n type RouterState,\n type SearchParam,\n} from './types'\nimport {arrayify} from './utils/arrayify'\nimport {debug} from './utils/debug'\nimport {parseScopedParams} from './utils/parseScopedParams'\n\nfunction matchPath(\n node: RouterNode,\n path: string,\n searchParams: InternalSearchParam[],\n): RouterState | null {\n const parts = path.split('/').filter(Boolean)\n const segmentsLength = node.route.segments.length\n\n if (parts.length < segmentsLength) {\n return null\n }\n\n const state: RouterState = {}\n const isMatching = node.route.segments.every((segment, i) => {\n if (segment.type === 'dir') {\n return segment.name === parts[i]\n }\n\n const transform = node.transform && node.transform[segment.name]\n\n state[segment.name] = transform ? transform.toState(parts[i]) : parts[i]\n\n return true\n })\n\n if (!isMatching) {\n return null\n }\n\n const rest = parts.slice(segmentsLength)\n\n let childState: RouterState | null = null\n\n const children =\n typeof node.children === 'function' ? arrayify(node.children(state)) : node.children\n\n const unscopedParams = removeScope(node.scope, searchParams)\n\n children.some((childNode) => {\n if (childNode) {\n const childParams = childNode.scope\n ? unscopedParams.filter(([namespaces]) => childNode.scope === namespaces[0])\n : unscopedParams\n\n childState = matchPath(childNode, rest.join('/'), childParams)\n return childState\n }\n return undefined\n })\n\n if (rest.length > 0 && !childState) {\n return null\n }\n\n const selfParams = unscopedParams.flatMap(([namespace, value]): SearchParam[] => {\n return namespace.length === 1 ? [[namespace[0], value]] : []\n })\n\n const mergedState: RouterState = {\n ...state,\n ...(childState === null ? {} : childState),\n ...(selfParams.length > 0 ? {_searchParams: selfParams} : {}),\n }\n\n return node.scope ? {[node.scope]: mergedState} : mergedState\n}\n\n/**\n * @internal\n */\nexport function _resolveStateFromPath(node: RouterNode, path: string): Record<string, any> | null {\n debug('resolving state from path %s', path)\n\n const [pathname, search] = path.split('?')\n const urlSearchParams = Array.from(new URLSearchParams(search).entries())\n\n const pathMatch = matchPath(node, pathname, parseScopedParams(urlSearchParams))\n\n debug('resolved: %o', pathMatch || null)\n\n return pathMatch || null\n}\n\nfunction removeScope(\n scope: string | undefined,\n searchParams: InternalSearchParam[],\n): InternalSearchParam[] {\n return scope\n ? searchParams.map(([namespaces, value]) => [\n namespaces[0] === scope ? namespaces.slice(1) : namespaces,\n value,\n ])\n : searchParams\n}\n","/**\n * `atob()` and `btoa()` do not support Unicode characters outside of the Latin1 range,\n * but we obviously want to support the full range of Unicode characters in our router.\n *\n * Additionally, we would prefer not to use characters like `+` and `=` in URLs, as they\n * have specific meanings there and may be misinterpreted. Thus, this uses base64url instead\n * of the more common base64.\n */\n\n/**\n * Encodes a string as base64url\n *\n * @param str - String to encode\n * @returns Encoded string\n * @internal\n */\nexport function encodeBase64Url(str: string): string {\n return encodeBase64(str).replace(/\\//g, '_').replace(/\\+/g, '-').replace(/[=]+$/, '')\n}\n\n/**\n * Decodes a base64url-encoded string\n *\n * @param str - String to decode\n * @returns Decoded string\n * @internal\n */\nexport function decodeBase64Url(str: string): string {\n return decodeBase64(str.replace(/-/g, '+').replace(/_/g, '/'))\n}\n\nfunction percentToByte(p: string) {\n return String.fromCharCode(parseInt(p.slice(1), 16))\n}\n\nfunction encodeBase64(str: string): string {\n return btoa(encodeURIComponent(str).replace(/%[0-9A-F]{2}/g, percentToByte))\n}\n\nfunction byteToPercent(b: string) {\n return `%${`00${b.charCodeAt(0).toString(16)}`.slice(-2)}`\n}\n\nfunction decodeBase64(str: string): string {\n return decodeURIComponent(Array.from(atob(str), byteToPercent).join(''))\n}\n","import {decodeBase64Url, encodeBase64Url} from './base64url'\n\n/**\n * Decode a path segment containing JSON parameters\n *\n * @param pathSegment - The path segment to decode\n * @returns The decoded parameters\n * @internal\n * @hidden\n */\nexport function decodeJsonParams(pathSegment = ''): any {\n const segment = decodeURIComponent(pathSegment)\n\n if (!segment) {\n return {}\n }\n\n // Because of high-unicode characters (eg outside of the latin1 range), we prefer base64url\n // since it also removes characters we'd rather not put in our URLs (eg '=' and '/')\n try {\n return JSON.parse(decodeBase64Url(segment))\n } catch (err) {\n // Fall-through: previously we used plain base64 encoding instead of base64url\n }\n\n try {\n return JSON.parse(atob(segment))\n } catch (err) {\n // Fall-through: before _that_, we used plain URI encoding\n }\n\n try {\n return JSON.parse(segment)\n } catch (err) {\n console.warn('Failed to parse JSON parameters')\n }\n\n return {}\n}\n\n/**\n * Encodes a set of parameters as a path segment, using base64url\n *\n * @param params - Paramters to encode\n * @returns The encoded parameters as a path segment\n * @internal\n * @hidden\n */\nexport function encodeJsonParams(params?: any): string {\n return params ? encodeBase64Url(JSON.stringify(params)) : ''\n}\n","export function decodeParams(pathSegment: string): Record<string, string> {\n return pathSegment.split(';').reduce<Record<string, string>>((params, pair) => {\n const [key, value] = pair.split('=')\n\n params[decodeURIComponent(key)] = decodeURIComponent(value)\n\n return params\n }, {})\n}\n\nexport function encodeParams(params: Record<string, string | undefined | null>): string {\n return Object.entries(params)\n .filter(([, value]) => value !== undefined && value !== null)\n .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value as string)}`)\n .join(';')\n}\n","import {_parseRoute} from './_parseRoute'\nimport {_resolvePathFromState} from './_resolvePathFromState'\nimport {_resolveStateFromPath} from './_resolveStateFromPath'\nimport {type RouteChildren, type Router, type RouteTransform} from './types'\nimport {decodeJsonParams, encodeJsonParams} from './utils/jsonParamsEncoding'\nimport {decodeParams, encodeParams} from './utils/paramsEncoding'\n\n/**\n * @public\n */\nexport interface RouteNodeOptions {\n /**\n * The path of the route node.\n */\n path?: string\n /**\n * The children of the route node. See {@link RouteChildren}\n */\n children?: RouteChildren\n /**\n * The transforms to apply to the route node. See {@link RouteTransform}\n */\n transform?: {\n [key: string]: RouteTransform<any>\n }\n /**\n * The scope of the route node.\n */\n scope?: string\n\n /**\n * Optionally disable scoping of search params\n * Scoped search params will be represented as scope[param]=value in the url\n * Disabling this will still scope search params based on any parent scope unless the parent scope also has disabled search params scoping\n * Caution: enabling this can cause conflicts with multiple plugins defining search params with the same name\n */\n __unsafe_disableScopedSearchParams?: boolean\n}\n\n/**\n * Interface for the {@link route} object.\n *\n * @public\n */\nexport interface RouteObject {\n /**\n * Creates a new router.\n * Returns {@link Router}\n * See {@link RouteNodeOptions} and {@link RouteChildren}\n */\n create: (\n routeOrOpts: RouteNodeOptions | string,\n childrenOrOpts?: RouteNodeOptions | RouteChildren | null,\n children?: Router | RouteChildren,\n ) => Router\n\n /**\n * Creates a new router for handling intents.\n * Returns {@link Router}\n */\n intents: (base: string) => Router\n\n /**\n * Creates a new router scope.\n * Returns {@link Router}\n */\n scope(\n scopeName: string,\n routeOrOpts: RouteNodeOptions | string,\n childrenOrOpts?: RouteNodeOptions | RouteChildren | null,\n children?: Router | RouteChildren,\n ): Router\n}\n\n/**\n * An object containing functions for creating routers and router scopes.\n * See {@link RouteObject}\n *\n * @public\n *\n * @example\n * ```ts\n * const router = route.create({\n * path: \"/foo\",\n * children: [\n * route.create({\n * path: \"/bar\",\n * children: [\n * route.create({\n * path: \"/:baz\",\n * transform: {\n * baz: {\n * toState: (id) => ({ id }),\n * toPath: (state) => state.id,\n * },\n * },\n * }),\n * ],\n * }),\n * ],\n * });\n * ```\n */\nexport const route: RouteObject = {\n create: (routeOrOpts, childrenOrOpts, children) =>\n _createNode(normalizeArgs(routeOrOpts, childrenOrOpts, children)),\n intents: (base: string) => {\n const basePath = normalize(base).join('/')\n\n return route.create(`${basePath}/:intent`, [\n route.create(\n ':params',\n {\n transform: {\n params: {\n toState: decodeParams,\n toPath: encodeParams,\n },\n },\n },\n [\n route.create(':payload', {\n transform: {\n payload: {\n toState: decodeJsonParams,\n toPath: encodeJsonParams,\n },\n },\n }),\n ],\n ),\n ])\n },\n scope(\n scopeName: string,\n routeOrOpts: RouteNodeOptions | string,\n childrenOrOpts?: RouteNodeOptions | RouteChildren | null,\n children?: Router | RouteChildren,\n ) {\n const options = normalizeArgs(routeOrOpts, childrenOrOpts, children)\n\n return _createNode({\n ...options,\n scope: scopeName,\n })\n },\n}\n\nfunction normalizeChildren(children: any): RouteChildren {\n if (Array.isArray(children) || typeof children === 'function') {\n return children\n }\n return children ? [children] : []\n}\n\nfunction isRoute(val?: RouteNodeOptions | Router | RouteChildren) {\n return val && '_isRoute' in val\n}\n\nfunction normalizeArgs(...args: any[]): RouteNodeOptions\nfunction normalizeArgs(\n path: string | RouteNodeOptions,\n childrenOrOpts?: RouteNodeOptions | Router | RouteChildren,\n children?: Router | RouteChildren,\n): RouteNodeOptions {\n if (typeof path === 'object') {\n return path\n }\n\n if (\n Array.isArray(childrenOrOpts) ||\n typeof childrenOrOpts === 'function' ||\n isRoute(childrenOrOpts)\n ) {\n return {path, children: normalizeChildren(childrenOrOpts)}\n }\n\n if (children) {\n return {path, ...childrenOrOpts, children: normalizeChildren(children)}\n }\n\n return {path, ...childrenOrOpts}\n}\n\nfunction normalize(...paths: string[]) {\n return paths.reduce<string[]>((acc, path) => acc.concat(path.split('/')), []).filter(Boolean)\n}\n\nconst EMPTY_STATE = {}\n\nfunction isRoot(pathname: string): boolean {\n // it is the root if every segment is an empty string\n return pathname.split('/').every((segment) => !segment)\n}\n\n/**\n * @internal\n * @param options - Route node options\n */\nexport function _createNode(options: RouteNodeOptions): Router {\n // eslint-disable-next-line camelcase\n const {path, scope, transform, children, __unsafe_disableScopedSearchParams} = options\n\n if (!path) {\n throw new TypeError('Missing path')\n }\n\n const parsedRoute = _parseRoute(path)\n\n return {\n _isRoute: true, // todo: make a Router class instead\n scope,\n // eslint-disable-next-line camelcase\n __unsafe_disableScopedSearchParams,\n route: parsedRoute,\n children: children || [],\n transform,\n encode(state) {\n return _resolvePathFromState(this, state)\n },\n decode(_path) {\n return _resolveStateFromPath(this, _path)\n },\n isRoot: isRoot,\n isNotFound(pathname: string): boolean {\n return this.decode(pathname) === null\n },\n getBasePath(): string {\n return this.encode(EMPTY_STATE)\n },\n getRedirectBase(pathname: string): string | null {\n if (isRoot(pathname)) {\n const basePath = this.getBasePath()\n // Check if basepath is something different than given\n if (pathname !== basePath) {\n return basePath\n }\n }\n return null\n },\n }\n}\n","/**\n * @internal\n */\nexport const STICKY_PARAMS: string[] = ['perspective', 'excludedPerspectives']\n","/**\n * @public\n */\nexport interface RouteSegment {\n /**\n * The name of the segment.\n */\n name: string\n /**\n * The type of the segment.\n * Can be either \"dir\" or \"param\".\n */\n type: 'dir' | 'param'\n}\n\n/**\n * @public\n */\nexport interface RouteTransform<T> {\n /**\n * Converts a path string to a state object.\n */\n toState: (value: string) => T\n\n /**\n * Converts a state object to a path string.\n */\n toPath: (value: T) => string\n}\n\n/**\n * @public\n */\nexport interface Route {\n /**\n * The raw string representation of the route.\n */\n raw: string\n /**\n * An array of route segments that make up the route.\n * See {@link RouteSegment}\n */\n segments: RouteSegment[]\n /**\n * An optional object containing route transforms.\n * See {@link RouteTransform} and {@link RouterState}\n */\n transform?: {\n [key: string]: RouteTransform<RouterState>\n }\n}\n\n/**\n * @public\n */\nexport type RouteChildren =\n | RouterNode[]\n | ((state: RouterState) => Router | RouterNode | RouterNode[] | undefined | false)\n\n/**\n * @public\n */\nexport interface RouterNode {\n /**\n * The route information for this node. See {@link Route}\n */\n route: Route\n /**\n * An optional scope for this node.\n */\n scope?: string\n\n /**\n * Optionally disable scoping of search params\n * Scoped search params will be represented as scope[param]=value in the url\n * Disabling this will still scope search params based on any parent scope unless the parent scope also has disabled search params scoping\n * Caution: enabling this can cause conflicts with multiple plugins defining search params with the same name\n */\n __unsafe_disableScopedSearchParams?: boolean\n\n /**\n * An optional object containing transforms to apply to this node.\n * See {@link RouteTransform} and {@link RouterState}\n */\n transform?: {\n [key: string]: RouteTransform<RouterState>\n }\n /**\n * The child nodes of this node. See {@link RouteChildren}\n */\n children: RouteChildren\n}\n\n/**\n * @public\n */\nexport interface Router extends RouterNode {\n /**\n * Indicates whether this router is a route.\n * @internal\n */\n _isRoute: boolean\n /**\n * Encodes the specified router state into a path string.\n * See {@link RouterState}\n *\n */\n encode: (state: RouterState) => string\n\n /**\n * Decodes the specified path string into a router state.\n * See {@link RouterState}\n */\n decode: (path: string) => RouterState | null\n\n /**\n * Determines whether the specified path is not found.\n */\n isNotFound: (path: string) => boolean\n\n /**\n * Gets the base path of this router.\n */\n getBasePath: () => string\n\n /**\n * Gets the redirect base of this router.\n */\n getRedirectBase: (pathname: string) => string | null\n\n /**\n * Determines whether the specified path is the root path.\n */\n isRoot: (path: string) => boolean\n}\n\n/** @internal */\nexport type InternalSearchParam = [scopedPath: string[], value: string]\n\n/** @internal */\nexport interface MatchOk {\n type: 'ok'\n node: RouterNode\n matchedState: Record<string, string>\n searchParams: InternalSearchParam[]\n child: MatchOk | undefined\n}\n\n/** @internal */\nexport interface MatchError {\n type: 'error'\n node: RouterNode\n /**\n * Parameters found in the route string but not provided as a key in the state object\n */\n missingKeys: string[]\n /**\n * These are keys found in the state object but not in the route definition (and can't be mapped to a child route)\n */\n unmappableStateKeys: string[]\n}\n/** @internal */\nexport type MatchResult = MatchError | MatchOk\n\n/**\n * @public\n */\nexport interface NavigateBaseOptions {\n replace?: boolean\n}\n\n/**\n * @public\n */\nexport interface NavigateOptions extends NavigateBaseOptions {\n stickyParams?: Record<string, string | undefined | null>\n}\n\n/**\n * @public\n */\nexport interface NavigateOptionsWithState extends NavigateOptions {\n state?: RouterState | null\n}\n\n/**\n * @public\n */\nexport interface RouterContextValue {\n /**\n * Resolves the path from the given router state. See {@link RouterState}\n *\n * When state is null, it will resolve the path from the current state\n * and navigate to the root path.\n */\n resolvePathFromState: (state: RouterState | null) => string\n\n /**\n * Resolves the intent link for the given intent name and parameters.\n * See {@link IntentParameters}\n */\n resolveIntentLink: (\n intentName: string,\n params?: IntentParameters,\n searchParams?: SearchParam[],\n ) => string\n\n /**\n * Navigates to the given URL.\n * The function requires an object that has a path and an optional replace property.\n */\n navigateUrl: (opts: {path: string; replace?: boolean}) => void\n\n /**\n * @deprecated Use `navigate({stickyParams: params, ...options})` instead\n */\n navigateStickyParams: (\n params: NavigateOptions['stickyParams'],\n options?: NavigateBaseOptions,\n ) => void\n\n /**\n * Updates the router state and navigates to a new path.\n * Allows specifying new state values and optionally merging sticky parameters.\n *\n * See {@link RouterState} and {@link NavigateOptions}\n *\n * @public\n *\n * @example Navigate with sticky params only, staying on the current path\n * ```tsx\n * router.navigate({stickyParams: {baz: 'qux'}})\n * ```\n * @remarks `null` sticky parameter value will remove the sticky parameter from the url\n *\n * @example Navigate with state and sticky params\n * ```tsx\n * router.navigate({stickyParams: {baz: 'qux'}, state: {foo: 'bar'}})\n * ```\n *\n * @example Navigate to root path\n * ```tsx\n * router.navigate({stickyParams: {baz: 'qux'}, state: null})\n * ```\n */\n navigate: {\n // legacy, state-first version - for when you want to navigate to a new state\n (nextState: RouterState, options?: NavigateOptions): void\n // Options version - for staying where you are (omit state) or going to root (state: null)\n (options: NavigateOptions & {state?: RouterState | null}): void\n }\n\n /**\n * Navigates to the given intent.\n * See {@link RouterState} and {@link NavigateBaseOptions}\n */\n navigateIntent: (\n intentName: string,\n params?: IntentParameters,\n options?: NavigateBaseOptions,\n ) => void\n\n /**\n * The current router state. See {@link RouterState}\n */\n state: RouterState\n\n /**\n * The current router state. See {@link RouterState}\n */\n stickyParams: Record<string, string | undefined | null>\n}\n\n/**\n * Base intent parameters\n *\n * @public\n * @todo dedupe with core/structure\n */\nexport interface BaseIntentParams {\n /**\n * Document schema type name to create/edit.\n * Required for `create` intents, optional for `edit` (but encouraged, safer and faster)\n */\n type?: string\n\n /**\n * ID of the document to create/edit.\n * Required for `edit` intents, optional for `create`.\n */\n id?: string\n\n /* Name (ID) of initial value template to use for `create` intent. Optional. */\n template?: string\n\n /**\n * Experimental field path\n *\n * @beta\n * @experimental\n * @hidden\n */\n path?: string\n\n /**\n * Optional \"mode\" to use for edit intent.\n * Known modes are `structure` and `presentation`.\n */\n mode?: string\n\n /**\n * Arbitrary/custom parameters are generally discouraged - try to keep them to a minimum,\n * or use `payload` (arbitrary JSON-serializable object) instead.\n */\n [key: string]: string | undefined\n}\n\n/**\n * Intent parameters (json)\n *\n * @public\n */\nexport type IntentJsonParams = {[key: string]: any}\n\n/**\n * @public\n * @todo dedupe with intent types in core\n */\nexport type IntentParameters = BaseIntentParams | [BaseIntentParams, IntentJsonParams]\n\n/**\n * @public\n */\nexport type SearchParam = [key: string, value: string]\n\n/**\n * @public\n */\nexport type RouterState = Record<string, unknown> & {_searchParams?: SearchParam[]}\n\nexport const isNavigateOptions = (\n maybeNavigateOptions: unknown,\n): maybeNavigateOptions is NavigateOptions & {state?: RouterState | null} => {\n if (\n typeof maybeNavigateOptions !== 'object' ||\n maybeNavigateOptions === null ||\n Array.isArray(maybeNavigateOptions)\n ) {\n return false\n }\n\n const hasNavigationProps =\n 'replace' in maybeNavigateOptions ||\n 'stickyParams' in maybeNavigateOptions ||\n 'state' in maybeNavigateOptions\n\n if (!hasNavigationProps) {\n return false\n }\n\n // if state exists then it should be of RouterState type\n if ('state' in maybeNavigateOptions) {\n const {state} = maybeNavigateOptions as {state: unknown}\n // allow null or undefined or RouterState (including empty object)\n return state === null || state === undefined || typeof state === 'object'\n }\n\n return true\n}\n\n/**\n * Type representing either a new router state or navigation options with an optional state.\n * @internal\n */\nexport type NextStateOrOptions = RouterState | (NavigateOptions & {state?: RouterState | null})\n","import {fromPairs, partition, toPairs} from 'lodash'\nimport {type ReactNode, useCallback, useMemo} from 'react'\nimport {RouterContext} from 'sanity/_singletons'\n\nimport {STICKY_PARAMS} from './stickyParams'\nimport {\n type IntentParameters,\n isNavigateOptions,\n type NavigateBaseOptions,\n type NavigateOptions,\n type NextStateOrOptions,\n type Router,\n type RouterContextValue,\n type RouterState,\n type SearchParam,\n} from './types'\n\n/**\n * The props for the {@link RouterProvider} component.\n *\n * @public\n */\nexport interface RouterProviderProps {\n /**\n * A function that is called when the user navigates to a new path.\n * Takes an object containing the path to navigate to and an optional `replace` flag.\n */\n onNavigate: (opts: {path: string; replace?: boolean}) => void\n /**\n * The router object that is used to handle navigation. See {@link Router}\n */\n router: Router\n /**\n * The current state of the router. See {@link RouterState}\n */\n state: RouterState\n /**\n * The child elements to render.\n */\n children: ReactNode\n}\n\n/**\n * @example\n * ```tsx\n * import {\n * NavigateOptions,\n * route,\n * RouterProvider,\n * RouterState\n * } from 'sanity'\n * import {useCallback, useMemo} from 'react'\n *\n * function Root() {\n * const [router] = useState(() => route.create('/'))\n *\n * const [state, setState] = useState<RouterState>({})\n *\n * const handleNavigate = useCallback((\n * path: string,\n * options?: NavigateOptions\n * ) => {\n * console.log('navigate', path, options)\n *\n * setState(router.decode(path))\n * }, [router])\n *\n * return (\n * <RouterProvider\n * onNavigate={handleNavigate}\n * router={router}\n * state={state}\n * >\n * <div>This is a routed application</div>\n * </RouterProvider>\n * )\n * }\n * ```\n *\n * @param props - The component props.\n * {@link RouterProviderProps}\n *\n * @public\n */\nexport function RouterProvider(props: RouterProviderProps): React.JSX.Element {\n const {onNavigate, router: routerProp, state} = props\n\n const resolveIntentLink = useCallback(\n (intentName: string, parameters?: IntentParameters, _searchParams?: SearchParam[]): string => {\n const [params, payload] = Array.isArray(parameters) ? parameters : [parameters]\n return routerProp.encode({\n intent: intentName,\n params,\n payload,\n _searchParams: toPairs({\n ...fromPairs((state._searchParams ?? []).filter(([key]) => STICKY_PARAMS.includes(key))),\n ...fromPairs(_searchParams ?? []),\n }),\n })\n },\n [routerProp, state._searchParams],\n )\n\n const resolvePathFromState = useCallback(\n (nextState: RouterState | null): string => {\n const currentStateParams = state._searchParams || []\n const nextStateParams = nextState?._searchParams || []\n const nextParams = STICKY_PARAMS.reduce((acc, param) => {\n return replaceStickyParam(\n acc,\n param,\n findParam(nextStateParams, param) ?? findParam(currentStateParams, param),\n )\n }, nextStateParams || [])\n\n return routerProp.encode({\n ...nextState,\n _searchParams: nextParams,\n })\n },\n [routerProp, state],\n )\n\n const navigate: RouterContextValue['navigate'] = useCallback(\n (nextStateOrOptions: NextStateOrOptions, maybeOptions?: NavigateOptions) => {\n // Determine options and state based on input pattern\n const isOptionsOnlyPattern = isNavigateOptions(nextStateOrOptions) && !maybeOptions\n const options = isOptionsOnlyPattern ? nextStateOrOptions : maybeOptions || {}\n\n const baseState = isNavigateOptions(nextStateOrOptions)\n ? (getStateFromOptions(nextStateOrOptions, state) ?? state)\n : nextStateOrOptions\n\n const currentParams = state._searchParams || []\n const nextStickyParams =\n options.stickyParams ??\n Object.fromEntries(currentParams.filter(([key]) => STICKY_PARAMS.includes(key)))\n\n validateStickyParams(nextStickyParams)\n\n const nextParams = baseState._searchParams || []\n const mergedParams = mergeStickyParams(nextParams, nextStickyParams)\n\n onNavigate({\n path: resolvePathFromState({...baseState, _searchParams: mergedParams}),\n replace: options.replace,\n })\n },\n [onNavigate, resolvePathFromState, state],\n )\n\n const handleNavigateStickyParams = useCallback(\n (params: NavigateOptions['stickyParams'], options: NavigateBaseOptions = {}) =>\n navigate({stickyParams: params, ...options, state: undefined}),\n [navigate],\n )\n\n const navigateIntent = useCallback(\n (intentName: string, params?: IntentParameters, options: NavigateBaseOptions = {}) => {\n onNavigate({path: resolveIntentLink(intentName, params), replace: options.replace})\n },\n [onNavigate, resolveIntentLink],\n )\n\n const [routerState, stickyParams] = useMemo(() => {\n if (!state._searchParams) {\n return [state, null]\n }\n const {_searchParams, ...rest} = state\n const [sticky, restParams] = partition(_searchParams, ([key]) => STICKY_PARAMS.includes(key))\n if (sticky.length === 0) {\n return [state, null]\n }\n return [{...rest, _searchParams: restParams}, sticky]\n }, [state])\n\n const stickyParamsByName = useMemo(() => Object.fromEntries(stickyParams || []), [stickyParams])\n\n const router: RouterContextValue = useMemo(\n () => ({\n navigate,\n navigateIntent,\n navigateStickyParams: handleNavigateStickyParams,\n navigateUrl: onNavigate,\n resolveIntentLink,\n resolvePathFromState,\n state: routerState,\n stickyParams: stickyParamsByName,\n }),\n [\n handleNavigateStickyParams,\n navigate,\n navigateIntent,\n onNavigate,\n resolveIntentLink,\n resolvePathFromState,\n routerState,\n stickyParamsByName,\n ],\n )\n\n return <RouterContext.Provider value={router}>{props.children}</RouterContext.Provider>\n}\n\nfunction replaceStickyParam(\n current: SearchParam[],\n param: string,\n value: string | undefined,\n): SearchParam[] {\n const filtered = current.filter(([key]) => key !== param)\n return value === undefined || value == '' ? filtered : [...filtered, [param, value]]\n}\n\nfunction mergeStickyParams(\n currentParams: SearchParam[],\n newParams?: Record<string, string | undefined | null>,\n): SearchParam[] {\n if (!newParams) return currentParams\n\n // Remove old sticky params before merging new ones\n const filteredParams = currentParams.filter(([key]) => !Object.hasOwn(newParams, key))\n\n // Type guard function to filter out undefined values\n const isValidSearchParam = (\n entry: [string, string | undefined | null],\n ): entry is [string, string] => entry[1] !== undefined\n\n const convertNullSearchParam = (entry: [string, string | null]): [string, string] => [\n entry[0],\n entry[1] === null ? '' : entry[1],\n ]\n\n // Convert newParams into the correct SearchParam format\n const newEntries = Object.entries(newParams)\n .filter(isValidSearchParam)\n .map(convertNullSearchParam)\n\n return [...filteredParams, ...newEntries]\n}\n\nfunction findParam(searchParams: SearchParam[], key: string): string | undefined {\n const entry = searchParams.find(([k]) => k === key)\n return entry ? entry[1] : undefined\n}\n\nfunction getStateFromOptions(\n nextStateOrOptions: NextStateOrOptions,\n state: RouterState,\n): RouterState | null {\n const isOptionsOnly = isNavigateOptions(nextStateOrOptions)\n\n if (isOptionsOnly) {\n if (nextStateOrOptions.state === null) {\n return {}\n }\n return nextStateOrOptions.state ?? state\n }\n return null\n}\n\nfunction validateStickyParams(nextStickyParams: Record<string, string | undefined | null>) {\n const hasInvalidParam = Object.keys(nextStickyParams).some(\n (param) => !STICKY_PARAMS.includes(param),\n )\n if (hasInvalidParam) throw new Error('One or more parameters are not sticky')\n}\n","/* eslint-disable camelcase */\nimport {type ReactNode, useCallback, useEffect, useMemo, useRef} from 'react'\nimport {RouterContext} from 'sanity/_singletons'\n\nimport {\n isNavigateOptions,\n type NavigateOptions,\n type NextStateOrOptions,\n type RouterContextValue,\n type RouterState,\n} from './types'\nimport {useRouter} from './useRouter'\n\nfunction addScope(\n routerState: Record<string, any>,\n scope: string,\n scopedState: Record<string, any>,\n) {\n return (\n scopedState && {\n ...routerState,\n [scope]: scopedState,\n }\n )\n}\n\n/**\n * Props for the {@link RouteScope} component.\n *\n * @public\n */\nexport interface RouteScopeProps {\n /**\n * The scope for the nested routes.\n */\n scope: string\n\n /**\n * Optionally disable scoping of search params\n * Scoped search params will be represented as scope[param]=value in the url\n * Disabling this will still scope search params based on any parent scope unless the parent scope also has disabled search params scoping\n * Caution: enabling this can cause conflicts with multiple plugins defining search params with the same name\n */\n __unsafe_disableScopedSearchParams?: boolean\n /**\n * The content to display inside the route scope.\n */\n children: ReactNode\n}\n\n/**\n * A component that wraps a scoped router context, so that calls to\n * `useRouter()`, `useRouterState()`, and usage of `<StateLink />`\n * will be prefixed with the scope segment.\n *\n * @public\n *\n * @param props - Props to pass `RouteScope` component.\n * See {@link RouteScopeProps}\n *\n * @example\n * ```tsx\n * function MyComponent() {\n * return (\n * <RouteScope scope=\"foo\">\n * <StateLink state={{bar: 'baz'}}>Link</StateLink>\n * </RouteScope>\n * )\n * }\n * ```\n */\nexport const RouteScope = function RouteScope(props: RouteScopeProps): React.JSX.Element {\n const {children, scope, __unsafe_disableScopedSearchParams} = props\n const parentRouter = useRouter()\n const {resolvePathFromState: parent_resolvePathFromState, navigate: parent_navigate} =\n parentRouter\n\n const parentStateRef = useRef(parentRouter.state)\n useEffect(() => {\n parentStateRef.current = parentRouter.state\n }, [parentRouter.state])\n\n const resolveNextParentState = useCallback(\n (_nextState: RouterState | null) => {\n if (_nextState === null) return null\n\n const {_searchParams, ...nextState} = _nextState || {}\n const nextParentState = addScope(parentStateRef.current, scope, nextState)\n if (__unsafe_disableScopedSearchParams) {\n // Move search params to parent scope\n nextParentState._searchParams = _searchParams\n } else {\n