diff --git a/docs/1.getting-started/12.upgrade.md b/docs/1.getting-started/12.upgrade.md index c8e6c66ef0..a50f1f3721 100644 --- a/docs/1.getting-started/12.upgrade.md +++ b/docs/1.getting-started/12.upgrade.md @@ -136,7 +136,9 @@ nuxt.config.ts 1. Move your `assets/`, `components/`, `composables/`, `layouts/`, `middleware/`, `pages/`, `plugins/` and `utils/` folders under it, as well as `app.vue`, `error.vue`, `app.config.ts`. If you have an `app/router-options.ts` or `app/spa-loading-template.html`, these paths remain the same. 1. Make sure your `nuxt.config.ts`, `modules/`, `public/` and `server/` folders remain outside the `app/` folder, in the root of your project. -However, migration is _not required_. If you wish to keep your current folder structure, Nuxt should auto-detect it. (If it does not, please raise an issue.) You can also force a v3 folder structure with the following configuration: +However, migration is _not required_. If you wish to keep your current folder structure, Nuxt should auto-detect it. (If it does not, please raise an issue.) The one exception is that if you _already_ have a custom `srcDir`. In this case, you should be aware that your `modules/`, `public/` and `server/` folders will be resolved from your `rootDir` rather than from your custom `srcDir`. You can override this by configuring `dir.modules`, `dir.public` and `serverDir` if you need to. + +You can also force a v3 folder structure with the following configuration: ```ts [nuxt.config.ts] export default defineNuxtConfig({ diff --git a/packages/kit/src/module/define.ts b/packages/kit/src/module/define.ts index ff2a56d2d4..061362370f 100644 --- a/packages/kit/src/module/define.ts +++ b/packages/kit/src/module/define.ts @@ -3,7 +3,7 @@ import { performance } from 'node:perf_hooks' import { defu } from 'defu' import { applyDefaults } from 'untyped' import { dirname } from 'pathe' -import type { ModuleDefinition, ModuleOptions, ModuleSetupReturn, Nuxt, NuxtModule, NuxtOptions, ResolvedNuxtTemplate } from '@nuxt/schema' +import type { ModuleDefinition, ModuleOptions, ModuleSetupInstallResult, ModuleSetupReturn, Nuxt, NuxtModule, NuxtOptions, ResolvedModuleOptions, ResolvedNuxtTemplate } from '@nuxt/schema' import { logger } from '../logger' import { nuxtCtx, tryUseNuxt, useNuxt } from '../context' import { checkNuxtCompatibility, isNuxt2 } from '../compatibility' @@ -13,28 +13,53 @@ import { compileTemplate, templateUtils } from '../internal/template' * Define a Nuxt module, automatically merging defaults with user provided options, installing * any hooks that are provided, and calling an optional setup function for full control. */ -export function defineNuxtModule (definition: ModuleDefinition | NuxtModule): NuxtModule { - if (typeof definition === 'function') { return defineNuxtModule({ setup: definition }) } +export function defineNuxtModule (definition: ModuleDefinition | NuxtModule): NuxtModule - // Normalize definition and meta - const module: ModuleDefinition & Required, 'meta'>> = defu(definition, { meta: {} }) - if (module.meta.configKey === undefined) { - module.meta.configKey = module.meta.name +export function defineNuxtModule (): { + with: > ( + definition: ModuleDefinition | NuxtModule + ) => NuxtModule +} + +export function defineNuxtModule (definition?: ModuleDefinition | NuxtModule) { + if (definition) { + return _defineNuxtModule(definition) } + return { + with: >( + definition: ModuleDefinition | NuxtModule, + ) => _defineNuxtModule(definition), + } +} + +function _defineNuxtModule> (definition: ModuleDefinition | NuxtModule): NuxtModule { + if (typeof definition === 'function') { return _defineNuxtModule({ setup: definition }) } + + // Normalize definition and meta + const module: ModuleDefinition & Required, 'meta'>> = defu(definition, { meta: {} }) + + module.meta.configKey ||= module.meta.name + // Resolves module options from inline options, [configKey] in nuxt.config, defaults and schema - async function getOptions (inlineOptions?: OptionsT, nuxt: Nuxt = useNuxt()) { - const configKey = module.meta.configKey || module.meta.name! - const _defaults = module.defaults instanceof Function ? module.defaults(nuxt) : module.defaults - let _options = defu(inlineOptions, nuxt.options[configKey as keyof NuxtOptions], _defaults) as OptionsT + async function getOptions (inlineOptions?: Partial, nuxt: Nuxt = useNuxt()): Promise> { + const nuxtConfigOptionsKey = module.meta.configKey || module.meta.name + + const nuxtConfigOptions: Partial = nuxtConfigOptionsKey && nuxtConfigOptionsKey in nuxt.options ? nuxt.options[ nuxtConfigOptionsKey] : {} + + const optionsDefaults: TOptionsDefaults = module.defaults instanceof Function ? module.defaults(nuxt) : module.defaults ?? {} + + let options: ResolvedModuleOptions = defu(inlineOptions, nuxtConfigOptions, optionsDefaults) + if (module.schema) { - _options = await applyDefaults(module.schema, _options) as OptionsT + options = await applyDefaults(module.schema, options) as any } - return Promise.resolve(_options) + + return Promise.resolve(options) } // Module format is always a simple function - async function normalizedModule (this: any, inlineOptions: OptionsT, nuxt: Nuxt) { + async function normalizedModule (this: any, inlineOptions: Partial, nuxt: Nuxt): Promise { if (!nuxt) { nuxt = tryUseNuxt() || this.nuxt /* invoked by nuxt 2 */ } @@ -87,7 +112,7 @@ export function defineNuxtModule (definition: Mo if (res === false) { return false } // Return module install result - return defu(res, { + return defu(res, { timings: { setup: setupTime, }, @@ -98,7 +123,7 @@ export function defineNuxtModule (definition: Mo normalizedModule.getMeta = () => Promise.resolve(module.meta) normalizedModule.getOptions = getOptions - return normalizedModule as NuxtModule + return > normalizedModule } // -- Nuxt 2 compatibility shims -- diff --git a/packages/kit/src/module/install.ts b/packages/kit/src/module/install.ts index b17cee47ca..d283388a63 100644 --- a/packages/kit/src/module/install.ts +++ b/packages/kit/src/module/install.ts @@ -1,5 +1,5 @@ import { existsSync, promises as fsp, lstatSync } from 'node:fs' -import type { ModuleMeta, Nuxt, NuxtModule } from '@nuxt/schema' +import type { ModuleMeta, Nuxt, NuxtConfig, NuxtModule } from '@nuxt/schema' import { dirname, isAbsolute, join, resolve } from 'pathe' import { defu } from 'defu' import { isNuxt2 } from '../compatibility' @@ -12,7 +12,10 @@ import { logger } from '../logger' const NODE_MODULES_RE = /[/\\]node_modules[/\\]/ /** Installs a module on a Nuxt instance. */ -export async function installModule (moduleToInstall: string | NuxtModule, inlineOptions?: any, nuxt: Nuxt = useNuxt()) { +export async function installModule< + T extends string | NuxtModule, + Config extends Extract[number], [T, any]>, +> (moduleToInstall: T, inlineOptions?: [Config] extends [never] ? any : Config[1], nuxt: Nuxt = useNuxt()) { const { nuxtModule, buildTimeModuleMeta } = await loadNuxtModuleInstance(moduleToInstall, nuxt) const localLayerModuleDirs = new Set() @@ -28,7 +31,7 @@ export async function installModule (moduleToInstall: string | NuxtModule, inlin isNuxt2() // @ts-expect-error Nuxt 2 `moduleContainer` is not typed ? await nuxtModule.call(nuxt.moduleContainer, inlineOptions, nuxt) - : await nuxtModule(inlineOptions, nuxt) + : await nuxtModule(inlineOptions || {}, nuxt) ) ?? {} if (res === false /* setup aborted */) { return @@ -43,10 +46,16 @@ export async function installModule (moduleToInstall: string | NuxtModule, inlin } nuxt.options._installedModules = nuxt.options._installedModules || [] + const entryPath = typeof moduleToInstall === 'string' ? resolveAlias(moduleToInstall) : undefined + + if (typeof moduleToInstall === 'string' && entryPath !== moduleToInstall) { + buildTimeModuleMeta.rawPath = moduleToInstall + } + nuxt.options._installedModules.push({ meta: defu(await nuxtModule.getMeta?.(), buildTimeModuleMeta), timings: res.timings, - entryPath: typeof moduleToInstall === 'string' ? resolveAlias(moduleToInstall) : undefined, + entryPath, }) } diff --git a/packages/kit/src/template.ts b/packages/kit/src/template.ts index 57199da8fb..829551de60 100644 --- a/packages/kit/src/template.ts +++ b/packages/kit/src/template.ts @@ -5,6 +5,7 @@ import type { Nuxt, NuxtTemplate, NuxtTypeTemplate, ResolvedNuxtTemplate, TSRefe import { withTrailingSlash } from 'ufo' import { defu } from 'defu' import type { TSConfig } from 'pkg-types' +import { gte } from 'semver' import { readPackageJSON } from 'pkg-types' import { tryResolveModule } from './internal/esm' @@ -122,6 +123,12 @@ export async function _generateTypes (nuxt: Nuxt) { .map(m => getDirectory(m.entryPath)), ) + const isV4 = nuxt.options.future?.compatibilityVersion === 4 + + const hasTypescriptVersionWithModulePreserve = await readPackageJSON('typescript', { url: nuxt.options.modulesDir }) + .then(r => r?.version && gte(r.version, '5.4.0')) + .catch(() => isV4) + // https://www.totaltypescript.com/tsconfig-cheat-sheet const tsConfig: TSConfig = defu(nuxt.options.typescript?.tsConfig, { compilerOptions: { @@ -136,11 +143,11 @@ export async function _generateTypes (nuxt: Nuxt) { verbatimModuleSyntax: true, /* Strictness */ strict: nuxt.options.typescript?.strict ?? true, - noUncheckedIndexedAccess: nuxt.options.future?.compatibilityVersion === 4, + noUncheckedIndexedAccess: isV4, forceConsistentCasingInFileNames: true, noImplicitOverride: true, /* If NOT transpiling with TypeScript: */ - module: 'preserve', + module: hasTypescriptVersionWithModulePreserve ? 'preserve' : 'es2022', noEmit: true, /* If your code runs in the DOM: */ lib: [ diff --git a/packages/nuxt/src/core/templates.ts b/packages/nuxt/src/core/templates.ts index 49a89f8943..0395ff2cbb 100644 --- a/packages/nuxt/src/core/templates.ts +++ b/packages/nuxt/src/core/templates.ts @@ -156,21 +156,29 @@ 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(/\.\w+$/, '') - const modules = moduleInfo.map(meta => [genString(meta.configKey), getImportName(meta.importName)]) + const modules = moduleInfo.map(meta => [genString(meta.configKey), getImportName(meta.importName), meta]) const privateRuntimeConfig = Object.create(null) for (const key in nuxt.options.runtimeConfig) { if (key !== 'public') { privateRuntimeConfig[key] = nuxt.options.runtimeConfig[key] } } - return [ - 'import { NuxtModule, RuntimeConfig } from \'nuxt/schema\'', - 'declare module \'nuxt/schema\' {', - ' interface NuxtConfig {', + const moduleOptionsInterface = [ ...modules.map(([configKey, importName]) => ` [${configKey}]?: typeof ${genDynamicImport(importName, { wrapper: false })}.default extends NuxtModule ? Partial : Record`, ), - modules.length > 0 ? ` modules?: (undefined | null | false | NuxtModule | string | [NuxtModule | string, Record] | ${modules.map(([configKey, importName]) => `[${genString(importName)}, Exclude]`).join(' | ')})[],` : '', + modules.length > 0 ? ` modules?: (undefined | null | false | NuxtModule | string | [NuxtModule | string, Record] | ${modules.map(([configKey, importName, meta]) => `[${genString(meta?.rawPath || importName)}, Exclude]`).join(' | ')})[],` : '', + ] + return [ + 'import { NuxtModule, RuntimeConfig } from \'@nuxt/schema\'', + 'declare module \'@nuxt/schema\' {', + ' interface NuxtConfig {', + moduleOptionsInterface, + ' }', + '}', + 'declare module \'nuxt/schema\' {', + ' interface NuxtConfig {', + moduleOptionsInterface, ' }', generateTypes(await resolveSchema(privateRuntimeConfig as Record), { diff --git a/packages/schema/src/types/module.ts b/packages/schema/src/types/module.ts index 9b92d6a93e..13b42365aa 100644 --- a/packages/schema/src/types/module.ts +++ b/packages/schema/src/types/module.ts @@ -1,3 +1,4 @@ +import type { Defu } from 'defu' import type { NuxtHooks } from './hooks' import type { Nuxt } from './nuxt' import type { NuxtCompatibility } from './compatibility' @@ -26,8 +27,7 @@ export interface ModuleMeta { /** The options received. */ export type ModuleOptions = Record -/** Optional result for nuxt modules */ -export interface ModuleSetupReturn { +export type ModuleSetupInstallResult = { /** * Timing information for the initial setup */ @@ -39,19 +39,37 @@ export interface ModuleSetupReturn { } type Awaitable = T | Promise -type _ModuleSetupReturn = Awaitable -/** Input module passed to defineNuxtModule. */ -export interface ModuleDefinition { +type Prettify = { + [K in keyof T]: T[K]; +} & {} + +export type ModuleSetupReturn = Awaitable + +export type ResolvedModuleOptions> = Prettify< + Defu< + Partial, + [Partial, TOptionsDefaults] + > +> + +/** Module definition passed to 'defineNuxtModule(...)' or 'defineNuxtModule().with(...)'. */ +export interface ModuleDefinition< + TOptions extends ModuleOptions, + TOptionsDefaults extends Partial = Partial, +> { meta?: ModuleMeta - defaults?: T | ((nuxt: Nuxt) => T) - schema?: T + defaults?: TOptionsDefaults | ((nuxt: Nuxt) => TOptionsDefaults) + schema?: TOptions hooks?: Partial - setup?: (this: void, resolvedOptions: T, nuxt: Nuxt) => _ModuleSetupReturn + setup?: (this: void, resolvedOptions: ResolvedModuleOptions, nuxt: Nuxt) => ModuleSetupReturn } -export interface NuxtModule { - (this: void, inlineOptions: T, nuxt: Nuxt): _ModuleSetupReturn - getOptions?: (inlineOptions?: T, nuxt?: Nuxt) => Promise +export interface NuxtModule< + TOptions extends ModuleOptions = ModuleOptions, + TOptionsDefaults extends Partial = Partial, +> { + (this: void, resolvedOptions: ResolvedModuleOptions, nuxt: Nuxt): ModuleSetupReturn + getOptions?: (inlineOptions?: Partial, nuxt?: Nuxt) => Promise> getMeta?: () => Promise } diff --git a/test/fixtures/basic-types/nuxt.config.ts b/test/fixtures/basic-types/nuxt.config.ts index 983039bcdf..3278402fa3 100644 --- a/test/fixtures/basic-types/nuxt.config.ts +++ b/test/fixtures/basic-types/nuxt.config.ts @@ -1,4 +1,4 @@ -import { addTypeTemplate } from 'nuxt/kit' +import { addTypeTemplate, installModule } from 'nuxt/kit' export default defineNuxtConfig({ experimental: { @@ -54,6 +54,15 @@ export default defineNuxtConfig({ filename: 'test.d.ts', getContents: () => 'declare type Fromage = "cheese"', }) + function _test () { + installModule('~/modules/example', { + typeTest (val) { + // @ts-expect-error module type defines val as boolean + const b: string = val + return !!b + }, + }) + } }, './modules/test', [ diff --git a/test/fixtures/basic-types/types.ts b/test/fixtures/basic-types/types.ts index dad52b4f6a..343ecb1a88 100644 --- a/test/fixtures/basic-types/types.ts +++ b/test/fixtures/basic-types/types.ts @@ -5,6 +5,7 @@ import type { NavigationFailure, RouteLocationNormalized, RouteLocationRaw, Rout import type { AppConfig, RuntimeValue, UpperSnakeCase } from 'nuxt/schema' import { defineNuxtConfig } from 'nuxt/config' +import { defineNuxtModule } from 'nuxt/kit' import { callWithNuxt, isVue3 } from '#app' import type { NuxtError } from '#app' import type { NavigateToOptions } from '#app/composables/router' @@ -242,6 +243,23 @@ describe('modules', () => { // @ts-expect-error we want to ensure we throw type error on invalid key defineNuxtConfig({ undeclaredKey: { other: false } }) }) + + it('correctly typed resolved options in defineNuxtModule setup using `.with()`', () => { + defineNuxtModule<{ + foo?: string + baz: number + }>().with({ + defaults: { + foo: 'bar', + }, + setup: (resolvedOptions) => { + expectTypeOf(resolvedOptions).toEqualTypeOf<{ + foo: string + baz?: number | undefined + }>() + }, + }) + }) }) describe('nuxtApp', () => {