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
text/typescript
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}`
}