mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-25 15:15:19 +00:00
feat(nuxt3): extends support for pages
& middleware
directories (#3783)
Co-authored-by: Pooya Parsa <pyapar@gmail.com>
This commit is contained in:
parent
29078bba74
commit
7c0d2e176c
3
examples/config-extends/base/middleware/foo.ts
Normal file
3
examples/config-extends/base/middleware/foo.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export default defineNuxtRouteMiddleware(() => {
|
||||
console.log('Hello from extended middleware !')
|
||||
})
|
11
examples/config-extends/base/pages/foo.vue
Normal file
11
examples/config-extends/base/pages/foo.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div>
|
||||
Hello from extended page !
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: 'foo'
|
||||
})
|
||||
</script>
|
@ -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)]))
|
||||
|
@ -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<NuxtPage[]> {
|
||||
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<string> =
|
||||
|
||||
export async function resolveMiddleware (): Promise<NuxtMiddleware[]> {
|
||||
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<string>()
|
||||
for (const item of arr) {
|
||||
if (keys.has(item[key])) {
|
||||
continue
|
||||
}
|
||||
keys.add(item[key])
|
||||
res.push(item)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
@ -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 <Head> components
|
||||
expect(html).toContain('<title>Basic fixture</title>')
|
||||
// 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 <Head> components
|
||||
expect(html).toContain('<title>Basic fixture</title>')
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
|
||||
|
3
test/fixtures/basic/extends/bar/middleware/override.ts
vendored
Normal file
3
test/fixtures/basic/extends/bar/middleware/override.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
to.meta.override = 'Injected by extended middleware from bar'
|
||||
})
|
3
test/fixtures/basic/extends/bar/nuxt.config.ts
vendored
Normal file
3
test/fixtures/basic/extends/bar/nuxt.config.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
import { defineNuxtConfig } from 'nuxt3'
|
||||
|
||||
export default defineNuxtConfig({})
|
3
test/fixtures/basic/extends/bar/pages/override.vue
vendored
Normal file
3
test/fixtures/basic/extends/bar/pages/override.vue
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div>Extended page from bar</div>
|
||||
</template>
|
3
test/fixtures/basic/extends/foo/middleware/foo.ts
vendored
Normal file
3
test/fixtures/basic/extends/foo/middleware/foo.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
to.meta.foo = 'Injected by extended middleware'
|
||||
})
|
3
test/fixtures/basic/extends/foo/middleware/override.ts
vendored
Normal file
3
test/fixtures/basic/extends/foo/middleware/override.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
to.meta.override = 'Injected by extended middleware from foo'
|
||||
})
|
3
test/fixtures/basic/extends/foo/nuxt.config.ts
vendored
Normal file
3
test/fixtures/basic/extends/foo/nuxt.config.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
import { defineNuxtConfig } from 'nuxt3'
|
||||
|
||||
export default defineNuxtConfig({})
|
3
test/fixtures/basic/extends/foo/pages/foo.vue
vendored
Normal file
3
test/fixtures/basic/extends/foo/pages/foo.vue
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div>Hello from extended page of foo!</div>
|
||||
</template>
|
3
test/fixtures/basic/extends/foo/pages/override.vue
vendored
Normal file
3
test/fixtures/basic/extends/foo/pages/override.vue
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div>Extended page from foo</div>
|
||||
</template>
|
9
test/fixtures/basic/extends/foo/pages/with-middleware-override.vue
vendored
Normal file
9
test/fixtures/basic/extends/foo/pages/with-middleware-override.vue
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: 'override'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>{{ $route.meta.override }}</div>
|
||||
</template>
|
9
test/fixtures/basic/extends/foo/pages/with-middleware.vue
vendored
Normal file
9
test/fixtures/basic/extends/foo/pages/with-middleware.vue
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: 'foo'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>{{ $route.meta.foo }}</div>
|
||||
</template>
|
4
test/fixtures/basic/nuxt.config.ts
vendored
4
test/fixtures/basic/nuxt.config.ts
vendored
@ -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 }
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user