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

658 lines (598 loc) • 20.9 kB
import {fromUrl} from '@sanity/bifur-client' import {createClient, type SanityClient} from '@sanity/client' import {type CurrentUser, type Schema, type SchemaValidationProblem} from '@sanity/types' import {studioTheme} from '@sanity/ui' import {type i18n} from 'i18next' import {startCase} from 'lodash' import {type ComponentType, createElement, type ElementType, isValidElement} from 'react' import {isValidElementType} from 'react-is' import {map, shareReplay} from 'rxjs/operators' import {FileSource, ImageSource} from '../form/studio/assetSource' import {type LocaleSource} from '../i18n' import {prepareI18n} from '../i18n/i18nConfig' import {createSchema} from '../schema' import {type AuthStore, createAuthStore, isAuthStore} from '../store/_legacy' import {validateWorkspaces} from '../studio' import {filterDefinitions} from '../studio/components/navbar/search/definitions/defaultFilters' import {operatorDefinitions} from '../studio/components/navbar/search/definitions/operators/defaultOperators' import {type InitialValueTemplateItem, type Template, type TemplateItem} from '../templates' import {EMPTY_ARRAY, isNonNullable} from '../util' import { documentActionsReducer, documentBadgesReducer, documentCommentsEnabledReducer, documentInspectorsReducer, documentLanguageFilterReducer, fileAssetSourceResolver, imageAssetSourceResolver, initialDocumentActions, initialDocumentBadges, initialLanguageFilter, internalTasksReducer, newDocumentOptionsResolver, newSearchEnabledReducer, partialIndexingEnabledReducer, resolveProductionUrlReducer, schemaTemplatesReducer, toolsReducer, } from './configPropertyReducers' import {ConfigResolutionError} from './ConfigResolutionError' import {createDefaultIcon} from './createDefaultIcon' import {documentFieldActionsReducer, initialDocumentFieldActions} from './document' import {resolveConfigProperty} from './resolveConfigProperty' import {resolveSchemaTypes} from './resolveSchemaTypes' import {SchemaError} from './SchemaError' import { type Config, type ConfigContext, type MissingConfigFile, type PreparedConfig, type SingleWorkspace, type Source, type SourceClientOptions, type SourceOptions, type WorkspaceOptions, type WorkspaceSummary, } from './types' type InternalSource = WorkspaceSummary['__internal']['sources'][number] const isError = (p: SchemaValidationProblem) => p.severity === 'error' function normalizeIcon( icon: ComponentType | ElementType | undefined, title: string, subtitle = '', ): JSX.Element { if (isValidElementType(icon)) return createElement(icon) if (isValidElement(icon)) return icon return createDefaultIcon(title, subtitle) } const preparedWorkspaces = new WeakMap<SingleWorkspace | WorkspaceOptions, WorkspaceSummary>() /** * Takes in a config (created from the `defineConfig` function) and returns * an array of `WorkspaceSummary`. Note: this only partially resolves a config. * * For usage inside the Studio, it's preferred to pull the pre-resolved * workspaces and sources via `useWorkspace` or `useSource`. For usage outside * the Studio or for testing, use `resolveConfig`. * * @internal */ export function prepareConfig( config: Config | MissingConfigFile, options?: {basePath?: string}, ): PreparedConfig { if (!Array.isArray(config) && 'missingConfigFile' in config) { throw new ConfigResolutionError({ name: '', type: 'configuration file', causes: ['No `sanity.config.ts` file found', 'No `sanity.config.js` file found'], }) } const rootPath = getRootPath(options?.basePath) const workspaceOptions: WorkspaceOptions[] | [SingleWorkspace] = Array.isArray(config) ? config : [config] try { validateWorkspaces({workspaces: workspaceOptions}) } catch (e) { throw new ConfigResolutionError({ name: '', type: 'workspace', causes: [e.message], }) } const workspaces = workspaceOptions.map((rawWorkspace): WorkspaceSummary => { if (preparedWorkspaces.has(rawWorkspace)) { return preparedWorkspaces.get(rawWorkspace)! } const {unstable_sources: nestedSources = [], ...rootSource} = rawWorkspace const sources = [rootSource as SourceOptions, ...nestedSources] const resolvedSources = sources.map((source): InternalSource => { const {projectId, dataset} = source let schemaTypes try { schemaTypes = resolveSchemaTypes({ config: source, context: {projectId, dataset}, }) } catch (e) { throw new ConfigResolutionError({ name: source.name, type: 'source', causes: [e], }) } const schema = createSchema({ name: source.name, types: schemaTypes, }) const schemaValidationProblemGroups = schema._validation const schemaErrors = schemaValidationProblemGroups?.filter((msg) => msg.problems.some(isError), ) if (schemaValidationProblemGroups && schemaErrors?.length) { // TODO: consider using the `ConfigResolutionError` throw new SchemaError(schema) } const auth = getAuthStore(source) const i18n = prepareI18n(source) const source$ = auth.state.pipe( map(({client, authenticated, currentUser}) => { return resolveSource({ config: source, client, currentUser, schema, authenticated, auth, i18n, }) }), shareReplay(1), ) return { name: source.name, projectId: source.projectId, dataset: source.dataset, title: source.title || startCase(source.name), auth, schema, i18n: i18n.source, source: source$, } }) const title = rootSource.title || startCase(rootSource.name) const workspaceSummary: WorkspaceSummary = { type: 'workspace-summary', auth: resolvedSources[0].auth, basePath: joinBasePath(rootPath, rootSource.basePath), dataset: rootSource.dataset, schema: resolvedSources[0].schema, i18n: resolvedSources[0].i18n, customIcon: !!rootSource.icon, icon: normalizeIcon(rootSource.icon, title, `${rootSource.projectId} ${rootSource.dataset}`), name: rootSource.name || 'default', projectId: rootSource.projectId, theme: rootSource.theme || studioTheme, title, subtitle: rootSource.subtitle, __internal: { sources: resolvedSources, }, tasks: rawWorkspace.unstable_tasks ?? {enabled: true}, } preparedWorkspaces.set(rawWorkspace, workspaceSummary) return workspaceSummary }) return {type: 'prepared-config', workspaces} } function getAuthStore(source: SourceOptions): AuthStore { if (isAuthStore(source.auth)) { return source.auth } const clientFactory = source.unstable_clientFactory || createClient const {projectId, dataset, apiHost} = source return createAuthStore({apiHost, ...source.auth, clientFactory, dataset, projectId}) } interface ResolveSourceOptions { config: SourceOptions schema: Schema client: SanityClient currentUser: CurrentUser | null authenticated: boolean auth: AuthStore i18n: {i18next: i18n; source: LocaleSource} } function getBifurClient(client: SanityClient, auth: AuthStore) { const bifurVersionedClient = client.withConfig({apiVersion: '2022-06-30'}) const {dataset, url: baseUrl, requestTagPrefix = 'sanity.studio'} = bifurVersionedClient.config() const url = `${baseUrl.replace(/\/+$/, '')}/socket/${dataset}`.replace(/^http/, 'ws') const urlWithTag = `${url}?tag=${requestTagPrefix}` const options = auth.token ? {token$: auth.token} : {} return fromUrl(urlWithTag, options) } function resolveSource({ config, client, currentUser, schema, authenticated, auth, i18n, }: ResolveSourceOptions): Source { const {dataset, projectId} = config const bifur = getBifurClient(client, auth) const errors: unknown[] = [] const clients: Record<string, SanityClient> = {} const getClient = (options: SourceClientOptions): SanityClient => { if (!options || !options.apiVersion) { throw new Error('Missing required `apiVersion` option') } if (!clients[options.apiVersion]) { clients[options.apiVersion] = client.withConfig(options) } return clients[options.apiVersion] } const context: ConfigContext & {client: SanityClient} = { client, getClient, currentUser, dataset, projectId, schema, i18n: i18n.source, } // <TEMPORARY UGLY HACK TO PRINT DEPRECATION WARNINGS ON USE> /* eslint-disable no-proto */ const wrappedClient = client as any context.client = [...Object.keys(client), ...Object.keys(wrappedClient.__proto__)].reduce( (acc, key) => { const original = Object.hasOwnProperty.call(client, key) ? wrappedClient[key] : wrappedClient.__proto__[key] return Object.defineProperty(acc, key, { get() { console.warn( '`configContext.client` is deprecated and will be removed in the next release! Use `context.getClient({apiVersion: "2021-06-07"})` instead', ) return original }, }) }, {}, ) as any as SanityClient /* eslint-enable no-proto */ // </TEMPORARY UGLY HACK TO PRINT DEPRECATION WARNINGS ON USE> let templates!: Source['templates'] try { templates = resolveConfigProperty({ config, context, propertyName: 'schema.templates', reducer: schemaTemplatesReducer, initialValue: schema .getTypeNames() .filter((typeName) => !/^sanity\./.test(typeName)) .map((typeName) => schema.get(typeName)) .filter(isNonNullable) .filter((schemaType) => schemaType.type?.name === 'document') .map((schemaType) => { const template: Template = { id: schemaType.name, schemaType: schemaType.name, title: schemaType.title || schemaType.name, icon: schemaType.icon, value: schemaType.initialValue || {_type: schemaType.name}, } return template }), }) // TODO: validate templates // TODO: validate that each one has a unique template ID } catch (e) { throw new ConfigResolutionError({ name: config.name, type: 'source', causes: [e], }) } let tools!: Source['tools'] try { tools = resolveConfigProperty({ config, context, initialValue: [], propertyName: 'tools', reducer: toolsReducer, }) } catch (e) { throw new ConfigResolutionError({ name: config.name, type: 'source', causes: [e], }) } // In this case we want to throw an error because it is not possible to have // a tool with the name "tool" due to logic that happens in the router. if (tools.some(({name}) => name === 'tool')) { throw new Error('A tool cannot have the name "tool". Please enter a different name.') } const initialTemplatesResponses = templates // filter out the ones with parameters to fill .filter((template) => !template.parameters?.length) .map( (template): TemplateItem => ({ templateId: template.id, description: template.description, icon: template.icon, title: template.title, }), ) const templateMap = templates.reduce((acc, template) => { acc.set(template.id, template) return acc }, new Map<string, Template>()) // TODO: extract this function const resolveNewDocumentOptions: Source['document']['resolveNewDocumentOptions'] = ( creationContext, ) => { const {schemaType: schemaTypeName} = creationContext const templateResponses = resolveConfigProperty({ config, context: {...context, creationContext}, initialValue: initialTemplatesResponses, propertyName: 'document.resolveNewDocumentOptions', reducer: newDocumentOptionsResolver, }) const templateErrors: unknown[] = [] // TODO: validate template responses // ensure there is a matching template per each one if (templateErrors.length) { throw new ConfigResolutionError({ name: config.name, type: 'source', causes: templateErrors, }) } return ( templateResponses // take the template responses and transform them into the formal // `InitialValueTemplateItem` .map((response, index): InitialValueTemplateItem => { const template = templateMap.get(response.templateId) if (!template) { throw new Error(`Could not find template with ID \`${response.templateId}\``) } const schemaType = schema.get(template.schemaType) if (!schemaType) { throw new Error( `Could not find matching schema type \`${template.schemaType}\` for template \`${template.id}\``, ) } const title = response.title || template.title // Don't show the type name as subtitle if it's the same as the template name const defaultSubtitle = schemaType?.title === title ? undefined : schemaType?.title return { id: `${response.templateId}-${index}`, templateId: response.templateId, type: 'initialValueTemplateItem', title, i18n: response.i18n || template.i18n, subtitle: response.subtitle || defaultSubtitle, description: response.description || template.description, icon: response.icon || template.icon || schemaType?.icon, initialDocumentId: response.initialDocumentId, parameters: response.parameters, schemaType: template.schemaType, } }) .filter((item) => { // if we are in a creationContext where there is no schema type, // then keep everything if (!schemaTypeName) return true // If we are in a 'document' creationContext then keep everything if (creationContext.type === 'document') return true // else only keep the `schemaType`s that match the creationContext return schemaTypeName === templateMap.get(item.templateId)?.schemaType }) ) } let staticInitialValueTemplateItems!: InitialValueTemplateItem[] try { staticInitialValueTemplateItems = resolveNewDocumentOptions({type: 'global'}) } catch (e) { errors.push(e) } if (errors.length) { throw new ConfigResolutionError({ name: config.name, type: 'source', causes: errors, }) } const source: Source = { type: 'source', name: config.name, title: config.title || startCase(config.name), schema, getClient, dataset, projectId, tools, currentUser, authenticated, templates, auth, i18n: i18n.source, // eslint-disable-next-line camelcase __internal_tasks: internalTasksReducer({ config, }), document: { actions: (partialContext) => resolveConfigProperty({ config, context: {...context, ...partialContext}, initialValue: initialDocumentActions, propertyName: 'document.actions', reducer: documentActionsReducer, }), badges: (partialContext) => resolveConfigProperty({ config, context: {...context, ...partialContext}, initialValue: initialDocumentBadges, propertyName: 'document.badges', reducer: documentBadgesReducer, }), unstable_fieldActions: (partialContext) => resolveConfigProperty({ config, context: {...context, ...partialContext}, initialValue: initialDocumentFieldActions, propertyName: 'document.unstable_fieldActions', reducer: documentFieldActionsReducer, }), inspectors: (partialContext) => resolveConfigProperty({ config, context: {...context, ...partialContext}, initialValue: EMPTY_ARRAY, propertyName: 'document.inspectors', reducer: documentInspectorsReducer, }), resolveProductionUrl: (partialContext) => resolveConfigProperty({ config, context: {...context, ...partialContext}, initialValue: undefined, propertyName: 'resolveProductionUrl', asyncReducer: resolveProductionUrlReducer, }), resolveNewDocumentOptions, unstable_languageFilter: (partialContext) => resolveConfigProperty({ config, context: {...context, ...partialContext}, initialValue: initialLanguageFilter, propertyName: 'document.unstable_languageFilter', reducer: documentLanguageFilterReducer, }), unstable_comments: { enabled: (partialContext) => { return documentCommentsEnabledReducer({ context: partialContext, config, initialValue: true, }) }, }, }, form: { file: { assetSources: resolveConfigProperty({ config, context, initialValue: [FileSource], propertyName: 'formBuilder.file.assetSources', reducer: fileAssetSourceResolver, }), directUploads: // TODO: consider refactoring this to `noDirectUploads` or similar // default value for this is `true` config.form?.file?.directUploads === undefined ? true : config.form.file.directUploads, }, image: { assetSources: resolveConfigProperty({ config, context, initialValue: [ImageSource], propertyName: 'formBuilder.image.assetSources', reducer: imageAssetSourceResolver, }), directUploads: // TODO: consider refactoring this to `noDirectUploads` or similar // default value for this is `true` config.form?.image?.directUploads === undefined ? true : config.form.image.directUploads, }, }, search: { filters: filterDefinitions, operators: operatorDefinitions, unstable_partialIndexing: { enabled: partialIndexingEnabledReducer({ config, initialValue: config.search?.unstable_partialIndexing?.enabled ?? false, }), }, unstable_enableNewSearch: resolveConfigProperty({ config, context, reducer: newSearchEnabledReducer, propertyName: 'search.unstable_enableNewSearch', initialValue: false, }), // we will use this when we add search config to PluginOptions /*filters: resolveConfigProperty({ config, context: context, initialValue: filterDefinitions, propertyName: 'search.filters', reducer: searchFilterReducer, }), operators: resolveConfigProperty({ config, context: context, initialValue: operatorDefinitions as SearchOperatorDefinition[], propertyName: 'search.operators', reducer: searchOperatorsReducer, }),*/ }, __internal: { bifur, i18next: i18n.i18next, staticInitialValueTemplateItems, options: config, }, } return source } /** * Validate and normalize the `basePath` option. * The root path will be used to prepend workspace-specific base paths. * For instance, a `/studio` root path is joined with `/design` to become `/studio/design`. * * @param basePath - The base path to validate. If not set, an empty string will be returned. * @returns A normalized string * @throws ConfigResolutionError if the basePath is invalid * @internal */ function getRootPath(basePath?: string) { const rootPath = basePath || '' if (typeof rootPath !== 'string' || (rootPath.length > 0 && !rootPath.startsWith('/'))) { throw new ConfigResolutionError({ name: '', type: 'options', causes: ['basePath must be a string, and must start with a slash'], }) } // Since we'll be appending other base paths, we don't want to end up with double slashes return rootPath === '/' ? '' : rootPath } /** * Join the root path of the studio with a workspace base path * * @param rootPath - The root path to prepend to the base path * @param basePath - The base path of the workspace (can be empty) * @returns A normalized and joined, complete base path for a workspace * @internal */ function joinBasePath(rootPath: string, basePath?: string) { const joined = [rootPath, basePath || ''] // Remove leading/trailing slashes .map((path) => path.replace(/^\/+/g, '').replace(/\/+$/g, '')) // Remove empty segments .filter(Boolean) // Join the segments .join('/') return `/${joined}` }