(fn: () => T): T {
119 | return (scope = scope || effectScope(true)).run(fn)!
120 | }
121 |
122 | /**
123 | * Stop and invalidate the scope used for data. Note this will make any application stop working. It should be used only
124 | * if there is a need to manually stop a running application without stopping the process.
125 | */
126 | export function stopScope() {
127 | if (scope) {
128 | scope.stop()
129 | scope = undefined
130 | }
131 | }
132 |
133 | export let currentContext:
134 | | [DataLoaderEntry, Router, RouteLocationNormalizedLoaded]
135 | | undefined
136 | | null
137 |
138 | export function getCurrentContext() {
139 | // an empty array allows destructuring without checking if it's undefined
140 | return currentContext || ([] as const)
141 | }
142 | export function setCurrentContext(context: typeof currentContext) {
143 | currentContext = context
144 | }
145 |
146 | export function withLoaderContext>(promise: P): P {
147 | const context = currentContext
148 | return promise.finally(() => (currentContext = context)) as P
149 | }
150 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "unplugin-vue-router",
3 | "version": "0.7.0",
4 | "packageManager": "pnpm@8.10.2",
5 | "description": "File based typed routing for Vue Router",
6 | "keywords": [
7 | "vue-router",
8 | "pages",
9 | "filesystem",
10 | "types",
11 | "typed",
12 | "router",
13 | "unplugin",
14 | "vite",
15 | "webpack",
16 | "rollup"
17 | ],
18 | "homepage": "https://github.com/posva/unplugin-vue-router#readme",
19 | "bugs": {
20 | "url": "https://github.com/posva/unplugin-vue-router/issues"
21 | },
22 | "license": "MIT",
23 | "repository": {
24 | "type": "git",
25 | "url": "git+https://github.com/posva/unplugin-vue-router.git"
26 | },
27 | "main": "dist/index.js",
28 | "module": "dist/index.mjs",
29 | "types": "dist/index.d.ts",
30 | "exports": {
31 | ".": {
32 | "types": "./dist/index.d.ts",
33 | "require": "./dist/index.js",
34 | "import": "./dist/index.mjs"
35 | },
36 | "./vite": {
37 | "types": "./dist/vite.d.ts",
38 | "require": "./dist/vite.js",
39 | "import": "./dist/vite.mjs"
40 | },
41 | "./webpack": {
42 | "types": "./dist/webpack.d.ts",
43 | "require": "./dist/webpack.js",
44 | "import": "./dist/webpack.mjs"
45 | },
46 | "./rollup": {
47 | "types": "./dist/rollup.d.ts",
48 | "require": "./dist/rollup.js",
49 | "import": "./dist/rollup.mjs"
50 | },
51 | "./esbuild": {
52 | "types": "./dist/esbuild.d.ts",
53 | "require": "./dist/esbuild.js",
54 | "import": "./dist/esbuild.mjs"
55 | },
56 | "./options": {
57 | "types": "./dist/options.d.ts",
58 | "require": "./dist/options.js",
59 | "import": "./dist/options.mjs"
60 | },
61 | "./runtime": {
62 | "types": "./dist/runtime.d.ts",
63 | "require": "./dist/runtime.js",
64 | "import": "./dist/runtime.mjs"
65 | },
66 | "./types": {
67 | "types": "./dist/types.d.ts",
68 | "require": "./dist/types.js",
69 | "import": "./dist/types.mjs"
70 | },
71 | "./client": {
72 | "types": "./client.d.ts"
73 | },
74 | "./*": "./*"
75 | },
76 | "files": [
77 | "dist",
78 | "./route.schema.json",
79 | "*.d.ts"
80 | ],
81 | "scripts": {
82 | "build": "tsup",
83 | "dev": "tsup --watch src",
84 | "build:fix": "esno scripts/postbuild.ts",
85 | "lint": "prettier -c '{src,examples,playground}/**/*.{ts,vue}'",
86 | "play": "npm -C playground run dev",
87 | "play:build": "npm -C playground run build",
88 | "release": "node scripts/release.mjs",
89 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 1",
90 | "start": "esno src/index.ts",
91 | "test": "vitest"
92 | },
93 | "gitHooks": {
94 | "pre-commit": "lint-staged",
95 | "commit-msg": "node scripts/verifyCommit.mjs"
96 | },
97 | "lint-staged": {
98 | "*.js": [
99 | "prettier --write"
100 | ],
101 | "*.ts?(x)": [
102 | "prettier --parser=typescript --write"
103 | ]
104 | },
105 | "dependencies": {
106 | "@babel/types": "^7.23.0",
107 | "@rollup/pluginutils": "^5.0.5",
108 | "@vue-macros/common": "^1.8.0",
109 | "ast-walker-scope": "^0.5.0",
110 | "chokidar": "^3.5.3",
111 | "fast-glob": "^3.3.1",
112 | "json5": "^2.2.3",
113 | "local-pkg": "^0.5.0",
114 | "mlly": "^1.4.2",
115 | "pathe": "^1.1.1",
116 | "scule": "^1.0.0",
117 | "unplugin": "^1.5.0",
118 | "yaml": "^2.3.4"
119 | },
120 | "peerDependencies": {
121 | "vue-router": "^4.1.0"
122 | },
123 | "peerDependenciesMeta": {
124 | "vue-router": {
125 | "optional": true
126 | }
127 | },
128 | "devDependencies": {
129 | "@vitest/coverage-v8": "^0.34.6",
130 | "@volar/vue-language-core": "^1.6.5",
131 | "@vue/test-utils": "^2.4.1",
132 | "chalk": "^5.3.0",
133 | "conventional-changelog-cli": "^4.1.0",
134 | "enquirer": "^2.4.1",
135 | "esno": "^4.0.0",
136 | "execa": "^8.0.1",
137 | "happy-dom": "^12.10.3",
138 | "lint-staged": "^15.1.0",
139 | "minimist": "^1.2.8",
140 | "nodemon": "^3.0.2",
141 | "p-series": "^3.0.0",
142 | "prettier": "^2.8.8",
143 | "rimraf": "^5.0.5",
144 | "rollup": "^4.3.0",
145 | "semver": "^7.5.4",
146 | "ts-expect": "^1.3.0",
147 | "tsup": "^8.0.1",
148 | "typescript": "^5.2.2",
149 | "unplugin-auto-import": "^0.16.7",
150 | "vite": "^5.0.4",
151 | "vite-plugin-vue-markdown": "^0.23.8",
152 | "vitest": "^0.34.6",
153 | "vue": "^3.3.7",
154 | "vue-router": "^4.2.5",
155 | "vue-router-mock": "^1.0.0",
156 | "webpack": "^5.89.0",
157 | "yorkie": "^2.0.0"
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/src/codegen/__snapshots__/generateRoutes.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1
2 |
3 | exports[`toRouteRecordSTring > adds children and name when folder and component exist 1`] = `
4 | "[
5 | {
6 | path: \\"/a\\",
7 | name: \\"/a\\",
8 | component: () => import('a.vue'),
9 | children: [
10 | {
11 | path: \\"c\\",
12 | name: \\"/a/c\\",
13 | component: () => import('a/c.vue'),
14 | /* no children */
15 | }
16 | ],
17 | },
18 | {
19 | path: \\"/b\\",
20 | /* no name */
21 | /* no component */
22 | children: [
23 | {
24 | path: \\"c\\",
25 | name: \\"/b/c\\",
26 | component: () => import('b/c.vue'),
27 | /* no children */
28 | }
29 | ],
30 | },
31 | {
32 | path: \\"/d\\",
33 | name: \\"/d\\",
34 | component: () => import('d.vue'),
35 | /* no children */
36 | }
37 | ]"
38 | `;
39 |
40 | exports[`toRouteRecordSTring > correctly names index.vue files 1`] = `
41 | "[
42 | {
43 | path: \\"/\\",
44 | name: \\"/\\",
45 | component: () => import('index.vue'),
46 | /* no children */
47 | },
48 | {
49 | path: \\"/b\\",
50 | /* no name */
51 | /* no component */
52 | children: [
53 | {
54 | path: \\"\\",
55 | name: \\"/b/\\",
56 | component: () => import('b/index.vue'),
57 | /* no children */
58 | }
59 | ],
60 | }
61 | ]"
62 | `;
63 |
64 | exports[`toRouteRecordSTring > nested children 1`] = `
65 | "[
66 | {
67 | path: \\"/a\\",
68 | /* no name */
69 | /* no component */
70 | children: [
71 | {
72 | path: \\"a\\",
73 | name: \\"/a/a\\",
74 | component: () => import('a/a.vue'),
75 | /* no children */
76 | },
77 | {
78 | path: \\"b\\",
79 | name: \\"/a/b\\",
80 | component: () => import('a/b.vue'),
81 | /* no children */
82 | },
83 | {
84 | path: \\"c\\",
85 | name: \\"/a/c\\",
86 | component: () => import('a/c.vue'),
87 | /* no children */
88 | }
89 | ],
90 | },
91 | {
92 | path: \\"/b\\",
93 | /* no name */
94 | /* no component */
95 | children: [
96 | {
97 | path: \\"b\\",
98 | name: \\"/b/b\\",
99 | component: () => import('b/b.vue'),
100 | /* no children */
101 | },
102 | {
103 | path: \\"c\\",
104 | name: \\"/b/c\\",
105 | component: () => import('b/c.vue'),
106 | /* no children */
107 | },
108 | {
109 | path: \\"d\\",
110 | name: \\"/b/d\\",
111 | component: () => import('b/d.vue'),
112 | /* no children */
113 | }
114 | ],
115 | }
116 | ]"
117 | `;
118 |
119 | exports[`toRouteRecordSTring > nested children 2`] = `
120 | "[
121 | {
122 | path: \\"/a\\",
123 | /* no name */
124 | /* no component */
125 | children: [
126 | {
127 | path: \\"a\\",
128 | name: \\"/a/a\\",
129 | component: () => import('a/a.vue'),
130 | /* no children */
131 | },
132 | {
133 | path: \\"b\\",
134 | name: \\"/a/b\\",
135 | component: () => import('a/b.vue'),
136 | /* no children */
137 | },
138 | {
139 | path: \\"c\\",
140 | name: \\"/a/c\\",
141 | component: () => import('a/c.vue'),
142 | /* no children */
143 | }
144 | ],
145 | },
146 | {
147 | path: \\"/b\\",
148 | /* no name */
149 | /* no component */
150 | children: [
151 | {
152 | path: \\"b\\",
153 | name: \\"/b/b\\",
154 | component: () => import('b/b.vue'),
155 | /* no children */
156 | },
157 | {
158 | path: \\"c\\",
159 | name: \\"/b/c\\",
160 | component: () => import('b/c.vue'),
161 | /* no children */
162 | },
163 | {
164 | path: \\"d\\",
165 | name: \\"/b/d\\",
166 | component: () => import('b/d.vue'),
167 | /* no children */
168 | }
169 | ],
170 | },
171 | {
172 | path: \\"/c\\",
173 | name: \\"/c\\",
174 | component: () => import('c.vue'),
175 | /* no children */
176 | },
177 | {
178 | path: \\"/d\\",
179 | name: \\"/d\\",
180 | component: () => import('d.vue'),
181 | /* no children */
182 | }
183 | ]"
184 | `;
185 |
186 | exports[`toRouteRecordSTring > works with some paths at root 1`] = `
187 | "[
188 | {
189 | path: \\"/a\\",
190 | name: \\"/a\\",
191 | component: () => import('a.vue'),
192 | /* no children */
193 | },
194 | {
195 | path: \\"/b\\",
196 | name: \\"/b\\",
197 | component: () => import('b.vue'),
198 | /* no children */
199 | },
200 | {
201 | path: \\"/c\\",
202 | name: \\"/c\\",
203 | component: () => import('c.vue'),
204 | /* no children */
205 | }
206 | ]"
207 | `;
208 |
--------------------------------------------------------------------------------
/src/typeExtensions/RouterTyped.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, it } from 'vitest'
2 | import { expectType } from 'ts-expect'
3 | import type {
4 | RouteRecordInfo,
5 | _RouteMapGeneric,
6 | } from '../codegen/generateRouteMap'
7 | import type {
8 | ParamValue,
9 | ParamValueOneOrMore,
10 | } from '../codegen/generateRouteParams'
11 | import type { _RouterTyped as RouterTyped } from './router'
12 | import { RouteLocationTyped } from './routeLocation'
13 |
14 | function defineRouter(): RouterTyped {
15 | return {} as RouterTyped
16 | }
17 |
18 | function typeTest(fn: () => any) {
19 | return fn
20 | }
21 |
22 | describe('RouterTyped', () => {
23 | // type is needed instead of an interface
24 | // https://github.com/microsoft/TypeScript/issues/15300
25 | type RouteMap = {
26 | '/[...path]': RouteRecordInfo<
27 | '/[...path]',
28 | '/:path(.*)',
29 | { path: ParamValue },
30 | { path: ParamValue }
31 | >
32 | '/[a]': RouteRecordInfo<
33 | '/[a]',
34 | '/:a',
35 | { a: ParamValue },
36 | { a: ParamValue }
37 | >
38 | '/a': RouteRecordInfo<
39 | '/a',
40 | '/a',
41 | Record,
42 | Record
43 | >
44 | '/[id]+': RouteRecordInfo<
45 | '/[id]+',
46 | '/:id+',
47 | { id: ParamValueOneOrMore },
48 | { id: ParamValueOneOrMore }
49 | >
50 | }
51 | const router = defineRouter()
52 |
53 | it('resolve', () => {
54 | typeTest(() => {
55 | expectType>(router.resolve({ name: '/a' }).params)
56 | expectType<{ a: ParamValue }>(
57 | router.resolve({ name: '/[a]' }).params
58 | )
59 |
60 | expectType>(
61 | router.resolve({ name: '/a' })
62 | )
63 | expectType<'/a'>(
64 | // @ts-expect-error: cannot infer based on path
65 | router.resolve({ path: '/a' }).name
66 | )
67 | expectType(router.resolve({ path: '/a' }).name)
68 | })
69 | })
70 |
71 | it('resolve', () => {
72 | typeTest(() => {
73 | router.push({ name: '/a', params: { a: 2 } })
74 | // @ts-expect-error
75 | router.push({ name: '/[a]', params: {} })
76 | // still allow relative params
77 | router.push({ name: '/[a]' })
78 | // @ts-expect-error
79 | router.push({ name: '/[a]', params: { a: [2] } })
80 | router.push({ name: '/[id]+', params: { id: [2] } })
81 | router.push({ name: '/[id]+', params: { id: [2, '3'] } })
82 | // @ts-expect-error
83 | router.push({ name: '/[id]+', params: { id: 2 } })
84 | })
85 | })
86 |
87 | it('beforeEach', () => {
88 | typeTest(() => {
89 | router.beforeEach((to, from) => {
90 | // @ts-expect-error: no route named this way
91 | if (to.name === '/[id]') {
92 | } else if (to.name === '/[a]') {
93 | expectType<{ a: ParamValue }>(to.params)
94 | }
95 | // @ts-expect-error: no route named this way
96 | if (from.name === '/[id]') {
97 | } else if (to.name === '/[a]') {
98 | expectType<{ a: ParamValue }>(to.params)
99 | }
100 | if (Math.random()) {
101 | return { name: '/[a]', params: { a: 2 } }
102 | } else if (Math.random()) {
103 | return '/any route does'
104 | }
105 | return true
106 | })
107 | })
108 | })
109 |
110 | it('beforeResolve', () => {
111 | typeTest(() => {
112 | router.beforeResolve((to, from) => {
113 | // @ts-expect-error: no route named this way
114 | if (to.name === '/[id]') {
115 | } else if (to.name === '/[a]') {
116 | expectType<{ a: ParamValue }>(to.params)
117 | }
118 | // @ts-expect-error: no route named this way
119 | if (from.name === '/[id]') {
120 | } else if (to.name === '/[a]') {
121 | expectType<{ a: ParamValue }>(to.params)
122 | }
123 | if (Math.random()) {
124 | return { name: '/[a]', params: { a: 2 } }
125 | } else if (Math.random()) {
126 | return '/any route does'
127 | }
128 | return true
129 | })
130 | })
131 | })
132 |
133 | it('afterEach', () => {
134 | typeTest(() => {
135 | router.afterEach((to, from) => {
136 | // @ts-expect-error: no route named this way
137 | if (to.name === '/[id]') {
138 | } else if (to.name === '/[a]') {
139 | expectType<{ a: ParamValue }>(to.params)
140 | }
141 | // @ts-expect-error: no route named this way
142 | if (from.name === '/[id]') {
143 | } else if (to.name === '/[a]') {
144 | expectType<{ a: ParamValue }>(to.params)
145 | }
146 | if (Math.random()) {
147 | return { name: '/[a]', params: { a: 2 } }
148 | } else if (Math.random()) {
149 | return '/any route does'
150 | }
151 | return true
152 | })
153 | })
154 | })
155 | })
156 |
--------------------------------------------------------------------------------
/src/codegen/generateRouteRecords.ts:
--------------------------------------------------------------------------------
1 | import type { TreeNode } from '../core/tree'
2 | import { ImportsMap } from '../core/utils'
3 | import { ResolvedOptions, _OptionsImportMode } from '../options'
4 |
5 | export function generateRouteRecord(
6 | node: TreeNode,
7 | options: ResolvedOptions,
8 | importsMap: ImportsMap,
9 | indent = 0
10 | ): string {
11 | // root
12 | if (node.value.path === '/' && indent === 0) {
13 | return `[
14 | ${node
15 | .getSortedChildren()
16 | .map((child) => generateRouteRecord(child, options, importsMap, indent + 1))
17 | .join(',\n')}
18 | ]`
19 | }
20 |
21 | const startIndent = ' '.repeat(indent * 2)
22 | const indentStr = ' '.repeat((indent + 1) * 2)
23 |
24 | // TODO: should meta be defined a different way to allow preserving imports?
25 | // const meta = node.value.overrides.meta
26 |
27 | // compute once since it's a getter
28 | const overrides = node.value.overrides
29 |
30 | // path
31 | const routeRecord = `${startIndent}{
32 | ${indentStr}path: '${node.path}',
33 | ${indentStr}${
34 | node.value.components.size
35 | ? `name: '${node.name}',`
36 | : `/* internal name: '${node.name}' */`
37 | }
38 | ${
39 | // component
40 | indentStr
41 | }${
42 | node.value.components.size
43 | ? generateRouteRecordComponent(
44 | node,
45 | indentStr,
46 | options.importMode,
47 | importsMap
48 | )
49 | : '/* no component */'
50 | }
51 | ${overrides.props != null ? indentStr + `props: ${overrides.props},\n` : ''}${
52 | overrides.alias != null
53 | ? indentStr + `alias: ${JSON.stringify(overrides.alias)},\n`
54 | : ''
55 | }${
56 | // children
57 | indentStr
58 | }${
59 | node.children.size > 0
60 | ? `children: [
61 | ${node
62 | .getSortedChildren()
63 | .map((child) => generateRouteRecord(child, options, importsMap, indent + 2))
64 | .join(',\n')}
65 | ${indentStr}],`
66 | : '/* no children */'
67 | }${formatMeta(node, indentStr)}
68 | ${startIndent}}`
69 |
70 | if (node.hasDefinePage) {
71 | const definePageDataList: string[] = []
72 | for (const [name, filePath] of node.value.components) {
73 | const pageDataImport = `_definePage_${name}_${importsMap.size}`
74 | definePageDataList.push(pageDataImport)
75 | importsMap.addDefault(`${filePath}?definePage&vue`, pageDataImport)
76 | }
77 |
78 | if (definePageDataList.length) {
79 | importsMap.add('unplugin-vue-router/runtime', '_mergeRouteRecord')
80 | return ` _mergeRouteRecord(
81 | ${routeRecord},
82 | ${definePageDataList.join(',\n')}
83 | )`
84 | }
85 | }
86 |
87 | return routeRecord
88 | }
89 |
90 | function generateRouteRecordComponent(
91 | node: TreeNode,
92 | indentStr: string,
93 | importMode: _OptionsImportMode,
94 | importsMap: ImportsMap
95 | ): string {
96 | const files = Array.from(node.value.components)
97 | const isDefaultExport = files.length === 1 && files[0][0] === 'default'
98 | return isDefaultExport
99 | ? `component: ${generatePageImport(files[0][1], importMode, importsMap)},`
100 | : // files has at least one entry
101 | `components: {
102 | ${files
103 | .map(
104 | ([key, path]) =>
105 | `${indentStr + ' '}'${key}': ${generatePageImport(
106 | path,
107 | importMode,
108 | importsMap
109 | )}`
110 | )
111 | .join(',\n')}
112 | ${indentStr}},`
113 | }
114 |
115 | /**
116 | * Generate the import (dynamic or static) for the given filepath. If the filepath is a static import, add it to the
117 | * @param filepath - the filepath to the file
118 | * @param importMode - the import mode to use
119 | * @param importsMap - the import list to fill
120 | * @returns
121 | */
122 | function generatePageImport(
123 | filepath: string,
124 | importMode: _OptionsImportMode,
125 | importsMap: ImportsMap
126 | ) {
127 | const mode =
128 | typeof importMode === 'function' ? importMode(filepath) : importMode
129 | if (mode === 'async') {
130 | return `() => import('${filepath}')`
131 | } else {
132 | const importName = `_page_${importsMap.size}`
133 | importsMap.addDefault(filepath, importName)
134 | return importName
135 | }
136 | }
137 |
138 | function generateImportList(node: TreeNode, indentStr: string) {
139 | const files = Array.from(node.value.components)
140 |
141 | return `[
142 | ${files
143 | .map(([_key, path]) => `${indentStr} () => import('${path}')`)
144 | .join(',\n')}
145 | ${indentStr}]`
146 | }
147 |
148 | const LOADER_GUARD_RE = /['"]_loaderGuard['"]:.*$/
149 |
150 | function formatMeta(node: TreeNode, indent: string): string {
151 | const meta = node.meta
152 | const formatted =
153 | meta &&
154 | meta
155 | .split('\n')
156 | .map(
157 | (line) =>
158 | indent +
159 | line.replace(
160 | LOADER_GUARD_RE,
161 | '[_HasDataLoaderMeta]: ' +
162 | generateImportList(node, indent + ' ') +
163 | ','
164 | )
165 | )
166 | .join('\n')
167 |
168 | return formatted ? '\n' + indent + 'meta: ' + formatted.trimStart() : ''
169 | }
170 |
--------------------------------------------------------------------------------
/src/data-fetching/dataFetchingGuard.ts:
--------------------------------------------------------------------------------
1 | import { DataLoader, isDataLoader } from './defineLoader'
2 | import type { RouteLocationNormalized, Router } from 'vue-router'
3 | import { _Awaitable } from '../core/utils'
4 |
5 | // Symbol used to detect if a route has loaders
6 | export const HasDataLoaderMeta = Symbol()
7 |
8 | declare module 'vue-router' {
9 | export interface RouteMeta {
10 | /**
11 | * List of lazy imports of modules that might have a loader. We need to extract the exports that are actually
12 | * loaders.
13 | */
14 | [HasDataLoaderMeta]?: Array<
15 | () => Promise | unknown>>
16 | >
17 | }
18 | }
19 |
20 | // dev only check
21 | const ADDED_SYMBOL = Symbol()
22 |
23 | // TODO:
24 | type NavigationResult = any
25 |
26 | export interface SetupDataFetchingGuardOptions {
27 | /**
28 | * Initial data to skip the initial data loaders. This is useful for SSR and should be set only on client side.
29 | */
30 | initialData?: Record
31 |
32 | /**
33 | * Hook that is called before each data loader is called. Can return a promise to delay the data loader call.
34 | */
35 | beforeLoad?: (route: RouteLocationNormalized) => Promise
36 |
37 | /**
38 | * Called if any data loader returns a `NavigationResult` with an array of them. Should decide what is the outcome of
39 | * the data fetching guard. Note this isn't called if no data loaders return a `NavigationResult`.
40 | */
41 | selectNavigationResult?: (
42 | results: NavigationResult[]
43 | ) => _Awaitable
44 | }
45 |
46 | export function setupDataFetchingGuard(
47 | router: Router,
48 | { initialData }: SetupDataFetchingGuardOptions = {}
49 | ) {
50 | if (process.env.NODE_ENV !== 'production') {
51 | if (ADDED_SYMBOL in router) {
52 | console.warn(
53 | '[vue-router]: Data fetching guard added twice. Make sure to remove the extra call.'
54 | )
55 | return
56 | }
57 | // @ts-expect-error: doesn't exist
58 | router[ADDED_SYMBOL] = true
59 | }
60 |
61 | const fetchedState: Record = {}
62 | let isFetched: undefined | boolean
63 |
64 | router.beforeEach((to) => {
65 | // We run all loaders in parallel
66 | return (
67 | Promise.all(
68 | // retrieve all loaders as a flat array
69 | to.matched
70 | .flatMap((route) => route.meta[HasDataLoaderMeta])
71 | // loaders are optional
72 | .filter(Boolean as unknown as (v: T) => v is NonNullable)
73 | // call the dynamic imports to get the loaders
74 | .map((moduleImport) =>
75 | moduleImport()
76 | // fetch or use the cache
77 | .then((mod) => {
78 | // check all the exports of the module and keep the loaders
79 | const loaders = Object.keys(mod)
80 | .filter((exportName) => isDataLoader(mod[exportName]))
81 | .map((loaderName) => mod[loaderName] as DataLoader)
82 |
83 | // fetch all the loaders
84 | return Promise.all(
85 | // load will ensure only one request is happening at a time
86 | loaders.map((loader) => {
87 | const {
88 | options: { key },
89 | entries,
90 | } = loader._
91 | /**
92 | * We need to:
93 | * 1. ssrKey
94 | * 2. getCurrentData (entries.get)
95 | * 3. load()
96 | */
97 | return loader._.load(
98 | to,
99 | router,
100 | undefined,
101 | initialData
102 | // FIXME: could the data.value be passed as an argument here?
103 | ).then(() => {
104 | if (!initialData) {
105 | // TODO: warn if we have an incomplete initialData
106 | if (key) {
107 | fetchedState[key] = entries.get(router)!.data.value
108 | }
109 | } else if (
110 | process.env.NODE_ENV !== 'production' &&
111 | !key &&
112 | !isFetched
113 | ) {
114 | // TODO: find a way to warn on client when initialData is empty when it shouldn't
115 | // console.warn()
116 | }
117 | })
118 | })
119 | )
120 | })
121 | )
122 | )
123 | // let the navigation go through by returning true or void
124 | .then(() => {
125 | // reset the initial state as it can only be used once
126 | initialData = undefined
127 | // NOTE: could this be dev only?
128 | isFetched = true
129 | })
130 | )
131 | })
132 |
133 | return initialData ? null : fetchedState
134 | }
135 |
--------------------------------------------------------------------------------
/src/data-fetching/README.md:
--------------------------------------------------------------------------------
1 | # Experimental Data Fetching
2 |
3 | ⚠️ Warning: This is an experimental feature and API could change anytime
4 |
5 | - [RFC discussion](https://github.com/vuejs/rfcs/discussions/460): Note that not everything is implemented yet.
6 |
7 | ## Installation
8 |
9 | Install the unplugin-vue-router library as described in its [main `README.md`](../../README.MD)
10 |
11 | ## Usage
12 |
13 | The data fetching layer is easier to use alongside the `unplugin-vue-router` plugin because it writes the boring _"plumbing-code"_ for you and lets you focus on the interesting part with `defineLoader()`. It's highly recommended to use the `unplugin-vue-router` plugin if you can. Below are instructions to setup in both scenarios:
14 |
15 | ### Setup
16 |
17 | To enable data fetching, you must setup the navigation guards with `setupDataFetchingGuard()`:
18 |
19 | ```ts
20 | import { setupDataFetchingGuard, createRouter } from 'vue-router/auto'
21 |
22 | const router = createRouter({
23 | //...
24 | })
25 |
26 | setupDataFetchingGuard(router)
27 | ```
28 |
29 | ### With `unplugin-vue-router`
30 |
31 | If you are using the route generation of `unplugin-vue-router`, you can make the injection of the meta field automatic:
32 |
33 | ```ts
34 | // vite.config.ts
35 | plugins: [
36 | VueRouter({
37 | dataFetching: true,
38 | }),
39 | ]
40 | ```
41 |
42 | ### Without `unplugin-vue-router`
43 |
44 | You must manually provide a new `meta` field to **each route that exports a data loader**:
45 |
46 | ```ts
47 | import { HasDataLoaderSymbol } from 'vue-router/auto'
48 |
49 | const router = createRouter({
50 | routes: [
51 | {
52 | path: '/users/:id',
53 | component: () => import('@/src/pages/users/[id].vue'),
54 | meta: {
55 | [HasDataLoaderSymbol]: () => import('@/src/pages/users/[id].vue'),
56 | },
57 | },
58 | ],
59 | })
60 | ```
61 |
62 | ### `defineLoader()` usage
63 |
64 | To define data loaders, you must use the `defineLoader()` function:
65 |
66 | ```vue
67 |
85 |
86 |
91 | ```
92 |
93 | Find more details on [the RFC](https://github.com/vuejs/rfcs/discussions/460)
94 |
95 | ### SSR
96 |
97 | To support SSR we need to do two things:
98 |
99 | - Pass a `key` to each loader so that it can be serialized into an object later. Would an array work? I don't think the order of execution is guaranteed.
100 | - On the client side, pass the initial state to `setupDataFetchingGuard()`. The initial state is used once and discarded afterwards.
101 |
102 | ```ts
103 | export const useBookCollection = defineLoader(
104 | async () => {
105 | const books = await fetchBookCollection()
106 | return books
107 | },
108 | { key: 'bookCollection' }
109 | )
110 | ```
111 |
112 | The configuration of `setupDataFetchingGuard()` depends on the SSR configuration, here is an example with vite-ssg:
113 |
114 | ```ts
115 | import { ViteSSG } from 'vite-ssg'
116 | import { setupDataFetchingGuard } from 'vue-router/auto'
117 | import App from './App.vue'
118 | import { routes } from './routes'
119 |
120 | export const createApp = ViteSSG(
121 | App,
122 | { routes },
123 | async ({ router, isClient, initialState }) => {
124 | // fetchedData will be populated during navigation
125 | const fetchedData = setupDataFetchingGuard(router, {
126 | initialData: isClient
127 | ? // on the client we pass the initial state
128 | initialState.vueRouter
129 | : // on server we want to generate the initial state
130 | undefined,
131 | })
132 |
133 | // on the server, we serialize the fetchedData
134 | if (!isClient) {
135 | initialState.vueRouter = fetchedData
136 | }
137 | }
138 | )
139 | ```
140 |
141 | Note that `setupDataFetchingGuard()` **should be called before `app.use(router)`** so it takes effect on the initial navigation. Otherwise a new navigation must be triggered after the navigation guard is added.
142 |
143 | Find more details on [the RFC](https://github.com/vuejs/rfcs/discussions/460)
144 |
145 | ## Auto imports
146 |
147 | If you use [unplugin-auto-import](https://github.com/antfu/unplugin-auto-import), you can use its preset to automatically have access to `defineLoader()` and other imports:
148 |
149 | ```ts
150 | // vite.config.ts
151 | import Vue from '@vitejs/plugin-vue'
152 | import { VueRouterAutoImports } from 'unplugin-vue-router'
153 |
154 | export default defineConfig({
155 | // ... other options
156 | plugins: [
157 | VueRouter({
158 | dataFetching: true,
159 | }),
160 | // ⚠️ Vue must be placed after VueRouter()
161 | Vue(),
162 | AutoImport({
163 | imports: [VueRouterAutoImports],
164 | }),
165 | ],
166 | })
167 | ```
168 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { createUnplugin } from 'unplugin'
2 | import { createRoutesContext } from './core/context'
3 | import {
4 | MODULE_ROUTES_PATH,
5 | MODULE_VUE_ROUTER,
6 | getVirtualId as _getVirtualId,
7 | asVirtualId as _asVirtualId,
8 | routeBlockQueryRE,
9 | ROUTE_BLOCK_ID,
10 | } from './core/moduleConstants'
11 | // TODO: export standalone createRoutesContext that resolves partial options
12 | import { Options, resolveOptions, DEFAULT_OPTIONS } from './options'
13 | import { createViteContext } from './core/vite'
14 | import { createFilter } from '@rollup/pluginutils'
15 | import { join } from 'pathe'
16 |
17 | export * from './types'
18 |
19 | export { DEFAULT_OPTIONS }
20 |
21 | export default createUnplugin((opt = {}, meta) => {
22 | const options = resolveOptions(opt)
23 | const ctx = createRoutesContext(options)
24 |
25 | function getVirtualId(id: string) {
26 | if (options._inspect) return id
27 | return _getVirtualId(id)
28 | }
29 |
30 | function asVirtualId(id: string) {
31 | // for inspection
32 | if (options._inspect) return id
33 | return _asVirtualId(id)
34 | }
35 |
36 | // create the transform filter to detect `definePage()` inside page component
37 | const pageFilePattern =
38 | `**/*` +
39 | (options.extensions.length === 1
40 | ? options.extensions[0]
41 | : `.{${options.extensions
42 | .map((extension) => extension.replace('.', ''))
43 | .join(',')}}`)
44 | const filterPageComponents = createFilter(
45 | [
46 | ...options.routesFolder.map((routeOption) =>
47 | join(routeOption.src, pageFilePattern)
48 | ),
49 | // importing the definePage block
50 | /definePage\&vue$/,
51 | ],
52 | options.exclude
53 | )
54 |
55 | return {
56 | name: 'unplugin-vue-router',
57 | enforce: 'pre',
58 |
59 | resolveId(id) {
60 | if (id === MODULE_ROUTES_PATH) {
61 | // virtual module
62 | return asVirtualId(id)
63 | }
64 | // NOTE: it wasn't possible to override or add new exports to vue-router
65 | // so we need to override it with a different package name
66 | if (id === MODULE_VUE_ROUTER) {
67 | return asVirtualId(id)
68 | }
69 |
70 | // this allows us to skip the route block module as a whole since we already parse it
71 | if (routeBlockQueryRE.test(id)) {
72 | return ROUTE_BLOCK_ID
73 | }
74 | },
75 |
76 | buildStart() {
77 | // TODO: how do we properly check if we are in dev mode?
78 | return ctx.scanPages(true)
79 | },
80 |
81 | buildEnd() {
82 | ctx.stopWatcher()
83 | },
84 |
85 | // we only need to transform page components
86 | transformInclude(id) {
87 | // console.log('filtering ' + id, filterPageComponents(id) ? '✅' : '❌')
88 | return filterPageComponents(id)
89 | },
90 |
91 | transform(code, id) {
92 | // console.log('👋 ', id)
93 | return ctx.definePageTransform(code, id)
94 | },
95 |
96 | // loadInclude is necessary for webpack
97 | loadInclude(id) {
98 | if (id === ROUTE_BLOCK_ID) return true
99 | const resolvedId = getVirtualId(id)
100 | return (
101 | resolvedId === MODULE_ROUTES_PATH || resolvedId === MODULE_VUE_ROUTER
102 | )
103 | },
104 |
105 | load(id) {
106 | // remove the block as it's parsed by the plugin
107 | if (id === ROUTE_BLOCK_ID) {
108 | return {
109 | code: `export default {}`,
110 | map: null,
111 | }
112 | }
113 |
114 | // we need to use a virtual module so that vite resolves the vue-router/auto/routes
115 | // dependency correctly
116 | const resolvedId = getVirtualId(id)
117 |
118 | // vue-router/auto/routes
119 | if (resolvedId === MODULE_ROUTES_PATH) {
120 | return ctx.generateRoutes()
121 | }
122 |
123 | // vue-router/auto
124 | if (resolvedId === MODULE_VUE_ROUTER) {
125 | return ctx.generateVueRouterProxy()
126 | }
127 | },
128 |
129 | // improves DX
130 | vite: {
131 | configureServer(server) {
132 | ctx.setServerContext(createViteContext(server))
133 | },
134 | },
135 | }
136 | })
137 |
138 | export { createRoutesContext }
139 | export { getFileBasedRouteName, getPascalCaseRouteName } from './core/utils'
140 |
141 | // Route Tree and edition
142 | // FIXME: deprecated, remove in next major
143 | export { createPrefixTree } from './core/tree'
144 | export { createTreeNodeValue } from './core/treeNodeValue'
145 | export { EditableTreeNode } from './core/extendRoutes'
146 |
147 | /**
148 | * @deprecated use `VueRouterAutoImports` instead
149 | */
150 | export const VueRouterExports: Array = [
151 | 'useRoute',
152 | 'useRouter',
153 | 'defineLoader',
154 | 'onBeforeRouteUpdate',
155 | 'onBeforeRouteLeave',
156 | // NOTE: the typing seems broken locally, so instead we export it directly from unplugin-vue-router/runtime
157 | // 'definePage',
158 | ]
159 |
160 | /**
161 | * Adds useful auto imports to the AutoImport config:
162 | * @example
163 | * ```js
164 | * import { VueRouterAutoImports } from 'unplugin-vue-router'
165 | *
166 | * AutoImport({
167 | * imports: [VueRouterAutoImports],
168 | * }),
169 | * ```
170 | */
171 | export const VueRouterAutoImports: Record<
172 | string,
173 | Array
174 | > = {
175 | 'vue-router/auto': VueRouterExports,
176 | 'unplugin-vue-router/runtime': [['_definePage', 'definePage']],
177 | }
178 |
--------------------------------------------------------------------------------
/examples/webpack/typed-router.d.ts:
--------------------------------------------------------------------------------
1 | // Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
2 | // It's recommended to commit this file.
3 | // Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
4 |
5 | ///
6 |
7 | import type {
8 | // type safe route locations
9 | RouteLocationTypedList,
10 | RouteLocationResolvedTypedList,
11 | RouteLocationNormalizedTypedList,
12 | RouteLocationNormalizedLoadedTypedList,
13 | RouteLocationAsString,
14 | RouteLocationAsRelativeTypedList,
15 | RouteLocationAsPathTypedList,
16 |
17 | // helper types
18 | // route definitions
19 | RouteRecordInfo,
20 | ParamValue,
21 | ParamValueOneOrMore,
22 | ParamValueZeroOrMore,
23 | ParamValueZeroOrOne,
24 |
25 | // vue-router extensions
26 | _RouterTyped,
27 | RouterLinkTyped,
28 | NavigationGuard,
29 | UseLinkFnTyped,
30 |
31 | // data fetching
32 | _DataLoader,
33 | _DefineLoaderOptions,
34 | } from 'unplugin-vue-router'
35 |
36 | declare module 'vue-router/auto/routes' {
37 | export interface RouteNamedMap {
38 | '/': RouteRecordInfo<'/', '/', Record, Record>,
39 | '/[id]': RouteRecordInfo<'/[id]', '/:id', { id: ParamValue }, { id: ParamValue }>,
40 | '/articles/[id]+': RouteRecordInfo<'/articles/[id]+', '/articles/:id+', { id: ParamValueOneOrMore }, { id: ParamValueOneOrMore }>,
41 | }
42 | }
43 |
44 | declare module 'vue-router/auto' {
45 | import type { RouteNamedMap } from 'vue-router/auto/routes'
46 |
47 | export type RouterTyped = _RouterTyped
48 |
49 | /**
50 | * Type safe version of `RouteLocationNormalized` (the type of `to` and `from` in navigation guards).
51 | * Allows passing the name of the route to be passed as a generic.
52 | */
53 | export type RouteLocationNormalized = RouteLocationNormalizedTypedList[Name]
54 |
55 | /**
56 | * Type safe version of `RouteLocationNormalizedLoaded` (the return type of `useRoute()`).
57 | * Allows passing the name of the route to be passed as a generic.
58 | */
59 | export type RouteLocationNormalizedLoaded = RouteLocationNormalizedLoadedTypedList[Name]
60 |
61 | /**
62 | * Type safe version of `RouteLocationResolved` (the returned route of `router.resolve()`).
63 | * Allows passing the name of the route to be passed as a generic.
64 | */
65 | export type RouteLocationResolved = RouteLocationResolvedTypedList[Name]
66 |
67 | /**
68 | * Type safe version of `RouteLocation` . Allows passing the name of the route to be passed as a generic.
69 | */
70 | export type RouteLocation = RouteLocationTypedList[Name]
71 |
72 | /**
73 | * Type safe version of `RouteLocationRaw` . Allows passing the name of the route to be passed as a generic.
74 | */
75 | export type RouteLocationRaw =
76 | | RouteLocationAsString
77 | | RouteLocationAsRelativeTypedList[Name]
78 | | RouteLocationAsPathTypedList[Name]
79 |
80 | /**
81 | * Generate a type safe params for a route location. Requires the name of the route to be passed as a generic.
82 | */
83 | export type RouteParams = RouteNamedMap[Name]['params']
84 | /**
85 | * Generate a type safe raw params for a route location. Requires the name of the route to be passed as a generic.
86 | */
87 | export type RouteParamsRaw = RouteNamedMap[Name]['paramsRaw']
88 |
89 | export function useRouter(): RouterTyped
90 | export function useRoute(name?: Name): RouteLocationNormalizedLoadedTypedList[Name]
91 |
92 | export const useLink: UseLinkFnTyped
93 |
94 | export function onBeforeRouteLeave(guard: NavigationGuard): void
95 | export function onBeforeRouteUpdate(guard: NavigationGuard): void
96 |
97 | // Experimental Data Fetching
98 |
99 | export function defineLoader<
100 | P extends Promise,
101 | Name extends keyof RouteNamedMap = keyof RouteNamedMap,
102 | isLazy extends boolean = false,
103 | >(
104 | name: Name,
105 | loader: (route: RouteLocationNormalizedLoaded) => P,
106 | options?: _DefineLoaderOptions,
107 | ): _DataLoader, isLazy>
108 | export function defineLoader<
109 | P extends Promise,
110 | isLazy extends boolean = false,
111 | >(
112 | loader: (route: RouteLocationNormalizedLoaded) => P,
113 | options?: _DefineLoaderOptions,
114 | ): _DataLoader, isLazy>
115 |
116 | export {
117 | _definePage as definePage,
118 | _HasDataLoaderMeta as HasDataLoaderMeta,
119 | _setupDataFetchingGuard as setupDataFetchingGuard,
120 | _stopDataFetchingScope as stopDataFetchingScope,
121 | } from 'unplugin-vue-router/runtime'
122 | }
123 |
124 | declare module 'vue-router' {
125 | import type { RouteNamedMap } from 'vue-router/auto/routes'
126 |
127 | export interface TypesConfig {
128 | beforeRouteUpdate: NavigationGuard
129 | beforeRouteLeave: NavigationGuard
130 |
131 | $route: RouteLocationNormalizedLoadedTypedList[keyof RouteNamedMap]
132 | $router: _RouterTyped
133 |
134 | RouterLink: RouterLinkTyped
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/src/codegen/generateDTS.ts:
--------------------------------------------------------------------------------
1 | export function generateDTS({
2 | vueRouterModule,
3 | routesModule,
4 | routeNamedMap,
5 | }: {
6 | vueRouterModule: string
7 | routesModule: string
8 | routeNamedMap: string
9 | }) {
10 | return `/* eslint-disable */
11 | /* prettier-ignore */
12 | // @ts-nocheck
13 | // Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
14 | // It's recommended to commit this file.
15 | // Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
16 |
17 | ///
18 |
19 | import type {
20 | // type safe route locations
21 | RouteLocationTypedList,
22 | RouteLocationResolvedTypedList,
23 | RouteLocationNormalizedTypedList,
24 | RouteLocationNormalizedLoadedTypedList,
25 | RouteLocationAsString,
26 | RouteLocationAsRelativeTypedList,
27 | RouteLocationAsPathTypedList,
28 |
29 | // helper types
30 | // route definitions
31 | RouteRecordInfo,
32 | ParamValue,
33 | ParamValueOneOrMore,
34 | ParamValueZeroOrMore,
35 | ParamValueZeroOrOne,
36 |
37 | // vue-router extensions
38 | _RouterTyped,
39 | RouterLinkTyped,
40 | RouterLinkPropsTyped,
41 | NavigationGuard,
42 | UseLinkFnTyped,
43 |
44 | // data fetching
45 | _DataLoader,
46 | _DefineLoaderOptions,
47 | } from 'unplugin-vue-router/types'
48 |
49 | declare module '${routesModule}' {
50 | ${routeNamedMap}
51 | }
52 |
53 | declare module '${vueRouterModule}' {
54 | import type { RouteNamedMap } from '${routesModule}'
55 |
56 | export type RouterTyped = _RouterTyped
57 |
58 | /**
59 | * Type safe version of \`RouteLocationNormalized\` (the type of \`to\` and \`from\` in navigation guards).
60 | * Allows passing the name of the route to be passed as a generic.
61 | */
62 | export type RouteLocationNormalized = RouteLocationNormalizedTypedList[Name]
63 |
64 | /**
65 | * Type safe version of \`RouteLocationNormalizedLoaded\` (the return type of \`useRoute()\`).
66 | * Allows passing the name of the route to be passed as a generic.
67 | */
68 | export type RouteLocationNormalizedLoaded = RouteLocationNormalizedLoadedTypedList[Name]
69 |
70 | /**
71 | * Type safe version of \`RouteLocationResolved\` (the returned route of \`router.resolve()\`).
72 | * Allows passing the name of the route to be passed as a generic.
73 | */
74 | export type RouteLocationResolved = RouteLocationResolvedTypedList[Name]
75 |
76 | /**
77 | * Type safe version of \`RouteLocation\` . Allows passing the name of the route to be passed as a generic.
78 | */
79 | export type RouteLocation = RouteLocationTypedList[Name]
80 |
81 | /**
82 | * Type safe version of \`RouteLocationRaw\` . Allows passing the name of the route to be passed as a generic.
83 | */
84 | export type RouteLocationRaw =
85 | | RouteLocationAsString
86 | | RouteLocationAsRelativeTypedList[Name]
87 | | RouteLocationAsPathTypedList[Name]
88 |
89 | /**
90 | * Generate a type safe params for a route location. Requires the name of the route to be passed as a generic.
91 | */
92 | export type RouteParams = RouteNamedMap[Name]['params']
93 | /**
94 | * Generate a type safe raw params for a route location. Requires the name of the route to be passed as a generic.
95 | */
96 | export type RouteParamsRaw = RouteNamedMap[Name]['paramsRaw']
97 |
98 | export function useRouter(): RouterTyped
99 | export function useRoute(name?: Name): RouteLocationNormalizedLoadedTypedList[Name]
100 |
101 | export const useLink: UseLinkFnTyped
102 |
103 | export function onBeforeRouteLeave(guard: NavigationGuard): void
104 | export function onBeforeRouteUpdate(guard: NavigationGuard): void
105 |
106 | export const RouterLink: RouterLinkTyped
107 | export const RouterLinkProps: RouterLinkPropsTyped
108 |
109 | // Experimental Data Fetching
110 |
111 | export function defineLoader<
112 | P extends Promise,
113 | Name extends keyof RouteNamedMap = keyof RouteNamedMap,
114 | isLazy extends boolean = false,
115 | >(
116 | name: Name,
117 | loader: (route: RouteLocationNormalizedLoaded) => P,
118 | options?: _DefineLoaderOptions,
119 | ): _DataLoader, isLazy>
120 | export function defineLoader<
121 | P extends Promise,
122 | isLazy extends boolean = false,
123 | >(
124 | loader: (route: RouteLocationNormalizedLoaded) => P,
125 | options?: _DefineLoaderOptions,
126 | ): _DataLoader, isLazy>
127 |
128 | export {
129 | _definePage as definePage,
130 | _HasDataLoaderMeta as HasDataLoaderMeta,
131 | _setupDataFetchingGuard as setupDataFetchingGuard,
132 | _stopDataFetchingScope as stopDataFetchingScope,
133 | } from 'unplugin-vue-router/runtime'
134 | }
135 |
136 | declare module 'vue-router' {
137 | import type { RouteNamedMap } from '${routesModule}'
138 |
139 | export interface TypesConfig {
140 | beforeRouteUpdate: NavigationGuard
141 | beforeRouteLeave: NavigationGuard
142 |
143 | $route: RouteLocationNormalizedLoadedTypedList[keyof RouteNamedMap]
144 | $router: _RouterTyped
145 |
146 | RouterLink: RouterLinkTyped
147 | }
148 | }
149 | `
150 | }
151 |
--------------------------------------------------------------------------------
/src/core/definePage.ts:
--------------------------------------------------------------------------------
1 | import {
2 | getTransformResult,
3 | isCallOf,
4 | parseSFC,
5 | MagicString,
6 | checkInvalidScopeReference,
7 | } from '@vue-macros/common'
8 | import { Thenable, TransformResult } from 'unplugin'
9 | import type {
10 | CallExpression,
11 | Node,
12 | ObjectProperty,
13 | Statement,
14 | StringLiteral,
15 | } from '@babel/types'
16 | import { walkAST } from 'ast-walker-scope'
17 | import { CustomRouteBlock } from './customBlock'
18 | import { warn } from './utils'
19 |
20 | const MACRO_DEFINE_PAGE = 'definePage'
21 | const MACRO_DEFINE_PAGE_QUERY = /[?&]definePage\b/
22 |
23 | function isStringLiteral(node: Node | null | undefined): node is StringLiteral {
24 | return node?.type === 'StringLiteral'
25 | }
26 |
27 | export function definePageTransform({
28 | code,
29 | id,
30 | }: {
31 | code: string
32 | id: string
33 | }): Thenable {
34 | if (!code.includes(MACRO_DEFINE_PAGE)) return
35 |
36 | const sfc = parseSFC(code, id)
37 | if (!sfc.scriptSetup) return
38 |
39 | // are we extracting only the definePage object
40 | const isExtractingDefinePage = MACRO_DEFINE_PAGE_QUERY.test(id)
41 |
42 | const { script, scriptSetup, getSetupAst } = sfc
43 | const setupAst = getSetupAst()
44 |
45 | const definePageNodes = (setupAst?.body || ([] as Node[]))
46 | .map((node) => {
47 | if (node.type === 'ExpressionStatement') node = node.expression
48 | return isCallOf(node, MACRO_DEFINE_PAGE) ? node : null
49 | })
50 | .filter((node): node is CallExpression => !!node)
51 |
52 | if (!definePageNodes.length) {
53 | return isExtractingDefinePage
54 | ? // e.g. index.vue?definePage that contains a commented `definePage()
55 | 'export default {}'
56 | : // e.g. index.vue that contains a commented `definePage()
57 | null
58 | } else if (definePageNodes.length > 1) {
59 | throw new SyntaxError(`duplicate definePage() call`)
60 | }
61 |
62 | const definePageNode = definePageNodes[0]
63 | const setupOffset = scriptSetup.loc.start.offset
64 |
65 | // we only want the page info
66 | if (isExtractingDefinePage) {
67 | const s = new MagicString(code)
68 | // remove everything except the page info
69 |
70 | const routeRecord = definePageNode.arguments[0]
71 |
72 | const scriptBindings = setupAst?.body ? getIdentifiers(setupAst.body) : []
73 |
74 | checkInvalidScopeReference(routeRecord, MACRO_DEFINE_PAGE, scriptBindings)
75 |
76 | // NOTE: this doesn't seem to be any faster than using MagicString
77 | // return (
78 | // 'export default ' +
79 | // code.slice(
80 | // setupOffset + routeRecord.start!,
81 | // setupOffset + routeRecord.end!
82 | // )
83 | // )
84 |
85 | s.remove(setupOffset + routeRecord.end!, code.length)
86 | s.remove(0, setupOffset + routeRecord.start!)
87 | s.prepend(`export default `)
88 |
89 | return getTransformResult(s, id)
90 | } else {
91 | // console.log('!!!', definePageNode)
92 |
93 | const s = new MagicString(code)
94 |
95 | // s.removeNode(definePageNode, { offset: setupOffset })
96 | s.remove(
97 | setupOffset + definePageNode.start!,
98 | setupOffset + definePageNode.end!
99 | )
100 |
101 | return getTransformResult(s, id)
102 | }
103 | }
104 |
105 | export function extractDefinePageNameAndPath(
106 | sfcCode: string,
107 | id: string
108 | ): { name?: string; path?: string } | null | undefined {
109 | if (!sfcCode.includes(MACRO_DEFINE_PAGE)) return
110 |
111 | const sfc = parseSFC(sfcCode, id)
112 |
113 | if (!sfc.scriptSetup) return
114 |
115 | const { getSetupAst } = sfc
116 | const setupAst = getSetupAst()
117 |
118 | const definePageNodes = (setupAst?.body ?? ([] as Node[]))
119 | .map((node) => {
120 | if (node.type === 'ExpressionStatement') node = node.expression
121 | return isCallOf(node, MACRO_DEFINE_PAGE) ? node : null
122 | })
123 | .filter((node): node is CallExpression => !!node)
124 |
125 | if (!definePageNodes.length) {
126 | return
127 | } else if (definePageNodes.length > 1) {
128 | throw new SyntaxError(`duplicate definePage() call`)
129 | }
130 |
131 | const definePageNode = definePageNodes[0]
132 |
133 | const routeRecord = definePageNode.arguments[0]
134 | if (routeRecord.type !== 'ObjectExpression') {
135 | throw new SyntaxError(
136 | `[${id}]: definePage() expects an object expression as its only argument`
137 | )
138 | }
139 |
140 | const routeInfo: Pick = {}
141 |
142 | for (const prop of routeRecord.properties) {
143 | if (prop.type === 'ObjectProperty' && prop.key.type === 'Identifier') {
144 | if (prop.key.name === 'name') {
145 | if (prop.value.type !== 'StringLiteral') {
146 | warn(`route name must be a string literal. Found in "${id}".`)
147 | } else {
148 | routeInfo.name = prop.value.value
149 | }
150 | } else if (prop.key.name === 'path') {
151 | if (prop.value.type !== 'StringLiteral') {
152 | warn(`route path must be a string literal. Found in "${id}".`)
153 | } else {
154 | routeInfo.path = prop.value.value
155 | }
156 | }
157 | }
158 | }
159 |
160 | return routeInfo
161 | }
162 |
163 | function extractRouteAlias(
164 | aliasValue: ObjectProperty['value'],
165 | id: string
166 | ): string[] | undefined {
167 | if (
168 | aliasValue.type !== 'StringLiteral' &&
169 | aliasValue.type !== 'ArrayExpression'
170 | ) {
171 | warn(`route alias must be a string literal. Found in "${id}".`)
172 | } else {
173 | return aliasValue.type === 'StringLiteral'
174 | ? [aliasValue.value]
175 | : aliasValue.elements.filter(isStringLiteral).map((el) => el.value)
176 | }
177 | }
178 |
179 | const getIdentifiers = (stmts: Statement[]) => {
180 | let ids: string[] = []
181 | walkAST(
182 | {
183 | type: 'Program',
184 | body: stmts,
185 | directives: [],
186 | sourceType: 'module',
187 | sourceFile: '',
188 | },
189 | {
190 | enter(node) {
191 | if (node.type === 'BlockStatement') {
192 | this.skip()
193 | }
194 | },
195 | leave(node) {
196 | if (node.type !== 'Program') return
197 | ids = Object.keys(this.scope)
198 | },
199 | }
200 | )
201 |
202 | return ids
203 | }
204 |
--------------------------------------------------------------------------------
/src/core/extendRoutes.ts:
--------------------------------------------------------------------------------
1 | import { RouteMeta } from 'vue-router'
2 | import { CustomRouteBlock } from './customBlock'
3 | import { type TreeNode } from './tree'
4 | import { warn } from './utils'
5 |
6 | /**
7 | * A route node that can be modified by the user. The tree can be iterated to be traversed.
8 | * @example
9 | * ```js
10 | * [...node] // creates an array of all the children
11 | * for (const child of node) {
12 | * // do something with the child node
13 | * }
14 | * ```
15 | *
16 | * @experimental
17 | */
18 | export class EditableTreeNode {
19 | private node: TreeNode
20 | // private _parent?: EditableTreeNode
21 |
22 | constructor(node: TreeNode) {
23 | this.node = node
24 | }
25 |
26 | /**
27 | * Remove and detach the current route node from the tree. Subsequently, its children will be removed as well.
28 | */
29 | delete() {
30 | return this.node.delete()
31 | }
32 |
33 | /**
34 | * Inserts a new route as a child of this route. This route cannot use `definePage()`. If it was meant to be included,
35 | * add it to the `routesFolder` option.
36 | *
37 | * @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.
38 | * added at the root of the tree.
39 | * @param filePath - file path
40 | */
41 | insert(path: string, filePath: string) {
42 | // adapt paths as they should match a file system
43 | let addBackLeadingSlash = false
44 | if (path.startsWith('/')) {
45 | // at the root of the tree, the path is relative to the root so we remove
46 | // the leading slash
47 | path = path.slice(1)
48 | // but in other places we need to instruct the path is at the root so we change it afterwards
49 | addBackLeadingSlash = !this.node.isRoot()
50 | }
51 | const node = this.node.insertParsedPath(path, filePath)
52 | const editable = new EditableTreeNode(node)
53 | if (addBackLeadingSlash) {
54 | editable.path = '/' + node.path
55 | }
56 | // TODO: read definePage from file or is this fine?
57 | return editable
58 | }
59 |
60 | /**
61 | * Get an editable version of the parent node if it exists.
62 | */
63 | get parent() {
64 | return this.node.parent && new EditableTreeNode(this.node.parent)
65 | }
66 |
67 | /**
68 | * Return a Map of the files associated to the current route. The key of the map represents the name of the view (Vue
69 | * Router feature) while the value is the file path. By default, the name of the view is `default`.
70 | */
71 | get components() {
72 | return this.node.value.components
73 | }
74 |
75 | /**
76 | * Name of the route. Note that **all routes are named** but when the final `routes` array is generated, routes
77 | * without a `component` will not include their `name` property to avoid accidentally navigating to them and display
78 | * nothing. {@see isPassThrough}
79 | */
80 | get name(): string {
81 | return this.node.name
82 | }
83 |
84 | /**
85 | * Override the name of the route.
86 | */
87 | set name(name: string | undefined) {
88 | this.node.value.addEditOverride({ name })
89 | }
90 |
91 | /**
92 | * Whether the route is a pass-through route. A pass-through route is a route that does not have a component and is
93 | * used to group other routes under the same prefix `path` and/or `meta` properties.
94 | */
95 | get isPassThrough() {
96 | return this.node.value.components.size === 0
97 | }
98 |
99 | /**
100 | * 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**.
101 | */
102 | get meta(): Readonly {
103 | return this.node.metaAsObject
104 | }
105 |
106 | /**
107 | * Override the meta property of the route. This will discard any other meta property defined with `` blocks or
108 | * through other means.
109 | */
110 | set meta(meta: RouteMeta) {
111 | this.node.value.removeOverride('meta')
112 | this.node.value.setEditOverride('meta', meta)
113 | }
114 |
115 | /**
116 | * Add meta properties to the route keeping the existing ones. The passed object will be deeply merged with the
117 | * existing meta object if any. Note that the meta property is later on serialized as JSON so you can't pass functions
118 | * or any other non-serializable value.
119 | */
120 | addToMeta(meta: Partial) {
121 | this.node.value.addEditOverride({ meta })
122 | }
123 |
124 | /**
125 | * Path of the route without parent paths.
126 | */
127 | get path() {
128 | return this.node.path
129 | }
130 |
131 | /**
132 | * Override the path of the route. You must ensure `params` match with the existing path.
133 | */
134 | set path(path: string) {
135 | if (!path.startsWith('/')) {
136 | warn(
137 | `Only absolute paths are supported. Make sure that "${path}" starts with a slash "/".`
138 | )
139 | return
140 | }
141 | this.node.value.addEditOverride({ path })
142 | }
143 |
144 | /**
145 | * Alias of the route.
146 | */
147 | get alias() {
148 | return this.node.value.overrides.alias
149 | }
150 |
151 | /**
152 | * Add an alias to the route.
153 | *
154 | * @param alias - Alias to add to the route
155 | */
156 | addAlias(alias: CustomRouteBlock['alias']) {
157 | this.node.value.addEditOverride({ alias })
158 | }
159 |
160 | /**
161 | * Array of the route params and all of its parent's params.
162 | */
163 | get params() {
164 | return this.node.params
165 | }
166 |
167 | /**
168 | * Path of the route including parent paths.
169 | */
170 | get fullPath() {
171 | return this.node.fullPath
172 | }
173 |
174 | /**
175 | * Computes an array of EditableTreeNode from the current node. Differently from iterating over the tree, this method
176 | * **only returns direct children**.
177 | */
178 | get children(): EditableTreeNode[] {
179 | return [...this.node.children.values()].map(
180 | (node) => new EditableTreeNode(node)
181 | )
182 | }
183 |
184 | /**
185 | * DFS traversal of the tree.
186 | * @example
187 | * ```ts
188 | * for (const node of tree) {
189 | * // ...
190 | * }
191 | * ```
192 | */
193 | *traverseDFS(): Generator {
194 | // The root node is not a route, so we skip it
195 | if (!this.node.isRoot()) {
196 | yield this
197 | }
198 | for (const [_name, child] of this.node.children) {
199 | yield* new EditableTreeNode(child).traverseDFS()
200 | }
201 | }
202 |
203 | *[Symbol.iterator](): Generator {
204 | yield* this.traverseBFS()
205 | }
206 |
207 | /**
208 | * BFS traversal of the tree as a generator.
209 | *
210 | * @example
211 | * ```ts
212 | * for (const node of tree) {
213 | * // ...
214 | * }
215 | * ```
216 | */
217 | *traverseBFS(): Generator {
218 | for (const [_name, child] of this.node.children) {
219 | yield new EditableTreeNode(child)
220 | }
221 | // we need to traverse again in case the user removed a route
222 | for (const [_name, child] of this.node.children) {
223 | yield* new EditableTreeNode(child).traverseBFS()
224 | }
225 | }
226 | }
227 |
--------------------------------------------------------------------------------
/src/codegen/generateRouteMap.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { generateRouteNamedMap } from './generateRouteMap'
3 | import { PrefixTree } from '../core/tree'
4 | import { resolveOptions } from '../options'
5 |
6 | const DEFAULT_OPTIONS = resolveOptions({})
7 |
8 | function formatExports(exports: string) {
9 | return exports
10 | .split('\n')
11 | .filter((line) => line.length > 0)
12 | .join('\n')
13 | }
14 |
15 | describe('generateRouteNamedMap', () => {
16 | it('works with some paths at root', () => {
17 | const tree = new PrefixTree(DEFAULT_OPTIONS)
18 | tree.insert('index.vue')
19 | tree.insert('a.vue')
20 | tree.insert('b.vue')
21 | tree.insert('c.vue')
22 | expect(formatExports(generateRouteNamedMap(tree))).toMatchInlineSnapshot(`
23 | "export interface RouteNamedMap {
24 | '/': RouteRecordInfo<'/', '/', Record, Record>,
25 | '/a': RouteRecordInfo<'/a', '/a', Record, Record>,
26 | '/b': RouteRecordInfo<'/b', '/b', Record, Record>,
27 | '/c': RouteRecordInfo<'/c', '/c', Record, Record>,
28 | }"
29 | `)
30 | })
31 |
32 | it('adds params', () => {
33 | const tree = new PrefixTree(DEFAULT_OPTIONS)
34 | tree.insert('[a].vue')
35 | tree.insert('partial-[a].vue')
36 | tree.insert('[[a]].vue') // optional
37 | tree.insert('partial-[[a]].vue') // partial-optional
38 | tree.insert('[a]+.vue') // repeated
39 | tree.insert('[[a]]+.vue') // optional repeated
40 | tree.insert('[...a].vue') // splat
41 | expect(formatExports(generateRouteNamedMap(tree))).toMatchInlineSnapshot(`
42 | "export interface RouteNamedMap {
43 | '/[a]': RouteRecordInfo<'/[a]', '/:a', { a: ParamValue }, { a: ParamValue }>,
44 | '/[[a]]': RouteRecordInfo<'/[[a]]', '/:a?', { a?: ParamValueZeroOrOne }, { a?: ParamValueZeroOrOne }>,
45 | '/[...a]': RouteRecordInfo<'/[...a]', '/:a(.*)', { a: ParamValue }, { a: ParamValue