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 • 85.3 kB
Source Map (JSON)
{"version":3,"file":"index3.mjs","sources":["../../src/structure/structureResolvers/PaneResolutionError.ts","../../src/structure/structureResolvers/assignId.ts","../../src/structure/structureResolvers/createPaneResolver.ts","../../src/structure/structureResolvers/memoBind.ts","../../src/structure/structureResolvers/resolveIntent.ts","../../src/structure/structureResolvers/createResolvedPaneNodeStream.ts","../../src/structure/structureResolvers/useResolvedPanes.ts","../../src/structure/components/structureTool/intentResolver/utils.ts","../../src/structure/components/structureTool/intentResolver/IntentResolver.tsx","../../src/structure/components/structureTool/StructureError.tsx","../../src/structure/components/structureTool/NoDocumentTypesScreen.tsx","../../src/structure/components/structureTool/StructureTitle.tsx","../../src/structure/components/structureTool/StructureTool.tsx","../../src/structure/components/structureTool/StructureToolBoundary.tsx"],"sourcesContent":["import {type RouterPaneSiblingContext} from '../types'\n\nexport interface PaneResolutionErrorOptions {\n message: string\n context?: RouterPaneSiblingContext\n helpId?: string\n cause?: Error\n}\n\n/**\n * An error thrown during pane resolving. This error is meant to be bubbled up\n * through react and handled in an error boundary. It includes a `cause`\n * property which is the original error caught\n */\nexport class PaneResolutionError extends Error {\n cause: Error | undefined\n context: RouterPaneSiblingContext | undefined\n helpId: string | undefined\n\n constructor({message, context, helpId, cause}: PaneResolutionErrorOptions) {\n super(message)\n this.name = 'PaneResolutionError'\n this.context = context\n this.helpId = helpId\n this.cause = cause\n }\n}\n","import {nanoid} from 'nanoid'\n\n// `WeakMap`s require the first type param to extend `object`\nconst randomIdCache = new WeakMap<object, string>()\n\n/**\n * given an object, this function randomly generates an ID and returns it. this\n * result is then saved in a WeakMap so subsequent requests for the same object\n * will receive the same ID\n */\nexport function assignId(obj: object): string {\n const cachedValue = randomIdCache.get(obj)\n if (cachedValue) return cachedValue\n\n const id = nanoid()\n randomIdCache.set(obj, id)\n return id\n}\n","import {from, isObservable, type Observable, of as observableOf} from 'rxjs'\nimport {publishReplay, refCount, switchMap} from 'rxjs/operators'\nimport {isRecord} from 'sanity'\n\nimport {type PaneNode, type RouterPaneSiblingContext, type UnresolvedPaneNode} from '../types'\nimport {PaneResolutionError} from './PaneResolutionError'\n\ninterface Serializable {\n serialize: (...args: never[]) => unknown\n}\n\nconst isPromise = (thing: any): thing is PromiseLike<unknown> => {\n return !!thing && typeof thing?.then === 'function'\n}\nconst isSerializable = (thing: unknown): thing is Serializable => {\n if (!isRecord(thing)) return false\n return typeof thing.serialize === 'function'\n}\n\n/**\n * The signature of the function used to take an `UnresolvedPaneNode` and turn\n * it into an `Observable<PaneNode>`.\n */\nexport type PaneResolver = (\n unresolvedPane: UnresolvedPaneNode | undefined,\n context: RouterPaneSiblingContext,\n flatIndex: number,\n) => Observable<PaneNode>\n\nexport type PaneResolverMiddleware = (paneResolveFn: PaneResolver) => PaneResolver\n\nconst rethrowWithPaneResolutionErrors: PaneResolverMiddleware =\n (next) => (unresolvedPane, context, flatIndex) => {\n try {\n return next(unresolvedPane, context, flatIndex)\n } catch (e) {\n // re-throw errors that are already `PaneResolutionError`s\n if (e instanceof PaneResolutionError) {\n throw e\n }\n\n // anything else, wrap with `PaneResolutionError` and set the underlying\n // error as a the `cause`\n throw new PaneResolutionError({\n message: typeof e?.message === 'string' ? e.message : '',\n context,\n cause: e,\n })\n }\n }\n\nconst wrapWithPublishReplay: PaneResolverMiddleware =\n (next) =>\n (...args) => {\n return next(...args).pipe(\n // need to add publishReplay + refCount to ensure new subscribers always\n // get an emission. without this, memoized observables may get stuck\n // waiting for their first emissions resulting in a loading pane\n publishReplay(1),\n refCount(),\n )\n }\n\nexport function createPaneResolver(middleware: PaneResolverMiddleware): PaneResolver {\n // note: this API includes a middleware/wrapper function because the function\n // is recursive. we want to call the wrapped version of the function always\n // (even inside of nested calls) so the identifier invoked for the recursion\n // should be the wrapped version\n const resolvePane = rethrowWithPaneResolutionErrors(\n wrapWithPublishReplay(\n middleware((unresolvedPane, context, flatIndex) => {\n if (!unresolvedPane) {\n throw new PaneResolutionError({\n message: 'Pane returned no child',\n context,\n helpId: 'structure-item-returned-no-child',\n })\n }\n\n if (isPromise(unresolvedPane) || isObservable(unresolvedPane)) {\n return from(unresolvedPane).pipe(\n switchMap((result) => resolvePane(result, context, flatIndex)),\n )\n }\n\n if (isSerializable(unresolvedPane)) {\n return resolvePane(unresolvedPane.serialize(context), context, flatIndex)\n }\n\n if (typeof unresolvedPane === 'function') {\n return resolvePane(unresolvedPane(context.id, context), context, flatIndex)\n }\n\n return observableOf(unresolvedPane)\n }),\n ),\n )\n\n return resolvePane\n}\n","/* eslint-disable @typescript-eslint/no-unsafe-function-type */\n\n// `WeakMap`s require the first type param to extend `object`\nconst bindCache = new WeakMap<object, Map<string, Function>>()\n\n/**\n * An alternative to `obj.method.bind(obj)` that utilizes a weakmap to return\n * the same memory reference for sequent binds.\n */\nexport function memoBind<\n T extends object,\n K extends keyof {[P in keyof T]: T[P] extends Function ? T[P] : never},\n>(obj: T, methodKey: K): T[K]\nexport function memoBind(obj: Record<string, unknown>, methodKey: string): Function {\n const boundMethods = bindCache.get(obj) || new Map<string, Function>()\n if (boundMethods) {\n const bound = boundMethods.get(methodKey)\n if (bound) return bound\n }\n\n const method = obj[methodKey]\n\n if (typeof method !== 'function') {\n throw new Error(\n `Expected property \\`${methodKey}\\` to be a function but got ${typeof method} instead.`,\n )\n }\n\n const bound = method.bind(obj)\n boundMethods.set(methodKey, bound)\n bindCache.set(obj, boundMethods)\n\n return bound\n}\n","import {omit} from 'lodash'\nimport {firstValueFrom, type Observable} from 'rxjs'\n\nimport {type StructureContext} from '../structureBuilder'\nimport {\n type PaneNode,\n type RouterPanes,\n type RouterPaneSiblingContext,\n type UnresolvedPaneNode,\n} from '../types'\nimport {assignId} from './assignId'\nimport {createPaneResolver, type PaneResolverMiddleware} from './createPaneResolver'\nimport {memoBind} from './memoBind'\n\ninterface TraverseOptions {\n unresolvedPane: UnresolvedPaneNode | undefined\n intent: string\n params: {type: string; id: string; [key: string]: string | undefined}\n payload: unknown\n parent: PaneNode | null\n path: string[]\n currentId: string\n flatIndex: number\n levelIndex: number\n structureContext: StructureContext\n}\n\nexport interface ResolveIntentOptions {\n rootPaneNode?: UnresolvedPaneNode\n intent: string\n params: {type: string; id: string; [key: string]: string | undefined}\n payload: unknown\n structureContext: StructureContext\n}\n\n/**\n * Resolves an intent request using breadth first search. If a match is not\n * found, the intent will resolve to the fallback editor.\n *\n * A match is found if:\n * 1. the `PaneNode` is of type `document` and the its ID matches the intent ID\n * 2. the `PaneNode` is of type `documentList` and the `schemaTypeName` matches\n * 3. the `PaneNode`'s `canHandleIntent` method returns true\n *\n * If a `PaneNode` of type `list` is found, it will be searched for a match.\n *\n * @see PaneNode\n */\nexport async function resolveIntent(options: ResolveIntentOptions): Promise<RouterPanes> {\n const resolvedPaneCache = new Map<string, Observable<PaneNode>>()\n\n // this is a simple version of the memoizer in `createResolvedPaneNodeStream`\n const memoize: PaneResolverMiddleware = (nextFn) => (unresolvedPane, context, flatIndex) => {\n const key = unresolvedPane && `${assignId(unresolvedPane)}-${context.path.join('__')}`\n const cachedResolvedPane = key && resolvedPaneCache.get(key)\n if (cachedResolvedPane) return cachedResolvedPane\n\n const result = nextFn(unresolvedPane, context, flatIndex)\n if (key) resolvedPaneCache.set(key, result)\n return result\n }\n\n const resolvePane = createPaneResolver(memoize)\n\n const fallbackEditorPanes: RouterPanes = [\n [\n {\n id: `__edit__${options.params.id}`,\n params: {...omit(options.params, ['id']), type: options.params.type},\n payload: options.payload,\n },\n ],\n ]\n\n async function traverse({\n currentId,\n flatIndex,\n intent,\n params,\n parent,\n path,\n payload,\n unresolvedPane,\n levelIndex,\n structureContext,\n }: TraverseOptions): Promise<\n Array<{panes: RouterPanes; depthIndex: number; levelIndex: number}>\n > {\n if (!unresolvedPane) return []\n\n const {id: targetId, type: schemaTypeName, ...otherParams} = params\n const context: RouterPaneSiblingContext = {\n id: currentId,\n splitIndex: 0,\n parent,\n path,\n index: flatIndex,\n params: {},\n payload: undefined,\n structureContext,\n }\n const resolvedPane = await firstValueFrom(resolvePane(unresolvedPane, context, flatIndex))\n\n // if the resolved pane is a document pane and the pane's ID matches then\n // resolve the intent to the current path\n if (resolvedPane.type === 'document' && resolvedPane.id === targetId) {\n return [\n {\n panes: [\n ...path.slice(0, path.length - 1).map((i) => [{id: i}]),\n [{id: targetId, params: otherParams, payload}],\n ],\n depthIndex: path.length,\n levelIndex,\n },\n ]\n }\n\n // NOTE: if you update this logic, please also update the similar handler in\n // `getIntentState.ts`\n if (\n // if the resolve pane's `canHandleIntent` returns true, then resolve\n resolvedPane.canHandleIntent?.(intent, params, {\n pane: resolvedPane,\n index: flatIndex,\n }) ||\n // if the pane's `canHandleIntent` did not return true, then match against\n // this default case. we will resolve the intent if:\n (resolvedPane.type === 'documentList' &&\n // 1. the schema type matches (this required for the document to render)\n resolvedPane.schemaTypeName === schemaTypeName &&\n // 2. the filter is the default filter.\n //\n // NOTE: this case is to prevent false positive matches where the user\n // has configured a more specific filter for a particular type. In that\n // case, the user can implement their own `canHandleIntent` function\n resolvedPane.options.filter === '_type == $type')\n ) {\n return [\n {\n panes: [\n // map the current path to router panes\n ...path.map((id) => [{id}]),\n // then augment with the intents IDs and params\n [{id: params.id, params: otherParams, payload}],\n ],\n depthIndex: path.length,\n levelIndex,\n },\n ]\n }\n\n if (resolvedPane.type === 'list' && resolvedPane.child && resolvedPane.items) {\n return (\n await Promise.all(\n resolvedPane.items.map((item, nextLevelIndex) => {\n if (item.type === 'divider') return Promise.resolve([])\n\n return traverse({\n currentId: item._id || item.id,\n flatIndex: flatIndex + 1,\n intent,\n params,\n parent: resolvedPane,\n path: [...path, item.id],\n payload,\n unresolvedPane:\n typeof resolvedPane.child === 'function'\n ? memoBind(resolvedPane, 'child')\n : resolvedPane.child,\n levelIndex: nextLevelIndex,\n structureContext,\n })\n }),\n )\n ).flat()\n }\n\n return []\n }\n\n const matchingPanes = await traverse({\n currentId: 'root',\n flatIndex: 0,\n levelIndex: 0,\n intent: options.intent,\n params: options.params,\n parent: null,\n path: [],\n payload: options.payload,\n unresolvedPane: options.rootPaneNode,\n structureContext: options.structureContext,\n })\n\n const closestPaneToRoot = matchingPanes.sort((a, b) => {\n // break ties with the level index\n if (a.depthIndex === b.depthIndex) return a.levelIndex - b.levelIndex\n return a.depthIndex - b.depthIndex\n })[0]\n\n if (closestPaneToRoot) {\n return closestPaneToRoot.panes\n }\n\n return fallbackEditorPanes\n}\n","import {generateHelpUrl} from '@sanity/generate-help-url'\nimport {isEqual} from 'lodash'\nimport {concat, NEVER, type Observable, of as observableOf} from 'rxjs'\nimport {distinctUntilChanged, map, pairwise, scan, startWith, switchMap} from 'rxjs/operators'\n\nimport {type StructureContext} from '../structureBuilder'\nimport {\n type DocumentPaneNode,\n type PaneNode,\n type PaneNodeResolver,\n type RouterPanes,\n type RouterPaneSibling,\n type RouterPaneSiblingContext,\n type UnresolvedPaneNode,\n} from '../types'\nimport {assignId} from './assignId'\nimport {\n createPaneResolver,\n type PaneResolver,\n type PaneResolverMiddleware,\n} from './createPaneResolver'\nimport {memoBind} from './memoBind'\nimport {PaneResolutionError} from './PaneResolutionError'\n\n/**\n * the fallback editor child that is implicitly inserted into the structure tree\n * if the id starts with `__edit__`\n */\nconst fallbackEditorChild: PaneNodeResolver = (nodeId, context): DocumentPaneNode => {\n const id = nodeId.replace(/^__edit__/, '')\n const {\n params,\n payload,\n structureContext: {resolveDocumentNode},\n } = context\n const {type, template} = params\n\n if (!type) {\n throw new Error(\n `Document type for document with ID ${id} was not provided in the router params.`,\n )\n }\n\n let defaultDocumentBuilder = resolveDocumentNode({schemaType: type, documentId: id}).id('editor')\n\n if (template) {\n defaultDocumentBuilder = defaultDocumentBuilder.initialValueTemplate(\n template,\n payload as {[key: string]: unknown},\n )\n }\n\n return defaultDocumentBuilder.serialize() as DocumentPaneNode\n}\n\n/**\n * takes in a `RouterPaneSiblingContext` and returns a normalized string\n * representation that can be used for comparisons\n */\nfunction hashContext(context: RouterPaneSiblingContext): string {\n return `contextHash(${JSON.stringify({\n id: context.id,\n parentId: parent && assignId(parent),\n path: context.path,\n index: context.index,\n splitIndex: context.splitIndex,\n serializeOptionsIndex: context.serializeOptions?.index,\n serializeOptionsPath: context.serializeOptions?.path,\n })})`\n}\n\n/**\n * takes in `ResolvedPaneMeta` and returns a normalized string representation\n * that can be used for comparisons\n */\nconst hashResolvedPaneMeta = (meta: ResolvedPaneMeta): string => {\n const normalized = {\n type: meta.type,\n id: meta.routerPaneSibling.id,\n params: meta.routerPaneSibling.params || {},\n payload: meta.routerPaneSibling.payload || null,\n flatIndex: meta.flatIndex,\n groupIndex: meta.groupIndex,\n siblingIndex: meta.siblingIndex,\n path: meta.path,\n paneNode: meta.type === 'resolvedMeta' ? assignId(meta.paneNode) : null,\n }\n\n return `metaHash(${JSON.stringify(normalized)})`\n}\n\n/**\n * Represents one flattened \"router pane\", including the source group and\n * sibling indexes.\n *\n * @see RouterPanes\n */\ninterface FlattenedRouterPane {\n routerPaneSibling: RouterPaneSibling\n flatIndex: number\n groupIndex: number\n siblingIndex: number\n}\n\n/**\n * The state of the accumulator used to store and manage memo cache state\n */\ninterface CacheState {\n /**\n * Holds the memoization results keyed by a combination of `assignId` and a\n * context hash.\n */\n resolvedPaneCache: Map<string, Observable<PaneNode>>\n /**\n * Acts as a dictionary that stores cache keys by their flat index. This is\n * used to clean up the cache between different branches in the pane\n * structure.\n *\n * @see createResolvedPaneNodeStream look inside the `scan` where `wrapFn` is\n * defined\n */\n cacheKeysByFlatIndex: Array<Set<string>>\n /**\n * The resulting memoized `PaneResolver` function. This function closes over\n * the `resolvedPaneCache`.\n */\n resolvePane: PaneResolver\n flattenedRouterPanes: FlattenedRouterPane[]\n}\n\nexport interface CreateResolvedPaneNodeStreamOptions {\n /**\n * an input stream of `RouterPanes`\n * @see RouterPanes\n */\n routerPanesStream: Observable<RouterPanes>\n /**\n * any `UnresolvedPaneNode` (could be an observable, promise, pane resolver etc)\n */\n rootPaneNode: UnresolvedPaneNode\n /** used primarily for testing */\n initialCacheState?: CacheState\n\n structureContext: StructureContext\n}\n\n/**\n * The result of pane resolving\n */\nexport type ResolvedPaneMeta = {\n groupIndex: number\n siblingIndex: number\n flatIndex: number\n routerPaneSibling: RouterPaneSibling\n path: string[]\n} & ({type: 'loading'; paneNode: null} | {type: 'resolvedMeta'; paneNode: PaneNode})\n\ninterface ResolvePaneTreeOptions {\n resolvePane: PaneResolver\n flattenedRouterPanes: FlattenedRouterPane[]\n unresolvedPane: UnresolvedPaneNode | undefined\n parent: PaneNode | null\n path: string[]\n structureContext: StructureContext\n}\n\n/**\n * A recursive pane resolving function. Starts at one unresolved pane node and\n * continues until there is no more flattened router panes that can be used as\n * input to the unresolved panes.\n */\nfunction resolvePaneTree({\n unresolvedPane,\n flattenedRouterPanes,\n parent,\n path,\n resolvePane,\n structureContext,\n}: ResolvePaneTreeOptions): Observable<ResolvedPaneMeta[]> {\n const [current, ...rest] = flattenedRouterPanes\n const next = rest[0] as FlattenedRouterPane | undefined\n\n const context: RouterPaneSiblingContext = {\n id: current.routerPaneSibling.id,\n splitIndex: current.siblingIndex,\n parent,\n path: [...path, current.routerPaneSibling.id],\n index: current.flatIndex,\n params: current.routerPaneSibling.params || {},\n payload: current.routerPaneSibling.payload,\n structureContext,\n }\n\n try {\n return resolvePane(unresolvedPane, context, current.flatIndex).pipe(\n // this switch map receives a resolved pane\n switchMap((paneNode) => {\n // we can create a `resolvedMeta` type using it\n const resolvedPaneMeta: ResolvedPaneMeta = {\n type: 'resolvedMeta',\n ...current,\n paneNode: paneNode,\n path: context.path,\n }\n\n // for the other unresolved panes, we can create \"loading panes\"\n const loadingPanes = rest.map((i, restIndex) => {\n const loadingPanePath = [\n ...context.path,\n ...rest.slice(restIndex).map((_, currentIndex) => `[${i.flatIndex + currentIndex}]`),\n ]\n\n const loadingPane: ResolvedPaneMeta = {\n type: 'loading',\n path: loadingPanePath,\n paneNode: null,\n ...i,\n }\n\n return loadingPane\n })\n\n if (!rest.length) {\n return observableOf([resolvedPaneMeta])\n }\n\n let nextStream\n\n if (\n // the fallback editor case\n next?.routerPaneSibling.id.startsWith('__edit__')\n ) {\n nextStream = resolvePaneTree({\n unresolvedPane: fallbackEditorChild,\n flattenedRouterPanes: rest,\n parent,\n path: context.path,\n resolvePane,\n structureContext,\n })\n } else if (current.groupIndex === next?.groupIndex) {\n // if the next flattened router pane has the same group index as the\n // current flattened router pane, then the next flattened router pane\n // belongs to the same group (i.e. it is a split pane)\n nextStream = resolvePaneTree({\n unresolvedPane,\n flattenedRouterPanes: rest,\n parent,\n path,\n resolvePane,\n structureContext,\n })\n } else {\n // normal children resolving\n nextStream = resolvePaneTree({\n unresolvedPane:\n typeof paneNode.child === 'function'\n ? (memoBind(paneNode, 'child') as PaneNodeResolver)\n : paneNode.child,\n flattenedRouterPanes: rest,\n parent: paneNode,\n path: context.path,\n resolvePane,\n structureContext,\n })\n }\n\n return concat(\n // we emit the loading panes first in a concat (this emits immediately)\n observableOf([resolvedPaneMeta, ...loadingPanes]),\n // then whenever the next stream is done, the results will be combined.\n nextStream.pipe(map((nextResolvedPanes) => [resolvedPaneMeta, ...nextResolvedPanes])),\n )\n }),\n )\n } catch (e) {\n if (e instanceof PaneResolutionError) {\n if (e.context) {\n console.warn(\n `Pane resolution error at index ${e.context.index}${\n e.context.splitIndex > 0 ? ` for split pane index ${e.context.splitIndex}` : ''\n }: ${e.message}${e.helpId ? ` - see ${generateHelpUrl(e.helpId)}` : ''}`,\n e,\n )\n }\n\n if (e.helpId === 'structure-item-returned-no-child') {\n // returning an observable of an empty array will remove loading panes\n // note: this one intentionally does not throw\n return observableOf([])\n }\n }\n\n throw e\n }\n}\n\n/**\n * Takes in a stream of `RouterPanes` and an unresolved root pane and returns\n * a stream of `ResolvedPaneMeta`\n */\nexport function createResolvedPaneNodeStream({\n routerPanesStream,\n rootPaneNode,\n initialCacheState = {\n cacheKeysByFlatIndex: [],\n flattenedRouterPanes: [],\n resolvedPaneCache: new Map(),\n resolvePane: () => NEVER,\n },\n structureContext,\n}: CreateResolvedPaneNodeStreamOptions): Observable<ResolvedPaneMeta[]> {\n const resolvedPanes$ = routerPanesStream.pipe(\n // add in implicit \"root\" router pane\n map((rawRouterPanes) => [[{id: 'root'}], ...rawRouterPanes]),\n // create flattened router panes\n map((routerPanes) => {\n const flattenedRouterPanes: FlattenedRouterPane[] = routerPanes\n .flatMap((routerPaneGroup, groupIndex) =>\n routerPaneGroup.map((routerPaneSibling, siblingIndex) => ({\n routerPaneSibling,\n groupIndex,\n siblingIndex,\n })),\n )\n // add in the flat index\n .map((i, index) => ({...i, flatIndex: index}))\n\n return flattenedRouterPanes\n }),\n // calculate a \"diffIndex\" used for clearing the memo cache\n startWith([] as FlattenedRouterPane[]),\n pairwise(),\n map(([prev, curr]) => {\n for (let i = 0; i < curr.length; i++) {\n const prevValue = prev[i]\n const currValue = curr[i]\n\n if (!isEqual(prevValue, currValue)) {\n return {\n flattenedRouterPanes: curr,\n diffIndex: i,\n }\n }\n }\n\n return {\n flattenedRouterPanes: curr,\n diffIndex: curr.length,\n }\n }),\n // create the memoized `resolvePane` function and manage the memo cache\n scan((acc, next) => {\n const {cacheKeysByFlatIndex, resolvedPaneCache} = acc\n const {flattenedRouterPanes, diffIndex} = next\n\n // use the `cacheKeysByFlatIndex` like a dictionary to find cache keys to\n // and cache keys to delete\n const beforeDiffIndex = cacheKeysByFlatIndex.slice(0, diffIndex + 1)\n const afterDiffIndex = cacheKeysByFlatIndex.slice(diffIndex + 1)\n\n const keysToKeep = new Set(beforeDiffIndex.flatMap((keySet) => Array.from(keySet)))\n const keysToDelete = afterDiffIndex\n .flatMap((keySet) => Array.from(keySet))\n .filter((key) => !keysToKeep.has(key))\n\n for (const key of keysToDelete) {\n resolvedPaneCache.delete(key)\n }\n\n // create a memoizing pane resolver middleware that utilizes the cache\n // maintained above. this keeps the cache from growing indefinitely\n const memoize: PaneResolverMiddleware = (nextFn) => (unresolvedPane, context, flatIndex) => {\n const key = unresolvedPane && `${assignId(unresolvedPane)}-${hashContext(context)}`\n const cachedResolvedPane = key && resolvedPaneCache.get(key)\n if (cachedResolvedPane) return cachedResolvedPane\n\n const result = nextFn(unresolvedPane, context, flatIndex)\n if (!key) return result\n\n const cacheKeySet = cacheKeysByFlatIndex[flatIndex] || new Set()\n cacheKeySet.add(key)\n cacheKeysByFlatIndex[flatIndex] = cacheKeySet\n resolvedPaneCache.set(key, result)\n return result\n }\n\n return {\n flattenedRouterPanes,\n cacheKeysByFlatIndex,\n resolvedPaneCache,\n resolvePane: createPaneResolver(memoize),\n }\n }, initialCacheState),\n // run the memoized, recursive resolving\n switchMap(({flattenedRouterPanes, resolvePane}) =>\n resolvePaneTree({\n unresolvedPane: rootPaneNode,\n flattenedRouterPanes,\n parent: null,\n path: [],\n resolvePane,\n structureContext,\n }),\n ),\n )\n\n // after we've created a stream of `ResolvedPaneMeta[]`, we need to clean up\n // the results to remove unwanted loading panes and prevent unnecessary\n // emissions\n return resolvedPanes$.pipe(\n // this diffs the previous emission with the current one. if there is a new\n // loading pane at the same position where a previous pane already had a\n // resolved value (looking at the IDs to compare), then return the previous\n // pane instead of the loading pane\n scan(\n (prev, next) =>\n next.map((nextPane, index) => {\n const prevPane = prev[index] as ResolvedPaneMeta | undefined\n if (!prevPane) return nextPane\n if (nextPane.type !== 'loading') return nextPane\n\n if (prevPane.routerPaneSibling.id === nextPane.routerPaneSibling.id) {\n return prevPane\n }\n return nextPane\n }),\n [] as ResolvedPaneMeta[],\n ),\n // this prevents duplicate emissions\n distinctUntilChanged((prev, next) => {\n if (prev.length !== next.length) return false\n\n for (let i = 0; i < next.length; i++) {\n const prevValue = prev[i]\n const nextValue = next[i]\n if (hashResolvedPaneMeta(prevValue) !== hashResolvedPaneMeta(nextValue)) {\n return false\n }\n }\n\n return true\n }),\n )\n}\n","import {useEffect, useMemo, useState} from 'react'\nimport {ReplaySubject} from 'rxjs'\nimport {map} from 'rxjs/operators'\nimport {type RouterState, useRouter} from 'sanity/router'\n\nimport {LOADING_PANE} from '../constants'\nimport {type PaneNode, type RouterPaneGroup, type RouterPanes} from '../types'\nimport {useStructureTool} from '../useStructureTool'\nimport {createResolvedPaneNodeStream} from './createResolvedPaneNodeStream'\n\ninterface PaneData {\n active: boolean\n childItemId: string | null\n groupIndex: number\n index: number\n itemId: string\n key: string\n pane: PaneNode | typeof LOADING_PANE\n params: Record<string, string | undefined> & {perspective?: string}\n path: string\n payload: unknown\n selected: boolean\n siblingIndex: number\n}\n\nexport interface Panes {\n paneDataItems: PaneData[]\n routerPanes: RouterPanes\n resolvedPanes: (PaneNode | typeof LOADING_PANE)[]\n}\n\nfunction useRouterPanesStream() {\n const [routerStateSubject] = useState(() => new ReplaySubject<RouterState>(1))\n const routerPanes$ = useMemo(\n () =>\n routerStateSubject\n .asObservable()\n .pipe(map((_routerState) => (_routerState?.panes || []) as RouterPanes)),\n [routerStateSubject],\n )\n const {state: routerState} = useRouter()\n useEffect(() => {\n routerStateSubject.next(routerState)\n }, [routerState, routerStateSubject])\n\n return routerPanes$\n}\n\nexport function useResolvedPanes(): Panes {\n // used to propagate errors from async effect. throwing inside of the render\n // will bubble the error to react where it can be picked up by standard error\n // boundaries\n const [error, setError] = useState<unknown>()\n if (error) throw error\n\n const {structureContext, rootPaneNode} = useStructureTool()\n\n const [data, setData] = useState<Panes>({\n paneDataItems: [],\n resolvedPanes: [],\n routerPanes: [],\n })\n\n const routerPanesStream = useRouterPanesStream()\n\n useEffect(() => {\n const resolvedPanes$ = createResolvedPaneNodeStream({\n rootPaneNode,\n routerPanesStream,\n structureContext,\n }).pipe(\n map((resolvedPanes) => {\n const routerPanes = resolvedPanes.reduce<RouterPanes>((acc, next) => {\n const currentGroup = acc[next.groupIndex] || []\n currentGroup[next.siblingIndex] = next.routerPaneSibling\n acc[next.groupIndex] = currentGroup\n return acc\n }, [])\n\n const groupsLen = routerPanes.length\n\n const paneDataItems = resolvedPanes.map((pane) => {\n const {groupIndex, flatIndex, siblingIndex, routerPaneSibling, path} = pane\n const itemId = routerPaneSibling.id\n const nextGroup = routerPanes[groupIndex + 1] as RouterPaneGroup | undefined\n\n const paneDataItem: PaneData = {\n active: groupIndex === groupsLen - 2,\n childItemId: nextGroup?.[0].id ?? null,\n index: flatIndex,\n itemId: routerPaneSibling.id,\n groupIndex,\n key: `${\n pane.type === 'loading' ? 'unknown' : pane.paneNode.id\n }-${itemId}-${siblingIndex}`,\n pane: pane.type === 'loading' ? LOADING_PANE : pane.paneNode,\n params: routerPaneSibling.params || {},\n path: path.join(';'),\n payload: routerPaneSibling.payload,\n selected: flatIndex === resolvedPanes.length - 1,\n siblingIndex,\n }\n\n return paneDataItem\n })\n\n return {\n paneDataItems,\n routerPanes,\n resolvedPanes: paneDataItems.map((pane) => pane.pane),\n }\n }),\n )\n\n const subscription = resolvedPanes$.subscribe({\n next: (result) => setData(result),\n error: (e) => setError(e),\n })\n\n return () => subscription.unsubscribe()\n }, [rootPaneNode, routerPanesStream, structureContext])\n\n return data\n}\n","import {uuid} from '@sanity/uuid'\nimport {firstValueFrom, type Observable} from 'rxjs'\nimport {type DocumentStore, getPublishedId} from 'sanity'\n\nimport {PaneResolutionError} from '../../../structureResolvers'\n\nexport function removeDraftPrefix(documentId: string): string {\n const publishedId = getPublishedId(documentId)\n\n if (publishedId !== documentId) {\n console.warn(\n 'Removed unexpected draft id in document link: All links to documents should have the ' +\n '`drafts.`-prefix removed and something appears to have made an intent link to `%s`',\n documentId,\n )\n }\n\n return publishedId\n}\n\nexport async function ensureDocumentIdAndType(\n documentStore: DocumentStore,\n id: string | undefined,\n type: string | undefined,\n): Promise<{id: string; type: string}> {\n if (id && type) return {id, type}\n if (!id && type) return {id: uuid(), type}\n if (id && !type) {\n const resolvedType = await firstValueFrom(\n documentStore.resolveTypeForDocument(id) as Observable<string>,\n )\n\n return {id, type: resolvedType}\n }\n\n throw new PaneResolutionError({\n message: 'Neither document `id` or `type` was provided when trying to resolve intent.',\n })\n}\n","import {memo, useCallback, useEffect, useState} from 'react'\nimport {isRecord, useDocumentStore} from 'sanity'\nimport {useRouter, useRouterState} from 'sanity/router'\n\nimport {resolveIntent} from '../../../structureResolvers'\nimport {useStructureTool} from '../../../useStructureTool'\nimport {ensureDocumentIdAndType} from './utils'\n\nconst EMPTY_RECORD: Record<string, unknown> = {}\n\n/**\n * A component that receives an intent from props and redirects to the resolved\n * intent location (while showing a loading spinner during the process)\n */\nexport const IntentResolver = memo(function IntentResolver() {\n const {navigate} = useRouter()\n const maybeIntent = useRouterState(\n useCallback((routerState) => {\n const intentName = typeof routerState.intent === 'string' ? routerState.intent : undefined\n return intentName\n ? {\n intent: intentName,\n params: isRecord(routerState.params) ? routerState.params : EMPTY_RECORD,\n payload: routerState.payload,\n }\n : undefined\n }, []),\n )\n const {rootPaneNode, structureContext} = useStructureTool()\n const documentStore = useDocumentStore()\n const [error, setError] = useState<unknown>(null)\n\n // this re-throws errors so that parent ErrorBoundary's can handle them properly\n if (error) throw error\n\n // eslint-disable-next-line consistent-return\n useEffect(() => {\n if (maybeIntent) {\n const {intent, params, payload} = maybeIntent\n\n let cancelled = false\n async function effect() {\n const {id, type} = await ensureDocumentIdAndType(\n documentStore,\n typeof params.id === 'string' ? params.id : undefined,\n typeof params.type === 'string' ? params.type : undefined,\n )\n\n if (cancelled) return\n\n const panes = await resolveIntent({\n intent,\n params: {...params, id, type},\n payload,\n rootPaneNode,\n structureContext,\n })\n\n if (cancelled) return\n\n navigate({panes}, {replace: true})\n }\n\n effect().catch(setError)\n\n return () => {\n cancelled = true\n }\n }\n }, [documentStore, maybeIntent, navigate, rootPaneNode, structureContext])\n\n return null\n})\n","import {generateHelpUrl} from '@sanity/generate-help-url'\nimport {SyncIcon} from '@sanity/icons'\nimport {Box, Card, Code, Container, Heading, Stack, Text} from '@sanity/ui'\nimport {useCallback} from 'react'\nimport {useTranslation} from 'sanity'\nimport {styled} from 'styled-components'\n\nimport {Button} from '../../../ui-components'\nimport {structureLocaleNamespace} from '../../i18n'\nimport {SerializeError} from '../../structureBuilder'\nimport {PaneResolutionError} from '../../structureResolvers'\n\nconst PathSegment = styled.span`\n &:not(:last-child)::after {\n content: ' ➝ ';\n opacity: 0.5;\n }\n`\n\nfunction formatStack(stack: string) {\n return (\n stack\n // Prettify builder functions\n .replace(/\\(\\.\\.\\.\\)\\./g, '(...)\\n .')\n // Remove webpack cruft from function names\n .replace(/__WEBPACK_IMPORTED_MODULE_\\d+_+/g, '')\n // Remove default export postfix from function names\n .replace(/___default\\./g, '.')\n // Replace full host path, leave only path to JS-file\n .replace(new RegExp(` \\\\(https?:\\\\/\\\\/${window.location.host}`, 'g'), ' (')\n )\n}\n\ninterface StructureErrorProps {\n error: unknown\n}\n\nexport function StructureError({error}: StructureErrorProps) {\n if (!(error instanceof PaneResolutionError)) {\n throw error\n }\n const {cause} = error\n const {t} = useTranslation(structureLocaleNamespace)\n\n // Serialize errors are well-formatted and should be readable, in these cases a stack trace is\n // usually not helpful. Build errors in dev (with HMR) usually also contains a bunch of garbage\n // instead of an actual error message, so make sure we show the message in these cases as well\n const stack = cause?.stack || error.stack\n const showStack =\n stack && !(cause instanceof SerializeError) && !error.message.includes('Module build failed:')\n\n const path = cause instanceof SerializeError ? cause.path : []\n const helpId = (cause instanceof SerializeError && cause.helpId) || error.helpId\n\n const handleReload = useCallback(() => {\n window.location.reload()\n }, [])\n\n return (\n <Card height=\"fill\" overflow=\"auto\" padding={4} sizing=\"border\" tone=\"critical\">\n <Container>\n <Heading as=\"h2\">{t('structure-error.header.text')}</Heading>\n\n <Card marginTop={4} padding={4} radius={2} overflow=\"auto\" shadow={1} tone=\"inherit\">\n {path.length > 0 && (\n <Stack space={2}>\n <Text size={1} weight=\"medium\">\n {t('structure-error.structure-path.label')}\n </Text>\n <Code>\n {/* TODO: it seems like the path is off by one and includes */}\n {/* `root` twice */}\n {path.slice(1).map((segment, i) => (\n <PathSegment key={`${segment}-${i}`}>{segment}</PathSegment>\n ))}\n </Code>\n </Stack>\n )}\n\n <Stack marginTop={4} space={2}>\n <Text size={1} weight=\"medium\">\n {t('structure-error.error.label')}\n </Text>\n <Code>{showStack ? formatStack(stack) : error.message}</Code>\n </Stack>\n\n {helpId && (\n <Box marginTop={4}>\n <Text>\n <a href={generateHelpUrl(helpId)} rel=\"noopener noreferrer\" target=\"_blank\">\n {t('structure-error.docs-link.text')}\n </a>\n </Text>\n </Box>\n )}\n\n <Box marginTop={4}>\n <Button\n text={t('structure-error.reload-button.text')}\n icon={SyncIcon}\n tone=\"primary\"\n onClick={handleReload}\n />\n </Box>\n </Card>\n </Container>\n </Card>\n )\n}\n","import {WarningOutlineIcon} from '@sanity/icons'\nimport {Box, Card, Container, Flex, Stack, Text} from '@sanity/ui'\nimport {useTranslation} from 'sanity'\n\nimport {structureLocaleNamespace} from '../../i18n'\n\nexport function NoDocumentTypesScreen() {\n const {t} = useTranslation(structureLocaleNamespace)\n\n return (\n <Card height=\"fill\">\n <Flex align=\"center\" height=\"fill\" justify=\"center\" padding={4} sizing=\"border\">\n <Container width={0}>\n <Card padding={4} radius={2} shadow={1} tone=\"caution\">\n <Flex>\n <Box>\n <Text size={1}>\n <WarningOutlineIcon />\n </Text>\n </Box>\n <Stack flex={1} marginLeft={3} space={3}>\n <Text as=\"h1\" size={1} weight=\"medium\">\n {t('no-document-types-screen.title')}\n </Text>\n <Text as=\"p\" muted size={1}>\n {t('no-document-types-screen.subtitle')}\n </Text>\n <Text as=\"p\" muted size={1}>\n <a\n href=\"https://www.sanity.io/docs/create-a-schema-and-configure-sanity-studio\"\n target=\"_blank\"\n rel=\"noreferrer\"\n >\n {t('no-document-types-screen.link-text')}\n </a>\n </Text>\n </Stack>\n </Flex>\n </Card>\n </Container>\n </Flex>\n </Card>\n )\n}\n","import {type ObjectSchemaType} from '@sanity/types'\nimport {useEffect} from 'react'\nimport {\n unstable_useValuePreview as useValuePreview,\n useEditState,\n usePerspective,\n useSchema,\n useTranslation,\n} from 'sanity'\n\nimport {LOADING_PANE} from '../../constants'\nimport {useDocumentLastRev} from '../../hooks/useDocumentLastRev'\nimport {structureLocaleNamespace} from '../../i18n'\nimport {type Panes} from '../../structureResolvers'\nimport {type DocumentPaneNode} from '../../types'\nimport {useStructureTool} from '../../useStructureTool'\n\ninterface StructureTitleProps {\n resolvedPanes: Panes['resolvedPanes']\n}\n\n// TODO: Fix state jank when editing different versions inside panes.\nconst DocumentTitle = (props: {documentId: string; documentType: string}) => {\n const {documentId, documentType} = props\n const {selectedReleaseId} = usePerspective()\n\n const editState = useEditState(documentId, documentType, 'default', selectedReleaseId)\n const schema = useSchema()\n const {t} = useTranslation(structureLocaleNamespace)\n const isNewDocument = !editState?.published && !editState?.draft\n const documentValue = editState?.version || editState?.draft || editState?.published\n const schemaType = schema.get(documentType) as ObjectSchemaType | undefined\n\n const {value, isLoading: previewValueIsLoading} = useValuePreview({\n enabled: !!documentValue,\n schemaType,\n value: documentValue,\n })\n\n const {lastRevisionDocument} = useDocumentLastRev(documentId, documentType)\n const isDeleted = lastRevisionDocument && !documentValue\n\n // if the document is deleted, we don't want to show the title\n const documentTitle = isDeleted\n ? ''\n : isNewDocument\n ? t('browser-document-title.new-document', {\n schemaType: schemaType?.title || schemaType?.name,\n })\n : value?.title || t('browser-document-title.untitled-document')\n\n const settled = editState.ready && !previewValueIsLoading\n const newTitle = useConstructDocumentTitle(documentTitle)\n useEffect(() => {\n if (!settled) return\n // Set the title as the document title\n document.title = newTitle\n }, [documentTitle, settled, newTitle])\n\n return null\n}\n\nconst PassthroughTitle = (props: {title?: string}) => {\n const {title} = props\n const newTitle = useConstructDocumentTitle(title)\n useEffect(() => {\n // Set the title as the document title\n document.title = newTitle\n }, [newTitle, title])\n return null\n}\n\nexport const StructureTitle = (props: StructureTitleProps) => {\n const {resolvedPanes} = props\n\n if (!resolvedPanes?.length) return null\n\n const lastPane = resolvedPanes[resolvedPanes.length - 1]\n\n // If the last pane is loading, display the structure tool title only\n if (isLoadingPane(lastPane)) {\n return <PassthroughTitle />\n }\n\n // If the last pane is a document\n if (isDocumentPane(lastPane)) {\n // Passthrough the document pane's title, which may be defined in structure builder\n if (lastPane?.title) {\n return <PassthroughTitle title={lastPane.title} />\n }\n\n // Otherwise, display a `document.title` containing the resolved Sanity document title\n return <DocumentTitle documentId={lastPane.options.id} documentType={lastPane.options.type} />\n }\n\n // Otherwise, display the last pane's title (if present)\n return <PassthroughTitle title={lastPane?.title} />\n}\n\n/**\n * Construct a pipe delimited title containing `activeTitle` (if applicable) and the base structure title.\n *\n * @param activeTitle - Title of the first segment\n *\n * @returns A pipe delimited title in the format `${activeTitle} | %BASE_STRUCTURE_TITLE%`\n * or simply `%BASE_STRUCTURE_TITLE` if `activeTitle` is undefined.\n */\nfunction useConstructDocumentTitle(activeTitle?: string) {\n const structureToolBaseTitle = useStructureTool().structureContext.title\n return [activeTitle, structureToolBaseTitle].filter((title) => title).join(' | ')\n}\n\n// Type guards\nfunction isDocumentPane(pane: Panes['resolvedPanes'][number]): pane is DocumentPaneNode {\n return pane !== LOADING_PANE && pane.type === 'document'\n}\n\nfunction isLoadingPane(pane: Panes['resolvedPanes'][number]): pane is typeof LOADING_PANE {\n return pane === LOADING_PANE\n}\n","import {PortalProvider, useTheme, useToast} from '@sanity/ui'\nimport {isHotkey} from 'is-hotkey-esm'\nimport {Fragment, memo, useCallback, useEffect, useState} from 'react'\nimport {_isCustomDocumentTypeDefinition, useSchema} from 'sanity'\nimport {useRouterState} from 'sanity/router'\nimport {styled} from 'styled-components'\n\nimport {LOADING_PANE} from '../../constants'\nimport {LoadingPane, StructureToolPane} from '../../panes'\nimport {useResolvedPanes} from '../../structureResolvers'\nimport {type PaneNode} from '../../types'\nimport {useStructureTool} from '../../useStructureTool'\nimport {PaneLayout} from '../pane'\nimport {NoDocumentTypesScreen} from './NoDocumentTypesScreen'\nimport {StructureTitle} from './StructureTitle'\n\ninterface StructureToolProps {\n onPaneChange: (panes: Array<PaneNode | typeof LOADING_PANE>) => void\n}\n\nconst StyledPaneLayout = styled(PaneLayout)`\n min-height: 100%;\n min-width: 320px;\n`\n\nconst isSaveHotkey = isHotkey('mod+s')\n\n/**\n * @internal\n */\nexport const StructureTool = memo(function StructureTool({onPaneChange}: StructureToolProps) {\n const {push: pushToast} = useToast()\n const schema = useSchema()\n const {layoutCollapsed, setLayoutCollapsed} = useStructureTool()\n const {paneDataItems, resolvedPanes} = useResolvedPanes()\n // Intent resolving is processed by the sibling `<IntentResolver />` but it doesn't have a UI for indicating progress.\n // We handle that here, so if there are only 1 pane (the root structure), and there's an intent state in the router, we need to show a placeholder LoadingPane until\n // the structure is resolved and we know what panes to load/display\n const isResolvingIntent = useRouterState(\n useCallback((routerState) => typeof routerState.intent === 'string', []),\n )\n const {\n sanity: {media},\n } = useTheme()\n\n const [portalElement, setPortalElement] = useState<HTMLDivElement | null>(null)\n\n const handleRootCollapse = useCallback(() => setLayoutCollapsed(true), [setLayoutCollapsed])\n const handleRootExpand = useCallback(() => setLayoutCollapsed(false), [setLayoutCollapsed])\n\n useEffect(() => {\n // we check for length before emitting here to skip the initial empty array\n // state from the `useResolvedPanes` hook. there should always be a root\n // pane emitted on subsequent emissions\n if (resolvedPanes.length) {\n onPaneChange(resolvedPanes)\n }\n }, [onPaneChange, resolvedPanes])\n\n useEffect(() => {\n const handleGlobalKeyDown = (event: KeyboardEvent) => {\n // Prevent `Cmd+S`\n if (isSaveHotkey(event)) {\n event.preventDefault()\n\n pushToast({\n closable: true,\n id: 'auto-save-message',\n status: 'info',\n title: 'Your work is automatically saved!',\n duration: 4000,\n })\n }\n }\n\n window.addEventListener('keydown', handleGlobalKeyDown)\n return () => window.removeEventListener('keydown', handleGlobalKeyDown)\n }, [pushToast])\n\n const hasDefinedDocumentTypes = schema._original?.types.some(_isCustomDocumentTypeDefinition)\n\n if (!hasDefinedDocumentTypes) {\n return <NoDocumentTypesScreen />\n }\n\n return (\n <PortalProvider element={portalElement || null}>\n <StyledPaneLayout\n flex={1}\n height={layoutCollapsed ? undefined : 'fill'}\n minWidth={media[1]}\n onCollapse={handleRootCollapse}\n onExpand={handleRootExpand}\n >\n {paneDataItems.map(\n ({\n active,\n childItemId,\n groupIndex,\n itemId,\n key: paneKey,\n pane,\n index: paneIndex,\n params: paneParams,\n path,\n payload,\n siblingIndex,\n selected,\n }) => (\n <Fragment key={`${pane === LOADING_PANE ? 'loading' : pane.type}-${paneIndex}`}>\n {pane === LOADING_PANE ? (\n <LoadingPane paneKey={paneKey} path={path} selected={selected} />\n ) : (\n <StructureToolPane\n active={active}\n groupIndex={groupIndex}\n index={paneIndex}\n pane={pane}\n childItemId={childItemId}\n itemId={itemId}\n paneKey={paneKey}\n params={paneParams}\n payload={payload}\n path={path}\n selected={selected}\n siblingIndex={siblingIndex}\n />\n )}\n </Fragment>\n ),\n )}\n {/* If there's just 1 pane (the root), or less, and we're resolving an intent then it's necessa