diff --git a/playground/src/App.vue b/playground/src/App.vue
index a18a862ed..b1b5e8bb9 100644
--- a/playground/src/App.vue
+++ b/playground/src/App.vue
@@ -61,6 +61,9 @@ function _test() {
Home
+
+ Group (thing.vue)
+
{{ href }}
diff --git a/playground/src/pages/(test-group)/test-group-child(treated-as-static).vue b/playground/src/pages/(test-group)/test-group-child(treated-as-static).vue
new file mode 100644
index 000000000..7a9dff7e3
--- /dev/null
+++ b/playground/src/pages/(test-group)/test-group-child(treated-as-static).vue
@@ -0,0 +1,3 @@
+
+ test group child (resolves to root, treated as static)
+
diff --git a/playground/src/pages/(test-group)/test-group-child.vue b/playground/src/pages/(test-group)/test-group-child.vue
new file mode 100644
index 000000000..fa79d0f72
--- /dev/null
+++ b/playground/src/pages/(test-group)/test-group-child.vue
@@ -0,0 +1,3 @@
+
+ Test group child (resolves to root)
+
diff --git a/playground/src/pages/file(ignored-parentheses).vue b/playground/src/pages/file(ignored-parentheses).vue
new file mode 100644
index 000000000..000058e1b
--- /dev/null
+++ b/playground/src/pages/file(ignored-parentheses).vue
@@ -0,0 +1,3 @@
+
+ file(ignored-brackets)
+
diff --git a/playground/src/pages/group/(thing).vue b/playground/src/pages/group/(thing).vue
new file mode 100644
index 000000000..694225f46
--- /dev/null
+++ b/playground/src/pages/group/(thing).vue
@@ -0,0 +1,3 @@
+
+ (thing).vue - Parentheses are ignored and this file becomes the index
+
diff --git a/playground/src/pages/nested-group/(group).vue b/playground/src/pages/nested-group/(group).vue
new file mode 100644
index 000000000..a235083e7
--- /dev/null
+++ b/playground/src/pages/nested-group/(group).vue
@@ -0,0 +1,3 @@
+
+ (group).vue
+
diff --git a/playground/src/pages/nested-group/(nested-group-first-level)/(nested-group-deep)/nested-group-deep-child.vue b/playground/src/pages/nested-group/(nested-group-first-level)/(nested-group-deep)/nested-group-deep-child.vue
new file mode 100644
index 000000000..9f0cf295e
--- /dev/null
+++ b/playground/src/pages/nested-group/(nested-group-first-level)/(nested-group-deep)/nested-group-deep-child.vue
@@ -0,0 +1,3 @@
+
+ Nested group deep child (resolves to nested-group)
+
diff --git a/playground/src/pages/nested-group/(nested-group-first-level)/nested-group-first-level-child.vue b/playground/src/pages/nested-group/(nested-group-first-level)/nested-group-first-level-child.vue
new file mode 100644
index 000000000..35ff6c475
--- /dev/null
+++ b/playground/src/pages/nested-group/(nested-group-first-level)/nested-group-first-level-child.vue
@@ -0,0 +1,3 @@
+
+ Nested group first level child (resolves to nested group)
+
diff --git a/src/codegen/generateRouteMap.spec.ts b/src/codegen/generateRouteMap.spec.ts
index 6afb60977..b2335c036 100644
--- a/src/codegen/generateRouteMap.spec.ts
+++ b/src/codegen/generateRouteMap.spec.ts
@@ -181,6 +181,42 @@ describe('generateRouteNamedMap', () => {
}"
`)
})
+
+ it('ignores folder names in parentheses', () => {
+ const tree = new PrefixTree(DEFAULT_OPTIONS)
+
+ tree.insert('(group)/a', 'a.vue')
+
+ expect(formatExports(generateRouteNamedMap(tree))).toMatchInlineSnapshot(`
+ "export interface RouteNamedMap {
+ '/(group)/a': RouteRecordInfo<'/(group)/a', '/a', Record, Record>,
+ }"
+ `)
+ })
+
+ it('ignores nested folder names in parentheses', () => {
+ const tree = new PrefixTree(DEFAULT_OPTIONS)
+
+ tree.insert('(group)/(subgroup)/c', 'c.vue')
+
+ expect(formatExports(generateRouteNamedMap(tree))).toMatchInlineSnapshot(`
+ "export interface RouteNamedMap {
+ '/(group)/(subgroup)/c': RouteRecordInfo<'/(group)/(subgroup)/c', '/c', Record, Record>,
+ }"
+ `)
+ })
+
+ it('treats files named with parentheses as index inside static folder', () => {
+ const tree = new PrefixTree(DEFAULT_OPTIONS)
+
+ tree.insert('folder/(group)', 'folder/(group).vue')
+
+ expect(formatExports(generateRouteNamedMap(tree))).toMatchInlineSnapshot(`
+ "export interface RouteNamedMap {
+ '/folder/(group)': RouteRecordInfo<'/folder/(group)', '/folder', Record, Record>,
+ }"
+ `)
+ })
})
/**
@@ -193,4 +229,8 @@ describe('generateRouteNamedMap', () => {
* /static/...[param].vue -> /static/:param+
* /static/...[[param]].vue -> /static/:param*
* /static/...[[...param]].vue -> /static/:param(.*)*
+ * /(group)/a.vue -> /a
+ * /(group)/(subgroup)/c.vue -> /c
+ * /folder/(group).vue -> /folder
+ * /(home).vue -> /
*/
diff --git a/src/core/tree.spec.ts b/src/core/tree.spec.ts
index 266a0962f..01b347145 100644
--- a/src/core/tree.spec.ts
+++ b/src/core/tree.spec.ts
@@ -3,9 +3,12 @@ import { DEFAULT_OPTIONS, resolveOptions } from '../options'
import { PrefixTree } from './tree'
import { TreeNodeType } from './treeNodeValue'
import { resolve } from 'pathe'
+import { mockWarn } from '../../tests/vitest-mock-warn'
describe('Tree', () => {
const RESOLVED_OPTIONS = resolveOptions(DEFAULT_OPTIONS)
+ mockWarn()
+
it('creates an empty tree', () => {
const tree = new PrefixTree(RESOLVED_OPTIONS)
expect(tree.children.size).toBe(0)
@@ -437,6 +440,81 @@ describe('Tree', () => {
expect(child.fullPath).toBe('/a')
})
+ it('strips groups from file paths', () => {
+ const tree = new PrefixTree(RESOLVED_OPTIONS)
+ tree.insert('(home)', '(home).vue')
+ let child = tree.children.get('(home)')!
+ expect(child).toBeDefined()
+ expect(child.path).toBe('/')
+ expect(child.fullPath).toBe('/')
+ })
+
+ it('strips groups from nested file paths', () => {
+ const tree = new PrefixTree(RESOLVED_OPTIONS)
+ tree.insert('nested/(home)', 'nested/(home).vue')
+ let child = tree.children.get('nested')!
+ expect(child).toBeDefined()
+
+ child = child.children.get('(home)')!
+ expect(child).toBeDefined()
+ expect(child.path).toBe('')
+ expect(child.fullPath).toBe('/nested')
+ })
+
+ it('strips groups in folders', () => {
+ const tree = new PrefixTree(RESOLVED_OPTIONS)
+ tree.insert('(group)/a', '(group)/a.vue')
+ tree.insert('(group)/index', '(group)/index.vue')
+
+ const group = tree.children.get('(group)')!
+ expect(group).toBeDefined()
+ expect(group.path).toBe('/')
+
+ const a = group.children.get('a')!
+ expect(a).toBeDefined()
+ expect(a.fullPath).toBe('/a')
+
+ const index = group.children.get('index')!
+ expect(index).toBeDefined()
+ expect(index.fullPath).toBe('/')
+ })
+
+ it('strips groups in nested folders', () => {
+ const tree = new PrefixTree(RESOLVED_OPTIONS)
+ tree.insert('nested/(nested-group)/a', 'nested/(nested-group)/a.vue')
+ tree.insert(
+ 'nested/(nested-group)/index',
+ 'nested/(nested-group)/index.vue'
+ )
+
+ const rootNode = tree.children.get('nested')!
+ expect(rootNode).toBeDefined()
+ expect(rootNode.path).toBe('/nested')
+
+ const nestedGroupNode = rootNode.children.get('(nested-group)')!
+ expect(nestedGroupNode).toBeDefined()
+ // nested groups have an empty path
+ expect(nestedGroupNode.path).toBe('')
+ expect(nestedGroupNode.fullPath).toBe('/nested')
+
+ const aNode = nestedGroupNode.children.get('a')!
+ expect(aNode).toBeDefined()
+ expect(aNode.fullPath).toBe('/nested/a')
+
+ const indexNode = nestedGroupNode.children.get('index')!
+ expect(indexNode).toBeDefined()
+ expect(indexNode.fullPath).toBe('/nested')
+ })
+
+ it('warns if the closing group is missing', () => {
+ const tree = new PrefixTree(RESOLVED_OPTIONS)
+ tree.insert('(home', '(home).vue')
+ expect(`"(home" is missing the closing ")"`).toHaveBeenWarned()
+ })
+
+ // TODO: check warns with different order
+ it.todo(`warns when a group's path conflicts with an existing file`)
+
describe('dot nesting', () => {
it('transforms dots into nested routes by default', () => {
const tree = new PrefixTree(RESOLVED_OPTIONS)
diff --git a/src/core/treeNodeValue.ts b/src/core/treeNodeValue.ts
index 1733629de..2812537c3 100644
--- a/src/core/treeNodeValue.ts
+++ b/src/core/treeNodeValue.ts
@@ -1,9 +1,10 @@
import type { RouteRecordRaw } from 'vue-router'
import { CustomRouteBlock } from './customBlock'
-import { joinPath, mergeRouteRecordOverride } from './utils'
+import { joinPath, mergeRouteRecordOverride, warn } from './utils'
export const enum TreeNodeType {
static,
+ group,
param,
}
@@ -90,6 +91,10 @@ class _TreeNodeValueBase {
return this._type === TreeNodeType.static
}
+ isGroup(): this is TreeNodeValueGroup {
+ return this._type === TreeNodeType.group
+ }
+
get overrides() {
return [...this._overrides.entries()]
.sort(([nameA], [nameB]) =>
@@ -177,6 +182,21 @@ export class TreeNodeValueStatic extends _TreeNodeValueBase {
}
}
+export class TreeNodeValueGroup extends _TreeNodeValueBase {
+ override _type: TreeNodeType.group = TreeNodeType.group
+ groupName: string
+
+ constructor(
+ rawSegment: string,
+ parent: TreeNodeValue | undefined,
+ pathSegment: string,
+ groupName: string
+ ) {
+ super(rawSegment, parent, pathSegment)
+ this.groupName = groupName
+ }
+}
+
export interface TreeRouteParam {
paramName: string
modifier: string
@@ -201,7 +221,10 @@ export class TreeNodeValueParam extends _TreeNodeValueBase {
}
}
-export type TreeNodeValue = TreeNodeValueStatic | TreeNodeValueParam
+export type TreeNodeValue =
+ | TreeNodeValueStatic
+ | TreeNodeValueParam
+ | TreeNodeValueGroup
export interface TreeNodeValueOptions extends ParseSegmentOptions {
/**
@@ -231,7 +254,7 @@ function resolveTreeNodeValueOptions(
}
/**
- * Creates a new TreeNodeValue based on the segment. The result can be a static segment or a param segment.
+ * Creates a new TreeNodeValue based on the segment. The result can be a static segment, group segment or a param segment.
*
* @param segment - path segment
* @param parent - parent node
@@ -249,6 +272,33 @@ export function createTreeNodeValue(
// ensure default options
const options = resolveTreeNodeValueOptions(opts)
+ // extract the group between parentheses
+ const openingPar = segment.indexOf('(')
+
+ // only apply to files, not to manually added routes
+ if (options.format === 'file' && openingPar >= 0) {
+ let groupName: string
+
+ const closingPar = segment.lastIndexOf(')')
+ if (closingPar < 0 || closingPar < openingPar) {
+ warn(
+ `Segment "${segment}" is missing the closing ")". It will be treated as a static segment.`
+ )
+
+ // avoid parsing errors
+ return new TreeNodeValueStatic(segment, parent, segment)
+ }
+
+ groupName = segment.slice(openingPar + 1, closingPar)
+ const before = segment.slice(0, openingPar)
+ const after = segment.slice(closingPar + 1)
+
+ if (!before && !after) {
+ // pure group: no contribution to the path
+ return new TreeNodeValueGroup(segment, parent, '', groupName)
+ }
+ }
+
const [pathSegment, params, subSegments] =
options.format === 'path'
? parseRawPathSegment(segment)