diff --git a/e2e/__snapshots__/routes.spec.ts.snap b/e2e/__snapshots__/routes.spec.ts.snap index daacab67c..067a82b4a 100644 --- a/e2e/__snapshots__/routes.spec.ts.snap +++ b/e2e/__snapshots__/routes.spec.ts.snap @@ -8,41 +8,35 @@ export const routes = [ path: '/', name: '/', component: () => import('/routes/index.vue'), - /* no props */ /* no children */ }, { path: '/:path(.*)', name: '/[...path]', component: () => import('/routes/[...path].vue'), - /* no props */ /* no children */ }, { path: '/about', name: '/about', component: () => import('/routes/about.vue'), - /* no props */ /* no children */ }, { path: '/articles', /* no name */ /* no component */ - /* no props */ children: [ { path: ':id', name: '/articles/[id]', component: () => import('/routes/articles/[id].vue'), - /* no props */ /* no children */ }, { path: ':slugs+', name: '/articles/[slugs]+', component: () => import('/routes/articles/[slugs]+.vue'), - /* no props */ /* no children */ } ], @@ -51,38 +45,32 @@ export const routes = [ path: '/nested', /* no name */ /* no component */ - /* no props */ children: [ { path: 'folder', /* no name */ /* no component */ - /* no props */ children: [ { path: '', name: '/nested/folder/', component: () => import('/routes/nested/folder/index.vue'), - /* no props */ /* no children */ }, { path: 'should', /* no name */ /* no component */ - /* no props */ children: [ { path: 'work', /* no name */ /* no component */ - /* no props */ children: [ { path: '', name: '/nested/folder/should/work/', component: () => import('/routes/nested/folder/should/work/index.vue'), - /* no props */ /* no children */ } ], @@ -97,20 +85,17 @@ export const routes = [ path: '/optional', /* no name */ /* no component */ - /* no props */ children: [ { path: ':doc?', name: '/optional/[[doc]]', component: () => import('/routes/optional/[[doc]].vue'), - /* no props */ /* no children */ }, { path: ':docs*', name: '/optional/[[docs]]+', component: () => import('/routes/optional/[[docs]]+.vue'), - /* no props */ /* no children */ } ], @@ -119,13 +104,11 @@ export const routes = [ path: '/users', name: '/users', component: () => import('/routes/users.vue'), - /* no props */ children: [ { path: ':id', name: '/users/[id]', component: () => import('/routes/users/[id].vue'), - /* no props */ /* no children */ } ], @@ -134,7 +117,6 @@ export const routes = [ path: '/users/new', name: '/users.new', component: () => import('/routes/users.new.vue'), - /* no props */ /* no children */ } ] diff --git a/src/codegen/__snapshots__/generateRouteRecords.spec.ts.snap b/src/codegen/__snapshots__/generateRouteRecords.spec.ts.snap index c8a496275..d9bf52c04 100644 --- a/src/codegen/__snapshots__/generateRouteRecords.spec.ts.snap +++ b/src/codegen/__snapshots__/generateRouteRecords.spec.ts.snap @@ -6,13 +6,11 @@ exports[`generateRouteRecord > adds children and name when folder and component path: '/a', name: '/a', component: () => import('a.vue'), - /* no props */ children: [ { path: 'c', name: '/a/c', component: () => import('a/c.vue'), - /* no props */ /* no children */ } ], @@ -21,13 +19,11 @@ exports[`generateRouteRecord > adds children and name when folder and component path: '/b', /* no name */ /* no component */ - /* no props */ children: [ { path: 'c', name: '/b/c', component: () => import('b/c.vue'), - /* no props */ /* no children */ } ], @@ -36,7 +32,6 @@ exports[`generateRouteRecord > adds children and name when folder and component path: '/d', name: '/d', component: () => import('d.vue'), - /* no props */ /* no children */ } ]" @@ -48,20 +43,17 @@ exports[`generateRouteRecord > correctly names index.vue files 1`] = ` path: '/', name: '/', component: () => import('index.vue'), - /* no props */ /* no children */ }, { path: '/b', /* no name */ /* no component */ - /* no props */ children: [ { path: '', name: '/b/', component: () => import('b/index.vue'), - /* no props */ /* no children */ } ], @@ -75,33 +67,28 @@ exports[`generateRouteRecord > generate custom imports 1`] = ` path: '/a', name: '/a', component: _page_0, - /* no props */ /* no children */ }, { path: '/b', name: '/b', component: () => import('b.vue'), - /* no props */ /* no children */ }, { path: '/nested', /* no name */ /* no component */ - /* no props */ children: [ { path: 'file', /* no name */ /* no component */ - /* no props */ children: [ { path: 'c', name: '/nested/file/c', component: () => import('nested/file/c.vue'), - /* no props */ /* no children */ } ], @@ -123,33 +110,28 @@ exports[`generateRouteRecord > generate static imports 1`] = ` path: '/a', name: '/a', component: _page_0, - /* no props */ /* no children */ }, { path: '/b', name: '/b', component: _page_1, - /* no props */ /* no children */ }, { path: '/nested', /* no name */ /* no component */ - /* no props */ children: [ { path: 'file', /* no name */ /* no component */ - /* no props */ children: [ { path: 'c', name: '/nested/file/c', component: _page_2, - /* no props */ /* no children */ } ], @@ -177,7 +159,6 @@ exports[`generateRouteRecord > handles multiple named views 1`] = ` 'a': () => import('foo@a.vue'), 'b': () => import('foo@b.vue') }, - /* no props */ /* no children */ } ]" @@ -189,33 +170,28 @@ exports[`generateRouteRecord > handles non nested routes 1`] = ` path: '/users', name: '/users', component: () => import('users.vue'), - /* no props */ children: [ { path: '', name: '/users/', component: () => import('users/index.vue'), - /* no props */ /* no children */ }, { path: ':id', name: '/users/[id]', component: () => import('users/[id].vue'), - /* no props */ children: [ { path: '', name: '/users/[id]/', component: () => import('users/[id]/index.vue'), - /* no props */ /* no children */ }, { path: 'other', name: '/users/[id]/other', component: () => import('users/[id]/other.vue'), - /* no props */ /* no children */ } ], @@ -224,14 +200,12 @@ exports[`generateRouteRecord > handles non nested routes 1`] = ` path: ':id/not-nested', name: '/users/[id].not-nested', component: () => import('users/[id].not-nested.vue'), - /* no props */ /* no children */ }, { path: 'other', name: '/users/other', component: () => import('users/other.vue'), - /* no props */ /* no children */ } ], @@ -240,14 +214,12 @@ exports[`generateRouteRecord > handles non nested routes 1`] = ` path: '/users/:id/also-not-nested', name: '/users.[id].also-not-nested', component: () => import('users.[id].also-not-nested.vue'), - /* no props */ /* no children */ }, { path: '/users/not-nested', name: '/users.not-nested', component: () => import('users.not-nested.vue'), - /* no props */ /* no children */ } ]" @@ -261,7 +233,6 @@ exports[`generateRouteRecord > handles single named views 1`] = ` components: { 'a': () => import('foo@a.vue') }, - /* no props */ /* no children */ } ]" @@ -273,34 +244,29 @@ exports[`generateRouteRecord > names > creates multi word names 1`] = ` path: '/', name: '/', component: () => import('index.vue'), - /* no props */ /* no children */ }, { path: '/my-users', name: '/my-users', component: () => import('my-users.vue'), - /* no props */ /* no children */ }, { path: '/MyPascalCaseUsers', name: '/MyPascalCaseUsers', component: () => import('MyPascalCaseUsers.vue'), - /* no props */ /* no children */ }, { path: '/some-nested', /* no name */ /* no component */ - /* no props */ children: [ { path: 'file-with-:id-in-the-middle', name: '/some-nested/file-with-[id]-in-the-middle', component: () => import('some-nested/file-with-[id]-in-the-middle.vue'), - /* no props */ /* no children */ } ], @@ -314,40 +280,34 @@ exports[`generateRouteRecord > names > creates single word names 1`] = ` path: '/', name: '/', component: () => import('index.vue'), - /* no props */ /* no children */ }, { path: '/about', name: '/about', component: () => import('about.vue'), - /* no props */ /* no children */ }, { path: '/users', /* no name */ /* no component */ - /* no props */ children: [ { path: '', name: '/users/', component: () => import('users/index.vue'), - /* no props */ /* no children */ }, { path: ':id', name: '/users/[id]', component: () => import('users/[id].vue'), - /* no props */ children: [ { path: 'edit', name: '/users/[id]/edit', component: () => import('users/[id]/edit.vue'), - /* no props */ /* no children */ } ], @@ -356,7 +316,6 @@ exports[`generateRouteRecord > names > creates single word names 1`] = ` path: 'new', name: '/users/new', component: () => import('users/new.vue'), - /* no props */ /* no children */ } ], @@ -370,33 +329,28 @@ exports[`generateRouteRecord > names > works with nested views 1`] = ` path: '/', name: '/', component: () => import('index.vue'), - /* no props */ /* no children */ }, { path: '/users', name: '/users', component: () => import('users.vue'), - /* no props */ children: [ { path: '', name: '/users/', component: () => import('users/index.vue'), - /* no props */ /* no children */ }, { path: ':id', name: '/users/[id]', component: () => import('users/[id].vue'), - /* no props */ children: [ { path: 'edit', name: '/users/[id]/edit', component: () => import('users/[id]/edit.vue'), - /* no props */ /* no children */ } ], @@ -412,27 +366,23 @@ exports[`generateRouteRecord > nested children 1`] = ` path: '/a', /* no name */ /* no component */ - /* no props */ children: [ { path: 'a', name: '/a/a', component: () => import('a/a.vue'), - /* no props */ /* no children */ }, { path: 'b', name: '/a/b', component: () => import('a/b.vue'), - /* no props */ /* no children */ }, { path: 'c', name: '/a/c', component: () => import('a/c.vue'), - /* no props */ /* no children */ } ], @@ -441,27 +391,23 @@ exports[`generateRouteRecord > nested children 1`] = ` path: '/b', /* no name */ /* no component */ - /* no props */ children: [ { path: 'b', name: '/b/b', component: () => import('b/b.vue'), - /* no props */ /* no children */ }, { path: 'c', name: '/b/c', component: () => import('b/c.vue'), - /* no props */ /* no children */ }, { path: 'd', name: '/b/d', component: () => import('b/d.vue'), - /* no props */ /* no children */ } ], @@ -475,27 +421,23 @@ exports[`generateRouteRecord > nested children 2`] = ` path: '/a', /* no name */ /* no component */ - /* no props */ children: [ { path: 'a', name: '/a/a', component: () => import('a/a.vue'), - /* no props */ /* no children */ }, { path: 'b', name: '/a/b', component: () => import('a/b.vue'), - /* no props */ /* no children */ }, { path: 'c', name: '/a/c', component: () => import('a/c.vue'), - /* no props */ /* no children */ } ], @@ -504,27 +446,23 @@ exports[`generateRouteRecord > nested children 2`] = ` path: '/b', /* no name */ /* no component */ - /* no props */ children: [ { path: 'b', name: '/b/b', component: () => import('b/b.vue'), - /* no props */ /* no children */ }, { path: 'c', name: '/b/c', component: () => import('b/c.vue'), - /* no props */ /* no children */ }, { path: 'd', name: '/b/d', component: () => import('b/d.vue'), - /* no props */ /* no children */ } ], @@ -533,14 +471,12 @@ exports[`generateRouteRecord > nested children 2`] = ` path: '/c', name: '/c', component: () => import('c.vue'), - /* no props */ /* no children */ }, { path: '/d', name: '/d', component: () => import('d.vue'), - /* no props */ /* no children */ } ]" @@ -552,20 +488,17 @@ exports[`generateRouteRecord > removes trailing slashes 1`] = ` path: '/nested', name: '/nested', component: () => import('nested.vue'), - /* no props */ children: [ { path: '', name: '/nested/', component: () => import('nested/index.vue'), - /* no props */ /* no children */ }, { path: 'other', name: '/nested/other', component: () => import('nested/other.vue'), - /* no props */ /* no children */ } ], @@ -574,20 +507,17 @@ exports[`generateRouteRecord > removes trailing slashes 1`] = ` path: '/users', /* no name */ /* no component */ - /* no props */ children: [ { path: '', name: '/users/', component: () => import('users/index.vue'), - /* no props */ /* no children */ }, { path: 'other', name: '/users/other', component: () => import('users/other.vue'), - /* no props */ /* no children */ } ], @@ -601,7 +531,6 @@ exports[`generateRouteRecord > route block > adds meta data 1`] = ` path: '/', name: '/', component: () => import('index.vue'), - /* no props */ /* no children */ meta: { \\"auth\\": true, @@ -620,7 +549,6 @@ exports[`generateRouteRecord > route block > handles named views with empty rout 'default': () => import('index.vue'), 'named': () => import('index@named.vue') }, - /* no props */ /* no children */ meta: { \\"auth\\": true, @@ -636,7 +564,6 @@ exports[`generateRouteRecord > route block > merges deep meta properties 1`] = ` path: '/', name: '/', component: () => import('index.vue'), - /* no props */ /* no children */ meta: { \\"a\\": { @@ -660,7 +587,6 @@ exports[`generateRouteRecord > route block > merges multiple meta properties 1`] path: '/custom', name: 'hello', component: () => import('index.vue'), - /* no props */ /* no children */ meta: { \\"one\\": true, @@ -676,7 +602,6 @@ exports[`generateRouteRecord > route block > merges regardless of order 1`] = ` path: '/', name: 'b', component: () => import('index.vue'), - /* no props */ /* no children */ } ]" @@ -688,21 +613,18 @@ exports[`generateRouteRecord > works with some paths at root 1`] = ` path: '/a', name: '/a', component: () => import('a.vue'), - /* no props */ /* no children */ }, { path: '/b', name: '/b', component: () => import('b.vue'), - /* no props */ /* no children */ }, { path: '/c', name: '/c', component: () => import('c.vue'), - /* no props */ /* no children */ } ]" diff --git a/src/codegen/generateRouteMap.ts b/src/codegen/generateRouteMap.ts index f2f82a436..4b7bafe58 100644 --- a/src/codegen/generateRouteMap.ts +++ b/src/codegen/generateRouteMap.ts @@ -17,7 +17,7 @@ ${node.getSortedChildren().map(generateRouteNamedMap).join('')}}` return ( // if the node has a filePath, it's a component, it has a routeName and it should be referenced in the RouteNamedMap // otherwise it should be skipped to avoid navigating to a route that doesn't render anything - (node.value.filePaths.size + (node.value.components.size ? ` '${node.name}': ${generateRouteRecordInfo(node)},\n` : '') + (node.children.size > 0 diff --git a/src/codegen/generateRouteRecords.ts b/src/codegen/generateRouteRecords.ts index 9b21eb597..0cd020bc2 100644 --- a/src/codegen/generateRouteRecords.ts +++ b/src/codegen/generateRouteRecords.ts @@ -23,17 +23,20 @@ ${node // TODO: should meta be defined a different way to allow preserving imports? // const meta = node.value.overrides.meta + // compute once since it's a getter + const overrides = node.value.overrides + // path const routeRecord = `${startIndent}{ ${indentStr}path: '${node.path}', ${indentStr}${ - node.value.filePaths.size ? `name: '${node.name}',` : '/* no name */' + node.value.components.size ? `name: '${node.name}',` : '/* no name */' } ${ // component indentStr }${ - node.value.filePaths.size + node.value.components.size ? generateRouteRecordComponent( node, indentStr, @@ -42,18 +45,14 @@ ${ ) : '/* no component */' } -${ - // props - indentStr -}${ - node.value.overrides.props != null - ? `props: ${node.value.overrides.props},` - : '/* no props */' - } -${ - // children - indentStr -}${ +${overrides.props != null ? indentStr + `props: ${overrides.props},\n` : ''}${ + overrides.alias != null + ? indentStr + `alias: ${JSON.stringify(overrides.alias)},\n` + : '' + }${ + // children + indentStr + }${ node.children.size > 0 ? `children: [ ${node @@ -67,7 +66,7 @@ ${startIndent}}` if (node.hasDefinePage) { const definePageDataList: string[] = [] - for (const [name, filePath] of node.value.filePaths) { + for (const [name, filePath] of node.value.components) { const pageDataImport = `_definePage_${name}_${importList.size}` definePageDataList.push(pageDataImport) importList.set(pageDataImport, `${filePath}?definePage&vue`) @@ -90,7 +89,7 @@ function generateRouteRecordComponent( importMode: _OptionsImportMode, importList: Map ): string { - const files = Array.from(node.value.filePaths) + const files = Array.from(node.value.components) const isDefaultExport = files.length === 1 && files[0][0] === 'default' return isDefaultExport ? `component: ${generatePageImport(files[0][1], importMode, importList)},` @@ -133,7 +132,7 @@ function generatePageImport( } function generateImportList(node: TreeNode, indentStr: string) { - const files = Array.from(node.value.filePaths) + const files = Array.from(node.value.components) return `[ ${files diff --git a/src/core/context.ts b/src/core/context.ts index 26f66efa9..f517bdaa7 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -26,6 +26,7 @@ export function createRoutesContext(options: ResolvedOptions) { : resolve(root, preferDTS) const routeTree = createPrefixTree(options) + const editableRoutes = new EditableTreeNode(routeTree) function log(...args: any[]) { if (options.logs) { @@ -56,8 +57,10 @@ export function createRoutesContext(options: ResolvedOptions) { .map((extension) => extension.replace('.', '')) .join(',')}}`) + // get the initial list of pages await Promise.all( routesFolder.map((folder) => { + // TODO: skip creating watchers during build const watcher = new RoutesFolderWatcher(folder, options) setupWatcher(watcher) watchers.push(watcher) @@ -82,6 +85,11 @@ export function createRoutesContext(options: ResolvedOptions) { }) ) + for (const route of editableRoutes) { + await options.extendRoute?.(route) + } + + // immediately write the files without the throttle await _writeConfigFiles() } @@ -100,28 +108,35 @@ export function createRoutesContext(options: ResolvedOptions) { options.dataFetching && (await hasNamedExports(path)) } - async function addPage({ filePath: path, routePath }: HandlerContext) { - log(`added "${routePath}" for "${path}"`) + async function addPage( + { filePath, routePath }: HandlerContext, + triggerExtendRoute = false + ) { + log(`added "${routePath}" for "${filePath}"`) // TODO: handle top level named view HMR - const node = routeTree.insert(routePath, path) + const node = routeTree.insert(routePath, filePath) - await writeRouteInfoToNode(node, path) + await writeRouteInfoToNode(node, filePath) + + if (triggerExtendRoute) { + await options.extendRoute?.(new EditableTreeNode(node)) + } } - async function updatePage({ filePath: path, routePath }: HandlerContext) { - log(`updated "${routePath}" for "${path}"`) - const node = routeTree.getChild(path) + async function updatePage({ filePath, routePath }: HandlerContext) { + log(`updated "${routePath}" for "${filePath}"`) + const node = routeTree.getChild(filePath) if (!node) { - console.warn(`Cannot update "${path}": Not found.`) + console.warn(`Cannot update "${filePath}": Not found.`) return } - writeRouteInfoToNode(node, path) + await writeRouteInfoToNode(node, filePath) + await options.extendRoute?.(new EditableTreeNode(node)) } - // TODO: the map should be integrated with the root tree to have one source of truth only - function removePage({ filePath: path, routePath }: HandlerContext) { - log(`remove "${routePath}" for "${path}"`) - routeTree.removeChild(path) + function removePage({ filePath, routePath }: HandlerContext) { + log(`remove "${routePath}" for "${filePath}"`) + routeTree.removeChild(filePath) } function setupWatcher(watcher: RoutesFolderWatcher) { @@ -132,7 +147,7 @@ export function createRoutesContext(options: ResolvedOptions) { writeConfigFiles() }) .on('add', async (ctx) => { - await addPage(ctx) + await addPage(ctx, true) writeConfigFiles() }) .on('unlink', async (ctx) => { diff --git a/src/core/extendRoutes.ts b/src/core/extendRoutes.ts index b6ea4e245..f58f78e09 100644 --- a/src/core/extendRoutes.ts +++ b/src/core/extendRoutes.ts @@ -1,68 +1,125 @@ -import { createPrefixTree, TreeNode } from "./tree"; - -export class ExtendableRoutes { - tree: TreeNode +import { RouteMeta } from 'vue-router' +import { CustomRouteBlock } from './customBlock' +import { type TreeNode } from './tree' + +/** + * A route node that can be modified by the user. + * + * @experimental + */ +export class EditableTreeNode { + private node: TreeNode + // private _parent?: EditableTreeNode - constructor(tree: TreeNode) { - this.tree = tree + constructor(node: TreeNode) { + this.node = node } /** - * Traverse the tree in BFS order. - * - * @returns + * Remove and detach the current route node from the tree. Subsequently, its children will be removed as well. */ - *[Symbol.iterator]() { - let currentNode: TreeNode | null = this.tree - let currentChildren = this.tree.children - - while (currentNode) { - yield* currentChildren - - - } + delete() { + return this.node.delete() } -} -export class EditableTreeNode { - node: TreeNode - parentName?: string + /** + * Inserts a new route as a child of this route. + */ + insert(path: string, filePath: string) { + const node = this.node.insert(path, filePath) + // TODO: read definePage from file + return new EditableTreeNode(node) + } - constructor(node: TreeNode, parentName?: string) { - this.node = node - this.parentName = parentName + /** + * Get an editable version of the parent node if it exists. + */ + get parent() { + return this.node.parent && new EditableTreeNode(this.node.parent) } /** - * Remove the current route and all its children. + * Return a Map of the files associated to the current route. The key of the map represents the name of the view (Vue + * Router feature) while the value is the file path. By default, the name of the view is `default`. */ - delete() { - if (this.node.parent) { - this.node.delete() - // throw new Error('Cannot delete the root node.') - } - // this.node.parent.remove(this.node.path.slice(1)) + get files() { + return this.node.value.components } /** - * Append a new route as a children of this route. + * Name of the route. Note that **all routes are named** but when the final `routes` array is generated, routes + * without a `component` will not include their `name` property to avoid accidentally navigating to them and display + * nothing. {@see isPassThrough} */ - append() { + get name(): string { + return this.node.name + } + /** + * Override the name of the route. + */ + set name(name: string | undefined) { + this.node.value.addEditOverride({ name }) } - get name() { - return this.node.name + /** + * Whether the route is a pass-through route. A pass-through route is a route that does not have a component and is + * used to group other routes under the same prefix `path` and/or `meta` properties. + */ + get isPassThrough() { + return this.node.value.components.size === 0 } + /** + * Meta property of the route as an object. Note this property is readonly and will be serialized as JSON. It won't contain the meta properties defined with `definePage()` as it could contain expressions **but it does contain the meta properties defined with `` blocks**. + */ get meta() { return this.node.metaAsObject } + /** + * Override the meta property of the route. The passed object will be deeply merged with the existing meta object if any. + * Note that the meta property is later on serialized as JSON so you can't pass functions or any other + * non-serializable value. + */ + set meta(meta: RouteMeta) { + this.node.value.addEditOverride({ meta }) + } + + /** + * Path of the route without parent paths. + */ get path() { return this.node.path } + // TODO: implement if needed. It requires to add logic to the generation of routes to include an alias + /** + * Alias of the route. + */ + get alias() { + return this.node.value.overrides.alias + } + + /** + * Add an alias to the route. + * + * @param alias - Alias to add to the route + */ + addAlias(alias: CustomRouteBlock['alias']) { + this.node.value.addEditOverride({ alias }) + } + + /** + * Array of the route params and all of its parent's params. + */ + get params() { + return this.node.params + } + + /** + * Path of the route including parent paths. + */ get fullPath() { return this.node.fullPath } @@ -77,13 +134,12 @@ export class EditableTreeNode { * ``` */ *traverseDFS(): Generator { - // the root node is special + // The root node is not a route, so we skip it if (!this.node.isRoot()) { yield this } - for (const [name, child] of this.node.children) { - // console.debug(`CHILD: ${_name} - ${child.fullPath}`) - yield* new EditableTreeNode(child, name).traverseDFS() + for (const [_name, child] of this.node.children) { + yield* new EditableTreeNode(child).traverseDFS() } } @@ -102,21 +158,12 @@ export class EditableTreeNode { * ``` */ *traverseBFS(): Generator { - for (const [name, child] of this.node.children) { - yield new EditableTreeNode(child, name) + for (const [_name, child] of this.node.children) { + yield new EditableTreeNode(child) } // we need to traverse again in case the user removed a route - for (const [name, child] of this.node.children) { - yield* new EditableTreeNode(child, name).traverseBFS() + for (const [_name, child] of this.node.children) { + yield* new EditableTreeNode(child).traverseBFS() } } } - -function testy() { - const tree = createPrefixTree({} as any) - const route = new EditableTreeNode(tree) - - for (const r of route) { - console.log(r.name) - } -} diff --git a/src/core/tree.spec.ts b/src/core/tree.spec.ts index 9f9eb5504..ef2833453 100644 --- a/src/core/tree.spec.ts +++ b/src/core/tree.spec.ts @@ -131,7 +131,7 @@ describe('Tree', () => { tree.insert('not.nested.path@b.vue') tree.insert('deep/not.nested.path@a.vue') tree.insert('deep/not.nested.path@b.vue') - expect([...tree.children.get('index')!.value.filePaths.keys()]).toEqual([ + expect([...tree.children.get('index')!.value.components.keys()]).toEqual([ 'default', 'a', 'b', @@ -140,29 +140,29 @@ describe('Tree', () => { ...tree.children .get('nested')! .children.get('foo')! - .value.filePaths.keys(), + .value.components.keys(), ]).toEqual(['a', 'b']) expect([ ...tree.children .get('nested')! .children.get('[id]')! - .value.filePaths.keys(), + .value.components.keys(), ]).toEqual(['a', 'b']) expect([ - ...tree.children.get('not.nested.path')!.value.filePaths.keys(), + ...tree.children.get('not.nested.path')!.value.components.keys(), ]).toEqual(['a', 'b']) expect([ ...tree.children .get('deep')! .children.get('not.nested.path')! - .value.filePaths.keys(), + .value.components.keys(), ]).toEqual(['a', 'b']) }) it('handles single named views that are not default', () => { const tree = createPrefixTree(DEFAULT_OPTIONS) tree.insert('index@a.vue') - expect([...tree.children.get('index')!.value.filePaths.keys()]).toEqual([ + expect([...tree.children.get('index')!.value.components.keys()]).toEqual([ 'a', ]) }) @@ -217,7 +217,7 @@ describe('Tree', () => { expect(index).toBeDefined() const a = tree.children.get('a')! expect(a).toBeDefined() - expect(a.value.filePaths.get('default')).toBeUndefined() + expect(a.value.components.get('default')).toBeUndefined() expect(a.value).toMatchObject({ rawSegment: 'a', path: '/a', @@ -232,7 +232,7 @@ describe('Tree', () => { }) tree.insert('a.vue') - expect(a.value.filePaths.get('default')).toBe('a.vue') + expect(a.value.components.get('default')).toBe('a.vue') expect(a.value).toMatchObject({ rawSegment: 'a', path: '/a', @@ -371,7 +371,7 @@ describe('Tree', () => { const a = tree.children.get('a')! expect(a).toBeDefined() - expect(a.value.filePaths.get('default')).toBe('a.page.vue') + expect(a.value.components.get('default')).toBe('a.page.vue') expect(a.fullPath).toBe('/a') const nested = tree.children.get('nested')! @@ -382,13 +382,13 @@ describe('Tree', () => { expect(b.children.size).toBe(1) const c = b.children.get('c')! expect(c).toBeDefined() - expect(c.value.filePaths.get('default')).toBe('nested/b/c.page.vue') + expect(c.value.components.get('default')).toBe('nested/b/c.page.vue') expect(c.fullPath).toBe('/nested/b/c') tree.insert('a/nested.page.vue') const aNested = a.children.get('nested')! expect(aNested).toBeDefined() - expect(aNested.value.filePaths.get('default')).toBe('a/nested.page.vue') + expect(aNested.value.components.get('default')).toBe('a/nested.page.vue') expect(aNested.fullPath).toBe('/a/nested') }) }) diff --git a/src/core/tree.ts b/src/core/tree.ts index 7bde03f25..c4aaf5b90 100644 --- a/src/core/tree.ts +++ b/src/core/tree.ts @@ -3,6 +3,7 @@ import { createTreeNodeValue, TreeRouteParam } from './treeNodeValue' import type { TreeNodeValue } from './treeNodeValue' import { trimExtension } from './utils' import { CustomRouteBlock } from './customBlock' +import { RouteMeta } from 'vue-router' export class TreeNode { /** @@ -51,11 +52,11 @@ export class TreeNode { if (!this.children.has(segment)) { this.children.set(segment, new TreeNode(this.options, segment, this)) - } + } // TODO: else error or still override? const child = this.children.get(segment)! if (isComponent) { - child.value.filePaths.set(viewName, filePath) + child.value.components.set(viewName, filePath) } if (tail) { @@ -109,16 +110,16 @@ export class TreeNode { if (tail) { child.remove(tail) // if the child doesn't create any route - if (child.children.size === 0 && child.value.filePaths.size === 0) { + if (child.children.size === 0 && child.value.components.size === 0) { this.children.delete(segment) } } else { // it can only be component because we only listen for removed files, not folders if (isComponent) { - child.value.filePaths.delete(viewName) + child.value.components.delete(viewName) } // this is the file we wanted to remove - if (child.children.size === 0 && child.value.filePaths.size === 0) { + if (child.children.size === 0 && child.value.components.size === 0) { this.children.delete(segment) } } @@ -151,7 +152,7 @@ export class TreeNode { /** * Returns the meta property as an object. */ - get metaAsObject() { + get metaAsObject(): Readonly { const meta = { ...this.value.overrides.meta, } @@ -193,16 +194,17 @@ export class TreeNode { * @returns true if the node is the root node */ isRoot() { - return this.value.path === '/' && !this.value.filePaths.size + return this.value.path === '/' && !this.value.components.size } toString(): string { return `${this.value}${ // either we have multiple names - this.value.filePaths.size > 1 || + this.value.components.size > 1 || // or we have one name and it's not default - (this.value.filePaths.size === 1 && !this.value.filePaths.get('default')) - ? ` ⎈(${Array.from(this.value.filePaths.keys()).join(', ')})` + (this.value.components.size === 1 && + !this.value.components.get('default')) + ? ` ⎈(${Array.from(this.value.components.keys()).join(', ')})` : '' }${this.hasDefinePage ? ' ⚑ definePage()' : ''}` } diff --git a/src/core/treeNodeValue.ts b/src/core/treeNodeValue.ts index 1cc8d5a76..fa7a8fbbf 100644 --- a/src/core/treeNodeValue.ts +++ b/src/core/treeNodeValue.ts @@ -14,6 +14,9 @@ export interface RouteRecordOverride export type SubSegment = string | TreeRouteParam +// internal name used for overrides done by the user at build time +export const EDITS_OVERRIDE_NAME = '@@edits' + class _TreeNodeValueBase { /** * flag based on the type of the segment @@ -45,15 +48,15 @@ class _TreeNodeValueBase { private _overrides = new Map() /** - * Should this add the loader guard in the route record. + * Should we add the loader guard to the route record. */ includeLoaderGuard: boolean = false /** - * Component path that maps to a view name, which is used for vue-router's named view feature. - * Use `default` key for the default view. + * View name (Vue Router feature) mapped to their corresponding file. By default, the view name is `default` unless + * specified with a `@` e.g. `index@aux.vue` will have a view name of `aux`. */ - filePaths: Map + components = new Map() constructor( rawSegment: string, @@ -72,7 +75,6 @@ class _TreeNodeValueBase { (!parentPath || parentPath === '/') && this.pathSegment === '' ? '/' : joinPath(parent?.path || '', this.pathSegment) - this.filePaths = new Map() } toString(): string { @@ -90,7 +92,13 @@ class _TreeNodeValueBase { get overrides() { return [...this._overrides.entries()] .sort(([nameA], [nameB]) => - nameA === nameB ? 0 : nameA < nameB ? -1 : 1 + nameA === nameB + ? 0 + : // EDITS_OVERRIDE_NAME should always be last + nameA !== EDITS_OVERRIDE_NAME && + (nameA < nameB || nameB === EDITS_OVERRIDE_NAME) + ? -1 + : 1 ) .reduce((acc, [_path, routeBlock]) => { return mergeRouteRecordOverride(acc, routeBlock) @@ -100,6 +108,28 @@ class _TreeNodeValueBase { setOverride(path: string, routeBlock: CustomRouteBlock | undefined) { this._overrides.set(path, routeBlock || {}) } + + /** + * Remove all overrides for a given key. + * + * @param key - key to remove from the override + */ + removeOverride(key: keyof CustomRouteBlock) { + this._overrides.forEach((routeBlock) => { + // @ts-expect-error + delete routeBlock[key] + }) + } + + mergeOverride(path: string, routeBlock: CustomRouteBlock) { + const existing = this._overrides.get(path) || {} + this._overrides.set(path, mergeRouteRecordOverride(existing, routeBlock)) + } + + addEditOverride(routeBlock: CustomRouteBlock) { + console.log('add edit', routeBlock) + return this.mergeOverride(EDITS_OVERRIDE_NAME, routeBlock) + } } export class TreeNodeValueStatic extends _TreeNodeValueBase { @@ -198,11 +228,12 @@ function parseSegment( ? '*' : '?' : currentTreeRouteParam.repeatable - ? '+' - : '' + ? '+' + : '' buffer = '' - pathSegment += `:${currentTreeRouteParam.paramName}${currentTreeRouteParam.isSplat ? '(.*)' : '' - }${currentTreeRouteParam.modifier}` + pathSegment += `:${currentTreeRouteParam.paramName}${ + currentTreeRouteParam.isSplat ? '(.*)' : '' + }${currentTreeRouteParam.modifier}` params.push(currentTreeRouteParam) subSegments.push(currentTreeRouteParam) currentTreeRouteParam = createEmptyRouteParam() diff --git a/src/core/utils.ts b/src/core/utils.ts index d5c96c5f5..497b9c96d 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -30,8 +30,9 @@ function printTree( const hasNext = index++ < total - 1 const { children } = child - treeStr += `${`${parentPre}${hasNext ? '├' : '└'}${'─' + (children.size > 0 ? '┬' : '') - } `}${child}\n` + treeStr += `${`${parentPre}${hasNext ? '├' : '└'}${ + '─' + (children.size > 0 ? '┬' : '') + } `}${child}\n` if (children) { treeStr += printTree( @@ -114,15 +115,17 @@ export function joinPath(...paths: string[]): string { } function paramToName({ paramName, modifier, isSplat }: TreeRouteParam) { - return `${isSplat ? '$' : ''}${paramName.charAt(0).toUpperCase() + paramName.slice(1) - }${modifier + return `${isSplat ? '$' : ''}${ + paramName.charAt(0).toUpperCase() + paramName.slice(1) + }${ + modifier // ? modifier === '+' // ? 'OneOrMore' // : modifier === '?' // ? 'ZeroOrOne' // : 'ZeroOrMore' // : '' - }` + }` } /** @@ -145,7 +148,7 @@ export function getPascalCaseRouteName(node: TreeNode): string { }) .join('') - if (node.value.filePaths.size && node.children.has('index')) { + if (node.value.components.size && node.children.has('index')) { name += 'Parent' } @@ -169,9 +172,7 @@ export function getFileBasedRouteName(node: TreeNode): string { return ( getFileBasedRouteName(node.parent) + '/' + - (node.value.rawSegment === 'index' - ? '' - : node.value.rawSegment) + (node.value.rawSegment === 'index' ? '' : node.value.rawSegment) ) } @@ -188,7 +189,8 @@ export function mergeRouteRecordOverride( ] for (const key of keys) { if (key === 'alias') { - merged[key] = [...(a[key] || []), ...(b[key] || [])] + const newAlias: string[] = [] + merged[key] = newAlias.concat(a.alias || [], b.alias || []) } else if (key === 'meta') { merged[key] = mergeDeep(a[key] || {}, b[key] || {}) } else { diff --git a/src/options.ts b/src/options.ts index 8680936f8..06b6012af 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,7 +1,8 @@ import { isPackageExists } from 'local-pkg' -import { getFileBasedRouteName, isArray } from './core/utils' +import { Awaitable, getFileBasedRouteName, isArray } from './core/utils' import type { TreeNode } from './core/tree' import { resolve } from 'pathe' +import { EditableTreeNode } from './core/extendRoutes' export interface RoutesFolderOption { src: string @@ -31,6 +32,15 @@ export interface ResolvedOptions { */ getRouteName: (node: TreeNode) => string + /** + * Allows to extend a route by modifying its node, adding children, or even deleting it. This will be invoked once for + * each route. + * + * @param route - {@link EditableTreeNode} of the route to extend + * @returns + */ + extendRoute?: (route: EditableTreeNode) => Awaitable + /** * Enables EXPERIMENTAL data fetching. See https://github.com/posva/unplugin-vue-router/tree/main/src/data-fetching */