diff --git a/docs/2.guide/3.going-further/1.features.md b/docs/2.guide/3.going-further/1.features.md index f6eff54c6f..9439d708b4 100644 --- a/docs/2.guide/3.going-further/1.features.md +++ b/docs/2.guide/3.going-further/1.features.md @@ -56,6 +56,10 @@ export default defineNuxtConfig({ compatibilityVersion: 4, }, // To re-enable _all_ Nuxt v3 behaviour, set the following options: + srcDir: '.', + dir: { + app: 'app' + }, experimental: { compileTemplate: true, templateUtils: true, diff --git a/packages/kit/src/loader/config.ts b/packages/kit/src/loader/config.ts index 0b30d81ffc..3c42525c4d 100644 --- a/packages/kit/src/loader/config.ts +++ b/packages/kit/src/loader/config.ts @@ -1,13 +1,15 @@ -import { resolve } from 'pathe' import type { JSValue } from 'untyped' import { applyDefaults } from 'untyped' -import type { LoadConfigOptions } from 'c12' +import type { ConfigLayer, ConfigLayerMeta, LoadConfigOptions } from 'c12' import { loadConfig } from 'c12' import type { NuxtConfig, NuxtOptions } from '@nuxt/schema' import { NuxtConfigSchema } from '@nuxt/schema' export interface LoadNuxtConfigOptions extends LoadConfigOptions {} +const layerSchemaKeys = ['future', 'srcDir', 'rootDir', 'dir'] +const layerSchema = Object.fromEntries(Object.entries(NuxtConfigSchema).filter(([key]) => layerSchemaKeys.includes(key))) + export async function loadNuxtConfig (opts: LoadNuxtConfigOptions): Promise { (globalThis as any).defineNuxtConfig = (c: any) => c const result = await loadConfig({ @@ -28,15 +30,20 @@ export async function loadNuxtConfig (opts: LoadNuxtConfigOptions): Promise[] = [] for (const layer of layers) { + // Resolve `rootDir` & `srcDir` of layers layer.config = layer.config || {} layer.config.rootDir = layer.config.rootDir ?? layer.cwd - layer.config.srcDir = resolve(layer.config.rootDir!, layer.config.srcDir!) + + // Normalise layer directories + layer.config = await applyDefaults(layerSchema, layer.config as NuxtConfig & Record) as unknown as NuxtConfig + + // Filter layers + if (!layer.configFile || layer.configFile.endsWith('.nuxtrc')) { continue } + _layers.push(layer) } - // Filter layers - const _layers = layers.filter(layer => layer.configFile && !layer.configFile.endsWith('.nuxtrc')) ;(nuxtConfig as any)._layers = _layers // Ensure at least one layer remains (without nuxt.config) diff --git a/packages/nuxt/src/core/nitro.ts b/packages/nuxt/src/core/nitro.ts index 18dcc24a4d..01ffd58ad5 100644 --- a/packages/nuxt/src/core/nitro.ts +++ b/packages/nuxt/src/core/nitro.ts @@ -154,7 +154,7 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { baseURL: nuxt.options.app.buildAssetsDir, }, ...nuxt.options._layers - .map(layer => join(layer.config.srcDir, (layer.config.rootDir === nuxt.options.rootDir ? nuxt.options : layer.config).dir?.public || 'public')) + .map(layer => resolve(layer.config.srcDir, (layer.config.rootDir === nuxt.options.rootDir ? nuxt.options : layer.config).dir?.public || 'public')) .filter(dir => existsSync(dir)) .map(dir => ({ dir })), ], @@ -570,9 +570,9 @@ async function spaLoadingTemplatePath (nuxt: Nuxt) { return resolve(nuxt.options.srcDir, nuxt.options.spaLoadingTemplate) } - const possiblePaths = nuxt.options._layers.map(layer => join(layer.config.srcDir, 'app/spa-loading-template.html')) + const possiblePaths = nuxt.options._layers.map(layer => resolve(layer.config.srcDir, layer.config.dir?.app || 'app', 'spa-loading-template.html')) - return await findPath(possiblePaths) ?? resolve(nuxt.options.srcDir, 'app/spa-loading-template.html') + return await findPath(possiblePaths) ?? resolve(nuxt.options.srcDir, nuxt.options.dir?.app || 'app', 'spa-loading-template.html') } async function spaLoadingTemplate (nuxt: Nuxt) { diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index 377caa5a2b..39e47a4e06 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -491,6 +491,12 @@ async function initNuxt (nuxt: Nuxt) { } } + // Restart Nuxt when new `app/` dir is added + if (event === 'addDir' && path === resolve(nuxt.options.srcDir, 'app')) { + logger.info(`\`${path}/\` ${event === 'addDir' ? 'created' : 'removed'}`) + return nuxt.callHook('restart', { hard: true }) + } + // Core Nuxt files: app.vue, error.vue and app.config.ts const isFileChange = ['add', 'unlink'].includes(event) if (isFileChange && RESTART_RE.test(path)) { diff --git a/packages/nuxt/src/pages/module.ts b/packages/nuxt/src/pages/module.ts index dfb4411cbc..a4f5e9d474 100644 --- a/packages/nuxt/src/pages/module.ts +++ b/packages/nuxt/src/pages/module.ts @@ -34,7 +34,7 @@ export default defineNuxtModule({ } for (const layer of nuxt.options._layers) { - const path = await findPath(resolve(layer.config.srcDir, 'app/router.options')) + const path = await findPath(resolve(layer.config.srcDir, layer.config.dir?.app || 'app', 'router.options')) if (path) { context.files.unshift({ path }) } } @@ -86,8 +86,8 @@ export default defineNuxtModule({ const restartPaths = nuxt.options._layers.flatMap((layer) => { const pagesDir = (layer.config.rootDir === nuxt.options.rootDir ? nuxt.options : layer.config).dir?.pages || 'pages' return [ - join(layer.config.srcDir || layer.cwd, 'app/router.options.ts'), - join(layer.config.srcDir || layer.cwd, pagesDir), + resolve(layer.config.srcDir || layer.cwd, layer.config.dir?.app || 'app', 'router.options.ts'), + resolve(layer.config.srcDir || layer.cwd, pagesDir), ] }) @@ -228,9 +228,9 @@ export default defineNuxtModule({ const updateTemplatePaths = nuxt.options._layers.flatMap((l) => { const dir = (l.config.rootDir === nuxt.options.rootDir ? nuxt.options : l.config).dir return [ - join(l.config.srcDir || l.cwd, dir?.pages || 'pages') + '/', - join(l.config.srcDir || l.cwd, dir?.layouts || 'layouts') + '/', - join(l.config.srcDir || l.cwd, dir?.middleware || 'middleware') + '/', + resolve(l.config.srcDir || l.cwd, dir?.pages || 'pages') + '/', + resolve(l.config.srcDir || l.cwd, dir?.layouts || 'layouts') + '/', + resolve(l.config.srcDir || l.cwd, dir?.middleware || 'middleware') + '/', ] }) diff --git a/packages/schema/src/config/common.ts b/packages/schema/src/config/common.ts index e68a2d7302..f2d1eb65a1 100644 --- a/packages/schema/src/config/common.ts +++ b/packages/schema/src/config/common.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs' import { defineUntypedSchema } from 'untyped' import { join, relative, resolve } from 'pathe' import { isDebug, isDevelopment, isTest } from 'std-env' @@ -88,7 +89,32 @@ export default defineUntypedSchema({ * ``` */ srcDir: { - $resolve: async (val: string | undefined, get): Promise => resolve(await get('rootDir') as string, val || '.'), + $resolve: async (val: string | undefined, get): Promise => { + if (val) { + return resolve(await get('rootDir') as string, val) + } + + const [rootDir, isV4] = await Promise.all([ + get('rootDir') as Promise, + (get('future') as Promise>).then(r => r.compatibilityVersion === 4), + ]) + + if (!isV4) { + return rootDir + } + + const srcDir = resolve(rootDir, 'app') + if (!existsSync(srcDir)) { + const keys = ['assets', 'layouts', 'middleware', 'pages', 'plugins'] as const + const dirs = await Promise.all(keys.map(key => get(`dir.${key}`) as Promise)) + for (const dir of dirs) { + if (existsSync(resolve(rootDir, dir))) { + return rootDir + } + } + } + return srcDir + }, }, /** @@ -99,7 +125,11 @@ export default defineUntypedSchema({ * */ serverDir: { - $resolve: async (val: string | undefined, get): Promise => resolve(await get('rootDir') as string, val || resolve(await get('srcDir') as string, 'server')), + $resolve: async (val: string | undefined, get): Promise => { + const isV4 = ((await get('future') as Record).compatibilityVersion === 4) + + return resolve(await get('rootDir') as string, (val || isV4) ? 'server' : resolve(await get('srcDir') as string, 'server')) + }, }, /** @@ -219,6 +249,15 @@ export default defineUntypedSchema({ * It is better to stick with defaults unless needed. */ dir: { + app: { + $resolve: async (val: string | undefined, get) => { + const isV4 = (await get('future') as Record).compatibilityVersion === 4 + if (isV4) { + return resolve(await get('srcDir') as string, val || '.') + } + return val || 'app' + }, + }, /** * The assets directory (aliased as `~assets` in your build). */ @@ -237,7 +276,15 @@ export default defineUntypedSchema({ /** * The modules directory, each file in which will be auto-registered as a Nuxt module. */ - modules: 'modules', + modules: { + $resolve: async (val: string | undefined, get) => { + const isV4 = (await get('future') as Record).compatibilityVersion === 4 + if (isV4) { + return resolve(await get('rootDir') as string, val || 'modules') + } + return val || 'modules' + }, + }, /** * The directory which will be processed to auto-generate your application page routes. @@ -254,7 +301,13 @@ export default defineUntypedSchema({ * and copied across into your `dist` folder when your app is generated. */ public: { - $resolve: async (val, get) => val || await get('dir.static') || 'public', + $resolve: async (val: string | undefined, get) => { + const isV4 = (await get('future') as Record).compatibilityVersion === 4 + if (isV4) { + return resolve(await get('rootDir') as string, val || await get('dir.static') as string || 'public') + } + return val || await get('dir.static') as string || 'public' + }, }, static: { diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index 1e4a3da098..e98ca7feb1 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -21,6 +21,10 @@ export default defineUntypedSchema({ * compatibilityVersion: 4, * }, * // To re-enable _all_ Nuxt v3 behaviour, set the following options: + * srcDir: '.', + * dir: { + * app: 'app' + * }, * experimental: { * compileTemplate: true, * templateUtils: true, diff --git a/packages/schema/test/folder-structure.spec.ts b/packages/schema/test/folder-structure.spec.ts new file mode 100644 index 0000000000..3ea1e70c8b --- /dev/null +++ b/packages/schema/test/folder-structure.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { applyDefaults } from 'untyped' + +import { NuxtConfigSchema } from '../src' +import type { NuxtOptions } from '../src' + +describe('nuxt folder structure', () => { + it('should resolve directories for v3 setup correctly', async () => { + const result = await applyDefaults(NuxtConfigSchema, {}) + expect(getDirs(result as unknown as NuxtOptions)).toMatchInlineSnapshot(` + { + "dir": { + "app": "app", + "modules": "modules", + "public": "public", + }, + "rootDir": "", + "serverDir": "/server", + "srcDir": "", + "workspaceDir": "", + } + `) + }) + + it('should resolve directories with a custom `srcDir` and `rootDir`', async () => { + const result = await applyDefaults(NuxtConfigSchema, { srcDir: 'src/', rootDir: '/test' }) + expect(getDirs(result as unknown as NuxtOptions)).toMatchInlineSnapshot(` + { + "dir": { + "app": "app", + "modules": "modules", + "public": "public", + }, + "rootDir": "/test", + "serverDir": "/test/src/server", + "srcDir": "/test/src", + "workspaceDir": "/test", + } + `) + }) + + it('should resolve directories when opting-in to v4 schema', async () => { + const result = await applyDefaults(NuxtConfigSchema, { future: { compatibilityVersion: 4 } }) + expect(getDirs(result as unknown as NuxtOptions)).toMatchInlineSnapshot(` + { + "dir": { + "app": "/app", + "modules": "/modules", + "public": "/public", + }, + "rootDir": "", + "serverDir": "/server", + "srcDir": "/app", + "workspaceDir": "", + } + `) + }) + + it('should resolve directories when opting-in to v4 schema with a custom `srcDir` and `rootDir`', async () => { + const result = await applyDefaults(NuxtConfigSchema, { future: { compatibilityVersion: 4 }, srcDir: 'customApp/', rootDir: '/test' }) + expect(getDirs(result as unknown as NuxtOptions)).toMatchInlineSnapshot(` + { + "dir": { + "app": "/test/customApp", + "modules": "/test/modules", + "public": "/test/public", + }, + "rootDir": "/test", + "serverDir": "/test/server", + "srcDir": "/test/customApp", + "workspaceDir": "/test", + } + `) + }) +}) + +function getDirs (options: NuxtOptions) { + const stripRoot = (dir: string) => dir.replace(process.cwd(), '') + return { + rootDir: stripRoot(options.rootDir), + serverDir: stripRoot(options.serverDir), + srcDir: stripRoot(options.srcDir), + dir: { + app: stripRoot(options.dir.app), + modules: stripRoot(options.dir.modules), + public: stripRoot(options.dir.public), + }, + workspaceDir: stripRoot(options.workspaceDir!), + } +}