diff --git a/examples/config-extends/base/middleware/foo.ts b/examples/config-extends/base/middleware/foo.ts new file mode 100644 index 0000000000..75a2c59ba7 --- /dev/null +++ b/examples/config-extends/base/middleware/foo.ts @@ -0,0 +1,3 @@ +export default defineNuxtRouteMiddleware(() => { + console.log('Hello from extended middleware !') +}) diff --git a/examples/config-extends/base/pages/foo.vue b/examples/config-extends/base/pages/foo.vue new file mode 100644 index 0000000000..b80089ec2b --- /dev/null +++ b/examples/config-extends/base/pages/foo.vue @@ -0,0 +1,11 @@ + + + diff --git a/examples/config-extends/app.vue b/examples/config-extends/pages/index.vue similarity index 100% rename from examples/config-extends/app.vue rename to examples/config-extends/pages/index.vue diff --git a/packages/nuxt3/src/pages/module.ts b/packages/nuxt3/src/pages/module.ts index 9d49959da7..fb8301413e 100644 --- a/packages/nuxt3/src/pages/module.ts +++ b/packages/nuxt3/src/pages/module.ts @@ -12,15 +12,18 @@ export default defineNuxtModule({ name: 'router' }, setup (_options, nuxt) { - const pagesDir = resolve(nuxt.options.srcDir, nuxt.options.dir.pages) - const runtimeDir = resolve(distDir, 'pages/runtime') + const pagesDirs = nuxt.options._layers.map( + layer => resolve(layer.config.srcDir, layer.config.dir?.pages || 'pages') + ) // Disable module (and use universal router) if pages dir do not exists - if (!existsSync(pagesDir)) { + if (!pagesDirs.some(dir => existsSync(dir))) { addPlugin(resolve(distDir, 'app/plugins/router')) return } + const runtimeDir = resolve(distDir, 'pages/runtime') + // Add $router types nuxt.hook('prepare:types', ({ references }) => { references.push({ types: 'vue-router' }) @@ -68,7 +71,7 @@ export default defineNuxtModule({ addTemplate({ filename: 'routes.mjs', async getContents () { - const pages = await resolvePagesRoutes(nuxt) + const pages = await resolvePagesRoutes() await nuxt.callHook('pages:extend', pages) const { routes, imports } = normalizeRoutes(pages) return [...imports, `export default ${routes}`].join('\n') @@ -99,6 +102,7 @@ export default defineNuxtModule({ filename: 'middleware.mjs', async getContents () { const middleware = await resolveMiddleware() + await nuxt.callHook('pages:middleware:extend', middleware) const globalMiddleware = middleware.filter(mw => mw.global) const namedMiddleware = middleware.filter(mw => !mw.global) const namedMiddlewareObject = genObjectFromRawEntries(namedMiddleware.map(mw => [mw.name, genDynamicImport(mw.path)])) diff --git a/packages/nuxt3/src/pages/utils.ts b/packages/nuxt3/src/pages/utils.ts index 8565f0482d..7d921bcb26 100644 --- a/packages/nuxt3/src/pages/utils.ts +++ b/packages/nuxt3/src/pages/utils.ts @@ -1,6 +1,6 @@ import { basename, extname, normalize, relative, resolve } from 'pathe' import { encodePath } from 'ufo' -import type { Nuxt, NuxtMiddleware, NuxtPage } from '@nuxt/schema' +import { NuxtMiddleware, NuxtPage } from '@nuxt/schema' import { resolveFiles, useNuxt } from '@nuxt/kit' import { kebabCase, pascalCase } from 'scule' import { genImport, genDynamicImport, genArrayFromRaw } from 'knitwork' @@ -24,14 +24,23 @@ interface SegmentToken { value: string } -export async function resolvePagesRoutes (nuxt: Nuxt) { - const pagesDir = resolve(nuxt.options.srcDir, nuxt.options.dir.pages) - const files = await resolveFiles(pagesDir, `**/*{${nuxt.options.extensions.join(',')}}`) +export async function resolvePagesRoutes (): Promise { + const nuxt = useNuxt() - // Sort to make sure parent are listed first - files.sort() + const pagesDirs = nuxt.options._layers.map( + layer => resolve(layer.config.srcDir, layer.config.dir?.pages || 'pages') + ) - return generateRoutesFromFiles(files, pagesDir) + const allRoutes = (await Promise.all( + pagesDirs.map(async (dir) => { + const files = await resolveFiles(dir, `**/*{${nuxt.options.extensions.join(',')}}`) + // Sort to make sure parent are listed first + files.sort() + return generateRoutesFromFiles(files, dir) + }) + )).flat() + + return uniqueBy(allRoutes, 'name') } export function generateRoutesFromFiles (files: string[], pagesDir: string): NuxtPage[] { @@ -236,11 +245,19 @@ export function normalizeRoutes (routes: NuxtPage[], metaImports: Set = export async function resolveMiddleware (): Promise { const nuxt = useNuxt() - const middlewareDir = resolve(nuxt.options.srcDir, nuxt.options.dir.middleware) - const files = await resolveFiles(middlewareDir, `*{${nuxt.options.extensions.join(',')}}`) - const middleware = files.map(path => ({ name: getNameFromPath(path), path, global: hasSuffix(path, '.global') })) - await nuxt.callHook('pages:middleware:extend', middleware) - return middleware + + const middlewareDirs = nuxt.options._layers.map( + layer => resolve(layer.config.srcDir, layer.config.dir?.middleware || 'middleware') + ) + + const allMiddlewares = (await Promise.all( + middlewareDirs.map(async (dir) => { + const files = await resolveFiles(dir, `*{${nuxt.options.extensions.join(',')}}`) + return files.map(path => ({ name: getNameFromPath(path), path, global: hasSuffix(path, '.global') })) + }) + )).flat() + + return uniqueBy(allMiddlewares, 'name') } function getNameFromPath (path: string) { @@ -254,3 +271,16 @@ function hasSuffix (path: string, suffix: string) { export function getImportName (name: string) { return pascalCase(name).replace(/[^\w]/g, '') } + +function uniqueBy (arr: any[], key: string) { + const res = [] + const keys = new Set() + for (const item of arr) { + if (keys.has(item[key])) { + continue + } + keys.add(item[key]) + res.push(item) + } + return res +} diff --git a/test/basic.test.ts b/test/basic.test.ts index a8183ed27d..62ac6814ec 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -2,158 +2,182 @@ import { fileURLToPath } from 'url' import { describe, expect, it } from 'vitest' import { setup, $fetch, startServer } from '@nuxt/test-utils' -describe('fixtures:basic', async () => { - await setup({ - rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)), - server: true - }) +await setup({ + rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)), + server: true +}) - describe('server api', () => { - it('should serialize', async () => { - expect(await $fetch('/api/hello')).toBe('Hello API') - expect(await $fetch('/api/hey')).toEqual({ - foo: 'bar', - baz: 'qux' - }) - }) - - it('should preserve states', async () => { - expect(await $fetch('/api/counter')).toEqual({ count: 0 }) - expect(await $fetch('/api/counter')).toEqual({ count: 1 }) - expect(await $fetch('/api/counter')).toEqual({ count: 2 }) - expect(await $fetch('/api/counter')).toEqual({ count: 3 }) +describe('server api', () => { + it('should serialize', async () => { + expect(await $fetch('/api/hello')).toBe('Hello API') + expect(await $fetch('/api/hey')).toEqual({ + foo: 'bar', + baz: 'qux' }) }) + it('should preserve states', async () => { + expect(await $fetch('/api/counter')).toEqual({ count: 0 }) + expect(await $fetch('/api/counter')).toEqual({ count: 1 }) + expect(await $fetch('/api/counter')).toEqual({ count: 2 }) + expect(await $fetch('/api/counter')).toEqual({ count: 3 }) + }) +}) + +describe('pages', () => { + it('render index', async () => { + const html = await $fetch('/') + + // Snapshot + // expect(html).toMatchInlineSnapshot() + + // should render text + expect(html).toContain('Hello Nuxt 3!') + // should render components + expect(html).toContain('Basic fixture') + // should inject runtime config + expect(html).toContain('RuntimeConfig | testConfig: 123') + // composables auto import + expect(html).toContain('Composable | foo: auto imported from ~/components/foo.ts') + expect(html).toContain('Composable | bar: auto imported from ~/components/useBar.ts') + // plugins + expect(html).toContain('Plugin | myPlugin: Injected by my-plugin') + // should import components + expect(html).toContain('This is a custom component with a named export.') + }) + + it('render 404', async () => { + const html = await $fetch('/not-found') + + // Snapshot + // expect(html).toMatchInlineSnapshot() + + expect(html).toContain('[...slug].vue') + expect(html).toContain('404 at not-found') + }) + + it('/nested/[foo]/[bar].vue', async () => { + const html = await $fetch('/nested/one/two') + + // Snapshot + // expect(html).toMatchInlineSnapshot() + + expect(html).toContain('nested/[foo]/[bar].vue') + expect(html).toContain('foo: one') + expect(html).toContain('bar: two') + }) + + it('/nested/[foo]/index.vue', async () => { + const html = await $fetch('/nested/foobar') + + // TODO: should resolved to same entry + // const html2 = await $fetch('/nested/foobar/index') + // expect(html).toEqual(html2) + + // Snapshot + // expect(html).toMatchInlineSnapshot() + + expect(html).toContain('nested/[foo]/index.vue') + expect(html).toContain('foo: foobar') + }) + + it('/nested/[foo]/user-[group].vue', async () => { + const html = await $fetch('/nested/foobar/user-admin') + + // Snapshot + // expect(html).toMatchInlineSnapshot() + + expect(html).toContain('nested/[foo]/user-[group].vue') + expect(html).toContain('foo: foobar') + expect(html).toContain('group: admin') + }) +}) + +describe('navigate', () => { + it('should redirect to index with navigateTo', async () => { + const html = await $fetch('/navigate-to/') + + // Snapshot + // expect(html).toMatchInlineSnapshot() + + expect(html).toContain('Hello Nuxt 3!') + }) +}) + +describe('middlewares', () => { + it('should redirect to index with global middleware', async () => { + const html = await $fetch('/redirect/') + + // Snapshot + // expect(html).toMatchInlineSnapshot() + + expect(html).toContain('Hello Nuxt 3!') + }) + + it('should inject auth', async () => { + const html = await $fetch('/auth') + + // Snapshot + // expect(html).toMatchInlineSnapshot() + + expect(html).toContain('auth.vue') + expect(html).toContain('auth: Injected by injectAuth middleware') + }) + + it('should not inject auth', async () => { + const html = await $fetch('/no-auth') + + // Snapshot + // expect(html).toMatchInlineSnapshot() + + expect(html).toContain('no-auth.vue') + expect(html).toContain('auth: ') + expect(html).not.toContain('Injected by injectAuth middleware') + }) +}) + +describe('layouts', () => { + it('should apply custom layout', async () => { + const html = await $fetch('/with-layout') + + // Snapshot + // expect(html).toMatchInlineSnapshot() + + expect(html).toContain('with-layout.vue') + expect(html).toContain('Custom Layout:') + }) +}) + +describe('reactivity transform', () => { + it('should works', async () => { + const html = await $fetch('/') + + expect(html).toContain('Sugar Counter 12 x 2 = 24') + }) +}) + +describe('extends support', () => { describe('pages', () => { - it('render index', async () => { - const html = await $fetch('/') - - // Snapshot - // expect(html).toMatchInlineSnapshot() - - // should render text - expect(html).toContain('Hello Nuxt 3!') - // should render components - expect(html).toContain('Basic fixture') - // should inject runtime config - expect(html).toContain('RuntimeConfig | testConfig: 123') - // composables auto import - expect(html).toContain('Composable | foo: auto imported from ~/components/foo.ts') - expect(html).toContain('Composable | bar: auto imported from ~/components/useBar.ts') - // plugins - expect(html).toContain('Plugin | myPlugin: Injected by my-plugin') - // should import components - expect(html).toContain('This is a custom component with a named export.') + it('extends foo/pages/index.vue', async () => { + const html = await $fetch('/foo') + expect(html).toContain('Hello from extended page of foo!') }) - it('render 404', async () => { - const html = await $fetch('/not-found') - - // Snapshot - // expect(html).toMatchInlineSnapshot() - - expect(html).toContain('[...slug].vue') - expect(html).toContain('404 at not-found') - }) - - it('/nested/[foo]/[bar].vue', async () => { - const html = await $fetch('/nested/one/two') - - // Snapshot - // expect(html).toMatchInlineSnapshot() - - expect(html).toContain('nested/[foo]/[bar].vue') - expect(html).toContain('foo: one') - expect(html).toContain('bar: two') - }) - - it('/nested/[foo]/index.vue', async () => { - const html = await $fetch('/nested/foobar') - - // TODO: should resolved to same entry - // const html2 = await $fetch('/nested/foobar/index') - // expect(html).toEqual(html2) - - // Snapshot - // expect(html).toMatchInlineSnapshot() - - expect(html).toContain('nested/[foo]/index.vue') - expect(html).toContain('foo: foobar') - }) - - it('/nested/[foo]/user-[group].vue', async () => { - const html = await $fetch('/nested/foobar/user-admin') - - // Snapshot - // expect(html).toMatchInlineSnapshot() - - expect(html).toContain('nested/[foo]/user-[group].vue') - expect(html).toContain('foo: foobar') - expect(html).toContain('group: admin') - }) - }) - - describe('navigate', () => { - it('should redirect to index with navigateTo', async () => { - const html = await $fetch('/navigate-to/') - - // Snapshot - // expect(html).toMatchInlineSnapshot() - - expect(html).toContain('Hello Nuxt 3!') + it('extends bar/pages/override.vue over foo/pages/override.vue', async () => { + const html = await $fetch('/override') + expect(html).toContain('Extended page from bar') }) }) describe('middlewares', () => { - it('should redirect to index with global middleware', async () => { - const html = await $fetch('/redirect/') - - // Snapshot - // expect(html).toMatchInlineSnapshot() - - expect(html).toContain('Hello Nuxt 3!') + it('extends foo/middleware/foo', async () => { + const html = await $fetch('/with-middleware') + expect(html).toContain('Injected by extended middleware') }) - it('should inject auth', async () => { - const html = await $fetch('/auth') - - // Snapshot - // expect(html).toMatchInlineSnapshot() - - expect(html).toContain('auth.vue') - expect(html).toContain('auth: Injected by injectAuth middleware') - }) - - it('should not inject auth', async () => { - const html = await $fetch('/no-auth') - - // Snapshot - // expect(html).toMatchInlineSnapshot() - - expect(html).toContain('no-auth.vue') - expect(html).toContain('auth: ') - expect(html).not.toContain('Injected by injectAuth middleware') - }) - }) - - describe('layouts', () => { - it('should apply custom layout', async () => { - const html = await $fetch('/with-layout') - - // Snapshot - // expect(html).toMatchInlineSnapshot() - - expect(html).toContain('with-layout.vue') - expect(html).toContain('Custom Layout:') - }) - }) - - describe('reactivity transform', () => { - it('should works', async () => { - const html = await $fetch('/') - - expect(html).toContain('Sugar Counter 12 x 2 = 24') + it('extends bar/middleware/override.vue over foo/middleware/override.vue', async () => { + const html = await $fetch('/with-middleware-override') + expect(html).toContain('Injected by extended middleware from bar') }) }) diff --git a/test/fixtures/basic/extends/bar/middleware/override.ts b/test/fixtures/basic/extends/bar/middleware/override.ts new file mode 100644 index 0000000000..00edf78124 --- /dev/null +++ b/test/fixtures/basic/extends/bar/middleware/override.ts @@ -0,0 +1,3 @@ +export default defineNuxtRouteMiddleware((to) => { + to.meta.override = 'Injected by extended middleware from bar' +}) diff --git a/test/fixtures/basic/extends/bar/nuxt.config.ts b/test/fixtures/basic/extends/bar/nuxt.config.ts new file mode 100644 index 0000000000..9c8174d1a0 --- /dev/null +++ b/test/fixtures/basic/extends/bar/nuxt.config.ts @@ -0,0 +1,3 @@ +import { defineNuxtConfig } from 'nuxt3' + +export default defineNuxtConfig({}) diff --git a/test/fixtures/basic/extends/bar/pages/override.vue b/test/fixtures/basic/extends/bar/pages/override.vue new file mode 100644 index 0000000000..563da71769 --- /dev/null +++ b/test/fixtures/basic/extends/bar/pages/override.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/basic/extends/foo/middleware/foo.ts b/test/fixtures/basic/extends/foo/middleware/foo.ts new file mode 100644 index 0000000000..3be035596f --- /dev/null +++ b/test/fixtures/basic/extends/foo/middleware/foo.ts @@ -0,0 +1,3 @@ +export default defineNuxtRouteMiddleware((to) => { + to.meta.foo = 'Injected by extended middleware' +}) diff --git a/test/fixtures/basic/extends/foo/middleware/override.ts b/test/fixtures/basic/extends/foo/middleware/override.ts new file mode 100644 index 0000000000..cd50286df4 --- /dev/null +++ b/test/fixtures/basic/extends/foo/middleware/override.ts @@ -0,0 +1,3 @@ +export default defineNuxtRouteMiddleware((to) => { + to.meta.override = 'Injected by extended middleware from foo' +}) diff --git a/test/fixtures/basic/extends/foo/nuxt.config.ts b/test/fixtures/basic/extends/foo/nuxt.config.ts new file mode 100644 index 0000000000..9c8174d1a0 --- /dev/null +++ b/test/fixtures/basic/extends/foo/nuxt.config.ts @@ -0,0 +1,3 @@ +import { defineNuxtConfig } from 'nuxt3' + +export default defineNuxtConfig({}) diff --git a/test/fixtures/basic/extends/foo/pages/foo.vue b/test/fixtures/basic/extends/foo/pages/foo.vue new file mode 100644 index 0000000000..7004d87963 --- /dev/null +++ b/test/fixtures/basic/extends/foo/pages/foo.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/basic/extends/foo/pages/override.vue b/test/fixtures/basic/extends/foo/pages/override.vue new file mode 100644 index 0000000000..e47e98b465 --- /dev/null +++ b/test/fixtures/basic/extends/foo/pages/override.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/basic/extends/foo/pages/with-middleware-override.vue b/test/fixtures/basic/extends/foo/pages/with-middleware-override.vue new file mode 100644 index 0000000000..4bce0a0d1c --- /dev/null +++ b/test/fixtures/basic/extends/foo/pages/with-middleware-override.vue @@ -0,0 +1,9 @@ + + + diff --git a/test/fixtures/basic/extends/foo/pages/with-middleware.vue b/test/fixtures/basic/extends/foo/pages/with-middleware.vue new file mode 100644 index 0000000000..d4ebb55161 --- /dev/null +++ b/test/fixtures/basic/extends/foo/pages/with-middleware.vue @@ -0,0 +1,9 @@ + + + diff --git a/test/fixtures/basic/nuxt.config.ts b/test/fixtures/basic/nuxt.config.ts index 738ee03741..b2eed60cc2 100644 --- a/test/fixtures/basic/nuxt.config.ts +++ b/test/fixtures/basic/nuxt.config.ts @@ -4,6 +4,10 @@ import { addComponent } from '@nuxt/kit' export default defineNuxtConfig({ buildDir: process.env.NITRO_BUILD_DIR, builder: process.env.TEST_WITH_WEBPACK ? 'webpack' : 'vite', + extends: [ + './extends/bar', + './extends/foo' + ], nitro: { output: { dir: process.env.NITRO_OUTPUT_DIR } },