Skip to content

Commit

Permalink
feat: add route groups (#549)
Browse files Browse the repository at this point in the history
Co-authored-by: Eduardo San Martin Morote <[email protected]>
Co-authored-by: Eduardo San Martin Morote <[email protected]>
  • Loading branch information
3 people authored Jan 13, 2025
1 parent 85d4545 commit e9bbf05
Show file tree
Hide file tree
Showing 11 changed files with 195 additions and 3 deletions.
3 changes: 3 additions & 0 deletions playground/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ function _test() {
<li>
<RouterLink to="/">Home</RouterLink>
</li>
<li>
<RouterLink to="/group">Group (thing.vue)</RouterLink>
</li>
<li>
<RouterLink to="/users/2" v-slot="{ href }">{{ href }}</RouterLink>
</li>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<h1>test group child (resolves to root, treated as static)</h1>
</template>
3 changes: 3 additions & 0 deletions playground/src/pages/(test-group)/test-group-child.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<h1>Test group child (resolves to root)</h1>
</template>
3 changes: 3 additions & 0 deletions playground/src/pages/file(ignored-parentheses).vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<h1>file(ignored-brackets)</h1>
</template>
3 changes: 3 additions & 0 deletions playground/src/pages/group/(thing).vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<h1>(thing).vue - Parentheses are ignored and this file becomes the index</h1>
</template>
3 changes: 3 additions & 0 deletions playground/src/pages/nested-group/(group).vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<h1>(group).vue</h1>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<h1>Nested group deep child (resolves to nested-group)</h1>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<h1>Nested group first level child (resolves to nested group)</h1>
</template>
40 changes: 40 additions & 0 deletions src/codegen/generateRouteMap.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never, never>, Record<never, never>>,
}"
`)
})

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<never, never>, Record<never, never>>,
}"
`)
})

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<never, never>, Record<never, never>>,
}"
`)
})
})

/**
Expand All @@ -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 -> /
*/
78 changes: 78 additions & 0 deletions src/core/tree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
56 changes: 53 additions & 3 deletions src/core/treeNodeValue.ts
Original file line number Diff line number Diff line change
@@ -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,
}

Expand Down Expand Up @@ -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]) =>
Expand Down Expand Up @@ -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
Expand All @@ -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 {
/**
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down

0 comments on commit e9bbf05

Please sign in to comment.