├── e2e ├── .gitignore ├── fixtures │ └── filenames │ │ └── routes │ │ ├── [...path].vue │ │ ├── about.vue │ │ ├── index.vue │ │ ├── users.new.vue │ │ ├── articles │ │ ├── [id].vue │ │ └── [slugs]+.vue │ │ ├── users │ │ └── [id].vue │ │ ├── nested │ │ └── folder │ │ │ ├── index.vue │ │ │ └── should │ │ │ └── work │ │ │ └── index.vue │ │ ├── optional │ │ ├── [[doc]].vue │ │ └── [[docs]]+.vue │ │ └── users.vue ├── routes.spec.ts └── __snapshots__ │ └── routes.spec.ts.snap ├── .github ├── FUNDING.yml └── workflows │ ├── release-tag.yml │ └── ci.yml ├── playground ├── src │ ├── pages │ │ ├── ignored │ │ │ └── not-used.vue │ │ ├── __not-used-either │ │ │ └── not-used.vue │ │ ├── not-used.md │ │ ├── deep │ │ │ └── nesting │ │ │ │ └── works │ │ │ │ ├── __not-used.vue │ │ │ │ ├── too.vue │ │ │ │ ├── [[files]]+.vue │ │ │ │ ├── custom-name.vue │ │ │ │ ├── custom-path.vue │ │ │ │ └── custom-name-and-path.vue │ │ ├── n-[[n]] │ │ │ ├── index.vue │ │ │ └── [[more]]+ │ │ │ │ ├── index.vue │ │ │ │ └── [final].vue │ │ ├── users │ │ │ ├── [id].edit.vue │ │ │ ├── index.vue │ │ │ ├── nested.route.deep.vue │ │ │ └── [id].vue │ │ ├── articles │ │ │ ├── [id]+.vue │ │ │ ├── [id].vue │ │ │ └── index.vue │ │ ├── my-optional-[[slug]].vue │ │ ├── multiple-[a]-[b]-params.vue │ │ ├── __not-used.vue │ │ ├── about.vue │ │ ├── articles.vue │ │ ├── not-used.component.vue │ │ ├── index@named.vue │ │ ├── with-extension.page.vue │ │ ├── custom-path.vue │ │ ├── index.vue │ │ ├── custom-definePage.vue │ │ ├── custom-name.vue │ │ ├── [...path].vue │ │ ├── partial-[name].vue │ │ ├── about.extra.nested.vue │ │ ├── custom-name-and-path.vue │ │ ├── @[profileId].vue │ │ └── [name].vue │ ├── docs │ │ ├── about.vue │ │ ├── index.vue │ │ ├── real │ │ │ └── index.md │ │ └── should-be-ignored.md │ ├── features │ │ ├── feature-1 │ │ │ └── pages │ │ │ │ ├── about.vue │ │ │ │ └── index.vue │ │ ├── feature-2 │ │ │ └── pages │ │ │ │ ├── about.vue │ │ │ │ └── index.vue │ │ └── feature-3 │ │ │ └── pages │ │ │ ├── about.vue │ │ │ └── index.vue │ ├── components │ │ ├── TestSetup.vue │ │ └── Test.vue │ ├── main.ts │ └── App.vue ├── tsconfig.config.json ├── package.json ├── index.html ├── tsconfig.json ├── env.d.ts ├── auto-imports.d.ts ├── vite.config.ts └── typed-router.d.ts ├── examples ├── webpack │ ├── src │ │ ├── pages │ │ │ ├── articles │ │ │ │ └── [id]+.vue │ │ │ ├── [id].vue │ │ │ └── index.vue │ │ ├── main.ts │ │ └── App.vue │ ├── shims-vue.d.ts │ ├── auto-imports.d.ts │ ├── tsconfig.json │ ├── public │ │ └── index.html │ ├── vue.config.js │ ├── package.json │ └── typed-router.d.ts └── nuxt │ ├── pages │ ├── index.vue │ └── users │ │ └── [id].vue │ ├── .gitignore │ ├── tsconfig.json │ ├── package.json │ ├── nuxt.config.ts │ ├── app.vue │ ├── plugins │ └── vueRouter.ts │ └── README.md ├── pnpm-workspace.yaml ├── renovate.json ├── src ├── vite.ts ├── rollup.ts ├── esbuild.ts ├── webpack.ts ├── data-fetching │ ├── parse.ts │ ├── locationUtils.ts │ ├── defineLoader-notes.md │ ├── dataCache.ts │ ├── dataFetchingGuard.ts │ └── README.md ├── core │ ├── __snapshots__ │ │ └── definePage.spec.ts.snap │ ├── options.spec.ts │ ├── utils.spec.ts │ ├── vite │ │ └── index.ts │ ├── moduleConstants.ts │ ├── customBlock.ts │ ├── definePage.spec.ts │ ├── RoutesFolderWatcher.ts │ ├── definePage.ts │ ├── extendRoutes.ts │ ├── extendRoutes.spec.ts │ └── context.ts ├── codegen │ ├── vueRouterModule.ts │ ├── generateRouteParams.ts │ ├── generateRouteMap.ts │ ├── __snapshots__ │ │ └── generateRoutes.spec.ts.snap │ ├── generateRouteRecords.ts │ ├── generateDTS.ts │ └── generateRouteMap.spec.ts ├── runtime.ts ├── types.ts ├── typeExtensions │ ├── router.ts │ ├── navigationGuards.ts │ ├── RouterLink.ts │ ├── routeLocation.ts │ └── RouterTyped.spec.ts ├── index.ts └── options.ts ├── .prettierignore ├── .npmrc ├── .prettierrc.js ├── vitest.config.ts ├── .vscode └── settings.json ├── tsup.config.ts ├── tsconfig.json ├── route.schema.json ├── client.d.ts ├── tests ├── router-mock.ts └── vitest-mock-warn.ts ├── scripts ├── verifyCommit.mjs └── postbuild.ts ├── LICENSE ├── volar └── index.js ├── .gitignore └── package.json /e2e/.gitignore: -------------------------------------------------------------------------------- 1 | *.d.ts 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [posva] 2 | -------------------------------------------------------------------------------- /e2e/fixtures/filenames/routes/[...path].vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /e2e/fixtures/filenames/routes/about.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /e2e/fixtures/filenames/routes/index.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /e2e/fixtures/filenames/routes/users.new.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /playground/src/pages/ignored/not-used.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /e2e/fixtures/filenames/routes/articles/[id].vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /e2e/fixtures/filenames/routes/users/[id].vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/webpack/src/pages/articles/[id]+.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /e2e/fixtures/filenames/routes/articles/[slugs]+.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /e2e/fixtures/filenames/routes/nested/folder/index.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /e2e/fixtures/filenames/routes/optional/[[doc]].vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /e2e/fixtures/filenames/routes/optional/[[docs]]+.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /playground/src/pages/__not-used-either/not-used.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /playground/src/pages/not-used.md: -------------------------------------------------------------------------------- 1 | ## Not used page 2 | -------------------------------------------------------------------------------- /playground/src/pages/deep/nesting/works/__not-used.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /e2e/fixtures/filenames/routes/nested/folder/should/work/index.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /playground/src/docs/about.vue: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /playground/src/docs/index.vue: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /playground/src/pages/n-[[n]]/index.vue: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /playground/src/pages/users/[id].edit.vue: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /playground/src/pages/users/index.vue: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | - packages 2 | - playground 3 | - "examples/*" -------------------------------------------------------------------------------- /playground/src/docs/real/index.md: -------------------------------------------------------------------------------- 1 | # Only some pages should be included 2 | -------------------------------------------------------------------------------- /playground/src/pages/articles/[id]+.vue: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /playground/src/pages/articles/[id].vue: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /playground/src/pages/n-[[n]]/[[more]]+/index.vue: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>posva/renovate-config"] 3 | } 4 | -------------------------------------------------------------------------------- /src/vite.ts: -------------------------------------------------------------------------------- 1 | import unplugin from '.' 2 | 3 | export default unplugin.vite 4 | -------------------------------------------------------------------------------- /playground/src/pages/articles/index.vue: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /playground/src/pages/deep/nesting/works/too.vue: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /playground/src/pages/n-[[n]]/[[more]]+/[final].vue: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/rollup.ts: -------------------------------------------------------------------------------- 1 | import unplugin from '.' 2 | 3 | export default unplugin.rollup 4 | -------------------------------------------------------------------------------- /playground/src/pages/my-optional-[[slug]].vue: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/esbuild.ts: -------------------------------------------------------------------------------- 1 | import unplugin from '.' 2 | 3 | export default unplugin.esbuild 4 | -------------------------------------------------------------------------------- /src/webpack.ts: -------------------------------------------------------------------------------- 1 | import unplugin from '.' 2 | 3 | export default unplugin.webpack 4 | -------------------------------------------------------------------------------- /playground/src/pages/multiple-[a]-[b]-params.vue: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /playground/src/pages/users/nested.route.deep.vue: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | __build__ 2 | dist 3 | .nuxt 4 | .output 5 | coverage 6 | typed-router.d.ts 7 | -------------------------------------------------------------------------------- /playground/src/pages/deep/nesting/works/[[files]]+.vue: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-workspace-root-check=true 2 | shamefully-hoist=true 3 | strict-peer-dependencies=false 4 | -------------------------------------------------------------------------------- /examples/nuxt/pages/index.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /playground/src/docs/should-be-ignored.md: -------------------------------------------------------------------------------- 1 | # Ignored 2 | 3 | This file is at the root so it will get ignored 4 | -------------------------------------------------------------------------------- /playground/src/pages/__not-used.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /playground/src/pages/about.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /playground/src/pages/articles.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | trailingComma: 'es5', 4 | singleQuote: true, 5 | } 6 | -------------------------------------------------------------------------------- /examples/nuxt/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log* 3 | .nuxt 4 | .nitro 5 | .cache 6 | .output 7 | .env 8 | dist 9 | -------------------------------------------------------------------------------- /playground/src/pages/not-used.component.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /e2e/fixtures/filenames/routes/users.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /examples/nuxt/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://v3.nuxtjs.org/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /playground/src/pages/index@named.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /playground/src/pages/with-extension.page.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /playground/src/pages/custom-path.vue: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "path": "/surprise-:id(\\d+)" 4 | } 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /playground/src/pages/deep/nesting/works/custom-name.vue: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "name": "deep a rebel" 4 | } 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | setupFiles: ['./tests/router-mock.ts'], 6 | }, 7 | }) 8 | -------------------------------------------------------------------------------- /playground/src/features/feature-1/pages/about.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /playground/src/features/feature-1/pages/index.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /playground/src/features/feature-2/pages/about.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /playground/src/features/feature-2/pages/index.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /playground/src/features/feature-3/pages/about.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /playground/src/features/feature-3/pages/index.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /playground/src/pages/deep/nesting/works/custom-path.vue: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "path": "/deep-surprise-:id(\\d+)" 4 | } 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /playground/src/pages/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | { "name": "home" } 9 | 10 | -------------------------------------------------------------------------------- /examples/webpack/src/pages/[id].vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /playground/src/pages/custom-definePage.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.enable": true, 3 | "typescript.preferences.autoImportFileExcludePatterns": [ 4 | "vue-router", 5 | ], 6 | "testing.automaticallyOpenPeekView": "never" 7 | } 8 | -------------------------------------------------------------------------------- /playground/src/components/TestSetup.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /playground/src/pages/custom-name.vue: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "name": "a rebel", 4 | "meta": { 5 | "requiresAuth": true 6 | } 7 | } 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/webpack/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | declare module '*.vue' { 3 | import type { DefineComponent } from 'vue' 4 | const component: DefineComponent<{}, {}, any> 5 | export default component 6 | } 7 | -------------------------------------------------------------------------------- /playground/src/components/Test.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /playground/src/pages/[...path].vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | { 10 | "props": true 11 | } 12 | 13 | -------------------------------------------------------------------------------- /playground/src/pages/partial-[name].vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /playground/tsconfig.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.json", 3 | "include": ["vite.config.*", "vitest.config.*", "cypress.config.*"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "types": ["node"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/*.ts'], 5 | clean: true, 6 | format: ['cjs', 'esm'], 7 | dts: true, 8 | external: ['@vue/compiler-sfc', 'vue', 'vue-router'], 9 | onSuccess: 'npm run build:fix', 10 | }) 11 | -------------------------------------------------------------------------------- /examples/nuxt/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "nuxt build", 5 | "dev": "nuxt dev", 6 | "generate": "nuxt generate", 7 | "preview": "nuxt preview" 8 | }, 9 | "devDependencies": { 10 | "nuxt": "^3.5.0", 11 | "unplugin-vue-router": "workspace:*" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/webpack/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import { createWebHistory, createRouter } from 'vue-router/auto' 4 | 5 | const router = createRouter({ 6 | history: createWebHistory(), 7 | }) 8 | 9 | const app = createApp(App) 10 | app.use(router) 11 | app.mount('#app') 12 | -------------------------------------------------------------------------------- /playground/src/pages/about.extra.nested.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /playground/src/pages/custom-name-and-path.vue: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "$schema": "https://raw.githubusercontent.com/posva/unplugin-vue-router/main/route.schema.json", 4 | "name": "the most rebel", 5 | "path": "/most-rebel", 6 | "props": true 7 | } 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /playground/src/pages/deep/nesting/works/custom-name-and-path.vue: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "$schema": "https://raw.githubusercontent.com/posva/unplugin-vue-router/main/route.schema.json", 4 | "name": "deep the most rebel", 5 | "path": "/deep-most-rebel" 6 | } 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/nuxt/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import { _HasDataLoaderMeta } from 'unplugin-vue-router/runtime' 2 | 3 | // https://v3.nuxtjs.org/api/configuration/nuxt.config 4 | export default defineNuxtConfig({ 5 | typescript: { 6 | shim: false, 7 | }, 8 | 9 | build: { transpile: [/unplugin-vue-router\/runtime/] }, 10 | 11 | app: { 12 | pageTransition: false, 13 | layoutTransition: false, 14 | }, 15 | }) 16 | -------------------------------------------------------------------------------- /playground/src/pages/@[profileId].vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | -------------------------------------------------------------------------------- /src/data-fetching/parse.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs' 2 | import { findExports } from 'mlly' 3 | 4 | export async function hasNamedExports(file: string) { 5 | const code = await fs.readFile(file, 'utf8') 6 | 7 | const exportedNames = findExports(code).filter( 8 | (e) => e.type !== 'default' && e.type !== 'star' 9 | ) 10 | 11 | // it may have exposed loaders 12 | return exportedNames.length > 0 13 | } 14 | -------------------------------------------------------------------------------- /examples/nuxt/app.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "nodemon -w '../src/**/*.ts' -e .ts -x vite", 5 | "build": "vite build" 6 | }, 7 | "devDependencies": { 8 | "@vitejs/plugin-vue": "^4.4.0", 9 | "@vue/compiler-sfc": "^3.3.7", 10 | "@vue/tsconfig": "^0.4.0", 11 | "unplugin-vue-router": "workspace:*", 12 | "vite": "^5.0.4", 13 | "vite-plugin-inspect": "^0.7.41", 14 | "vue": "^3.3.7" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/webpack/auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by 'unplugin-auto-import' 2 | export {} 3 | declare global { 4 | const defineLoader: typeof import('vue-router/auto')['defineLoader'] 5 | const onBeforeRouteLeave: typeof import('vue-router/auto')['onBeforeRouteLeave'] 6 | const onBeforeRouteUpdate: typeof import('vue-router/auto')['onBeforeRouteUpdate'] 7 | const useRoute: typeof import('vue-router/auto')['useRoute'] 8 | const useRouter: typeof import('vue-router/auto')['useRouter'] 9 | } 10 | -------------------------------------------------------------------------------- /examples/webpack/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": [ 4 | "shims-vue.d.ts", 5 | "src/**/*", 6 | "src/**/*.vue", 7 | "./shim-vue.d.ts", 8 | "./typed-router.d.ts", 9 | "./auto-imports.d.ts" 10 | ], 11 | "compilerOptions": { 12 | "baseUrl": ".", 13 | "paths": { 14 | "@/*": ["./src/*"] 15 | } 16 | }, 17 | 18 | "references": [ 19 | { 20 | "path": "../../playground/tsconfig.config.json" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /playground/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import { 4 | createWebHistory, 5 | createRouter, 6 | setupDataFetchingGuard, 7 | } from 'vue-router/auto' 8 | 9 | const router = createRouter({ 10 | history: createWebHistory(), 11 | extendRoutes: (routes) => { 12 | // routes.find((r) => r.name === '/')!.meta = {} 13 | return routes 14 | }, 15 | }) 16 | 17 | setupDataFetchingGuard(router) 18 | 19 | const app = createApp(App) 20 | 21 | app.use(router) 22 | 23 | app.mount('#app') 24 | -------------------------------------------------------------------------------- /examples/nuxt/plugins/vueRouter.ts: -------------------------------------------------------------------------------- 1 | import { _setupDataFetchingGuard } from 'unplugin-vue-router/runtime' 2 | 3 | export default defineNuxtPlugin((nuxtApp) => { 4 | // console.log('going in!', useRouter()) 5 | const data = _setupDataFetchingGuard( 6 | useRouter(), 7 | process.client ? nuxtApp.payload._uvr : undefined 8 | ) 9 | 10 | if (process.server) { 11 | nuxtApp.payload._uvr = data 12 | } 13 | 14 | useRouter() 15 | .isReady() 16 | .then(() => { 17 | console.log('READY!', nuxtApp.payload._uvr) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /playground/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | visit /__inspect/ to inspect the intermediate state 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/core/__snapshots__/definePage.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`definePage > removes definePage 1`] = ` 4 | " 5 | 10 | 11 | 14 | " 15 | `; 16 | 17 | exports[`definePage > works if file is named definePage 1`] = ` 18 | " 19 | 24 | 25 | 28 | " 29 | `; 30 | -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": [ 4 | "./env.d.ts", 5 | "./src/**/*.ts", 6 | "./src/**/*.vue", 7 | "./typed-router.d.ts", 8 | "./auto-imports.d.ts" 9 | ], 10 | "compilerOptions": { 11 | "baseUrl": ".", 12 | "paths": { 13 | "@/*": [ 14 | "./src/*" 15 | ] 16 | } 17 | }, 18 | "vueCompilerOptions": { 19 | "plugins": [ 20 | "../volar" 21 | ] 22 | }, 23 | "references": [ 24 | { 25 | "path": "./tsconfig.config.json" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "*.d.ts", 4 | "src/**/*.ts", 5 | "src/*.d.ts" 6 | ], 7 | "exclude": [ 8 | "node_modules", 9 | "dist" 10 | ], 11 | "compilerOptions": { 12 | "target": "es2017", 13 | "module": "esnext", 14 | "lib": [ 15 | "esnext", 16 | "DOM" 17 | ], 18 | "moduleResolution": "node", 19 | "skipLibCheck": true, 20 | "esModuleInterop": true, 21 | "strict": true, 22 | "strictNullChecks": true, 23 | "resolveJsonModule": true, 24 | "types": [ 25 | "vite/client" 26 | ] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /playground/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // manual extension of route types 4 | declare module 'vue-router/auto/routes' { 5 | import type { 6 | RouteRecordInfo, 7 | ParamValue, 8 | ParamValueOneOrMore, 9 | ParamValueZeroOrMore, 10 | ParamValueZeroOrOne, 11 | } from 'unplugin-vue-router' 12 | 13 | export interface RouteNamedMap { 14 | 'custom-dynamic-name': RouteRecordInfo< 15 | 'custom-dynamic-name', 16 | '/added-during-runtime/[...path]', 17 | { path: ParamValue }, 18 | { path: ParamValue } 19 | > 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/core/options.spec.ts: -------------------------------------------------------------------------------- 1 | // this file had to be moved to avoid tsup from picking it up 2 | import { describe, expect, it } from 'vitest' 3 | import { resolveOptions } from '../options' 4 | import { mockWarn } from '../../tests/vitest-mock-warn' 5 | 6 | describe('options', () => { 7 | mockWarn() 8 | it('ensure starting dots in extensions', () => { 9 | expect( 10 | resolveOptions({ 11 | extensions: ['vue', '.ts'], 12 | }) 13 | ).toMatchObject({ 14 | extensions: ['.vue', '.ts'], 15 | }) 16 | 17 | expect('Invalid extension "vue"').toHaveBeenWarned() 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /examples/webpack/src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 23 | -------------------------------------------------------------------------------- /playground/src/pages/users/[id].vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 17 | 18 | 19 | const a = 20 as 20 | 30 20 | 21 | console.log('WITHIN ROUTE_BLOCK', a) 22 | 23 | export default { 24 | alias: '/u/:id', 25 | meta: { 26 | a, 27 | other: 'other', 28 | }, 29 | } 30 | 31 | -------------------------------------------------------------------------------- /playground/auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | // @ts-nocheck 4 | // Generated by unplugin-auto-import 5 | export {} 6 | declare global { 7 | const defineLoader: typeof import('vue-router/auto')['defineLoader'] 8 | const definePage: typeof import('unplugin-vue-router/runtime')['_definePage'] 9 | const onBeforeRouteLeave: typeof import('vue-router/auto')['onBeforeRouteLeave'] 10 | const onBeforeRouteUpdate: typeof import('vue-router/auto')['onBeforeRouteUpdate'] 11 | const useRoute: typeof import('vue-router/auto')['useRoute'] 12 | const useRouter: typeof import('vue-router/auto')['useRouter'] 13 | } 14 | -------------------------------------------------------------------------------- /examples/webpack/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= htmlWebpackPlugin.options.title %> 8 | 9 | 10 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /examples/webpack/vue.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('@vue/cli-service') 2 | const { VueRouterExports } = require('unplugin-vue-router') 3 | const routerPlugin = require('unplugin-vue-router/webpack').default 4 | const autoImport = require('unplugin-auto-import/webpack') 5 | 6 | module.exports = defineConfig({ 7 | lintOnSave: false, 8 | configureWebpack: { 9 | plugins: [ 10 | routerPlugin({ 11 | routesFolder: 'src/pages', 12 | }), 13 | autoImport({ 14 | imports: [ 15 | { 16 | 'vue-router/auto': VueRouterExports, 17 | }, 18 | ], 19 | }), 20 | ], 21 | }, 22 | }) 23 | -------------------------------------------------------------------------------- /.github/workflows/release-tag.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 5 | 6 | name: Create Release 7 | 8 | jobs: 9 | build: 10 | name: Create Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@master 15 | - name: Create Release for Tag 16 | id: release_tag 17 | uses: yyx990803/release-tag@master 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | with: 21 | tag_name: ${{ github.ref }} 22 | body: | 23 | Please refer to [CHANGELOG.md](https://github.com/posva/unplugin-vue-router/blob/main/CHANGELOG.md) for details. 24 | -------------------------------------------------------------------------------- /src/core/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { trimExtension } from './utils' 3 | 4 | describe('utils', () => { 5 | describe('trimExtension', () => { 6 | it('trims when found', () => { 7 | expect(trimExtension('foo.vue', ['.vue'])).toBe('foo') 8 | expect(trimExtension('foo.vue', ['.ts', '.vue'])).toBe('foo') 9 | expect(trimExtension('foo.ts', ['.ts', '.vue'])).toBe('foo') 10 | expect(trimExtension('foo.page.vue', ['.page.vue'])).toBe('foo') 11 | }) 12 | 13 | it('skips if not found', () => { 14 | expect(trimExtension('foo.vue', ['.page.vue'])).toBe('foo.vue') 15 | expect(trimExtension('foo.page.vue', ['.vue'])).toBe('foo.page') 16 | }) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /examples/webpack/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webpack", 3 | "private": true, 4 | "scripts": { 5 | "dev": "vue-cli-service serve", 6 | "build": "vue-cli-service build" 7 | }, 8 | "dependencies": { 9 | "vue": "^3.3.4", 10 | "vue-router": "^4.2.0" 11 | }, 12 | "devDependencies": { 13 | "@babel/core": "^7.21.8", 14 | "@vue/cli-plugin-router": "~5.0.0", 15 | "@vue/cli-plugin-typescript": "~5.0.0", 16 | "@vue/cli-service": "~5.0.0", 17 | "@vue/tsconfig": "^0.4.0", 18 | "typescript": "^5.0.4", 19 | "unplugin-auto-import": "^0.16.0", 20 | "unplugin-vue-router": "workspace:*" 21 | }, 22 | "browserslist": [ 23 | "> 1%", 24 | "last 2 versions", 25 | "not dead", 26 | "not ie 11" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /src/core/vite/index.ts: -------------------------------------------------------------------------------- 1 | import { ViteDevServer } from 'vite' 2 | import { ServerContext } from '../../options' 3 | import { asVirtualId } from '../moduleConstants' 4 | 5 | export function createViteContext(server: ViteDevServer): ServerContext { 6 | function invalidate(path: string) { 7 | const { moduleGraph } = server 8 | const foundModule = moduleGraph.getModuleById(asVirtualId(path)) 9 | if (foundModule) { 10 | moduleGraph.invalidateModule(foundModule) 11 | } 12 | return !!foundModule 13 | } 14 | 15 | function reload() { 16 | if (server.ws) { 17 | server.ws.send({ 18 | type: 'full-reload', 19 | path: '*', 20 | }) 21 | } 22 | } 23 | 24 | return { 25 | invalidate, 26 | reload, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/core/moduleConstants.ts: -------------------------------------------------------------------------------- 1 | export const MODULE_VUE_ROUTER = 'vue-router/auto' 2 | // the path is used by the user and having slashes is just more natural 3 | export const MODULE_ROUTES_PATH = `${MODULE_VUE_ROUTER}/routes` 4 | 5 | export const VIRTUAL_PREFIX = 'virtual:' 6 | 7 | // allows removing the route block from the code 8 | export const ROUTE_BLOCK_ID = `${VIRTUAL_PREFIX}/vue-router/auto/route-block` 9 | 10 | export const MODULES_ID_LIST = [MODULE_VUE_ROUTER, MODULE_ROUTES_PATH] 11 | 12 | export function getVirtualId(id: string) { 13 | return id.startsWith(VIRTUAL_PREFIX) ? id.slice(VIRTUAL_PREFIX.length) : null 14 | } 15 | 16 | export const routeBlockQueryRE = /\?vue&type=route/ 17 | 18 | export function asVirtualId(id: string) { 19 | return VIRTUAL_PREFIX + id 20 | } 21 | -------------------------------------------------------------------------------- /examples/nuxt/README.md: -------------------------------------------------------------------------------- 1 | # Nuxt 3 Minimal Starter 2 | 3 | Look at the [nuxt 3 documentation](https://v3.nuxtjs.org) to learn more. 4 | 5 | ## Setup 6 | 7 | Make sure to install the dependencies: 8 | 9 | ```bash 10 | # yarn 11 | yarn install 12 | 13 | # npm 14 | npm install 15 | 16 | # pnpm 17 | pnpm install --shamefully-hoist 18 | ``` 19 | 20 | ## Development Server 21 | 22 | Start the development server on http://localhost:3000 23 | 24 | ```bash 25 | npm run dev 26 | ``` 27 | 28 | ## Production 29 | 30 | Build the application for production: 31 | 32 | ```bash 33 | npm run build 34 | ``` 35 | 36 | Locally preview production build: 37 | 38 | ```bash 39 | npm run preview 40 | ``` 41 | 42 | Checkout the [deployment documentation](https://v3.nuxtjs.org/guide/deploy/presets) for more information. 43 | -------------------------------------------------------------------------------- /route.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$id": "https://raw.githubusercontent.com/posva/unplugin-vue-router/main/route.schema.json", 4 | "title": "Route custom block", 5 | "description": "An SFC custom block to add information to a route", 6 | "type": "object", 7 | "properties": { 8 | "name": { 9 | "type": "string", 10 | "description": "The name of the route" 11 | }, 12 | "path": { 13 | "type": "string", 14 | "description": "The path of the route" 15 | }, 16 | "meta": { 17 | "type": "object", 18 | "description": "The meta of the route" 19 | }, 20 | "props": { 21 | "type": "boolean", 22 | "description": "Whether the route should be passed its params as props" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'vue-router/auto/routes' { 2 | import type { RouteRecordRaw } from 'vue-router' 3 | export const routes: RouteRecordRaw[] 4 | } 5 | 6 | declare module 'vue-router/auto' { 7 | import type { RouterOptions, Router, RouteRecordRaw } from 'vue-router' 8 | export * from 'vue-router' 9 | 10 | /** 11 | * unplugin-vue-router version of `RouterOptions`. 12 | */ 13 | export interface _RouterOptions extends Omit { 14 | /** 15 | * Allows modifying the routes before they are passed to the router. You can modify the existing array or return a 16 | * new one. 17 | * 18 | * @param routes - The routes generated by this plugin and exposed by `vue-router/auto/routes` 19 | */ 20 | extendRoutes?: (routes: RouteRecordRaw[]) => RouteRecordRaw[] | void 21 | } 22 | export function createRouter(options: _RouterOptions): Router 23 | } 24 | -------------------------------------------------------------------------------- /examples/nuxt/pages/users/[id].vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 30 | 31 | 37 | -------------------------------------------------------------------------------- /tests/router-mock.ts: -------------------------------------------------------------------------------- 1 | import { 2 | VueRouterMock, 3 | createRouterMock as _createRouterMock, 4 | injectRouterMock, 5 | RouterMockOptions, 6 | } from 'vue-router-mock' 7 | import { config } from '@vue/test-utils' 8 | import { beforeEach, vi, SpyInstance } from 'vitest' 9 | 10 | export function createRouterMock(options?: RouterMockOptions) { 11 | return _createRouterMock({ 12 | ...options, 13 | spy: { 14 | create: (fn) => vi.fn(fn), 15 | reset: (spy: SpyInstance) => spy.mockClear(), 16 | ...options?.spy, 17 | }, 18 | }) 19 | } 20 | 21 | export function setupRouterMock() { 22 | if (typeof global.document === 'undefined') { 23 | // skip this plugin in non jsdom environments 24 | return 25 | } 26 | 27 | const router = createRouterMock({ 28 | useRealNavigation: true, 29 | }) 30 | 31 | beforeEach(() => { 32 | router.reset() 33 | injectRouterMock(router) 34 | }) 35 | 36 | config.plugins.VueWrapper.install(VueRouterMock) 37 | } 38 | 39 | setupRouterMock() 40 | -------------------------------------------------------------------------------- /src/data-fetching/locationUtils.ts: -------------------------------------------------------------------------------- 1 | import { LocationQuery } from 'vue-router' 2 | 3 | // FIXME: this exists in vue-router 4 | /** 5 | * Returns true if `inner` is a subset of `outer` 6 | * 7 | * @param outer - the bigger params 8 | * @param inner - the smaller params 9 | */ 10 | export function includesParams( 11 | outer: LocationQuery, 12 | inner: Partial 13 | ): boolean { 14 | for (const key in inner) { 15 | const innerValue = inner[key] 16 | const outerValue = outer[key] 17 | if (typeof innerValue === 'string') { 18 | if (innerValue !== outerValue) return false 19 | } else if (!innerValue || !outerValue) { 20 | // if one of them is undefined, we need to check if the other is undefined too 21 | if (innerValue !== outerValue) return false 22 | } else { 23 | if ( 24 | !Array.isArray(outerValue) || 25 | outerValue.length !== innerValue.length || 26 | innerValue.some((value, i) => value !== outerValue[i]) 27 | ) 28 | return false 29 | } 30 | } 31 | 32 | return true 33 | } 34 | -------------------------------------------------------------------------------- /scripts/verifyCommit.mjs: -------------------------------------------------------------------------------- 1 | // Invoked on the commit-msg git hook by yorkie. 2 | 3 | import chalk from 'chalk' 4 | import { readFileSync } from 'fs' 5 | 6 | const msgPath = process.env.GIT_PARAMS 7 | const msg = readFileSync(msgPath, 'utf-8').trim() 8 | 9 | const commitRE = 10 | /^(revert: )?(feat|fix|docs|dx|style|refactor|perf|test|workflow|build|ci|chore|types|wip|release)(\(.+\))?: .{1,50}/ 11 | 12 | if (!commitRE.test(msg)) { 13 | console.log() 14 | console.error( 15 | ` ${chalk.bgRed.white(' ERROR ')} ${chalk.red( 16 | `invalid commit message format.` 17 | )}\n\n` + 18 | chalk.red( 19 | ` Proper commit message format is required for automated changelog generation. Examples:\n\n` 20 | ) + 21 | ` ${chalk.green( 22 | `fix(view): handle keep-alive with aborted navigations` 23 | )}\n` + 24 | ` ${chalk.green( 25 | `fix(view): handle keep-alive with aborted navigations (close #28)` 26 | )}\n\n` + 27 | chalk.red(` See .github/commit-convention.md for more details.\n`) 28 | ) 29 | process.exit(1) 30 | } 31 | -------------------------------------------------------------------------------- /e2e/routes.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from 'vitest' 2 | import { createRoutesContext } from '../src/core/context' 3 | import { DEFAULT_OPTIONS } from '../src/options' 4 | import { fileURLToPath, URL } from 'url' 5 | import { normalize, join } from 'pathe' 6 | 7 | const __dirname = fileURLToPath(new URL('./', import.meta.url)) 8 | 9 | /** 10 | * This is a simple full test to check that all filenames are valid in different environment (windows, mac, linux). 11 | */ 12 | 13 | it('generates the routes', async () => { 14 | const context = createRoutesContext({ 15 | ...DEFAULT_OPTIONS, 16 | // dts: join(__dirname, './__types.d.ts'), 17 | dts: false, 18 | logs: false, 19 | routesFolder: [{ src: join(__dirname, './fixtures/filenames/routes') }], 20 | }) 21 | 22 | await context.scanPages() 23 | expect( 24 | context 25 | .generateRoutes() 26 | .replace( 27 | /import\(["'](.+?)["']\)/g, 28 | (_, filePath) => `import('${normalize(filePath)}')` 29 | ) 30 | .replace(/(import\(["'])(?:.+?)fixtures\/filenames/gi, '$1') 31 | ).toMatchSnapshot() 32 | }) 33 | -------------------------------------------------------------------------------- /src/codegen/vueRouterModule.ts: -------------------------------------------------------------------------------- 1 | // NOTE: this code needs to be generated because otherwise it doesn't go through transforms and `vue-router/auto/routes` 2 | 3 | import type { ResolvedOptions } from '../options' 4 | 5 | // cannot be resolved. 6 | export function generateVueRouterProxy( 7 | routesModule: string, 8 | options: ResolvedOptions 9 | ) { 10 | return ` 11 | import { routes } from '${routesModule}' 12 | import { createRouter as _createRouter } from 'vue-router' 13 | 14 | export * from 'vue-router' 15 | export { 16 | _defineLoader as defineLoader, 17 | _definePage as definePage, 18 | _HasDataLoaderMeta as HasDataLoaderMeta, 19 | _setupDataFetchingGuard as setupDataFetchingGuard, 20 | _stopDataFetchingScope as stopDataFetchingScope, 21 | } from 'unplugin-vue-router/runtime' 22 | 23 | export function createRouter(options) { 24 | const { extendRoutes } = options 25 | // use Object.assign for better browser support 26 | const router = _createRouter(Object.assign( 27 | options, 28 | { routes: typeof extendRoutes === 'function' ? extendRoutes(routes) : routes }, 29 | )) 30 | 31 | return router 32 | } 33 | ` 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Eduardo San Martin Morote 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set node 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 16.x 21 | 22 | - name: Setup 23 | run: npm i -g @antfu/ni 24 | 25 | - name: Install 26 | run: nci 27 | 28 | - name: Lint 29 | run: nr lint 30 | 31 | - name: Build 32 | run: nr build 33 | 34 | test: 35 | runs-on: ${{ matrix.os }} 36 | 37 | strategy: 38 | matrix: 39 | node: [16.x, 18.x] 40 | os: [ubuntu-latest, windows-latest, macos-latest] 41 | fail-fast: false 42 | 43 | steps: 44 | - uses: actions/checkout@v4 45 | - name: Set node ${{ matrix.node }} 46 | uses: actions/setup-node@v4 47 | with: 48 | node-version: ${{ matrix.node }} 49 | 50 | - name: Setup 51 | run: npm i -g @antfu/ni 52 | 53 | - name: Install 54 | run: nci 55 | 56 | - name: Test 57 | run: nr test 58 | -------------------------------------------------------------------------------- /scripts/postbuild.ts: -------------------------------------------------------------------------------- 1 | import { basename, resolve } from 'path' 2 | import { promises as fs } from 'fs' 3 | import fg from 'fast-glob' 4 | import chalk from 'chalk' 5 | 6 | async function run() { 7 | const files = await fg('*.js', { 8 | ignore: ['chunk-*'], 9 | absolute: true, 10 | cwd: resolve(__dirname, '../dist'), 11 | }) 12 | for (const file of files) { 13 | const filename = basename(file) 14 | console.log(chalk.cyan.inverse(' POST '), `Fix ${filename}`) 15 | if (file === 'index.js') { 16 | // fix cjs exports 17 | let code = await fs.readFile(file, 'utf8') 18 | code = code.replace('exports.default =', 'module.exports =') 19 | code += 'exports.default = module.exports;' 20 | await fs.writeFile(file, code) 21 | } 22 | // generate submodule .d.ts redirecting 23 | const name = basename(file, '.js') 24 | await fs.writeFile( 25 | `${name}.d.ts`, 26 | // these files should keep the regular export 27 | filename === 'runtime.js' || 28 | filename === 'types.js' || 29 | filename === 'index.js' || 30 | filename === 'options.js' 31 | ? `export * from './dist/${name}'\n` 32 | : `export { default } from './dist/${name}'\n` 33 | ) 34 | } 35 | } 36 | 37 | run() 38 | -------------------------------------------------------------------------------- /volar/index.js: -------------------------------------------------------------------------------- 1 | const plugin = () => { 2 | return { 3 | getEmbeddedFileNames(fileName, sfc) { 4 | const fileNames = [] 5 | for (let i = 0; i < sfc.customBlocks.length; i++) { 6 | const block = sfc.customBlocks[i] 7 | if (block.type === 'route' && block.lang === 'ts') { 8 | fileNames.push(`${fileName}.route_${i}.${block.lang}`) 9 | } 10 | } 11 | return fileNames 12 | }, 13 | 14 | resolveEmbeddedFile(fileName, sfc, embeddedFile) { 15 | const match = embeddedFile.fileName.match(/^(.*)\.route_(\d+)\.([^.]+)$/) 16 | if (match) { 17 | const index = parseInt(match[2]) 18 | const block = sfc.customBlocks[index] 19 | embeddedFile.capabilities = { 20 | diagnostics: true, 21 | foldingRanges: true, 22 | formatting: true, 23 | documentSymbol: true, 24 | codeActions: true, 25 | inlayHints: true, 26 | } 27 | embeddedFile.isTsHostFile = true 28 | embeddedFile.codeGen.addCode2(block.content, 0, { 29 | vueTag: 'customBlock', 30 | vueTagIndex: index, 31 | capabilities: { 32 | basic: true, 33 | references: true, 34 | definitions: true, 35 | diagnostic: true, 36 | rename: true, 37 | completion: true, 38 | semanticTokens: true, 39 | }, 40 | }) 41 | } 42 | }, 43 | } 44 | } 45 | 46 | module.exports = plugin 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # parcel-bundler cache (https://parceljs.org/) 63 | .cache 64 | 65 | # next.js build output 66 | .next 67 | 68 | # nuxt.js build output 69 | .nuxt 70 | 71 | # Nuxt generate 72 | dist 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless 79 | 80 | # IDE 81 | .idea 82 | /*.d.ts 83 | !client.d.ts 84 | -------------------------------------------------------------------------------- /src/codegen/generateRouteParams.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from '../core/tree' 2 | 3 | export function generateRouteParams(node: TreeNode, isRaw: boolean): string { 4 | const nodeParams = node.params 5 | return node.params.length > 0 6 | ? `{ ${node.params 7 | .map( 8 | (param) => 9 | `${param.paramName}${param.optional ? '?' : ''}: ` + 10 | (param.modifier === '+' 11 | ? `ParamValueOneOrMore<${isRaw}>` 12 | : param.modifier === '*' 13 | ? `ParamValueZeroOrMore<${isRaw}>` 14 | : param.modifier === '?' 15 | ? `ParamValueZeroOrOne<${isRaw}>` 16 | : `ParamValue<${isRaw}>`) 17 | ) 18 | .join(', ')} }` 19 | : // no params allowed 20 | 'Record' 21 | } 22 | 23 | /** 24 | * Utility type for raw and non raw params like :id+ 25 | * 26 | */ 27 | export type ParamValueOneOrMore = [ 28 | ParamValue, 29 | ...ParamValue[] 30 | ] 31 | 32 | /** 33 | * Utility type for raw and non raw params like :id* 34 | * 35 | */ 36 | export type ParamValueZeroOrMore = 37 | | ParamValue[] 38 | | undefined 39 | | null 40 | 41 | /** 42 | * Utility type for raw and non raw params like :id? 43 | * 44 | */ 45 | export type ParamValueZeroOrOne = true extends isRaw 46 | ? string | number | null | undefined 47 | : string 48 | 49 | /** 50 | * Utility type for raw and non raw params like :id 51 | * 52 | */ 53 | export type ParamValue = true extends isRaw 54 | ? string | number 55 | : string 56 | -------------------------------------------------------------------------------- /src/codegen/generateRouteMap.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | RouteMeta, 3 | RouteParamsRaw, 4 | RouteParams, 5 | RouterLinkProps as _RouterLinkProps, 6 | } from 'vue-router' 7 | import type { TreeNode } from '../core/tree' 8 | import { generateRouteParams } from './generateRouteParams' 9 | 10 | export function generateRouteNamedMap(node: TreeNode): string { 11 | // root 12 | if (node.isRoot()) { 13 | return `export interface RouteNamedMap { 14 | ${node.getSortedChildren().map(generateRouteNamedMap).join('')}}` 15 | } 16 | 17 | return ( 18 | // if the node has a filePath, it's a component, it has a routeName and it should be referenced in the RouteNamedMap 19 | // otherwise it should be skipped to avoid navigating to a route that doesn't render anything 20 | (node.value.components.size 21 | ? ` '${node.name}': ${generateRouteRecordInfo(node)},\n` 22 | : '') + 23 | (node.children.size > 0 24 | ? node.getSortedChildren().map(generateRouteNamedMap).join('\n') 25 | : '') 26 | ) 27 | } 28 | 29 | export function generateRouteRecordInfo(node: TreeNode) { 30 | return `RouteRecordInfo<'${node.name}', '${ 31 | node.fullPath 32 | }', ${generateRouteParams(node, true)}, ${generateRouteParams(node, false)}>` 33 | } 34 | 35 | export interface RouteRecordInfo< 36 | Name extends string = string, 37 | Path extends string = string, 38 | ParamsRaw extends RouteParamsRaw = RouteParamsRaw, 39 | Params extends RouteParams = RouteParams, 40 | Meta extends RouteMeta = RouteMeta 41 | > { 42 | name: Name 43 | path: Path 44 | paramsRaw: ParamsRaw 45 | params: Params 46 | // TODO: implement meta with a defineRoute macro 47 | meta: Meta 48 | } 49 | 50 | export type _RouteMapGeneric = Record 51 | -------------------------------------------------------------------------------- /src/runtime.ts: -------------------------------------------------------------------------------- 1 | import type { RouteRecordRaw } from 'vue-router' 2 | 3 | export { defineLoader as _defineLoader } from './data-fetching/defineLoader' 4 | export type { 5 | DefineLoaderOptions, 6 | DataLoader, 7 | } from './data-fetching/defineLoader' 8 | export { 9 | setupDataFetchingGuard as _setupDataFetchingGuard, 10 | HasDataLoaderMeta as _HasDataLoaderMeta, 11 | } from './data-fetching/dataFetchingGuard' 12 | export { stopScope as _stopDataFetchingScope } from './data-fetching/dataCache' 13 | 14 | /** 15 | * Defines properties of the route for the current page component. 16 | * 17 | * @param route - route information to be added to this page 18 | */ 19 | export const _definePage = (route: DefinePage) => route 20 | 21 | /** 22 | * Merges route records. 23 | * 24 | * @internal 25 | * 26 | * @param main - main route record 27 | * @param routeRecords - route records to merge 28 | * @returns merged route record 29 | */ 30 | export function _mergeRouteRecord( 31 | main: RouteRecordRaw, 32 | ...routeRecords: Partial[] 33 | ): RouteRecordRaw { 34 | // @ts-expect-error: complicated types 35 | return routeRecords.reduce((acc, routeRecord) => { 36 | const meta = Object.assign({}, acc.meta, routeRecord.meta) 37 | const alias: string[] = ([] as string[]).concat( 38 | acc.alias || [], 39 | routeRecord.alias || [] 40 | ) 41 | 42 | // TODO: other nested properties 43 | // const props = Object.assign({}, acc.props, routeRecord.props) 44 | 45 | Object.assign(acc, routeRecord) 46 | acc.meta = meta 47 | acc.alias = alias 48 | return acc 49 | }, main) 50 | } 51 | 52 | /** 53 | * Type to define a page. Can be augmented to add custom properties. 54 | */ 55 | export interface DefinePage 56 | extends Partial< 57 | Omit 58 | > {} 59 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file only contain types and is used for the generated d.ts to avoid polluting the global namespace. 3 | * https://github.com/posva/unplugin-vue-router/issues/136 4 | */ 5 | 6 | export type { Options } from './options' 7 | 8 | export type { 9 | _RouteMapGeneric, 10 | RouteRecordInfo, 11 | } from './codegen/generateRouteMap' 12 | export type { 13 | // TODO: mark all of these as internals since the dynamically exposed versions are fully typed, these are just helpers 14 | // to generate the convenient types 15 | RouteLocationAsRelativeTyped, 16 | RouteLocationAsRelativeTypedList, 17 | RouteLocationAsPathTyped, 18 | RouteLocationAsPathTypedList, 19 | RouteLocationAsString, 20 | RouteLocationTyped, 21 | RouteLocationTypedList, 22 | RouteLocationResolvedTyped, 23 | RouteLocationResolvedTypedList, 24 | RouteLocationNormalizedTyped, 25 | RouteLocationNormalizedTypedList, 26 | RouteLocationNormalizedLoadedTyped, 27 | RouteLocationNormalizedLoadedTypedList, 28 | } from './typeExtensions/routeLocation' 29 | export type { NavigationGuard } from './typeExtensions/navigationGuards' 30 | export type { _RouterTyped } from './typeExtensions/router' 31 | export type { 32 | RouterLinkTyped, 33 | UseLinkFnTyped, 34 | _UseLinkReturnTyped, 35 | RouterLinkPropsTyped, 36 | } from './typeExtensions/RouterLink' 37 | export type { 38 | ParamValue, 39 | ParamValueOneOrMore, 40 | ParamValueZeroOrMore, 41 | ParamValueZeroOrOne, 42 | } from './codegen/generateRouteParams' 43 | 44 | export type { TreeNode } from './core/tree' 45 | export type { 46 | TreeNodeValueParam, 47 | TreeNodeValueStatic, 48 | } from './core/treeNodeValue' 49 | 50 | // expose for generated type extensions 51 | export type { 52 | DefineLoaderOptions as _DefineLoaderOptions, 53 | DataLoader as _DataLoader, 54 | } from './data-fetching/defineLoader' 55 | -------------------------------------------------------------------------------- /src/typeExtensions/router.ts: -------------------------------------------------------------------------------- 1 | import type { Ref } from 'vue' 2 | import type { Router, RouteLocationNormalizedLoaded } from 'vue-router' 3 | import type { 4 | RouteLocationResolvedTypedList, 5 | RouteLocationNormalizedLoadedTypedList, 6 | RouteLocationAsString, 7 | RouteLocationAsRelativeTyped, 8 | RouteLocationAsPathTyped, 9 | } from './routeLocation' 10 | import type { _RouteMapGeneric } from '../codegen/generateRouteMap' 11 | import type { 12 | NavigationGuardWithThis, 13 | NavigationHookAfter, 14 | } from './navigationGuards' 15 | 16 | export interface _RouterTyped< 17 | RouteMap extends _RouteMapGeneric = _RouteMapGeneric 18 | > extends Omit< 19 | Router, 20 | | 'resolve' 21 | | 'push' 22 | | 'replace' 23 | | 'beforeEach' 24 | | 'beforeResolve' 25 | | 'afterEach' 26 | > { 27 | currentRoute: Ref< 28 | RouteLocationNormalizedLoadedTypedList[keyof RouteMap] 29 | > 30 | 31 | push( 32 | to: 33 | | RouteLocationAsString 34 | | RouteLocationAsRelativeTyped 35 | | RouteLocationAsPathTyped 36 | ): ReturnType 37 | 38 | replace( 39 | to: 40 | | RouteLocationAsString 41 | | RouteLocationAsRelativeTyped 42 | | RouteLocationAsPathTyped 43 | ): ReturnType 44 | 45 | resolve( 46 | to: 47 | | RouteLocationAsString 48 | | RouteLocationAsRelativeTyped 49 | | RouteLocationAsPathTyped, 50 | currentLocation?: RouteLocationNormalizedLoaded 51 | ): RouteLocationResolvedTypedList[Name] 52 | 53 | beforeEach( 54 | guard: NavigationGuardWithThis 55 | ): ReturnType 56 | beforeResolve( 57 | guard: NavigationGuardWithThis 58 | ): ReturnType 59 | afterEach( 60 | guard: NavigationHookAfter 61 | ): ReturnType 62 | } 63 | -------------------------------------------------------------------------------- /src/typeExtensions/navigationGuards.ts: -------------------------------------------------------------------------------- 1 | // original is 2 | 3 | import type { NavigationGuardNext, NavigationFailure } from 'vue-router' 4 | import type { _RouteMapGeneric } from '../codegen/generateRouteMap' 5 | import type { 6 | RouteLocationAsPathTypedList, 7 | RouteLocationAsRelativeTypedList, 8 | RouteLocationAsString, 9 | RouteLocationNormalizedLoadedTypedList, 10 | RouteLocationNormalizedTypedList, 11 | } from './routeLocation' 12 | 13 | // type NavigationGuardReturn = void | Error | RouteLocationRaw | boolean | NavigationGuardNextCallback; 14 | type NavigationGuardReturn = 15 | | void 16 | // | Error 17 | | boolean 18 | | RouteLocationAsString 19 | // | RouteLocationAsRelativeTyped 20 | | RouteLocationAsRelativeTypedList[keyof RouteMap] 21 | | RouteLocationAsPathTypedList[keyof RouteMap] 22 | // type NavigationGuardReturn = Exclude, Promise | RouteLocationRaw> 23 | 24 | export interface NavigationGuardWithThis { 25 | ( 26 | this: T, 27 | to: RouteLocationNormalizedTypedList[keyof RouteMap], 28 | from: RouteLocationNormalizedLoadedTypedList[keyof RouteMap], 29 | // intentionally not typed to make people use the other version 30 | next: NavigationGuardNext 31 | ): NavigationGuardReturn | Promise> 32 | } 33 | 34 | export interface NavigationGuard { 35 | ( 36 | to: RouteLocationNormalizedTypedList[keyof RouteMap], 37 | from: RouteLocationNormalizedLoadedTypedList[keyof RouteMap], 38 | // intentionally not typed to make people use the other version 39 | next: NavigationGuardNext 40 | ): NavigationGuardReturn | Promise> 41 | } 42 | 43 | export interface NavigationHookAfter< 44 | RouteMap extends _RouteMapGeneric = _RouteMapGeneric 45 | > { 46 | ( 47 | to: RouteLocationNormalizedTypedList[keyof RouteMap], 48 | from: RouteLocationNormalizedLoadedTypedList[keyof RouteMap], 49 | failure?: NavigationFailure | void 50 | ): any 51 | } 52 | -------------------------------------------------------------------------------- /src/core/customBlock.ts: -------------------------------------------------------------------------------- 1 | import { SFCBlock, parse } from '@vue/compiler-sfc' 2 | import { promises as fs } from 'fs' 3 | import { ResolvedOptions } from '../options' 4 | import JSON5 from 'json5' 5 | import { parse as YAMLParser } from 'yaml' 6 | import { RouteRecordRaw } from 'vue-router' 7 | import { warn } from './utils' 8 | 9 | export async function getRouteBlock(path: string, options: ResolvedOptions) { 10 | const content = await fs.readFile(path, 'utf8') 11 | 12 | const parsedSFC = await parse(content, { pad: 'space' }).descriptor 13 | const blockStr = parsedSFC?.customBlocks.find((b) => b.type === 'route') 14 | 15 | if (!blockStr) return 16 | 17 | let result = parseCustomBlock(blockStr, path, options) 18 | 19 | // validation 20 | if (result) { 21 | if (result.path != null && !result.path.startsWith('/')) { 22 | warn(`Overridden path must start with "/". Found in "${path}".`) 23 | } 24 | } 25 | 26 | return result 27 | } 28 | 29 | export interface CustomRouteBlock 30 | extends Partial< 31 | Omit< 32 | RouteRecordRaw, 33 | 'components' | 'component' | 'children' | 'beforeEnter' | 'name' 34 | > 35 | > { 36 | name?: string 37 | } 38 | 39 | function parseCustomBlock( 40 | block: SFCBlock, 41 | filePath: string, 42 | options: ResolvedOptions 43 | ): CustomRouteBlock | undefined { 44 | const lang = block.lang ?? options.routeBlockLang 45 | 46 | if (lang === 'json5') { 47 | try { 48 | return JSON5.parse(block.content) 49 | } catch (err: any) { 50 | warn( 51 | `Invalid JSON5 format of <${block.type}> content in ${filePath}\n${err.message}` 52 | ) 53 | } 54 | } else if (lang === 'json') { 55 | try { 56 | return JSON.parse(block.content) 57 | } catch (err: any) { 58 | warn( 59 | `Invalid JSON format of <${block.type}> content in ${filePath}\n${err.message}` 60 | ) 61 | } 62 | } else if (lang === 'yaml' || lang === 'yml') { 63 | try { 64 | return YAMLParser(block.content) 65 | } catch (err: any) { 66 | warn( 67 | `Invalid YAML format of <${block.type}> content in ${filePath}\n${err.message}` 68 | ) 69 | } 70 | } else { 71 | warn( 72 | `Language "${lang}" for <${block.type}> is not supported. Supported languages are: json5, json, yaml, yml. Found in in ${filePath}.` 73 | ) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /examples/webpack/src/pages/index.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 84 | 85 | 88 | -------------------------------------------------------------------------------- /src/data-fetching/defineLoader-notes.md: -------------------------------------------------------------------------------- 1 | # `defineLoader()` notes 2 | 3 | ## Vue Query 4 | 5 | Link: 6 | 7 | Demo from docs 8 | 9 | ```vue 10 | 19 | ``` 20 | 21 | Target API 22 | 23 | Simple query 24 | 25 | ```vue 26 | 41 | 42 | 46 | ``` 47 | 48 | - They could allow passing multiple queries and internally call `useQueries()` () 49 | 50 | - SSR: they have their own API with `hydrate`, `dehydrate` and a `QueryClient` class. They will likely need to pass the initial state to the `setupDataFetchingGuard()` `initialData` option. 51 | 52 | TODO: 53 | 54 | - What is the caching mechanism inside 55 | - What are the ops needed: 56 | - Create 57 | - Update 58 | - Invalidate 59 | - Fail/Success 60 | 61 | ## Vue Apollo 62 | 63 | Very similar to vue query in terms of need and API: 64 | 65 | ```ts 66 | const useTodos = defineQueryLoader(fetchTodoList, { 67 | // the key seems to be inferred automatically 68 | }) 69 | ``` 70 | 71 | To pass variables based on the route, a function could be allowed 72 | 73 | ```ts 74 | const useContact = defineQueryLoader(fetchContact, (to) =>{ 75 | id: to.params.id 76 | }) 77 | ``` 78 | 79 | Vue apollo automatically calls again the query when the variables change. we need a way to create a computed variable from the function passed to `defineQueryLoader`. There is also a `refetch()` function, maybe the argument can be passed at that time to invoke the function during a navigation. 80 | 81 | ## VueFire 82 | 83 | ```ts 84 | const useUserProfile = defineFirestoreLoader(to => ['users', to.params.id]) 85 | const useUserProfile = defineFirestoreLoader(to => doc(useFirestore(), 'users', to.params.id) 86 | ``` 87 | 88 | ## Vue SWR 89 | -------------------------------------------------------------------------------- /src/typeExtensions/RouterLink.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AllowedComponentProps, 3 | ComponentCustomProps, 4 | VNodeProps, 5 | VNode, 6 | ComputedRef, 7 | UnwrapRef, 8 | Ref, 9 | } from 'vue' 10 | import type { 11 | NavigationFailure, 12 | RouteLocationRaw, 13 | RouterLinkProps as _RouterLinkProps, 14 | } from 'vue-router' 15 | import type { _RouterTyped } from './router' 16 | 17 | // TODO: could this have a name generic to type the slot? is it possible 18 | 19 | import type { _RouteMapGeneric } from '../codegen/generateRouteMap' 20 | import type { 21 | RouteLocationAsPathTyped, 22 | RouteLocationAsPathTypedList, 23 | RouteLocationAsRelativeTyped, 24 | RouteLocationAsRelativeTypedList, 25 | RouteLocationAsString, 26 | RouteLocationResolvedTypedList, 27 | } from './routeLocation' 28 | 29 | /** 30 | * Typed version of `RouterLinkProps`. 31 | */ 32 | export interface RouterLinkPropsTyped< 33 | RouteMap extends _RouteMapGeneric, 34 | Name extends keyof RouteMap = keyof RouteMap 35 | > extends Omit<_RouterLinkProps, 'to'> { 36 | to: 37 | | RouteLocationAsString 38 | | RouteLocationAsRelativeTypedList[Name] 39 | | RouteLocationAsPathTypedList[Name] 40 | } 41 | 42 | /** 43 | * Typed version of `` component. 44 | */ 45 | export interface RouterLinkTyped { 46 | new (): { 47 | $props: AllowedComponentProps & 48 | ComponentCustomProps & 49 | VNodeProps & 50 | RouterLinkPropsTyped 51 | 52 | $slots: { 53 | default: (arg: UnwrapRef<_UseLinkReturnTyped>) => VNode[] 54 | } 55 | } 56 | } 57 | 58 | // TODO: should be exposed by the router instead 59 | /** 60 | * Return type of `useLink()`. Should be exposed by the router instead. 61 | * @internal 62 | */ 63 | export interface _UseLinkReturnTyped< 64 | RouteMap extends _RouteMapGeneric, 65 | Name extends keyof RouteMap = keyof RouteMap 66 | > { 67 | route: ComputedRef[Name]> 68 | href: ComputedRef 69 | isActive: ComputedRef 70 | isExactActive: ComputedRef 71 | navigate(e?: MouseEvent): Promise 72 | } 73 | 74 | /** 75 | * Typed version of `useLink()`. 76 | */ 77 | export interface UseLinkFnTyped { 78 | (props: { 79 | to: 80 | | RouteLocationAsString 81 | | RouteLocationAsRelativeTyped 82 | | RouteLocationAsPathTyped 83 | | Ref 84 | replace?: boolean | undefined | Ref 85 | }): _UseLinkReturnTyped 86 | } 87 | -------------------------------------------------------------------------------- /playground/src/App.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 77 | 78 | 98 | -------------------------------------------------------------------------------- /src/core/definePage.spec.ts: -------------------------------------------------------------------------------- 1 | import { TransformResult } from 'vite' 2 | import { expect, describe, it } from 'vitest' 3 | import { definePageTransform, extractDefinePageNameAndPath } from './definePage' 4 | 5 | const sampleCode = ` 6 | 14 | 15 | 18 | ` 19 | 20 | describe('definePage', () => { 21 | it('removes definePage', async () => { 22 | const result = (await definePageTransform({ 23 | code: sampleCode, 24 | id: 'src/pages/basic.vue', 25 | })) as Exclude 26 | 27 | expect(result).toHaveProperty('code') 28 | expect(result?.code).toMatchSnapshot() 29 | }) 30 | 31 | it('extracts name and path', async () => { 32 | expect( 33 | await extractDefinePageNameAndPath(sampleCode, 'src/pages/basic.vue') 34 | ).toEqual({ 35 | name: 'custom', 36 | path: '/custom', 37 | }) 38 | }) 39 | 40 | it('extract name skipped when non existent', async () => { 41 | expect( 42 | await extractDefinePageNameAndPath( 43 | ` 44 | 48 | 49 | 52 | `, 53 | 'src/pages/basic.vue' 54 | ) 55 | ).toBeFalsy() 56 | }) 57 | 58 | it('works with comments', async () => { 59 | const code = ` 60 | 63 | 64 | 67 | ` 68 | // no need to transform 69 | let result = (await definePageTransform({ 70 | code, 71 | id: 'src/pages/basic.vue', 72 | })) as Exclude 73 | expect(result).toBeFalsy() 74 | 75 | // should give an empty object 76 | result = (await definePageTransform({ 77 | code, 78 | id: 'src/pages/basic.vue?definePage&vue', 79 | })) as Exclude 80 | 81 | expect(result).toBe('export default {}') 82 | }) 83 | 84 | it('works if file is named definePage', async () => { 85 | const result = (await definePageTransform({ 86 | code: sampleCode, 87 | id: 'src/pages/definePage.vue', 88 | })) as Exclude 89 | 90 | expect(result).toHaveProperty('code') 91 | // should be the sfc without the definePage call 92 | expect(result?.code).toMatchSnapshot() 93 | 94 | expect( 95 | await definePageTransform({ 96 | code: sampleCode, 97 | id: 'src/pages/definePage?definePage.vue', 98 | }) 99 | ).toMatchObject({ 100 | code: `\ 101 | export default { 102 | name: 'custom', 103 | path: '/custom', 104 | }`, 105 | }) 106 | 107 | expect( 108 | await extractDefinePageNameAndPath(sampleCode, 'src/pages/definePage.vue') 109 | ).toEqual({ 110 | name: 'custom', 111 | path: '/custom', 112 | }) 113 | }) 114 | }) 115 | -------------------------------------------------------------------------------- /e2e/__snapshots__/routes.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`generates the routes 1`] = ` 4 | "export const routes = [ 5 | { 6 | path: '/', 7 | name: '/', 8 | component: () => import('/routes/index.vue'), 9 | /* no children */ 10 | }, 11 | { 12 | path: '/:path(.*)', 13 | name: '/[...path]', 14 | component: () => import('/routes/[...path].vue'), 15 | /* no children */ 16 | }, 17 | { 18 | path: '/about', 19 | name: '/about', 20 | component: () => import('/routes/about.vue'), 21 | /* no children */ 22 | }, 23 | { 24 | path: '/articles', 25 | /* internal name: '/articles' */ 26 | /* no component */ 27 | children: [ 28 | { 29 | path: ':id', 30 | name: '/articles/[id]', 31 | component: () => import('/routes/articles/[id].vue'), 32 | /* no children */ 33 | }, 34 | { 35 | path: ':slugs+', 36 | name: '/articles/[slugs]+', 37 | component: () => import('/routes/articles/[slugs]+.vue'), 38 | /* no children */ 39 | } 40 | ], 41 | }, 42 | { 43 | path: '/nested', 44 | /* internal name: '/nested' */ 45 | /* no component */ 46 | children: [ 47 | { 48 | path: 'folder', 49 | /* internal name: '/nested/folder' */ 50 | /* no component */ 51 | children: [ 52 | { 53 | path: '', 54 | name: '/nested/folder/', 55 | component: () => import('/routes/nested/folder/index.vue'), 56 | /* no children */ 57 | }, 58 | { 59 | path: 'should', 60 | /* internal name: '/nested/folder/should' */ 61 | /* no component */ 62 | children: [ 63 | { 64 | path: 'work', 65 | /* internal name: '/nested/folder/should/work' */ 66 | /* no component */ 67 | children: [ 68 | { 69 | path: '', 70 | name: '/nested/folder/should/work/', 71 | component: () => import('/routes/nested/folder/should/work/index.vue'), 72 | /* no children */ 73 | } 74 | ], 75 | } 76 | ], 77 | } 78 | ], 79 | } 80 | ], 81 | }, 82 | { 83 | path: '/optional', 84 | /* internal name: '/optional' */ 85 | /* no component */ 86 | children: [ 87 | { 88 | path: ':doc?', 89 | name: '/optional/[[doc]]', 90 | component: () => import('/routes/optional/[[doc]].vue'), 91 | /* no children */ 92 | }, 93 | { 94 | path: ':docs*', 95 | name: '/optional/[[docs]]+', 96 | component: () => import('/routes/optional/[[docs]]+.vue'), 97 | /* no children */ 98 | } 99 | ], 100 | }, 101 | { 102 | path: '/users', 103 | name: '/users', 104 | component: () => import('/routes/users.vue'), 105 | children: [ 106 | { 107 | path: ':id', 108 | name: '/users/[id]', 109 | component: () => import('/routes/users/[id].vue'), 110 | /* no children */ 111 | } 112 | ], 113 | }, 114 | { 115 | path: '/users/new', 116 | name: '/users.new', 117 | component: () => import('/routes/users.new.vue'), 118 | /* no children */ 119 | } 120 | ] 121 | " 122 | `; 123 | -------------------------------------------------------------------------------- /src/typeExtensions/routeLocation.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | RouteLocation, 3 | RouteLocationNormalized, 4 | RouteLocationNormalizedLoaded, 5 | RouteLocationOptions, 6 | RouteQueryAndHash, 7 | RouteRecordName, 8 | } from 'vue-router' 9 | import type { 10 | RouteRecordInfo, 11 | _RouteMapGeneric, 12 | } from '../codegen/generateRouteMap' 13 | import type { LiteralStringUnion } from '../core/utils' 14 | 15 | export interface RouteLocationNormalizedTyped< 16 | RouteMap extends _RouteMapGeneric = Record, 17 | Name extends keyof RouteMap = keyof RouteMap 18 | > extends RouteLocationNormalized { 19 | name: Extract 20 | // we don't override path because it could contain params and in practice it's just not useful 21 | params: RouteMap[Name]['params'] 22 | } 23 | 24 | export type RouteLocationNormalizedTypedList< 25 | RouteMap extends _RouteMapGeneric = Record 26 | > = { [N in keyof RouteMap]: RouteLocationNormalizedTyped } 27 | 28 | export interface RouteLocationNormalizedLoadedTyped< 29 | RouteMap extends _RouteMapGeneric = Record, 30 | Name extends keyof RouteMap = keyof RouteMap 31 | > extends RouteLocationNormalizedLoaded { 32 | name: Extract 33 | // we don't override path because it could contain params and in practice it's just not useful 34 | params: RouteMap[Name]['params'] 35 | } 36 | 37 | export type RouteLocationNormalizedLoadedTypedList< 38 | RouteMap extends _RouteMapGeneric = Record 39 | > = { [N in keyof RouteMap]: RouteLocationNormalizedLoadedTyped } 40 | 41 | export interface RouteLocationAsRelativeTyped< 42 | RouteMap extends _RouteMapGeneric = Record, 43 | Name extends keyof RouteMap = keyof RouteMap 44 | > extends RouteQueryAndHash, 45 | RouteLocationOptions { 46 | name?: Name 47 | params?: RouteMap[Name]['paramsRaw'] 48 | } 49 | 50 | export type RouteLocationAsRelativeTypedList< 51 | RouteMap extends _RouteMapGeneric = Record 52 | > = { [N in keyof RouteMap]: RouteLocationAsRelativeTyped } 53 | 54 | export interface RouteLocationAsPathTyped< 55 | RouteMap extends _RouteMapGeneric = Record, 56 | Name extends keyof RouteMap = keyof RouteMap 57 | > extends RouteQueryAndHash, 58 | RouteLocationOptions { 59 | path: LiteralStringUnion 60 | } 61 | 62 | export type RouteLocationAsPathTypedList< 63 | RouteMap extends _RouteMapGeneric = Record 64 | > = { [N in keyof RouteMap]: RouteLocationAsPathTyped } 65 | 66 | export type RouteLocationAsString< 67 | RouteMap extends _RouteMapGeneric = Record 68 | > = LiteralStringUnion 69 | 70 | export interface RouteLocationTyped< 71 | RouteMap extends _RouteMapGeneric, 72 | Name extends keyof RouteMap 73 | > extends RouteLocation { 74 | name: Extract 75 | params: RouteMap[Name]['params'] 76 | } 77 | 78 | export type RouteLocationTypedList< 79 | RouteMap extends _RouteMapGeneric = Record 80 | > = { [N in keyof RouteMap]: RouteLocationTyped } 81 | 82 | export interface RouteLocationResolvedTyped< 83 | RouteMap extends _RouteMapGeneric, 84 | Name extends keyof RouteMap 85 | > extends RouteLocationTyped { 86 | href: string 87 | } 88 | 89 | export type RouteLocationResolvedTypedList< 90 | RouteMap extends _RouteMapGeneric = Record 91 | > = { [N in keyof RouteMap]: RouteLocationResolvedTyped } 92 | -------------------------------------------------------------------------------- /playground/src/pages/[name].vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 106 | 107 | 116 | 117 | 118 | { 119 | "meta": { 120 | "hello": "there" 121 | } 122 | } 123 | 124 | -------------------------------------------------------------------------------- /src/core/RoutesFolderWatcher.ts: -------------------------------------------------------------------------------- 1 | import chokidar from 'chokidar' 2 | import { resolve } from 'pathe' 3 | import { 4 | ResolvedOptions, 5 | RoutesFolderOption, 6 | RoutesFolderOptionResolved, 7 | _OverridableOption, 8 | } from '../options' 9 | import { appendExtensionListToPattern, asRoutePath } from './utils' 10 | 11 | // TODO: export an implementable interface to create a watcher and let users provide a different watcher than chokidar to improve performance on windows 12 | 13 | export class RoutesFolderWatcher { 14 | src: string 15 | path: string | ((filepath: string) => string) 16 | extensions: string[] 17 | filePatterns: string[] 18 | exclude: string[] 19 | 20 | watcher: chokidar.FSWatcher 21 | 22 | constructor(folderOptions: RoutesFolderOptionResolved) { 23 | this.src = folderOptions.src 24 | this.path = folderOptions.path 25 | this.exclude = folderOptions.exclude 26 | this.extensions = folderOptions.extensions 27 | this.filePatterns = folderOptions.filePatterns 28 | 29 | this.watcher = chokidar.watch(folderOptions.pattern, { 30 | cwd: this.src, 31 | ignoreInitial: true, 32 | // disableGlobbing: true, 33 | ignorePermissionErrors: true, 34 | ignored: this.exclude, 35 | 36 | // useFsEvents: true, 37 | // TODO: allow user options 38 | }) 39 | } 40 | 41 | on( 42 | event: 'add' | 'change' | 'unlink' | 'unlinkDir', 43 | handler: (context: HandlerContext) => void 44 | ) { 45 | this.watcher.on(event, (filePath: string) => { 46 | // skip other extensions 47 | if (this.extensions.every((extension) => !filePath.endsWith(extension))) { 48 | return 49 | } 50 | 51 | // ensure consistent absolute path for Windows and Unix 52 | filePath = resolve(this.src, filePath) 53 | 54 | handler({ 55 | filePath, 56 | routePath: asRoutePath({ src: this.src, path: this.path }, filePath), 57 | }) 58 | }) 59 | return this 60 | } 61 | 62 | close() { 63 | this.watcher.close() 64 | } 65 | } 66 | 67 | export interface HandlerContext { 68 | // resolved path 69 | filePath: string 70 | // routePath 71 | routePath: string 72 | } 73 | 74 | export function resolveFolderOptions( 75 | globalOptions: ResolvedOptions, 76 | folderOptions: RoutesFolderOption 77 | ): RoutesFolderOptionResolved { 78 | const extensions = overrideOption( 79 | globalOptions.extensions, 80 | folderOptions.extensions 81 | ) 82 | const filePatterns = overrideOption( 83 | globalOptions.filePatterns, 84 | folderOptions.filePatterns 85 | ) 86 | 87 | return { 88 | src: folderOptions.src, 89 | pattern: appendExtensionListToPattern( 90 | filePatterns, 91 | // also override the extensions if the folder has a custom extensions 92 | extensions 93 | ), 94 | path: folderOptions.path || '', 95 | extensions, 96 | filePatterns, 97 | exclude: overrideOption(globalOptions.exclude, folderOptions.exclude).map( 98 | (p) => (p.startsWith('**') ? p : resolve(p)) 99 | ), 100 | } 101 | } 102 | 103 | function overrideOption( 104 | existing: string[] | string, 105 | newValue: undefined | string[] | string | ((existing: string[]) => string[]) 106 | ): string[] { 107 | const asArray = typeof existing === 'string' ? [existing] : existing 108 | // allow extending when a function is passed 109 | if (typeof newValue === 'function') { 110 | return newValue(asArray) 111 | } 112 | // override if passed 113 | if (typeof newValue !== 'undefined') { 114 | return typeof newValue === 'string' ? [newValue] : newValue 115 | } 116 | // fallback to existing 117 | return asArray 118 | } 119 | -------------------------------------------------------------------------------- /tests/vitest-mock-warn.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/posva/jest-mock-warn/blob/master/src/index.js 2 | 3 | import { afterEach, beforeEach, expect, SpyInstance, vi } from 'vitest' 4 | 5 | export function mockWarn() { 6 | expect.extend({ 7 | toHaveBeenWarned(received: string | RegExp) { 8 | asserted.set(received.toString(), received) 9 | const passed = warn.mock.calls.some((args) => 10 | typeof received === 'string' 11 | ? args[0].indexOf(received) > -1 12 | : received.test(args[0]) 13 | ) 14 | if (passed) { 15 | return { 16 | pass: true, 17 | message: () => `expected "${received}" not to have been warned.`, 18 | } 19 | } else { 20 | const msgs = warn.mock.calls.map((args) => args[0]).join('\n - ') 21 | return { 22 | pass: false, 23 | message: () => 24 | `expected "${received}" to have been warned.\n\nActual messages:\n\n - ${msgs}`, 25 | } 26 | } 27 | }, 28 | 29 | toHaveBeenWarnedLast(received: string | RegExp) { 30 | asserted.set(received.toString(), received) 31 | const lastCall = warn.mock.calls[warn.mock.calls.length - 1][0] 32 | const passed = 33 | typeof received === 'string' 34 | ? lastCall.indexOf(received) > -1 35 | : received.test(lastCall) 36 | if (passed) { 37 | return { 38 | pass: true, 39 | message: () => `expected "${received}" not to have been warned last.`, 40 | } 41 | } else { 42 | const msgs = warn.mock.calls.map((args) => args[0]).join('\n - ') 43 | return { 44 | pass: false, 45 | message: () => 46 | `expected "${received}" to have been warned last.\n\nActual messages:\n\n - ${msgs}`, 47 | } 48 | } 49 | }, 50 | 51 | toHaveBeenWarnedTimes(received: string | RegExp, n: number) { 52 | asserted.set(received.toString(), received) 53 | let found = 0 54 | warn.mock.calls.forEach((args) => { 55 | const isFound = 56 | typeof received === 'string' 57 | ? args[0].indexOf(received) > -1 58 | : received.test(args[0]) 59 | if (isFound) { 60 | found++ 61 | } 62 | }) 63 | 64 | if (found === n) { 65 | return { 66 | pass: true, 67 | message: () => 68 | `expected "${received}" to have been warned ${n} times.`, 69 | } 70 | } else { 71 | return { 72 | pass: false, 73 | message: () => 74 | `expected "${received}" to have been warned ${n} times but got ${found}.`, 75 | } 76 | } 77 | }, 78 | }) 79 | 80 | let warn: SpyInstance 81 | const asserted = new Map() 82 | 83 | beforeEach(() => { 84 | asserted.clear() 85 | warn = vi.spyOn(console, 'warn') 86 | warn.mockImplementation(() => {}) 87 | }) 88 | 89 | afterEach(() => { 90 | const assertedArray = Array.from(asserted) 91 | const nonAssertedWarnings = warn.mock.calls 92 | .map((args) => args[0]) 93 | .filter((received) => { 94 | return !assertedArray.some(([key, assertedMsg]) => { 95 | return typeof assertedMsg === 'string' 96 | ? received.indexOf(assertedMsg) > -1 97 | : assertedMsg.test(received) 98 | }) 99 | }) 100 | warn.mockRestore() 101 | if (nonAssertedWarnings.length) { 102 | nonAssertedWarnings.forEach((warning) => { 103 | console.warn(warning) 104 | }) 105 | throw new Error(`test case threw unexpected warnings.`) 106 | } 107 | }) 108 | } 109 | 110 | declare global { 111 | namespace Vi { 112 | interface JestAssertion { 113 | toHaveBeenWarned(): void 114 | toHaveBeenWarnedLast(): void 115 | toHaveBeenWarnedTimes(n: number): void 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /playground/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'url' 2 | import { defineConfig } from 'vite' 3 | import { join } from 'node:path' 4 | import Inspect from 'vite-plugin-inspect' 5 | import Markdown from 'vite-plugin-vue-markdown' 6 | // @ts-ignore: the plugin should not be checked in the playground 7 | import VueRouter from '../src/vite' 8 | import { 9 | getFileBasedRouteName, 10 | getPascalCaseRouteName, 11 | VueRouterAutoImports, 12 | } from '../src' 13 | import Vue from '@vitejs/plugin-vue' 14 | import AutoImport from 'unplugin-auto-import/vite' 15 | 16 | export default defineConfig({ 17 | clearScreen: false, 18 | build: { 19 | sourcemap: true, 20 | }, 21 | // optimizeDeps: { 22 | // exclude: ['ufo', 'mlly', 'magic-string', 'fsevents'], 23 | // }, 24 | 25 | plugins: [ 26 | VueRouter({ 27 | dataFetching: true, 28 | extensions: ['.page.vue', '.vue', '.md'], 29 | extendRoute(route) { 30 | // console.log('extending route', route.meta) 31 | 32 | // example of deleting routes 33 | // if (route.name.startsWith('/users')) { 34 | // route.delete() 35 | // } 36 | 37 | if (route.name === '/[name]') { 38 | route.addAlias('/hello-vite-:name') 39 | } 40 | 41 | // if (route.name === '/deep/nesting') { 42 | // const children = [...route] 43 | // children.forEach((child) => { 44 | // // TODO: remove one node while copying the children to its parent 45 | // }) 46 | // } 47 | 48 | // example moving a route (without its children to the root) 49 | if (route.fullPath.startsWith('/deep/nesting/works/too')) { 50 | route.parent!.insert( 51 | '/at-root-but-from-nested', 52 | route.components.get('default')! 53 | ) 54 | // TODO: make it easier to access the root 55 | let root = route 56 | while (root.parent) { 57 | root = root.parent 58 | } 59 | route.delete() 60 | const newRoute = root.insert( 61 | '/custom/page', 62 | route.components.get('default')! 63 | ) 64 | // newRoute.components.set('default', route.components.get('default')!) 65 | newRoute.meta = { 66 | 'custom-meta': 'works', 67 | } 68 | } 69 | }, 70 | beforeWriteFiles(root) { 71 | root.insert('/from-root', join(__dirname, './src/pages/index.vue')) 72 | }, 73 | routesFolder: [ 74 | // can add multiple routes folders 75 | { 76 | src: 'src/pages', 77 | // can even add params 78 | // path: ':lang/', 79 | }, 80 | { 81 | src: 'src/docs', 82 | path: 'docs/:lang/', 83 | // doesn't take into account files directly at src/docs, only subfolders 84 | filePatterns: ['*/**/*'], 85 | // ignores .vue files 86 | extensions: ['.md'], 87 | }, 88 | { 89 | src: 'src/features', 90 | filePatterns: '*/pages/**/*', 91 | path: (file) => { 92 | const prefix = 'src/features' 93 | // +1 for the starting slash 94 | file = file 95 | .slice(file.lastIndexOf(prefix) + prefix.length + 1) 96 | .replace('/pages', '') 97 | console.log('👉 FILE', file) 98 | return file 99 | }, 100 | }, 101 | ], 102 | logs: true, 103 | // getRouteName: getPascalCaseRouteName, 104 | exclude: [ 105 | '**/ignored/**', 106 | // '**/ignored/**/*', 107 | '**/__*', 108 | '**/__**/*', 109 | '**/*.component.vue', 110 | // resolve(__dirname, './src/pages/ignored'), 111 | // 112 | // './src/pages/**/*.spec.ts', 113 | ], 114 | }), 115 | Vue({ 116 | include: [/\.vue$/, /\.md$/], 117 | }), 118 | Markdown(), 119 | AutoImport({ 120 | imports: [VueRouterAutoImports], 121 | }), 122 | Inspect(), 123 | ], 124 | resolve: { 125 | alias: { 126 | '@': fileURLToPath(new URL('./src', import.meta.url)), 127 | }, 128 | }, 129 | }) 130 | -------------------------------------------------------------------------------- /src/data-fetching/dataCache.ts: -------------------------------------------------------------------------------- 1 | import { EffectScope, ref, ToRefs, effectScope, Ref, UnwrapRef } from 'vue' 2 | import { 3 | LocationQuery, 4 | RouteParams, 5 | Router, 6 | RouteLocationNormalizedLoaded, 7 | } from 'vue-router' 8 | import { DefineLoaderOptions } from './defineLoader' 9 | 10 | /** 11 | * `DataLoaderEntry` groups all of the properties that can be relied on by the data fetching guard. Any extended loader 12 | * should implement this interface. Each loaders has their own set of entries attached to an app instance. 13 | */ 14 | export interface DataLoaderEntry { 15 | /** 16 | * When was the data loaded in ms (Date.now()). 17 | * @internal 18 | */ 19 | when: number 20 | 21 | /** 22 | * Location's params that were used to load the data. 23 | */ 24 | params: Partial 25 | /** 26 | * Location's query that was used to load the data. 27 | */ 28 | query: Partial 29 | /** 30 | * Location's hash that was used to load the data. 31 | */ 32 | hash: string | null 33 | 34 | /** 35 | * Other data loaders that depend on this one. This is used to invalidate the data when a dependency is invalidated. 36 | */ 37 | children: Set 38 | 39 | /** 40 | * Whether there is an ongoing request. 41 | */ 42 | pending: Ref 43 | 44 | // TODO: allow delaying pending? maybe 45 | 46 | /** 47 | * Error if there was an error. 48 | */ 49 | error: Ref // any is simply more convenient for errors 50 | 51 | /** 52 | * Is the entry ready with data. This is set to `true` the first time the entry is updated with data. 53 | */ 54 | isReady: boolean 55 | 56 | /** 57 | * Data stored in the entry. 58 | */ 59 | data: false extends isLazy ? Ref> : Ref | undefined> 60 | } 61 | 62 | export function isCacheExpired( 63 | entry: DataLoaderEntry, 64 | options: Required 65 | ): boolean { 66 | const { cacheTime } = options 67 | return ( 68 | // cacheTime == 0 means no cache 69 | !cacheTime || 70 | // did we hit the expiration time 71 | Date.now() - entry.when >= cacheTime || 72 | Array.from(entry.children).some((childEntry) => 73 | isCacheExpired(childEntry, options) 74 | ) 75 | ) 76 | } 77 | 78 | export function createDataLoaderEntry( 79 | options: Required>, 80 | initialData?: T 81 | ): DataLoaderEntry { 82 | return withinScope>(() => ({ 83 | pending: ref(false), 84 | error: ref(), 85 | // set to 0 to when there is an initialData so the next request will always trigger the data loaders 86 | when: initialData === undefined ? Date.now() : 0, 87 | children: new Set(), 88 | // @ts-expect-error: data always start as empty 89 | data: ref(initialData), 90 | params: {}, 91 | query: {}, 92 | // hash: null, 93 | isReady: false, 94 | // this was just too annoying to type 95 | })) 96 | } 97 | 98 | export function updateDataLoaderEntry( 99 | entry: DataLoaderEntry, 100 | data: T, 101 | params: Partial, 102 | query: Partial, 103 | hash: { v: string | null } 104 | ) { 105 | entry.when = Date.now() 106 | entry.params = params 107 | entry.query = query 108 | entry.hash = hash.v 109 | entry.isReady = true 110 | // @ts-expect-error: unwrapping magic 111 | entry.data.value = data 112 | } 113 | 114 | // local scope 115 | 116 | export let scope: EffectScope | undefined 117 | 118 | export function withinScope(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 }>, 46 | '/[[a]]+': RouteRecordInfo<'/[[a]]+', '/:a*', { a?: ParamValueZeroOrMore }, { a?: ParamValueZeroOrMore }>, 47 | '/[a]+': RouteRecordInfo<'/[a]+', '/:a+', { a: ParamValueOneOrMore }, { a: ParamValueOneOrMore }>, 48 | '/partial-[a]': RouteRecordInfo<'/partial-[a]', '/partial-:a', { a: ParamValue }, { a: ParamValue }>, 49 | '/partial-[[a]]': RouteRecordInfo<'/partial-[[a]]', '/partial-:a?', { a?: ParamValueZeroOrOne }, { a?: ParamValueZeroOrOne }>, 50 | }" 51 | `) 52 | }) 53 | 54 | it('handles params from raw routes', () => { 55 | const tree = new PrefixTree(DEFAULT_OPTIONS) 56 | const a = tree.insertParsedPath(':a', 'a.vue') 57 | const b = tree.insertParsedPath(':b()', 'a.vue') 58 | expect(a.name).toBe('/:a') 59 | expect(b.name).toBe('/:b()') 60 | expect(formatExports(generateRouteNamedMap(tree))).toMatchInlineSnapshot(` 61 | "export interface RouteNamedMap { 62 | '/:a': RouteRecordInfo<'/:a', '/:a', { a: ParamValue }, { a: ParamValue }>, 63 | '/:b()': RouteRecordInfo<'/:b()', '/:b()', { b: ParamValue }, { b: ParamValue }>, 64 | }" 65 | `) 66 | }) 67 | 68 | it('handles nested params in folders', () => { 69 | const tree = new PrefixTree(DEFAULT_OPTIONS) 70 | tree.insert('n/[a]/index.vue') // normal 71 | tree.insert('n/[a]/other.vue') 72 | tree.insert('n/[a]/[b].vue') 73 | tree.insert('n/[a]/[c]/other-[d].vue') 74 | expect(formatExports(generateRouteNamedMap(tree))).toMatchInlineSnapshot(` 75 | "export interface RouteNamedMap { 76 | '/n/[a]/': RouteRecordInfo<'/n/[a]/', '/n/:a', { a: ParamValue }, { a: ParamValue }>, 77 | '/n/[a]/[b]': RouteRecordInfo<'/n/[a]/[b]', '/n/:a/:b', { a: ParamValue, b: ParamValue }, { a: ParamValue, b: ParamValue }>, 78 | '/n/[a]/[c]/other-[d]': RouteRecordInfo<'/n/[a]/[c]/other-[d]', '/n/:a/:c/other-:d', { a: ParamValue, c: ParamValue, d: ParamValue }, { a: ParamValue, c: ParamValue, d: ParamValue }>, 79 | '/n/[a]/other': RouteRecordInfo<'/n/[a]/other', '/n/:a/other', { a: ParamValue }, { a: ParamValue }>, 80 | }" 81 | `) 82 | }) 83 | 84 | it('adds nested params', () => { 85 | const tree = new PrefixTree(DEFAULT_OPTIONS) 86 | tree.insert('n/[a].vue') // normal 87 | // tree.insert('n/partial-[a].vue') // partial 88 | tree.insert('n/[[a]].vue') // optional 89 | tree.insert('n/[a]+.vue') // repeated 90 | tree.insert('n/[[a]]+.vue') // optional repeated 91 | tree.insert('n/[...a].vue') // splat 92 | expect(formatExports(generateRouteNamedMap(tree))).toMatchInlineSnapshot(` 93 | "export interface RouteNamedMap { 94 | '/n/[a]': RouteRecordInfo<'/n/[a]', '/n/:a', { a: ParamValue }, { a: ParamValue }>, 95 | '/n/[[a]]': RouteRecordInfo<'/n/[[a]]', '/n/:a?', { a?: ParamValueZeroOrOne }, { a?: ParamValueZeroOrOne }>, 96 | '/n/[...a]': RouteRecordInfo<'/n/[...a]', '/n/:a(.*)', { a: ParamValue }, { a: ParamValue }>, 97 | '/n/[[a]]+': RouteRecordInfo<'/n/[[a]]+', '/n/:a*', { a?: ParamValueZeroOrMore }, { a?: ParamValueZeroOrMore }>, 98 | '/n/[a]+': RouteRecordInfo<'/n/[a]+', '/n/:a+', { a: ParamValueOneOrMore }, { a: ParamValueOneOrMore }>, 99 | }" 100 | `) 101 | }) 102 | 103 | it('nested children', () => { 104 | const tree = new PrefixTree(DEFAULT_OPTIONS) 105 | tree.insert('a/a.vue') 106 | tree.insert('a/b.vue') 107 | tree.insert('a/c.vue') 108 | tree.insert('b/b.vue') 109 | tree.insert('b/c.vue') 110 | tree.insert('b/d.vue') 111 | tree.insert('c.vue') 112 | tree.insert('d.vue') 113 | expect(formatExports(generateRouteNamedMap(tree))).toMatchInlineSnapshot(` 114 | "export interface RouteNamedMap { 115 | '/a/a': RouteRecordInfo<'/a/a', '/a/a', Record, Record>, 116 | '/a/b': RouteRecordInfo<'/a/b', '/a/b', Record, Record>, 117 | '/a/c': RouteRecordInfo<'/a/c', '/a/c', Record, Record>, 118 | '/b/b': RouteRecordInfo<'/b/b', '/b/b', Record, Record>, 119 | '/b/c': RouteRecordInfo<'/b/c', '/b/c', Record, Record>, 120 | '/b/d': RouteRecordInfo<'/b/d', '/b/d', Record, Record>, 121 | '/c': RouteRecordInfo<'/c', '/c', Record, Record>, 122 | '/d': RouteRecordInfo<'/d', '/d', Record, Record>, 123 | }" 124 | `) 125 | }) 126 | 127 | it('keeps parent path overrides', () => { 128 | const tree = new PrefixTree(DEFAULT_OPTIONS) 129 | const parent = tree.insert('parent.vue') 130 | const child = tree.insert('parent/child.vue') 131 | parent.value.setOverride('parent.vue', { path: '/' }) 132 | expect(child.fullPath).toBe('/child') 133 | expect(formatExports(generateRouteNamedMap(tree))).toMatchInlineSnapshot(` 134 | "export interface RouteNamedMap { 135 | '/parent': RouteRecordInfo<'/parent', '/', Record, Record>, 136 | '/parent/child': RouteRecordInfo<'/parent/child', '/child', Record, Record>, 137 | }" 138 | `) 139 | }) 140 | }) 141 | 142 | /** 143 | * /static.vue -> /static 144 | * /static/[param].vue -> /static/:param 145 | * /static/pre-[param].vue -> /static/pre-:param 146 | * /static/pre-[param].vue -> /static/pre-:param 147 | * /static/pre-[[param]].vue -> /static/pre-:param? 148 | * /static/[...param].vue -> /static/:param(.*) 149 | * /static/...[param].vue -> /static/:param+ 150 | * /static/...[[param]].vue -> /static/:param* 151 | * /static/...[[...param]].vue -> /static/:param(.*)* 152 | */ 153 | -------------------------------------------------------------------------------- /src/core/extendRoutes.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, describe, it } from 'vitest' 2 | import { PrefixTree } from './tree' 3 | import { DEFAULT_OPTIONS } from '../options' 4 | import { EditableTreeNode } from './extendRoutes' 5 | 6 | describe('EditableTreeNode', () => { 7 | it('creates an editable tree node', () => { 8 | const tree = new PrefixTree(DEFAULT_OPTIONS) 9 | const editable = new EditableTreeNode(tree) 10 | 11 | expect(editable.children).toEqual([]) 12 | }) 13 | 14 | it('reflects changes made on the tree', () => { 15 | const tree = new PrefixTree(DEFAULT_OPTIONS) 16 | const editable = new EditableTreeNode(tree) 17 | 18 | tree.insert('foo', 'file.vue') 19 | expect(editable.children).toHaveLength(1) 20 | expect(editable.children[0].path).toBe('/foo') 21 | }) 22 | 23 | it('reflects changes made on the editable tree', () => { 24 | const tree = new PrefixTree(DEFAULT_OPTIONS) 25 | const editable = new EditableTreeNode(tree) 26 | 27 | editable.insert('foo', 'file.vue') 28 | expect(tree.children.size).toBe(1) 29 | expect(tree.children.get('foo')?.path).toBe('/foo') 30 | }) 31 | 32 | it('keeps nested routes flat', () => { 33 | const tree = new PrefixTree(DEFAULT_OPTIONS) 34 | const editable = new EditableTreeNode(tree) 35 | 36 | editable.insert('foo/bar', 'file.vue') 37 | expect(tree.children.size).toBe(1) 38 | expect(tree.children.get('foo/bar')?.children.size).toBe(0) 39 | expect(tree.children.get('foo/bar')?.fullPath).toBe('/foo/bar') 40 | expect(tree.children.get('foo/bar')?.path).toBe('/foo/bar') 41 | }) 42 | 43 | it('can nest routes', () => { 44 | const tree = new PrefixTree(DEFAULT_OPTIONS) 45 | const editable = new EditableTreeNode(tree) 46 | 47 | const node = editable.insert('foo', 'file.vue') 48 | node.insert('bar/nested', 'file.vue') 49 | expect(tree.children.size).toBe(1) 50 | expect(node.children.length).toBe(1) 51 | expect(node.fullPath).toBe('/foo') 52 | expect(node.path).toBe('/foo') 53 | expect(node.children.at(0)?.path).toBe('bar/nested') 54 | expect(node.children.at(0)?.fullPath).toBe('/foo/bar/nested') 55 | }) 56 | 57 | it('adds params', () => { 58 | const tree = new PrefixTree(DEFAULT_OPTIONS) 59 | const editable = new EditableTreeNode(tree) 60 | 61 | editable.insert(':id', 'file.vue') 62 | expect(tree.children.size).toBe(1) 63 | const child = tree.children.get(':id')! 64 | expect(child.fullPath).toBe('/:id') 65 | expect(child.path).toBe('/:id') 66 | expect(child.params).toEqual([ 67 | { 68 | paramName: 'id', 69 | modifier: '', 70 | optional: false, 71 | repeatable: false, 72 | isSplat: false, 73 | }, 74 | ]) 75 | }) 76 | 77 | it('adds params with modifiers', () => { 78 | const tree = new PrefixTree(DEFAULT_OPTIONS) 79 | const editable = new EditableTreeNode(tree) 80 | 81 | editable.insert(':id+', 'file.vue') 82 | expect(tree.children.size).toBe(1) 83 | const child = tree.children.get(':id+')! 84 | expect(child.fullPath).toBe('/:id+') 85 | expect(child.path).toBe('/:id+') 86 | expect(child.params).toEqual([ 87 | { 88 | paramName: 'id', 89 | modifier: '+', 90 | optional: false, 91 | repeatable: true, 92 | isSplat: false, 93 | }, 94 | ]) 95 | }) 96 | 97 | it('can have multiple params', () => { 98 | const tree = new PrefixTree(DEFAULT_OPTIONS) 99 | const editable = new EditableTreeNode(tree) 100 | 101 | editable.insert(':foo/:bar', 'file.vue') 102 | expect(tree.children.size).toBe(1) 103 | const node = tree.children.get(':foo/:bar')! 104 | expect(node.fullPath).toBe('/:foo/:bar') 105 | expect(node.path).toBe('/:foo/:bar') 106 | expect(node.params).toEqual([ 107 | { 108 | paramName: 'foo', 109 | modifier: '', 110 | optional: false, 111 | repeatable: false, 112 | isSplat: false, 113 | }, 114 | { 115 | paramName: 'bar', 116 | modifier: '', 117 | optional: false, 118 | repeatable: false, 119 | isSplat: false, 120 | }, 121 | ]) 122 | }) 123 | 124 | it('can have multiple params with modifiers', () => { 125 | const tree = new PrefixTree(DEFAULT_OPTIONS) 126 | const editable = new EditableTreeNode(tree) 127 | 128 | editable.insert(':foo/:bar+_:o(\\d+)', 'file.vue') 129 | expect(tree.children.size).toBe(1) 130 | const node = tree.children.get(':foo/:bar+_:o(\\d+)')! 131 | expect(node.fullPath).toBe('/:foo/:bar+_:o(\\d+)') 132 | expect(node.path).toBe('/:foo/:bar+_:o(\\d+)') 133 | expect(node.params).toEqual([ 134 | { 135 | paramName: 'foo', 136 | modifier: '', 137 | optional: false, 138 | repeatable: false, 139 | isSplat: false, 140 | }, 141 | { 142 | paramName: 'bar', 143 | modifier: '+', 144 | optional: false, 145 | repeatable: true, 146 | isSplat: false, 147 | }, 148 | { 149 | paramName: 'o', 150 | modifier: '', 151 | optional: false, 152 | repeatable: false, 153 | isSplat: false, 154 | }, 155 | ]) 156 | }) 157 | 158 | it('adds params with custom regex', () => { 159 | const tree = new PrefixTree(DEFAULT_OPTIONS) 160 | const editable = new EditableTreeNode(tree) 161 | 162 | editable.insert(':id(\\d+)', 'file.vue') 163 | const node = tree.children.get(':id(\\d+)')! 164 | expect(node.fullPath).toBe('/:id(\\d+)') 165 | expect(node.path).toBe('/:id(\\d+)') 166 | expect(node.params).toEqual([ 167 | { 168 | paramName: 'id', 169 | modifier: '', 170 | optional: false, 171 | repeatable: false, 172 | isSplat: false, 173 | }, 174 | ]) 175 | }) 176 | 177 | it('adds a param with empty regex', () => { 178 | const tree = new PrefixTree(DEFAULT_OPTIONS) 179 | const editable = new EditableTreeNode(tree) 180 | 181 | editable.insert(':id()', 'file.vue') 182 | const node = tree.children.get(':id()')! 183 | expect(node.fullPath).toBe('/:id()') 184 | expect(node.path).toBe('/:id()') 185 | expect(node.params).toEqual([ 186 | { 187 | paramName: 'id', 188 | modifier: '', 189 | optional: false, 190 | repeatable: false, 191 | isSplat: false, 192 | }, 193 | ]) 194 | }) 195 | 196 | it('adds a param with a modifier and custom regex', () => { 197 | const tree = new PrefixTree(DEFAULT_OPTIONS) 198 | const editable = new EditableTreeNode(tree) 199 | 200 | editable.insert(':id(\\d+)+', 'file.vue') 201 | const node = tree.children.get(':id(\\d+)+')! 202 | expect(node.fullPath).toBe('/:id(\\d+)+') 203 | expect(node.path).toBe('/:id(\\d+)+') 204 | expect(node.params).toEqual([ 205 | { 206 | paramName: 'id', 207 | modifier: '+', 208 | optional: false, 209 | repeatable: true, 210 | isSplat: false, 211 | }, 212 | ]) 213 | }) 214 | 215 | it('adds a param with a modifier and empty regex', () => { 216 | const tree = new PrefixTree(DEFAULT_OPTIONS) 217 | const editable = new EditableTreeNode(tree) 218 | 219 | editable.insert(':id()+', 'file.vue') 220 | const node = tree.children.get(':id()+')! 221 | expect(node.fullPath).toBe('/:id()+') 222 | expect(node.path).toBe('/:id()+') 223 | expect(node.params).toEqual([ 224 | { 225 | paramName: 'id', 226 | modifier: '+', 227 | optional: false, 228 | repeatable: true, 229 | isSplat: false, 230 | }, 231 | ]) 232 | }) 233 | 234 | it('detects a splat', () => { 235 | const tree = new PrefixTree(DEFAULT_OPTIONS) 236 | const editable = new EditableTreeNode(tree) 237 | 238 | editable.insert('/:path(.*)', 'file.vue') 239 | expect(tree.children.size).toBe(1) 240 | const child = tree.children.get(':path(.*)')! 241 | expect(child.fullPath).toBe('/:path(.*)') 242 | expect(child.path).toBe('/:path(.*)') 243 | expect(child.params).toEqual([ 244 | { 245 | paramName: 'path', 246 | modifier: '', 247 | optional: false, 248 | repeatable: false, 249 | isSplat: true, 250 | }, 251 | ]) 252 | }) 253 | }) 254 | -------------------------------------------------------------------------------- /src/core/context.ts: -------------------------------------------------------------------------------- 1 | import { ResolvedOptions } from '../options' 2 | import { TreeNode, PrefixTree } from './tree' 3 | import { promises as fs } from 'fs' 4 | import { 5 | appendExtensionListToPattern, 6 | asRoutePath, 7 | ImportsMap, 8 | logTree, 9 | throttle, 10 | } from './utils' 11 | import { generateRouteNamedMap } from '../codegen/generateRouteMap' 12 | import { MODULE_ROUTES_PATH, MODULE_VUE_ROUTER } from './moduleConstants' 13 | import { generateRouteRecord } from '../codegen/generateRouteRecords' 14 | import fg from 'fast-glob' 15 | import { relative, resolve } from 'pathe' 16 | import { ServerContext } from '../options' 17 | import { getRouteBlock } from './customBlock' 18 | import { 19 | RoutesFolderWatcher, 20 | HandlerContext, 21 | resolveFolderOptions, 22 | } from './RoutesFolderWatcher' 23 | import { generateDTS as _generateDTS } from '../codegen/generateDTS' 24 | import { generateVueRouterProxy as _generateVueRouterProxy } from '../codegen/vueRouterModule' 25 | import { hasNamedExports } from '../data-fetching/parse' 26 | import { definePageTransform, extractDefinePageNameAndPath } from './definePage' 27 | import { EditableTreeNode } from './extendRoutes' 28 | 29 | export function createRoutesContext(options: ResolvedOptions) { 30 | const { dts: preferDTS, root, routesFolder } = options 31 | const dts = 32 | preferDTS === false 33 | ? false 34 | : preferDTS === true 35 | ? resolve(root, 'typed-router.d.ts') 36 | : resolve(root, preferDTS) 37 | 38 | const routeTree = new PrefixTree(options) 39 | const editableRoutes = new EditableTreeNode(routeTree) 40 | 41 | function log(...args: any[]) { 42 | if (options.logs) { 43 | console.log(...args) 44 | } 45 | } 46 | 47 | // populated by the initial scan pages 48 | const watchers: RoutesFolderWatcher[] = [] 49 | 50 | async function scanPages(startWatchers = true) { 51 | if (options.extensions.length < 1) { 52 | throw new Error( 53 | '"extensions" cannot be empty. Please specify at least one extension.' 54 | ) 55 | } 56 | 57 | // initial scan was already done 58 | if (watchers.length > 0) { 59 | return 60 | } 61 | 62 | // get the initial list of pages 63 | await Promise.all( 64 | routesFolder 65 | .map((folder) => resolveFolderOptions(options, folder)) 66 | .map((folder) => { 67 | if (startWatchers) { 68 | watchers.push(setupWatcher(new RoutesFolderWatcher(folder))) 69 | } 70 | 71 | // the ignore option must be relative to cwd or absolute 72 | const ignorePattern = folder.exclude.map((f) => 73 | // if it starts with ** then it will work as expected 74 | f.startsWith('**') ? f : relative(folder.src, f) 75 | ) 76 | 77 | return fg(folder.pattern, { 78 | cwd: folder.src, 79 | // TODO: do they return the symbolic link path or the original file? 80 | // followSymbolicLinks: false, 81 | ignore: ignorePattern, 82 | }).then((files) => 83 | Promise.all( 84 | files 85 | // ensure consistent files in Windows/Unix and absolute paths 86 | .map((file) => resolve(folder.src, file)) 87 | .map((file) => 88 | addPage({ 89 | routePath: asRoutePath(folder, file), 90 | filePath: file, 91 | }) 92 | ) 93 | ) 94 | ) 95 | }) 96 | ) 97 | 98 | for (const route of editableRoutes) { 99 | await options.extendRoute?.(route) 100 | } 101 | 102 | // immediately write the files without the throttle 103 | await _writeConfigFiles() 104 | } 105 | 106 | async function writeRouteInfoToNode(node: TreeNode, filePath: string) { 107 | const content = await fs.readFile(filePath, 'utf8') 108 | // TODO: cache the result of parsing the SFC so the transform can reuse the parsing 109 | node.hasDefinePage = content.includes('definePage') 110 | const [definedPageNameAndPath, routeBlock] = await Promise.all([ 111 | extractDefinePageNameAndPath(content, filePath), 112 | getRouteBlock(filePath, options), 113 | ]) 114 | // TODO: should warn if hasDefinePage and customRouteBlock 115 | // if (routeBlock) log(routeBlock) 116 | node.setCustomRouteBlock(filePath, { 117 | ...routeBlock, 118 | ...definedPageNameAndPath, 119 | }) 120 | node.value.includeLoaderGuard = 121 | options.dataFetching && (await hasNamedExports(filePath)) 122 | } 123 | 124 | async function addPage( 125 | { filePath, routePath }: HandlerContext, 126 | triggerExtendRoute = false 127 | ) { 128 | log(`added "${routePath}" for "${filePath}"`) 129 | // TODO: handle top level named view HMR 130 | const node = routeTree.insert(routePath, filePath) 131 | 132 | await writeRouteInfoToNode(node, filePath) 133 | 134 | if (triggerExtendRoute) { 135 | await options.extendRoute?.(new EditableTreeNode(node)) 136 | } 137 | } 138 | 139 | async function updatePage({ filePath, routePath }: HandlerContext) { 140 | log(`updated "${routePath}" for "${filePath}"`) 141 | const node = routeTree.getChild(filePath) 142 | if (!node) { 143 | console.warn(`Cannot update "${filePath}": Not found.`) 144 | return 145 | } 146 | await writeRouteInfoToNode(node, filePath) 147 | await options.extendRoute?.(new EditableTreeNode(node)) 148 | } 149 | 150 | function removePage({ filePath, routePath }: HandlerContext) { 151 | log(`remove "${routePath}" for "${filePath}"`) 152 | routeTree.removeChild(filePath) 153 | } 154 | 155 | function setupWatcher(watcher: RoutesFolderWatcher) { 156 | log(`🤖 Scanning files in ${watcher.src}`) 157 | 158 | return watcher 159 | .on('change', async (ctx) => { 160 | await updatePage(ctx) 161 | writeConfigFiles() 162 | }) 163 | .on('add', async (ctx) => { 164 | await addPage(ctx, true) 165 | writeConfigFiles() 166 | }) 167 | .on('unlink', async (ctx) => { 168 | await removePage(ctx) 169 | writeConfigFiles() 170 | }) 171 | 172 | // TODO: handle folder removal: apparently chokidar only emits a raw event when deleting a folder instead of the 173 | // unlinkDir event 174 | } 175 | 176 | function generateRoutes() { 177 | const importsMap = new ImportsMap() 178 | 179 | const routesExport = `export const routes = ${generateRouteRecord( 180 | routeTree, 181 | options, 182 | importsMap 183 | )}` 184 | 185 | if (options.dataFetching) { 186 | importsMap.add('unplugin-vue-router/runtime', '_HasDataLoaderMeta') 187 | } 188 | 189 | // generate the list of imports 190 | let imports = `${importsMap}` 191 | // add an empty line for readability 192 | if (imports) { 193 | imports += '\n' 194 | } 195 | 196 | // prepend it to the code 197 | return `${imports}${routesExport}\n` 198 | } 199 | 200 | function generateDTS(): string { 201 | return _generateDTS({ 202 | vueRouterModule: MODULE_VUE_ROUTER, 203 | routesModule: MODULE_ROUTES_PATH, 204 | routeNamedMap: generateRouteNamedMap(routeTree) 205 | .split('\n') 206 | .filter((line) => line) // remove empty lines 207 | .map((line) => ' ' + line) // Indent by two spaces 208 | .join('\n'), 209 | }) 210 | } 211 | 212 | // NOTE: this code needs to be generated because otherwise it doesn't go through transforms and `vue-router/auto/routes` 213 | // cannot be resolved. 214 | function generateVueRouterProxy() { 215 | return _generateVueRouterProxy(MODULE_ROUTES_PATH, options) 216 | } 217 | 218 | let lastDTS: string | undefined 219 | async function _writeConfigFiles() { 220 | log('💾 writing...') 221 | 222 | if (options.beforeWriteFiles) { 223 | await options.beforeWriteFiles(editableRoutes) 224 | } 225 | 226 | logTree(routeTree, log) 227 | if (dts) { 228 | const content = generateDTS() 229 | if (lastDTS !== content) { 230 | await fs.writeFile(dts, content, 'utf-8') 231 | lastDTS = content 232 | server?.invalidate(MODULE_ROUTES_PATH) 233 | server?.invalidate(MODULE_VUE_ROUTER) 234 | server?.reload() 235 | } 236 | } 237 | } 238 | 239 | // debounce of 100ms + throttle of 500ms 240 | // => Initially wait 100ms (renames are actually remove and add but we rather write once) (debounce) 241 | // subsequent calls after the first execution will wait 500ms-100ms to execute (throttling) 242 | const writeConfigFiles = throttle(_writeConfigFiles, 500, 100) 243 | 244 | function stopWatcher() { 245 | if (watchers.length) { 246 | if (options.logs) { 247 | console.log('🛑 stopping watcher') 248 | } 249 | watchers.forEach((watcher) => watcher.close()) 250 | } 251 | } 252 | 253 | let server: ServerContext | undefined 254 | function setServerContext(_server: ServerContext) { 255 | server = _server 256 | } 257 | 258 | return { 259 | scanPages, 260 | writeConfigFiles, 261 | 262 | setServerContext, 263 | stopWatcher, 264 | 265 | generateRoutes, 266 | generateVueRouterProxy, 267 | 268 | definePageTransform(code: string, id: string) { 269 | return definePageTransform({ 270 | code, 271 | id, 272 | }) 273 | }, 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | import { isPackageExists } from 'local-pkg' 2 | import { _Awaitable, getFileBasedRouteName, isArray, warn } from './core/utils' 3 | import type { TreeNode } from './core/tree' 4 | import { resolve } from 'pathe' 5 | import { EditableTreeNode } from './core/extendRoutes' 6 | import { ParseSegmentOptions } from './core/treeNodeValue' 7 | 8 | export interface RoutesFolderOption { 9 | /** 10 | * Folder to scan files that should be used for routes. **Cannot be a glob**, use the `path`, `filePatterns`, and 11 | * `exclude` options to filter out files. This section will **be removed** from the resulting path. 12 | */ 13 | src: string 14 | 15 | /** 16 | * Prefix to add to the route path **as is**. Defaults to `''`. Can also be a function 17 | * to reuse parts of the filepath, in that case you should return a **modified version of the filepath**. 18 | * 19 | * @example 20 | * ```js 21 | * { 22 | * src: 'src/pages', 23 | * // this is equivalent to the default behavior 24 | * path: (file) => file.slice(file.lastIndexOf('src/pages') + 'src/pages'.length 25 | * }, 26 | * { 27 | * src: 'src/features', 28 | * // match all files (note the \ is not needed in real code) 29 | * filePatterns: '*‍/pages/**\/', 30 | * path: (file) => { 31 | * const prefix = 'src/features' 32 | * // remove the everything before src/features and removes /pages 33 | * // /src/features/feature1/pages/index.vue -> feature1/index.vue 34 | * return file.slice(file.lastIndexOf(prefix) + prefix.length + 1).replace('/pages', '') 35 | * }, 36 | * }, 37 | * ``` 38 | * 39 | */ 40 | path?: string | ((filepath: string) => string) 41 | 42 | /** 43 | * Allows to override the global `filePattern` option for this folder. It can also extend the global values by passing 44 | * a function that returns an array. 45 | */ 46 | filePatterns?: _OverridableOption | string 47 | 48 | /** 49 | * Allows to override the global `exclude` option for this folder. It can also extend the global values by passing a 50 | * function that returns an array. 51 | */ 52 | exclude?: _OverridableOption 53 | 54 | /** 55 | * Allows to override the global `extensions` option for this folder. It can also extend the global values by passing 56 | * a function that returns an array. 57 | */ 58 | extensions?: _OverridableOption 59 | } 60 | 61 | /** 62 | * Normalized options for a routes folder. 63 | */ 64 | export interface RoutesFolderOptionResolved extends RoutesFolderOption { 65 | path: string | ((filepath: string) => string) 66 | /** 67 | * Final glob pattern to match files in the folder. 68 | */ 69 | pattern: string[] 70 | filePatterns: string[] 71 | exclude: string[] 72 | extensions: string[] 73 | } 74 | 75 | export type _OverridableOption = T | ((existing: T) => T) 76 | 77 | export type _RoutesFolder = string | RoutesFolderOption 78 | export type RoutesFolder = _RoutesFolder[] | _RoutesFolder 79 | 80 | export interface ResolvedOptions { 81 | /** 82 | * Extensions of files to be considered as pages. Defaults to `['.vue']`. Cannot be empty. This allows to strip a 83 | * bigger part of the filename e.g. `index.page.vue` -> `index` if an extension of `.page.vue` is provided. 84 | */ 85 | extensions: string[] 86 | 87 | /** 88 | * Folder containing the components that should be used for routes. Can also be an array if you want to add multiple 89 | * folders, or an object if you want to define a route prefix. Supports glob patterns but must be a folder, use 90 | * `extensions` and `exclude` to filter files. 91 | * 92 | * @default "src/pages" 93 | */ 94 | routesFolder: RoutesFolderOption[] 95 | 96 | /** 97 | * Array of `picomatch` globs to ignore. Defaults to `[]`. Note the globs are relative to the cwd, so avoid writing 98 | * something like `['ignored']` to match folders named that way, instead provide a path similar to the `routesFolder`: 99 | * `['src/pages/ignored/**']` or use `['**​/ignored']` to match every folder named `ignored`. 100 | */ 101 | exclude: string[] 102 | 103 | // NOTE: the comment below contains ZWJ characters to allow the sequence `**/*` to be displayed correctly 104 | /** 105 | * Pattern to match files in the `routesFolder`. Defaults to `**‍/*` plus a combination of all the possible extensions, 106 | * e.g. `**‍/*.{vue,md}` if `extensions` is set to `['.vue', '.md']`. 107 | * @default "**‍/*" 108 | */ 109 | filePatterns: string | string[] 110 | 111 | /** 112 | * Method to generate the name of a route. 113 | */ 114 | getRouteName: (node: TreeNode) => string 115 | 116 | /** 117 | * Allows to extend a route by modifying its node, adding children, or even deleting it. This will be invoked once for 118 | * each route. 119 | * 120 | * @experimental See https://github.com/posva/unplugin-vue-router/issues/43 121 | * 122 | * @param route - {@link EditableTreeNode} of the route to extend 123 | */ 124 | extendRoute?: (route: EditableTreeNode) => _Awaitable 125 | 126 | /** 127 | * Allows to do some changes before writing the files. This will be invoked **every time** the files need to be written. 128 | * 129 | * @experimental See https://github.com/posva/unplugin-vue-router/issues/43 130 | * 131 | * @param rootRoute - {@link EditableTreeNode} of the root route 132 | */ 133 | beforeWriteFiles?: (rootRoute: EditableTreeNode) => _Awaitable 134 | 135 | /** 136 | * Enables EXPERIMENTAL data fetching. See https://github.com/posva/unplugin-vue-router/tree/main/src/data-fetching 137 | * @experimental 138 | */ 139 | dataFetching: boolean 140 | 141 | /** 142 | * Defines how page components should be imported. Defaults to dynamic imports to enable lazy loading of pages. 143 | */ 144 | importMode: _OptionsImportMode 145 | 146 | /** 147 | * Root of the project. All paths are resolved relatively to this one. Defaults to `process.cwd()`. 148 | */ 149 | root: string 150 | 151 | /** 152 | * Language for `` blocks in SFC files. Defaults to `'json5'`. 153 | */ 154 | routeBlockLang: 'yaml' | 'yml' | 'json5' | 'json' 155 | 156 | /** 157 | * Should we generate d.ts files or ont. Defaults to `true` if `typescript` is installed. Can be set to a string of 158 | * the filepath to write the d.ts files to. By default it will generate a file named `typed-router.d.ts`. 159 | */ 160 | dts: boolean | string 161 | 162 | /** 163 | * Allows inspection by vite-plugin-inspect by not adding the leading `\0` to the id of virtual modules. 164 | * @internal 165 | */ 166 | _inspect: boolean 167 | 168 | /** 169 | * Activates debug logs. 170 | */ 171 | logs: boolean 172 | 173 | /** 174 | * @inheritDoc ParseSegmentOptions 175 | */ 176 | pathParser: ParseSegmentOptions 177 | } 178 | 179 | /** 180 | * @internal 181 | */ 182 | export type _OptionsImportMode = 183 | | 'sync' 184 | | 'async' 185 | | ((filepath: string) => 'sync' | 'async') 186 | 187 | export interface Options 188 | extends Partial> { 189 | routesFolder?: RoutesFolder 190 | } 191 | 192 | export const DEFAULT_OPTIONS: ResolvedOptions = { 193 | extensions: ['.vue'], 194 | exclude: [], 195 | routesFolder: [{ src: 'src/pages' }], 196 | filePatterns: '**/*', 197 | routeBlockLang: 'json5', 198 | getRouteName: getFileBasedRouteName, 199 | dataFetching: false, 200 | importMode: 'async', 201 | root: process.cwd(), 202 | dts: isPackageExists('typescript'), 203 | logs: false, 204 | _inspect: false, 205 | pathParser: { 206 | dotNesting: true, 207 | }, 208 | } 209 | 210 | export interface ServerContext { 211 | invalidate: (module: string) => void 212 | reload: () => void 213 | } 214 | 215 | function normalizeRoutesFolderOption( 216 | routesFolder: RoutesFolder 217 | ): RoutesFolderOption[] { 218 | return (isArray(routesFolder) ? routesFolder : [routesFolder]).map( 219 | (routeOption) => 220 | typeof routeOption === 'string' ? { src: routeOption } : routeOption 221 | ) 222 | } 223 | 224 | /** 225 | * Normalize user options with defaults and resolved paths. 226 | * 227 | * @param options - user provided options 228 | * @returns normalized options 229 | */ 230 | export function resolveOptions(options: Options): ResolvedOptions { 231 | const root = options.root || DEFAULT_OPTIONS.root 232 | 233 | // normalize the paths with the root 234 | const routesFolder = normalizeRoutesFolderOption( 235 | options.routesFolder || DEFAULT_OPTIONS.routesFolder 236 | ).map((routeOption) => ({ 237 | ...routeOption, 238 | src: resolve(root, routeOption.src), 239 | })) 240 | 241 | if (options.extensions) { 242 | options.extensions = options.extensions 243 | // ensure that extensions start with a dot or warn the user 244 | // this is needed when filtering the files with the pattern .{vue,js,ts} 245 | // in src/index.ts 246 | .map((ext) => { 247 | if (!ext.startsWith('.')) { 248 | warn(`Invalid extension "${ext}". Extensions must start with a dot.`) 249 | return '.' + ext 250 | } 251 | return ext 252 | }) 253 | // sort extensions by length to ensure that the longest one is used first 254 | // e.g. ['.vue', '.page.vue'] -> ['.page.vue', '.vue'] as both would match and order matters 255 | .sort((a, b) => b.length - a.length) 256 | } 257 | 258 | return { 259 | ...DEFAULT_OPTIONS, 260 | ...options, 261 | routesFolder, 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /playground/typed-router.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | // @ts-nocheck 4 | // Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️ 5 | // It's recommended to commit this file. 6 | // Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry. 7 | 8 | /// 9 | 10 | import type { 11 | // type safe route locations 12 | RouteLocationTypedList, 13 | RouteLocationResolvedTypedList, 14 | RouteLocationNormalizedTypedList, 15 | RouteLocationNormalizedLoadedTypedList, 16 | RouteLocationAsString, 17 | RouteLocationAsRelativeTypedList, 18 | RouteLocationAsPathTypedList, 19 | 20 | // helper types 21 | // route definitions 22 | RouteRecordInfo, 23 | ParamValue, 24 | ParamValueOneOrMore, 25 | ParamValueZeroOrMore, 26 | ParamValueZeroOrOne, 27 | 28 | // vue-router extensions 29 | _RouterTyped, 30 | RouterLinkTyped, 31 | RouterLinkPropsTyped, 32 | NavigationGuard, 33 | UseLinkFnTyped, 34 | 35 | // data fetching 36 | _DataLoader, 37 | _DefineLoaderOptions, 38 | } from 'unplugin-vue-router/types' 39 | 40 | declare module 'vue-router/auto/routes' { 41 | export interface RouteNamedMap { 42 | 'home': RouteRecordInfo<'home', '/', Record, Record>, 43 | '/[name]': RouteRecordInfo<'/[name]', '/:name', { name: ParamValue }, { name: ParamValue }>, 44 | '/[...path]': RouteRecordInfo<'/[...path]', '/:path(.*)', { path: ParamValue }, { path: ParamValue }>, 45 | '/@[profileId]': RouteRecordInfo<'/@[profileId]', '/@:profileId', { profileId: ParamValue }, { profileId: ParamValue }>, 46 | '/about': RouteRecordInfo<'/about', '/about', Record, Record>, 47 | '/about.extra.nested': RouteRecordInfo<'/about.extra.nested', '/about/extra/nested', Record, Record>, 48 | '/articles': RouteRecordInfo<'/articles', '/articles', Record, Record>, 49 | '/articles/': RouteRecordInfo<'/articles/', '/articles', Record, Record>, 50 | '/articles/[id]': RouteRecordInfo<'/articles/[id]', '/articles/:id', { id: ParamValue }, { id: ParamValue }>, 51 | '/articles/[id]+': RouteRecordInfo<'/articles/[id]+', '/articles/:id+', { id: ParamValueOneOrMore }, { id: ParamValueOneOrMore }>, 52 | '/custom-definePage': RouteRecordInfo<'/custom-definePage', '/custom-definePage', Record, Record>, 53 | 'a rebel': RouteRecordInfo<'a rebel', '/custom-name', Record, Record>, 54 | '/custom/page': RouteRecordInfo<'/custom/page', '/custom/page', Record, Record>, 55 | '/deep/nesting/works/[[files]]+': RouteRecordInfo<'/deep/nesting/works/[[files]]+', '/deep/nesting/works/:files*', { files?: ParamValueZeroOrMore }, { files?: ParamValueZeroOrMore }>, 56 | '/deep/nesting/works/at-root-but-from-nested': RouteRecordInfo<'/deep/nesting/works/at-root-but-from-nested', '/at-root-but-from-nested', Record, Record>, 57 | 'deep the most rebel': RouteRecordInfo<'deep the most rebel', '/deep-most-rebel', Record, Record>, 58 | '/deep/nesting/works/custom-path': RouteRecordInfo<'/deep/nesting/works/custom-path', '/deep-surprise-:id(\d+)', Record, Record>, 59 | 'deep a rebel': RouteRecordInfo<'deep a rebel', '/deep/nesting/works/custom-name', Record, Record>, 60 | '/docs/:lang/real/': RouteRecordInfo<'/docs/:lang/real/', '/docs/:lang/real', Record, Record>, 61 | '/from-root': RouteRecordInfo<'/from-root', '/from-root', Record, Record>, 62 | 'the most rebel': RouteRecordInfo<'the most rebel', '/most-rebel', Record, Record>, 63 | '/multiple-[a]-[b]-params': RouteRecordInfo<'/multiple-[a]-[b]-params', '/multiple-:a-:b-params', { a: ParamValue, b: ParamValue }, { a: ParamValue, b: ParamValue }>, 64 | '/my-optional-[[slug]]': RouteRecordInfo<'/my-optional-[[slug]]', '/my-optional-:slug?', { slug?: ParamValueZeroOrOne }, { slug?: ParamValueZeroOrOne }>, 65 | '/n-[[n]]/': RouteRecordInfo<'/n-[[n]]/', '/n-:n?', { n?: ParamValueZeroOrOne }, { n?: ParamValueZeroOrOne }>, 66 | '/n-[[n]]/[[more]]+/': RouteRecordInfo<'/n-[[n]]/[[more]]+/', '/n-:n?/:more*', { n?: ParamValueZeroOrOne, more?: ParamValueZeroOrMore }, { n?: ParamValueZeroOrOne, more?: ParamValueZeroOrMore }>, 67 | '/n-[[n]]/[[more]]+/[final]': RouteRecordInfo<'/n-[[n]]/[[more]]+/[final]', '/n-:n?/:more*/:final', { n?: ParamValueZeroOrOne, more?: ParamValueZeroOrMore, final: ParamValue }, { n?: ParamValueZeroOrOne, more?: ParamValueZeroOrMore, final: ParamValue }>, 68 | '/not-used': RouteRecordInfo<'/not-used', '/not-used', Record, Record>, 69 | '/partial-[name]': RouteRecordInfo<'/partial-[name]', '/partial-:name', { name: ParamValue }, { name: ParamValue }>, 70 | '/custom-path': RouteRecordInfo<'/custom-path', '/surprise-:id(\d+)', Record, Record>, 71 | '/users/': RouteRecordInfo<'/users/', '/users', Record, Record>, 72 | '/users/[id]': RouteRecordInfo<'/users/[id]', '/users/:id', { id: ParamValue }, { id: ParamValue }>, 73 | '/users/[id].edit': RouteRecordInfo<'/users/[id].edit', '/users/:id/edit', { id: ParamValue }, { id: ParamValue }>, 74 | '/users/nested.route.deep': RouteRecordInfo<'/users/nested.route.deep', '/users/nested/route/deep', Record, Record>, 75 | '/with-extension': RouteRecordInfo<'/with-extension', '/with-extension', Record, Record>, 76 | } 77 | } 78 | 79 | declare module 'vue-router/auto' { 80 | import type { RouteNamedMap } from 'vue-router/auto/routes' 81 | 82 | export type RouterTyped = _RouterTyped 83 | 84 | /** 85 | * Type safe version of `RouteLocationNormalized` (the type of `to` and `from` in navigation guards). 86 | * Allows passing the name of the route to be passed as a generic. 87 | */ 88 | export type RouteLocationNormalized = RouteLocationNormalizedTypedList[Name] 89 | 90 | /** 91 | * Type safe version of `RouteLocationNormalizedLoaded` (the return type of `useRoute()`). 92 | * Allows passing the name of the route to be passed as a generic. 93 | */ 94 | export type RouteLocationNormalizedLoaded = RouteLocationNormalizedLoadedTypedList[Name] 95 | 96 | /** 97 | * Type safe version of `RouteLocationResolved` (the returned route of `router.resolve()`). 98 | * Allows passing the name of the route to be passed as a generic. 99 | */ 100 | export type RouteLocationResolved = RouteLocationResolvedTypedList[Name] 101 | 102 | /** 103 | * Type safe version of `RouteLocation` . Allows passing the name of the route to be passed as a generic. 104 | */ 105 | export type RouteLocation = RouteLocationTypedList[Name] 106 | 107 | /** 108 | * Type safe version of `RouteLocationRaw` . Allows passing the name of the route to be passed as a generic. 109 | */ 110 | export type RouteLocationRaw = 111 | | RouteLocationAsString 112 | | RouteLocationAsRelativeTypedList[Name] 113 | | RouteLocationAsPathTypedList[Name] 114 | 115 | /** 116 | * Generate a type safe params for a route location. Requires the name of the route to be passed as a generic. 117 | */ 118 | export type RouteParams = RouteNamedMap[Name]['params'] 119 | /** 120 | * Generate a type safe raw params for a route location. Requires the name of the route to be passed as a generic. 121 | */ 122 | export type RouteParamsRaw = RouteNamedMap[Name]['paramsRaw'] 123 | 124 | export function useRouter(): RouterTyped 125 | export function useRoute(name?: Name): RouteLocationNormalizedLoadedTypedList[Name] 126 | 127 | export const useLink: UseLinkFnTyped 128 | 129 | export function onBeforeRouteLeave(guard: NavigationGuard): void 130 | export function onBeforeRouteUpdate(guard: NavigationGuard): void 131 | 132 | export const RouterLink: RouterLinkTyped 133 | export const RouterLinkProps: RouterLinkPropsTyped 134 | 135 | // Experimental Data Fetching 136 | 137 | export function defineLoader< 138 | P extends Promise, 139 | Name extends keyof RouteNamedMap = keyof RouteNamedMap, 140 | isLazy extends boolean = false, 141 | >( 142 | name: Name, 143 | loader: (route: RouteLocationNormalizedLoaded) => P, 144 | options?: _DefineLoaderOptions, 145 | ): _DataLoader, isLazy> 146 | export function defineLoader< 147 | P extends Promise, 148 | isLazy extends boolean = false, 149 | >( 150 | loader: (route: RouteLocationNormalizedLoaded) => P, 151 | options?: _DefineLoaderOptions, 152 | ): _DataLoader, isLazy> 153 | 154 | export { 155 | _definePage as definePage, 156 | _HasDataLoaderMeta as HasDataLoaderMeta, 157 | _setupDataFetchingGuard as setupDataFetchingGuard, 158 | _stopDataFetchingScope as stopDataFetchingScope, 159 | } from 'unplugin-vue-router/runtime' 160 | } 161 | 162 | declare module 'vue-router' { 163 | import type { RouteNamedMap } from 'vue-router/auto/routes' 164 | 165 | export interface TypesConfig { 166 | beforeRouteUpdate: NavigationGuard 167 | beforeRouteLeave: NavigationGuard 168 | 169 | $route: RouteLocationNormalizedLoadedTypedList[keyof RouteNamedMap] 170 | $router: _RouterTyped 171 | 172 | RouterLink: RouterLinkTyped 173 | } 174 | } 175 | --------------------------------------------------------------------------------