feat: experimental config schema (#18410)

This commit is contained in:
pooya parsa 2023-01-23 19:07:21 +01:00 committed by GitHub
parent 3c715ac729
commit 1af319e0fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 224 additions and 5 deletions

View File

@ -4,6 +4,7 @@ export * from './module/install'
// Loader // Loader
export * from './loader/config' export * from './loader/config'
export * from './loader/schema'
export * from './loader/nuxt' export * from './loader/nuxt'
// Utils // Utils

View File

@ -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)
})
}

View File

@ -42,11 +42,10 @@
"@nuxt/telemetry": "^2.1.9", "@nuxt/telemetry": "^2.1.9",
"@nuxt/ui-templates": "^1.1.0", "@nuxt/ui-templates": "^1.1.0",
"@nuxt/vite-builder": "3.0.0", "@nuxt/vite-builder": "3.0.0",
"@unhead/ssr": "^1.0.18",
"@vue/reactivity": "^3.2.45", "@vue/reactivity": "^3.2.45",
"@vue/shared": "^3.2.45", "@vue/shared": "^3.2.45",
"@vueuse/head": "^1.0.23", "@vueuse/head": "^1.0.23",
"unhead": "^1.0.18",
"@unhead/ssr": "^1.0.18",
"chokidar": "^3.5.3", "chokidar": "^3.5.3",
"cookie-es": "^0.5.0", "cookie-es": "^0.5.0",
"defu": "^6.1.1", "defu": "^6.1.1",
@ -58,13 +57,14 @@
"h3": "^1.0.2", "h3": "^1.0.2",
"hash-sum": "^2.0.0", "hash-sum": "^2.0.0",
"hookable": "^5.4.2", "hookable": "^5.4.2",
"jiti": "^1.16.2",
"knitwork": "^1.0.0", "knitwork": "^1.0.0",
"magic-string": "^0.27.0", "magic-string": "^0.27.0",
"mlly": "^1.1.0", "mlly": "^1.1.0",
"nitropack": "^2.0.0-rc.0", "nitropack": "^2.0.0-rc.0",
"nuxi": "3.0.0", "nuxi": "3.0.0",
"ohash": "^1.0.0",
"ofetch": "^1.0.0", "ofetch": "^1.0.0",
"ohash": "^1.0.0",
"pathe": "^1.0.0", "pathe": "^1.0.0",
"perfect-debounce": "^0.1.3", "perfect-debounce": "^0.1.3",
"scule": "^1.0.0", "scule": "^1.0.0",
@ -73,6 +73,7 @@
"ultrahtml": "^1.2.0", "ultrahtml": "^1.2.0",
"unctx": "^2.1.1", "unctx": "^2.1.1",
"unenv": "^1.0.1", "unenv": "^1.0.1",
"unhead": "^1.0.18",
"unimport": "^1.3.0", "unimport": "^1.3.0",
"unplugin": "^1.0.1", "unplugin": "^1.0.1",
"untyped": "^1.2.2", "untyped": "^1.2.2",

View File

@ -21,6 +21,7 @@ import { TreeShakePlugin } from './plugins/tree-shake'
import { DevOnlyPlugin } from './plugins/dev-only' import { DevOnlyPlugin } from './plugins/dev-only'
import { addModuleTranspiles } from './modules' import { addModuleTranspiles } from './modules'
import { initNitro } from './nitro' import { initNitro } from './nitro'
import schemaModule from './schema'
export function createNuxt (options: NuxtOptions): Nuxt { export function createNuxt (options: NuxtOptions): Nuxt {
const hooks = createHooks<NuxtHooks>() const hooks = createHooks<NuxtHooks>()
@ -231,6 +232,7 @@ export async function loadNuxt (opts: LoadNuxtOptions): Promise<Nuxt> {
.map(i => new RegExp(`(^|\\/)${escapeRE(i.cwd!.split('node_modules/').pop()!)}(\\/|$)(?!node_modules\\/)`)) .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(options.workspaceDir, 'node_modules'))
options.modulesDir.push(resolve(pkgDir, 'node_modules')) options.modulesDir.push(resolve(pkgDir, 'node_modules'))
options.build.transpile.push('@nuxt/ui-templates') options.build.transpile.push('@nuxt/ui-templates')

View File

@ -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<NuxtCustomSchema['appConfig'], undefined>
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')
}
}
})

View File

@ -1,8 +1,11 @@
/// <reference types="nitropack" /> /// <reference types="nitropack" />
export * from './dist/index' export * from './dist/index'
import type { Schema, SchemaDefinition } from '@nuxt/schema'
declare global { declare global {
const defineNuxtConfig: typeof import('nuxt/config')['defineNuxtConfig'] const defineNuxtConfig: typeof import('nuxt/config')['defineNuxtConfig']
const defineNuxtSchema: (schema: SchemaDefinition) => SchemaDefinition
} }
declare module 'nitropack' { declare module 'nitropack' {

View File

@ -442,4 +442,6 @@ export default defineUntypedSchema({
* @type {typeof import('../src/types/config').AppConfig} * @type {typeof import('../src/types/config').AppConfig}
*/ */
appConfig: {}, appConfig: {},
$schema: {}
}) })

View File

@ -100,6 +100,13 @@ export default defineUntypedSchema({
/** /**
* Experimental component islands support with <NuxtIsland> and .island.vue files. * Experimental component islands support with <NuxtIsland> and .island.vue files.
*/ */
componentIslands: false componentIslands: false,
/**
* Enable experimental config schema support
*
* @see https://github.com/nuxt/nuxt/issues/15592
*/
configSchema: false
} }
}) })

