feat(nuxt3): extends support for pages & middleware directories (#3783)

Co-authored-by: Pooya Parsa <pyapar@gmail.com>
This commit is contained in:
Kevin Marrec 2022-03-22 19:12:54 +01:00 committed by GitHub
parent 29078bba74
commit 7c0d2e176c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 273 additions and 155 deletions

View File

@ -0,0 +1,3 @@
export default defineNuxtRouteMiddleware(() => {
console.log('Hello from extended middleware !')
})

View File

@ -0,0 +1,11 @@
<template>
<div>
Hello from extended page !
</div>
</template>
<script setup>
definePageMeta({
middleware: 'foo'
})
</script>

View File

@ -12,15 +12,18 @@ export default defineNuxtModule({
name: 'router' name: 'router'
}, },
setup (_options, nuxt) { setup (_options, nuxt) {
const pagesDir = resolve(nuxt.options.srcDir, nuxt.options.dir.pages) const pagesDirs = nuxt.options._layers.map(
const runtimeDir = resolve(distDir, 'pages/runtime') layer => resolve(layer.config.srcDir, layer.config.dir?.pages || 'pages')
)
// Disable module (and use universal router) if pages dir do not exists // 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')) addPlugin(resolve(distDir, 'app/plugins/router'))
return return
} }
const runtimeDir = resolve(distDir, 'pages/runtime')
// Add $router types // Add $router types
nuxt.hook('prepare:types', ({ references }) => { nuxt.hook('prepare:types', ({ references }) => {
references.push({ types: 'vue-router' }) references.push({ types: 'vue-router' })
@ -68,7 +71,7 @@ export default defineNuxtModule({
addTemplate({ addTemplate({
filename: 'routes.mjs', filename: 'routes.mjs',
async getContents () { async getContents () {
const pages = await resolvePagesRoutes(nuxt) const pages = await resolvePagesRoutes()
await nuxt.callHook('pages:extend', pages) await nuxt.callHook('pages:extend', pages)
const { routes, imports } = normalizeRoutes(pages) const { routes, imports } = normalizeRoutes(pages)
return [...imports, `export default ${routes}`].join('\n') return [...imports, `export default ${routes}`].join('\n')
@ -99,6 +102,7 @@ export default defineNuxtModule({
filename: 'middleware.mjs', filename: 'middleware.mjs',
async getContents () { async getContents () {
const middleware = await resolveMiddleware() const middleware = await resolveMiddleware()
await nuxt.callHook('pages:middleware:extend', middleware)
const globalMiddleware = middleware.filter(mw => mw.global) const globalMiddleware = middleware.filter(mw => mw.global)
const namedMiddleware = middleware.filter(mw => !mw.global) const namedMiddleware = middleware.filter(mw => !mw.global)
const namedMiddlewareObject = genObjectFromRawEntries(namedMiddleware.map(mw => [mw.name, genDynamicImport(mw.path)])) const namedMiddlewareObject = genObjectFromRawEntries(namedMiddleware.map(mw => [mw.name, genDynamicImport(mw.path)]))

View File

@ -1,6 +1,6 @@
import { basename, extname, normalize, relative, resolve } from 'pathe' import { basename, extname, normalize, relative, resolve } from 'pathe'
import { encodePath } from 'ufo' 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 { resolveFiles, useNuxt } from '@nuxt/kit'
import { kebabCase, pascalCase } from 'scule' import { kebabCase, pascalCase } from 'scule'
import { genImport, genDynamicImport, genArrayFromRaw } from 'knitwork' import { genImport, genDynamicImport, genArrayFromRaw } from 'knitwork'
@ -24,14 +24,23 @@ interface SegmentToken {
value: string value: string
} }
export async function resolvePagesRoutes (nuxt: Nuxt) { export async function resolvePagesRoutes (): Promise<NuxtPage[]> {
const pagesDir = resolve(nuxt.options.srcDir, nuxt.options.dir.pages) const nuxt = useNuxt()
const files = await resolveFiles(pagesDir, `**/*{${nuxt.options.extensions.join(',')}}`)
// Sort to make sure parent are listed first const pagesDirs = nuxt.options._layers.map(
files.sort() 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[] { 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[]> { export async function resolveMiddleware (): Promise<NuxtMiddleware[]> {
const nuxt = useNuxt() const nuxt = useNuxt()
const middlewareDir = resolve(nuxt.options.srcDir, nuxt.options.dir.middleware)
const files = await resolveFiles(middlewareDir, `*{${nuxt.options.extensions.join(',')}}`) const middlewareDirs = nuxt.options._layers.map(
const middleware = files.map(path => ({ name: getNameFromPath(path), path, global: hasSuffix(path, '.global') })) layer => resolve(layer.config.srcDir, layer.config.dir?.middleware || 'middleware')
await nuxt.callHook('pages:middleware:extend', middleware) )
return 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) { function getNameFromPath (path: string) {
@ -254,3 +271,16 @@ function hasSuffix (path: string, suffix: string) {
export function getImportName (name: string) { export function getImportName (name: string) {
return pascalCase(name).replace(/[^\w]/g, '') 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
}

View File

@ -2,158 +2,182 @@ import { fileURLToPath } from 'url'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { setup, $fetch, startServer } from '@nuxt/test-utils' import { setup, $fetch, startServer } from '@nuxt/test-utils'
describe('fixtures:basic', async () => { await setup({
await setup({ rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)),
rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)), server: true
server: true })
})
describe('server api', () => { describe('server api', () => {
it('should serialize', async () => { it('should serialize', async () => {
expect(await $fetch('/api/hello')).toBe('Hello API') expect(await $fetch('/api/hello')).toBe('Hello API')
expect(await $fetch('/api/hey')).toEqual({ expect(await $fetch('/api/hey')).toEqual({
foo: 'bar', foo: 'bar',
baz: 'qux' 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 })
}) })
}) })
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', () => { describe('pages', () => {
it('render index', async () => { it('extends foo/pages/index.vue', async () => {
const html = await $fetch('/') const html = await $fetch('/foo')
expect(html).toContain('Hello from extended page of foo!')
// 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 () => { it('extends bar/pages/override.vue over foo/pages/override.vue', async () => {
const html = await $fetch('/not-found') const html = await $fetch('/override')
expect(html).toContain('Extended page from bar')
// 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', () => { describe('middlewares', () => {
it('should redirect to index with global middleware', async () => { it('extends foo/middleware/foo', async () => {
const html = await $fetch('/redirect/') const html = await $fetch('/with-middleware')
expect(html).toContain('Injected by extended middleware')
// Snapshot
// expect(html).toMatchInlineSnapshot()
expect(html).toContain('Hello Nuxt 3!')
}) })
it('should inject auth', async () => { it('extends bar/middleware/override.vue over foo/middleware/override.vue', async () => {
const html = await $fetch('/auth') const html = await $fetch('/with-middleware-override')
expect(html).toContain('Injected by extended middleware from bar')
// 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')
}) })
}) })

View File

@ -0,0 +1,3 @@
export default defineNuxtRouteMiddleware((to) => {
to.meta.override = 'Injected by extended middleware from bar'
})

View File

@ -0,0 +1,3 @@
import { defineNuxtConfig } from 'nuxt3'
export default defineNuxtConfig({})

View File

@ -0,0 +1,3 @@
<template>
<div>Extended page from bar</div>
</template>

View File

@ -0,0 +1,3 @@
export default defineNuxtRouteMiddleware((to) => {
to.meta.foo = 'Injected by extended middleware'
})

View File

@ -0,0 +1,3 @@
export default defineNuxtRouteMiddleware((to) => {
to.meta.override = 'Injected by extended middleware from foo'
})

View File

@ -0,0 +1,3 @@
import { defineNuxtConfig } from 'nuxt3'
export default defineNuxtConfig({})

View File

@ -0,0 +1,3 @@
<template>
<div>Hello from extended page of foo!</div>
</template>

View File

@ -0,0 +1,3 @@
<template>
<div>Extended page from foo</div>
</template>

View File

@ -0,0 +1,9 @@
<script setup>
definePageMeta({
middleware: 'override'
})
</script>
<template>
<div>{{ $route.meta.override }}</div>
</template>

View File

@ -0,0 +1,9 @@
<script setup>
definePageMeta({
middleware: 'foo'
})
</script>
<template>
<div>{{ $route.meta.foo }}</div>
</template>

View File

@ -4,6 +4,10 @@ import { addComponent } from '@nuxt/kit'
export default defineNuxtConfig({ export default defineNuxtConfig({
buildDir: process.env.NITRO_BUILD_DIR, buildDir: process.env.NITRO_BUILD_DIR,
builder: process.env.TEST_WITH_WEBPACK ? 'webpack' : 'vite', builder: process.env.TEST_WITH_WEBPACK ? 'webpack' : 'vite',
extends: [
'./extends/bar',
'./extends/foo'
],
nitro: { nitro: {
output: { dir: process.env.NITRO_OUTPUT_DIR } output: { dir: process.env.NITRO_OUTPUT_DIR }
}, },