diff --git a/src/core/extendRoutes.spec.ts b/src/core/extendRoutes.spec.ts new file mode 100644 index 000000000..963fe7680 --- /dev/null +++ b/src/core/extendRoutes.spec.ts @@ -0,0 +1,81 @@ +import { expect, describe, it } from 'vitest' +import { createPrefixTree } from './tree' +import { DEFAULT_OPTIONS } from '../options' +import { EditableTreeNode } from './extendRoutes' + +describe('EditableTreeNode', () => { + it('creates an editable tree node', () => { + const tree = createPrefixTree(DEFAULT_OPTIONS) + const editable = new EditableTreeNode(tree) + + expect(editable.children).toEqual([]) + }) + + it('reflects changes made on the tree', () => { + const tree = createPrefixTree(DEFAULT_OPTIONS) + const editable = new EditableTreeNode(tree) + + tree.insert('foo', 'file.vue') + expect(editable.children).toHaveLength(1) + expect(editable.children[0].path).toBe('/foo') + }) + + it('reflects changes made on the editable tree', () => { + const tree = createPrefixTree(DEFAULT_OPTIONS) + const editable = new EditableTreeNode(tree) + + editable.insert('foo', 'file.vue') + expect(tree.children.size).toBe(1) + expect(tree.children.get('foo')?.path).toBe('/foo') + }) + + it('can insert nested nodes', () => { + const tree = createPrefixTree(DEFAULT_OPTIONS) + const editable = new EditableTreeNode(tree) + + editable.insert('foo/bar', 'file.vue') + expect(tree.children.size).toBe(1) + expect(tree.children.get('foo')?.children.size).toBe(1) + expect(tree.children.get('foo')?.children.get('bar')?.path).toBe('bar') + }) + + it('adds params', () => { + const tree = createPrefixTree(DEFAULT_OPTIONS) + const editable = new EditableTreeNode(tree) + + editable.insert(':id', 'file.vue') + expect(tree.children.size).toBe(1) + const child = tree.children.get(':id')! + expect(child.fullPath).toBe('/:id') + expect(child.path).toBe('/:id') + expect(child.params).toEqual([ + { + paramName: 'id', + modifier: '', + optional: false, + repeatable: false, + isSplat: false, + }, + ]) + }) + + it('add params with modifiers', () => { + const tree = createPrefixTree(DEFAULT_OPTIONS) + const editable = new EditableTreeNode(tree) + + editable.insert(':id+', 'file.vue') + expect(tree.children.size).toBe(1) + const child = tree.children.get(':id+')! + expect(child.fullPath).toBe('/:id+') + expect(child.path).toBe('/:id+') + expect(child.params).toEqual([ + { + paramName: 'id', + modifier: '+', + optional: false, + repeatable: true, + isSplat: false, + }, + ]) + }) +}) diff --git a/src/core/extendRoutes.ts b/src/core/extendRoutes.ts index dd4427f3c..4ff8050da 100644 --- a/src/core/extendRoutes.ts +++ b/src/core/extendRoutes.ts @@ -33,13 +33,12 @@ export class EditableTreeNode { /** * Inserts a new route as a child of this route. This route cannot use `definePage()`. If it was meant to be included, * add it to the `routesFolder` option. + * + * @param path - path segment to insert. Note this is relative to the current route. It shouldn't start with `/` unless you want the route path to be absolute. + * added at the root of the tree. + * @param filePath - file path */ insert(path: string, filePath: string) { - const extDotIndex = filePath.lastIndexOf('.') - const ext = filePath.slice(extDotIndex) - if (!path.endsWith(ext)) { - path += ext - } // adapt paths as they should match a file system let addBackLeadingSlash = false if (path.startsWith('/')) { @@ -49,7 +48,7 @@ export class EditableTreeNode { // but in other places we need to instruct the path is at the root so we change it afterwards addBackLeadingSlash = !this.node.isRoot() } - const node = this.node.insert(path, filePath) + const node = this.node.insertParsedPath(path, filePath) const editable = new EditableTreeNode(node) if (addBackLeadingSlash) { editable.path = '/' + node.path diff --git a/src/core/tree.ts b/src/core/tree.ts index 4b18e7c61..7bc30a983 100644 --- a/src/core/tree.ts +++ b/src/core/tree.ts @@ -1,10 +1,18 @@ import type { ResolvedOptions } from '../options' -import { createTreeNodeValue, TreeRouteParam } from './treeNodeValue' +import { + createTreeNodeValue, + TreeNodeValueOptions, + TreeRouteParam, +} from './treeNodeValue' import type { TreeNodeValue } from './treeNodeValue' import { trimExtension } from './utils' import { CustomRouteBlock } from './customBlock' import { RouteMeta } from 'vue-router' +export interface TreeNodeOptions extends ResolvedOptions { + treeNodeOptions?: TreeNodeValueOptions +} + export class TreeNode { /** * value of the node @@ -24,20 +32,20 @@ export class TreeNode { /** * Plugin options taken into account by the tree. */ - options: ResolvedOptions + options: TreeNodeOptions /** * Should this page import the page info */ hasDefinePage: boolean = false - constructor(options: ResolvedOptions, filePath: string, parent?: TreeNode) { + constructor(options: TreeNodeOptions, filePath: string, parent?: TreeNode) { this.options = options this.parent = parent this.value = createTreeNodeValue( filePath, parent?.value, - options.pathParser + options.treeNodeOptions ) } @@ -69,6 +77,51 @@ export class TreeNode { return child } + /** + * Adds a path to the tree. `path` cannot start with a `/`. + * + * @param path - path segment to insert, already parsed (e.g. users/:id) + * @param filePath - file path, defaults to path for convenience and testing + */ + insertParsedPath(path: string, filePath: string = path): TreeNode { + const slashPos = path.indexOf('/') + const segment = slashPos < 0 ? path : path.slice(0, slashPos) + const tail = slashPos < 0 ? '' : path.slice(slashPos + 1) + + // TODO: allow null filePath? + const isComponent = !tail + + if (!this.children.has(segment)) { + this.children.set( + segment, + new TreeNode( + { + ...this.options, + // force the format to raw + treeNodeOptions: { + ...this.options.pathParser, + format: 'path', + }, + }, + segment, + this + ) + ) + } + const child = this.children.get(segment)! + + if (isComponent) { + // TODO: allow a way to set the view name + child.value.components.set('default', filePath) + } + + if (tail) { + return child.insertParsedPath(tail, filePath) + } + + return child + } + setCustomRouteBlock(path: string, routeBlock: CustomRouteBlock | undefined) { this.value.setOverride(path, routeBlock) } diff --git a/src/core/treeNodeValue.ts b/src/core/treeNodeValue.ts index 7db9ac02c..787b472c2 100644 --- a/src/core/treeNodeValue.ts +++ b/src/core/treeNodeValue.ts @@ -182,19 +182,31 @@ export class TreeNodeValueParam extends _TreeNodeValueBase { export type TreeNodeValue = TreeNodeValueStatic | TreeNodeValueParam +export interface TreeNodeValueOptions extends ParseSegmentOptions { + /** + * Format of the route path. Defaults to `file` which is the format used by unplugin-vue-router and matches the file + * structure (e.g. `index`, ``, or `users/[id]`). In `path` format, routes are expected in the format of vue-router + * (e.g. `/` or '/users/:id' ). + * + * @default 'file' + */ + format?: 'file' | 'path' +} + export function createTreeNodeValue( segment: string, parent?: TreeNodeValue, - parseSegmentOptions?: ParseSegmentOptions + options: TreeNodeValueOptions = {} ): TreeNodeValue { if (!segment || segment === 'index') { return new TreeNodeValueStatic(segment, parent, '') } - const [pathSegment, params, subSegments] = parseSegment( - segment, - parseSegmentOptions - ) + const [pathSegment, params, subSegments] = + options.format === 'path' + ? parseRawPathSegment(segment) + : // by default, we use the file format + parseFileSegment(segment, options) if (params.length) { return new TreeNodeValueParam( @@ -209,7 +221,7 @@ export function createTreeNodeValue( return new TreeNodeValueStatic(segment, parent, pathSegment) } -const enum ParseSegmentState { +const enum ParseFileSegmentState { static, paramOptional, // within [[]] or [] param, // within [] @@ -223,7 +235,7 @@ const enum ParseSegmentState { export interface ParseSegmentOptions { /** * Should we allow dot nesting in the param name. e.g. `users.[id]` will be parsed as `users/[id]` if this is `true`, - * nesting + * nesting. Note this only works for the `file` format. * @default true */ dotNesting?: boolean @@ -237,12 +249,12 @@ const IS_VARIABLE_CHAR_RE = /[0-9a-zA-Z_]/ * @param segment - segment to parse without the extension * @returns - the pathSegment and the params */ -function parseSegment( +function parseFileSegment( segment: string, { dotNesting = true }: ParseSegmentOptions = {} ): [string, TreeRouteParam[], SubSegment[]] { let buffer = '' - let state: ParseSegmentState = ParseSegmentState.static + let state: ParseFileSegmentState = ParseFileSegmentState.static const params: TreeRouteParam[] = [] let pathSegment = '' const subSegments: SubSegment[] = [] @@ -254,11 +266,11 @@ function parseSegment( let c: string function consumeBuffer() { - if (state === ParseSegmentState.static) { + if (state === ParseFileSegmentState.static) { // add the buffer to the path segment as is pathSegment += buffer subSegments.push(buffer) - } else if (state === ParseSegmentState.modifier) { + } else if (state === ParseFileSegmentState.modifier) { currentTreeRouteParam.paramName = buffer currentTreeRouteParam.modifier = currentTreeRouteParam.optional ? currentTreeRouteParam.repeatable @@ -287,17 +299,17 @@ function parseSegment( for (pos = 0; pos < segment.length; pos++) { c = segment[pos] - if (state === ParseSegmentState.static) { + if (state === ParseFileSegmentState.static) { if (c === '[') { consumeBuffer() // check if it's an optional param or not - state = ParseSegmentState.paramOptional + state = ParseFileSegmentState.paramOptional } else { // append the char to the buffer or if the dotNesting option // is enabled (by default it is), transform into a slash buffer += dotNesting && c === '.' ? '/' : c } - } else if (state === ParseSegmentState.paramOptional) { + } else if (state === ParseFileSegmentState.paramOptional) { if (c === '[') { currentTreeRouteParam.optional = true } else if (c === '.') { @@ -307,21 +319,21 @@ function parseSegment( // keep it for the param buffer += c } - state = ParseSegmentState.param - } else if (state === ParseSegmentState.param) { + state = ParseFileSegmentState.param + } else if (state === ParseFileSegmentState.param) { if (c === ']') { if (currentTreeRouteParam.optional) { // skip the next ] pos++ } - state = ParseSegmentState.modifier + state = ParseFileSegmentState.modifier } else if (c === '.') { currentTreeRouteParam.isSplat = true pos += 2 // skip the other 2 dots } else { buffer += c } - } else if (state === ParseSegmentState.modifier) { + } else if (state === ParseFileSegmentState.modifier) { if (c === '+') { currentTreeRouteParam.repeatable = true } else { @@ -330,13 +342,13 @@ function parseSegment( } consumeBuffer() // start again - state = ParseSegmentState.static + state = ParseFileSegmentState.static } } if ( - state === ParseSegmentState.param || - state === ParseSegmentState.paramOptional + state === ParseFileSegmentState.param || + state === ParseFileSegmentState.paramOptional ) { throw new Error(`Invalid segment: "${segment}"`) } @@ -348,6 +360,137 @@ function parseSegment( return [pathSegment, params, subSegments] } +// TODO: this logic is flawed because it only handles segments. We should use the path parser from vue router that already has all this logic baked in. + +const enum ParseRawPathSegmentState { + static, + param, // after : + regexp, // after :id( + modifier, // after :id(...) +} + +const IS_MODIFIER_RE = /[+*?]/ + +/** + * Parses a raw path segment like the `:id` in a route `/users/:id`. + * + * @param segment - segment to parse without the extension + * @returns - the pathSegment and the params + */ +function parseRawPathSegment( + segment: string +): [string, TreeRouteParam[], SubSegment[]] { + let buffer = '' + let state: ParseRawPathSegmentState = ParseRawPathSegmentState.static + const params: TreeRouteParam[] = [] + const subSegments: SubSegment[] = [] + let currentTreeRouteParam: TreeRouteParam = createEmptyRouteParam() + + // position in segment + let pos = 0 + // current char + let c: string + + function consumeBuffer() { + if (state === ParseRawPathSegmentState.static) { + // add the buffer to the path segment as is + subSegments.push(buffer) + } else if ( + state === ParseRawPathSegmentState.param || + state === ParseRawPathSegmentState.regexp || + state === ParseRawPathSegmentState.modifier + ) { + // we consume the current param + subSegments.push(currentTreeRouteParam) + params.push(currentTreeRouteParam) + currentTreeRouteParam = createEmptyRouteParam() + } + // no other cases + + buffer = '' + } + + for (pos = 0; pos < segment.length; pos++) { + c = segment[pos] + + if (state === ParseRawPathSegmentState.static) { + if (c === ':') { + consumeBuffer() + // check if it's an optional param or not + state = ParseRawPathSegmentState.param + } else { + buffer += c + } + } else if (state === ParseRawPathSegmentState.param) { + if (c === '(') { + // consume the param name and start the regexp + currentTreeRouteParam.paramName = buffer + buffer = '' + state = ParseRawPathSegmentState.regexp + } else if (IS_MODIFIER_RE.test(c)) { + // add as modifier + currentTreeRouteParam.modifier = c + currentTreeRouteParam.optional = c === '?' || c === '*' + currentTreeRouteParam.repeatable = c === '+' || c === '*' + // consume the param + consumeBuffer() + // start again + state = ParseRawPathSegmentState.static + } else if (IS_VARIABLE_CHAR_RE.test(c)) { + buffer += c + // keep it as we could be at the end of the string + currentTreeRouteParam.paramName = buffer + } else { + currentTreeRouteParam.paramName = buffer + // we reached the end of the param + consumeBuffer() + // we need to parse this again + pos-- + state = ParseRawPathSegmentState.static + } + } else if (state === ParseRawPathSegmentState.regexp) { + if (c === ')') { + // we don't actually care about the regexp as it already on the segment + // currentTreeRouteParam.regexp = buffer + buffer = '' + // check if there is a modifier + state = ParseRawPathSegmentState.modifier + } else { + buffer += c + } + } else if (state === ParseRawPathSegmentState.modifier) { + if (IS_MODIFIER_RE.test(c)) { + currentTreeRouteParam.modifier = c + currentTreeRouteParam.optional = c === '?' || c === '*' + currentTreeRouteParam.repeatable = c === '+' || c === '*' + } else { + // parse this character again + pos-- + } + // add the param to the segment list + consumeBuffer() + // start again + state = ParseRawPathSegmentState.static + } + } + + // we cannot reach the end of the segment + if (state === ParseRawPathSegmentState.regexp) { + throw new Error(`Invalid segment: "${segment}"`) + } + + if (buffer) { + consumeBuffer() + } + + return [ + // here the segment is already a valid path segment + segment, + params, + subSegments, + ] +} + function createEmptyRouteParam(): TreeRouteParam { return { paramName: '',