diff --git a/docs/content/1.getting-started/3.configuration.md b/docs/content/1.getting-started/3.configuration.md index 918f85faf..a6484ace0 100644 --- a/docs/content/1.getting-started/3.configuration.md +++ b/docs/content/1.getting-started/3.configuration.md @@ -152,13 +152,16 @@ 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`, 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 | | `overloadClientMethods` | `boolean` | `true` | Generate `@sanity/client` method overloads | 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-fixtures/plugin.config.ts b/playground/cms/typegen-fixtures/plugin.config.ts new file mode 100644 index 000000000..4f9581e93 --- /dev/null +++ b/playground/cms/typegen-fixtures/plugin.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'sanity' +import { typegenTestPlugin } from './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-fixtures/test-plugin.ts b/playground/cms/typegen-fixtures/test-plugin.ts new file mode 100644 index 000000000..5ccd87df1 --- /dev/null +++ b/playground/cms/typegen-fixtures/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-fixtures/workspaces.config.ts b/playground/cms/typegen-fixtures/workspaces.config.ts new file mode 100644 index 000000000..de341535b --- /dev/null +++ b/playground/cms/typegen-fixtures/workspaces.config.ts @@ -0,0 +1,38 @@ +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([ + { + name: 'first', + basePath: '/first', + projectId: 'typegen-test', + dataset: 'production', + plugins: [firstPlugin()], + schema: { + types: [{ name: 'firstDocument', type: 'document', fields: [] }], + }, + }, + { + name: 'second', + basePath: '/second', + projectId: 'typegen-test', + dataset: 'production', + plugins: [secondPlugin()], + schema: { + types: [{ name: 'secondDocument', type: 'document', fields: [] }], + }, + }, +]) diff --git a/src/module.ts b/src/module.ts index 79419d55e..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 /** @@ -128,6 +131,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,20 +219,23 @@ export default defineNuxtModule({ configFile: '~~/cms/sanity.config', }, async setup(options, nuxt) { + 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) { // 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))) } - 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 } } } @@ -383,11 +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 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]) @@ -400,6 +413,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({ @@ -464,14 +481,19 @@ 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) + } 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..4b54e2600 100644 --- a/src/runtime/typegen/schema-extractor.ts +++ b/src/runtime/typegen/schema-extractor.ts @@ -1,21 +1,126 @@ 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 SanityModule = { + resolveSchemaTypes: (options: { + 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 + workspace?: string } export async function extractSchemaFromTypesFile( options: ExtractSchemaFromTypesOptions, ): Promise { - const { typesPath, exportName } = options + const schemaTypes = await resolveSchemaTypes(options) + + 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 resolveSchemaTypes(options: ExtractSchemaFromTypesOptions): Promise { + const { configPath, typesPath, exportName } = options + + 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}`) + } + } + if (!typesPath) { + throw new Error('Could not extract a Sanity schema. Provide either a Sanity config file or `schemaTypesPath`.') + } + + return readSchemaTypesFromModule(typesPath, exportName) +} + +async function resolveSchemaTypesFromConfig( + configPath: string, + options: ExtractSchemaFromTypesOptions, +): Promise { + const config = await loadStudioConfig(configPath) + + if (!config) { + throw new Error(`Could not import Sanity config at ${configPath}`) + } + + 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') + 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: { + projectId: options.projectId || workspace.projectId, + dataset: options.dataset || workspace.dataset, + }, + }) + + if (!Array.isArray(schemaTypes)) { + throw new TypeError(`Could not resolve schema types from Sanity config at ${configPath}`) + } + + return schemaTypes +} + +async function readSchemaTypesFromModule( + typesPath: string, + exportName?: string, +): Promise { const jiti = createJiti(typesPath, { jsx: true, interopDefault: true }) const mod = await jiti.import(typesPath, { try: true }) @@ -23,28 +128,17 @@ export async function extractSchemaFromTypesFile( throw new Error(`Could not import schema types module at ${typesPath}`) } - const schemaTypes = resolveSchemaTypes(mod, exportName) + 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).`, ) } - const builtinSchema = Schema.compile({ - name: 'studio', - types: builtinTypes, - }) - - const compiledSchema = Schema.compile({ - name: 'default', - types: schemaTypes, - parent: builtinSchema, - }) - - return extractSchema(compiledSchema, { enforceRequiredFields: true }) + 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/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..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,6 +20,42 @@ describe('sanity typegen (programmatic)', () => { expect(schema.some(t => t?.type === 'document' && t?.name === 'movie')).toBe(true) }) + 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: 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?.type === 'document' && t?.name === 'movie')).toBe(true) + }) + + it('requires an explicit workspace when config matches are ambiguous', async () => { + await expect(extractSchemaFromTypesFile({ + configPath: workspacesConfigPath, + dataset: 'production', + projectId: 'typegen-test', + 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 schema = await extractSchemaFromTypesFile({ + 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 === 'secondDocument')).toBe(true) + }) + it('generates type declarations for extracted queries', async () => { const typesPath = resolve(process.cwd(), 'test/fixtures/schema-types.ts') const schema = await extractSchemaFromTypesFile({ typesPath })