diff --git a/packages/kit/src/index.ts b/packages/kit/src/index.ts index a562990317..07cfd11a77 100644 --- a/packages/kit/src/index.ts +++ b/packages/kit/src/index.ts @@ -4,6 +4,7 @@ export * from './module/install' // Loader export * from './loader/config' +export * from './loader/schema' export * from './loader/nuxt' // Utils diff --git a/packages/kit/src/loader/schema.ts b/packages/kit/src/loader/schema.ts new file mode 100644 index 0000000000..c905434c1b --- /dev/null +++ b/packages/kit/src/loader/schema.ts @@ -0,0 +1,9 @@ +import type { SchemaDefinition } from '@nuxt/schema' +import { useNuxt } from '../context' + +export function extendNuxtSchema (def: SchemaDefinition | (() => SchemaDefinition)) { + const nuxt = useNuxt() + nuxt.hook('schema:extend', (schemas) => { + schemas.push(typeof def === 'function' ? def() : def) + }) +} diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 2be4c4c69c..971416d507 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -42,11 +42,10 @@ "@nuxt/telemetry": "^2.1.9", "@nuxt/ui-templates": "^1.1.0", "@nuxt/vite-builder": "3.0.0", + "@unhead/ssr": "^1.0.18", "@vue/reactivity": "^3.2.45", "@vue/shared": "^3.2.45", "@vueuse/head": "^1.0.23", - "unhead": "^1.0.18", - "@unhead/ssr": "^1.0.18", "chokidar": "^3.5.3", "cookie-es": "^0.5.0", "defu": "^6.1.1", @@ -58,13 +57,14 @@ "h3": "^1.0.2", "hash-sum": "^2.0.0", "hookable": "^5.4.2", + "jiti": "^1.16.2", "knitwork": "^1.0.0", "magic-string": "^0.27.0", "mlly": "^1.1.0", "nitropack": "^2.0.0-rc.0", "nuxi": "3.0.0", - "ohash": "^1.0.0", "ofetch": "^1.0.0", + "ohash": "^1.0.0", "pathe": "^1.0.0", "perfect-debounce": "^0.1.3", "scule": "^1.0.0", @@ -73,6 +73,7 @@ "ultrahtml": "^1.2.0", "unctx": "^2.1.1", "unenv": "^1.0.1", + "unhead": "^1.0.18", "unimport": "^1.3.0", "unplugin": "^1.0.1", "untyped": "^1.2.2", diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index ec752c7d7a..477f5b10cd 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -21,6 +21,7 @@ import { TreeShakePlugin } from './plugins/tree-shake' import { DevOnlyPlugin } from './plugins/dev-only' import { addModuleTranspiles } from './modules' import { initNitro } from './nitro' +import schemaModule from './schema' export function createNuxt (options: NuxtOptions): Nuxt { const hooks = createHooks() @@ -231,6 +232,7 @@ export async function loadNuxt (opts: LoadNuxtOptions): Promise { .map(i => new RegExp(`(^|\\/)${escapeRE(i.cwd!.split('node_modules/').pop()!)}(\\/|$)(?!node_modules\\/)`)) } }]) + options._modules.push(schemaModule) options.modulesDir.push(resolve(options.workspaceDir, 'node_modules')) options.modulesDir.push(resolve(pkgDir, 'node_modules')) options.build.transpile.push('@nuxt/ui-templates') diff --git a/packages/nuxt/src/core/schema.ts b/packages/nuxt/src/core/schema.ts new file mode 100644 index 0000000000..3540f1b0d5 --- /dev/null +++ b/packages/nuxt/src/core/schema.ts @@ -0,0 +1,168 @@ +import { existsSync } from 'node:fs' +import { writeFile, mkdir, rm } from 'node:fs/promises' +import { dirname, resolve } from 'pathe' +import chokidar from 'chokidar' +import { defu } from 'defu' +import { debounce } from 'perfect-debounce' +import { defineNuxtModule, createResolver } from '@nuxt/kit' +import { + resolveSchema as resolveUntypedSchema, + generateMarkdown, + generateTypes +} from 'untyped' +import type { Schema, SchemaDefinition } from 'untyped' +// @ts-ignore +import untypedPlugin from 'untyped/babel-plugin' +import jiti from 'jiti' + +export default defineNuxtModule({ + meta: { + name: 'nuxt-config-schema' + }, + async setup (_, nuxt) { + if (!nuxt.options.experimental.configSchema) { + return + } + const resolver = createResolver(import.meta.url) + + // Initialize untyped/jiti loader + const _resolveSchema = jiti(dirname(import.meta.url), { + esmResolve: true, + interopDefault: true, + cache: false, + requireCache: false, + transformOptions: { + babel: { + plugins: [untypedPlugin] + } + } + }) + + // Register module types + nuxt.hook('prepare:types', (ctx) => { + ctx.references.push({ path: 'nuxt-config-schema' }) + ctx.references.push({ path: 'schema/nuxt.schema.d.ts' }) + }) + + // Resolve schema after all modules initialized + let schema: Schema + nuxt.hook('modules:done', async () => { + schema = await resolveSchema() + }) + + // Writie schema after build to allow further modifications + nuxt.hooks.hook('build:done', async () => { + await nuxt.hooks.callHook('schema:beforeWrite', schema) + await writeSchema(schema) + await nuxt.hooks.callHook('schema:written') + }) + + // Watch for schema changes in development mode + if (nuxt.options.dev) { + const filesToWatch = await Promise.all(nuxt.options._layers.map(layer => + resolver.resolve(layer.config.rootDir, 'nuxt.schema.*') + )) + const watcher = chokidar.watch(filesToWatch, { + ...nuxt.options.watchers.chokidar, + ignoreInitial: true + }) + const onChange = debounce(async () => { + schema = await resolveSchema() + await nuxt.hooks.callHook('schema:beforeWrite', schema) + await writeSchema(schema) + await nuxt.hooks.callHook('schema:written') + }) + watcher.on('all', onChange) + nuxt.hook('close', () => watcher.close()) + } + + // --- utils --- + + async function resolveSchema () { + // Global import + // @ts-ignore + globalThis.defineNuxtSchema = (val: any) => val + + // Load schema from layers + const schemaDefs: SchemaDefinition[] = [nuxt.options.$schema] + for (const layer of nuxt.options._layers) { + const filePath = await resolver.resolvePath(resolve(layer.config.rootDir, 'nuxt.schema')) + if (filePath && existsSync(filePath)) { + let loadedConfig: SchemaDefinition + try { + loadedConfig = _resolveSchema(filePath) + } catch (err) { + console.warn( + '[nuxt-config-schema] Unable to load schema from', + filePath, + err + ) + continue + } + schemaDefs.push(loadedConfig) + } + } + + // Allow hooking to extend custom schemas + await nuxt.hooks.callHook('schema:extend', schemaDefs) + + // Resolve and merge schemas + const schemas = await Promise.all( + schemaDefs.map(schemaDef => resolveUntypedSchema(schemaDef)) + ) + + // @ts-expect-error + // Merge after normalazation + const schema = defu(...schemas) + + // Allow hooking to extend resolved schema + await nuxt.hooks.callHook('schema:resolved', schema) + + return schema + } + + async function writeSchema (schema: Schema) { + // Avoid writing empty schema + const isEmptySchema = !schema.properties || Object.keys(schema.properties).length === 0 + if (isEmptySchema) { + await rm(resolve(nuxt.options.buildDir, 'schema'), { recursive: true }).catch(() => { }) + return + } + + // Write it to build dir + await mkdir(resolve(nuxt.options.buildDir, 'schema'), { recursive: true }) + await writeFile( + resolve(nuxt.options.buildDir, 'schema/nuxt.schema.json'), + JSON.stringify(schema, null, 2), + 'utf8' + ) + const markdown = '# Nuxt Custom Config Schema' + generateMarkdown(schema) + await writeFile( + resolve(nuxt.options.buildDir, 'schema/nuxt.schema.md'), + markdown, + 'utf8' + ) + const _types = generateTypes(schema, { + addExport: true, + interfaceName: 'NuxtCustomSchema', + partial: true + }) + const types = + _types + + ` +export type CustomAppConfig = Exclude + +declare module '@nuxt/schema' { + interface NuxtConfig extends NuxtCustomSchema {} + interface NuxtOptions extends NuxtCustomSchema {} + interface AppConfigInput extends CustomAppConfig {} + interface AppConfig extends CustomAppConfig {} +}` + const typesPath = resolve( + nuxt.options.buildDir, + 'schema/nuxt.schema.d.ts' + ) + await writeFile(typesPath, types, 'utf8') + } + } +}) diff --git a/packages/nuxt/types.d.ts b/packages/nuxt/types.d.ts index 23f8a32114..a6b548793e 100644 --- a/packages/nuxt/types.d.ts +++ b/packages/nuxt/types.d.ts @@ -1,8 +1,11 @@ /// export * from './dist/index' +import type { Schema, SchemaDefinition } from '@nuxt/schema' + declare global { const defineNuxtConfig: typeof import('nuxt/config')['defineNuxtConfig'] + const defineNuxtSchema: (schema: SchemaDefinition) => SchemaDefinition } declare module 'nitropack' { diff --git a/packages/schema/src/config/common.ts b/packages/schema/src/config/common.ts index 636de688a8..f53d99281d 100644 --- a/packages/schema/src/config/common.ts +++ b/packages/schema/src/config/common.ts @@ -442,4 +442,6 @@ export default defineUntypedSchema({ * @type {typeof import('../src/types/config').AppConfig} */ appConfig: {}, + + $schema: {} }) diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index 7e113da2c9..69156f1be1 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -100,6 +100,13 @@ export default defineUntypedSchema({ /** * Experimental component islands support with and .island.vue files. */ - componentIslands: false + componentIslands: false, + + /** + * Enable experimental config schema support + * + * @see https://github.com/nuxt/nuxt/issues/15592 + */ + configSchema: false } }) diff --git a/packages/schema/src/types/config.ts b/packages/schema/src/types/config.ts index e7ce4d1b28..8571fbaf43 100644 --- a/packages/schema/src/types/config.ts +++ b/packages/schema/src/types/config.ts @@ -4,6 +4,8 @@ import type { ServerOptions as ViteServerOptions, UserConfig as ViteUserConfig } import type { Options as VuePluginOptions } from '@vitejs/plugin-vue' import type { AppHeadMetaObject } from './meta' import type { Nuxt } from './nuxt' +import type { SchemaDefinition } from 'untyped' +export type { SchemaDefinition } from 'untyped' type DeepPartial = T extends Function ? T : T extends Record ? { [P in keyof T]?: DeepPartial } : T @@ -11,6 +13,13 @@ type DeepPartial = T extends Function ? T : T extends Record ? { export interface NuxtConfig extends DeepPartial> { // Avoid DeepPartial for vite config interface (#4772) vite?: ConfigSchema['vite'] + + /** + * Experimental custom config schema + * + * @see https://github.com/nuxt/nuxt/issues/15592 + */ + $schema?: SchemaDefinition } // TODO: Expose ConfigLayer from c12 @@ -29,6 +38,7 @@ export interface NuxtOptions extends Omit { sourcemap: Required> builder: '@nuxt/vite-builder' | '@nuxt/webpack-builder' | { bundle: (nuxt: Nuxt) => Promise } _layers: NuxtConfigLayer[] + $schema: SchemaDefinition } export interface ViteConfig extends ViteUserConfig { diff --git a/packages/schema/src/types/hooks.ts b/packages/schema/src/types/hooks.ts index d05719ce26..f9ab2d6519 100644 --- a/packages/schema/src/types/hooks.ts +++ b/packages/schema/src/types/hooks.ts @@ -10,6 +10,7 @@ import type { Nuxt, NuxtApp, ResolvedNuxtTemplate } from './nuxt' import type { Nitro, NitroConfig } from 'nitropack' import type { Component, ComponentsOptions } from './components' import type { NuxtCompatibility, NuxtCompatibilityIssues } from '..' +import type { Schema, SchemaDefinition } from 'untyped' export type HookResult = Promise | void @@ -92,6 +93,12 @@ export interface NuxtHooks { 'prepare:types': (options: { references: TSReference[], declarations: string[], tsConfig: TSConfig }) => HookResult 'listen': (listenerServer: HttpServer | HttpsServer, listener: any) => HookResult + // Schema + 'schema:extend': (schemas: SchemaDefinition[]) => void + 'schema:resolved': (schema: Schema) => void + 'schema:beforeWrite': (schema: Schema) => void + 'schema:written': () => void + // Vite 'vite:extend': (viteBuildContext: { nuxt: Nuxt, config: ViteInlineConfig }) => HookResult 'vite:extendConfig': (viteInlineConfig: ViteInlineConfig, env: { isClient: boolean, isServer: boolean }) => HookResult diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a2d79b42b..ca74c95e6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -432,6 +432,7 @@ importers: h3: ^1.0.2 hash-sum: ^2.0.0 hookable: ^5.4.2 + jiti: ^1.16.2 knitwork: ^1.0.0 magic-string: ^0.27.0 mlly: ^1.1.0 @@ -478,6 +479,7 @@ importers: h3: 1.0.2 hash-sum: 2.0.0 hookable: 5.4.2 + jiti: 1.16.2 knitwork: 1.0.0 magic-string: 0.27.0 mlly: 1.1.0 diff --git a/test/fixtures/basic/nuxt.config.ts b/test/fixtures/basic/nuxt.config.ts index 4545b827d7..10fe1c1c3b 100644 --- a/test/fixtures/basic/nuxt.config.ts +++ b/test/fixtures/basic/nuxt.config.ts @@ -146,7 +146,8 @@ export default defineNuxtConfig({ componentIslands: true, reactivityTransform: true, treeshakeClientOnly: true, - payloadExtraction: true + payloadExtraction: true, + configSchema: true }, appConfig: { fromNuxtConfig: true, diff --git a/test/fixtures/basic/nuxt.schema.ts b/test/fixtures/basic/nuxt.schema.ts new file mode 100644 index 0000000000..42ac4eea86 --- /dev/null +++ b/test/fixtures/basic/nuxt.schema.ts @@ -0,0 +1,6 @@ +export default defineNuxtSchema({ + appConfig: { + /** This is an example app config defined in custom schema */ + userConfig: 123 + } +})