diff --git a/dev/embedded-studio/package.json b/dev/embedded-studio/package.json
index 1d245fd10e5..e60a14dff7c 100644
--- a/dev/embedded-studio/package.json
+++ b/dev/embedded-studio/package.json
@@ -3,7 +3,7 @@
"version": "3.59.0",
"private": true,
"scripts": {
- "build": "tsc && vite build",
+ "build": "tsc && vite build && sanity manifest extract",
"dev": "vite",
"preview": "vite preview"
},
diff --git a/dev/embedded-studio/sanity.cli.ts b/dev/embedded-studio/sanity.cli.ts
new file mode 100644
index 00000000000..fac247bf8cf
--- /dev/null
+++ b/dev/embedded-studio/sanity.cli.ts
@@ -0,0 +1,8 @@
+import {defineCliConfig} from 'sanity/cli'
+
+export default defineCliConfig({
+ api: {
+ projectId: 'ppsg7ml5',
+ dataset: 'test',
+ },
+})
diff --git a/dev/embedded-studio/sanity.config.ts b/dev/embedded-studio/sanity.config.ts
new file mode 100644
index 00000000000..c49026536a2
--- /dev/null
+++ b/dev/embedded-studio/sanity.config.ts
@@ -0,0 +1,34 @@
+import {defineConfig, defineType} from 'sanity'
+import {structureTool} from 'sanity/structure'
+
+const BLOG_POST_SCHEMA = defineType({
+ type: 'document',
+ name: 'blogPost',
+ title: 'Blog post',
+ fields: [
+ {
+ type: 'string',
+ name: 'title',
+ title: 'Title',
+ },
+ ],
+})
+
+export const SCHEMA_TYPES = [BLOG_POST_SCHEMA]
+
+export default defineConfig({
+ projectId: 'ppsg7ml5',
+ dataset: 'test',
+
+ document: {
+ unstable_comments: {
+ enabled: true,
+ },
+ },
+
+ schema: {
+ types: SCHEMA_TYPES,
+ },
+
+ plugins: [structureTool()],
+})
diff --git a/dev/embedded-studio/src/App.tsx b/dev/embedded-studio/src/App.tsx
index d07913d0206..7ee792a216b 100644
--- a/dev/embedded-studio/src/App.tsx
+++ b/dev/embedded-studio/src/App.tsx
@@ -1,46 +1,8 @@
import {Button, Card, Flex, studioTheme, ThemeProvider, usePrefersDark} from '@sanity/ui'
import {useCallback, useMemo, useState} from 'react'
-import {
- defineConfig,
- defineType,
- Studio,
- StudioLayout,
- StudioProvider,
- type StudioThemeColorSchemeKey,
-} from 'sanity'
-import {structureTool} from 'sanity/structure'
+import {Studio, StudioLayout, StudioProvider, type StudioThemeColorSchemeKey} from 'sanity'
-const BLOG_POST_SCHEMA = defineType({
- type: 'document',
- name: 'blogPost',
- title: 'Blog post',
- fields: [
- {
- type: 'string',
- name: 'title',
- title: 'Title',
- },
- ],
-})
-
-const SCHEMA_TYPES = [BLOG_POST_SCHEMA]
-
-const config = defineConfig({
- projectId: 'ppsg7ml5',
- dataset: 'test',
-
- document: {
- unstable_comments: {
- enabled: true,
- },
- },
-
- schema: {
- types: SCHEMA_TYPES,
- },
-
- plugins: [structureTool()],
-})
+import config from '../sanity.config'
export function App() {
const prefersDark = usePrefersDark()
diff --git a/dev/starter-next-studio/.gitignore b/dev/starter-next-studio/.gitignore
index a680367ef56..f0f5197150f 100644
--- a/dev/starter-next-studio/.gitignore
+++ b/dev/starter-next-studio/.gitignore
@@ -1 +1,4 @@
.next
+
+public/static/*.create-schema.json
+public/static/create-manifest.json
diff --git a/dev/starter-next-studio/components/Studio.tsx b/dev/starter-next-studio/components/Studio.tsx
index 00557ec43c7..11aa0e3ce65 100644
--- a/dev/starter-next-studio/components/Studio.tsx
+++ b/dev/starter-next-studio/components/Studio.tsx
@@ -1,41 +1,13 @@
-import {useMemo} from 'react'
-import {defineConfig, Studio} from 'sanity'
-import {structureTool} from 'sanity/structure'
+import {Studio} from 'sanity'
+
+import config from '../sanity.config'
const wrapperStyles = {height: '100vh', width: '100vw'}
export default function StudioRoot({basePath}: {basePath: string}) {
- const config = useMemo(
- () =>
- defineConfig({
- basePath,
- plugins: [structureTool()],
- title: 'Next.js Starter',
- projectId: 'ppsg7ml5',
- dataset: 'test',
- schema: {
- types: [
- {
- type: 'document',
- name: 'post',
- title: 'Post',
- fields: [
- {
- type: 'string',
- name: 'title',
- title: 'Title',
- },
- ],
- },
- ],
- },
- }),
- [basePath],
- )
-
return (
-
+
)
}
diff --git a/dev/starter-next-studio/package.json b/dev/starter-next-studio/package.json
index 6db5bcb1008..c92faad7acf 100644
--- a/dev/starter-next-studio/package.json
+++ b/dev/starter-next-studio/package.json
@@ -5,7 +5,7 @@
"license": "MIT",
"author": "Sanity.io ",
"scripts": {
- "build": "next build",
+ "build": "sanity manifest extract --path public/static && next build",
"dev": "next dev",
"start": "next start"
},
diff --git a/dev/starter-next-studio/sanity.cli.ts b/dev/starter-next-studio/sanity.cli.ts
new file mode 100644
index 00000000000..fac247bf8cf
--- /dev/null
+++ b/dev/starter-next-studio/sanity.cli.ts
@@ -0,0 +1,8 @@
+import {defineCliConfig} from 'sanity/cli'
+
+export default defineCliConfig({
+ api: {
+ projectId: 'ppsg7ml5',
+ dataset: 'test',
+ },
+})
diff --git a/dev/starter-next-studio/sanity.config.ts b/dev/starter-next-studio/sanity.config.ts
new file mode 100644
index 00000000000..102cbb15f94
--- /dev/null
+++ b/dev/starter-next-studio/sanity.config.ts
@@ -0,0 +1,25 @@
+import {defineConfig} from 'sanity'
+import {structureTool} from 'sanity/structure'
+
+export default defineConfig({
+ plugins: [structureTool()],
+ title: 'Next.js Starter',
+ projectId: 'ppsg7ml5',
+ dataset: 'test',
+ schema: {
+ types: [
+ {
+ type: 'document',
+ name: 'post',
+ title: 'Post',
+ fields: [
+ {
+ type: 'string',
+ name: 'title',
+ title: 'Title',
+ },
+ ],
+ },
+ ],
+ },
+})
diff --git a/packages/@sanity/cli/src/util/noSuchCommandText.ts b/packages/@sanity/cli/src/util/noSuchCommandText.ts
index 07b94d6b1ce..3c35f5dd65d 100644
--- a/packages/@sanity/cli/src/util/noSuchCommandText.ts
+++ b/packages/@sanity/cli/src/util/noSuchCommandText.ts
@@ -18,6 +18,7 @@ const coreCommands = [
'graphql',
'hook',
'migration',
+ 'manifest',
'preview',
'schema',
'start',
diff --git a/packages/sanity/package.config.ts b/packages/sanity/package.config.ts
index 08aa9c96444..a2e7757e57a 100644
--- a/packages/sanity/package.config.ts
+++ b/packages/sanity/package.config.ts
@@ -41,6 +41,11 @@ export default defineConfig({
require: './lib/_internal/cli/threads/extractSchema.js',
runtime: 'node',
},
+ {
+ source: './src/_internal/cli/threads/extractManifest.ts',
+ require: './lib/_internal/cli/threads/extractManifest.js',
+ runtime: 'node',
+ },
],
extract: {
diff --git a/packages/sanity/src/_internal/cli/actions/build/buildAction.ts b/packages/sanity/src/_internal/cli/actions/build/buildAction.ts
index 62b9b8fe8d2..24cb65957bc 100644
--- a/packages/sanity/src/_internal/cli/actions/build/buildAction.ts
+++ b/packages/sanity/src/_internal/cli/actions/build/buildAction.ts
@@ -187,6 +187,7 @@ export default async function buildSanityStudio(
spin.text = `Build Sanity Studio (${buildDuration.toFixed()}ms)`
spin.succeed()
+
trace.complete()
if (flags.stats) {
output.print('\nLargest module files:')
diff --git a/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts b/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts
index 364aec55735..042fb803190 100644
--- a/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts
+++ b/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts
@@ -7,6 +7,7 @@ import tar from 'tar-fs'
import {shouldAutoUpdate} from '../../util/shouldAutoUpdate'
import buildSanityStudio, {type BuildSanityStudioCommandFlags} from '../build/buildAction'
+import {extractManifestSafe} from '../manifest/extractManifestAction'
import {
checkDir,
createDeployment,
@@ -101,16 +102,25 @@ export default async function deployStudioAction(
// Always build the project, unless --no-build is passed
const shouldBuild = flags.build
if (shouldBuild) {
- const buildArgs = [customSourceDir].filter(Boolean)
- const {didCompile} = await buildSanityStudio(
- {...args, extOptions: flags, argsWithoutOptions: buildArgs},
- context,
- {basePath: '/'},
- )
+ const buildArgs = {
+ ...args,
+ extOptions: flags,
+ argsWithoutOptions: [customSourceDir].filter(Boolean),
+ }
+ const {didCompile} = await buildSanityStudio(buildArgs, context, {basePath: '/'})
if (!didCompile) {
return
}
+
+ await extractManifestSafe(
+ {
+ ...buildArgs,
+ extOptions: {},
+ extraArguments: [],
+ },
+ context,
+ )
}
// Ensure that the directory exists, is a directory and seems to have valid content
diff --git a/packages/sanity/src/_internal/cli/actions/manifest/extractManifestAction.ts b/packages/sanity/src/_internal/cli/actions/manifest/extractManifestAction.ts
new file mode 100644
index 00000000000..509110cf775
--- /dev/null
+++ b/packages/sanity/src/_internal/cli/actions/manifest/extractManifestAction.ts
@@ -0,0 +1,182 @@
+import {createHash} from 'node:crypto'
+import {mkdir, writeFile} from 'node:fs/promises'
+import {dirname, join, resolve} from 'node:path'
+import {Worker} from 'node:worker_threads'
+
+import {type CliCommandArguments, type CliCommandContext} from '@sanity/cli'
+import {minutesToMilliseconds} from 'date-fns'
+import readPkgUp from 'read-pkg-up'
+
+import {
+ type CreateManifest,
+ type CreateWorkspaceManifest,
+ type ManifestWorkspaceFile,
+} from '../../../manifest/manifestTypes'
+import {type ExtractManifestWorkerData} from '../../threads/extractManifest'
+import {getTimer} from '../../util/timing'
+
+const MANIFEST_FILENAME = 'create-manifest.json'
+const SCHEMA_FILENAME_SUFFIX = '.create-schema.json'
+
+/** Escape-hatch env flags to change action behavior */
+const FEATURE_ENABLED_ENV_NAME = 'SANITY_CLI_EXTRACT_MANIFEST_ENABLED'
+const EXTRACT_MANIFEST_ENABLED = process.env[FEATURE_ENABLED_ENV_NAME] !== 'false'
+const EXTRACT_MANIFEST_LOG_ERRORS = process.env.SANITY_CLI_EXTRACT_MANIFEST_LOG_ERRORS === 'true'
+
+const CREATE_TIMER = 'create-manifest'
+
+const EXTRACT_TASK_TIMEOUT_MS = minutesToMilliseconds(2)
+
+const EXTRACT_FAILURE_MESSAGE =
+ "Couldn't extract manifest file. Sanity Create will not be available for the studio.\n" +
+ `Disable this message with ${FEATURE_ENABLED_ENV_NAME}=false`
+
+interface ExtractFlags {
+ path?: string
+}
+
+/**
+ * This function will never throw.
+ * @returns `undefined` if extract succeeded - caught error if it failed
+ */
+export async function extractManifestSafe(
+ args: CliCommandArguments,
+ context: CliCommandContext,
+): Promise {
+ if (!EXTRACT_MANIFEST_ENABLED) {
+ return undefined
+ }
+
+ try {
+ await extractManifest(args, context)
+ return undefined
+ } catch (err) {
+ if (EXTRACT_MANIFEST_LOG_ERRORS) {
+ context.output.error(err)
+ }
+ return err
+ }
+}
+
+async function extractManifest(
+ args: CliCommandArguments,
+ context: CliCommandContext,
+): Promise {
+ const {output, workDir} = context
+
+ const flags = args.extOptions
+ const defaultOutputDir = resolve(join(workDir, 'dist'))
+
+ const outputDir = resolve(defaultOutputDir)
+ const defaultStaticPath = join(outputDir, 'static')
+
+ const staticPath = flags.path ?? defaultStaticPath
+
+ const path = join(staticPath, MANIFEST_FILENAME)
+
+ const rootPkgPath = readPkgUp.sync({cwd: __dirname})?.path
+ if (!rootPkgPath) {
+ throw new Error('Could not find root directory for `sanity` package')
+ }
+
+ const timer = getTimer()
+ timer.start(CREATE_TIMER)
+ const spinner = output.spinner({}).start('Extracting manifest')
+
+ try {
+ const workspaceManifests = await getWorkspaceManifests({rootPkgPath, workDir})
+ await mkdir(staticPath, {recursive: true})
+
+ const workspaceFiles = await writeWorkspaceFiles(workspaceManifests, staticPath)
+
+ const manifest: CreateManifest = {
+ version: 1,
+ createdAt: new Date().toISOString(),
+ workspaces: workspaceFiles,
+ }
+
+ await writeFile(path, JSON.stringify(manifest, null, 2))
+ const manifestDuration = timer.end(CREATE_TIMER)
+
+ spinner.succeed(`Extracted manifest (${manifestDuration.toFixed()}ms)`)
+ } catch (err) {
+ spinner.info(EXTRACT_FAILURE_MESSAGE)
+ throw err
+ }
+}
+
+async function getWorkspaceManifests({
+ rootPkgPath,
+ workDir,
+}: {
+ rootPkgPath: string
+ workDir: string
+}): Promise {
+ const workerPath = join(
+ dirname(rootPkgPath),
+ 'lib',
+ '_internal',
+ 'cli',
+ 'threads',
+ 'extractManifest.js',
+ )
+
+ const worker = new Worker(workerPath, {
+ workerData: {workDir} satisfies ExtractManifestWorkerData,
+ // eslint-disable-next-line no-process-env
+ env: process.env,
+ })
+
+ let timeout = false
+ const timeoutId = setTimeout(() => {
+ timeout = true
+ worker.terminate()
+ }, EXTRACT_TASK_TIMEOUT_MS)
+
+ try {
+ return await new Promise((resolveWorkspaces, reject) => {
+ const buffer: CreateWorkspaceManifest[] = []
+ worker.addListener('message', (message) => buffer.push(message))
+ worker.addListener('exit', (exitCode) => {
+ if (exitCode === 0) {
+ resolveWorkspaces(buffer)
+ } else if (timeout) {
+ reject(new Error(`Extract manifest was aborted after ${EXTRACT_TASK_TIMEOUT_MS}ms`))
+ }
+ })
+ worker.addListener('error', reject)
+ })
+ } finally {
+ clearTimeout(timeoutId)
+ }
+}
+
+function writeWorkspaceFiles(
+ manifestWorkspaces: CreateWorkspaceManifest[],
+ staticPath: string,
+): Promise {
+ const output = manifestWorkspaces.reduce[]>(
+ (workspaces, workspace) => {
+ return [...workspaces, writeWorkspaceSchemaFile(workspace, staticPath)]
+ },
+ [],
+ )
+ return Promise.all(output)
+}
+
+async function writeWorkspaceSchemaFile(
+ workspace: CreateWorkspaceManifest,
+ staticPath: string,
+): Promise {
+ const schemaString = JSON.stringify(workspace.schema, null, 2)
+ const hash = createHash('sha1').update(schemaString).digest('hex')
+ const filename = `${hash.slice(0, 8)}${SCHEMA_FILENAME_SUFFIX}`
+
+ // workspaces with identical schemas will overwrite each others schema file. This is ok, since they are identical and can be shared
+ await writeFile(join(staticPath, filename), schemaString)
+
+ return {
+ ...workspace,
+ schema: filename,
+ }
+}
diff --git a/packages/sanity/src/_internal/cli/commands/index.ts b/packages/sanity/src/_internal/cli/commands/index.ts
index e27daee4cce..1a6801e8a82 100644
--- a/packages/sanity/src/_internal/cli/commands/index.ts
+++ b/packages/sanity/src/_internal/cli/commands/index.ts
@@ -41,6 +41,8 @@ import hookGroup from './hook/hookGroup'
import listHookLogsCommand from './hook/listHookLogsCommand'
import listHooksCommand from './hook/listHooksCommand'
import printHookAttemptCommand from './hook/printHookAttemptCommand'
+import extractManifestCommand from './manifest/extractManifestCommand'
+import manifestGroup from './manifest/manifestGroup'
import createMigrationCommand from './migration/createMigrationCommand'
import listMigrationsCommand from './migration/listMigrationsCommand'
import migrationGroup from './migration/migrationGroup'
@@ -110,6 +112,8 @@ const commands: (CliCommandDefinition | CliCommandGroupDefinition)[] = [
previewCommand,
uninstallCommand,
execCommand,
+ manifestGroup,
+ extractManifestCommand,
]
/**
diff --git a/packages/sanity/src/_internal/cli/commands/manifest/extractManifestCommand.ts b/packages/sanity/src/_internal/cli/commands/manifest/extractManifestCommand.ts
new file mode 100644
index 00000000000..7422524e2a4
--- /dev/null
+++ b/packages/sanity/src/_internal/cli/commands/manifest/extractManifestCommand.ts
@@ -0,0 +1,35 @@
+import {type CliCommandDefinition} from '@sanity/cli'
+
+const description = 'Extracts the studio configuration as one or more JSON manifest files.'
+
+const helpText = `
+**Note**: This command is experimental and subject to change. It is currently intended for use with Create only.
+
+Options
+ --path Optional path to specify destination directory of the manifest files. Default: /dist/static
+
+Examples
+ # Extracts manifests
+ sanity manifest extract
+
+ # Extracts manifests into /public/static
+ sanity manifest extract --path /public/static
+`
+
+const extractManifestCommand: CliCommandDefinition = {
+ name: 'extract',
+ group: 'manifest',
+ signature: '',
+ description,
+ helpText,
+ action: async (args, context) => {
+ const {extractManifestSafe} = await import('../../actions/manifest/extractManifestAction')
+ const extractError = await extractManifestSafe(args, context)
+ if (extractError) {
+ throw extractError
+ }
+ return extractError
+ },
+}
+
+export default extractManifestCommand
diff --git a/packages/sanity/src/_internal/cli/commands/manifest/manifestGroup.ts b/packages/sanity/src/_internal/cli/commands/manifest/manifestGroup.ts
new file mode 100644
index 00000000000..ba086d91672
--- /dev/null
+++ b/packages/sanity/src/_internal/cli/commands/manifest/manifestGroup.ts
@@ -0,0 +1,6 @@
+export default {
+ name: 'manifest',
+ signature: '[COMMAND]',
+ isGroupRoot: true,
+ description: 'Interacts with the studio configuration.',
+}
diff --git a/packages/sanity/src/_internal/cli/threads/extractManifest.ts b/packages/sanity/src/_internal/cli/threads/extractManifest.ts
new file mode 100644
index 00000000000..e3080dce09f
--- /dev/null
+++ b/packages/sanity/src/_internal/cli/threads/extractManifest.ts
@@ -0,0 +1,33 @@
+import {isMainThread, parentPort, workerData as _workerData} from 'node:worker_threads'
+
+import {extractCreateWorkspaceManifest} from '../../manifest/extractWorkspaceManifest'
+import {getStudioWorkspaces} from '../util/getStudioWorkspaces'
+import {mockBrowserEnvironment} from '../util/mockBrowserEnvironment'
+
+/** @internal */
+export interface ExtractManifestWorkerData {
+ workDir: string
+}
+
+if (isMainThread || !parentPort) {
+ throw new Error('This module must be run as a worker thread')
+}
+
+const opts = _workerData as ExtractManifestWorkerData
+
+const cleanup = mockBrowserEnvironment(opts.workDir)
+
+async function main() {
+ try {
+ const workspaces = await getStudioWorkspaces({basePath: opts.workDir})
+
+ for (const workspace of workspaces) {
+ parentPort?.postMessage(extractCreateWorkspaceManifest(workspace))
+ }
+ } finally {
+ parentPort?.close()
+ cleanup()
+ }
+}
+
+main()
diff --git a/packages/sanity/src/_internal/manifest/extractWorkspaceManifest.ts b/packages/sanity/src/_internal/manifest/extractWorkspaceManifest.ts
new file mode 100644
index 00000000000..427d4b83a7b
--- /dev/null
+++ b/packages/sanity/src/_internal/manifest/extractWorkspaceManifest.ts
@@ -0,0 +1,502 @@
+import startCase from 'lodash/startCase'
+import {
+ type ArraySchemaType,
+ type BlockDefinition,
+ type BooleanSchemaType,
+ ConcreteRuleClass,
+ createSchema,
+ type CrossDatasetReferenceSchemaType,
+ type FileSchemaType,
+ type MultiFieldSet,
+ type NumberSchemaType,
+ type ObjectField,
+ type ObjectSchemaType,
+ type ReferenceSchemaType,
+ type Rule,
+ type RuleSpec,
+ type Schema,
+ type SchemaType,
+ type SchemaValidationValue,
+ type SpanSchemaType,
+ type StringSchemaType,
+ type Workspace,
+} from 'sanity'
+
+import {
+ getCustomFields,
+ isCrossDatasetReference,
+ isCustomized,
+ isDefined,
+ isPrimitive,
+ isRecord,
+ isReference,
+ isString,
+ isType,
+} from './manifestTypeHelpers'
+import {
+ type CreateWorkspaceManifest,
+ type ManifestField,
+ type ManifestFieldset,
+ type ManifestSchemaType,
+ type ManifestSerializable,
+ type ManifestTitledValue,
+ type ManifestValidationGroup,
+ type ManifestValidationRule,
+} from './manifestTypes'
+
+interface Context {
+ schema: Schema
+}
+
+type SchemaTypeKey =
+ | keyof ArraySchemaType
+ | keyof BooleanSchemaType
+ | keyof FileSchemaType
+ | keyof NumberSchemaType
+ | keyof ObjectSchemaType
+ | keyof StringSchemaType
+ | keyof ReferenceSchemaType
+ | keyof BlockDefinition
+ | 'group' // we strip this from fields
+
+type Validation = {validation: ManifestValidationGroup[]} | Record
+type ObjectFields = {fields: ManifestField[]} | Record
+type SerializableProp = ManifestSerializable | ManifestSerializable[] | undefined
+type ManifestValidationFlag = ManifestValidationRule['flag']
+type ValidationRuleTransformer = (rule: RuleSpec) => ManifestValidationRule | undefined
+
+const MAX_CUSTOM_PROPERTY_DEPTH = 5
+const INLINE_TYPES = ['document', 'object', 'image', 'file']
+
+export function extractCreateWorkspaceManifest(workspace: Workspace): CreateWorkspaceManifest {
+ const serializedSchema = extractManifestSchemaTypes(workspace.schema)
+
+ return {
+ name: workspace.name,
+ title: workspace.title,
+ subtitle: workspace.subtitle,
+ basePath: workspace.basePath,
+ dataset: workspace.dataset,
+ schema: serializedSchema,
+ }
+}
+
+/**
+ * Extracts all serializable properties from userland schema types,
+ * so they best-effort can be used as definitions for Schema.compile
+. */
+export function extractManifestSchemaTypes(schema: Schema): ManifestSchemaType[] {
+ const typeNames = schema.getTypeNames()
+ const context = {schema}
+
+ const studioDefaultTypeNames = createSchema({name: 'default', types: []}).getTypeNames()
+
+ return typeNames
+ .filter((typeName) => !studioDefaultTypeNames.includes(typeName))
+ .map((typeName) => schema.get(typeName))
+ .filter((type): type is SchemaType => typeof type !== 'undefined')
+ .map((type) => transformType(type, context))
+}
+
+function transformCommonTypeFields(
+ type: SchemaType & {fieldset?: string},
+ typeName: string,
+ context: Context,
+): Omit {
+ const arrayProps =
+ typeName === 'array' && type.jsonType === 'array' ? transformArrayMember(type, context) : {}
+
+ const referenceProps = isReference(type) ? transformReference(type) : {}
+ const crossDatasetRefProps = isCrossDatasetReference(type)
+ ? transformCrossDatasetReference(type)
+ : {}
+
+ const objectFields: ObjectFields =
+ type.jsonType === 'object' && type.type && INLINE_TYPES.includes(typeName) && isCustomized(type)
+ ? {
+ fields: getCustomFields(type).map((objectField) => transformField(objectField, context)),
+ }
+ : {}
+
+ return {
+ ...retainCustomTypeProps(type),
+ ...transformValidation(type.validation),
+ ...ensureString('description', type.description),
+ ...objectFields,
+ ...arrayProps,
+ ...referenceProps,
+ ...crossDatasetRefProps,
+ ...ensureConditional('readOnly', type.readOnly),
+ ...ensureConditional('hidden', type.hidden),
+ ...transformFieldsets(type),
+ // fieldset prop gets instrumented via getCustomFields
+ ...ensureString('fieldset', type.fieldset),
+ ...transformBlockType(type, context),
+ }
+}
+
+function transformFieldsets(
+ type: SchemaType,
+): {fieldsets: ManifestFieldset[]} | Record {
+ if (type.jsonType !== 'object') {
+ return {}
+ }
+ const fieldsets = type.fieldsets
+ ?.filter((fs): fs is MultiFieldSet => !fs.single)
+ .map((fs) => {
+ const options = isRecord(fs.options) ? {options: retainSerializableProps(fs.options)} : {}
+ return {
+ name: fs.name,
+ ...ensureCustomTitle(fs.name, fs.title),
+ ...ensureString('description', fs.description),
+ ...ensureConditional('readOnly', fs.readOnly),
+ ...ensureConditional('hidden', fs.hidden),
+ ...options,
+ }
+ })
+
+ return fieldsets?.length ? {fieldsets} : {}
+}
+
+function transformType(type: SchemaType, context: Context): ManifestSchemaType {
+ const typeName = type.type ? type.type.name : type.jsonType
+
+ return {
+ ...transformCommonTypeFields(type, typeName, context),
+ name: type.name,
+ type: typeName,
+ ...ensureCustomTitle(type.name, type.title),
+ }
+}
+
+function retainCustomTypeProps(type: SchemaType): Record {
+ const manuallySerializedFields: SchemaTypeKey[] = [
+ //explicitly added
+ 'name',
+ 'title',
+ 'description',
+ 'readOnly',
+ 'hidden',
+ 'validation',
+ 'fieldsets',
+ 'fields',
+ 'to',
+ 'of',
+ // not serialized
+ 'type',
+ 'jsonType',
+ '__experimental_actions',
+ '__experimental_formPreviewTitle',
+ '__experimental_omnisearch_visibility',
+ '__experimental_search',
+ 'components',
+ 'icon',
+ 'orderings',
+ 'preview',
+ 'groups',
+ //only exists on fields
+ 'group',
+ // we know about these, but let them be generically handled
+ // deprecated
+ // rows (from text)
+ // initialValue
+ // options
+ // crossDatasetReference props
+ ]
+ const typeWithoutManuallyHandledFields = Object.fromEntries(
+ Object.entries(type).filter(
+ ([key]) => !manuallySerializedFields.includes(key as unknown as SchemaTypeKey),
+ ),
+ )
+ return retainSerializableProps(typeWithoutManuallyHandledFields) as Record<
+ string,
+ SerializableProp
+ >
+}
+
+function retainSerializableProps(maybeSerializable: unknown, depth = 0): SerializableProp {
+ if (depth > MAX_CUSTOM_PROPERTY_DEPTH) {
+ return undefined
+ }
+
+ if (!isDefined(maybeSerializable)) {
+ return undefined
+ }
+
+ if (isPrimitive(maybeSerializable)) {
+ // cull empty strings
+ if (maybeSerializable === '') {
+ return undefined
+ }
+ return maybeSerializable
+ }
+
+ // url-schemes ect..
+ if (maybeSerializable instanceof RegExp) {
+ return maybeSerializable.toString()
+ }
+
+ if (Array.isArray(maybeSerializable)) {
+ const arrayItems = maybeSerializable
+ .map((item) => retainSerializableProps(item, depth + 1))
+ .filter((item): item is ManifestSerializable => isDefined(item))
+ return arrayItems.length ? arrayItems : undefined
+ }
+
+ if (isRecord(maybeSerializable)) {
+ const serializableEntries = Object.entries(maybeSerializable)
+ .map(([key, value]) => {
+ return [key, retainSerializableProps(value, depth + 1)]
+ })
+ .filter(([, value]) => isDefined(value))
+ return serializableEntries.length ? Object.fromEntries(serializableEntries) : undefined
+ }
+
+ return undefined
+}
+
+function transformField(field: ObjectField & {fieldset?: string}, context: Context): ManifestField {
+ const fieldType = field.type
+ const typeNameExists = !!context.schema.get(fieldType.name)
+ const typeName = typeNameExists ? fieldType.name : (fieldType.type?.name ?? fieldType.name)
+ return {
+ ...transformCommonTypeFields(fieldType, typeName, context),
+ name: field.name,
+ type: typeName,
+ ...ensureCustomTitle(field.name, fieldType.title),
+ // this prop gets added synthetically via getCustomFields
+ ...ensureString('fieldset', field.fieldset),
+ }
+}
+
+function transformArrayMember(
+ arrayMember: ArraySchemaType,
+ context: Context,
+): Pick {
+ return {
+ of: arrayMember.of.map((type) => {
+ const typeNameExists = !!context.schema.get(type.name)
+ const typeName = typeNameExists ? type.name : (type.type?.name ?? type.name)
+ return {
+ ...transformCommonTypeFields(type, typeName, context),
+ type: typeName,
+ ...(typeName === type.name ? {} : {name: type.name}),
+ ...ensureCustomTitle(type.name, type.title),
+ }
+ }),
+ }
+}
+
+function transformReference(reference: ReferenceSchemaType): Pick {
+ return {
+ to: (reference.to ?? []).map((type) => {
+ return {
+ ...retainCustomTypeProps(type),
+ type: type.name,
+ }
+ }),
+ }
+}
+
+function transformCrossDatasetReference(
+ reference: CrossDatasetReferenceSchemaType,
+): Pick {
+ return {
+ to: (reference.to ?? []).map((crossDataset) => {
+ const preview = crossDataset.preview?.select
+ ? {preview: {select: crossDataset.preview.select}}
+ : {}
+ return {
+ type: crossDataset.type,
+ ...ensureCustomTitle(crossDataset.type, crossDataset.title),
+ ...preview,
+ }
+ }),
+ }
+}
+
+const transformTypeValidationRule: ValidationRuleTransformer = (rule) => {
+ return {
+ ...rule,
+ constraint:
+ 'constraint' in rule &&
+ (typeof rule.constraint === 'string'
+ ? rule.constraint.toLowerCase()
+ : retainSerializableProps(rule.constraint)),
+ }
+}
+
+const validationRuleTransformers: Partial<
+ Record
+> = {
+ type: transformTypeValidationRule,
+}
+
+function transformValidation(validation: SchemaValidationValue): Validation {
+ const validationArray = (Array.isArray(validation) ? validation : [validation]).filter(
+ (value): value is Rule => typeof value === 'object' && '_type' in value,
+ )
+
+ // we dont want type in the output as that is implicitly given by the typedef itself an will only bloat the payload
+ const disallowedFlags = ['type']
+
+ // Validation rules that refer to other fields use symbols, which cannot be serialized. It would
+ // be possible to transform these to a serializable type, but we haven't implemented that for now.
+ const disallowedConstraintTypes: (symbol | unknown)[] = [ConcreteRuleClass.FIELD_REF]
+
+ const serializedValidation = validationArray
+ .map(({_rules, _message, _level}) => {
+ const message: Partial> =
+ typeof _message === 'string' ? {message: _message} : {}
+
+ const serializedRules = _rules
+ .filter((rule) => {
+ if (!('constraint' in rule)) {
+ return false
+ }
+
+ const {flag, constraint} = rule
+
+ if (disallowedFlags.includes(flag)) {
+ return false
+ }
+
+ return !(
+ typeof constraint === 'object' &&
+ 'type' in constraint &&
+ disallowedConstraintTypes.includes(constraint.type)
+ )
+ })
+ .reduce((rules, rule) => {
+ const transformer: ValidationRuleTransformer =
+ validationRuleTransformers[rule.flag] ??
+ ((spec) => retainSerializableProps(spec) as ManifestValidationRule)
+
+ const transformedRule = transformer(rule)
+ if (!transformedRule) {
+ return rules
+ }
+ return [...rules, transformedRule]
+ }, [])
+
+ return {
+ rules: serializedRules,
+ level: _level,
+ ...message,
+ }
+ })
+ .filter((group) => !!group.rules.length)
+
+ return serializedValidation.length ? {validation: serializedValidation} : {}
+}
+
+function ensureCustomTitle(typeName: string, value: unknown) {
+ const titleObject = ensureString('title', value)
+
+ const defaultTitle = startCase(typeName)
+ // omit title if its the same as default, to reduce payload
+ if (titleObject.title === defaultTitle) {
+ return {}
+ }
+ return titleObject
+}
+
+function ensureString(key: Key, value: unknown) {
+ if (typeof value === 'string') {
+ return {
+ [key]: value,
+ }
+ }
+
+ return {}
+}
+
+function ensureConditional(key: Key, value: unknown) {
+ if (typeof value === 'boolean') {
+ return {
+ [key]: value,
+ }
+ }
+
+ if (typeof value === 'function') {
+ return {
+ [key]: 'conditional',
+ }
+ }
+
+ return {}
+}
+
+export function transformBlockType(
+ blockType: SchemaType,
+ context: Context,
+): Pick | Record {
+ if (blockType.jsonType !== 'object' || !isType(blockType, 'block')) {
+ return {}
+ }
+
+ const childrenField = blockType.fields?.find((field) => field.name === 'children') as
+ | {type: ArraySchemaType}
+ | undefined
+
+ if (!childrenField) {
+ return {}
+ }
+ const ofType = childrenField.type.of
+ if (!ofType) {
+ return {}
+ }
+ const spanType = ofType.find((memberType) => memberType.name === 'span') as
+ | ObjectSchemaType
+ | undefined
+ if (!spanType) {
+ return {}
+ }
+ const inlineObjectTypes = (ofType.filter((memberType) => memberType.name !== 'span') ||
+ []) as ObjectSchemaType[]
+
+ return {
+ marks: {
+ annotations: (spanType as SpanSchemaType).annotations.map((t) => transformType(t, context)),
+ decorators: resolveEnabledDecorators(spanType),
+ },
+ lists: resolveEnabledListItems(blockType),
+ styles: resolveEnabledStyles(blockType),
+ of: inlineObjectTypes.map((t) => transformType(t, context)),
+ }
+}
+
+function resolveEnabledStyles(blockType: ObjectSchemaType): ManifestTitledValue[] | undefined {
+ const styleField = blockType.fields?.find((btField) => btField.name === 'style')
+ return resolveTitleValueArray(styleField?.type?.options?.list)
+}
+
+function resolveEnabledDecorators(spanType: ObjectSchemaType): ManifestTitledValue[] | undefined {
+ return 'decorators' in spanType ? resolveTitleValueArray(spanType.decorators) : undefined
+}
+
+function resolveEnabledListItems(blockType: ObjectSchemaType): ManifestTitledValue[] | undefined {
+ const listField = blockType.fields?.find((btField) => btField.name === 'listItem')
+ return resolveTitleValueArray(listField?.type?.options?.list)
+}
+
+function resolveTitleValueArray(possibleArray: unknown): ManifestTitledValue[] | undefined {
+ if (!possibleArray || !Array.isArray(possibleArray)) {
+ return undefined
+ }
+ const titledValues = possibleArray
+ .filter(
+ (d): d is {value: string; title?: string} => isRecord(d) && !!d.value && isString(d.value),
+ )
+ .map((item) => {
+ return {
+ value: item.value,
+ ...ensureString('title', item.title),
+ } satisfies ManifestTitledValue
+ })
+ if (!titledValues?.length) {
+ return undefined
+ }
+
+ return titledValues
+}
diff --git a/packages/sanity/src/_internal/manifest/manifestTypeHelpers.ts b/packages/sanity/src/_internal/manifest/manifestTypeHelpers.ts
new file mode 100644
index 00000000000..e9b366e978a
--- /dev/null
+++ b/packages/sanity/src/_internal/manifest/manifestTypeHelpers.ts
@@ -0,0 +1,107 @@
+import {
+ type CrossDatasetReferenceSchemaType,
+ type ObjectField,
+ type ObjectSchemaType,
+ type ReferenceSchemaType,
+ type SchemaType,
+} from '@sanity/types'
+
+const DEFAULT_IMAGE_FIELDS = ['asset', 'hotspot', 'crop']
+const DEFAULT_FILE_FIELDS = ['asset']
+const DEFAULT_GEOPOINT_FIELDS = ['lat', 'lng', 'alt']
+const DEFAULT_SLUG_FIELDS = ['current', 'source']
+
+export function getCustomFields(type: ObjectSchemaType): (ObjectField & {fieldset?: string})[] {
+ const fields = type.fieldsets
+ ? type.fieldsets.flatMap((fs) => {
+ if (fs.single) {
+ return fs.field
+ }
+ return fs.fields.map((field) => ({
+ ...field,
+ fieldset: fs.name,
+ }))
+ })
+ : type.fields
+
+ if (isType(type, 'block')) {
+ return []
+ }
+ if (isType(type, 'slug')) {
+ return fields.filter((f) => !DEFAULT_SLUG_FIELDS.includes(f.name))
+ }
+ if (isType(type, 'geopoint')) {
+ return fields.filter((f) => !DEFAULT_GEOPOINT_FIELDS.includes(f.name))
+ }
+ if (isType(type, 'image')) {
+ return fields.filter((f) => !DEFAULT_IMAGE_FIELDS.includes(f.name))
+ }
+ if (isType(type, 'file')) {
+ return fields.filter((f) => !DEFAULT_FILE_FIELDS.includes(f.name))
+ }
+ return fields
+}
+
+export function isReference(type: SchemaType): type is ReferenceSchemaType {
+ return isType(type, 'reference')
+}
+
+export function isCrossDatasetReference(type: SchemaType): type is CrossDatasetReferenceSchemaType {
+ return isType(type, 'crossDatasetReference')
+}
+
+export function isObjectField(maybeOjectField: unknown): boolean {
+ return (
+ typeof maybeOjectField === 'object' && maybeOjectField !== null && 'name' in maybeOjectField
+ )
+}
+
+export function isCustomized(maybeCustomized: SchemaType): boolean {
+ const hasFieldsArray =
+ isObjectField(maybeCustomized) &&
+ !isType(maybeCustomized, 'reference') &&
+ !isType(maybeCustomized, 'crossDatasetReference') &&
+ 'fields' in maybeCustomized &&
+ Array.isArray(maybeCustomized.fields)
+
+ if (!hasFieldsArray) {
+ return false
+ }
+
+ const fields = getCustomFields(maybeCustomized)
+ return !!fields.length
+}
+
+export function isType(schemaType: SchemaType, typeName: string): boolean {
+ if (schemaType.name === typeName) {
+ return true
+ }
+ if (!schemaType.type) {
+ return false
+ }
+ return isType(schemaType.type, typeName)
+}
+
+export function isDefined(value: T | null | undefined): value is T {
+ return value !== null && value !== undefined
+}
+
+export function isRecord(value: unknown): value is Record {
+ return !!value && typeof value === 'object'
+}
+
+export function isPrimitive(value: unknown): value is string | boolean | number {
+ return isString(value) || isBoolean(value) || isNumber(value)
+}
+
+export function isString(value: unknown): value is string {
+ return typeof value === 'string'
+}
+
+function isNumber(value: unknown): value is number {
+ return typeof value === 'boolean'
+}
+
+function isBoolean(value: unknown): value is boolean {
+ return typeof value === 'number'
+}
diff --git a/packages/sanity/src/_internal/manifest/manifestTypes.ts b/packages/sanity/src/_internal/manifest/manifestTypes.ts
new file mode 100644
index 00000000000..7ce29c9ba7e
--- /dev/null
+++ b/packages/sanity/src/_internal/manifest/manifestTypes.ts
@@ -0,0 +1,85 @@
+export type ManifestSerializable =
+ | string
+ | number
+ | boolean
+ | {[k: string]: ManifestSerializable}
+ | ManifestSerializable[]
+
+export interface CreateManifest {
+ version: 1
+ createdAt: string
+ workspaces: ManifestWorkspaceFile[]
+}
+
+export interface ManifestWorkspaceFile {
+ name: string
+ dataset: string
+ schema: string // filename
+}
+
+export interface CreateWorkspaceManifest {
+ name: string
+ title?: string
+ subtitle?: string
+ basePath: string
+ dataset: string
+ schema: ManifestSchemaType[]
+}
+
+export interface ManifestSchemaType {
+ type: string
+ name: string
+ title?: string
+ deprecated?: {
+ reason: string
+ }
+ readOnly?: boolean | 'conditional'
+ hidden?: boolean | 'conditional'
+ validation?: ManifestValidationGroup[]
+ fields?: ManifestField[]
+ to?: ManifestReferenceMember[]
+ of?: ManifestArrayMember[]
+ preview?: {
+ select: Record
+ }
+ fieldsets?: ManifestFieldset[]
+ options?: Record
+ //portable text
+ marks?: {
+ annotations?: ManifestArrayMember[]
+ decorators?: ManifestTitledValue[]
+ }
+ lists?: ManifestTitledValue[]
+ styles?: ManifestTitledValue[]
+
+ // userland (assignable to ManifestSerializable | undefined)
+ // not included to add some typesafty to extractManifest
+ // [index: string]: unknown
+}
+
+export interface ManifestFieldset {
+ name: string
+ title?: string
+ [index: string]: ManifestSerializable | undefined
+}
+
+export interface ManifestTitledValue {
+ value: string
+ title?: string
+}
+
+export type ManifestField = ManifestSchemaType & {fieldset?: string}
+export type ManifestArrayMember = Omit & {name?: string}
+export type ManifestReferenceMember = Omit & {name?: string}
+
+export interface ManifestValidationGroup {
+ rules: ManifestValidationRule[]
+ message?: string
+ level?: 'error' | 'warning' | 'info'
+}
+
+export type ManifestValidationRule = {
+ flag: string
+ constraint?: ManifestSerializable
+ [index: string]: ManifestSerializable | undefined
+}
diff --git a/packages/sanity/src/core/index.ts b/packages/sanity/src/core/index.ts
index 56606120212..83140342883 100644
--- a/packages/sanity/src/core/index.ts
+++ b/packages/sanity/src/core/index.ts
@@ -33,5 +33,9 @@ export * from './templates'
export * from './theme'
export * from './user-color'
export * from './util'
-export {validateDocument, type ValidateDocumentOptions} from './validation'
+export {
+ Rule as ConcreteRuleClass,
+ validateDocument,
+ type ValidateDocumentOptions,
+} from './validation'
export * from './version'
diff --git a/packages/sanity/src/core/validation/Rule.ts b/packages/sanity/src/core/validation/Rule.ts
index 6640cda8a09..15b4ac165a7 100644
--- a/packages/sanity/src/core/validation/Rule.ts
+++ b/packages/sanity/src/core/validation/Rule.ts
@@ -54,21 +54,25 @@ const ruleConstraintTypes: RuleTypeConstraint[] = [
'String',
]
-// Note: `RuleClass` and `Rule` are split to fit the current `@sanity/types`
-// setup. Classes are a bit weird in the `@sanity/types` package because classes
-// create an actual javascript class while simultaneously creating a type
-// definition.
-//
-// This implicitly creates two types:
-// 1. the instance type — `Rule` and
-// 2. the static/class type - `RuleClass`
-//
-// The `RuleClass` type contains the static methods and the `Rule` instance
-// contains the instance methods.
-//
-// This package exports the RuleClass as a value without implicitly exporting
-// an instance definition. This should help reminder downstream users to import
-// from the `@sanity/types` package.
+/**
+ * Note: `RuleClass` and `Rule` are split to fit the current `@sanity/types`
+ * setup. Classes are a bit weird in the `@sanity/types` package because classes
+ * create an actual javascript class while simultaneously creating a type
+ * definition.
+ *
+ * This implicitly creates two types:
+ * 1. the instance type — `Rule` and
+ * 2. the static/class type - `RuleClass`
+ *
+ * The `RuleClass` type contains the static methods and the `Rule` instance
+ * contains the instance methods.
+ *
+ * This package exports the RuleClass as a value without implicitly exporting
+ * an instance definition. This should help reminder downstream users to import
+ * from the `@sanity/types` package.
+ *
+ * @internal
+ */
export const Rule: RuleClass = class Rule implements IRule {
static readonly FIELD_REF = FIELD_REF
static array = (def?: SchemaType): Rule => new Rule(def).type('Array')
diff --git a/packages/sanity/test/manifest/extractManifest.test.ts b/packages/sanity/test/manifest/extractManifest.test.ts
new file mode 100644
index 00000000000..36cabadb2e9
--- /dev/null
+++ b/packages/sanity/test/manifest/extractManifest.test.ts
@@ -0,0 +1,990 @@
+/* eslint-disable camelcase */
+import {describe, expect, test} from '@jest/globals'
+import {defineArrayMember, defineField, defineType} from '@sanity/types'
+
+import {extractManifestSchemaTypes} from '../../src/_internal/manifest/extractWorkspaceManifest'
+import {createSchema} from '../../src/core'
+
+describe('Extract studio manifest', () => {
+ describe('serialize schema for manifest', () => {
+ test('extracted schema should only include user defined types (and no built-in types)', () => {
+ const documentType = 'basic'
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ name: documentType,
+ type: 'document',
+ fields: [defineField({name: 'title', type: 'string'})],
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+ expect(extracted.map((v) => v.name)).toStrictEqual([documentType])
+ })
+
+ test('indicate conditional for function values on hidden and readOnly fields', () => {
+ const documentType = 'basic'
+
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ name: documentType,
+ type: 'document',
+ readOnly: true,
+ hidden: false,
+ fields: [
+ defineField({
+ name: 'string',
+ type: 'string',
+ hidden: () => true,
+ readOnly: () => false,
+ }),
+ ],
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+ const serializedDoc = extracted.find((serialized) => serialized.name === documentType)
+ expect(serializedDoc).toEqual({
+ type: 'document',
+ name: 'basic',
+ readOnly: true,
+ hidden: false,
+ fields: [
+ {
+ name: 'string',
+ type: 'string',
+ hidden: 'conditional',
+ readOnly: 'conditional',
+ },
+ ],
+ })
+ })
+
+ test('should omit known non-serializable schema props ', () => {
+ const documentType = 'remove-props'
+
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ //include
+ name: documentType,
+ type: 'document',
+ title: 'My document',
+ description: 'Stuff',
+ deprecated: {
+ reason: 'old',
+ },
+ options: {
+ custom: 'value',
+ },
+ initialValue: {title: 'Default'},
+ liveEdit: true,
+
+ //omit
+ icon: () => 'remove-icon',
+ groups: [{name: 'groups-are-removed'}],
+ __experimental_omnisearch_visibility: true,
+ __experimental_search: [
+ {
+ path: 'title',
+ weight: 100,
+ },
+ ],
+ __experimental_formPreviewTitle: true,
+ components: {
+ field: () => 'remove-components',
+ },
+ orderings: [
+ {name: 'remove-orderings', title: '', by: [{field: 'title', direction: 'desc'}]},
+ ],
+ fields: [
+ defineField({
+ name: 'string',
+ type: 'string',
+ group: 'groups-are-removed',
+ }),
+ ],
+ preview: {
+ select: {title: 'remove-preview'},
+ },
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+ const serializedDoc = extracted.find((serialized) => serialized.name === documentType)
+ expect(serializedDoc).toEqual({
+ type: 'document',
+ name: documentType,
+ title: 'My document',
+ description: 'Stuff',
+ deprecated: {
+ reason: 'old',
+ },
+ options: {
+ custom: 'value',
+ },
+ initialValue: {title: 'Default'},
+ liveEdit: true,
+ fields: [
+ {
+ name: 'string',
+ type: 'string',
+ },
+ ],
+ })
+ })
+
+ test('schema should include most userland properties', () => {
+ const documentType = 'basic'
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const recursiveObject: any = {
+ repeat: 'string',
+ }
+ recursiveObject.recurse = recursiveObject
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const customization: any = {
+ recursiveObject, // this one will be cut off at max-depth
+ serializableProp: 'dummy',
+ nonSerializableProp: () => {},
+ options: {
+ serializableOption: true,
+ nonSerializableOption: () => {},
+ nested: {
+ serializableOption: 1,
+ nonSerializableOption: () => {},
+ },
+ },
+ }
+
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ name: documentType,
+ type: 'document',
+ fields: [
+ defineField({
+ title: 'Nested',
+ name: 'nested',
+ type: 'object',
+ fields: [
+ defineField({
+ title: 'Nested inline string',
+ name: 'nestedString',
+ type: 'string',
+ ...customization,
+ }),
+ ],
+ ...customization,
+ }),
+ ],
+ ...customization,
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+
+ const expectedCustomProps = {
+ serializableProp: 'dummy',
+ options: {
+ serializableOption: true,
+ nested: {
+ serializableOption: 1,
+ },
+ },
+ recursiveObject: {
+ recurse: {
+ recurse: {
+ recurse: {
+ repeat: 'string',
+ },
+ repeat: 'string',
+ },
+ repeat: 'string',
+ },
+ repeat: 'string',
+ },
+ }
+
+ const serializedDoc = extracted.find((serialized) => serialized.name === documentType)
+ expect(serializedDoc).toEqual({
+ type: 'document',
+ name: 'basic',
+ fields: [
+ {
+ name: 'nested',
+ type: 'object',
+ fields: [
+ {
+ name: 'nestedString',
+ title: 'Nested inline string',
+ type: 'string',
+ ...expectedCustomProps,
+ },
+ ],
+ ...expectedCustomProps,
+ },
+ ],
+ ...expectedCustomProps,
+ })
+ })
+
+ test('should serialize fieldset config', () => {
+ const documentType = 'fieldsets'
+
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ name: documentType,
+ type: 'document',
+ fields: [
+ defineField({
+ name: 'string',
+ type: 'string',
+ }),
+ ],
+ preview: {
+ select: {title: 'title'},
+ prepare: () => ({
+ title: 'remove-prepare',
+ }),
+ },
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+ const serializedDoc = extracted.find((serialized) => serialized.name === documentType)
+ expect(serializedDoc).toEqual({
+ type: 'document',
+ name: documentType,
+ fields: [
+ {
+ name: 'string',
+ type: 'string',
+ },
+ ],
+ })
+ })
+
+ test('serialize fieldless types', () => {
+ const documentType = 'fieldless-types'
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ title: 'Some document',
+ name: documentType,
+ type: 'document',
+ fields: [
+ defineField({title: 'String field', name: 'string', type: 'string'}),
+ defineField({title: 'Text field', name: 'text', type: 'text'}),
+ defineField({title: 'Number field', name: 'number', type: 'number'}),
+ defineField({title: 'Boolean field', name: 'boolean', type: 'boolean'}),
+ defineField({title: 'Date field', name: 'date', type: 'date'}),
+ defineField({title: 'Datetime field', name: 'datetime', type: 'datetime'}),
+ defineField({title: 'Geopoint field', name: 'geopoint', type: 'geopoint'}),
+ defineField({title: 'Basic image field', name: 'image', type: 'image'}),
+ defineField({title: 'Basic file field', name: 'file', type: 'file'}),
+ defineField({title: 'Slug field', name: 'slug', type: 'slug'}),
+ defineField({title: 'URL field', name: 'url', type: 'url'}),
+ ],
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+
+ const serializedDoc = extracted.find((serialized) => serialized.name === documentType)
+ expect(serializedDoc).toEqual({
+ fields: [
+ {name: 'string', title: 'String field', type: 'string'},
+ {name: 'text', title: 'Text field', type: 'text'},
+ {name: 'number', title: 'Number field', type: 'number'},
+ {name: 'boolean', title: 'Boolean field', type: 'boolean'},
+ {name: 'date', title: 'Date field', type: 'date'},
+ {name: 'datetime', title: 'Datetime field', type: 'datetime'},
+ {name: 'geopoint', title: 'Geopoint field', type: 'geopoint'},
+ {name: 'image', title: 'Basic image field', type: 'image'},
+ {name: 'file', title: 'Basic file field', type: 'file'},
+ {
+ name: 'slug',
+ title: 'Slug field',
+ type: 'slug',
+ validation: [{level: 'error', rules: [{flag: 'custom'}]}],
+ },
+ {
+ name: 'url',
+ title: 'URL field',
+ type: 'url',
+ validation: [
+ {
+ level: 'error',
+ rules: [
+ {
+ constraint: {
+ options: {
+ allowCredentials: false,
+ allowRelative: false,
+ relativeOnly: false,
+ scheme: ['/^http$/', '/^https$/'],
+ },
+ },
+ flag: 'uri',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ name: documentType,
+ title: 'Some document',
+ type: 'document',
+ })
+ })
+
+ test('serialize types with fields', () => {
+ const documentType = 'field-types'
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ fields: [
+ {
+ name: 'existingType',
+ type: documentType,
+ },
+ {
+ fields: [
+ {
+ name: 'nestedString',
+ title: 'Nested inline string',
+ type: 'string',
+ },
+ {
+ fields: [
+ {
+ name: 'inner',
+ title: 'Inner',
+ type: 'number',
+ },
+ ],
+ name: 'nestedTwice',
+ title: 'Child object',
+ type: 'object',
+ },
+ ],
+ name: 'nested',
+ title: 'Nested',
+ type: 'object',
+ },
+ {
+ fields: [
+ {
+ name: 'title',
+ title: 'Image title',
+ type: 'string',
+ },
+ ],
+ name: 'image',
+ type: 'image',
+ },
+ {
+ fields: [
+ {
+ name: 'title',
+ title: 'File title',
+ type: 'string',
+ },
+ ],
+ name: 'file',
+ type: 'file',
+ },
+ ],
+ name: documentType,
+ type: 'document',
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+
+ const serializedDoc = extracted.find((serialized) => serialized.name === documentType)
+ expect(serializedDoc).toEqual({
+ fields: [
+ {
+ name: 'existingType',
+ type: 'field-types',
+ },
+
+ {
+ fields: [
+ {
+ name: 'nestedString',
+ title: 'Nested inline string',
+ type: 'string',
+ },
+ {
+ fields: [
+ {
+ name: 'inner',
+ type: 'number',
+ },
+ ],
+ name: 'nestedTwice',
+ title: 'Child object',
+ type: 'object',
+ },
+ ],
+ name: 'nested',
+ type: 'object',
+ },
+ {
+ fields: [
+ {
+ name: 'title',
+ title: 'Image title',
+ type: 'string',
+ },
+ ],
+ name: 'image',
+ type: 'image',
+ },
+ {
+ fields: [
+ {
+ name: 'title',
+ title: 'File title',
+ type: 'string',
+ },
+ ],
+ name: 'file',
+ type: 'file',
+ },
+ ],
+ name: documentType,
+ type: 'document',
+ })
+ })
+
+ test('serialize array-like fields (portable text tested separately)', () => {
+ const documentType = 'all-types'
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ title: 'Basic doc',
+ name: documentType,
+ type: 'document',
+ fields: [
+ defineField({
+ title: 'String array',
+ name: 'stringArray',
+ type: 'array',
+ of: [{type: 'string'}],
+ }),
+ defineField({
+ title: 'Number array',
+ name: 'numberArray',
+ type: 'array',
+ of: [{type: 'number'}],
+ }),
+ defineField({
+ title: 'Boolean array',
+ name: 'booleanArray',
+ type: 'array',
+ of: [{type: 'boolean'}],
+ }),
+ defineField({
+ name: 'objectArray',
+ type: 'array',
+ of: [
+ defineArrayMember({
+ title: 'Anonymous object item',
+ type: 'object',
+ fields: [
+ defineField({
+ name: 'itemTitle',
+ type: 'string',
+ }),
+ ],
+ }),
+ defineArrayMember({
+ type: 'object',
+ title: 'Inline named object item',
+ name: 'item',
+ fields: [
+ defineField({
+ name: 'otherTitle',
+ type: 'string',
+ }),
+ ],
+ }),
+ defineArrayMember({
+ title: 'Existing type object item',
+ type: documentType,
+ }),
+ ],
+ }),
+ ],
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+
+ const serializedDoc = extracted.find((serialized) => serialized.name === documentType)
+ expect(serializedDoc).toEqual({
+ fields: [
+ {
+ name: 'stringArray',
+ of: [{type: 'string'}],
+ title: 'String array',
+ type: 'array',
+ },
+ {
+ name: 'numberArray',
+ of: [{type: 'number'}],
+ title: 'Number array',
+ type: 'array',
+ },
+ {
+ name: 'booleanArray',
+ of: [{type: 'boolean'}],
+ title: 'Boolean array',
+ type: 'array',
+ },
+ {
+ name: 'objectArray',
+ of: [
+ {
+ title: 'Anonymous object item',
+ type: 'object',
+ fields: [{name: 'itemTitle', type: 'string'}],
+ },
+ {
+ fields: [{name: 'otherTitle', type: 'string'}],
+ title: 'Inline named object item',
+ type: 'object',
+ name: 'item',
+ },
+ {
+ title: 'Existing type object item',
+ type: 'all-types',
+ },
+ ],
+ type: 'array',
+ },
+ ],
+ name: 'all-types',
+ title: 'Basic doc',
+ type: 'document',
+ })
+ })
+
+ test('serialize array with type reference and overridden typename', () => {
+ const arrayType = 'someArray'
+ const objectBaseType = 'someObject'
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ name: objectBaseType,
+ type: 'object',
+ fields: [
+ defineField({
+ name: 'title',
+ type: 'string',
+ }),
+ ],
+ }),
+ defineType({
+ name: arrayType,
+ type: 'array',
+ of: [{type: objectBaseType, name: 'override'}],
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+
+ const serializedDoc = extracted.find((serialized) => serialized.name === arrayType)
+ expect(serializedDoc).toEqual({
+ name: arrayType,
+ of: [{title: 'Some Object', type: objectBaseType, name: 'override'}],
+ type: 'array',
+ })
+ })
+
+ test('serialize schema with indirectly recursive structure', () => {
+ const arrayType = 'someArray'
+ const objectBaseType = 'someObject'
+ const otherObjectType = 'other'
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ name: objectBaseType,
+ type: 'object',
+ fields: [
+ defineField({
+ name: 'recurse',
+ type: otherObjectType,
+ }),
+ ],
+ }),
+ defineType({
+ name: otherObjectType,
+ type: 'object',
+ fields: [
+ defineField({
+ name: 'recurse2',
+ type: arrayType,
+ }),
+ ],
+ }),
+ defineType({
+ name: arrayType,
+ type: 'array',
+ of: [{type: objectBaseType}],
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+
+ expect(extracted).toEqual([
+ {
+ fields: [{name: 'recurse', type: 'other'}],
+ name: 'someObject',
+ type: 'object',
+ },
+ {
+ fields: [{name: 'recurse2', type: 'someArray'}],
+ name: 'other',
+ type: 'object',
+ },
+ {
+ name: 'someArray',
+ of: [{type: 'someObject'}],
+ type: 'array',
+ },
+ ])
+ })
+
+ test('serialize portable text field', () => {
+ const documentType = 'pt'
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ name: documentType,
+ type: 'document',
+ fields: [
+ defineField({
+ title: 'Portable text',
+ name: 'pt',
+ type: 'array',
+ of: [
+ defineArrayMember({
+ title: 'Block',
+ name: 'block',
+ type: 'block',
+ of: [
+ defineField({
+ title: 'Inline block',
+ name: 'inlineBlock',
+ type: 'object',
+ fields: [
+ defineField({
+ title: 'Inline value',
+ name: 'value',
+ type: 'string',
+ }),
+ ],
+ }),
+ ],
+ marks: {
+ annotations: [
+ defineField({
+ title: 'Annotation',
+ name: 'annotation',
+ type: 'object',
+ fields: [
+ defineField({
+ title: 'Annotation value',
+ name: 'value',
+ type: 'string',
+ }),
+ ],
+ }),
+ ],
+ decorators: [{title: 'Custom mark', value: 'custom'}],
+ },
+ lists: [{value: 'bullet', title: 'Bullet list'}],
+ styles: [{value: 'customStyle', title: 'Custom style'}],
+ }),
+ ],
+ }),
+ ],
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+
+ const serializedDoc = extracted.find((serialized) => serialized.name === documentType)
+ expect(serializedDoc).toEqual({
+ fields: [
+ {
+ name: 'pt',
+ of: [
+ {
+ lists: [{title: 'Bullet list', value: 'bullet'}],
+ marks: {
+ annotations: [
+ {
+ fields: [{name: 'value', title: 'Annotation value', type: 'string'}],
+ name: 'annotation',
+ type: 'object',
+ },
+ ],
+ decorators: [{title: 'Custom mark', value: 'custom'}],
+ },
+ of: [
+ {
+ fields: [{name: 'value', title: 'Inline value', type: 'string'}],
+ name: 'inlineBlock',
+ title: 'Inline block',
+ type: 'object',
+ },
+ ],
+ styles: [
+ {title: 'Normal', value: 'normal'},
+ {title: 'Custom style', value: 'customStyle'},
+ ],
+ type: 'block',
+ },
+ ],
+ title: 'Portable text',
+ type: 'array',
+ },
+ ],
+ name: 'pt',
+ type: 'document',
+ })
+ })
+
+ test('serialize fields with references', () => {
+ const documentType = 'ref-types'
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ name: documentType,
+ type: 'document',
+ fields: [
+ defineField({
+ title: 'Reference to',
+ name: 'reference',
+ type: 'reference',
+ to: [{type: documentType}],
+ }),
+ defineField({
+ title: 'Cross dataset ref',
+ name: 'crossDatasetReference',
+ type: 'crossDatasetReference',
+ dataset: 'production',
+ studioUrl: () => 'cannot serialize studioUrl function',
+ to: [
+ {
+ type: documentType,
+ preview: {
+ select: {title: 'title'},
+ prepare: () => ({
+ title: 'cannot serialize prepare function',
+ }),
+ },
+ },
+ ],
+ }),
+ defineField({
+ title: 'Reference array',
+ name: 'refArray',
+ type: 'array',
+ of: [
+ defineArrayMember({
+ title: 'Reference to',
+ name: 'reference',
+ type: 'reference',
+ to: [{type: documentType}],
+ }),
+ ],
+ }),
+ ],
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+
+ const serializedDoc = extracted.find((serialized) => serialized.name === documentType)
+ expect(serializedDoc).toEqual({
+ fields: [
+ {
+ name: 'reference',
+ title: 'Reference to',
+ to: [{type: documentType}],
+ type: 'reference',
+ },
+ {
+ dataset: 'production',
+ name: 'crossDatasetReference',
+ title: 'Cross dataset ref',
+ type: 'crossDatasetReference',
+ to: [
+ {
+ type: documentType,
+ preview: {
+ select: {title: 'title'},
+ },
+ },
+ ],
+ },
+ {
+ name: 'refArray',
+ of: [
+ {
+ title: 'Reference to',
+ to: [{type: documentType}],
+ type: 'reference',
+ },
+ ],
+ title: 'Reference array',
+ type: 'array',
+ },
+ ],
+ name: documentType,
+ type: 'document',
+ })
+ })
+
+ test('fieldsets and fieldset on fields is serialized', () => {
+ const documentType = 'basic'
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ name: documentType,
+ type: 'document',
+ fieldsets: [
+ {
+ name: 'test',
+ title: 'Test fieldset',
+ hidden: false,
+ readOnly: true,
+ options: {
+ collapsed: true,
+ },
+ description: 'my fieldset',
+ },
+ {
+ name: 'conditional',
+ hidden: () => true,
+ readOnly: () => true,
+ },
+ ],
+ fields: [
+ defineField({name: 'title', type: 'string', fieldset: 'test'}),
+ defineField({name: 'other', type: 'string', fieldset: 'conditional'}),
+ ],
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+ const serializedDoc = extracted.find((serialized) => serialized.name === documentType)
+ expect(serializedDoc).toEqual({
+ fields: [
+ {
+ fieldset: 'test',
+ name: 'title',
+ type: 'string',
+ },
+ {
+ fieldset: 'conditional',
+ name: 'other',
+ type: 'string',
+ },
+ ],
+ fieldsets: [
+ {
+ description: 'my fieldset',
+ hidden: false,
+ name: 'test',
+ options: {
+ collapsed: true,
+ },
+ readOnly: true,
+ title: 'Test fieldset',
+ },
+ {
+ hidden: 'conditional',
+ name: 'conditional',
+ readOnly: 'conditional',
+ },
+ ],
+ name: 'basic',
+ type: 'document',
+ })
+ })
+
+ test('do not serialize default titles (default titles added by Schema.compile based on type/field name)', () => {
+ const documentType = 'basic-document'
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ name: documentType,
+ type: 'document',
+ fieldsets: [
+ {name: 'someFieldset'},
+ {
+ name: 'conditional',
+ hidden: () => true,
+ readOnly: () => true,
+ },
+ ],
+ fields: [
+ defineField({name: 'title', type: 'string'}),
+ defineField({name: 'someField', type: 'array', of: [{type: 'string'}]}),
+ defineField({name: 'customTitleField', type: 'string', title: 'Custom'}),
+ ],
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+ const serializedDoc = extracted.find((serialized) => serialized.name === documentType)
+ expect(serializedDoc).toEqual({
+ fields: [
+ {name: 'title', type: 'string'},
+ {name: 'someField', of: [{type: 'string'}], type: 'array'},
+ {name: 'customTitleField', type: 'string', title: 'Custom'},
+ ],
+ name: 'basic-document',
+ type: 'document',
+ })
+ })
+ })
+})
diff --git a/packages/sanity/test/manifest/extractManifestRestore.test.ts b/packages/sanity/test/manifest/extractManifestRestore.test.ts
new file mode 100644
index 00000000000..a64ab1e8699
--- /dev/null
+++ b/packages/sanity/test/manifest/extractManifestRestore.test.ts
@@ -0,0 +1,205 @@
+import {describe, expect, test} from '@jest/globals'
+import {
+ defineArrayMember,
+ defineField,
+ defineType,
+ type ObjectSchemaType,
+ type SchemaType,
+} from '@sanity/types'
+import pick from 'lodash/pick'
+
+import {extractManifestSchemaTypes} from '../../src/_internal/manifest/extractWorkspaceManifest'
+import {createSchema} from '../../src/core'
+
+describe('Extract studio manifest', () => {
+ test('extracted schema types should be mappable to a createSchema compatible version', () => {
+ const documentType = 'basic'
+ const sourceSchema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ name: documentType,
+ type: 'document',
+ fields: [
+ defineField({name: 'string', type: 'string'}),
+ defineField({name: 'text', type: 'text'}),
+ defineField({name: 'number', type: 'number'}),
+ defineField({name: 'boolean', type: 'boolean'}),
+ defineField({name: 'date', type: 'date'}),
+ defineField({name: 'datetime', type: 'datetime'}),
+ defineField({name: 'geopoint', type: 'geopoint'}),
+ defineField({name: 'image', type: 'image'}),
+ defineField({name: 'file', type: 'file'}),
+ defineField({name: 'slug', type: 'slug'}),
+ defineField({name: 'url', type: 'url'}),
+ defineField({name: 'object', type: documentType}),
+ defineField({
+ type: 'object',
+ name: 'nestedObject',
+ fields: [{name: 'nestedString', type: 'string'}],
+ }),
+ defineField({
+ type: 'image',
+ name: 'customImage',
+ fields: [{name: 'title', type: 'string'}],
+ }),
+ defineField({
+ type: 'file',
+ name: 'customFile',
+ fields: [{name: 'title', type: 'string'}],
+ options: {storeOriginalFilename: true},
+ }),
+ defineField({
+ name: 'typeAliasArray',
+ type: 'array',
+ of: [{type: documentType}],
+ }),
+ defineField({
+ name: 'stringArray',
+ type: 'array',
+ of: [{type: 'string'}],
+ }),
+ defineField({
+ name: 'numberArray',
+ type: 'array',
+ of: [{type: 'number'}],
+ }),
+ defineField({
+ name: 'booleanArray',
+ type: 'array',
+ of: [{type: 'boolean'}],
+ }),
+ defineField({
+ name: 'objectArray',
+ type: 'array',
+ of: [
+ defineArrayMember({
+ type: 'object',
+ fields: [defineField({name: 'itemTitle', type: 'string'})],
+ }),
+ ],
+ }),
+ defineField({
+ name: 'reference',
+ type: 'reference',
+ to: [{type: documentType}],
+ }),
+ defineField({
+ name: 'crossDatasetReference',
+ type: 'crossDatasetReference',
+ dataset: 'production',
+ to: [
+ {
+ type: documentType,
+ preview: {select: {title: 'title'}},
+ },
+ ],
+ }),
+ defineField({
+ name: 'refArray',
+ type: 'array',
+ of: [
+ defineArrayMember({
+ name: 'reference',
+ type: 'reference',
+ to: [{type: documentType}],
+ }),
+ ],
+ }),
+ defineField({
+ name: 'pt',
+ type: 'array',
+ of: [
+ defineArrayMember({
+ name: 'block',
+ type: 'block',
+ of: [
+ defineField({
+ name: 'inlineBlock',
+ type: 'object',
+ fields: [
+ defineField({
+ name: 'value',
+ type: 'string',
+ }),
+ ],
+ }),
+ ],
+ marks: {
+ annotations: [
+ defineField({
+ name: 'annotation',
+ type: 'object',
+ fields: [
+ defineField({
+ name: 'value',
+ type: 'string',
+ }),
+ ],
+ }),
+ ],
+ decorators: [{title: 'Custom mark', value: 'custom'}],
+ },
+ lists: [{value: 'bullet', title: 'Bullet list'}],
+ styles: [{value: 'customStyle', title: 'Custom style'}],
+ }),
+ ],
+ }),
+ ],
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(sourceSchema)
+
+ const restoredSchema = createSchema({
+ name: 'test',
+ types: extracted,
+ })
+
+ expect(restoredSchema._validation).toEqual([])
+ expect(restoredSchema.getTypeNames().sort()).toEqual(sourceSchema.getTypeNames().sort())
+
+ const restoredDocument = restoredSchema.get(documentType) as ObjectSchemaType
+ const sourceDocument = sourceSchema.get(documentType) as ObjectSchemaType
+
+ // this is not an exhaustive test (requires additional mapping to make validation, readOnly ect schema def compliant);
+ // it just asserts that a basic schema can be restored without crashing
+ expect(typeForComparison(restoredDocument)).toEqual(typeForComparison(sourceDocument))
+ })
+})
+
+function typeForComparison(_type: SchemaType, depth = 0): unknown {
+ const type = pick(_type, 'jsonType', 'name', 'title', 'fields', 'of', 'to')
+
+ if (depth > 10) {
+ return undefined
+ }
+
+ if ('to' in type) {
+ return {
+ ...type,
+ to: (type.to as SchemaType[]).map((item) => ({
+ type: item.name,
+ })),
+ }
+ }
+
+ if (type.jsonType === 'object' && type.fields) {
+ return {
+ ...type,
+ fields: type.fields.map((field) => ({
+ ...field,
+ type: typeForComparison(field.type, depth + 1),
+ })),
+ }
+ }
+ if (type.jsonType === 'array' && 'of' in type) {
+ return {
+ ...type,
+ of: (type.of as SchemaType[]).map((item) => typeForComparison(item, depth + 1)),
+ }
+ }
+
+ return type
+}
diff --git a/packages/sanity/test/manifest/extractManifestValidation.test.ts b/packages/sanity/test/manifest/extractManifestValidation.test.ts
new file mode 100644
index 00000000000..9709c8ea554
--- /dev/null
+++ b/packages/sanity/test/manifest/extractManifestValidation.test.ts
@@ -0,0 +1,515 @@
+/* eslint-disable camelcase */
+import {describe, expect, test} from '@jest/globals'
+import {defineField, defineType} from '@sanity/types'
+
+import {extractManifestSchemaTypes} from '../../src/_internal/manifest/extractWorkspaceManifest'
+import {createSchema} from '../../src/core'
+
+describe('Extract studio manifest', () => {
+ describe('serialize validation rules', () => {
+ test('object validation rules', () => {
+ const docType = 'some-doc'
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ name: docType,
+ type: 'document',
+ fields: [defineField({name: 'title', type: 'string'})],
+ validation: (rule) => [
+ rule
+ .required()
+ .custom(() => 'doesnt-matter')
+ .warning('custom-warning'),
+ rule.custom(() => 'doesnt-matter').error('custom-error'),
+ rule.custom(() => 'doesnt-matter').info('custom-info'),
+ ],
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+ const validation = extracted.find((e) => e.name === docType)?.validation
+ expect(validation).toEqual([
+ {
+ level: 'warning',
+ message: 'custom-warning',
+ rules: [{constraint: 'required', flag: 'presence'}, {flag: 'custom'}],
+ },
+ {
+ level: 'error',
+ message: 'custom-error',
+ rules: [{flag: 'custom'}],
+ },
+ {
+ level: 'info',
+ message: 'custom-info',
+ rules: [{flag: 'custom'}],
+ },
+ ])
+ })
+
+ test('array validation rules', () => {
+ const type = 'someArray'
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ name: type,
+ type: 'array',
+ of: [{type: 'string'}],
+ validation: (rule) => [
+ rule
+ .required()
+ .unique()
+ .min(1)
+ .max(10)
+ .length(10)
+ .custom(() => 'doesnt-matter'),
+ ],
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+ const validation = extracted.find((e) => e.name === type)?.validation
+ expect(validation).toEqual([
+ {
+ level: 'error',
+ rules: [
+ {constraint: 'required', flag: 'presence'},
+ {constraint: 1, flag: 'min'},
+ {constraint: 10, flag: 'max'},
+ {constraint: 10, flag: 'length'},
+ {flag: 'custom'},
+ ],
+ },
+ ])
+ })
+
+ test('boolean validation rules', () => {
+ const type = 'someArray'
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ name: type,
+ type: 'boolean',
+ validation: (rule) => [rule.required().custom(() => 'doesnt-matter')],
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+ const validation = extracted.find((e) => e.name === type)?.validation
+ expect(validation).toEqual([
+ {
+ level: 'error',
+ rules: [{constraint: 'required', flag: 'presence'}, {flag: 'custom'}],
+ },
+ ])
+ })
+
+ test('date validation rules', () => {
+ const type = 'someDate'
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ name: type,
+ type: 'date',
+ validation: (rule) => [
+ rule
+ .required()
+ .min('2022-01-01')
+ .max('2022-01-02')
+ .custom(() => 'doesnt-matter'),
+ ],
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+ const validation = extracted.find((e) => e.name === type)?.validation
+ expect(validation).toEqual([
+ {
+ level: 'error',
+ rules: [
+ {constraint: 'required', flag: 'presence'},
+ {constraint: '2022-01-01', flag: 'min'},
+ {constraint: '2022-01-02', flag: 'max'},
+ {flag: 'custom'},
+ ],
+ },
+ ])
+ })
+
+ test('image validation rules', () => {
+ const type = 'someImage'
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ name: type,
+ type: 'image',
+ validation: (rule) => [
+ rule
+ .required()
+ .assetRequired()
+ .custom(() => 'doesnt-matter'),
+ ],
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+ const validation = extracted.find((e) => e.name === type)?.validation
+ expect(validation).toEqual([
+ {
+ level: 'error',
+ rules: [
+ {constraint: 'required', flag: 'presence'},
+ {constraint: {assetType: 'image'}, flag: 'assetRequired'},
+ {flag: 'custom'},
+ ],
+ },
+ ])
+ })
+
+ test('file validation rules', () => {
+ const type = 'someFile'
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ name: type,
+ type: 'file',
+ validation: (rule) => [
+ rule
+ .required()
+ .assetRequired()
+ .custom(() => 'doesnt-matter'),
+ ],
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+ const validation = extracted.find((e) => e.name === type)?.validation
+ expect(validation).toEqual([
+ {
+ level: 'error',
+ rules: [
+ {constraint: 'required', flag: 'presence'},
+ {constraint: {assetType: 'file'}, flag: 'assetRequired'},
+ {flag: 'custom'},
+ ],
+ },
+ ])
+ })
+
+ test('number validation rules', () => {
+ const type = 'someNumber'
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ name: type,
+ type: 'number',
+ validation: (rule) => [
+ rule
+ .custom(() => 'doesnt-matter')
+ .required()
+ .min(1)
+ .max(2),
+ rule.integer().positive(),
+ rule.greaterThan(-4).negative(),
+ rule.precision(2).lessThan(5),
+ ],
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+ const validation = extracted.find((e) => e.name === type)?.validation
+ expect(validation).toEqual([
+ {
+ level: 'error',
+ rules: [
+ {flag: 'custom'},
+ {constraint: 'required', flag: 'presence'},
+ {constraint: 1, flag: 'min'},
+ {constraint: 2, flag: 'max'},
+ ],
+ },
+ {
+ level: 'error',
+ rules: [{constraint: 0, flag: 'min'}],
+ },
+ {
+ level: 'error',
+ rules: [
+ {constraint: -4, flag: 'greaterThan'},
+ {constraint: 0, flag: 'lessThan'},
+ ],
+ },
+ {
+ level: 'error',
+ rules: [
+ {constraint: 2, flag: 'precision'},
+ {constraint: 5, flag: 'lessThan'},
+ ],
+ },
+ ])
+ })
+
+ test('reference validation rules', () => {
+ const type = 'someRef'
+ const docType = 'doc'
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ type: 'document',
+ name: docType,
+ fields: [
+ defineField({
+ type: 'string',
+ name: 'title',
+ }),
+ ],
+ }),
+ defineType({
+ name: type,
+ type: 'reference',
+ to: [{type: docType}],
+ validation: (rule) => rule.required().custom(() => 'doesnt-matter'),
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+ const validation = extracted.find((e) => e.name === type)?.validation
+ expect(validation).toEqual([
+ {
+ level: 'error',
+ rules: [{constraint: 'required', flag: 'presence'}, {flag: 'custom'}],
+ },
+ ])
+ })
+
+ test('slug validation rules', () => {
+ const type = 'someSlug'
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ name: type,
+ type: 'slug',
+ validation: (rule) => rule.required().custom(() => 'doesnt-matter'),
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+ const validation = extracted.find((e) => e.name === type)?.validation
+ expect(validation).toEqual([
+ {
+ level: 'error',
+ rules: [
+ {
+ flag: 'custom', // this is the default unique checking rule
+ },
+ {
+ constraint: 'required',
+ flag: 'presence',
+ },
+ {
+ flag: 'custom',
+ },
+ ],
+ },
+ ])
+ })
+
+ test('string validation rules', () => {
+ const type = 'someString'
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ name: type,
+ type: 'string',
+ validation: (rule) => [
+ rule
+ .required()
+ .max(50)
+ .min(5)
+ .length(10)
+ .uppercase()
+ .lowercase()
+ .regex(/a+/, 'test', {name: 'yeah', invert: true})
+ .regex(/a+/, {name: 'yeah', invert: true})
+ .regex(/a+/, 'test')
+ .regex(/a+/)
+ .email()
+ .custom(() => 'doesnt-matter'),
+ ],
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+ const validation = extracted.find((e) => e.name === type)?.validation
+ expect(validation).toEqual([
+ {
+ level: 'error',
+ rules: [
+ {constraint: 'required', flag: 'presence'},
+ {constraint: 50, flag: 'max'},
+ {constraint: 5, flag: 'min'},
+ {constraint: 10, flag: 'length'},
+ {constraint: 'uppercase', flag: 'stringCasing'},
+ {constraint: 'lowercase', flag: 'stringCasing'},
+ {
+ constraint: {
+ invert: false,
+ name: 'test',
+ pattern: '/a+/',
+ },
+ flag: 'regex',
+ },
+ {
+ constraint: {
+ invert: true,
+ name: 'yeah',
+ pattern: '/a+/',
+ },
+ flag: 'regex',
+ },
+ {
+ constraint: {
+ invert: false,
+ name: 'test',
+ pattern: '/a+/',
+ },
+ flag: 'regex',
+ },
+ {
+ constraint: {
+ invert: false,
+ pattern: '/a+/',
+ },
+ flag: 'regex',
+ },
+ {
+ flag: 'custom',
+ },
+ ],
+ },
+ ])
+ })
+
+ test('url validation rules', () => {
+ const type = 'someUrl'
+ const schema = createSchema({
+ name: 'test',
+ types: [
+ defineType({
+ name: type,
+ type: 'url',
+ validation: (rule) => [
+ rule.required().custom(() => 'doesnt-matter'),
+ rule.uri({scheme: 'ftp'}),
+ rule.uri({
+ scheme: ['https'],
+ allowCredentials: true,
+ allowRelative: true,
+ relativeOnly: false,
+ }),
+ rule.uri({
+ scheme: /^custom-protocol.*$/g,
+ }),
+ ],
+ }),
+ ],
+ })
+
+ const extracted = extractManifestSchemaTypes(schema)
+ const validation = extracted.find((e) => e.name === type)?.validation
+ expect(validation).toEqual([
+ {
+ level: 'error',
+ rules: [
+ {
+ constraint: {
+ options: {
+ allowCredentials: false,
+ allowRelative: false,
+ relativeOnly: false,
+ scheme: ['/^http$/', '/^https$/'],
+ },
+ },
+ flag: 'uri',
+ },
+ {
+ constraint: 'required',
+ flag: 'presence',
+ },
+ {
+ flag: 'custom',
+ },
+ ],
+ },
+ {
+ level: 'error',
+ rules: [
+ {
+ constraint: {
+ options: {
+ allowCredentials: false,
+ allowRelative: false,
+ relativeOnly: false,
+ scheme: ['/^ftp$/'],
+ },
+ },
+ flag: 'uri',
+ },
+ ],
+ },
+ {
+ level: 'error',
+ rules: [
+ {
+ constraint: {
+ options: {
+ allowCredentials: true,
+ allowRelative: true,
+ relativeOnly: false,
+ scheme: ['/^https$/'],
+ },
+ },
+ flag: 'uri',
+ },
+ ],
+ },
+ {
+ level: 'error',
+ rules: [
+ {
+ constraint: {
+ options: {
+ allowCredentials: false,
+ allowRelative: false,
+ relativeOnly: false,
+ scheme: ['/^custom-protocol.*$/g'],
+ },
+ },
+ flag: 'uri',
+ },
+ ],
+ },
+ ])
+ })
+ })
+})