diff --git a/src/core/context.ts b/src/core/context.ts index 5d9de314e..db44cfef3 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -115,6 +115,7 @@ export function createRoutesContext(options: ResolvedOptions) { const routeBlock = getRouteBlock(filePath, content, options) // TODO: should warn if hasDefinePage and customRouteBlock // if (routeBlock) logger.log(routeBlock) + node.setCustomRouteBlock(filePath, { ...routeBlock, ...definedPageNameAndPath, diff --git a/src/core/customBlock.ts b/src/core/customBlock.ts index b8c8f5da4..fd0ce5ffc 100644 --- a/src/core/customBlock.ts +++ b/src/core/customBlock.ts @@ -13,18 +13,7 @@ export function getRouteBlock( const parsedSFC = parse(content, { pad: 'space' }).descriptor const blockStr = parsedSFC?.customBlocks.find((b) => b.type === 'route') - if (!blockStr) return - - let result = parseCustomBlock(blockStr, path, options) - - // validation - if (result) { - if (result.path != null && !result.path.startsWith('/')) { - warn(`Overridden path must start with "/". Found in "${path}".`) - } - } - - return result + if (blockStr) return parseCustomBlock(blockStr, path, options) } export interface CustomRouteBlock diff --git a/src/core/extendRoutes.spec.ts b/src/core/extendRoutes.spec.ts index 730a0e647..608bcb8c4 100644 --- a/src/core/extendRoutes.spec.ts +++ b/src/core/extendRoutes.spec.ts @@ -1,9 +1,14 @@ -import { expect, describe, it } from 'vitest' +import { expect, describe, it, beforeAll } from 'vitest' import { PrefixTree } from './tree' import { DEFAULT_OPTIONS, resolveOptions } from '../options' import { EditableTreeNode } from './extendRoutes' +import { mockWarn } from '../../tests/vitest-mock-warn' describe('EditableTreeNode', () => { + beforeAll(() => { + mockWarn() + }) + const RESOLVED_OPTIONS = resolveOptions(DEFAULT_OPTIONS) it('creates an editable tree node', () => { const tree = new PrefixTree(RESOLVED_OPTIONS) @@ -251,4 +256,46 @@ describe('EditableTreeNode', () => { }, ]) }) + + it('can override children path with relative ones', () => { + const tree = new PrefixTree(RESOLVED_OPTIONS) + const editable = new EditableTreeNode(tree) + const parent = editable.insert('parent', 'file.vue') + const child = parent.insert('child', 'file.vue') + const grandChild = child.insert('grandchild', 'file.vue') + + child.path = 'relative' + expect(child.path).toBe('relative') + expect(child.fullPath).toBe('/parent/relative') + expect(grandChild.fullPath).toBe('/parent/relative/grandchild') + + child.path = '/absolute' + expect(child.path).toBe('/absolute') + expect(child.fullPath).toBe('/absolute') + expect(grandChild.fullPath).toBe('/absolute/grandchild') + }) + + it('can override paths at tho root', () => { + const tree = new PrefixTree(RESOLVED_OPTIONS) + const editable = new EditableTreeNode(tree) + const parent = editable.insert('parent', 'file.vue') + const child = parent.insert('child', 'child.vue') + + parent.path = '/p' + expect(parent.path).toBe('/p') + expect(parent.fullPath).toBe('/p') + expect(child.fullPath).toBe('/p/child') + }) + + it('still creates valid paths if the path misses a leading slash', () => { + const tree = new PrefixTree(RESOLVED_OPTIONS) + const editable = new EditableTreeNode(tree) + const parent = editable.insert('parent', 'file.vue') + const child = parent.insert('child', 'file.vue') + + parent.path = 'bar' + expect(parent.path).toBe('/bar') + expect(parent.fullPath).toBe('/bar') + expect(child.fullPath).toBe('/bar/child') + }) }) diff --git a/src/core/extendRoutes.ts b/src/core/extendRoutes.ts index 8692d73e1..540e91e6d 100644 --- a/src/core/extendRoutes.ts +++ b/src/core/extendRoutes.ts @@ -1,7 +1,6 @@ import { RouteMeta } from 'vue-router' import { CustomRouteBlock } from './customBlock' import { type TreeNode } from './tree' -import { warn } from './utils' /** * A route node that can be modified by the user. The tree can be iterated to be traversed. @@ -142,11 +141,13 @@ export class EditableTreeNode { * Override the path of the route. You must ensure `params` match with the existing path. */ set path(path: string) { - if (!path.startsWith('/')) { - warn( - `Only absolute paths are supported. Make sure that "${path}" starts with a slash "/".` - ) - return + // automatically prefix the path with `/` if the route is at the root of the tree + // that matches the behavior of node.insert('path', 'file.vue') that also adds it + if ( + (!this.node.parent || this.node.parent.isRoot()) && + !path.startsWith('/') + ) { + path = '/' + path } this.node.value.addEditOverride({ path }) } diff --git a/src/core/tree.spec.ts b/src/core/tree.spec.ts index 9b49a0d2d..045978cdc 100644 --- a/src/core/tree.spec.ts +++ b/src/core/tree.spec.ts @@ -22,7 +22,7 @@ describe('Tree', () => { expect(child).toBeDefined() expect(child.value).toMatchObject({ rawSegment: 'foo', - path: '/foo', + fullPath: '/foo', _type: TreeNodeType.static, }) expect(child.children.size).toBe(0) @@ -37,7 +37,7 @@ describe('Tree', () => { expect(child.value).toMatchObject({ rawSegment: '[id]', params: [{ paramName: 'id' }], - path: '/:id', + fullPath: '/:id', _type: TreeNodeType.param, }) expect(child.children.size).toBe(0) @@ -50,14 +50,14 @@ describe('Tree', () => { expect(tree.children.get('[id]_a')!.value).toMatchObject({ rawSegment: '[id]_a', params: [{ paramName: 'id' }], - path: '/:id()_a', + fullPath: '/:id()_a', _type: TreeNodeType.param, }) expect(tree.children.get('[a]e[b]f')!.value).toMatchObject({ rawSegment: '[a]e[b]f', params: [{ paramName: 'a' }, { paramName: 'b' }], - path: '/:a()e:b()f', + fullPath: '/:a()e:b()f', _type: TreeNodeType.param, }) }) @@ -155,7 +155,7 @@ describe('Tree', () => { modifier: '+', }, ], - path: '/:id+', + fullPath: '/:id+', _type: TreeNodeType.param, }) }) @@ -173,7 +173,7 @@ describe('Tree', () => { modifier: '*', }, ], - path: '/:id*', + fullPath: '/:id*', _type: TreeNodeType.param, }) }) @@ -191,7 +191,7 @@ describe('Tree', () => { modifier: '?', }, ], - path: '/:id?', + fullPath: '/:id?', _type: TreeNodeType.param, }) }) @@ -292,7 +292,7 @@ describe('Tree', () => { expect(index.value).toMatchObject({ rawSegment: 'index', // the root should have a '/' instead of '' for the autocompletion - path: '/', + fullPath: '/', }) expect(index).toBeDefined() const a = tree.children.get('a')! @@ -300,7 +300,7 @@ describe('Tree', () => { expect(a.value.components.get('default')).toBeUndefined() expect(a.value).toMatchObject({ rawSegment: 'a', - path: '/a', + fullPath: '/a', }) expect(Array.from(a.children.keys())).toEqual(['index', 'b']) const aIndex = a.children.get('index')! @@ -308,14 +308,14 @@ describe('Tree', () => { expect(Array.from(aIndex.children.keys())).toEqual([]) expect(aIndex.value).toMatchObject({ rawSegment: 'index', - path: '/a', + fullPath: '/a', }) tree.insert('a', 'a.vue') expect(a.value.components.get('default')).toBe('a.vue') expect(a.value).toMatchObject({ rawSegment: 'a', - path: '/a', + fullPath: '/a', }) }) @@ -328,7 +328,7 @@ describe('Tree', () => { expect(child.value).toMatchObject({ rawSegment: '[id]+', params: [{ paramName: 'id', modifier: '+' }], - path: '/:id+', + fullPath: '/:id+', pathSegment: ':id+', _type: TreeNodeType.param, }) @@ -346,7 +346,7 @@ describe('Tree', () => { expect(child.value).toMatchObject({ rawSegment: '[id]', params: [{ paramName: 'id' }], - path: '/:id', + fullPath: '/:id', pathSegment: ':id', }) expect(child.children.size).toBe(0) @@ -543,7 +543,7 @@ describe('Tree', () => { expect(users.value).toMatchObject({ rawSegment: 'users.new', pathSegment: 'users/new', - path: '/users/new', + fullPath: '/users/new', _type: TreeNodeType.static, }) }) @@ -562,7 +562,7 @@ describe('Tree', () => { expect(lesson.value).toMatchObject({ rawSegment: '1.2.3-lesson', pathSegment: '1.2.3-lesson', - path: '/1.2.3-lesson', + fullPath: '/1.2.3-lesson', _type: TreeNodeType.static, }) }) diff --git a/src/core/tree.ts b/src/core/tree.ts index 501a7c3c3..dc2858abf 100644 --- a/src/core/tree.ts +++ b/src/core/tree.ts @@ -195,7 +195,7 @@ export class TreeNode { * Returns the route path of the node including parent paths. */ get fullPath() { - return this.value.overrides.path ?? this.value.path + return this.value.fullPath } /** @@ -247,7 +247,7 @@ export class TreeNode { */ isRoot() { return ( - !this.parent && this.value.path === '/' && !this.value.components.size + !this.parent && this.value.fullPath === '/' && !this.value.components.size ) } diff --git a/src/core/treeNodeValue.ts b/src/core/treeNodeValue.ts index 2812537c3..668d3cb87 100644 --- a/src/core/treeNodeValue.ts +++ b/src/core/treeNodeValue.ts @@ -68,15 +68,24 @@ class _TreeNodeValueBase { } /** - * fullPath of the node based on parent nodes + * Path of the node. Can be absolute or not. If it has been overridden, it + * will return the overridden path. */ get path(): string { - const parentPath = this.parent?.path - // both the root record and the index record have a path of / - const pathSegment = this.overrides.path ?? this.pathSegment - return (!parentPath || parentPath === '/') && pathSegment === '' - ? '/' - : joinPath(parentPath || '', pathSegment) + return this.overrides.path ?? this.pathSegment + } + + /** + * Full path of the node including parent nodes. + */ + get fullPath(): string { + const pathSegment = this.path + // if the path is absolute, we don't need to join it with the parent + if (pathSegment.startsWith('/')) { + return pathSegment + } + + return joinPath(this.parent?.fullPath ?? '', pathSegment) } toString(): string { diff --git a/src/core/utils.spec.ts b/src/core/utils.spec.ts index e3c30d5cd..8dead0747 100644 --- a/src/core/utils.spec.ts +++ b/src/core/utils.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { trimExtension } from './utils' +import { joinPath, trimExtension } from './utils' describe('utils', () => { describe('trimExtension', () => { @@ -15,4 +15,44 @@ describe('utils', () => { expect(trimExtension('foo.page.vue', ['.vue'])).toBe('foo.page') }) }) + + describe('joinPath', () => { + it('joins paths', () => { + expect(joinPath('/foo', 'bar')).toBe('/foo/bar') + expect(joinPath('/foo', 'bar', 'baz')).toBe('/foo/bar/baz') + expect(joinPath('/foo', 'bar', 'baz', 'qux')).toBe('/foo/bar/baz/qux') + expect(joinPath('/foo', 'bar', 'baz', 'qux', 'quux')).toBe( + '/foo/bar/baz/qux/quux' + ) + }) + + it('adds a leading slash if missing', () => { + expect(joinPath('foo')).toBe('/foo') + expect(joinPath('foo', '')).toBe('/foo') + expect(joinPath('foo', 'bar')).toBe('/foo/bar') + expect(joinPath('foo', 'bar', 'baz')).toBe('/foo/bar/baz') + }) + + it('works with empty paths', () => { + expect(joinPath('', '', '', '')).toBe('/') + expect(joinPath('', '/', '', '')).toBe('/') + expect(joinPath('', '/', '', '/')).toBe('/') + expect(joinPath('', '/', '/', '/')).toBe('/') + expect(joinPath('/', '', '', '')).toBe('/') + }) + + it('collapses slashes', () => { + expect(joinPath('/foo/', 'bar')).toBe('/foo/bar') + expect(joinPath('/foo', 'bar')).toBe('/foo/bar') + expect(joinPath('/foo', 'bar/', 'foo')).toBe('/foo/bar/foo') + expect(joinPath('/foo', 'bar', 'foo')).toBe('/foo/bar/foo') + }) + + it('keeps trailing slashes', () => { + expect(joinPath('/foo', 'bar/')).toBe('/foo/bar/') + expect(joinPath('/foo/', 'bar/')).toBe('/foo/bar/') + expect(joinPath('/foo/', 'bar', 'baz/')).toBe('/foo/bar/baz/') + expect(joinPath('/foo/', 'bar/', 'baz/')).toBe('/foo/bar/baz/') + }) + }) }) diff --git a/src/core/utils.ts b/src/core/utils.ts index 171ff7d2b..63f269b49 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -116,7 +116,7 @@ export function joinPath(...paths: string[]): string { // check path to avoid adding a trailing slash when joining an empty string (path && '/' + path.replace(LEADING_SLASH_RE, '')) } - return result + return result || '/' } function paramToName({ paramName, modifier, isSplat }: TreeRouteParam) {