From 90b4971c1f78fb2326169fd88b3e10739bbf6eb2 Mon Sep 17 00:00:00 2001 From: Isak Wang Gustavsen <69854945+isakgustavsen@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:17:16 +0200 Subject: [PATCH 1/3] fix: resolve plugin schema types during typegen --- .../1.getting-started/3.configuration.md | 6 + playground/cms/typegen-plugin.config.ts | 23 ++++ playground/cms/typegen-test-plugin.ts | 13 ++ playground/cms/typegen-workspaces.config.ts | 22 +++ src/module.ts | 21 ++- src/runtime/typegen/schema-extractor.ts | 125 ++++++++++++++++-- test/e2e/typegen.test.ts | 1 + test/types/typegen.test.ts | 42 ++++++ 8 files changed, 242 insertions(+), 11 deletions(-) create mode 100644 playground/cms/typegen-plugin.config.ts create mode 100644 playground/cms/typegen-test-plugin.ts create mode 100644 playground/cms/typegen-workspaces.config.ts diff --git a/docs/content/1.getting-started/3.configuration.md b/docs/content/1.getting-started/3.configuration.md index 918f85faf..c7f024174 100644 --- a/docs/content/1.getting-started/3.configuration.md +++ b/docs/content/1.getting-started/3.configuration.md @@ -152,6 +152,11 @@ Used to enable and configure Visual Editing. See the [Visual Editing](/getting-s Used to enable and configure automatic TypeScript type generation for GROQ queries. See the [Type Generation](/getting-started/typegen) section for more details. +When a Sanity config file exists at `configFile`, type generation resolves the +workspace schema through that config so schema types contributed by plugins are +included. If no config file exists, only the array exported from +`schemaTypesPath` is used. + Available options: | Option | Type | Default | Description | @@ -159,6 +164,7 @@ Available options: | `enabled` | `boolean` | `false` | Enable type generation | | `schemaTypesPath` | `string` | - | Path to schema types module (required when enabled) | | `schemaTypesExport` | `string` | `'schemaTypes'` | Export name to read schema types from | +| `workspace` | `string` | - | Workspace name to use when the Sanity config defines multiple workspaces | | `queryPaths` | `string \| string[]` | `['**/*.{ts,tsx,js,jsx,mjs,cjs,vue,astro}']` | Glob patterns for files to scan | | `overloadClientMethods` | `boolean` | `true` | Generate `@sanity/client` method overloads | diff --git a/playground/cms/typegen-plugin.config.ts b/playground/cms/typegen-plugin.config.ts new file mode 100644 index 000000000..572c519ac --- /dev/null +++ b/playground/cms/typegen-plugin.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'sanity' +import { typegenTestPlugin } from './typegen-test-plugin' + +export default defineConfig({ + name: 'default', + projectId: 'typegen-test', + dataset: 'production', + plugins: [typegenTestPlugin()], + schema: { + types: [ + { + name: 'pluginDocument', + type: 'document', + fields: [ + { + name: 'pluginField', + type: 'pluginString', + }, + ], + }, + ], + }, +}) diff --git a/playground/cms/typegen-test-plugin.ts b/playground/cms/typegen-test-plugin.ts new file mode 100644 index 000000000..5ccd87df1 --- /dev/null +++ b/playground/cms/typegen-test-plugin.ts @@ -0,0 +1,13 @@ +import { definePlugin } from 'sanity' + +export const typegenTestPlugin = definePlugin({ + name: 'typegen-test-plugin', + schema: { + types: [ + { + name: 'pluginString', + type: 'string', + }, + ], + }, +}) diff --git a/playground/cms/typegen-workspaces.config.ts b/playground/cms/typegen-workspaces.config.ts new file mode 100644 index 000000000..80f93001d --- /dev/null +++ b/playground/cms/typegen-workspaces.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'sanity' + +export default defineConfig([ + { + name: 'first', + basePath: '/first', + projectId: 'typegen-test', + dataset: 'production', + schema: { + types: [{ name: 'firstDocument', type: 'document', fields: [] }], + }, + }, + { + name: 'second', + basePath: '/second', + projectId: 'typegen-test', + dataset: 'production', + schema: { + types: [{ name: 'secondDocument', type: 'document', fields: [] }], + }, + }, +]) diff --git a/src/module.ts b/src/module.ts index 79419d55e..81ab0d127 100644 --- a/src/module.ts +++ b/src/module.ts @@ -128,6 +128,10 @@ export interface SanityTypegenOptions { * @default 'schemaTypes' */ schemaTypesExport?: string + /** + * Sanity workspace name to use when the Studio config defines multiple workspaces. + */ + workspace?: string /** * Glob(s) to scan for GROQ queries. */ @@ -212,10 +216,13 @@ export default defineNuxtModule({ configFile: '~~/cms/sanity.config', }, async setup(options, nuxt) { + const resolvedSanityConfigPath = await resolvePath(options.configFile!) + const sanityConfigPath = resolvedSanityConfigPath + || /* backwards compatibility */ resolve(nuxt.options.rootDir, './sanity.json') + // If explicit configuration is not provided, attempt to load it from `sanity.config.ts` if (!options.projectId || !options.dataset) { // Register watcher on sanity.config.ts - const sanityConfigPath = await resolvePath(options.configFile!) || /* backwards compatibility */ resolve(nuxt.options.rootDir, './sanity.json') const relativeSanityConfigPath = relative(nuxt.options.rootDir, sanityConfigPath) if (!relativeSanityConfigPath.startsWith('..')) { nuxt.options.watch.push(createRegExp(exactly(relativeSanityConfigPath))) @@ -388,6 +395,9 @@ export default defineNuxtModule({ } else { const schemaTypesPath = await resolvePath(options.typegen.schemaTypesPath) + const typegenConfigPath = resolvedSanityConfigPath && existsSync(resolvedSanityConfigPath) + ? resolvedSanityConfigPath + : undefined const queryPaths = options.typegen.queryPaths ? (Array.isArray(options.typegen.queryPaths) ? options.typegen.queryPaths : [options.typegen.queryPaths]) @@ -400,6 +410,10 @@ export default defineNuxtModule({ const schema = await extractSchemaFromTypesFile({ typesPath: schemaTypesPath, exportName: options.typegen?.schemaTypesExport, + configPath: typegenConfigPath, + dataset, + projectId, + workspace: options.typegen?.workspace, }) const result = await generateSanityTypes({ @@ -465,13 +479,16 @@ export default defineNuxtModule({ if (nuxt.options.dev) { nuxt.options.watch.push(schemaTypesPath) + if (typegenConfigPath) { + nuxt.options.watch.push(typegenConfigPath) + } nuxt.hook('builder:watch', async (_event, path) => { if (!typegenTemplate) return const changedPath = isAbsolute(path) ? path : resolve(nuxt.options.rootDir, path) - const isSchemaChange = changedPath === schemaTypesPath + const isSchemaChange = changedPath === schemaTypesPath || changedPath === typegenConfigPath const relativeToSrc = relative(nuxt.options.srcDir, changedPath) const isInSrcDir = !relativeToSrc.startsWith('..') const isSupportedExt = /\.(?:ts|tsx|js|jsx|mjs|cjs|vue|astro)$/.test(changedPath) diff --git a/src/runtime/typegen/schema-extractor.ts b/src/runtime/typegen/schema-extractor.ts index 7011e5a15..3be3fe0c6 100644 --- a/src/runtime/typegen/schema-extractor.ts +++ b/src/runtime/typegen/schema-extractor.ts @@ -5,17 +5,58 @@ import { builtinTypes, extractSchema } from '@sanity/schema/_internal' type ExtractedSchema = ReturnType type SchemaTypesModule = Record & { default?: unknown } +type SanityConfig = { + dataset?: string + name?: string + projectId?: string +} + +type SanityModule = { + resolveSchemaTypes: (options: { + config: SanityConfig + context: { + dataset?: string + projectId?: string + } + }) => unknown +} export interface ExtractSchemaFromTypesOptions { typesPath: string exportName?: string + configPath?: string + dataset?: string + projectId?: string + workspace?: string } export async function extractSchemaFromTypesFile( options: ExtractSchemaFromTypesOptions, ): Promise { - const { typesPath, exportName } = options + const { typesPath, exportName, configPath } = options + + const schemaTypes = configPath + ? await resolveSchemaTypesFromConfig(configPath, options) + : await resolveSchemaTypesFromModule(typesPath, exportName) + const builtinSchema = Schema.compile({ + name: 'studio', + types: builtinTypes, + }) + + const compiledSchema = Schema.compile({ + name: 'default', + types: schemaTypes, + parent: builtinSchema, + }) + + return extractSchema(compiledSchema, { enforceRequiredFields: true }) +} + +async function resolveSchemaTypesFromModule( + typesPath: string, + exportName?: string, +): Promise { const jiti = createJiti(typesPath, { jsx: true, interopDefault: true }) const mod = await jiti.import(typesPath, { try: true }) @@ -30,18 +71,84 @@ export async function extractSchemaFromTypesFile( ) } - const builtinSchema = Schema.compile({ - name: 'studio', - types: builtinTypes, + return schemaTypes +} + +async function resolveSchemaTypesFromConfig( + configPath: string, + options: ExtractSchemaFromTypesOptions, +): Promise { + const jiti = createJiti(configPath, { + jsx: true, + interopDefault: true, + }) + const config = await jiti.import(configPath, { + default: true, + try: true, }) - const compiledSchema = Schema.compile({ - name: 'default', - types: schemaTypes, - parent: builtinSchema, + if (!config) { + throw new Error(`Could not import Sanity config at ${configPath}`) + } + + const configs = Array.isArray(config) ? config : [config] + const workspace = selectWorkspace(configs, configPath, options) + + const sanity = await jiti.import('sanity') + if (typeof sanity?.resolveSchemaTypes !== 'function') { + throw new TypeError(`The Sanity package used by ${configPath} does not export resolveSchemaTypes`) + } + + const schemaTypes = sanity.resolveSchemaTypes({ + config: workspace, + context: { + dataset: options.dataset || workspace.dataset, + projectId: options.projectId || workspace.projectId, + }, }) - return extractSchema(compiledSchema, { enforceRequiredFields: true }) + if (!Array.isArray(schemaTypes)) { + throw new TypeError(`Could not resolve schema types from Sanity config at ${configPath}`) + } + + return schemaTypes +} + +function selectWorkspace( + configs: SanityConfig[], + configPath: string, + options: ExtractSchemaFromTypesOptions, +): SanityConfig { + if (options.workspace) { + const workspace = configs.find(config => config.name === options.workspace) + if (!workspace) { + throw new Error( + `Could not find Sanity workspace "${options.workspace}" in ${configPath}. Available workspaces: ${formatWorkspaceNames(configs)}.`, + ) + } + return workspace + } + + if (configs.length === 1 && configs[0]) { + return configs[0] + } + + const matchingWorkspaces = configs.filter(config => ( + (!options.projectId || config.projectId === options.projectId) + && (!options.dataset || config.dataset === options.dataset) + )) + + if (matchingWorkspaces.length === 1 && matchingWorkspaces[0]) { + return matchingWorkspaces[0] + } + + throw new Error( + `Could not select a unique Sanity workspace from ${configPath}. Set typegen.workspace to one of: ${formatWorkspaceNames(configs)}.`, + ) +} + +function formatWorkspaceNames(configs: SanityConfig[]): string { + return configs.map(config => config.name || 'default').join(', ') } function resolveSchemaTypes(mod: SchemaTypesModule, exportName?: string): unknown { diff --git a/test/e2e/typegen.test.ts b/test/e2e/typegen.test.ts index 64d3ffcab..dc0735adf 100644 --- a/test/e2e/typegen.test.ts +++ b/test/e2e/typegen.test.ts @@ -30,5 +30,6 @@ describe('sanity typegen (nuxt integration)', () => { expect(content).toContain('// Generated by @nuxtjs/sanity') expect(content).toContain('declare module "@sanity/client"') expect(content).toContain('interface SanityQueries') + expect(content).toContain('export type SanityPreviewUrlSecret') }) }) diff --git a/test/types/typegen.test.ts b/test/types/typegen.test.ts index 1c228452c..a2f58d5d4 100644 --- a/test/types/typegen.test.ts +++ b/test/types/typegen.test.ts @@ -17,6 +17,48 @@ describe('sanity typegen (programmatic)', () => { expect(schema.some(t => t?.type === 'document' && t?.name === 'movie')).toBe(true) }) + it('extracts schema types contributed by Sanity plugins', async () => { + const configPath = resolve(process.cwd(), 'playground/cms/typegen-plugin.config.ts') + const typesPath = resolve(process.cwd(), 'playground/cms/schemaTypes/index.ts') + + const schema = await extractSchemaFromTypesFile({ + configPath, + dataset: 'production', + projectId: 'typegen-test', + typesPath, + }) + + expect(schema.some(t => t?.type === 'document' && t?.name === 'pluginDocument')).toBe(true) + }) + + it('requires an explicit workspace when config matches are ambiguous', async () => { + const configPath = resolve(process.cwd(), 'playground/cms/typegen-workspaces.config.ts') + const typesPath = resolve(process.cwd(), 'playground/cms/schemaTypes/index.ts') + + await expect(extractSchemaFromTypesFile({ + configPath, + dataset: 'production', + projectId: 'typegen-test', + typesPath, + })).rejects.toThrow('Set typegen.workspace to one of: first, second') + }) + + it('extracts the explicitly selected workspace', async () => { + const configPath = resolve(process.cwd(), 'playground/cms/typegen-workspaces.config.ts') + const typesPath = resolve(process.cwd(), 'playground/cms/schemaTypes/index.ts') + + const schema = await extractSchemaFromTypesFile({ + configPath, + dataset: 'production', + projectId: 'typegen-test', + typesPath, + workspace: 'second', + }) + + expect(schema.some(t => t?.type === 'document' && t?.name === 'secondDocument')).toBe(true) + expect(schema.some(t => t?.type === 'document' && t?.name === 'firstDocument')).toBe(false) + }) + it('generates type declarations for extracted queries', async () => { const typesPath = resolve(process.cwd(), 'test/fixtures/schema-types.ts') const schema = await extractSchemaFromTypesFile({ typesPath }) From 5d5e6a8036625097e4a2d642a349fee8b956c7a9 Mon Sep 17 00:00:00 2001 From: Isak Wang Gustavsen <69854945+isakgustavsen@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:30:13 +0200 Subject: [PATCH 2/3] fix: preserve schemaTypesPath precedence --- .../1.getting-started/3.configuration.md | 8 +++--- playground/cms/typegen-workspaces.config.ts | 18 ++++++++++++- src/runtime/typegen/schema-extractor.ts | 26 +++++++++++++------ test/types/typegen.test.ts | 9 ++++--- 4 files changed, 45 insertions(+), 16 deletions(-) diff --git a/docs/content/1.getting-started/3.configuration.md b/docs/content/1.getting-started/3.configuration.md index c7f024174..7b825dc10 100644 --- a/docs/content/1.getting-started/3.configuration.md +++ b/docs/content/1.getting-started/3.configuration.md @@ -152,10 +152,10 @@ Used to enable and configure Visual Editing. See the [Visual Editing](/getting-s Used to enable and configure automatic TypeScript type generation for GROQ queries. See the [Type Generation](/getting-started/typegen) section for more details. -When a Sanity config file exists at `configFile`, type generation resolves the -workspace schema through that config so schema types contributed by plugins are -included. If no config file exists, only the array exported from -`schemaTypesPath` is used. +The array exported from `schemaTypesPath` remains the source of the workspace +schema. When a Sanity config file exists at `configFile`, type generation also +resolves plugins from that config so their contributed schema types are +included. Available options: diff --git a/playground/cms/typegen-workspaces.config.ts b/playground/cms/typegen-workspaces.config.ts index 80f93001d..de341535b 100644 --- a/playground/cms/typegen-workspaces.config.ts +++ b/playground/cms/typegen-workspaces.config.ts @@ -1,4 +1,18 @@ -import { defineConfig } from 'sanity' +import { defineConfig, definePlugin } from 'sanity' + +const firstPlugin = definePlugin({ + name: 'first-plugin', + schema: { + types: [{ name: 'firstPluginString', type: 'string' }], + }, +}) + +const secondPlugin = definePlugin({ + name: 'second-plugin', + schema: { + types: [{ name: 'secondPluginString', type: 'string' }], + }, +}) export default defineConfig([ { @@ -6,6 +20,7 @@ export default defineConfig([ basePath: '/first', projectId: 'typegen-test', dataset: 'production', + plugins: [firstPlugin()], schema: { types: [{ name: 'firstDocument', type: 'document', fields: [] }], }, @@ -15,6 +30,7 @@ export default defineConfig([ basePath: '/second', projectId: 'typegen-test', dataset: 'production', + plugins: [secondPlugin()], schema: { types: [{ name: 'secondDocument', type: 'document', fields: [] }], }, diff --git a/src/runtime/typegen/schema-extractor.ts b/src/runtime/typegen/schema-extractor.ts index 3be3fe0c6..d3d3b522a 100644 --- a/src/runtime/typegen/schema-extractor.ts +++ b/src/runtime/typegen/schema-extractor.ts @@ -8,6 +8,7 @@ type SchemaTypesModule = Record & { default?: unknown } type SanityConfig = { dataset?: string name?: string + schema?: Record projectId?: string } @@ -35,9 +36,10 @@ export async function extractSchemaFromTypesFile( ): Promise { const { typesPath, exportName, configPath } = options - const schemaTypes = configPath - ? await resolveSchemaTypesFromConfig(configPath, options) - : await resolveSchemaTypesFromModule(typesPath, exportName) + const schemaTypes = await resolveSchemaTypesFromModule(typesPath, exportName) + const resolvedSchemaTypes = configPath + ? await resolveSchemaTypesFromConfig(configPath, options, schemaTypes) + : schemaTypes const builtinSchema = Schema.compile({ name: 'studio', @@ -46,7 +48,7 @@ export async function extractSchemaFromTypesFile( const compiledSchema = Schema.compile({ name: 'default', - types: schemaTypes, + types: resolvedSchemaTypes, parent: builtinSchema, }) @@ -77,6 +79,7 @@ async function resolveSchemaTypesFromModule( async function resolveSchemaTypesFromConfig( configPath: string, options: ExtractSchemaFromTypesOptions, + schemaTypes: unknown[], ): Promise { const jiti = createJiti(configPath, { jsx: true, @@ -93,25 +96,32 @@ async function resolveSchemaTypesFromConfig( const configs = Array.isArray(config) ? config : [config] const workspace = selectWorkspace(configs, configPath, options) + const typegenWorkspace = { + ...workspace, + schema: { + ...workspace.schema, + types: schemaTypes, + }, + } const sanity = await jiti.import('sanity') if (typeof sanity?.resolveSchemaTypes !== 'function') { throw new TypeError(`The Sanity package used by ${configPath} does not export resolveSchemaTypes`) } - const schemaTypes = sanity.resolveSchemaTypes({ - config: workspace, + const resolvedSchemaTypes = sanity.resolveSchemaTypes({ + config: typegenWorkspace, context: { dataset: options.dataset || workspace.dataset, projectId: options.projectId || workspace.projectId, }, }) - if (!Array.isArray(schemaTypes)) { + if (!Array.isArray(resolvedSchemaTypes)) { throw new TypeError(`Could not resolve schema types from Sanity config at ${configPath}`) } - return schemaTypes + return resolvedSchemaTypes } function selectWorkspace( diff --git a/test/types/typegen.test.ts b/test/types/typegen.test.ts index a2f58d5d4..131243216 100644 --- a/test/types/typegen.test.ts +++ b/test/types/typegen.test.ts @@ -28,7 +28,9 @@ describe('sanity typegen (programmatic)', () => { typesPath, }) - expect(schema.some(t => t?.type === 'document' && t?.name === 'pluginDocument')).toBe(true) + expect(schema.some(t => t?.name === 'pluginString')).toBe(true) + expect(schema.some(t => t?.type === 'document' && t?.name === 'movie')).toBe(true) + expect(schema.some(t => t?.name === 'pluginDocument')).toBe(false) }) it('requires an explicit workspace when config matches are ambiguous', async () => { @@ -55,8 +57,9 @@ describe('sanity typegen (programmatic)', () => { workspace: 'second', }) - expect(schema.some(t => t?.type === 'document' && t?.name === 'secondDocument')).toBe(true) - expect(schema.some(t => t?.type === 'document' && t?.name === 'firstDocument')).toBe(false) + expect(schema.some(t => t?.name === 'secondPluginString')).toBe(true) + expect(schema.some(t => t?.name === 'firstPluginString')).toBe(false) + expect(schema.some(t => t?.type === 'document' && t?.name === 'movie')).toBe(true) }) it('generates type declarations for extracted queries', async () => { From f4e77a7db4503d8e068c3ab99cc5b4f04134d47c Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 17 Aug 2026 14:47:09 +0000 Subject: [PATCH 3/3] refactor: resolve typegen schema from the sanity config --- .../1.getting-started/3.configuration.md | 7 +- docs/content/1.getting-started/7.typegen.md | 15 +- .../plugin.config.ts} | 2 +- .../test-plugin.ts} | 0 .../workspaces.config.ts} | 0 src/module.ts | 35 ++-- src/runtime/typegen/schema-extractor.ts | 149 ++++++++---------- src/runtime/typegen/studio-config.ts | 57 +++++++ test/types/typegen.test.ts | 40 ++--- 9 files changed, 172 insertions(+), 133 deletions(-) rename playground/cms/{typegen-plugin.config.ts => typegen-fixtures/plugin.config.ts} (87%) rename playground/cms/{typegen-test-plugin.ts => typegen-fixtures/test-plugin.ts} (100%) rename playground/cms/{typegen-workspaces.config.ts => typegen-fixtures/workspaces.config.ts} (100%) create mode 100644 src/runtime/typegen/studio-config.ts diff --git a/docs/content/1.getting-started/3.configuration.md b/docs/content/1.getting-started/3.configuration.md index 7b825dc10..a6484ace0 100644 --- a/docs/content/1.getting-started/3.configuration.md +++ b/docs/content/1.getting-started/3.configuration.md @@ -152,17 +152,14 @@ Used to enable and configure Visual Editing. See the [Visual Editing](/getting-s Used to enable and configure automatic TypeScript type generation for GROQ queries. See the [Type Generation](/getting-started/typegen) section for more details. -The array exported from `schemaTypesPath` remains the source of the workspace -schema. When a Sanity config file exists at `configFile`, type generation also -resolves plugins from that config so their contributed schema types are -included. +When a Sanity config file exists at `configFile`, the schema is resolved from it, including types contributed by plugins. `schemaTypesPath` is used when there is no Sanity config to read. Available options: | Option | Type | Default | Description | |--------|------|---------|-------------| | `enabled` | `boolean` | `false` | Enable type generation | -| `schemaTypesPath` | `string` | - | Path to schema types module (required when enabled) | +| `schemaTypesPath` | `string` | - | Path to schema types module (required when there is no Sanity config file) | | `schemaTypesExport` | `string` | `'schemaTypes'` | Export name to read schema types from | | `workspace` | `string` | - | Workspace name to use when the Sanity config defines multiple workspaces | | `queryPaths` | `string \| string[]` | `['**/*.{ts,tsx,js,jsx,mjs,cjs,vue,astro}']` | Glob patterns for files to scan | diff --git a/docs/content/1.getting-started/7.typegen.md b/docs/content/1.getting-started/7.typegen.md index 11f2af33c..e9e088c59 100644 --- a/docs/content/1.getting-started/7.typegen.md +++ b/docs/content/1.getting-started/7.typegen.md @@ -4,7 +4,9 @@ ### Prerequisites -Your project must have a Sanity schema types file that exports an array of schema type definitions. This is typically located at `cms/schemaTypes/index.ts`: +Your project must have a Sanity config file (see [`configFile`](/getting-started/configuration#configfile)), or a schema types file that exports an array of schema type definitions. + +When a Sanity config file is present, the schema is resolved from it, so types contributed by plugins are included. Otherwise the schema is read from `schemaTypesPath`, which is typically located at `cms/schemaTypes/index.ts`: ```ts{}[cms/schemaTypes/index.ts] import { movie } from './movie' @@ -99,9 +101,15 @@ Enable or disable type generation. ### `typegen.schemaTypesPath` - Type: **string** -- Required when `enabled` is `true` +- Required when `enabled` is `true` and there is no Sanity config file + +Path to your schema types module. This should be a file that exports an array of Sanity schema type definitions. It is ignored when a Sanity config file is found, as the config is a superset of it. + +### `typegen.workspace` + +- Type: **string** -Path to your schema types module. This should be a file that exports an array of Sanity schema type definitions. +The name of the workspace to generate types for, when your Sanity config defines more than one. If it is omitted, the workspace matching your `projectId` and `dataset` is used. ### `typegen.schemaTypesExport` @@ -134,6 +142,7 @@ Currently, you must manually specify the result type in composables (e.g., `useS In development mode, the module watches for changes to: +- Your Sanity config file (`configFile`) - Your schema types file (`schemaTypesPath`) - Any files matching `queryPaths` patterns diff --git a/playground/cms/typegen-plugin.config.ts b/playground/cms/typegen-fixtures/plugin.config.ts similarity index 87% rename from playground/cms/typegen-plugin.config.ts rename to playground/cms/typegen-fixtures/plugin.config.ts index 572c519ac..4f9581e93 100644 --- a/playground/cms/typegen-plugin.config.ts +++ b/playground/cms/typegen-fixtures/plugin.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from 'sanity' -import { typegenTestPlugin } from './typegen-test-plugin' +import { typegenTestPlugin } from './test-plugin' export default defineConfig({ name: 'default', diff --git a/playground/cms/typegen-test-plugin.ts b/playground/cms/typegen-fixtures/test-plugin.ts similarity index 100% rename from playground/cms/typegen-test-plugin.ts rename to playground/cms/typegen-fixtures/test-plugin.ts diff --git a/playground/cms/typegen-workspaces.config.ts b/playground/cms/typegen-fixtures/workspaces.config.ts similarity index 100% rename from playground/cms/typegen-workspaces.config.ts rename to playground/cms/typegen-fixtures/workspaces.config.ts diff --git a/src/module.ts b/src/module.ts index 81ab0d127..b6ece3a43 100644 --- a/src/module.ts +++ b/src/module.ts @@ -2,7 +2,6 @@ import { fileURLToPath } from 'node:url' import crypto from 'node:crypto' import { existsSync } from 'node:fs' import { readFile, writeFile } from 'node:fs/promises' -import { createJiti } from 'jiti' import { createRegExp, exactly } from 'magic-regexp' import { addComponent, @@ -35,6 +34,7 @@ import { name, version } from '../package.json' import type { ClientConfig as MinimalClientConfig } from './runtime/minimal-client' import type { SanityGroqQueryArray, SanityGroqQueryMap, SanityPublicRuntimeConfig, SanityRuntimeConfig, SanityVisualEditingZIndex } from './runtime/types' import { extractSchemaFromTypesFile } from './runtime/typegen/schema-extractor' +import { loadStudioConfig, selectStudioWorkspace } from './runtime/typegen/studio-config' import { generateSanityTypes } from './runtime/typegen/type-generator' export type SanityVisualEditingMode = 'live-visual-editing' | 'visual-editing' | 'custom' @@ -120,6 +120,9 @@ export interface SanityTypegenOptions { enabled?: boolean /** * Path to a module exporting your schema types array (e.g. `cms/schemaTypes/index.ts`). + * + * Only used when no Sanity config file is found at `configFile`, as the schema declared there + * includes types contributed by plugins. */ schemaTypesPath?: string /** @@ -216,9 +219,9 @@ export default defineNuxtModule({ configFile: '~~/cms/sanity.config', }, async setup(options, nuxt) { - const resolvedSanityConfigPath = await resolvePath(options.configFile!) - const sanityConfigPath = resolvedSanityConfigPath + const sanityConfigPath = await resolvePath(options.configFile!) || /* backwards compatibility */ resolve(nuxt.options.rootDir, './sanity.json') + const hasSanityConfig = existsSync(sanityConfigPath) // If explicit configuration is not provided, attempt to load it from `sanity.config.ts` if (!options.projectId || !options.dataset) { @@ -227,12 +230,12 @@ export default defineNuxtModule({ if (!relativeSanityConfigPath.startsWith('..')) { nuxt.options.watch.push(createRegExp(exactly(relativeSanityConfigPath))) } - const jiti = createJiti(import.meta.url, { jsx: true }) - if (existsSync(sanityConfigPath)) { - const sanityConfig = await jiti.import(sanityConfigPath, { default: true, try: true }) as { projectId?: string, dataset?: string } - if (sanityConfig) { - options.projectId ||= sanityConfig.projectId - options.dataset ||= sanityConfig.dataset + if (hasSanityConfig) { + const config = await loadStudioConfig(sanityConfigPath) + const workspace = config && selectStudioWorkspace(config.workspaces, { name: options.typegen?.workspace }) + if (workspace) { + options.projectId ||= workspace.projectId + options.dataset ||= workspace.dataset } } } @@ -390,14 +393,14 @@ export default defineNuxtModule({ let typegenTemplate: { filename: string, dst: string } | null = null if (options.typegen?.enabled) { - if (!options.typegen.schemaTypesPath) { - logger.warn('Sanity typegen is enabled but `schemaTypesPath` is missing.') + if (!options.typegen.schemaTypesPath && !hasSanityConfig) { + logger.warn('Sanity typegen is enabled but no Sanity config was found and `schemaTypesPath` is missing.') } else { - const schemaTypesPath = await resolvePath(options.typegen.schemaTypesPath) - const typegenConfigPath = resolvedSanityConfigPath && existsSync(resolvedSanityConfigPath) - ? resolvedSanityConfigPath + const schemaTypesPath = options.typegen.schemaTypesPath + ? await resolvePath(options.typegen.schemaTypesPath) : undefined + const typegenConfigPath = hasSanityConfig ? sanityConfigPath : undefined const queryPaths = options.typegen.queryPaths ? (Array.isArray(options.typegen.queryPaths) ? options.typegen.queryPaths : [options.typegen.queryPaths]) @@ -478,7 +481,9 @@ export default defineNuxtModule({ }) if (nuxt.options.dev) { - nuxt.options.watch.push(schemaTypesPath) + if (schemaTypesPath) { + nuxt.options.watch.push(schemaTypesPath) + } if (typegenConfigPath) { nuxt.options.watch.push(typegenConfigPath) } diff --git a/src/runtime/typegen/schema-extractor.ts b/src/runtime/typegen/schema-extractor.ts index d3d3b522a..4b54e2600 100644 --- a/src/runtime/typegen/schema-extractor.ts +++ b/src/runtime/typegen/schema-extractor.ts @@ -1,30 +1,31 @@ import { createJiti } from 'jiti' +import { consola } from 'consola' import { Schema } from '@sanity/schema' import { builtinTypes, extractSchema } from '@sanity/schema/_internal' +import type { SanityStudioWorkspace } from './studio-config' +import { formatWorkspaceNames, loadStudioConfig, selectStudioWorkspace } from './studio-config' type ExtractedSchema = ReturnType type SchemaTypesModule = Record & { default?: unknown } -type SanityConfig = { - dataset?: string - name?: string - schema?: Record - projectId?: string -} type SanityModule = { resolveSchemaTypes: (options: { - config: SanityConfig - context: { - dataset?: string - projectId?: string - } + config: SanityStudioWorkspace + context: { projectId?: string, dataset?: string } }) => unknown } +const logger = consola.withTag('@nuxtjs/sanity') + +/** Thrown when the Sanity config declares workspaces we cannot choose between. Never recoverable by falling back. */ +class WorkspaceSelectionError extends Error {} + export interface ExtractSchemaFromTypesOptions { - typesPath: string + /** Path to a module exporting an array of schema types. Used when no Sanity config is available. */ + typesPath?: string exportName?: string + /** Path to a Sanity config file, whose schema types and plugins are the source of truth when present. */ configPath?: string dataset?: string projectId?: string @@ -34,12 +35,7 @@ export interface ExtractSchemaFromTypesOptions { export async function extractSchemaFromTypesFile( options: ExtractSchemaFromTypesOptions, ): Promise { - const { typesPath, exportName, configPath } = options - - const schemaTypes = await resolveSchemaTypesFromModule(typesPath, exportName) - const resolvedSchemaTypes = configPath - ? await resolveSchemaTypesFromConfig(configPath, options, schemaTypes) - : schemaTypes + const schemaTypes = await resolveSchemaTypes(options) const builtinSchema = Schema.compile({ name: 'studio', @@ -48,60 +44,57 @@ export async function extractSchemaFromTypesFile( const compiledSchema = Schema.compile({ name: 'default', - types: resolvedSchemaTypes, + types: schemaTypes, parent: builtinSchema, }) return extractSchema(compiledSchema, { enforceRequiredFields: true }) } -async function resolveSchemaTypesFromModule( - typesPath: string, - exportName?: string, -): Promise { - const jiti = createJiti(typesPath, { jsx: true, interopDefault: true }) - const mod = await jiti.import(typesPath, { try: true }) +async function resolveSchemaTypes(options: ExtractSchemaFromTypesOptions): Promise { + const { configPath, typesPath, exportName } = options - if (!mod) { - throw new Error(`Could not import schema types module at ${typesPath}`) + if (configPath) { + try { + return await resolveSchemaTypesFromConfig(configPath, options) + } + catch (error) { + if (error instanceof WorkspaceSelectionError || !typesPath) { + throw error + } + const message = error instanceof Error ? error.message : String(error) + logger.warn(`Could not resolve schema types from ${configPath}, falling back to ${typesPath}: ${message}`) + } } - const schemaTypes = resolveSchemaTypes(mod, exportName) - if (!Array.isArray(schemaTypes)) { - throw new TypeError( - `Could not resolve schema types from ${typesPath}. Expected an array export (default or named export).`, - ) + if (!typesPath) { + throw new Error('Could not extract a Sanity schema. Provide either a Sanity config file or `schemaTypesPath`.') } - return schemaTypes + return readSchemaTypesFromModule(typesPath, exportName) } async function resolveSchemaTypesFromConfig( configPath: string, options: ExtractSchemaFromTypesOptions, - schemaTypes: unknown[], ): Promise { - const jiti = createJiti(configPath, { - jsx: true, - interopDefault: true, - }) - const config = await jiti.import(configPath, { - default: true, - try: true, - }) + const config = await loadStudioConfig(configPath) if (!config) { throw new Error(`Could not import Sanity config at ${configPath}`) } - const configs = Array.isArray(config) ? config : [config] - const workspace = selectWorkspace(configs, configPath, options) - const typegenWorkspace = { - ...workspace, - schema: { - ...workspace.schema, - types: schemaTypes, - }, + const { jiti, workspaces } = config + const workspace = selectStudioWorkspace(workspaces, { + name: options.workspace, + projectId: options.projectId, + dataset: options.dataset, + }) + + if (!workspace) { + throw new WorkspaceSelectionError( + `Could not resolve a Sanity workspace in ${configPath}. Set \`typegen.workspace\` to one of: ${formatWorkspaceNames(workspaces)}.`, + ) } const sanity = await jiti.import('sanity') @@ -109,59 +102,43 @@ async function resolveSchemaTypesFromConfig( throw new TypeError(`The Sanity package used by ${configPath} does not export resolveSchemaTypes`) } - const resolvedSchemaTypes = sanity.resolveSchemaTypes({ - config: typegenWorkspace, + const schemaTypes = sanity.resolveSchemaTypes({ + config: workspace, context: { - dataset: options.dataset || workspace.dataset, projectId: options.projectId || workspace.projectId, + dataset: options.dataset || workspace.dataset, }, }) - if (!Array.isArray(resolvedSchemaTypes)) { + if (!Array.isArray(schemaTypes)) { throw new TypeError(`Could not resolve schema types from Sanity config at ${configPath}`) } - return resolvedSchemaTypes + return schemaTypes } -function selectWorkspace( - configs: SanityConfig[], - configPath: string, - options: ExtractSchemaFromTypesOptions, -): SanityConfig { - if (options.workspace) { - const workspace = configs.find(config => config.name === options.workspace) - if (!workspace) { - throw new Error( - `Could not find Sanity workspace "${options.workspace}" in ${configPath}. Available workspaces: ${formatWorkspaceNames(configs)}.`, - ) - } - return workspace - } +async function readSchemaTypesFromModule( + typesPath: string, + exportName?: string, +): Promise { + const jiti = createJiti(typesPath, { jsx: true, interopDefault: true }) + const mod = await jiti.import(typesPath, { try: true }) - if (configs.length === 1 && configs[0]) { - return configs[0] + if (!mod) { + throw new Error(`Could not import schema types module at ${typesPath}`) } - const matchingWorkspaces = configs.filter(config => ( - (!options.projectId || config.projectId === options.projectId) - && (!options.dataset || config.dataset === options.dataset) - )) - - if (matchingWorkspaces.length === 1 && matchingWorkspaces[0]) { - return matchingWorkspaces[0] + const schemaTypes = readSchemaTypesExport(mod, exportName) + if (!Array.isArray(schemaTypes)) { + throw new TypeError( + `Could not resolve schema types from ${typesPath}. Expected an array export (default or named export).`, + ) } - throw new Error( - `Could not select a unique Sanity workspace from ${configPath}. Set typegen.workspace to one of: ${formatWorkspaceNames(configs)}.`, - ) -} - -function formatWorkspaceNames(configs: SanityConfig[]): string { - return configs.map(config => config.name || 'default').join(', ') + return schemaTypes } -function resolveSchemaTypes(mod: SchemaTypesModule, exportName?: string): unknown { +function readSchemaTypesExport(mod: SchemaTypesModule, exportName?: string): unknown { if (exportName) { return mod[exportName] } diff --git a/src/runtime/typegen/studio-config.ts b/src/runtime/typegen/studio-config.ts new file mode 100644 index 000000000..e46f64e46 --- /dev/null +++ b/src/runtime/typegen/studio-config.ts @@ -0,0 +1,57 @@ +import { createJiti } from 'jiti' + +export interface SanityStudioWorkspace { + name?: string + projectId?: string + dataset?: string + schema?: { types?: unknown[] } +} + +export interface LoadedStudioConfig { + /** Rooted at the config file so that `sanity` resolves from the studio's own dependencies. */ + jiti: ReturnType + workspaces: SanityStudioWorkspace[] +} + +export async function loadStudioConfig(configPath: string): Promise { + const jiti = createJiti(configPath, { jsx: true, interopDefault: true }) + const config = await jiti.import(configPath, { + default: true, + try: true, + }) + + if (!config) { + return + } + + return { jiti, workspaces: [config].flat() } +} + +export interface SelectStudioWorkspaceOptions { + name?: string + projectId?: string + dataset?: string +} + +export function selectStudioWorkspace( + workspaces: SanityStudioWorkspace[], + options: SelectStudioWorkspaceOptions = {}, +): SanityStudioWorkspace | undefined { + if (options.name) { + return workspaces.find(workspace => workspace.name === options.name) + } + + if (workspaces.length === 1) { + return workspaces[0] + } + + const matches = workspaces.filter(workspace => ( + workspace.projectId === options.projectId && workspace.dataset === options.dataset + )) + + return matches.length === 1 ? matches[0] : undefined +} + +export function formatWorkspaceNames(workspaces: SanityStudioWorkspace[]): string { + return workspaces.map(workspace => workspace.name || 'default').join(', ') +} diff --git a/test/types/typegen.test.ts b/test/types/typegen.test.ts index 131243216..e668723eb 100644 --- a/test/types/typegen.test.ts +++ b/test/types/typegen.test.ts @@ -7,6 +7,9 @@ import { join, resolve } from 'pathe' import { extractSchemaFromTypesFile } from '../../src/runtime/typegen/schema-extractor' import { generateSanityTypes } from '../../src/runtime/typegen/type-generator' +const pluginConfigPath = resolve(process.cwd(), 'playground/cms/typegen-fixtures/plugin.config.ts') +const workspacesConfigPath = resolve(process.cwd(), 'playground/cms/typegen-fixtures/workspaces.config.ts') + describe('sanity typegen (programmatic)', () => { it('extracts schema from schema types module', async () => { const typesPath = resolve(process.cwd(), 'test/fixtures/schema-types.ts') @@ -17,49 +20,40 @@ describe('sanity typegen (programmatic)', () => { expect(schema.some(t => t?.type === 'document' && t?.name === 'movie')).toBe(true) }) - it('extracts schema types contributed by Sanity plugins', async () => { - const configPath = resolve(process.cwd(), 'playground/cms/typegen-plugin.config.ts') - const typesPath = resolve(process.cwd(), 'playground/cms/schemaTypes/index.ts') + it('extracts schema types declared by the Sanity config and its plugins', async () => { + const schema = await extractSchemaFromTypesFile({ configPath: pluginConfigPath }) + + expect(schema.some(t => t?.name === 'pluginString')).toBe(true) + expect(schema.some(t => t?.type === 'document' && t?.name === 'pluginDocument')).toBe(true) + }) + it('falls back to the schema types module when the config cannot be resolved', async () => { const schema = await extractSchemaFromTypesFile({ - configPath, - dataset: 'production', - projectId: 'typegen-test', - typesPath, + configPath: resolve(process.cwd(), 'playground/cms/typegen-fixtures/does-not-exist.config.ts'), + typesPath: resolve(process.cwd(), 'test/fixtures/schema-types.ts'), }) - expect(schema.some(t => t?.name === 'pluginString')).toBe(true) expect(schema.some(t => t?.type === 'document' && t?.name === 'movie')).toBe(true) - expect(schema.some(t => t?.name === 'pluginDocument')).toBe(false) }) it('requires an explicit workspace when config matches are ambiguous', async () => { - const configPath = resolve(process.cwd(), 'playground/cms/typegen-workspaces.config.ts') - const typesPath = resolve(process.cwd(), 'playground/cms/schemaTypes/index.ts') - await expect(extractSchemaFromTypesFile({ - configPath, + configPath: workspacesConfigPath, dataset: 'production', projectId: 'typegen-test', - typesPath, - })).rejects.toThrow('Set typegen.workspace to one of: first, second') + typesPath: resolve(process.cwd(), 'test/fixtures/schema-types.ts'), + })).rejects.toThrow('Set `typegen.workspace` to one of: first, second') }) it('extracts the explicitly selected workspace', async () => { - const configPath = resolve(process.cwd(), 'playground/cms/typegen-workspaces.config.ts') - const typesPath = resolve(process.cwd(), 'playground/cms/schemaTypes/index.ts') - const schema = await extractSchemaFromTypesFile({ - configPath, - dataset: 'production', - projectId: 'typegen-test', - typesPath, + configPath: workspacesConfigPath, workspace: 'second', }) expect(schema.some(t => t?.name === 'secondPluginString')).toBe(true) expect(schema.some(t => t?.name === 'firstPluginString')).toBe(false) - expect(schema.some(t => t?.type === 'document' && t?.name === 'movie')).toBe(true) + expect(schema.some(t => t?.type === 'document' && t?.name === 'secondDocument')).toBe(true) }) it('generates type declarations for extracted queries', async () => {