View File

@ -4,6 +4,8 @@ import type { ServerOptions as ViteServerOptions, UserConfig as ViteUserConfig }
import type { Options as VuePluginOptions } from '@vitejs/plugin-vue' import type { Options as VuePluginOptions } from '@vitejs/plugin-vue'
import type { AppHeadMetaObject } from './meta' import type { AppHeadMetaObject } from './meta'
import type { Nuxt } from './nuxt' import type { Nuxt } from './nuxt'
import type { SchemaDefinition } from 'untyped'
export type { SchemaDefinition } from 'untyped'
type DeepPartial<T> = T extends Function ? T : T extends Record<string, any> ? { [P in keyof T]?: DeepPartial<T[P]> } : T type DeepPartial<T> = T extends Function ? T : T extends Record<string, any> ? { [P in keyof T]?: DeepPartial<T[P]> } : T
@ -11,6 +13,13 @@ type DeepPartial<T> = T extends Function ? T : T extends Record<string, any> ? {
export interface NuxtConfig extends DeepPartial<Omit<ConfigSchema, 'vite'>> { export interface NuxtConfig extends DeepPartial<Omit<ConfigSchema, 'vite'>> {
// Avoid DeepPartial for vite config interface (#4772) // Avoid DeepPartial for vite config interface (#4772)
vite?: ConfigSchema['vite'] vite?: ConfigSchema['vite']
/**
* Experimental custom config schema
*
* @see https://github.com/nuxt/nuxt/issues/15592
*/
$schema?: SchemaDefinition
} }
// TODO: Expose ConfigLayer<T> from c12 // TODO: Expose ConfigLayer<T> from c12
@ -29,6 +38,7 @@ export interface NuxtOptions extends Omit<ConfigSchema, 'builder'> {
sourcemap: Required<Exclude<ConfigSchema['sourcemap'], boolean>> sourcemap: Required<Exclude<ConfigSchema['sourcemap'], boolean>>
builder: '@nuxt/vite-builder' | '@nuxt/webpack-builder' | { bundle: (nuxt: Nuxt) => Promise<void> } builder: '@nuxt/vite-builder' | '@nuxt/webpack-builder' | { bundle: (nuxt: Nuxt) => Promise<void> }
_layers: NuxtConfigLayer[] _layers: NuxtConfigLayer[]
$schema: SchemaDefinition
} }
export interface ViteConfig extends ViteUserConfig { export interface ViteConfig extends ViteUserConfig {

View File

@ -10,6 +10,7 @@ import type { Nuxt, NuxtApp, ResolvedNuxtTemplate } from './nuxt'
import type { Nitro, NitroConfig } from 'nitropack' import type { Nitro, NitroConfig } from 'nitropack'
import type { Component, ComponentsOptions } from './components' import type { Component, ComponentsOptions } from './components'
import type { NuxtCompatibility, NuxtCompatibilityIssues } from '..' import type { NuxtCompatibility, NuxtCompatibilityIssues } from '..'
import type { Schema, SchemaDefinition } from 'untyped'
export type HookResult = Promise<void> | void export type HookResult = Promise<void> | void
@ -92,6 +93,12 @@ export interface NuxtHooks {
'prepare:types': (options: { references: TSReference[], declarations: string[], tsConfig: TSConfig }) => HookResult 'prepare:types': (options: { references: TSReference[], declarations: string[], tsConfig: TSConfig }) => HookResult
'listen': (listenerServer: HttpServer | HttpsServer, listener: any) => 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
'vite:extend': (viteBuildContext: { nuxt: Nuxt, config: ViteInlineConfig }) => HookResult 'vite:extend': (viteBuildContext: { nuxt: Nuxt, config: ViteInlineConfig }) => HookResult
'vite:extendConfig': (viteInlineConfig: ViteInlineConfig, env: { isClient: boolean, isServer: boolean }) => HookResult 'vite:extendConfig': (viteInlineConfig: ViteInlineConfig, env: { isClient: boolean, isServer: boolean }) => HookResult

View File

@ -432,6 +432,7 @@ importers:
h3: ^1.0.2 h3: ^1.0.2
hash-sum: ^2.0.0 hash-sum: ^2.0.0
hookable: ^5.4.2 hookable: ^5.4.2
jiti: ^1.16.2
knitwork: ^1.0.0 knitwork: ^1.0.0
magic-string: ^0.27.0 magic-string: ^0.27.0
mlly: ^1.1.0 mlly: ^1.1.0
@ -478,6 +479,7 @@ importers:
h3: 1.0.2 h3: 1.0.2
hash-sum: 2.0.0 hash-sum: 2.0.0
hookable: 5.4.2 hookable: 5.4.2
jiti: 1.16.2
knitwork: 1.0.0 knitwork: 1.0.0
magic-string: 0.27.0 magic-string: 0.27.0
mlly: 1.1.0 mlly: 1.1.0

View File

@ -146,7 +146,8 @@ export default defineNuxtConfig({
componentIslands: true, componentIslands: true,
reactivityTransform: true, reactivityTransform: true,
treeshakeClientOnly: true, treeshakeClientOnly: true,
payloadExtraction: true payloadExtraction: true,
configSchema: true
}, },
appConfig: { appConfig: {
fromNuxtConfig: true, fromNuxtConfig: true,

6
test/fixtures/basic/nuxt.schema.ts vendored Normal file
View File

@ -0,0 +1,6 @@
export default defineNuxtSchema({
appConfig: {
/** This is an example app config defined in custom schema */
userConfig: 123
}
})