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 @@ + 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 @@ + 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 @@ + 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 @@ + 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 @@ + 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 @@ + 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 @@ + 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)