feat(nuxt): allow organising pages within route groups (#28276)

This commit is contained in:
Horu 2024-08-13 07:16:04 +10:00 committed by Daniel Roe
parent 5bf971f4ef
commit c4ae151de5
No known key found for this signature in database
GPG Key ID: CBC814C393D93268
10 changed files with 150 additions and 4 deletions

View File

@ -226,6 +226,22 @@ definePageMeta({
:link-example{to="/docs/examples/routing/pages"} :link-example{to="/docs/examples/routing/pages"}
## Route Groups
In some cases, you may want to group a set of routes together in a way which doesn't affect file-based routing. For this purpose, you can put files in a folder which is wrapped in parentheses - `(` and `)`.
For example:
```bash [Directory structure]
-| pages/
---| index.vue
---| (marketing)/
-----| about.vue
-----| contact.vue
```
This will produce `/`, `/about` and `/contact` pages in your app. The `marketing` group is ignored for purposes of your URL structure.
## Page Metadata ## Page Metadata
You might want to define metadata for each route in your app. You can do this using the `definePageMeta` macro, which will work both in `<script>` and in `<script setup>`: You might want to define metadata for each route in your app. You can do this using the `definePageMeta` macro, which will work both in `<script>` and in `<script setup>`:

View File

@ -23,6 +23,7 @@ enum SegmentParserState {
dynamic, dynamic,
optional, optional,
catchall, catchall,
group,
} }
enum SegmentTokenType { enum SegmentTokenType {
@ -30,6 +31,7 @@ enum SegmentTokenType {
dynamic, dynamic,
optional, optional,
catchall, catchall,
group,
} }
interface SegmentToken { interface SegmentToken {
@ -115,7 +117,13 @@ export function generateRoutesFromFiles (files: ScannedFile[], options: Generate
const segment = segments[i] const segment = segments[i]
const tokens = parseSegment(segment) const tokens = parseSegment(segment)
const segmentName = tokens.map(({ value }) => value).join('')
// Skip group segments
if (tokens.every(token => token.type === SegmentTokenType.group)) {
continue
}
const segmentName = tokens.map(({ value, type }) => type === SegmentTokenType.group ? '' : value).join('')
// ex: parent/[slug].vue -> parent-slug // ex: parent/[slug].vue -> parent-slug
route.name += (route.name && '/') + segmentName route.name += (route.name && '/') + segmentName
@ -298,7 +306,9 @@ function getRoutePath (tokens: SegmentToken[]): string {
? `:${token.value}()` ? `:${token.value}()`
: token.type === SegmentTokenType.catchall : token.type === SegmentTokenType.catchall
? `:${token.value}(.*)*` ? `:${token.value}(.*)*`
: encodePath(token.value).replace(/:/g, '\\:')) : token.type === SegmentTokenType.group
? ''
: encodePath(token.value).replace(/:/g, '\\:'))
) )
}, '/') }, '/')
} }
@ -328,7 +338,9 @@ function parseSegment (segment: string) {
? SegmentTokenType.dynamic ? SegmentTokenType.dynamic
: state === SegmentParserState.optional : state === SegmentParserState.optional
? SegmentTokenType.optional ? SegmentTokenType.optional
: SegmentTokenType.catchall, : state === SegmentParserState.catchall
? SegmentTokenType.catchall
: SegmentTokenType.group,
value: buffer, value: buffer,
}) })
@ -343,6 +355,8 @@ function parseSegment (segment: string) {
buffer = '' buffer = ''
if (c === '[') { if (c === '[') {
state = SegmentParserState.dynamic state = SegmentParserState.dynamic
} else if (c === '(') {
state = SegmentParserState.group
} else { } else {
i-- i--
state = SegmentParserState.static state = SegmentParserState.static
@ -353,6 +367,9 @@ function parseSegment (segment: string) {
if (c === '[') { if (c === '[') {
consumeBuffer() consumeBuffer()
state = SegmentParserState.dynamic state = SegmentParserState.dynamic
} else if (c === '(') {
consumeBuffer()
state = SegmentParserState.group
} else { } else {
buffer += c buffer += c
} }
@ -361,6 +378,7 @@ function parseSegment (segment: string) {
case SegmentParserState.catchall: case SegmentParserState.catchall:
case SegmentParserState.dynamic: case SegmentParserState.dynamic:
case SegmentParserState.optional: case SegmentParserState.optional:
case SegmentParserState.group:
if (buffer === '...') { if (buffer === '...') {
buffer = '' buffer = ''
state = SegmentParserState.catchall state = SegmentParserState.catchall
@ -375,10 +393,16 @@ function parseSegment (segment: string) {
consumeBuffer() consumeBuffer()
} }
state = SegmentParserState.initial state = SegmentParserState.initial
} else if (c === ')' && state === SegmentParserState.group) {
if (!buffer) {
throw new Error('Empty group')
} else {
consumeBuffer()
}
state = SegmentParserState.initial
} else if (PARAM_CHAR_RE.test(c)) { } else if (PARAM_CHAR_RE.test(c)) {
buffer += c buffer += c
} else { } else {
// console.debug(`[pages]Ignored character "${c}" while building param "${buffer}" from "segment"`) // console.debug(`[pages]Ignored character "${c}" while building param "${buffer}" from "segment"`)
} }
break break

View File

@ -339,6 +339,34 @@
"redirect": "mockMeta?.redirect", "redirect": "mockMeta?.redirect",
}, },
], ],
"should handle route groups": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/(foo)/index.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "index"",
"path": "mockMeta?.path ?? "/"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"children": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/(bar)/about/index.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "about"",
"path": "mockMeta?.path ?? """,
"redirect": "mockMeta?.redirect",
},
],
"component": "() => import("pages/(foo)/about.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? undefined",
"path": "mockMeta?.path ?? "/about"",
"redirect": "mockMeta?.redirect",
},
],
"should handle trailing slashes with index routes": [ "should handle trailing slashes with index routes": [
{ {
"alias": "mockMeta?.alias || []", "alias": "mockMeta?.alias || []",

View File

@ -234,6 +234,25 @@
"path": ""/parent"", "path": ""/parent"",
}, },
], ],
"should handle route groups": [
{
"component": "() => import("pages/(foo)/index.vue").then(m => m.default || m)",
"name": ""index"",
"path": ""/"",
},
{
"children": [
{
"component": "() => import("pages/(bar)/about/index.vue").then(m => m.default || m)",
"name": ""about"",
"path": """",
},
],
"component": "() => import("pages/(foo)/about.vue").then(m => m.default || m)",
"name": "mockMeta?.name",
"path": ""/about"",
},
],
"should handle trailing slashes with index routes": [ "should handle trailing slashes with index routes": [
{ {
"children": [ "children": [

View File

@ -601,6 +601,37 @@ describe('pages:generateRoutesFromFiles', () => {
}, },
], ],
}, },
{
description: 'should handle route groups',
files: [
{ path: `${pagesDir}/(foo)/index.vue` },
{ path: `${pagesDir}/(foo)/about.vue` },
{ path: `${pagesDir}/(bar)/about/index.vue` },
],
output: [
{
name: 'index',
path: '/',
file: `${pagesDir}/(foo)/index.vue`,
meta: undefined,
children: [],
},
{
path: '/about',
file: `${pagesDir}/(foo)/about.vue`,
meta: undefined,
children: [
{
name: 'about',
path: '',
file: `${pagesDir}/(bar)/about/index.vue`,
children: [],
},
],
},
],
},
] ]
const normalizedResults: Record<string, any> = {} const normalizedResults: Record<string, any> = {}

View File

@ -570,6 +570,14 @@ describe('pages', () => {
await normalInitialPage.close() await normalInitialPage.close()
}) })
it('groups routes', async () => {
for (const targetRoute of ['/group-page', '/nested-group/group-page', '/nested-group']) {
const { status } = await fetch(targetRoute)
expect(status).toBe(200)
}
})
}) })
describe('nuxt composables', () => { describe('nuxt composables', () => {

View File

@ -0,0 +1,5 @@
<template>
<div>
Hello from new group
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div>
Page deep in group
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div>
Index page of a group
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div>
Page deep, deep in group
</div>
</template>