From 6a4b7232fca8155308fa1d4ada718616a680951b Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 10 Feb 2025 16:28:05 +0000 Subject: [PATCH] feat(schema): add runtime + internal type validation (#30844) --- packages/nuxt/src/core/templates.ts | 19 +- packages/schema/package.json | 6 + packages/schema/src/config/adhoc.ts | 13 +- packages/schema/src/config/app.ts | 126 ++++++++++---- packages/schema/src/config/build.ts | 67 +++++--- packages/schema/src/config/common.ts | 191 +++++++++++++-------- packages/schema/src/config/dev.ts | 11 +- packages/schema/src/config/experimental.ts | 78 ++++++--- packages/schema/src/config/generate.ts | 4 +- packages/schema/src/config/index.ts | 4 +- packages/schema/src/config/internal.ts | 6 +- packages/schema/src/config/nitro.ts | 19 +- packages/schema/src/config/postcss.ts | 14 +- packages/schema/src/config/router.ts | 4 +- packages/schema/src/config/typescript.ts | 16 +- packages/schema/src/config/vite.ts | 60 ++++--- packages/schema/src/config/webpack.ts | 65 ++++--- packages/schema/src/types/module.ts | 6 + packages/schema/src/utils/definition.ts | 56 ++++++ pnpm-lock.yaml | 25 +++ 20 files changed, 550 insertions(+), 240 deletions(-) create mode 100644 packages/schema/src/utils/definition.ts diff --git a/packages/nuxt/src/core/templates.ts b/packages/nuxt/src/core/templates.ts index a99237fc0b..a42e141637 100644 --- a/packages/nuxt/src/core/templates.ts +++ b/packages/nuxt/src/core/templates.ts @@ -7,7 +7,7 @@ import escapeRE from 'escape-string-regexp' import { hash } from 'ohash' import { camelCase } from 'scule' import { filename } from 'pathe/utils' -import type { NuxtTemplate, NuxtTypeTemplate } from 'nuxt/schema' +import type { NuxtOptions, NuxtTemplate, NuxtTypeTemplate } from 'nuxt/schema' import type { Nitro } from 'nitropack' import { annotatePlugins, checkForCircularDependencies } from './app' @@ -199,9 +199,18 @@ export const schemaTemplate: NuxtTemplate = { const relativeRoot = relative(resolve(nuxt.options.buildDir, 'types'), nuxt.options.rootDir) const getImportName = (name: string) => (name[0] === '.' ? './' + join(relativeRoot, name) : name).replace(IMPORT_NAME_RE, '') - const modules = nuxt.options._installedModules - .filter(m => m.meta && m.meta.configKey && m.meta.name && !m.meta.name.startsWith('nuxt:') && m.meta.name !== 'nuxt-config-schema') - .map(m => [genString(m.meta.configKey), getImportName(m.entryPath || m.meta.name), m] as const) + const modules: [string, string, NuxtOptions['_installedModules'][number]][] = [] + for (const m of nuxt.options._installedModules) { + // modules without sufficient metadata + if (!m.meta || !m.meta.configKey || !m.meta.name) { + continue + } + // core nuxt modules + if (m.meta.name.startsWith('nuxt:') || m.meta.name === 'nuxt-config-schema') { + continue + } + modules.push([genString(m.meta.configKey), getImportName(m.entryPath || m.meta.name), m]) + } const privateRuntimeConfig = Object.create(null) for (const key in nuxt.options.runtimeConfig) { @@ -224,7 +233,7 @@ export const schemaTemplate: NuxtTemplate = { } else if (mod.meta?.repository) { if (typeof mod.meta.repository === 'string') { link = mod.meta.repository - } else if (typeof mod.meta.repository.url === 'string') { + } else if (typeof mod.meta.repository === 'object' && 'url' in mod.meta.repository && typeof mod.meta.repository.url === 'string') { link = mod.meta.repository.url } if (link) { diff --git a/packages/schema/package.json b/packages/schema/package.json index 3e840f8ac9..878ab58025 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -37,6 +37,9 @@ }, "devDependencies": { "@types/pug": "2.0.10", + "@types/rollup-plugin-visualizer": "4.2.4", + "@types/webpack-bundle-analyzer": "4.7.0", + "@types/webpack-hot-middleware": "2.25.9", "@unhead/schema": "1.11.18", "@vitejs/plugin-vue": "5.2.1", "@vitejs/plugin-vue-jsx": "4.1.1", @@ -46,14 +49,17 @@ "c12": "2.0.1", "chokidar": "4.0.3", "compatx": "0.1.8", + "css-minimizer-webpack-plugin": "7.0.0", "esbuild-loader": "4.2.2", "file-loader": "6.2.0", "h3": "1.15.0", "hookable": "5.5.3", "ignore": "7.0.3", + "mini-css-extract-plugin": "2.9.2", "nitropack": "2.10.4", "ofetch": "1.4.1", "pkg-types": "1.3.1", + "postcss": "8.5.1", "sass-loader": "16.0.4", "scule": "1.3.0", "unbuild": "3.3.1", diff --git a/packages/schema/src/config/adhoc.ts b/packages/schema/src/config/adhoc.ts index 1d9f6a9d40..26942cc85d 100644 --- a/packages/schema/src/config/adhoc.ts +++ b/packages/schema/src/config/adhoc.ts @@ -1,6 +1,6 @@ -import { defineUntypedSchema } from 'untyped' +import { defineResolvers } from '../utils/definition' -export default defineUntypedSchema({ +export default defineResolvers({ /** * Configure Nuxt component auto-registration. * @@ -14,10 +14,13 @@ export default defineUntypedSchema({ if (Array.isArray(val)) { return { dirs: val } } - if (val === undefined || val === true) { - return { dirs: [{ path: '~/components/global', global: true }, '~/components'] } + if (val === false) { + return { dirs: [] } + } + return { + dirs: [{ path: '~/components/global', global: true }, '~/components'], + ...typeof val === 'object' ? val : {}, } - return val }, }, diff --git a/packages/schema/src/config/app.ts b/packages/schema/src/config/app.ts index b759a2809d..4bfc22184f 100644 --- a/packages/schema/src/config/app.ts +++ b/packages/schema/src/config/app.ts @@ -1,9 +1,10 @@ -import { defineUntypedSchema } from 'untyped' import { defu } from 'defu' import { resolve } from 'pathe' +import { defineResolvers } from '../utils/definition' import type { AppHeadMetaObject } from '../types/head' +import type { NuxtAppConfig } from '../types/config' -export default defineUntypedSchema({ +export default defineResolvers({ /** * Vue.js config */ @@ -27,7 +28,18 @@ export default defineUntypedSchema({ * Include Vue compiler in runtime bundle. */ runtimeCompiler: { - $resolve: async (val, get) => val ?? await get('experimental.runtimeVueCompiler') ?? false, + $resolve: async (val, get) => { + if (typeof val === 'boolean') { + return val + } + // @ts-expect-error TODO: formally deprecate in v4 + const legacyProperty = await get('experimental.runtimeVueCompiler') as unknown + if (typeof legacyProperty === 'boolean') { + return legacyProperty + } + + return false + }, }, /** @@ -41,7 +53,7 @@ export default defineUntypedSchema({ * may be set in your `nuxt.config`. All other options should be set at runtime in a Nuxt plugin.. * @see [Vue app config documentation](https://vuejs.org/api/application.html#app-config) */ - config: undefined, + config: {}, }, /** @@ -68,12 +80,22 @@ export default defineUntypedSchema({ * ``` */ baseURL: { - $resolve: val => val || process.env.NUXT_APP_BASE_URL || '/', + $resolve: (val) => { + if (typeof val === 'string') { + return val + } + return process.env.NUXT_APP_BASE_URL || '/' + }, }, /** The folder name for the built site assets, relative to `baseURL` (or `cdnURL` if set). This is set at build time and should not be customized at runtime. */ buildAssetsDir: { - $resolve: val => val || process.env.NUXT_APP_BUILD_ASSETS_DIR || '/_nuxt/', + $resolve: (val) => { + if (typeof val === 'string') { + return val + } + return process.env.NUXT_APP_BUILD_ASSETS_DIR || '/_nuxt/' + }, }, /** @@ -96,7 +118,12 @@ export default defineUntypedSchema({ * ``` */ cdnURL: { - $resolve: async (val, get) => (await get('dev')) ? '' : (process.env.NUXT_APP_CDN_URL ?? val) || '', + $resolve: async (val, get) => { + if (await get('dev')) { + return '' + } + return process.env.NUXT_APP_CDN_URL || (typeof val === 'string' ? val : '') + }, }, /** @@ -132,14 +159,20 @@ export default defineUntypedSchema({ * @type {typeof import('../src/types/config').NuxtAppConfig['head']} */ head: { - $resolve: async (val: Partial | undefined, get) => { - const resolved = defu(val, await get('meta') as Partial, { + $resolve: async (_val, get) => { + // @ts-expect-error TODO: remove in Nuxt v4 + const legacyMetaValues = await get('meta') as Record + const val: Partial = _val && typeof _val === 'object' ? _val : {} + + type NormalizedMetaObject = Required> + + const resolved: NuxtAppConfig['head'] & NormalizedMetaObject = defu(val, legacyMetaValues, { meta: [], link: [], style: [], script: [], noscript: [], - } as Required>) + } satisfies NormalizedMetaObject) // provides default charset and viewport if not set if (!resolved.meta.find(m => m.charset)?.charset) { @@ -190,9 +223,13 @@ export default defineUntypedSchema({ * @type {typeof import('../src/types/config').NuxtAppConfig['viewTransition']} */ viewTransition: { - $resolve: async (val, get) => val ?? await (get('experimental') as Promise>).then( - e => e?.viewTransition, - ) ?? false, + $resolve: async (val, get) => { + if (val === 'always' || typeof val === 'boolean') { + return val + } + + return await get('experimental').then(e => e.viewTransition) ?? false + }, }, /** @@ -211,14 +248,14 @@ export default defineUntypedSchema({ * @deprecated Prefer `rootAttrs.id` instead */ rootId: { - $resolve: val => val === false ? false : (val || '__nuxt'), + $resolve: val => val === false ? false : (val && typeof val === 'string' ? val : '__nuxt'), }, /** * Customize Nuxt root element tag. */ rootTag: { - $resolve: val => val || 'div', + $resolve: val => val && typeof val === 'string' ? val : 'div', }, /** @@ -226,11 +263,12 @@ export default defineUntypedSchema({ * @type {typeof import('@unhead/schema').HtmlAttributes} */ rootAttrs: { - $resolve: async (val: undefined | null | Record, get) => { + $resolve: async (val, get) => { const rootId = await get('app.rootId') - return defu(val, { + return { id: rootId === false ? undefined : (rootId || '__nuxt'), - }) + ...typeof val === 'object' ? val : {}, + } }, }, @@ -238,7 +276,7 @@ export default defineUntypedSchema({ * Customize Nuxt Teleport element tag. */ teleportTag: { - $resolve: val => val || 'div', + $resolve: val => val && typeof val === 'string' ? val : 'div', }, /** @@ -247,7 +285,7 @@ export default defineUntypedSchema({ * @deprecated Prefer `teleportAttrs.id` instead */ teleportId: { - $resolve: val => val === false ? false : (val || 'teleports'), + $resolve: val => val === false ? false : (val && typeof val === 'string' ? val : 'teleports'), }, /** @@ -255,11 +293,12 @@ export default defineUntypedSchema({ * @type {typeof import('@unhead/schema').HtmlAttributes} */ teleportAttrs: { - $resolve: async (val: undefined | null | Record, get) => { + $resolve: async (val, get) => { const teleportId = await get('app.teleportId') - return defu(val, { + return { id: teleportId === false ? undefined : (teleportId || 'teleports'), - }) + ...typeof val === 'object' ? val : {}, + } }, }, @@ -267,12 +306,12 @@ export default defineUntypedSchema({ * Customize Nuxt SpaLoader element tag. */ spaLoaderTag: { - $resolve: val => val || 'div', + $resolve: val => val && typeof val === 'string' ? val : 'div', }, /** * Customize Nuxt Nuxt SpaLoader element attributes. - * @type {typeof import('@unhead/schema').HtmlAttributes} + * @type {Partial} */ spaLoaderAttrs: { id: '__nuxt-loader', @@ -332,10 +371,18 @@ export default defineUntypedSchema({ * } * * ``` - * @type {string | boolean} + * @type {string | boolean | undefined} */ spaLoadingTemplate: { - $resolve: async (val: string | boolean | undefined, get) => typeof val === 'string' ? resolve(await get('srcDir') as string, val) : val ?? null, + $resolve: async (val, get) => { + if (typeof val === 'string') { + return resolve(await get('srcDir'), val) + } + if (typeof val === 'boolean') { + return val + } + return undefined + }, }, /** @@ -386,7 +433,21 @@ export default defineUntypedSchema({ * @type {string[]} */ css: { - $resolve: (val: string[] | undefined) => (val ?? []).map((c: any) => c.src || c), + $resolve: (val) => { + if (!Array.isArray(val)) { + return [] + } + const css: string[] = [] + for (const item of val) { + if (typeof item === 'string') { + css.push(item) + } else if (item && 'src' in item) { + // TODO: remove in Nuxt v4 + css.push(item.src) + } + } + return css + }, }, /** @@ -410,12 +471,13 @@ export default defineUntypedSchema({ * @type {typeof import('@unhead/schema').RenderSSRHeadOptions} */ renderSSRHeadOptions: { - $resolve: async (val: Record | undefined, get) => { - const isV4 = ((await get('future') as Record).compatibilityVersion === 4) + $resolve: async (val, get) => { + const isV4 = (await get('future')).compatibilityVersion === 4 - return defu(val, { + return { + ...typeof val === 'object' ? val : {}, omitLineBreaks: isV4, - }) + } }, }, }, diff --git a/packages/schema/src/config/build.ts b/packages/schema/src/config/build.ts index d5e43804c9..af7a1e1911 100644 --- a/packages/schema/src/config/build.ts +++ b/packages/schema/src/config/build.ts @@ -1,25 +1,35 @@ -import { defineUntypedSchema } from 'untyped' import { defu } from 'defu' import { join } from 'pathe' import { isTest } from 'std-env' import { consola } from 'consola' +import type { Nuxt } from 'nuxt/schema' +import { defineResolvers } from '../utils/definition' -export default defineUntypedSchema({ +export default defineResolvers({ /** * The builder to use for bundling the Vue part of your application. * @type {'vite' | 'webpack' | 'rspack' | { bundle: (nuxt: typeof import('../src/types/nuxt').Nuxt) => Promise }} */ builder: { - $resolve: async (val: 'vite' | 'webpack' | 'rspack' | { bundle: (nuxt: unknown) => Promise } | undefined = 'vite', get) => { - if (typeof val === 'object') { - return val + $resolve: async (val, get) => { + if (val && typeof val === 'object' && 'bundle' in val) { + return val as { bundle: (nuxt: Nuxt) => Promise } } - const map: Record = { + const map = { rspack: '@nuxt/rspack-builder', vite: '@nuxt/vite-builder', webpack: '@nuxt/webpack-builder', } - return map[val] || val || (await get('vite') === false ? map.webpack : map.vite) + type Builder = 'vite' | 'webpack' | 'rspack' + if (typeof val === 'string' && val in map) { + // TODO: improve normalisation inference + return map[val as keyof typeof map] as Builder + } + // @ts-expect-error TODO: remove old, unsupported config in v4 + if (await get('vite') === false) { + return map.webpack as Builder + } + return map.vite as Builder }, }, @@ -37,14 +47,15 @@ export default defineUntypedSchema({ * @type {boolean | { server?: boolean | 'hidden', client?: boolean | 'hidden' }} */ sourcemap: { - $resolve: async (val: boolean | { server?: boolean | 'hidden', client?: boolean | 'hidden' } | undefined, get) => { + $resolve: async (val, get) => { if (typeof val === 'boolean') { return { server: val, client: val } } - return defu(val, { + return { server: true, client: await get('dev'), - }) + ...typeof val === 'object' ? val : {}, + } }, }, @@ -56,11 +67,11 @@ export default defineUntypedSchema({ * @type {'silent' | 'info' | 'verbose'} */ logLevel: { - $resolve: (val: string | undefined) => { - if (val && !['silent', 'info', 'verbose'].includes(val)) { + $resolve: (val) => { + if (val && typeof val === 'string' && !['silent', 'info', 'verbose'].includes(val)) { consola.warn(`Invalid \`logLevel\` option: \`${val}\`. Must be one of: \`silent\`, \`info\`, \`verbose\`.`) } - return val ?? (isTest ? 'silent' : 'info') + return val && typeof val === 'string' ? val as 'silent' | 'info' | 'verbose' : (isTest ? 'silent' : 'info') }, }, @@ -81,7 +92,20 @@ export default defineUntypedSchema({ * @type {Array string | RegExp | false)>} */ transpile: { - $resolve: (val: Array string | RegExp | false)> | undefined) => (val || []).filter(Boolean), + $resolve: (val) => { + const transpile: Array string | RegExp | false)> = [] + if (Array.isArray(val)) { + for (const pattern of val) { + if (!pattern) { + continue + } + if (typeof pattern === 'string' || typeof pattern === 'function' || pattern instanceof RegExp) { + transpile.push(pattern) + } + } + } + return transpile + }, }, /** @@ -110,16 +134,17 @@ export default defineUntypedSchema({ * analyzerMode: 'static' * } * ``` - * @type {boolean | { enabled?: boolean } & ((0 extends 1 & typeof import('webpack-bundle-analyzer').BundleAnalyzerPlugin.Options ? {} : typeof import('webpack-bundle-analyzer').BundleAnalyzerPlugin.Options) | typeof import('rollup-plugin-visualizer').PluginVisualizerOptions)} + * @type {boolean | { enabled?: boolean } & ((0 extends 1 & typeof import('webpack-bundle-analyzer').BundleAnalyzerPlugin.Options ? Record : typeof import('webpack-bundle-analyzer').BundleAnalyzerPlugin.Options) | typeof import('rollup-plugin-visualizer').PluginVisualizerOptions)} */ analyze: { - $resolve: async (val: boolean | { enabled?: boolean } | Record, get) => { - const [rootDir, analyzeDir] = await Promise.all([get('rootDir'), get('analyzeDir')]) as [string, string] - return defu(typeof val === 'boolean' ? { enabled: val } : val, { + $resolve: async (val, get) => { + const [rootDir, analyzeDir] = await Promise.all([get('rootDir'), get('analyzeDir')]) + return { template: 'treemap', projectRoot: rootDir, filename: join(analyzeDir, '{name}.html'), - }) + ...typeof val === 'boolean' ? { enabled: val } : typeof val === 'object' ? val : {}, + } }, }, }, @@ -139,7 +164,7 @@ export default defineUntypedSchema({ * @type {Array<{ name: string, source?: string | RegExp, argumentLength: number }>} */ keyedComposables: { - $resolve: (val: Array<{ name: string, argumentLength: string }> | undefined) => [ + $resolve: val => [ { name: 'callOnce', argumentLength: 3 }, { name: 'defineNuxtComponent', argumentLength: 2 }, { name: 'useState', argumentLength: 2 }, @@ -147,7 +172,7 @@ export default defineUntypedSchema({ { name: 'useAsyncData', argumentLength: 3 }, { name: 'useLazyAsyncData', argumentLength: 3 }, { name: 'useLazyFetch', argumentLength: 3 }, - ...val || [], + ...Array.isArray(val) ? val : [], ].filter(Boolean), }, diff --git a/packages/schema/src/config/common.ts b/packages/schema/src/config/common.ts index c4c5baa912..c53a1ef0fc 100644 --- a/packages/schema/src/config/common.ts +++ b/packages/schema/src/config/common.ts @@ -1,16 +1,16 @@ import { existsSync } from 'node:fs' import { readdir } from 'node:fs/promises' import { randomUUID } from 'node:crypto' -import { defineUntypedSchema } from 'untyped' import { basename, join, relative, resolve } from 'pathe' import { isDebug, isDevelopment, isTest } from 'std-env' import { defu } from 'defu' import { findWorkspaceDir } from 'pkg-types' -import type { RuntimeConfig } from '../types/config' import type { NuxtDebugOptions } from '../types/debug' +import type { NuxtModule } from '../types/module' +import { defineResolvers } from '../utils/definition' -export default defineUntypedSchema({ +export default defineResolvers({ /** * Extend project from multiple local or remote sources. * @@ -21,7 +21,7 @@ export default defineUntypedSchema({ * @see [`giget` documentation](https://github.com/unjs/giget) * @type {string | [string, typeof import('c12').SourceOptions?] | (string | [string, typeof import('c12').SourceOptions?])[]} */ - extends: null, + extends: undefined, /** * Specify a compatibility date for your app. @@ -43,7 +43,7 @@ export default defineUntypedSchema({ * You can use `github:`, `gitlab:`, `bitbucket:` or `https://` to extend from a remote git repository. * @type {string} */ - theme: null, + theme: undefined, /** * Define the root directory of your application. @@ -67,9 +67,9 @@ export default defineUntypedSchema({ * It is normally not needed to configure this option. */ workspaceDir: { - $resolve: async (val: string | undefined, get): Promise => { - const rootDir = await get('rootDir') as string - return val ? resolve(rootDir, val) : await findWorkspaceDir(rootDir).catch(() => rootDir) + $resolve: async (val, get) => { + const rootDir = await get('rootDir') + return val && typeof val === 'string' ? resolve(rootDir, val) : await findWorkspaceDir(rootDir).catch(() => rootDir) }, }, @@ -105,14 +105,14 @@ export default defineUntypedSchema({ * ``` */ srcDir: { - $resolve: async (val: string | undefined, get): Promise => { - if (val) { - return resolve(await get('rootDir') as string, val) + $resolve: async (val, get) => { + if (val && typeof val === 'string') { + return resolve(await get('rootDir'), val) } const [rootDir, isV4] = await Promise.all([ - get('rootDir') as Promise, - (get('future') as Promise>).then(r => r.compatibilityVersion === 4), + get('rootDir'), + get('future').then(r => r.compatibilityVersion === 4), ]) if (!isV4) { @@ -138,7 +138,7 @@ export default defineUntypedSchema({ } } const keys = ['assets', 'layouts', 'middleware', 'pages', 'plugins'] as const - const dirs = await Promise.all(keys.map(key => get(`dir.${key}`) as Promise)) + const dirs = await Promise.all(keys.map(key => get(`dir.${key}`))) for (const dir of dirs) { if (existsSync(resolve(rootDir, dir))) { return rootDir @@ -157,13 +157,13 @@ export default defineUntypedSchema({ * */ serverDir: { - $resolve: async (val: string | undefined, get): Promise => { - if (val) { - const rootDir = await get('rootDir') as string + $resolve: async (val, get) => { + if (val && typeof val === 'string') { + const rootDir = await get('rootDir') return resolve(rootDir, val) } - const isV4 = (await get('future') as Record).compatibilityVersion === 4 - return join(isV4 ? await get('rootDir') as string : await get('srcDir') as string, 'server') + const isV4 = (await get('future')).compatibilityVersion === 4 + return join(isV4 ? await get('rootDir') : await get('srcDir'), 'server') }, }, @@ -180,7 +180,10 @@ export default defineUntypedSchema({ * ``` */ buildDir: { - $resolve: async (val: string | undefined, get): Promise => resolve(await get('rootDir') as string, val || '.nuxt'), + $resolve: async (val, get) => { + const rootDir = await get('rootDir') + return resolve(rootDir, val && typeof val === 'string' ? val : '.nuxt') + }, }, /** @@ -189,14 +192,14 @@ export default defineUntypedSchema({ * Defaults to `nuxt-app`. */ appId: { - $resolve: (val: string) => val ?? 'nuxt-app', + $resolve: val => val && typeof val === 'string' ? val : 'nuxt-app', }, /** * A unique identifier matching the build. This may contain the hash of the current state of the project. */ buildId: { - $resolve: async (val: string | undefined, get): Promise => { + $resolve: async (val, get): Promise => { if (typeof val === 'string') { return val } const [isDev, isTest] = await Promise.all([get('dev') as Promise, get('test') as Promise]) @@ -220,12 +223,17 @@ export default defineUntypedSchema({ */ modulesDir: { $default: ['node_modules'], - $resolve: async (val: string[] | undefined, get): Promise => { - const rootDir = await get('rootDir') as string - return [...new Set([ - ...(val || []).map((dir: string) => resolve(rootDir, dir)), - resolve(rootDir, 'node_modules'), - ])] + $resolve: async (val, get) => { + const rootDir = await get('rootDir') + const modulesDir = new Set([resolve(rootDir, 'node_modules')]) + if (Array.isArray(val)) { + for (const dir of val) { + if (dir && typeof dir === 'string') { + modulesDir.add(resolve(rootDir, dir)) + } + } + } + return [...modulesDir] }, }, @@ -235,9 +243,9 @@ export default defineUntypedSchema({ * If a relative path is specified, it will be relative to your `rootDir`. */ analyzeDir: { - $resolve: async (val: string | undefined, get): Promise => val - ? resolve(await get('rootDir') as string, val) - : resolve(await get('buildDir') as string, 'analyze'), + $resolve: async (val, get) => val && typeof val === 'string' + ? resolve(await get('rootDir'), val) + : resolve(await get('buildDir'), 'analyze'), }, /** @@ -246,14 +254,14 @@ export default defineUntypedSchema({ * Normally, you should not need to set this. */ dev: { - $resolve: val => val ?? Boolean(isDevelopment), + $resolve: val => typeof val === 'boolean' ? val : Boolean(isDevelopment), }, /** * Whether your app is being unit tested. */ test: { - $resolve: val => val ?? Boolean(isTest), + $resolve: val => typeof val === 'boolean' ? val : Boolean(isTest), }, /** @@ -267,11 +275,8 @@ export default defineUntypedSchema({ * @type {boolean | (typeof import('../src/types/debug').NuxtDebugOptions) | undefined} */ debug: { - $resolve: (val: boolean | NuxtDebugOptions | undefined) => { + $resolve: (val) => { val ??= isDebug - if (val === false) { - return val - } if (val === true) { return { templates: true, @@ -286,7 +291,10 @@ export default defineUntypedSchema({ hydration: true, } satisfies Required } - return val + if (val && typeof val === 'object') { + return val + } + return false }, }, @@ -295,7 +303,7 @@ export default defineUntypedSchema({ * If set to `false` generated pages will have no content. */ ssr: { - $resolve: val => val ?? true, + $resolve: val => typeof val === 'boolean' ? val : true, }, /** @@ -324,7 +332,20 @@ export default defineUntypedSchema({ * @type {(typeof import('../src/types/module').NuxtModule | string | [typeof import('../src/types/module').NuxtModule | string, Record] | undefined | null | false)[]} */ modules: { - $resolve: (val: string[] | undefined): string[] => (val || []).filter(Boolean), + $resolve: (val) => { + const modules: Array]> = [] + if (Array.isArray(val)) { + for (const mod of val) { + if (!mod) { + continue + } + if (typeof mod === 'string' || typeof mod === 'function' || (Array.isArray(mod) && mod[0])) { + modules.push(mod) + } + } + } + return modules + }, }, /** @@ -334,13 +355,13 @@ export default defineUntypedSchema({ */ dir: { app: { - $resolve: async (val: string | undefined, get) => { - const isV4 = (await get('future') as Record).compatibilityVersion === 4 + $resolve: async (val, get) => { + const isV4 = (await get('future')).compatibilityVersion === 4 if (isV4) { - const [srcDir, rootDir] = await Promise.all([get('srcDir') as Promise, get('rootDir') as Promise]) - return resolve(await get('srcDir') as string, val || (srcDir === rootDir ? 'app' : '.')) + const [srcDir, rootDir] = await Promise.all([get('srcDir'), get('rootDir')]) + return resolve(await get('srcDir'), val && typeof val === 'string' ? val : (srcDir === rootDir ? 'app' : '.')) } - return val || 'app' + return val && typeof val === 'string' ? val : 'app' }, }, /** @@ -362,12 +383,12 @@ export default defineUntypedSchema({ * The modules directory, each file in which will be auto-registered as a Nuxt module. */ modules: { - $resolve: async (val: string | undefined, get) => { - const isV4 = (await get('future') as Record).compatibilityVersion === 4 + $resolve: async (val, get) => { + const isV4 = (await get('future')).compatibilityVersion === 4 if (isV4) { - return resolve(await get('rootDir') as string, val || 'modules') + return resolve(await get('rootDir'), val && typeof val === 'string' ? val : 'modules') } - return val || 'modules' + return val && typeof val === 'string' ? val : 'modules' }, }, @@ -391,18 +412,25 @@ export default defineUntypedSchema({ * and copied across into your `dist` folder when your app is generated. */ public: { - $resolve: async (val: string | undefined, get) => { - const isV4 = (await get('future') as Record).compatibilityVersion === 4 + $resolve: async (val, get) => { + const isV4 = (await get('future')).compatibilityVersion === 4 if (isV4) { - return resolve(await get('rootDir') as string, val || await get('dir.static') as string || 'public') + return resolve(await get('rootDir'), val && typeof val === 'string' ? val : (await get('dir.static') || 'public')) } - return val || await get('dir.static') as string || 'public' + return val && typeof val === 'string' ? val : (await get('dir.static') || 'public') }, }, + // TODO: remove in v4 static: { + // @ts-expect-error schema has invalid types $schema: { deprecated: 'use `dir.public` option instead' }, - $resolve: async (val, get) => val || await get('dir.public') || 'public', + $resolve: async (val, get) => { + if (val && typeof val === 'string') { + return val + } + return await get('dir.public') || 'public' + }, }, }, @@ -410,7 +438,17 @@ export default defineUntypedSchema({ * The extensions that should be resolved by the Nuxt resolver. */ extensions: { - $resolve: (val: string[] | undefined): string[] => ['.js', '.jsx', '.mjs', '.ts', '.tsx', '.vue', ...val || []].filter(Boolean), + $resolve: (val): string[] => { + const extensions = ['.js', '.jsx', '.mjs', '.ts', '.tsx', '.vue'] + if (Array.isArray(val)) { + for (const item of val) { + if (item && typeof item === 'string') { + extensions.push(item) + } + } + } + return extensions + }, }, /** @@ -454,8 +492,8 @@ export default defineUntypedSchema({ * @type {Record} */ alias: { - $resolve: async (val: Record, get): Promise> => { - const [srcDir, rootDir, assetsDir, publicDir, buildDir, sharedDir] = await Promise.all([get('srcDir'), get('rootDir'), get('dir.assets'), get('dir.public'), get('buildDir'), get('dir.shared')]) as [string, string, string, string, string, string] + $resolve: async (val, get) => { + const [srcDir, rootDir, assetsDir, publicDir, buildDir, sharedDir] = await Promise.all([get('srcDir'), get('rootDir'), get('dir.assets'), get('dir.public'), get('buildDir'), get('dir.shared')]) return { '~': srcDir, '@': srcDir, @@ -466,7 +504,7 @@ export default defineUntypedSchema({ [basename(publicDir)]: resolve(srcDir, publicDir), '#build': buildDir, '#internal/nuxt/paths': resolve(buildDir, 'paths.mjs'), - ...val, + ...typeof val === 'object' ? val : {}, } }, }, @@ -491,7 +529,7 @@ export default defineUntypedSchema({ * By default, the `ignorePrefix` is set to '-', ignoring any files starting with '-'. */ ignorePrefix: { - $resolve: val => val ?? '-', + $resolve: val => val && typeof val === 'string' ? val : '-', }, /** @@ -499,18 +537,27 @@ export default defineUntypedSchema({ * inside the `ignore` array will be ignored in building. */ ignore: { - $resolve: async (val: string[] | undefined, get): Promise => { - const [rootDir, ignorePrefix, analyzeDir, buildDir] = await Promise.all([get('rootDir'), get('ignorePrefix'), get('analyzeDir'), get('buildDir')]) as [string, string, string, string] - return [ + $resolve: async (val, get): Promise => { + const [rootDir, ignorePrefix, analyzeDir, buildDir] = await Promise.all([get('rootDir'), get('ignorePrefix'), get('analyzeDir'), get('buildDir')]) + const ignore = new Set([ '**/*.stories.{js,cts,mts,ts,jsx,tsx}', // ignore storybook files '**/*.{spec,test}.{js,cts,mts,ts,jsx,tsx}', // ignore tests '**/*.d.{cts,mts,ts}', // ignore type declarations '**/.{pnpm-store,vercel,netlify,output,git,cache,data}', relative(rootDir, analyzeDir), relative(rootDir, buildDir), - ignorePrefix && `**/${ignorePrefix}*.*`, - ...val || [], - ].filter(Boolean) + ]) + if (ignorePrefix) { + ignore.add(`**/${ignorePrefix}*.*`) + } + if (Array.isArray(val)) { + for (const pattern in val) { + if (pattern) { + ignore.add(pattern) + } + } + } + return [...ignore] }, }, @@ -523,8 +570,11 @@ export default defineUntypedSchema({ * @type {Array} */ watch: { - $resolve: (val: Array | undefined) => { - return (val || []).filter((b: unknown) => typeof b === 'string' || b instanceof RegExp) + $resolve: (val) => { + if (Array.isArray(val)) { + return val.filter((b: unknown) => typeof b === 'string' || b instanceof RegExp) + } + return [] }, }, @@ -580,7 +630,7 @@ export default defineUntypedSchema({ * ``` * @type {typeof import('../src/types/hooks').NuxtHooks} */ - hooks: null, + hooks: undefined, /** * Runtime config allows passing dynamic config and environment variables to the Nuxt app context. @@ -608,8 +658,9 @@ export default defineUntypedSchema({ * @type {typeof import('../src/types/config').RuntimeConfig} */ runtimeConfig: { - $resolve: async (val: RuntimeConfig, get): Promise> => { - const [app, buildId] = await Promise.all([get('app') as Promise>, get('buildId') as Promise]) + $resolve: async (_val, get) => { + const val = _val && typeof _val === 'object' ? _val : {} + const [app, buildId] = await Promise.all([get('app'), get('buildId')]) provideFallbackValues(val) return defu(val, { public: {}, diff --git a/packages/schema/src/config/dev.ts b/packages/schema/src/config/dev.ts index 9c7ebd0ab0..292c2b00fd 100644 --- a/packages/schema/src/config/dev.ts +++ b/packages/schema/src/config/dev.ts @@ -1,7 +1,7 @@ -import { defineUntypedSchema } from 'untyped' +import { defineResolvers } from '../utils/definition' import { template as loadingTemplate } from '../../../ui-templates/dist/templates/loading' -export default defineUntypedSchema({ +export default defineResolvers({ devServer: { /** * Whether to enable HTTPS. @@ -21,9 +21,12 @@ export default defineUntypedSchema({ https: false, /** Dev server listening port */ - port: process.env.NUXT_PORT || process.env.NITRO_PORT || process.env.PORT || 3000, + port: Number(process.env.NUXT_PORT || process.env.NITRO_PORT || process.env.PORT || 3000), - /** Dev server listening host */ + /** + * Dev server listening host + * @type {string | undefined} + */ host: process.env.NUXT_HOST || process.env.NITRO_HOST || process.env.HOST || undefined, /** diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index 0dd79e2e81..f43bbf7e8c 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -1,6 +1,6 @@ -import { defineUntypedSchema } from 'untyped' +import { defineResolvers } from '../utils/definition' -export default defineUntypedSchema({ +export default defineResolvers({ /** * `future` is for early opting-in to new features that will become default in a future * (possibly major) version of the framework. @@ -63,10 +63,10 @@ export default defineUntypedSchema({ */ typescriptBundlerResolution: { async $resolve (val, get) { - // TODO: remove in v3.10 - val = val ?? await (get('experimental') as Promise>).then(e => e?.typescriptBundlerResolution) + // @ts-expect-error TODO: remove in v3.10 + val = typeof val === 'boolean' ? val : await (get('experimental')).then(e => e?.typescriptBundlerResolution as string | undefined) if (typeof val === 'boolean') { return val } - const setting = await get('typescript.tsConfig.compilerOptions.moduleResolution') as string | undefined + const setting = await get('typescript.tsConfig').then(r => r?.compilerOptions?.moduleResolution) if (setting) { return setting.toLowerCase() === 'bundler' } @@ -86,14 +86,22 @@ export default defineUntypedSchema({ * @type {boolean | ((id?: string) => boolean)} */ inlineStyles: { - async $resolve (val, get) { - // TODO: remove in v3.10 - val = val ?? await (get('experimental') as Promise>).then((e: Record) => e?.inlineSSRStyles) - if (val === false || (await get('dev')) || (await get('ssr')) === false || (await get('builder')) === '@nuxt/webpack-builder') { + async $resolve (_val, get) { + const val = typeof _val === 'boolean' || typeof _val === 'function' + ? _val + // @ts-expect-error TODO: legacy property - remove in v3.10 + : await (get('experimental')).then(e => e?.inlineSSRStyles) as undefined | boolean + if ( + val === false || + (await get('dev')) || + (await get('ssr')) === false || + // @ts-expect-error TODO: handled normalised types + (await get('builder')) === '@nuxt/webpack-builder' + ) { return false } // Enabled by default for vite prod with ssr (for vue components) - return val ?? ((await get('future') as Record).compatibilityVersion === 4 ? (id: string) => id && id.includes('.vue') : true) + return val ?? ((await get('future')).compatibilityVersion === 4 ? (id?: string) => !!id && id.includes('.vue') : true) }, }, @@ -106,7 +114,9 @@ export default defineUntypedSchema({ */ devLogs: { async $resolve (val, get) { - if (val !== undefined) { return val } + if (typeof val === 'boolean' || val === 'silent') { + return val + } const [isDev, isTest] = await Promise.all([get('dev'), get('test')]) return isDev && !isTest }, @@ -118,8 +128,10 @@ export default defineUntypedSchema({ */ noScripts: { async $resolve (val, get) { - // TODO: remove in v3.10 - return val ?? await (get('experimental') as Promise>).then((e: Record) => e?.noScripts) ?? false + return typeof val === 'boolean' + ? val + // @ts-expect-error TODO: legacy property - remove in v3.10 + : (await (get('experimental')).then(e => e?.noScripts as boolean | undefined) ?? false) }, }, }, @@ -128,7 +140,7 @@ export default defineUntypedSchema({ * Set to true to generate an async entry point for the Vue bundle (for module federation support). */ asyncEntry: { - $resolve: val => val ?? false, + $resolve: val => typeof val === 'boolean' ? val : false, }, // TODO: Remove when nitro has support for mocking traced dependencies @@ -178,7 +190,17 @@ export default defineUntypedSchema({ if (val === 'reload') { return 'automatic' } - return val ?? 'automatic' + if (val === false) { + return false + } + + const validOptions = ['manual', 'automatic', 'automatic-immediate'] as const + type EmitRouteChunkError = typeof validOptions[number] + if (typeof val === 'string' && validOptions.includes(val as EmitRouteChunkError)) { + return val as EmitRouteChunkError + } + + return 'automatic' }, }, @@ -347,14 +369,16 @@ export default defineUntypedSchema({ */ watcher: { $resolve: async (val, get) => { - if (val) { - return val + const validOptions = ['chokidar', 'parcel', 'chokidar-granular'] as const + type WatcherOption = typeof validOptions[number] + if (typeof val === 'string' && validOptions.includes(val as WatcherOption)) { + return val as WatcherOption } - const [srcDir, rootDir] = await Promise.all([get('srcDir'), get('rootDir')]) as [string, string] + const [srcDir, rootDir] = await Promise.all([get('srcDir'), get('rootDir')]) if (srcDir === rootDir) { - return 'chokidar-granular' + return 'chokidar-granular' as const } - return 'chokidar' + return 'chokidar' as const }, }, @@ -396,7 +420,7 @@ export default defineUntypedSchema({ */ scanPageMeta: { async $resolve (val, get) { - return val ?? ((await get('future') as Record).compatibilityVersion === 4 ? 'after-resolve' : true) + return typeof val === 'boolean' || val === 'after-resolve' ? val : ((await get('future')).compatibilityVersion === 4 ? 'after-resolve' : true) }, }, @@ -435,7 +459,7 @@ export default defineUntypedSchema({ */ sharedPrerenderData: { async $resolve (val, get) { - return val ?? ((await get('future') as Record).compatibilityVersion === 4) + return typeof val === 'boolean' ? val : ((await get('future')).compatibilityVersion === 4) }, }, @@ -569,7 +593,7 @@ export default defineUntypedSchema({ */ normalizeComponentNames: { $resolve: async (val, get) => { - return val ?? ((await get('future') as Record).compatibilityVersion === 4) + return typeof val === 'boolean' ? val : ((await get('future')).compatibilityVersion === 4) }, }, @@ -580,7 +604,9 @@ export default defineUntypedSchema({ */ spaLoadingTemplateLocation: { $resolve: async (val, get) => { - return val ?? (((await get('future') as Record).compatibilityVersion === 4) ? 'body' : 'within') + const validOptions = ['body', 'within'] as const + type SpaLoadingTemplateLocation = typeof validOptions[number] + return typeof val === 'string' && validOptions.includes(val as SpaLoadingTemplateLocation) ? val as SpaLoadingTemplateLocation : (((await get('future')).compatibilityVersion === 4) ? 'body' : 'within') }, }, @@ -590,7 +616,7 @@ export default defineUntypedSchema({ * @see [the Chrome DevTools extensibility API](https://developer.chrome.com/docs/devtools/performance/extension#tracks) */ browserDevtoolsTiming: { - $resolve: async (val, get) => val ?? await get('dev'), + $resolve: async (val, get) => typeof val === 'boolean' ? val : await get('dev'), }, /** @@ -598,7 +624,7 @@ export default defineUntypedSchema({ */ debugModuleMutation: { $resolve: async (val, get) => { - return val ?? Boolean(await get('debug')) + return typeof val === 'boolean' ? val : Boolean(await get('debug')) }, }, }, diff --git a/packages/schema/src/config/generate.ts b/packages/schema/src/config/generate.ts index eb0e334393..5acab1e98d 100644 --- a/packages/schema/src/config/generate.ts +++ b/packages/schema/src/config/generate.ts @@ -1,6 +1,6 @@ -import { defineUntypedSchema } from 'untyped' +import { defineResolvers } from '../utils/definition' -export default defineUntypedSchema({ +export default defineResolvers({ generate: { /** * The routes to generate. diff --git a/packages/schema/src/config/index.ts b/packages/schema/src/config/index.ts index 42666bc8c3..c35253fe7c 100644 --- a/packages/schema/src/config/index.ts +++ b/packages/schema/src/config/index.ts @@ -1,3 +1,5 @@ +import type { ResolvableConfigSchema } from '../utils/definition' + import adhoc from './adhoc' import app from './app' import build from './build' @@ -28,4 +30,4 @@ export default { ...typescript, ...vite, ...webpack, -} +} satisfies ResolvableConfigSchema diff --git a/packages/schema/src/config/internal.ts b/packages/schema/src/config/internal.ts index d1986aa263..e618081d04 100644 --- a/packages/schema/src/config/internal.ts +++ b/packages/schema/src/config/internal.ts @@ -1,6 +1,6 @@ -import { defineUntypedSchema } from 'untyped' +import { defineResolvers } from '../utils/definition' -export default defineUntypedSchema({ +export default defineResolvers({ /** @private */ _majorVersion: 3, /** @private */ @@ -25,7 +25,7 @@ export default defineUntypedSchema({ appDir: '', /** * @private - * @type {Array<{ meta: ModuleMeta; module: NuxtModule, timings?: Record; entryPath?: string }>} + * @type {Array<{ meta: typeof import('../src/types/module').ModuleMeta; module: typeof import('../src/types/module').NuxtModule, timings?: Record; entryPath?: string }>} */ _installedModules: [], /** @private */ diff --git a/packages/schema/src/config/nitro.ts b/packages/schema/src/config/nitro.ts index b1a71db94c..82e12d8b07 100644 --- a/packages/schema/src/config/nitro.ts +++ b/packages/schema/src/config/nitro.ts @@ -1,7 +1,6 @@ -import { defineUntypedSchema } from 'untyped' -import type { RuntimeConfig } from '../types/config' +import { defineResolvers } from '../utils/definition' -export default defineUntypedSchema({ +export default defineResolvers({ /** * Configuration for Nitro. * @see [Nitro configuration docs](https://nitro.unjs.io/config/) @@ -9,8 +8,8 @@ export default defineUntypedSchema({ */ nitro: { runtimeConfig: { - $resolve: async (val: Record | undefined, get) => { - const runtimeConfig = await get('runtimeConfig') as RuntimeConfig + $resolve: async (val, get) => { + const runtimeConfig = await get('runtimeConfig') return { ...runtimeConfig, app: { @@ -27,10 +26,12 @@ export default defineUntypedSchema({ }, }, routeRules: { - $resolve: async (val: Record | undefined, get) => ({ - ...await get('routeRules') as Record, - ...val, - }), + $resolve: async (val, get) => { + return { + ...await get('routeRules'), + ...(val && typeof val === 'object' ? val : {}), + } + }, }, }, diff --git a/packages/schema/src/config/postcss.ts b/packages/schema/src/config/postcss.ts index 573fcefb73..afd794dafd 100644 --- a/packages/schema/src/config/postcss.ts +++ b/packages/schema/src/config/postcss.ts @@ -1,4 +1,4 @@ -import { defineUntypedSchema } from 'untyped' +import { defineResolvers } from '../utils/definition' const ensureItemIsLast = (item: string) => (arr: string[]) => { const index = arr.indexOf(item) @@ -17,7 +17,7 @@ const orderPresets = { }, } -export default defineUntypedSchema({ +export default defineResolvers({ postcss: { /** * A strategy for ordering PostCSS plugins. @@ -25,14 +25,20 @@ export default defineUntypedSchema({ * @type {'cssnanoLast' | 'autoprefixerLast' | 'autoprefixerAndCssnanoLast' | string[] | ((names: string[]) => string[])} */ order: { - $resolve: (val: string | string[] | ((plugins: string[]) => string[])): string[] | ((plugins: string[]) => string[]) => { + $resolve: (val) => { if (typeof val === 'string') { if (!(val in orderPresets)) { throw new Error(`[nuxt] Unknown PostCSS order preset: ${val}`) } return orderPresets[val as keyof typeof orderPresets] } - return val ?? orderPresets.autoprefixerAndCssnanoLast + if (typeof val === 'function') { + return val as (names: string[]) => string[] + } + if (Array.isArray(val)) { + return val + } + return orderPresets.autoprefixerAndCssnanoLast }, }, /** diff --git a/packages/schema/src/config/router.ts b/packages/schema/src/config/router.ts index 5365829177..b9b8b574e7 100644 --- a/packages/schema/src/config/router.ts +++ b/packages/schema/src/config/router.ts @@ -1,6 +1,6 @@ -import { defineUntypedSchema } from 'untyped' +import { defineResolvers } from '../utils/definition' -export default defineUntypedSchema({ +export default defineResolvers({ router: { /** * Additional router options passed to `vue-router`. On top of the options for `vue-router`, diff --git a/packages/schema/src/config/typescript.ts b/packages/schema/src/config/typescript.ts index e3cdd6dc5a..e1e30889bf 100644 --- a/packages/schema/src/config/typescript.ts +++ b/packages/schema/src/config/typescript.ts @@ -1,6 +1,6 @@ -import { defineUntypedSchema } from 'untyped' +import { defineResolvers } from '../utils/definition' -export default defineUntypedSchema({ +export default defineResolvers({ /** * Configuration for Nuxt's TypeScript integration. * @@ -23,7 +23,17 @@ export default defineUntypedSchema({ * @type {'vite' | 'webpack' | 'rspack' | 'shared' | false | undefined} */ builder: { - $resolve: val => val ?? null, + $resolve: (val) => { + const validBuilderTypes = ['vite', 'webpack', 'rspack', 'shared'] as const + type ValidBuilderType = typeof validBuilderTypes[number] + if (typeof val === 'string' && validBuilderTypes.includes(val as ValidBuilderType)) { + return val as ValidBuilderType + } + if (val === false) { + return false + } + return undefined + }, }, /** diff --git a/packages/schema/src/config/vite.ts b/packages/schema/src/config/vite.ts index 9d49537a12..0f3d04dbba 100644 --- a/packages/schema/src/config/vite.ts +++ b/packages/schema/src/config/vite.ts @@ -1,10 +1,9 @@ import { consola } from 'consola' import { resolve } from 'pathe' import { isTest } from 'std-env' -import { defineUntypedSchema } from 'untyped' -import type { NuxtDebugOptions } from '../types/debug' +import { defineResolvers } from '../utils/definition' -export default defineUntypedSchema({ +export default defineResolvers({ /** * Configuration that will be passed directly to Vite. * @@ -14,22 +13,21 @@ export default defineUntypedSchema({ */ vite: { root: { - $resolve: async (val, get) => val ?? (await get('srcDir')), + $resolve: async (val, get) => typeof val === 'string' ? val : (await get('srcDir')), }, mode: { - $resolve: async (val, get) => val ?? (await get('dev') ? 'development' : 'production'), + $resolve: async (val, get) => typeof val === 'string' ? val : (await get('dev') ? 'development' : 'production'), }, define: { - $resolve: async (val: Record | undefined, get) => { - const [isDev, debug] = await Promise.all([get('dev'), get('debug')]) as [boolean, boolean | NuxtDebugOptions] - + $resolve: async (_val, get) => { + const [isDev, isDebug] = await Promise.all([get('dev'), get('debug')]) return { - '__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': Boolean(debug && (debug === true || debug.hydration)), + '__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': Boolean(isDebug && (isDebug === true || isDebug.hydration)), 'process.dev': isDev, 'import.meta.dev': isDev, 'process.test': isTest, 'import.meta.test': isTest, - ...val, + ..._val && typeof _val === 'object' ? _val : {}, } }, }, @@ -37,6 +35,7 @@ export default defineUntypedSchema({ extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'], }, publicDir: { + // @ts-expect-error this is missing from our `vite` types deliberately, so users do not configure it $resolve: (val) => { if (val) { consola.warn('Directly configuring the `vite.publicDir` option is not supported. Instead, set `dir.public`. You can read more in `https://nuxt.com/docs/api/nuxt-config#public`.') @@ -46,46 +45,51 @@ export default defineUntypedSchema({ }, vue: { isProduction: { - $resolve: async (val, get) => val ?? !(await get('dev')), + $resolve: async (val, get) => typeof val === 'boolean' ? val : !(await get('dev')), }, template: { compilerOptions: { - $resolve: async (val, get) => val ?? (await get('vue') as Record).compilerOptions, + $resolve: async (val, get) => val ?? (await get('vue')).compilerOptions, }, transformAssetUrls: { - $resolve: async (val, get) => val ?? (await get('vue') as Record).transformAssetUrls, + $resolve: async (val, get) => val ?? (await get('vue')).transformAssetUrls, }, }, script: { hoistStatic: { - $resolve: async (val, get) => val ?? (await get('vue') as Record).compilerOptions?.hoistStatic, + $resolve: async (val, get) => typeof val === 'boolean' ? val : (await get('vue')).compilerOptions?.hoistStatic, }, }, features: { propsDestructure: { $resolve: async (val, get) => { - if (val !== undefined && val !== null) { + if (typeof val === 'boolean') { return val } - const vueOptions = await get('vue') as Record || {} - return Boolean(vueOptions.script?.propsDestructure ?? vueOptions.propsDestructure) + const vueOptions = await get('vue') || {} + return Boolean( + // @ts-expect-error TODO: remove in future: supporting a legacy schema + vueOptions.script?.propsDestructure + ?? vueOptions.propsDestructure, + ) }, }, }, }, vueJsx: { - $resolve: async (val: Record, get) => { + $resolve: async (val, get) => { return { - isCustomElement: (await get('vue') as Record).compilerOptions?.isCustomElement, - ...val, + // TODO: investigate type divergence between types for @vue/compiler-core and @vue/babel-plugin-jsx + isCustomElement: (await get('vue')).compilerOptions?.isCustomElement as undefined | ((tag: string) => boolean), + ...typeof val === 'object' ? val : {}, } }, }, optimizeDeps: { exclude: { - $resolve: async (val: string[] | undefined, get) => [ - ...val || [], - ...(await get('build.transpile') as Array string | RegExp | false)>).filter(i => typeof i === 'string'), + $resolve: async (val, get) => [ + ...Array.isArray(val) ? val : [], + ...(await get('build.transpile')).filter(i => typeof i === 'string'), 'vue-demi', ], }, @@ -98,29 +102,29 @@ export default defineUntypedSchema({ clearScreen: true, build: { assetsDir: { - $resolve: async (val, get) => val ?? (await get('app') as Record).buildAssetsDir?.replace(/^\/+/, ''), + $resolve: async (val, get) => typeof val === 'string' ? val : (await get('app')).buildAssetsDir?.replace(/^\/+/, ''), }, emptyOutDir: false, }, server: { fs: { allow: { - $resolve: async (val: string[] | undefined, get) => { - const [buildDir, srcDir, rootDir, workspaceDir, modulesDir] = await Promise.all([get('buildDir'), get('srcDir'), get('rootDir'), get('workspaceDir'), get('modulesDir')]) as [string, string, string, string, string] + $resolve: async (val, get) => { + const [buildDir, srcDir, rootDir, workspaceDir, modulesDir] = await Promise.all([get('buildDir'), get('srcDir'), get('rootDir'), get('workspaceDir'), get('modulesDir')]) return [...new Set([ buildDir, srcDir, rootDir, workspaceDir, ...(modulesDir), - ...val ?? [], + ...Array.isArray(val) ? val : [], ])] }, }, }, }, cacheDir: { - $resolve: async (val, get) => val ?? resolve(await get('rootDir') as string, 'node_modules/.cache/vite'), + $resolve: async (val, get) => typeof val === 'string' ? val : resolve(await get('rootDir'), 'node_modules/.cache/vite'), }, }, }) diff --git a/packages/schema/src/config/webpack.ts b/packages/schema/src/config/webpack.ts index ee4d94843d..86f78af662 100644 --- a/packages/schema/src/config/webpack.ts +++ b/packages/schema/src/config/webpack.ts @@ -1,8 +1,7 @@ import { defu } from 'defu' -import { defineUntypedSchema } from 'untyped' -import type { VueLoaderOptions } from 'vue-loader' +import { defineResolvers } from '../utils/definition' -export default defineUntypedSchema({ +export default defineResolvers({ webpack: { /** * Nuxt uses `webpack-bundle-analyzer` to visualize your bundles and how to optimize them. @@ -17,8 +16,8 @@ export default defineUntypedSchema({ * @type {boolean | { enabled?: boolean } & typeof import('webpack-bundle-analyzer').BundleAnalyzerPlugin.Options} */ analyze: { - $resolve: async (val: boolean | { enabled?: boolean } | Record, get) => { - const value = typeof val === 'boolean' ? { enabled: val } : val + $resolve: async (val, get) => { + const value = typeof val === 'boolean' ? { enabled: val } : (val && typeof val === 'object' ? val : {}) return defu(value, await get('build.analyze') as { enabled?: boolean } | Record) }, }, @@ -83,7 +82,7 @@ export default defineUntypedSchema({ * Enables CSS source map support (defaults to `true` in development). */ cssSourceMap: { - $resolve: async (val, get) => val ?? await get('dev'), + $resolve: async (val, get) => typeof val === 'boolean' ? val : await get('dev'), }, /** @@ -147,7 +146,10 @@ export default defineUntypedSchema({ for (const name of styleLoaders) { const loader = loaders[name] if (loader && loader.sourceMap === undefined) { - loader.sourceMap = Boolean(await get('build.cssSourceMap')) + loader.sourceMap = Boolean( + // @ts-expect-error TODO: remove legacay configuration + await get('build.cssSourceMap'), + ) } } return loaders @@ -165,30 +167,27 @@ export default defineUntypedSchema({ /** * @see [`file-loader` Options](https://github.com/webpack-contrib/file-loader#options) - * @type {Omit} * @default * ```ts * { esModule: false } * ``` */ - file: { esModule: false }, + file: { esModule: false, limit: 1000 }, /** * @see [`file-loader` Options](https://github.com/webpack-contrib/file-loader#options) - * @type {Omit} * @default * ```ts - * { esModule: false, limit: 1000 } + * { esModule: false } * ``` */ fontUrl: { esModule: false, limit: 1000 }, /** * @see [`file-loader` Options](https://github.com/webpack-contrib/file-loader#options) - * @type {Omit} * @default * ```ts - * { esModule: false, limit: 1000 } + * { esModule: false } * ``` */ imgUrl: { esModule: false, limit: 1000 }, @@ -205,26 +204,38 @@ export default defineUntypedSchema({ */ vue: { transformAssetUrls: { - $resolve: async (val, get) => (val ?? (await get('vue.transformAssetUrls'))) as VueLoaderOptions['transformAssetUrls'], + $resolve: async (val, get) => (val ?? (await get('vue.transformAssetUrls'))), }, compilerOptions: { - $resolve: async (val, get) => (val ?? (await get('vue.compilerOptions'))) as VueLoaderOptions['compilerOptions'], + $resolve: async (val, get) => (val ?? (await get('vue.compilerOptions'))), }, propsDestructure: { $resolve: async (val, get) => Boolean(val ?? await get('vue.propsDestructure')), }, - } satisfies { [K in keyof VueLoaderOptions]: { $resolve: (val: unknown, get: (id: string) => Promise) => Promise } }, + }, + /** + * See [css-loader](https://github.com/webpack-contrib/css-loader) for available options. + */ css: { importLoaders: 0, + /** + * @type {boolean | { filter: (url: string, resourcePath: string) => boolean }} + */ url: { filter: (url: string, _resourcePath: string) => url[0] !== '/', }, esModule: false, }, + /** + * See [css-loader](https://github.com/webpack-contrib/css-loader) for available options. + */ cssModules: { importLoaders: 0, + /** + * @type {boolean | { filter: (url: string, resourcePath: string) => boolean }} + */ url: { filter: (url: string, _resourcePath: string) => url[0] !== '/', }, @@ -241,7 +252,6 @@ export default defineUntypedSchema({ /** * @see [`sass-loader` Options](https://github.com/webpack-contrib/sass-loader#options) - * @type {typeof import('sass-loader')['Options']} * @default * ```ts * { @@ -259,7 +269,6 @@ export default defineUntypedSchema({ /** * @see [`sass-loader` Options](https://github.com/webpack-contrib/sass-loader#options) - * @type {typeof import('sass-loader')['Options']} */ scss: {}, @@ -300,7 +309,14 @@ export default defineUntypedSchema({ * @type {false | typeof import('css-minimizer-webpack-plugin').BasePluginOptions & typeof import('css-minimizer-webpack-plugin').DefinedDefaultMinimizerAndOptions} */ optimizeCSS: { - $resolve: async (val, get) => val ?? (await get('build.extractCSS') ? {} : false), + $resolve: async (val, get) => { + if (val === false || (val && typeof val === 'object')) { + return val + } + // @ts-expect-error TODO: remove legacy configuration + const extractCSS = await get('build.extractCSS') + return extractCSS ? {} : false + }, }, /** @@ -310,7 +326,9 @@ export default defineUntypedSchema({ optimization: { runtimeChunk: 'single', /** Set minimize to `false` to disable all minimizers. (It is disabled in development by default). */ - minimize: { $resolve: async (val, get) => val ?? !(await get('dev')) }, + minimize: { + $resolve: async (val, get) => typeof val === 'boolean' ? val : !(await get('dev')), + }, /** You can set minimizer to a customized array of plugins. */ minimizer: undefined, splitChunks: { @@ -323,15 +341,12 @@ export default defineUntypedSchema({ /** * Customize PostCSS Loader. * same options as [`postcss-loader` options](https://github.com/webpack-contrib/postcss-loader#options) - * @type {{ execute?: boolean, postcssOptions: typeof import('postcss').ProcessOptions, sourceMap?: boolean, implementation?: any }} + * @type {{ execute?: boolean, postcssOptions: typeof import('postcss').ProcessOptions & { plugins: Record & { autoprefixer?: typeof import('autoprefixer').Options; cssnano?: typeof import('cssnano').Options } }, sourceMap?: boolean, implementation?: any }} */ postcss: { postcssOptions: { - config: { - $resolve: async (val, get) => val ?? (await get('postcss.config')), - }, plugins: { - $resolve: async (val, get) => val ?? (await get('postcss.plugins')), + $resolve: async (val, get) => val && typeof val === 'object' ? val : (await get('postcss.plugins')), }, }, }, diff --git a/packages/schema/src/types/module.ts b/packages/schema/src/types/module.ts index 532308f5a3..1a621e12f2 100644 --- a/packages/schema/src/types/module.ts +++ b/packages/schema/src/types/module.ts @@ -21,6 +21,12 @@ export interface ModuleMeta { */ compatibility?: NuxtCompatibility + /** + * Fully resolved path used internally by Nuxt. Do not depend on this value. + * @internal + */ + rawPath?: string + [key: string]: unknown } diff --git a/packages/schema/src/utils/definition.ts b/packages/schema/src/utils/definition.ts new file mode 100644 index 0000000000..cf316f240d --- /dev/null +++ b/packages/schema/src/utils/definition.ts @@ -0,0 +1,56 @@ +import type { InputObject } from 'untyped' + +import { defineUntypedSchema } from 'untyped' + +import type { ConfigSchema } from '../../schema/config' + +type KeysOf = keyof T extends string + ? + { + [K in keyof T]: K extends string + ? string extends K + ? never // exclude generic 'string' type + : unknown extends Prefix + ? `${K | KeysOf}` + : Prefix extends string + ? `${Prefix}.${K | KeysOf}` + : never + : never + }[keyof T] + : never + +type ReturnFromKey = keyof T extends string + ? K extends keyof T + ? T[K] + : K extends `${keyof T}.${string}` + ? K extends `${infer Prefix}.${string}` + ? Prefix extends keyof T + ? K extends `${Prefix}.${infer Suffix}` + ? ReturnFromKey + : never + : never + : never + : never + : never + +type Awaitable = T | Promise + +interface Resolvers { + $resolve: (val: unknown, get: >(key: K) => Promise>) => Awaitable + $schema?: InputObject['$schema'] + $default?: ReturnValue +} + +type Resolvable = keyof Exclude, boolean | string | (() => any)> extends string + ? { + [K in keyof Namespace]: Partial> | Resolvers + } | Namespace + : Namespace | Resolvers + +export function defineResolvers>> (config: C) { + return defineUntypedSchema(config) /* as C */ +} + +export type ResolvableConfigSchema = Partial> + +export { defineUntypedSchema } from 'untyped' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 758a0c5c7b..bb5408cfa3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -708,6 +708,15 @@ importers: '@types/pug': specifier: 2.0.10 version: 2.0.10 + '@types/rollup-plugin-visualizer': + specifier: 4.2.4 + version: 4.2.4 + '@types/webpack-bundle-analyzer': + specifier: 4.7.0 + version: 4.7.0 + '@types/webpack-hot-middleware': + specifier: 2.25.9 + version: 2.25.9 '@unhead/schema': specifier: 1.11.18 version: 1.11.18 @@ -735,6 +744,9 @@ importers: compatx: specifier: 0.1.8 version: 0.1.8 + css-minimizer-webpack-plugin: + specifier: 7.0.0 + version: 7.0.0(webpack@5.96.1) esbuild-loader: specifier: 4.2.2 version: 4.2.2(webpack@5.96.1) @@ -750,6 +762,9 @@ importers: ignore: specifier: 7.0.3 version: 7.0.3 + mini-css-extract-plugin: + specifier: 2.9.2 + version: 2.9.2(webpack@5.96.1) nitropack: specifier: 2.10.4 version: 2.10.4(typescript@5.7.3) @@ -759,6 +774,9 @@ importers: pkg-types: specifier: 1.3.1 version: 1.3.1 + postcss: + specifier: 8.5.1 + version: 8.5.1 sass-loader: specifier: 16.0.4 version: 16.0.4(@rspack/core@1.2.2)(webpack@5.96.1) @@ -2966,6 +2984,9 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/rollup-plugin-visualizer@4.2.4': + resolution: {integrity: sha512-BW4Q6D1Qy5gno5qHWrnMDC2dOe/TAKXvqCpckOggCCu+XpS+ZZJJ1lq1+K3bvYccoO3Y7f5kglbFAgYGqCgULg==} + '@types/semver@7.5.8': resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} @@ -9892,6 +9913,10 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/rollup-plugin-visualizer@4.2.4': + dependencies: + rollup: 4.34.6 + '@types/semver@7.5.8': {} '@types/unist@2.0.11': {}