diff --git a/README.md b/README.md index 2225afeab..244e12d88 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,10 @@ VueRouter({ // Customizes the default langage for `` blocks // json5 is just a more permissive version of json routeBlockLang: 'json5', + + // Change the import mode of page components. Can be 'async', 'sync', or a function with the following signature: + // (filepath: string) => 'async' | 'sync' + importMode: 'async', }) ``` diff --git a/src/codegen/__snapshots__/generateRouteRecords.spec.ts.snap b/src/codegen/__snapshots__/generateRouteRecords.spec.ts.snap index 690332582..9daa86f1b 100644 --- a/src/codegen/__snapshots__/generateRouteRecords.spec.ts.snap +++ b/src/codegen/__snapshots__/generateRouteRecords.spec.ts.snap @@ -69,6 +69,104 @@ exports[`generateRouteRecord > correctly names index.vue files 1`] = ` ]" `; +exports[`generateRouteRecord > generate custom imports 1`] = ` +"[ + { + path: '/a', + name: '/a', + component: _page_a_vue, + /* 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 */ + } + ], + } + ], + } +]" +`; + +exports[`generateRouteRecord > generate custom imports 2`] = ` +Map { + "a.vue" => "_page_a_vue", +} +`; + +exports[`generateRouteRecord > generate static imports 1`] = ` +"[ + { + path: '/a', + name: '/a', + component: _page_a_vue, + /* no props */ + /* no children */ + }, + { + path: '/b', + name: '/b', + component: _page_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: _page_nested_file_c_vue, + /* no props */ + /* no children */ + } + ], + } + ], + } +]" +`; + +exports[`generateRouteRecord > generate static imports 2`] = ` +Map { + "a.vue" => "_page_a_vue", + "b.vue" => "_page_b_vue", + "nested/file/c.vue" => "_page_nested_file_c_vue", +} +`; + exports[`generateRouteRecord > handles multiple named views 1`] = ` "[ { diff --git a/src/codegen/generateRouteRecords.spec.ts b/src/codegen/generateRouteRecords.spec.ts index 2f4d8dc43..c0d6346b5 100644 --- a/src/codegen/generateRouteRecords.spec.ts +++ b/src/codegen/generateRouteRecords.spec.ts @@ -1,14 +1,18 @@ +import { basename } from 'pathe' import { describe, expect, it } from 'vitest' -import { createPrefixTree } from '../core/tree' -import { DEFAULT_OPTIONS } from '../options' -import { RouteRecordRaw } from 'vue-router' +import { createPrefixTree, TreeLeaf } from '../core/tree' +import { DEFAULT_OPTIONS, ResolvedOptions } from '../options' import { generateRouteRecord } from './generateRouteRecords' describe('generateRouteRecord', () => { + function generateRouteRecordSimple(tree: TreeLeaf) { + return generateRouteRecord(tree, DEFAULT_OPTIONS, new Map()) + } + it('works with an empty tree', () => { const tree = createPrefixTree(DEFAULT_OPTIONS) - expect(generateRouteRecord(tree)).toMatchInlineSnapshot(` + expect(generateRouteRecordSimple(tree)).toMatchInlineSnapshot(` "[ ]" @@ -20,7 +24,7 @@ describe('generateRouteRecord', () => { tree.insert('a.vue') tree.insert('b.vue') tree.insert('c.vue') - expect(generateRouteRecord(tree)).toMatchSnapshot() + expect(generateRouteRecordSimple(tree)).toMatchSnapshot() }) it('handles multiple named views', () => { @@ -28,13 +32,13 @@ describe('generateRouteRecord', () => { tree.insert('foo.vue') tree.insert('foo@a.vue') tree.insert('foo@b.vue') - expect(generateRouteRecord(tree)).toMatchSnapshot() + expect(generateRouteRecordSimple(tree)).toMatchSnapshot() }) it('handles single named views', () => { const tree = createPrefixTree(DEFAULT_OPTIONS) tree.insert('foo@a.vue') - expect(generateRouteRecord(tree)).toMatchSnapshot() + expect(generateRouteRecordSimple(tree)).toMatchSnapshot() }) it('nested children', () => { @@ -45,10 +49,10 @@ describe('generateRouteRecord', () => { tree.insert('b/b.vue') tree.insert('b/c.vue') tree.insert('b/d.vue') - expect(generateRouteRecord(tree)).toMatchSnapshot() + expect(generateRouteRecordSimple(tree)).toMatchSnapshot() tree.insert('c.vue') tree.insert('d.vue') - expect(generateRouteRecord(tree)).toMatchSnapshot() + expect(generateRouteRecordSimple(tree)).toMatchSnapshot() }) it('adds children and name when folder and component exist', () => { @@ -57,14 +61,14 @@ describe('generateRouteRecord', () => { tree.insert('b/c.vue') tree.insert('a.vue') tree.insert('d.vue') - expect(generateRouteRecord(tree)).toMatchSnapshot() + expect(generateRouteRecordSimple(tree)).toMatchSnapshot() }) it('correctly names index.vue files', () => { const tree = createPrefixTree(DEFAULT_OPTIONS) tree.insert('index.vue') tree.insert('b/index.vue') - expect(generateRouteRecord(tree)).toMatchSnapshot() + expect(generateRouteRecordSimple(tree)).toMatchSnapshot() }) it('handles non nested routes', () => { @@ -78,7 +82,39 @@ describe('generateRouteRecord', () => { tree.insert('users/[id].vue') tree.insert('users/[id].not-nested.vue') tree.insert('users.[id].also-not-nested.vue') - expect(generateRouteRecord(tree)).toMatchSnapshot() + expect(generateRouteRecordSimple(tree)).toMatchSnapshot() + }) + + it('generate static imports', () => { + const options: ResolvedOptions = { + ...DEFAULT_OPTIONS, + importMode: 'sync', + } as const + const tree = createPrefixTree(options) + tree.insert('a.vue') + tree.insert('b.vue') + tree.insert('nested/file/c.vue') + const importList = new Map() + expect(generateRouteRecord(tree, options, importList)).toMatchSnapshot() + + expect(importList).toMatchSnapshot() + }) + + it('generate custom imports', () => { + const options: ResolvedOptions = { + ...DEFAULT_OPTIONS, + importMode: (filepath) => + basename(filepath) === 'a.vue' ? 'sync' : 'async', + } + + const tree = createPrefixTree(options) + tree.insert('a.vue') + tree.insert('b.vue') + tree.insert('nested/file/c.vue') + const importList = new Map() + expect(generateRouteRecord(tree, options, importList)).toMatchSnapshot() + + expect(importList).toMatchSnapshot() }) describe('names', () => { @@ -91,7 +127,7 @@ describe('generateRouteRecord', () => { tree.insert('users/[id]/edit.vue') tree.insert('users/new.vue') - expect(generateRouteRecord(tree)).toMatchSnapshot() + expect(generateRouteRecordSimple(tree)).toMatchSnapshot() }) it('creates multi word names', () => { @@ -101,7 +137,7 @@ describe('generateRouteRecord', () => { tree.insert('MyPascalCaseUsers.vue') tree.insert('some-nested/file-with-[id]-in-the-middle.vue') - expect(generateRouteRecord(tree)).toMatchSnapshot() + expect(generateRouteRecordSimple(tree)).toMatchSnapshot() }) it('works with nested views', () => { @@ -112,7 +148,7 @@ describe('generateRouteRecord', () => { tree.insert('users/[id]/edit.vue') tree.insert('users/[id].vue') - expect(generateRouteRecord(tree)).toMatchSnapshot() + expect(generateRouteRecordSimple(tree)).toMatchSnapshot() }) }) @@ -127,7 +163,7 @@ describe('generateRouteRecord', () => { }, }) - expect(generateRouteRecord(tree)).toMatchSnapshot() + expect(generateRouteRecordSimple(tree)).toMatchSnapshot() }) it('merges multiple meta properties', async () => { @@ -146,7 +182,7 @@ describe('generateRouteRecord', () => { }, }) - expect(generateRouteRecord(tree)).toMatchSnapshot() + expect(generateRouteRecordSimple(tree)).toMatchSnapshot() }) it('merges regardless of order', async () => { @@ -159,7 +195,7 @@ describe('generateRouteRecord', () => { name: 'b', }) - const one = generateRouteRecord(tree) + const one = generateRouteRecordSimple(tree) node.setCustomRouteBlock('index@named.vue', { name: 'b', @@ -168,7 +204,7 @@ describe('generateRouteRecord', () => { name: 'a', }) - expect(generateRouteRecord(tree)).toBe(one) + expect(generateRouteRecordSimple(tree)).toBe(one) expect(one).toMatchSnapshot() }) @@ -188,11 +224,11 @@ describe('generateRouteRecord', () => { // coming from index@named.vue (no route block) node.setCustomRouteBlock('index@named.vue', undefined) - expect(generateRouteRecord(tree)).toMatchSnapshot() + expect(generateRouteRecordSimple(tree)).toMatchSnapshot() }) // FIXME: allow aliases - it('merges alias properties', async () => { + it.todo('merges alias properties', async () => { const tree = createPrefixTree(DEFAULT_OPTIONS) const node = tree.insert('index.vue') node.setCustomRouteBlock('index.vue', { @@ -202,7 +238,7 @@ describe('generateRouteRecord', () => { alias: ['/two', '/three'], }) - expect(generateRouteRecord(tree)).toMatchInlineSnapshot(` + expect(generateRouteRecordSimple(tree)).toMatchInlineSnapshot(` "[ { path: '/', @@ -231,7 +267,7 @@ describe('generateRouteRecord', () => { }, }) - expect(generateRouteRecord(tree)).toMatchSnapshot() + expect(generateRouteRecordSimple(tree)).toMatchSnapshot() }) }) }) diff --git a/src/codegen/generateRouteRecords.ts b/src/codegen/generateRouteRecords.ts index aeb8446f0..bd4dbdb72 100644 --- a/src/codegen/generateRouteRecords.ts +++ b/src/codegen/generateRouteRecords.ts @@ -1,12 +1,19 @@ import type { TreeLeaf } from '../core/tree' +import { ResolvedOptions, _OptionsImportMode } from '../options' +import { basename } from 'pathe' -export function generateRouteRecord(node: TreeLeaf, indent = 0): string { +export function generateRouteRecord( + node: TreeLeaf, + options: ResolvedOptions, + importList: Map, + indent = 0 +): string { // root if (node.value.path === '/' && indent === 0) { return `[ ${node .getSortedChildren() - .map((child) => generateRouteRecord(child, indent + 1)) + .map((child) => generateRouteRecord(child, options, importList, indent + 1)) .join(',\n')} ]` } @@ -28,7 +35,12 @@ ${ indentStr }${ node.value.filePaths.size - ? generateRouteRecordComponent(node, indentStr) + ? generateRouteRecordComponent( + node, + indentStr, + options.importMode, + importList + ) : '/* no component */' } ${ @@ -47,7 +59,7 @@ ${ ? `children: [ ${node .getSortedChildren() - .map((child) => generateRouteRecord(child, indent + 2)) + .map((child) => generateRouteRecord(child, options, importList, indent + 2)) .join(',\n')} ${indentStr}],` : '/* no children */' @@ -57,20 +69,52 @@ ${startIndent}}` function generateRouteRecordComponent( node: TreeLeaf, - indentStr: string + indentStr: string, + importMode: _OptionsImportMode, + importList: Map ): string { const files = Array.from(node.value.filePaths) const isDefaultExport = files.length === 1 && files[0][0] === 'default' return isDefaultExport - ? `component: () => import('${files[0][1]}'),` + ? `component: ${generatePageImport(files[0][1], importMode, importList)},` : // files has at least one entry `components: { ${files - .map(([key, path]) => `${indentStr + ' '}'${key}': () => import('${path}')`) + .map( + ([key, path]) => + `${indentStr + ' '}'${key}': ${generatePageImport( + path, + importMode, + importList + )}` + ) .join(',\n')} ${indentStr}},` } +/** + * Generate the import (dynamic or static) for the given filepath. If the filepath is a static import, add it to the + * @param filepath - the filepath to the file + * @param importMode - the import mode to use + * @param importList - the import list to fill + * @returns + */ +function generatePageImport( + filepath: string, + importMode: _OptionsImportMode, + importList: Map +) { + const mode = + typeof importMode === 'function' ? importMode(filepath) : importMode + if (mode === 'async') { + return `() => import('${filepath}')` + } else { + const importName = `_page_${filepath.replace(/[\/\.]/g, '_')}` + importList.set(filepath, importName) + return importName + } +} + function generateImportList(node: TreeLeaf, indentStr: string) { const files = Array.from(node.value.filePaths) diff --git a/src/core/context.ts b/src/core/context.ts index 1254aac33..a043c3e85 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -145,11 +145,29 @@ export function createRoutesContext(options: ResolvedOptions) { } function generateRoutes() { - const imports = options.dataFetching - ? `import { _LoaderSymbol } from 'unplugin-vue-router/runtime'\n\n` - : `` + const importList = new Map() + + const routesExport = `export const routes = ${generateRouteRecord( + routeTree, + options, + importList + )}` + + let imports = '' + if (options.dataFetching) { + imports += `import { _LoaderSymbol } from 'unplugin-vue-router/runtime'\n` + } + for (const [path, name] of importList) { + imports += `import ${name} from '${path}'\n` + } + + // add an empty line for readability + if (imports) { + imports += '\n' + } + return `${imports}\ -export const routes = ${generateRouteRecord(routeTree)} +${routesExport} ` } diff --git a/src/options.ts b/src/options.ts index 1dbb0b63b..8b29e8738 100644 --- a/src/options.ts +++ b/src/options.ts @@ -35,11 +35,10 @@ export interface ResolvedOptions { */ dataFetching: boolean - // TODO: - // importMode?: - // | 'sync' - // | 'async' - // | ((path: string, resolvedOptions: Options) => 'sync' | 'async') + /** + * Defines how page components should be imported. Defaults to dynamic imports to enable lazy loading of pages. + */ + importMode: _OptionsImportMode /** * Array of file globs to ignore. Defaults to `[]`. @@ -73,6 +72,14 @@ export interface ResolvedOptions { logs: boolean } +/** + * @internal + */ +export type _OptionsImportMode = + | 'sync' + | 'async' + | ((filepath: string) => 'sync' | 'async') + export type Options = Partial export const DEFAULT_OPTIONS: ResolvedOptions = { @@ -82,6 +89,7 @@ export const DEFAULT_OPTIONS: ResolvedOptions = { routeBlockLang: 'json5', getRouteName: getFileBasedRouteName, dataFetching: false, + importMode: 'async', root: process.cwd(), dts: isPackageExists('typescript'), logs: false,