├── 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
│ │ └── multi-extensions
│ │ ├── about.vue
│ │ ├── docs
│ │ ├── index.md
│ │ └── [...pathMatch].vue
│ │ └── index.vue
├── hmr
│ ├── .gitignore
│ ├── playground
│ │ ├── src
│ │ │ ├── pages
│ │ │ │ ├── hmr-alias.vue
│ │ │ │ ├── hmr-path.vue
│ │ │ │ ├── (home).vue
│ │ │ │ ├── hmr-name.vue
│ │ │ │ ├── hmr-meta.vue
│ │ │ │ ├── hmr-params-[id].vue
│ │ │ │ └── hmr-update.vue
│ │ │ ├── router.ts
│ │ │ ├── main.ts
│ │ │ └── App.vue
│ │ ├── package.json
│ │ ├── edits
│ │ │ └── src
│ │ │ │ └── pages
│ │ │ │ ├── hmr-alias-define-page-with-alias.vue
│ │ │ │ ├── hmr-path-define-page-with-custom-path.vue
│ │ │ │ ├── hmr-name-define-page-with-custom-name.vue
│ │ │ │ ├── (home)-route-block-with-meta.vue
│ │ │ │ ├── hmr-meta-define-page-with-meta.vue
│ │ │ │ ├── hmr-update-define-page-with-updated-meta.vue
│ │ │ │ └── hmr-params-[id]-define-page-with-int-parser.vue
│ │ ├── index.html
│ │ ├── vite.config.routes.ts
│ │ ├── vite.config.resolver.ts
│ │ └── vite.config.base.ts
│ └── fixtures
│ │ └── vite-server.ts
└── routes.spec.ts
├── .github
├── FUNDING.yml
├── workflows
│ ├── autofix.yml
│ ├── playwright.yml
│ ├── pkg.pr.new.yml
│ └── ci.yml
└── settings.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
│ │ │ ├── colada-loader.[id].vue
│ │ │ ├── tq-query-bug.vue
│ │ │ ├── tq-infinite-query.vue
│ │ │ ├── pinia-colada.[id].vue
│ │ │ └── [id].vue
│ │ ├── articles
│ │ │ ├── [id]+.vue
│ │ │ ├── [id].vue
│ │ │ └── index.vue
│ │ ├── my-optional-[[slug]].vue
│ │ ├── multiple-[a]-[b]-params.vue
│ │ ├── (some-layout)
│ │ │ ├── app.vue
│ │ │ └── home.vue
│ │ ├── named-views
│ │ │ ├── parent
│ │ │ │ ├── index.vue
│ │ │ │ ├── index@a.vue
│ │ │ │ └── index@b.vue
│ │ │ └── parent.vue
│ │ ├── nested-group
│ │ │ ├── (group).vue
│ │ │ └── (nested-group-first-level)
│ │ │ │ ├── nested-group-first-level-child.vue
│ │ │ │ └── (nested-group-deep)
│ │ │ │ └── nested-group-deep-child.vue
│ │ ├── __not-used.vue
│ │ ├── file(ignored-parentheses).vue
│ │ ├── not-used.component.vue
│ │ ├── with-extension.page.vue
│ │ ├── group
│ │ │ └── (thing).vue
│ │ ├── custom-path.vue
│ │ ├── custom-definePage.vue
│ │ ├── custom-name.vue
│ │ ├── test-[a-id].vue
│ │ ├── partial-[name].vue
│ │ ├── (some-layout).vue
│ │ ├── [...path].vue
│ │ ├── emoji-🤡.vue
│ │ ├── index@named.vue
│ │ ├── (test-group).vue
│ │ ├── vuefire-tests
│ │ │ └── get-doc.vue
│ │ ├── (test-group)
│ │ │ └── test-group-child.vue
│ │ ├── todos
│ │ │ ├── +layout.vue
│ │ │ └── index.vue
│ │ ├── about.extra.nested.vue
│ │ ├── [...path]+.vue
│ │ ├── custom-name-and-path.vue
│ │ ├── index.vue
│ │ ├── about.vue
│ │ ├── @[profileId].vue
│ │ └── articles.vue
│ ├── docs
│ │ ├── about.vue
│ │ ├── real
│ │ │ └── index.md
│ │ ├── should-be-ignored.md
│ │ └── index.vue
│ ├── 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
│ ├── params
│ │ ├── number.ts
│ │ └── date.ts
│ ├── api
│ │ ├── cat-facts.ts
│ │ └── index.ts
│ ├── queries
│ │ └── recipes.ts
│ ├── utils.ts
│ ├── router.ts
│ ├── main.ts
│ └── loaders
│ │ └── colada-loaders.ts
├── .gitignore
├── env.d.ts
├── tsconfig.config.json
├── db.json
├── index.html
├── auto-imports.d.ts
├── package.json
└── tsconfig.json
├── playground-experimental
├── src
│ ├── pages
│ │ ├── [a].[b].vue
│ │ ├── b.vue
│ │ ├── (home).vue
│ │ ├── nested
│ │ │ ├── index.vue
│ │ │ └── other.vue
│ │ ├── a.[b].c.[d].vue
│ │ ├── blog
│ │ │ ├── [slug]+.vue
│ │ │ ├── [[slugOptional]]+.vue
│ │ │ └── info
│ │ │ │ ├── [[section]].vue
│ │ │ │ └── (info).vue
│ │ ├── u[name]
│ │ │ ├── 24.vue
│ │ │ └── [userId=int].vue
│ │ ├── users
│ │ │ ├── sub-[first]-[second].vue
│ │ │ └── [userId=int].vue
│ │ ├── u[name].vue
│ │ ├── tests
│ │ │ └── [[optional]]
│ │ │ │ └── end.vue
│ │ ├── events
│ │ │ ├── [when=date].vue
│ │ │ └── repeat
│ │ │ │ └── [when=date]+.vue
│ │ ├── emoji-🤡.vue
│ │ ├── opt.[[num=int]].vue
│ │ ├── nested.vue
│ │ └── [...path].vue
│ ├── page-outside.vue
│ ├── main.ts
│ ├── params
│ │ └── date.ts
│ ├── router.ts
│ └── App.vue
├── .gitignore
├── env.d.ts
├── tsconfig.config.json
├── db.json
├── auto-imports.d.ts
├── index.html
├── package.json
└── tsconfig.json
├── tests
├── data-loaders
│ ├── index.ts
│ ├── env.d.ts
│ ├── ComponentWithLoader.vue
│ ├── loaders.ts
│ ├── ComponentWithNestedLoader.vue
│ └── RouterViewMock.vue
├── router-mock.ts
└── utils.ts
├── docs
├── data-loaders
│ ├── apollo
│ │ └── index.md
│ ├── ssr.md
│ ├── reloading-data.md
│ ├── nuxt.md
│ ├── load-cancellation.md
│ ├── basic
│ │ └── index.md
│ ├── organization.md
│ └── colada
│ │ └── index.md
├── package.json
├── .vitepress
│ ├── twoslash
│ │ ├── code
│ │ │ ├── stores.ts
│ │ │ ├── api.ts
│ │ │ └── typed-router.ts
│ │ └── files.ts
│ ├── theme
│ │ └── index.ts
│ ├── meta.ts
│ └── twoslash-files.ts
├── guide
│ ├── eslint.md
│ ├── hmr.md
│ └── configuration.md
├── nuxt
│ └── getting-started.md
├── why.md
└── index.md
├── renovate.json
├── src
├── vite.ts
├── rollup.ts
├── esbuild.ts
├── rolldown.ts
├── webpack.ts
├── types.ts
├── core
│ ├── options.spec.ts
│ ├── moduleConstants.ts
│ ├── __snapshots__
│ │ └── definePage.spec.ts.snap
│ ├── vite
│ │ └── index.ts
│ ├── utils.spec.ts
│ ├── customBlock.ts
│ └── RoutesFolderWatcher.spec.ts
├── data-loaders
│ ├── types-config.ts
│ ├── entries
│ │ ├── pinia-colada.ts
│ │ ├── basic.ts
│ │ └── index.ts
│ ├── symbols.ts
│ ├── defineLoader-notes.md
│ ├── meta-extensions.test-d.ts
│ ├── meta-extensions.ts
│ ├── auto-exports.ts
│ ├── tests-defineLoader.md
│ └── defineQueryLoader.spec.ts
├── volar
│ ├── utils
│ │ └── augment-vls-ctx.ts
│ └── entries
│ │ └── sfc-route-blocks.ts
├── utils
│ └── index.ts
├── codegen
│ ├── __snapshots__
│ │ └── generateRouteResolver.spec.ts.snap
│ ├── generateDTS.ts
│ ├── generateRouteMap.ts
│ ├── generateRouteFileInfoMap.ts
│ └── generateRouteParams.ts
└── runtime.ts
├── .npmrc
├── examples
└── nuxt
│ ├── pages
│ ├── index.vue
│ └── users
│ │ ├── [id].vue
│ │ └── colada-[userId].vue
│ ├── .gitignore
│ ├── tsconfig.json
│ ├── colada.options.ts
│ ├── nuxt.config.ts
│ ├── package.json
│ ├── plugins
│ └── data-loaders.ts
│ ├── README.md
│ └── app.vue
├── .prettierrc.js
├── tsconfig.typecheck.json
├── .prettierignore
├── pnpm-workspace.yaml
├── .vscode
├── settings.json
└── launch.json
├── route.schema.json
├── tsdown.config.ts
├── scripts
└── verifyCommit.mjs
├── LICENSE
├── tsdown-runtime.config.ts
├── playwright.config.ts
├── client.d.ts
├── vitest.config.ts
├── tsconfig.json
└── .gitignore
/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 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/[a].[b].vue:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/playground/.gitignore:
--------------------------------------------------------------------------------
1 | tsconfig.tsbuildinfo
2 |
--------------------------------------------------------------------------------
/e2e/fixtures/filenames/multi-extensions/about.vue:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/e2e/fixtures/filenames/multi-extensions/docs/index.md:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/e2e/fixtures/filenames/multi-extensions/index.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 |
--------------------------------------------------------------------------------
/tests/data-loaders/index.ts:
--------------------------------------------------------------------------------
1 | export * from './tester'
2 |
--------------------------------------------------------------------------------
/docs/data-loaders/apollo/index.md:
--------------------------------------------------------------------------------
1 | # Apollo
2 |
3 | WIP
4 |
--------------------------------------------------------------------------------
/e2e/fixtures/filenames/multi-extensions/docs/[...pathMatch].vue:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/data-loaders/ssr.md:
--------------------------------------------------------------------------------
1 | # Server side rendering
2 |
3 | WIP
4 |
--------------------------------------------------------------------------------
/e2e/fixtures/filenames/routes/nested/folder/should/work/index.vue:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/playground/src/docs/about.vue:
--------------------------------------------------------------------------------
1 | docs about
2 |
--------------------------------------------------------------------------------
/playground/src/pages/n-[[n]]/index.vue:
--------------------------------------------------------------------------------
1 | n+
2 |
--------------------------------------------------------------------------------
/playground-experimental/.gitignore:
--------------------------------------------------------------------------------
1 | tsconfig.tsbuildinfo
2 | .vite
3 |
--------------------------------------------------------------------------------
/playground/src/pages/users/[id].edit.vue:
--------------------------------------------------------------------------------
1 | id.edit
2 |
--------------------------------------------------------------------------------
/playground/src/pages/users/index.vue:
--------------------------------------------------------------------------------
1 | user index
2 |
--------------------------------------------------------------------------------
/tests/data-loaders/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/playground/src/docs/real/index.md:
--------------------------------------------------------------------------------
1 | # Only some pages should be included
2 |
--------------------------------------------------------------------------------
/playground/src/pages/articles/[id]+.vue:
--------------------------------------------------------------------------------
1 | articles id+
2 |
--------------------------------------------------------------------------------
/playground/src/pages/articles/[id].vue:
--------------------------------------------------------------------------------
1 | articles [id]
2 |
--------------------------------------------------------------------------------
/playground/src/pages/n-[[n]]/[[more]]+/index.vue:
--------------------------------------------------------------------------------
1 | more
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 | articles index
2 |
--------------------------------------------------------------------------------
/playground/src/pages/deep/nesting/works/too.vue:
--------------------------------------------------------------------------------
1 | deep too
2 |
--------------------------------------------------------------------------------
/playground/src/pages/n-[[n]]/[[more]]+/[final].vue:
--------------------------------------------------------------------------------
1 | final
2 |
--------------------------------------------------------------------------------
/src/rollup.ts:
--------------------------------------------------------------------------------
1 | import unplugin from '.'
2 |
3 | export default unplugin.rollup
4 |
--------------------------------------------------------------------------------
/playground/src/pages/my-optional-[[slug]].vue:
--------------------------------------------------------------------------------
1 | optional slug
2 |
--------------------------------------------------------------------------------
/src/esbuild.ts:
--------------------------------------------------------------------------------
1 | import unplugin from '.'
2 |
3 | export default unplugin.esbuild
4 |
--------------------------------------------------------------------------------
/src/rolldown.ts:
--------------------------------------------------------------------------------
1 | import unplugin from '.'
2 |
3 | export default unplugin.rolldown
4 |
--------------------------------------------------------------------------------
/src/webpack.ts:
--------------------------------------------------------------------------------
1 | import unplugin from '.'
2 |
3 | export default unplugin.webpack
4 |
--------------------------------------------------------------------------------
/playground/src/pages/multiple-[a]-[b]-params.vue:
--------------------------------------------------------------------------------
1 | mulitple params
2 |
--------------------------------------------------------------------------------
/playground/src/pages/users/nested.route.deep.vue:
--------------------------------------------------------------------------------
1 | nested route deep
2 |
--------------------------------------------------------------------------------
/e2e/hmr/.gitignore:
--------------------------------------------------------------------------------
1 | playground-tmp-*
2 | playground-routes-tmp-*
3 | playground-resolver-tmp-*
4 |
--------------------------------------------------------------------------------
/playground/src/pages/(some-layout)/app.vue:
--------------------------------------------------------------------------------
1 |
2 | App
3 |
4 |
--------------------------------------------------------------------------------
/playground/src/pages/(some-layout)/home.vue:
--------------------------------------------------------------------------------
1 |
2 | Home
3 |
4 |
--------------------------------------------------------------------------------
/playground/src/pages/deep/nesting/works/[[files]]+.vue:
--------------------------------------------------------------------------------
1 | deep with files+
2 |
--------------------------------------------------------------------------------
/playground/src/pages/named-views/parent/index.vue:
--------------------------------------------------------------------------------
1 |
2 | Default
3 |
4 |
--------------------------------------------------------------------------------
/playground/src/pages/named-views/parent/index@a.vue:
--------------------------------------------------------------------------------
1 |
2 | A
3 |
4 |
--------------------------------------------------------------------------------
/playground/src/pages/named-views/parent/index@b.vue:
--------------------------------------------------------------------------------
1 |
2 | B
3 |
4 |
--------------------------------------------------------------------------------
/playground/src/pages/nested-group/(group).vue:
--------------------------------------------------------------------------------
1 |
2 | (group).vue
3 |
4 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | ignore-workspace-root-check=true
2 | shamefully-hoist=true
3 | strict-peer-dependencies=false
4 |
--------------------------------------------------------------------------------
/examples/nuxt/pages/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Home
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | export default {
2 | semi: false,
3 | trailingComma: 'es5',
4 | singleQuote: true,
5 | }
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 | Not used
4 |
--------------------------------------------------------------------------------
/examples/nuxt/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | *.log*
3 | .nuxt
4 | .nitro
5 | .cache
6 | .output
7 | .env
8 | dist
9 |
--------------------------------------------------------------------------------
/playground/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "docs",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module"
6 | }
7 |
--------------------------------------------------------------------------------
/playground/src/pages/file(ignored-parentheses).vue:
--------------------------------------------------------------------------------
1 |
2 | file(ignored-brackets)
3 |
4 |
--------------------------------------------------------------------------------
/playground/src/pages/not-used.component.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Not used
4 |
--------------------------------------------------------------------------------
/e2e/fixtures/filenames/routes/users.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | users
4 |
--------------------------------------------------------------------------------
/e2e/hmr/playground/src/pages/hmr-alias.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | HMR Alias Test
4 |
5 |
6 |
--------------------------------------------------------------------------------
/e2e/hmr/playground/src/pages/hmr-path.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | HMR Path Test
4 |
5 |
6 |
--------------------------------------------------------------------------------
/examples/nuxt/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // https://v3.nuxtjs.org/concepts/typescript
3 | "extends": "./.nuxt/tsconfig.json"
4 | }
5 |
--------------------------------------------------------------------------------
/playground-experimental/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/b.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Page B
5 |
6 |
--------------------------------------------------------------------------------
/tsconfig.typecheck.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": [
4 | "node_modules",
5 | "dist"
6 | ],
7 | }
8 |
--------------------------------------------------------------------------------
/playground/src/pages/with-extension.page.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | I have an extension
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | __build__
2 | dist
3 | .nuxt
4 | .output
5 | coverage
6 | typed-router.d.ts
7 | auto-imports.d.ts
8 | e2e/hmr/playground-tmp-*
9 |
--------------------------------------------------------------------------------
/playground/src/pages/group/(thing).vue:
--------------------------------------------------------------------------------
1 |
2 | (thing).vue - Parentheses are ignored and this file becomes the index
3 |
4 |
--------------------------------------------------------------------------------
/playground-experimental/src/page-outside.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Page outside of pages
5 |
6 |
--------------------------------------------------------------------------------
/playground/src/pages/custom-path.vue:
--------------------------------------------------------------------------------
1 |
2 | {
3 | "path": "/surprise-:id(\\d+)"
4 | }
5 |
6 |
7 | custom names
8 |
--------------------------------------------------------------------------------
/examples/nuxt/colada.options.ts:
--------------------------------------------------------------------------------
1 | import type { PiniaColadaOptions } from '@pinia/colada'
2 |
3 | export default {
4 | // Options here
5 | } satisfies PiniaColadaOptions
6 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/(home).vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Home
5 |
6 | This is the homepage.
7 |
8 |
--------------------------------------------------------------------------------
/playground/src/pages/deep/nesting/works/custom-name.vue:
--------------------------------------------------------------------------------
1 |
2 | {
3 | "name": "deep a rebel"
4 | }
5 |
6 |
7 | custom name
8 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/nested/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Nested index
6 |
7 |
8 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/nested/other.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Nested other
6 |
7 |
8 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/a.[b].c.[d].vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ String($route.name) }} - {{ $route.path }}
5 |
6 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/blog/[slug]+.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Blog
5 |
6 | {{ $route.params }}
7 |
8 |
--------------------------------------------------------------------------------
/playground/src/docs/index.vue:
--------------------------------------------------------------------------------
1 |
5 |
6 | docs index
7 |
--------------------------------------------------------------------------------
/playground/src/features/feature-1/pages/about.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Feature 1 - About
6 |
7 |
8 |
--------------------------------------------------------------------------------
/playground/src/features/feature-1/pages/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Feature 1 - Index
6 |
7 |
8 |
--------------------------------------------------------------------------------
/playground/src/features/feature-2/pages/about.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Feature 2 - About
6 |
7 |
8 |
--------------------------------------------------------------------------------
/playground/src/features/feature-2/pages/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Feature 2 - Index
6 |
7 |
8 |
--------------------------------------------------------------------------------
/playground/src/features/feature-3/pages/about.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Feature 2 - About
6 |
7 |
8 |
--------------------------------------------------------------------------------
/playground/src/features/feature-3/pages/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Feature 3 - Index
6 |
7 |
8 |
--------------------------------------------------------------------------------
/playground/src/pages/deep/nesting/works/custom-path.vue:
--------------------------------------------------------------------------------
1 |
2 | {
3 | "path": "/deep-surprise-:id(\\d+)"
4 | }
5 |
6 |
7 | custom path
8 |
--------------------------------------------------------------------------------
/e2e/hmr/playground/src/pages/(home).vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Home
4 |
5 | {{ $route.meta.hello }}
6 |
7 |
8 |
--------------------------------------------------------------------------------
/e2e/hmr/playground/src/pages/hmr-name.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | HMR Name Test
4 | {{ $route.name }}
5 |
6 |
7 |
--------------------------------------------------------------------------------
/docs/.vitepress/twoslash/code/stores.ts:
--------------------------------------------------------------------------------
1 | import { defineStore } from 'pinia'
2 |
3 | export const useSomeStore = defineStore('some', {})
4 | export const useOtherStore = defineStore('other', {})
5 |
--------------------------------------------------------------------------------
/e2e/hmr/playground/src/pages/hmr-meta.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | HMR Meta Test
4 | {{ $route.meta.hello }}
5 |
6 |
7 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/blog/[[slugOptional]]+.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Blog
5 |
6 | {{ $route.params }}
7 |
8 |
--------------------------------------------------------------------------------
/e2e/hmr/playground/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "fixture-hmr",
4 | "scripts": {
5 | "dev": "vite -c vite.config.routes.ts",
6 | "build": "vite build"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/blog/info/[[section]].vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Blog Info
5 |
6 | {{ $route.params }}
7 |
8 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/u[name]/24.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Nested [name] page
5 | Too test specificity in routes
6 |
7 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/users/sub-[first]-[second].vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ String($route.name) }} - {{ $route.path }}
5 |
6 |
--------------------------------------------------------------------------------
/playground/src/pages/custom-definePage.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 | content
9 |
10 |
--------------------------------------------------------------------------------
/playground/src/pages/nested-group/(nested-group-first-level)/nested-group-first-level-child.vue:
--------------------------------------------------------------------------------
1 |
2 | Nested group first level child (resolves to nested group)
3 |
4 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/u[name]/[userId=int].vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | This page should never be visible
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/playground/src/pages/nested-group/(nested-group-first-level)/(nested-group-deep)/nested-group-deep-child.vue:
--------------------------------------------------------------------------------
1 |
2 | Nested group deep child (resolves to nested-group)
3 |
4 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/u[name].vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Named param
5 | {{ $route.params }}
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/tests/[[optional]]/end.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Optional param in the middle
5 |
6 | {{ $route.fullPath }}
7 |
8 |
--------------------------------------------------------------------------------
/playground/src/pages/custom-name.vue:
--------------------------------------------------------------------------------
1 |
2 | {
3 | "name": "a rebel",
4 | "meta": {
5 | "requiresAuth": true
6 | }
7 | }
8 |
9 |
10 | custom names
11 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - playground
3 | - playground-experimental
4 | - examples/*
5 | ignoredBuiltDependencies:
6 | - '@firebase/util'
7 | onlyBuiltDependencies:
8 | - esbuild
9 | - vue-demi
10 |
--------------------------------------------------------------------------------
/playground/src/pages/test-[a-id].vue:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 | test {{ route.params.aId }}
8 |
9 |
--------------------------------------------------------------------------------
/playground/src/pages/named-views/parent.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/playground/src/pages/partial-[name].vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 | Partial Param: {{ route.params.name }}
8 |
9 |
10 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/events/[when=date].vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 | Date
7 | {{ typeof route.params.when }} - {{ route.params.when }}
8 |
9 |
--------------------------------------------------------------------------------
/playground/src/pages/(some-layout).vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 | This is a layout page
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/playground/src/pages/[...path].vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Not Found
4 | {{ $route.params.path }} does not exist.
5 |
6 |
7 |
8 |
9 | {
10 | "props": true
11 | }
12 |
13 |
--------------------------------------------------------------------------------
/playground/src/pages/emoji-🤡.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
I have emoji in my filename
8 |
9 |
{{ route }}
10 |
11 |
12 |
--------------------------------------------------------------------------------
/playground/src/pages/index@named.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 | Home - named view
12 |
13 |
14 |
--------------------------------------------------------------------------------
/e2e/hmr/playground/edits/src/pages/hmr-alias-define-page-with-alias.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 | HMR Alias Test
10 |
11 |
12 |
--------------------------------------------------------------------------------
/e2e/hmr/playground/src/pages/hmr-params-[id].vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | HMR Params Test
4 | {{ $route.params.id }}
5 | {{ typeof $route.params.id }}
6 |
7 |
8 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/emoji-🤡.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
I have emoji in my filename
8 |
9 |
{{ route }}
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/examples/nuxt/nuxt.config.ts:
--------------------------------------------------------------------------------
1 | export default defineNuxtConfig({
2 | devtools: { enabled: true },
3 |
4 | modules: ['@pinia/nuxt', '@pinia/colada-nuxt'],
5 |
6 | experimental: {
7 | typedPages: true,
8 | },
9 |
10 | compatibilityDate: '2024-09-10',
11 | })
12 |
--------------------------------------------------------------------------------
/playground/src/pages/(test-group).vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | {{ $route.meta }}
15 |
16 |
--------------------------------------------------------------------------------
/playground/src/pages/vuefire-tests/get-doc.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 | Example to check syntax with loaders
9 |
10 |
--------------------------------------------------------------------------------
/e2e/hmr/playground/edits/src/pages/hmr-path-define-page-with-custom-path.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 | HMR Path Test
10 |
11 |
12 |
--------------------------------------------------------------------------------
/playground/src/pages/deep/nesting/works/custom-name-and-path.vue:
--------------------------------------------------------------------------------
1 |
2 | {
3 | "name": "deep the most rebel",
4 | "path": "/deep-most-rebel"
5 | }
6 |
7 |
8 |
9 | custom name and path
10 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/events/repeat/[when=date]+.vue:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 | Date
8 | {{ typeof route.params.when }} - {{ route.params.when }}
9 |
10 |
--------------------------------------------------------------------------------
/playground/src/pages/(test-group)/test-group-child.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 | Test group child (resolves to root)
12 |
13 |
--------------------------------------------------------------------------------
/e2e/hmr/playground/src/pages/hmr-update.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 | HMR Update Test
12 | {{ $route.meta.foo }}
13 |
14 |
15 |
--------------------------------------------------------------------------------
/e2e/hmr/playground/edits/src/pages/hmr-name-define-page-with-custom-name.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 | HMR Name Test
10 | {{ $route.name }}
11 |
12 |
13 |
--------------------------------------------------------------------------------
/e2e/hmr/playground/edits/src/pages/(home)-route-block-with-meta.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Home
4 |
5 | {{ $route.meta.hello }}
6 |
7 |
8 |
9 |
10 | {
11 | "meta": {
12 | "hello": "world"
13 | }
14 | }
15 |
16 |
--------------------------------------------------------------------------------
/e2e/hmr/playground/edits/src/pages/hmr-meta-define-page-with-meta.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 | HMR Meta Test
12 | {{ $route.meta.hello }}
13 |
14 |
15 |
--------------------------------------------------------------------------------
/playground-experimental/tsconfig.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@vue/tsconfig/tsconfig.json",
3 | "include": [
4 | "vite.config.*",
5 | "vitest.config.*",
6 | "cypress.config.*"
7 | ],
8 | "compilerOptions": {
9 | "composite": true,
10 | "noEmit": false,
11 | "types": [
12 | "node"
13 | ]
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/playground/src/pages/todos/+layout.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/e2e/hmr/playground/edits/src/pages/hmr-update-define-page-with-updated-meta.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 | HMR Update Test
12 | {{ $route.meta.foo }}
13 |
14 |
15 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/opt.[[num=int]].vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 | Named param
15 | {{ route.params }}
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/playground/src/pages/about.extra.nested.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 | About extra nested
14 |
15 |
16 |
--------------------------------------------------------------------------------
/tests/data-loaders/ComponentWithLoader.vue:
--------------------------------------------------------------------------------
1 |
7 |
8 |
11 |
12 |
13 | {{ data }}
14 |
15 |
--------------------------------------------------------------------------------
/playground/tsconfig.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@vue/tsconfig/tsconfig.json",
3 | "include": ["vite.config.*", "vitest.config.*", "cypress.config.*"],
4 | "$schema": "https://json.schemastore.org/tsconfig",
5 | "compilerOptions": {
6 | "noEmit": false,
7 | "emitDeclarationOnly": true,
8 | "composite": true,
9 | "types": ["node"]
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/e2e/hmr/playground/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | HMR E2E Tests
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/playground/src/pages/[...path]+.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 | Not Found
10 | {{ route.params.path }} does not exist.
11 |
12 |
13 |
14 |
15 | {
16 | "props": true
17 | }
18 |
19 |
--------------------------------------------------------------------------------
/playground/src/pages/custom-name-and-path.vue:
--------------------------------------------------------------------------------
1 |
2 | {
3 | "name": "the most rebel",
4 | "path": "/most-rebel",
5 | "props": true
6 | }
7 |
8 |
9 |
10 |
11 | custom names
12 |
13 |
16 |
--------------------------------------------------------------------------------
/tests/data-loaders/loaders.ts:
--------------------------------------------------------------------------------
1 | import { vi } from 'vitest'
2 | import { defineBasicLoader } from '../../src/data-loaders/defineLoader'
3 |
4 | export const dataOneSpy = vi.fn(async () => 'resolved 1')
5 | export const dataTwoSpy = vi.fn(async () => 'resolved 2')
6 |
7 | export const useDataOne = defineBasicLoader(dataOneSpy)
8 | export const useDataTwo = defineBasicLoader(dataTwoSpy)
9 |
--------------------------------------------------------------------------------
/playground/src/components/TestSetup.vue:
--------------------------------------------------------------------------------
1 |
15 |
--------------------------------------------------------------------------------
/docs/.vitepress/theme/index.ts:
--------------------------------------------------------------------------------
1 | import Theme from 'vitepress/theme'
2 | import TwoslashFloatingVue from '@shikijs/vitepress-twoslash/client'
3 | import '@shikijs/vitepress-twoslash/style.css'
4 | import type { EnhanceAppContext } from 'vitepress'
5 |
6 | export default {
7 | extends: Theme,
8 | enhanceApp({ app }: EnhanceAppContext) {
9 | app.use(TwoslashFloatingVue)
10 | },
11 | }
12 |
--------------------------------------------------------------------------------
/e2e/hmr/playground/src/router.ts:
--------------------------------------------------------------------------------
1 | import { createRouter, createWebHistory } from 'vue-router'
2 | import { routes, handleHotUpdate } from 'vue-router/auto-routes'
3 |
4 | export const router = createRouter({
5 | history: createWebHistory(),
6 | routes,
7 | })
8 |
9 | if (import.meta.hot) {
10 | handleHotUpdate(router, (routes) => {
11 | console.log('🔥 HMR with', routes)
12 | })
13 | }
14 |
--------------------------------------------------------------------------------
/tests/data-loaders/ComponentWithNestedLoader.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
12 |
13 |
14 | {{ data }}
15 |
16 |
17 |
--------------------------------------------------------------------------------
/playground/src/pages/index.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 | Home
13 | {{ $route.meta }}
14 |
15 |
16 |
17 |
18 | {
19 | "name": "home",
20 | "meta": {
21 | "n": 5
22 | }
23 | }
24 |
25 |
--------------------------------------------------------------------------------
/playground/src/pages/about.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 | About
12 |
13 | Increment to test HMR: 3
14 |
15 | {{ $route.meta }}
16 |
17 |
18 |
19 |
20 | {
21 | "meta": {
22 | "number": 14
23 | }
24 | }
25 |
26 |
--------------------------------------------------------------------------------
/e2e/hmr/playground/src/main.ts:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue'
2 | import App from './App.vue'
3 | import { router } from './router'
4 |
5 | const app = createApp(App)
6 | app.use(router)
7 | app.mount('#app')
8 |
9 | // small logger for navigations, useful to check HMR
10 | router.isReady().then(() => {
11 | router.beforeEach((to, from) => {
12 | console.log('🧭', from.fullPath, '->', to.fullPath)
13 | })
14 | })
15 |
--------------------------------------------------------------------------------
/tests/data-loaders/RouterViewMock.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/playground/src/pages/@[profileId].vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 | Some profile {{ route.params.profileId }}
16 |
17 |
18 |
--------------------------------------------------------------------------------
/playground/db.json:
--------------------------------------------------------------------------------
1 | {
2 | "todos": [
3 | {
4 | "id": 2,
5 | "title": "Walking",
6 | "completed": true
7 | },
8 | {
9 | "id": 3,
10 | "title": "Cleaning",
11 | "completed": false
12 | },
13 | {
14 | "id": 4,
15 | "title": "Cooking",
16 | "completed": true
17 | },
18 | {
19 | "title": "hello",
20 | "completed": false,
21 | "id": 7
22 | }
23 | ]
24 | }
--------------------------------------------------------------------------------
/e2e/hmr/playground/edits/src/pages/hmr-params-[id]-define-page-with-int-parser.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 | HMR Params Test
14 | {{ $route.params.id }}
15 | {{ typeof $route.params.id }}
16 |
17 |
18 |
--------------------------------------------------------------------------------
/playground-experimental/db.json:
--------------------------------------------------------------------------------
1 | {
2 | "todos": [
3 | {
4 | "id": 2,
5 | "title": "Walking",
6 | "completed": true
7 | },
8 | {
9 | "id": 3,
10 | "title": "Cleaning",
11 | "completed": false
12 | },
13 | {
14 | "id": 4,
15 | "title": "Cooking",
16 | "completed": true
17 | },
18 | {
19 | "title": "hello",
20 | "completed": false,
21 | "id": 7
22 | }
23 | ]
24 | }
--------------------------------------------------------------------------------
/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 | export type { TreeNode } from './core/tree'
8 | export type {
9 | TreeNodeValue,
10 | TreeNodeValueStatic,
11 | TreeNodeValueParam,
12 | TreeNodeValueGroup,
13 | } from './core/treeNodeValue'
14 | export type { EditableTreeNode } from './core/extendRoutes'
15 |
--------------------------------------------------------------------------------
/playground/src/params/number.ts:
--------------------------------------------------------------------------------
1 | // NOTE: should be imported from vue-router
2 | const invalid = (...args: ConstructorParameters) =>
3 | new Error(...args)
4 |
5 | export const parse = (value: string): number => {
6 | const asNumber = Number(value)
7 | if (Number.isFinite(asNumber)) {
8 | return asNumber
9 | }
10 | throw invalid(`Expected a number, but received: ${value}`)
11 | }
12 |
13 | // Same as default serializer
14 | export const toString = (value: number): string => String(value)
15 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "prettier.enable": true,
3 | "typescript.tsdk": "node_modules/typescript/lib",
4 | "typescript.preferences.autoImportFileExcludePatterns": [
5 | "vue-router$",
6 | ],
7 | "editor.formatOnSave": true,
8 | "[typescript]": {
9 | "editor.defaultFormatter": "esbenp.prettier-vscode",
10 | },
11 | "[javascript]": {
12 | "editor.defaultFormatter": "esbenp.prettier-vscode",
13 | },
14 | "[json]": {
15 | "editor.defaultFormatter": "esbenp.prettier-vscode",
16 | },
17 | }
--------------------------------------------------------------------------------
/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 | "@pinia/colada-devtools": "^0.1.9",
11 | "@pinia/colada-nuxt": "^0.2.4",
12 | "@pinia/nuxt": "^0.11.3",
13 | "nuxt": "^4.2.2",
14 | "unplugin-vue-router": "workspace:*"
15 | },
16 | "dependencies": {
17 | "@pinia/colada": "^0.18.1",
18 | "pinia": "^3.0.4"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/e2e/hmr/playground/vite.config.routes.ts:
--------------------------------------------------------------------------------
1 | import { mergeConfig } from 'vite'
2 | import baseConfig from './vite.config.base.ts'
3 | import { fileURLToPath, URL } from 'node:url'
4 | import VueRouter from '../../../src/vite'
5 | import Vue from '@vitejs/plugin-vue'
6 |
7 | const root = fileURLToPath(new URL('./', import.meta.url))
8 |
9 | export default mergeConfig(baseConfig, {
10 | plugins: [
11 | VueRouter({
12 | root,
13 | // logs: true,
14 | // defaults to false on CI
15 | watch: true,
16 | }),
17 | Vue(),
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 |
--------------------------------------------------------------------------------
/docs/guide/eslint.md:
--------------------------------------------------------------------------------
1 | # ESlint
2 |
3 | If you are not using auto imports, you will need to tell ESlint about `vue-router/auto-routes`. Add these lines to your eslint configuration:
4 |
5 | ```json{3}
6 | {
7 | "settings": {
8 | "import/core-modules": ["vue-router/auto-routes"]
9 | }
10 | }
11 | ```
12 |
13 | ## `definePage()`
14 |
15 | Since `definePage()` is a global macro, you need to tell ESlint about it. Add these lines to your eslint configuration:
16 |
17 | ```json{3}
18 | {
19 | "globals": {
20 | "definePage": "readonly"
21 | }
22 | }
23 | ```
24 |
--------------------------------------------------------------------------------
/playground/src/params/date.ts:
--------------------------------------------------------------------------------
1 | // NOTE: should be imported from vue-router
2 | const invalid = (...args: ConstructorParameters) =>
3 | new Error(...args)
4 |
5 | export const parse = (value: string): Date => {
6 | const asDate = new Date(value)
7 | if (Number.isNaN(asDate.getTime())) {
8 | throw invalid(`Invalid date: "${value}"`)
9 | }
10 |
11 | return asDate
12 | }
13 |
14 | export const toString = (value: Date): string =>
15 | value
16 | .toISOString()
17 | // allows keeping simple dates like 2023-10-01 without time
18 | .replace('T00:00:00.000Z', '')
19 |
--------------------------------------------------------------------------------
/playground/src/api/cat-facts.ts:
--------------------------------------------------------------------------------
1 | import { mande } from 'mande'
2 |
3 | export interface CatFacts {
4 | current_page: number
5 | data: Array<{ fact: string; length: number }>
6 | first_page_url: string
7 | from: number
8 | last_page: number
9 | last_page_url: string
10 | links: Array<{
11 | url: string | null
12 | label: string
13 | active: boolean
14 | }>
15 | next_page_url: string | null
16 | path: string
17 | per_page: number
18 | prev_page_url: string | null
19 | to: number
20 | total: number
21 | }
22 |
23 | export const factsApi = mande('https://catfact.ninja/facts')
24 |
--------------------------------------------------------------------------------
/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/nuxt/plugins/data-loaders.ts:
--------------------------------------------------------------------------------
1 | import {
2 | DataLoaderPlugin,
3 | type DataLoaderPluginOptions,
4 | } from 'unplugin-vue-router/data-loaders'
5 |
6 | export default defineNuxtPlugin({
7 | name: 'data-loaders',
8 | dependsOn: ['nuxt:router'],
9 | setup(nuxtApp) {
10 | nuxtApp.vueApp.use(DataLoaderPlugin, {
11 | router: nuxtApp.vueApp.config.globalProperties.$router,
12 | isSSR: import.meta.server,
13 |
14 | errors(reason) {
15 | console.error('[Data Loaders]', reason)
16 | return false
17 | },
18 | } satisfies DataLoaderPluginOptions)
19 | },
20 | })
21 |
--------------------------------------------------------------------------------
/docs/nuxt/getting-started.md:
--------------------------------------------------------------------------------
1 | # Nuxt
2 |
3 | Currently, this plugin is included as an experimental setting in Nuxt. You can enable it by adding the following to your `nuxt.config.ts`:
4 |
5 | ```ts{2-4}
6 | export default defineNuxtConfig({
7 | experimental: {
8 | typedPages: true,
9 | },
10 | })
11 | ```
12 |
13 | The `sfc-typed-router` Volar plugin, that automatically types `useRoute()` and `$route` in page components, cannot be enabled using a feature flag in Nuxt at this time. Read how to enable it here: [Using the `sfc-typed-router` Volar plugin](/guide/typescript#using-the-sfc-typed-router-volar-plugin).
14 |
--------------------------------------------------------------------------------
/docs/.vitepress/twoslash/code/api.ts:
--------------------------------------------------------------------------------
1 | export interface User {
2 | id: number
3 | name: string
4 | photoURL: string
5 | }
6 |
7 | export async function getUserById(id: string | number) {
8 | return {} as User
9 | }
10 | export async function getUserList() {
11 | return [] as User[]
12 | }
13 |
14 | export async function getCommonFriends(
15 | userAId: string | number,
16 | userBId: string | number
17 | ) {
18 | return [] as User[]
19 | }
20 |
21 | export async function getCurrentUser() {
22 | return {} as User
23 | }
24 |
25 | export async function getFriends(id: string | number) {
26 | return [] as User[]
27 | }
28 |
--------------------------------------------------------------------------------
/src/data-loaders/types-config.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Allows you to extend the default types of the library.
3 | *
4 | * @example
5 | * ```ts
6 | * // types-extension.d.ts
7 | * import 'unplugin-vue-router/data-loaders'
8 | * export {}
9 | * declare module 'unplugin-vue-router/data-loaders' {
10 | * interface TypesConfig {
11 | * Error: MyCustomError
12 | * }
13 | * }
14 | * ```
15 | */
16 | export interface TypesConfig {
17 | // Error: Error
18 | }
19 |
20 | /**
21 | * The default error type used.
22 | * @internal
23 | */
24 | export type ErrorDefault =
25 | TypesConfig extends Record<'Error', infer E> ? E : Error
26 |
--------------------------------------------------------------------------------
/e2e/hmr/playground/vite.config.resolver.ts:
--------------------------------------------------------------------------------
1 | import { mergeConfig } from 'vite'
2 | import baseConfig from './vite.config.base.ts'
3 | import { fileURLToPath, URL } from 'node:url'
4 | import VueRouter from '../../../src/vite'
5 | import Vue from '@vitejs/plugin-vue'
6 |
7 | const root = fileURLToPath(new URL('./', import.meta.url))
8 |
9 | export default mergeConfig(baseConfig, {
10 | plugins: [
11 | VueRouter({
12 | root,
13 | // logs: true,
14 | // defaults to false on CI
15 | watch: true,
16 | experimental: {
17 | paramParsers: true,
18 | },
19 | }),
20 | Vue(),
21 | ],
22 | })
23 |
--------------------------------------------------------------------------------
/e2e/hmr/playground/vite.config.base.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import { fileURLToPath, URL } from 'node:url'
3 |
4 | const root = fileURLToPath(new URL('./', import.meta.url))
5 |
6 | export default defineConfig({
7 | root,
8 | clearScreen: false,
9 | resolve: {
10 | alias: {
11 | '@': fileURLToPath(new URL('./src', import.meta.url)),
12 | '~': fileURLToPath(new URL('./src', import.meta.url)),
13 | 'unplugin-vue-router/runtime': fileURLToPath(
14 | new URL('../../../src/runtime.ts', import.meta.url)
15 | ),
16 | },
17 | },
18 | build: {
19 | sourcemap: true,
20 | },
21 | })
22 |
--------------------------------------------------------------------------------
/playground-experimental/auto-imports.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /* prettier-ignore */
3 | // @ts-nocheck
4 | // noinspection JSUnusedGlobalSymbols
5 | // Generated by unplugin-auto-import
6 | // biome-ignore lint: disable
7 | export {}
8 | declare global {
9 | const defineBasicLoader: typeof import('../src/data-loaders/entries/basic').defineBasicLoader
10 | const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
11 | const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
12 | const useRoute: typeof import('vue-router').useRoute
13 | const useRouter: typeof import('vue-router').useRouter
14 | }
15 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
3 | "version": "0.2.0",
4 | "configurations": [
5 | {
6 | "type": "node",
7 | "request": "launch",
8 | "name": "Debug Current Test File",
9 | "autoAttachChildProcesses": true,
10 | "skipFiles": [
11 | "/**",
12 | "**/node_modules/**"
13 | ],
14 | "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
15 | "args": [
16 | "run",
17 | "${relativeFile}"
18 | ],
19 | "smartStep": true,
20 | "console": "integratedTerminal"
21 | }
22 | ]
23 | }
24 |
--------------------------------------------------------------------------------
/playground-experimental/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
12 |
13 | visit /__inspect/ to inspect the intermediate state
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/playground-experimental/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "type": "module",
4 | "scripts": {
5 | "dev": "nodemon -w '../src/**/*.ts' -e .ts -x vite",
6 | "json-server": "json-server --watch db.json --port 4000",
7 | "playground:build": "vite build"
8 | },
9 | "devDependencies": {
10 | "@vitejs/plugin-vue": "^6.0.3",
11 | "@vue/compiler-sfc": "^3.5.25",
12 | "@vue/tsconfig": "^0.8.1",
13 | "json-server": "^0.17.4",
14 | "unplugin-vue-router": "workspace:*",
15 | "vite": "^7.3.0"
16 | },
17 | "dependencies": {
18 | "mande": "^2.0.9",
19 | "pinia": "^3.0.4",
20 | "vue": "^3.5.25",
21 | "vue-router": "^4.6.4"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/blog/info/(info).vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Blog Info - Index
5 |
6 |
7 |
8 | Go to Section null
17 |
18 |
19 | Go to Section 1
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/docs/.vitepress/meta.ts:
--------------------------------------------------------------------------------
1 | // noinspection ES6PreferShortImport: IntelliJ IDE hint to avoid warning to use `~/contributors`, will fail on build if changed
2 |
3 | /* Texts */
4 | export const headTitle = 'Unplugin Vue Router'
5 | export const headDescription = 'Typed file-based routing for Vue Router'
6 |
7 | /* GitHub and social links */
8 | export const github = 'https://github.com/posva/unplugin-vue-router'
9 | export const releases = 'https://github.com/posva/unplugin-vue-router/releases'
10 | export const contributing =
11 | 'https://github.com/posva/unplugin-vue-router/blob/main/.github/CONTRIBUTING.md'
12 | export const discord = 'https://chat.vuejs.org'
13 | export const twitter = 'https://twitter.com/posva'
14 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/users/[userId=int].vue:
--------------------------------------------------------------------------------
1 |
27 |
28 |
29 | {{ String($route.name) }} - {{ $route.path }}
30 |
31 | {{ route.params }}
32 |
33 |
--------------------------------------------------------------------------------
/playground/auto-imports.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /* prettier-ignore */
3 | // @ts-nocheck
4 | // noinspection JSUnusedGlobalSymbols
5 | // Generated by unplugin-auto-import
6 | // biome-ignore lint: disable
7 | export {}
8 | declare global {
9 | const defineBasicLoader: typeof import('../src/data-loaders/entries/basic').defineBasicLoader
10 | const defineColadaLoader: typeof import('../src/data-loaders/entries/pinia-colada')['defineColadaLoader']
11 | const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
12 | const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
13 | const useRoute: typeof import('vue-router').useRoute
14 | const useRouter: typeof import('vue-router').useRouter
15 | }
16 |
--------------------------------------------------------------------------------
/.github/workflows/autofix.yml:
--------------------------------------------------------------------------------
1 | name: autofix.ci
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - main
7 | paths-ignore:
8 | - 'docs/**'
9 | - 'scripts/**'
10 |
11 | permissions:
12 | contents: read
13 |
14 | jobs:
15 | autofix:
16 | runs-on: ubuntu-latest
17 | steps:
18 | - uses: actions/checkout@v6
19 | - uses: pnpm/action-setup@v4
20 | - uses: actions/setup-node@v6
21 | with:
22 | node-version: lts/*
23 | cache: pnpm
24 |
25 | - run: pnpm install --frozen-lockfile
26 | - run: pnpm run lint --write
27 |
28 | - uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27
29 | with:
30 | commit-message: 'style: fix code style'
31 |
--------------------------------------------------------------------------------
/e2e/hmr/playground/src/App.vue:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 |
17 |
20 |
21 |
22 | {{ $route.fullPath }}
23 |
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/src/data-loaders/entries/pinia-colada.ts:
--------------------------------------------------------------------------------
1 | export { defineColadaLoader } from '../defineColadaLoader'
2 | export type {
3 | DataLoaderColadaEntry,
4 | DataColadaLoaderContext,
5 | DefineDataColadaLoaderOptions_LaxData,
6 | UseDataLoaderColadaResult,
7 | UseDataLoaderColada_LaxData,
8 | UseDataLoaderColada_DefinedData,
9 | // deprecated
10 | DefineDataColadaLoaderOptions,
11 | } from '../defineColadaLoader'
12 | // export type {
13 | // UseDataLoader,
14 | // UseDataLoaderInternals,
15 | // UseDataLoaderResult,
16 | // DataLoaderContextBase,
17 | // DataLoaderEntryBase,
18 | // DefineDataLoaderOptionsBase,
19 | // DefineLoaderFn,
20 | // _DataMaybeLazy,
21 | // DefineDataLoader,
22 | // DefineDataLoaderCommit,
23 | // } from '../createDataLoader'
24 |
--------------------------------------------------------------------------------
/docs/why.md:
--------------------------------------------------------------------------------
1 | # Why?
2 |
3 | This project idea came from trying [to type the router directly using Typescript](https://github.com/vuejs/router/pull/1397/commits/a7c591b6fd5d8478ba3f87e833514bc0e30f93a9), finding out it's not fast enough to be pleasant to use and, ending up using build-based tools, taking some inspiration from other projects like:
4 |
5 | - [Nuxt](https://nuxtjs.org/) - The Vue.js Framework
6 | - [vite-plugin-pages](https://github.com/hannoeru/vite-plugin-pages) - Framework agnostic file based routing
7 | - [Typed Router for Nuxt](https://github.com/victorgarciaesgi/nuxt-typed-router) - A module to add typed routing to Nuxt
8 |
9 | It's currently included as [an experimental setting in Nuxt](https://nuxt.com/docs/guide/going-further/experimental-features#typedpages).
10 |
--------------------------------------------------------------------------------
/src/data-loaders/entries/basic.ts:
--------------------------------------------------------------------------------
1 | export { defineBasicLoader } from '../defineLoader'
2 | export type {
3 | DataLoaderContext,
4 | DataLoaderBasicEntry,
5 | UseDataLoaderBasic_LaxData,
6 | UseDataLoaderBasic_DefinedData,
7 | DefineDataLoaderOptions_LaxData,
8 | DefineDataLoaderOptions_DefinedData,
9 | // deprecated
10 | DefineDataLoaderOptions,
11 | UseDataLoaderBasic,
12 | } from '../defineLoader'
13 |
14 | // export type {
15 | // UseDataLoader,
16 | // UseDataLoaderInternals,
17 | // UseDataLoaderResult,
18 | // DataLoaderContextBase,
19 | // DataLoaderEntryBase,
20 | // DefineDataLoaderOptionsBase,
21 | // DefineLoaderFn,
22 | // _DataMaybeLazy,
23 | // DefineDataLoader,
24 | // DefineDataLoaderCommit,
25 | // } from '../createDataLoader'
26 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/tsdown.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, type Options } from 'tsdown'
2 |
3 | export const commonOptions = {
4 | format: ['cjs', 'esm'],
5 | external: [
6 | '@vue/compiler-sfc',
7 | 'vue',
8 | 'vue-router',
9 | 'vue-demi',
10 | '@pinia/colada',
11 | 'pinia',
12 | ],
13 | } satisfies Options
14 |
15 | export default defineConfig([
16 | {
17 | ...commonOptions,
18 | outputOptions: {
19 | // TODO: check if everthing works with this to remove the warning
20 | // exports: 'named',
21 | },
22 | entry: [
23 | './src/index.ts',
24 | './src/options.ts',
25 | './src/esbuild.ts',
26 | './src/rolldown.ts',
27 | './src/rollup.ts',
28 | './src/vite.ts',
29 | './src/webpack.ts',
30 | './src/types.ts',
31 | ],
32 | },
33 | ])
34 |
--------------------------------------------------------------------------------
/playground/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "type": "module",
4 | "scripts": {
5 | "dev": "nodemon -w '../src/**/*.ts' -e .ts -x vite",
6 | "json-server": "json-server --watch db.json --port 4000",
7 | "build": "vite build"
8 | },
9 | "devDependencies": {
10 | "@pinia/colada-devtools": "^0.1.9",
11 | "@tanstack/vue-query-devtools": "^6.1.2",
12 | "@vitejs/plugin-vue": "^6.0.3",
13 | "@vue/compiler-sfc": "^3.5.25",
14 | "@vue/tsconfig": "^0.8.1",
15 | "json-server": "^0.17.4",
16 | "unplugin-vue-router": "workspace:*",
17 | "vite": "^7.3.0"
18 | },
19 | "dependencies": {
20 | "@pinia/colada": "^0.18.1",
21 | "@tanstack/vue-query": "^5.92.1",
22 | "mande": "^2.0.9",
23 | "pinia": "^3.0.4",
24 | "vue": "^3.5.25",
25 | "vue-router": "^4.6.4"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/examples/nuxt/app.vue:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 | Hello {{ state }}
10 |
11 | Home
12 | |
13 | {{ route.href }}
14 | |
15 | {{
16 | route.href
17 | }}
18 | |
19 | {{
20 | route.href
21 | }}
22 | |
23 | {{
24 | route.href
25 | }}
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/.github/workflows/playwright.yml:
--------------------------------------------------------------------------------
1 | name: Playwright Tests
2 | on:
3 | push:
4 | branches: [main]
5 | pull_request:
6 | branches: [main]
7 | jobs:
8 | test:
9 | timeout-minutes: 60
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v6
13 | - uses: pnpm/action-setup@v4
14 | - uses: actions/setup-node@v6
15 | with:
16 | node-version: lts/*
17 | cache: 'pnpm'
18 | - name: Install dependencies
19 | run: pnpm install
20 | - name: Install Playwright Browsers
21 | run: pnpm exec playwright install --with-deps
22 | - name: Run Playwright tests
23 | run: pnpm exec playwright test
24 | - uses: actions/upload-artifact@v6
25 | if: ${{ !cancelled() }}
26 | with:
27 | name: playwright-report
28 | path: playwright-report/
29 | retention-days: 30
30 |
--------------------------------------------------------------------------------
/playground/src/api/index.ts:
--------------------------------------------------------------------------------
1 | import { mande } from 'mande'
2 |
3 | const todos = mande('http://localhost:4000/todos', {})
4 |
5 | export interface Todo {
6 | id: string
7 | title: string
8 | completed: boolean
9 | }
10 |
11 | export function fetchTodos() {
12 | return todos.get()
13 | }
14 |
15 | export function createTodo({
16 | title,
17 | completed = false,
18 | }: {
19 | title: string
20 | completed: boolean
21 | }) {
22 | if (title == 'fail') {
23 | return Promise.reject(new Error('Invalid title'))
24 | }
25 | return todos.post({ title, completed })
26 | }
27 |
28 | export function updateTodo(todoUpdate: {
29 | id: string
30 | title: string
31 | completed?: boolean
32 | }) {
33 | return todos.patch(todoUpdate.id, todoUpdate)
34 | }
35 |
36 | export function deleteTodo(id: string) {
37 | return todos.delete(id)
38 | }
39 |
40 | const delay = (ms: number) => new Promise((res) => setTimeout(res, ms))
41 |
--------------------------------------------------------------------------------
/playground-experimental/src/main.ts:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue'
2 | import App from './App.vue'
3 | import { createPinia } from 'pinia'
4 | import { PiniaColada } from '@pinia/colada'
5 | import { router } from './router'
6 | import { DataLoaderPlugin } from 'unplugin-vue-router/data-loaders'
7 | import { RouterLink, RouterView } from 'vue-router'
8 |
9 | const app = createApp(App)
10 |
11 | app.use(createPinia())
12 | app.use(PiniaColada, {})
13 | // @ts-expect-error: FIXME: should be doable
14 | app.use(DataLoaderPlugin, { router })
15 | app.component('RouterLink', RouterLink)
16 | app.component('RouterView', RouterView)
17 | app.use(router)
18 |
19 | // @ts-expect-error: for debugging on browser
20 | window.$router = router
21 |
22 | app.mount('#app')
23 |
24 | // small logger for navigations, useful to check HMR
25 | router.isReady().then(() => {
26 | router.beforeEach((to, from) => {
27 | console.log('🧭', from.fullPath, '->', to.fullPath)
28 | })
29 | })
30 |
--------------------------------------------------------------------------------
/.github/workflows/pkg.pr.new.yml:
--------------------------------------------------------------------------------
1 | name: Publish Any Commit
2 |
3 | on:
4 | pull_request:
5 | branches: main
6 | paths-ignore:
7 | - 'docs/**'
8 | - 'playground/**'
9 | - 'examples/**'
10 |
11 | push:
12 | branches:
13 | - '**'
14 | tags:
15 | - '!**'
16 | paths-ignore:
17 | - 'docs/**'
18 | - 'playground/**'
19 | - 'examples/**'
20 |
21 | jobs:
22 | build:
23 | runs-on: ubuntu-latest
24 |
25 | steps:
26 | - name: Checkout code
27 | uses: actions/checkout@v6
28 | with:
29 | fetch-depth: 0
30 | - uses: pnpm/action-setup@v4
31 | - uses: actions/setup-node@v6
32 | with:
33 | node-version: lts/*
34 | cache: pnpm
35 |
36 | - name: Install
37 | run: pnpm install --frozen-lockfile
38 |
39 | - name: Build
40 | run: pnpm build
41 |
42 | - name: Release
43 | run: pnpx pkg-pr-new publish --compact --pnpm .
44 |
--------------------------------------------------------------------------------
/playground-experimental/src/params/date.ts:
--------------------------------------------------------------------------------
1 | import { defineParamParser, miss } from 'vue-router/experimental'
2 |
3 | function toDate(value: string): Date {
4 | const asDate = new Date(value)
5 | if (Number.isNaN(asDate.getTime())) {
6 | throw miss(`Invalid date: "${value}"`)
7 | }
8 |
9 | return asDate
10 | }
11 |
12 | function toString(value: Date): string {
13 | return (
14 | value
15 | .toISOString()
16 | // allows keeping simple dates like 2023-10-01 without time
17 | // while still being able to parse full dates like 2023-10-01T12:00:00.000Z
18 | .replace('T00:00:00.000Z', '')
19 | )
20 | }
21 |
22 | export const parser = defineParamParser({
23 | get: (value: string | string[] | null) => {
24 | if (!value) {
25 | throw miss()
26 | }
27 | return Array.isArray(value) ? value.map(toDate) : toDate(value)
28 | },
29 | set: (value: Date | Date[]) =>
30 | Array.isArray(value) ? value.map(toString) : toString(value),
31 | })
32 |
--------------------------------------------------------------------------------
/docs/data-loaders/reloading-data.md:
--------------------------------------------------------------------------------
1 | # Reloading data
2 |
3 | Very often, it is required to reload the data (e.g. fetch the latest data) without navigating. Since Vue Router considers that a duplicated navigation, we cannot just `router.push()` and expect navigation guards to run again to fetch the latest data. To overcome this, data loaders expose a convenient `reload` method that can be invoked to manually rerun the loader **without navigating**. This has some extra implications we will cover in this page.
4 |
5 | ## Navigation Unaware
6 |
7 | When reloading data, the navigation is not involved, so not only the navigation guards will not run (`beforeRouteUpdate`, `beforeRouteLeave`, etc) but also any `NavigationResult` returned or thrown by the data loader will be ignored.
8 |
9 | ## Errors
10 |
11 | Because we are not within a navigation, errors are actually kept in the `error` property of the loader. Similar to lazy loaders. This allows to display any errors that might have occurred during the reload.
12 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: home
3 |
4 | title: Unplugin Vue Router
5 | titleTemplate: Next gen routing
6 |
7 | hero:
8 | name: Unplugin Vue Router
9 | tagline: Typed, file-based routing for Vue 3
10 | actions:
11 | - theme: brand
12 | text: Get Started
13 | link: /introduction
14 | - theme: alt
15 | text: Data Loaders
16 | link: /data-loaders/
17 |
18 | features:
19 | - title: Type Safe
20 | icon: 🔑
21 | details: Catch invalid routes at runtime, and get autocompletion for links.
22 | link: /guide/typescript
23 | - title: File based
24 | icon: 📁
25 | details: Automatically generate the routes from your file structure.
26 | link: /guide/file-based-routing
27 | - title: Build Time
28 | icon: 🏗
29 | details: Control the generated routes at build time to avoid shipping unnecessary code.
30 | - title: Data Loaders
31 | icon: 🔄
32 | details: Support for the upcoming Data Loaders for Vue Router.
33 | link: /data-loaders/
34 | ---
35 |
--------------------------------------------------------------------------------
/playground/src/components/Test.vue:
--------------------------------------------------------------------------------
1 |
33 |
--------------------------------------------------------------------------------
/playground/src/queries/recipes.ts:
--------------------------------------------------------------------------------
1 | export const RECIPE_QUERY_KEYS = {
2 | root: ['recipes'] as const,
3 |
4 | search: (options: { query?: string; page?: number; limit?: number }) =>
5 | [...RECIPE_QUERY_KEYS.root, 'search', options] as const,
6 |
7 | detail: (id: string) => [...RECIPE_QUERY_KEYS.root, 'detail', id] as const,
8 |
9 | recentList: (options?: { limit?: number }) =>
10 | [
11 | ...RECIPE_QUERY_KEYS.root,
12 | 'list',
13 | 'recent',
14 | ...(options ? [options] : []),
15 | ] as const,
16 |
17 | byAuthor: (authorId: string, options?: { page?: number; limit?: number }) =>
18 | [
19 | ...RECIPE_QUERY_KEYS.root,
20 | 'list',
21 | 'by-author',
22 | authorId,
23 | ...(options ? [options] : []),
24 | ] as const,
25 |
26 | byTags: (tags: string[], options?: { page?: number; limit?: number }) =>
27 | [
28 | ...RECIPE_QUERY_KEYS.root,
29 | 'list',
30 | 'by-tags',
31 | tags,
32 | ...(options ? [options] : []),
33 | ] as const,
34 | }
35 |
--------------------------------------------------------------------------------
/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, MockInstance } 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: MockInstance) => 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 |
--------------------------------------------------------------------------------
/examples/nuxt/pages/users/[id].vue:
--------------------------------------------------------------------------------
1 |
22 |
23 |
31 |
32 |
33 |
34 |
User {{ route.params.id }}
35 |
{{ user.name }}
36 |
37 | {{ user }}
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/docs/.vitepress/twoslash/code/typed-router.ts:
--------------------------------------------------------------------------------
1 | /* prettier-ignore */
2 |
3 | declare module 'vue-router/auto-routes' {
4 | import type {
5 | RouteRecordInfo,
6 | ParamValue,
7 | ParamValueOneOrMore,
8 | ParamValueZeroOrMore,
9 | ParamValueZeroOrOne,
10 | } from 'vue-router'
11 |
12 | export interface RouteNamedMap {
13 | '/': RouteRecordInfo<
14 | '/',
15 | '/',
16 | Record,
17 | Record,
18 | | never
19 | >
20 | '/users': RouteRecordInfo<
21 | '/users',
22 | '/users',
23 | Record,
24 | Record,
25 | | never
26 | >
27 | '/users/[id]': RouteRecordInfo<
28 | '/users/[id]',
29 | '/users/:id',
30 | { id: ParamValue },
31 | { id: ParamValue },
32 | | '/users/[id]/edit'
33 | >
34 | '/users/[id]/edit': RouteRecordInfo<
35 | '/users/[id]/edit',
36 | '/users/:id/edit',
37 | { id: ParamValue },
38 | { id: ParamValue },
39 | | never
40 | >
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/playground-experimental/src/router.ts:
--------------------------------------------------------------------------------
1 | import { experimental_createRouter } from 'vue-router/experimental'
2 | import { resolver, handleHotUpdate } from 'vue-router/auto-resolver'
3 |
4 | import {
5 | type RouteRecordInfo,
6 | type ParamValue,
7 | createWebHistory,
8 | } from 'vue-router'
9 |
10 | export const router = experimental_createRouter({
11 | history: createWebHistory(),
12 | resolver,
13 | })
14 |
15 | if (import.meta.hot) {
16 | handleHotUpdate(router)
17 | }
18 |
19 | // manual extension of route types
20 | declare module 'vue-router/auto-routes' {
21 | export interface RouteNamedMap {
22 | 'custom-dynamic-name': RouteRecordInfo<
23 | 'custom-dynamic-name',
24 | '/added-during-runtime/[...path]',
25 | { path: ParamValue },
26 | { path: ParamValue },
27 | 'custom-dynamic-child-name'
28 | >
29 | 'custom-dynamic-child-name': RouteRecordInfo<
30 | 'custom-dynamic-child-name',
31 | '/added-during-runtime/[...path]/child',
32 | { path: ParamValue },
33 | { path: ParamValue },
34 | never
35 | >
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/data-loaders/entries/index.ts:
--------------------------------------------------------------------------------
1 | export type {
2 | UseDataLoader,
3 | UseDataLoaderInternals,
4 | UseDataLoaderResult,
5 | DataLoaderContextBase,
6 | DataLoaderEntryBase,
7 | DefineDataLoaderOptionsBase_LaxData,
8 | DefineDataLoaderOptionsBase_DefinedData,
9 | DefineLoaderFn,
10 | // deprecated
11 | DefineDataLoaderOptionsBase,
12 | } from '../createDataLoader'
13 | export { toLazyValue } from '../createDataLoader'
14 |
15 | // new data fetching
16 | export {
17 | DataLoaderPlugin,
18 | NavigationResult,
19 | useIsDataLoading,
20 | } from '../navigation-guard'
21 | export type {
22 | DataLoaderPluginOptions,
23 | SetupLoaderGuardOptions,
24 | _DataLoaderRedirectResult,
25 | } from '../navigation-guard'
26 |
27 | export {
28 | getCurrentContext,
29 | setCurrentContext,
30 | type _PromiseMerged,
31 | assign,
32 | isSubsetOf,
33 | trackRoute,
34 | withLoaderContext,
35 | currentContext,
36 | } from '../utils'
37 |
38 | // expose all symbols that could be used by loaders
39 | export * from '../meta-extensions'
40 |
41 | export type { TypesConfig, ErrorDefault } from '../types-config'
42 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/volar/utils/augment-vls-ctx.ts:
--------------------------------------------------------------------------------
1 | import type { Code } from '@vue/language-core'
2 |
3 | /**
4 | * Augments the VLS context (volar) with additianal type information.
5 | *
6 | * @param content - content retrieved from the volar pluign
7 | * @param codes - codes to add to the VLS context
8 | */
9 | export function augmentVlsCtx(content: Code[], codes: Code[]) {
10 | let from = -1
11 |
12 | for (let i = 0; i < content.length; i++) {
13 | const code = content[i]
14 |
15 | if (typeof code !== 'string') {
16 | continue
17 | }
18 |
19 | if (from === -1 && code.startsWith(`const __VLS_ctx`)) {
20 | from = i
21 | } else if (from !== -1) {
22 | if (code === `}`) {
23 | content.splice(i, 0, ...codes.map((code) => `...${code},\n`))
24 | break
25 | } else if (code === `;\n`) {
26 | content.splice(
27 | from + 1,
28 | i - from,
29 | `{\n`,
30 | `...`,
31 | ...content.slice(from + 1, i),
32 | `,\n`,
33 | ...codes.map((code) => `...${code},\n`),
34 | `}`,
35 | `;\n`
36 | )
37 | break
38 | }
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/tsdown-runtime.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'tsdown'
2 | import { commonOptions } from './tsdown.config.ts'
3 |
4 | export default defineConfig([
5 | {
6 | ...commonOptions,
7 | clean: false,
8 | entry: ['./src/runtime.ts'],
9 | external: [...commonOptions.external, 'unplugin-vue-router/types'],
10 | },
11 |
12 | {
13 | ...commonOptions,
14 | clean: false,
15 | entry: ['./src/data-loaders/entries/*'],
16 | // to work with node10 moduleResolution mode
17 | outDir: 'dist/data-loaders',
18 | external: [
19 | ...commonOptions.external,
20 | 'unplugin-vue-router/types',
21 | 'unplugin-vue-router/runtime',
22 | 'unplugin-vue-router/data-loaders',
23 | ],
24 | },
25 |
26 | // volar plugin is CJS
27 | {
28 | ...commonOptions,
29 | clean: false,
30 | // splitting: false,
31 | format: ['cjs'],
32 | entry: ['./src/volar/entries/*'],
33 | // to work with node10 moduleResolution mode
34 | outDir: 'dist/volar',
35 | external: [
36 | ...commonOptions.external,
37 | 'unplugin-vue-router/volar',
38 | '@vue/language-core',
39 | 'muggle-string',
40 | ],
41 | },
42 | ])
43 |
--------------------------------------------------------------------------------
/playground-experimental/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 | "../src"
10 | ],
11 | "compilerOptions": {
12 | "rootDir": ".",
13 | "composite": true,
14 | "moduleResolution": "Bundler",
15 | "paths": {
16 | "@/*": [
17 | "./src/*"
18 | ],
19 | "unplugin-vue-router/runtime": [
20 | "../src/runtime.ts"
21 | ],
22 | "unplugin-vue-router/types": [
23 | "../src/types.ts"
24 | ],
25 | "unplugin-vue-router/data-loaders": [
26 | "../src/data-loaders/entries/index.ts"
27 | ],
28 | "unplugin-vue-router/data-loaders/basic": [
29 | "../src/data-loaders/entries/basic.ts"
30 | ],
31 | "unplugin-vue-router/data-loaders/pinia-colada": [
32 | "../src/data-loaders/entries/pinia-colada.ts"
33 | ]
34 | }
35 | },
36 | "vueCompilerOptions": {
37 | "plugins": [
38 | "unplugin-vue-router/volar/sfc-route-blocks",
39 | "unplugin-vue-router/volar/sfc-typed-router"
40 | ]
41 | },
42 | "references": []
43 | }
44 |
--------------------------------------------------------------------------------
/docs/.vitepress/twoslash-files.ts:
--------------------------------------------------------------------------------
1 | export const typedRouterFile = `
2 | declare module 'vue-router/auto-routes' {
3 | import type {
4 | RouteRecordInfo,
5 | ParamValue,
6 | ParamValueOneOrMore,
7 | ParamValueZeroOrMore,
8 | ParamValueZeroOrOne,
9 | } from 'vue-router'
10 |
11 | /**
12 | * Route name map generated by unplugin-vue-router
13 | */
14 | export interface RouteNamedMap {
15 | '/': RouteRecordInfo<
16 | '/',
17 | '/',
18 | Record,
19 | Record,
20 | | never
21 | >
22 | '/users': RouteRecordInfo<
23 | '/users',
24 | '/users',
25 | Record,
26 | Record,
27 | | never
28 | >
29 | '/users/[id]': RouteRecordInfo<
30 | '/users/[id]',
31 | '/users/:id',
32 | { id: ParamValue },
33 | { id: ParamValue },
34 | | '/users/[id]/edit'
35 | >
36 | '/users/[id]/edit': RouteRecordInfo<
37 | '/users/[id]/edit',
38 | '/users/:id/edit',
39 | { id: ParamValue },
40 | { id: ParamValue },
41 | | never
42 | >
43 | }
44 | }
45 | `
46 | export const typedRouterFileAsModule = typedRouterFile + '\nexport {}\n'
47 |
--------------------------------------------------------------------------------
/src/core/moduleConstants.ts:
--------------------------------------------------------------------------------
1 | // vue-router/auto/routes was more natural but didn't work well with TS
2 | export const MODULE_ROUTES_PATH = `vue-router/auto-routes`
3 | export const MODULE_RESOLVER_PATH = `vue-router/auto-resolver`
4 |
5 | // NOTE: not sure if needed. Used for HMR the virtual routes
6 | let time = Date.now()
7 | /**
8 | * Last time the routes were loaded from MODULE_ROUTES_PATH
9 | */
10 | export const ROUTES_LAST_LOAD_TIME = {
11 | get value() {
12 | return time
13 | },
14 | update(when = Date.now()) {
15 | time = when
16 | },
17 | }
18 |
19 | // we used to have `/__` because HMR didn't work with `\0` virtual modules
20 | // but it seems to work now, so switching to the official Vite virtual module prefix
21 | export const VIRTUAL_PREFIX = '\0'
22 |
23 | // allows removing the route block from the code
24 | export const ROUTE_BLOCK_ID = asVirtualId('vue-router/auto/route-block')
25 |
26 | export function getVirtualId(id: string) {
27 | return id.startsWith(VIRTUAL_PREFIX) ? id.slice(VIRTUAL_PREFIX.length) : null
28 | }
29 |
30 | export const routeBlockQueryRE = /\?vue&type=route/
31 |
32 | export function asVirtualId(id: string) {
33 | return VIRTUAL_PREFIX + id
34 | }
35 |
36 | export const DEFINE_PAGE_QUERY_RE = /\?.*\bdefinePage\&vue\b/
37 |
--------------------------------------------------------------------------------
/tests/utils.ts:
--------------------------------------------------------------------------------
1 | import { vi } from 'vitest'
2 |
3 | export const delay = (ms: number) =>
4 | new Promise((resolve) => setTimeout(resolve, ms))
5 |
6 | export function mockPromise(resolved: Resolved, rejected?: Err) {
7 | let _resolve: null | ((resolvedValue: Resolved) => void) = null
8 | let _reject: null | ((rejectedValue?: Err) => void) = null
9 | function resolve(resolvedValue?: Resolved) {
10 | if (!_resolve || !promise)
11 | throw new Error('Resolve called with no active promise')
12 | _resolve(resolvedValue ?? resolved)
13 | _resolve = null
14 | _reject = null
15 | promise = null
16 | }
17 | function reject(rejectedValue?: Err) {
18 | if (!_reject || !promise)
19 | throw new Error('Reject called with no active promise')
20 | _reject(rejectedValue ?? rejected)
21 | _resolve = null
22 | _reject = null
23 | promise = null
24 | }
25 |
26 | let promise: Promise | null = null
27 | const spy = vi
28 | .fn<(...args: any[]) => Promise>()
29 | .mockImplementation(() => {
30 | return (promise = new Promise((res, rej) => {
31 | _resolve = res
32 | _reject = rej
33 | }))
34 | })
35 |
36 | return [spy, resolve, reject] as const
37 | }
38 |
--------------------------------------------------------------------------------
/docs/.vitepress/twoslash/files.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs'
2 | import { join } from 'node:path'
3 | import { fileURLToPath } from 'node:url'
4 |
5 | const __dirname = fileURLToPath(new URL('.', import.meta.url))
6 |
7 | const apiCode = fs.readFileSync(join(__dirname, './code/api.ts'), 'utf-8')
8 |
9 | export const usersLoaderCode = `
10 | import { defineBasicLoader } from 'unplugin-vue-router/data-loaders/basic'
11 |
12 | ${apiCode}
13 |
14 | export const useUserData = defineBasicLoader((route) => getUserById(route.params.id as string))
15 | export const useUserList = defineBasicLoader(() => getUserList())
16 |
17 | export { User, getUserById, getUserList }
18 | `
19 |
20 | export const extraFiles = {
21 | '@/stores/index.ts': fs.readFileSync(
22 | join(__dirname, './code/stores.ts'),
23 | 'utf-8'
24 | ),
25 |
26 | 'shims-vue.d.ts': `
27 | declare module '*.vue' {
28 | import { defineComponent } from 'vue'
29 | export default defineComponent({})
30 | }
31 | `.trimStart(),
32 |
33 | // 'router.ts': typedRouterFileAsModule,
34 | 'typed-router.d.ts': fs.readFileSync(
35 | join(__dirname, './code/typed-router.ts'),
36 | 'utf-8'
37 | ),
38 | 'api/index.ts': apiCode,
39 | '../api/index.ts': apiCode,
40 | 'loaders/users.ts': usersLoaderCode,
41 | }
42 |
--------------------------------------------------------------------------------
/playground/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "extends": "@vue/tsconfig/tsconfig.dom.json",
4 | "include": [
5 | "./env.d.ts",
6 | "./src/**/*.ts",
7 | "./src/**/*.vue",
8 | "./typed-router.d.ts",
9 | "./auto-imports.d.ts"
10 | ],
11 | "compilerOptions": {
12 | "rootDir": ".",
13 | "composite": true,
14 | "noEmit": true,
15 | "moduleResolution": "Bundler",
16 | "paths": {
17 | "@/*": ["./src/*"],
18 | "unplugin-vue-router/runtime": ["../src/runtime.ts"],
19 | "unplugin-vue-router/types": ["../src/types.ts"],
20 | "unplugin-vue-router/data-loaders": [
21 | "../src/data-loaders/entries/index.ts"
22 | ],
23 | "unplugin-vue-router/data-loaders/basic": [
24 | "../src/data-loaders/entries/basic.ts"
25 | ],
26 | "unplugin-vue-router/data-loaders/pinia-colada": [
27 | "../src/data-loaders/entries/pinia-colada.ts"
28 | ]
29 | }
30 | },
31 | "vueCompilerOptions": {
32 | "plugins": [
33 | "unplugin-vue-router/volar/sfc-route-blocks",
34 | "unplugin-vue-router/volar/sfc-typed-router"
35 | ]
36 | },
37 | "references": [
38 | {
39 | "path": "./tsconfig.config.json"
40 | }
41 | ]
42 | }
43 |
--------------------------------------------------------------------------------
/docs/data-loaders/nuxt.md:
--------------------------------------------------------------------------------
1 | # Nuxt
2 |
3 | To use Data Loaders in Nuxt, create a new plugin file in the `plugins` directory of your Nuxt project and setup the Data Loaders plugin like usual:
4 |
5 | ```ts
6 | // plugins/data-loaders.ts
7 | import { DataLoaderPlugin } from 'unplugin-vue-router/data-loaders'
8 |
9 | export default defineNuxtPlugin({
10 | name: 'data-loaders',
11 | dependsOn: ['nuxt:router'],
12 | setup(nuxtApp) {
13 | const appConfig = useAppConfig()
14 |
15 | nuxtApp.vueApp.use(DataLoaderPlugin, {
16 | router: nuxtApp.vueApp.config.globalProperties.$router,
17 | isSSR: import.meta.server,
18 | // other options...
19 | })
20 | },
21 | })
22 | ```
23 |
24 | The two required options are:
25 |
26 | - `router`: the Vue Router instance
27 | - `isSSR`: a boolean indicating if the app is running on the server side
28 |
29 | ## No module?
30 |
31 | > "Why do I need to write the plugin myself instead of using a Module?"
32 |
33 | The Data Loader plugin has options that are not serializable (e.g. `selectNavigationResult()` and `errors`). In order to support those within a module, we would have to pass them through the `app.config.ts`, splitting up the configuration and making it harder to maintain. A short plugin is easier to understand and closer to the _vanilla_ version.
34 |
--------------------------------------------------------------------------------
/playground/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { inject, toValue, onUnmounted } from 'vue'
2 | import type { RouteLocation, RouteLocationNormalizedLoaded } from 'vue-router'
3 | import { viewDepthKey, useRoute, useRouter } from 'vue-router'
4 | import type { RouteNamedMap } from 'vue-router/auto-routes'
5 |
6 | type NavigationReturn = RouteLocation | boolean | void
7 |
8 | export function useParamMatcher(
9 | _name: Name,
10 | fn: (
11 | to: RouteLocationNormalizedLoaded
12 | ) => NavigationReturn | Promise
13 | ) {
14 | const route = useRoute()
15 | const router = useRouter()
16 | const depth = inject(viewDepthKey, 0)
17 | // we only need it the first time
18 | const matchedRecord = route.matched[toValue(depth) - 1]?.name
19 | console.log(matchedRecord)
20 |
21 | if (!matchedRecord) return
22 |
23 | console.log('add guard')
24 |
25 | const removeGuard = router.beforeEach((to) => {
26 | console.log('beforeEach', to)
27 | if (to.matched.find((record) => record.name === matchedRecord)) {
28 | return fn(to as RouteLocationNormalizedLoaded)
29 | }
30 | })
31 |
32 | onUnmounted(removeGuard)
33 | }
34 |
35 | export function dummy(arg: unknown) {
36 | return 'ok'
37 | }
38 |
39 | export const dummy_id = 'dummy_id'
40 | export const dummy_number = 42
41 |
--------------------------------------------------------------------------------
/playground/src/router.ts:
--------------------------------------------------------------------------------
1 | import { createRouter, createWebHistory } from 'vue-router'
2 | import { routes, handleHotUpdate } from 'vue-router/auto-routes'
3 | import type { RouteRecordInfo, ParamValue } from 'vue-router'
4 |
5 | export const router = createRouter({
6 | history: createWebHistory(),
7 | routes,
8 | })
9 |
10 | function addRedirects() {
11 | router.addRoute({
12 | path: '/new-about',
13 | redirect: '/about?from=hoho',
14 | })
15 | }
16 |
17 | if (import.meta.hot) {
18 | handleHotUpdate(router, (routes) => {
19 | console.log('🔥 HMR with', routes)
20 | addRedirects()
21 | })
22 | } else {
23 | // production
24 | addRedirects()
25 | }
26 |
27 | /* prettier-ignore */
28 |
29 | // manual extension of route types
30 | declare module 'vue-router/auto-routes' {
31 | export interface RouteNamedMap {
32 | 'custom-dynamic-name': RouteRecordInfo<
33 | 'custom-dynamic-name',
34 | '/added-during-runtime/[...path]',
35 | { path: ParamValue },
36 | { path: ParamValue },
37 | | 'custom-dynamic-child-name'
38 | >
39 | 'custom-dynamic-child-name': RouteRecordInfo<
40 | 'custom-dynamic-child-name',
41 | '/added-during-runtime/[...path]/child',
42 | { path: ParamValue },
43 | { path: ParamValue },
44 | | never
45 | >
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, devices } from '@playwright/test'
2 |
3 | export default defineConfig({
4 | testDir: './e2e/hmr',
5 |
6 | fullyParallel: true,
7 | forbidOnly: !!process.env.CI,
8 | // no retries because we have a setup
9 | retries: 0,
10 | // Each worker gets its own isolated temp folder
11 | workers: process.env.CI ? 1 : undefined,
12 | reporter: [
13 | // for console logs
14 | ['list'],
15 | // to debug
16 | ['html'],
17 | ],
18 | use: {
19 | /* Base URL to use in actions like `await page.goto('')`. */
20 | // baseURL: 'http://localhost:3000',
21 | trace: 'on-first-retry',
22 | },
23 |
24 | /* Configure projects for major browsers */
25 | projects: [
26 | // {
27 | // name: 'chromium',
28 | // use: { ...devices['Desktop Chrome'] },
29 | // },
30 | //
31 | // {
32 | // name: 'firefox',
33 | // use: { ...devices['Desktop Firefox'] },
34 | // },
35 | //
36 | {
37 | name: 'hmr-routes',
38 | testMatch: 'e2e/hmr/routes/hmr.spec.ts',
39 | use: {
40 | ...devices['Desktop Safari'],
41 | playgroundName: 'routes',
42 | },
43 | },
44 | {
45 | name: 'hmr-resolver',
46 | testMatch: 'e2e/hmr/resolver/hmr.spec.ts',
47 | use: {
48 | ...devices['Desktop Safari'],
49 | playgroundName: 'resolver',
50 | },
51 | },
52 | ],
53 | })
54 |
--------------------------------------------------------------------------------
/playground/src/pages/articles.vue:
--------------------------------------------------------------------------------
1 |
42 |
43 |
44 | Articles
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/playground/src/main.ts:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue'
2 | import App from './App.vue'
3 | import {
4 | MutationCache,
5 | QueryCache,
6 | VueQueryPlugin,
7 | type VueQueryPluginOptions,
8 | } from '@tanstack/vue-query'
9 | import { createPinia } from 'pinia'
10 | import { PiniaColada } from '@pinia/colada'
11 | import { router } from './router'
12 | import { DataLoaderPlugin } from 'unplugin-vue-router/data-loaders'
13 |
14 | const app = createApp(App)
15 |
16 | app.use(createPinia())
17 | app.use(PiniaColada, {})
18 | app.use(VueQueryPlugin, {
19 | queryClientConfig: {
20 | defaultOptions: {
21 | queries: {
22 | gcTime: 5000,
23 | },
24 | },
25 | mutationCache: new MutationCache({
26 | onSuccess(data, vars, context, mutation) {
27 | // debugger
28 | mutation
29 | },
30 | onMutate(variables, mutation) {
31 | return { wow: 'wah' }
32 | },
33 | async onSettled(...args) {
34 | await new Promise((r) => setTimeout(r, 1000))
35 | console.log('global onSettled', ...args)
36 | },
37 | }),
38 | },
39 | } satisfies VueQueryPluginOptions)
40 | app.use(DataLoaderPlugin, { router })
41 | app.use(router)
42 |
43 | app.mount('#app')
44 |
45 | // small logger for navigations, useful to check HMR
46 | router.isReady().then(() => {
47 | router.beforeEach((to, from) => {
48 | console.log('🧭', from.fullPath, '->', to.fullPath)
49 | })
50 | })
51 |
--------------------------------------------------------------------------------
/docs/data-loaders/load-cancellation.md:
--------------------------------------------------------------------------------
1 | # Cancelling a data loader
2 |
3 | Data loaders receive an [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that can be passed to `fetch` and other Web APIs to cancel ongoing requests when the navigation is cancelled. If the navigation is cancelled because of errors or a new navigation, the signal aborts, causing any request using it to abort as well.
4 |
5 | ```ts twoslash
6 | interface Book {
7 | title: string
8 | isbn: string
9 | description: string
10 | }
11 | function fetchBookCollection(options: {
12 | signal?: AbortSignal
13 | }): Promise {
14 | return {} as any
15 | }
16 | // ---cut---
17 | import { defineBasicLoader } from 'unplugin-vue-router/data-loaders/basic'
18 | export const useBookCollection = defineBasicLoader(
19 | async (_route, { signal }) => {
20 | return fetchBookCollection({ signal })
21 | }
22 | )
23 | ```
24 |
25 | This aligns with the future [Navigation API](https://github.com/WICG/navigation-api#navigation-monitoring-and-interception) and other web APIs that use the `AbortSignal` to cancel an ongoing invocation.
26 |
27 | ## Best practices
28 |
29 | Depending on the data loader implementation, it might be more interesting **not** to cancel an ongoing request, for example, when using [Pinia Colada](./colada/), it might be more interesting to keep the request ongoing and cache the result for future navigations. Make sure to read the documentation
30 |
--------------------------------------------------------------------------------
/playground/src/loaders/colada-loaders.ts:
--------------------------------------------------------------------------------
1 | import { defineColadaLoader } from 'unplugin-vue-router/data-loaders/pinia-colada'
2 | import { ref } from 'vue'
3 |
4 | const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
5 | export const simulateError = ref(false)
6 |
7 | export const useUserData = defineColadaLoader('/users/colada-loader.[id]', {
8 | async query(to, { signal }) {
9 | console.log('[🍹] coladaLoader', to.fullPath)
10 | signal.addEventListener('abort', () => {
11 | console.log('[🍹❌] aborted', to.fullPath)
12 | })
13 | // we need to read these before the delay
14 | const id = to.params.id
15 | // @ts-expect-error: no param "name"!
16 | const name = to.params.name
17 |
18 | await delay(2000)
19 | if (simulateError.value) {
20 | throw new Error('Simulated Error')
21 | }
22 |
23 | const user = {
24 | id,
25 | name,
26 | when: new Date().toUTCString(),
27 | }
28 |
29 | return user
30 | },
31 | key: (to) => {
32 | // console.log('[🍹] key', to.fullPath)
33 | return ['loader-users', to.params.id]
34 | },
35 | staleTime: 10000,
36 | placeholderData: (previousData) => {
37 | console.log('[🍹] placeholderData', previousData)
38 | return previousData
39 | },
40 | lazy: (to, from) => {
41 | const lazy = to.name && to.name === from?.name
42 | console.log('[🍹] lazy', to.fullPath, lazy)
43 | return lazy
44 | },
45 | })
46 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/nested.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
Nested root
10 |
11 |
string url
12 |
13 |
14 |
15 | {{ href }}
16 |
17 |
18 | {{ href }}
19 |
20 |
21 | {{
22 | href
23 | }}
24 |
25 |
26 |
27 |
28 |
named locations
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | {{
38 | href
39 | }}
40 |
41 |
42 | {{
43 | href
44 | }}
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
62 |
--------------------------------------------------------------------------------
/client.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'vue-router/auto-routes' {
2 | import type { RouteRecordRaw, Router } from 'vue-router'
3 |
4 | /**
5 | * Array of routes generated by unplugin-vue-router
6 | */
7 | export const routes: readonly RouteRecordRaw[]
8 |
9 | /**
10 | * Setups hot module replacement for routes.
11 | *
12 | * @param router - The router instance
13 | * @param hotUpdateCallback - Callback to be called after replacing the routes and before the navigation
14 | *
15 | * @example
16 | *
17 | * ```ts
18 | * import { createRouter, createWebHistory } from 'vue-router'
19 | * import { routes, handleHotUpdate } from 'vue-router/auto-routes'
20 | * const router = createRouter({
21 | * history: createWebHistory(),
22 | * routes,
23 | * })
24 | * if (import.meta.hot) {
25 | * handleHotUpdate(router)
26 | * }
27 | * ```
28 | */
29 | export function handleHotUpdate(
30 | router: Router,
31 | hotUpdateCallback?: (newRoutes: RouteRecordRaw[]) => void
32 | ): void
33 | }
34 |
35 | declare module 'vue-router' {
36 | import type { RouteNamedMap } from 'vue-router/auto-routes'
37 | import type { ParamParserCustom } from 'vue-router/auto-resolver'
38 |
39 | export interface TypesConfig {
40 | RouteNamedMap: RouteNamedMap
41 | ParamParsers: ParamParserCustom
42 | }
43 | }
44 |
45 | // Make the macros globally available
46 | declare global {
47 | const definePage: (typeof import('unplugin-vue-router/runtime'))['definePage']
48 | }
49 |
50 | export {}
51 |
--------------------------------------------------------------------------------
/playground-experimental/src/pages/[...path].vue:
--------------------------------------------------------------------------------
1 |
54 |
55 |
56 |
57 | Not Found
58 |
59 | {{ $route.params }}
60 | {{ $route.query }}
61 | {{ $route.meta }}
62 |
63 |
64 |
65 |
66 | {
67 | "meta": {
68 | "from block": true
69 | }
70 | }
71 |
72 |
--------------------------------------------------------------------------------
/.github/settings.yml:
--------------------------------------------------------------------------------
1 | repository:
2 | homepage: https://uvr.esm.is
3 | description: Next Generation file based typed routing for Vue Router
4 | labels:
5 | - name: 🐞 bug
6 | color: ee0701
7 | description: this isn't working as expected
8 | oldname: bug
9 | - name: ✨ feature request
10 | color: fbca04
11 | description: a new feature request
12 | - name: ⚡️ enhancement
13 | color: a2eeef
14 | description: improvement over an existing feature
15 | oldname: enhancement
16 | - name: ♦️ need repro
17 | color: c9581c
18 | description: the issue needs a reproduction for further investigation
19 | - name: has workaround
20 | color: 2139c4
21 | description: has a temporary fix to get around the problem
22 | - name: 👍 contribution welcome
23 | color: 0e8a16
24 | description: others are welcome to implement/fix this
25 | - name: 🔹 typescript
26 | color: 3178c6
27 | description: issue related to types
28 | - name: 💬 discussion
29 | color: 4935ad
30 | description: topic that requires further discussion
31 | - name: 📚 docs
32 | color: 8be281
33 | description: related to documentation
34 | oldname: documentation
35 | - name: 1️⃣ good first issue
36 | color: 7057ff
37 | description: this should be manageable for new-comers
38 | - name: 🙏 help wanted
39 | color: 008672
40 | description: help in this issue is welcome
41 | - name: wontfix
42 | color: ffffff
43 | description: this issue won't be fixed
44 | oldname: wontfix
45 | - name: 🔄 data-loaders
46 | color: baffc9
47 | description: related to Data Loaders
48 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config'
2 | import Vue from '@vitejs/plugin-vue'
3 | import { fileURLToPath, URL } from 'url'
4 |
5 | export default defineConfig({
6 | resolve: {
7 | alias: [
8 | {
9 | find: 'unplugin-vue-router/runtime',
10 | replacement: fileURLToPath(
11 | new URL('./src/runtime.ts', import.meta.url)
12 | ),
13 | },
14 | {
15 | find: 'unplugin-vue-router/data-loaders',
16 | replacement: fileURLToPath(
17 | new URL('./src/data-loaders/entries/index.ts', import.meta.url)
18 | ),
19 | },
20 | ],
21 | },
22 |
23 | plugins: [Vue()],
24 |
25 | test: {
26 | setupFiles: ['./tests/router-mock.ts'],
27 | include: ['{src,e2e}/**/*.spec.ts'],
28 | exclude: [
29 | 'src/**/*.test-d.ts',
30 | // exclude playwright e2e tests
31 | 'e2e/hmr',
32 | ],
33 | // open: false,
34 | coverage: {
35 | include: ['src/**/*.ts'],
36 | exclude: [
37 | 'src/**/*.d.ts',
38 | 'src/**/*.test-d.ts',
39 | 'src/**/*.spec.ts',
40 | 'src/volar',
41 | // entry points
42 | 'src/index.ts',
43 | 'src/esbuild.ts',
44 | 'src/rollup.ts',
45 | 'src/vite.ts',
46 | 'src/webpack.ts',
47 | 'src/types.ts',
48 | ],
49 | },
50 | typecheck: {
51 | enabled: true,
52 | checker: 'vue-tsc',
53 | // only: true,
54 | // by default it includes all specs too
55 | include: ['src/**/*.test-d.ts'],
56 | // tsconfig: './tsconfig.typecheck.json',
57 | },
58 | },
59 | })
60 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "./src/**/*.ts",
4 | "./src/*.d.ts",
5 | "./client.d.ts",
6 | "./tests/**/*.ts,"
7 | ],
8 | "exclude": [
9 | "./src/**/*.test-d.ts",
10 | // "./src/**/*.spec.ts",
11 | // "./src/data-loaders/defineQueryLoader.ts",
12 | // "./src/data-loaders/defineVueFireLoader.ts",
13 | "node_modules",
14 | "dist"
15 | ],
16 | "compilerOptions": {
17 | "rootDir": ".",
18 | "jsx": "preserve",
19 | "target": "ESNext",
20 | "module": "ESNext",
21 | "lib": ["ESNext", "DOM"],
22 | "moduleResolution": "Bundler",
23 | "skipDefaultLibCheck": true,
24 | "skipLibCheck": true,
25 | "esModuleInterop": true,
26 | "isolatedModules": true,
27 | "strict": true,
28 | "allowUnusedLabels": false,
29 | "allowUnreachableCode": false,
30 | "exactOptionalPropertyTypes": false,
31 | "noFallthroughCasesInSwitch": true,
32 | "noImplicitOverride": true,
33 | "noImplicitReturns": true,
34 | "noPropertyAccessFromIndexSignature": false,
35 | "noUncheckedIndexedAccess": true,
36 | "noUnusedLocals": true,
37 | "noUnusedParameters": true,
38 | "strictNullChecks": true,
39 | "resolveJsonModule": true,
40 | "paths": {
41 | "unplugin-vue-router": ["./src/index.ts"],
42 | "unplugin-vue-router/types": ["./src/types.ts"],
43 | "unplugin-vue-router/runtime": ["./src/runtime.ts"],
44 | "unplugin-vue-router/data-loaders": [
45 | "./src/data-loaders/entries/index.ts"
46 | ]
47 | },
48 | "types": ["node", "vite/client"]
49 | },
50 | "vueCompilerOptions": {
51 | "plugins": []
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/docs/guide/hmr.md:
--------------------------------------------------------------------------------
1 | # Hot Module Replacement
2 |
3 | When using `definePage()` and `` blocks, it's possible to enable Hot Module Replacement (HMR) for your routes **and avoid the need of reloading the page or the server** when you make changes to your routes.
4 |
5 | Enabling HMR is **strongly recommended** and currently **only works with Vite**.
6 |
7 | ```ts [src/router.ts]
8 | import { createRouter, createWebHistory } from 'vue-router'
9 | import {
10 | routes,
11 | handleHotUpdate, // [!code ++]
12 | } from 'vue-router/auto-routes'
13 |
14 | export const router = createRouter({
15 | history: createWebHistory(),
16 | routes,
17 | })
18 |
19 | // This will update routes at runtime without reloading the page
20 | if (import.meta.hot) { // [!code ++]
21 | handleHotUpdate(router) // [!code ++]
22 | } // [!code ++]
23 | ```
24 |
25 | ## Runtime routes
26 |
27 | If you add routes at runtime, you will have to add them within a callback to ensure they are added during development.
28 |
29 | ```ts{16-23} [src/router.ts]
30 | import { createRouter, createWebHistory } from 'vue-router'
31 | import { routes, handleHotUpdate } from 'vue-router/auto-routes'
32 |
33 | export const router = createRouter({
34 | history: createWebHistory(),
35 | routes,
36 | })
37 |
38 | function addRedirects() {
39 | router.addRoute({
40 | path: '/new-about',
41 | redirect: '/about?from=/new-about',
42 | })
43 | }
44 |
45 | if (import.meta.hot) {
46 | handleHotUpdate(router, (newRoutes) => {
47 | addRedirects()
48 | })
49 | } else {
50 | // production
51 | addRedirects()
52 | }
53 | ```
54 |
55 | This is **optional**, you can also just reload the page.
56 |
--------------------------------------------------------------------------------
/.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@v6
17 | - uses: pnpm/action-setup@v4
18 | - uses: actions/setup-node@v6
19 | with:
20 | node-version: lts/*
21 | cache: pnpm
22 |
23 | - run: pnpm install --frozen-lockfile
24 | - run: pnpm run lint
25 | - run: pnpm run build
26 | - run: pnpm run -C playground build
27 |
28 | test:
29 | runs-on: ${{ matrix.os }}
30 |
31 | strategy:
32 | matrix:
33 | node: [20.x, lts/*]
34 | os: [ubuntu-latest, windows-latest, macos-latest]
35 | fail-fast: false
36 |
37 | steps:
38 | - uses: actions/checkout@v6
39 | - uses: pnpm/action-setup@v4
40 | - name: Set node ${{ matrix.node }}
41 | uses: actions/setup-node@v6
42 | with:
43 | node-version: ${{ matrix.node }}
44 | cache: pnpm
45 |
46 | - run: pnpm install --frozen-lockfile
47 |
48 | - name: Tests with coverage
49 | if: ${{ matrix.os == 'ubuntu-latest' && matrix.node == 'lts/*' }}
50 | run: pnpm run vitest --coverage
51 |
52 | - name: Tests
53 | if: ${{ matrix.os != 'ubuntu-latest' || matrix.node != 'lts/*' }}
54 | run: pnpm run vitest
55 |
56 | - name: Upload coverage to Codecov
57 | if: ${{ matrix.os == 'ubuntu-latest' && matrix.node == 'lts/*' }}
58 | uses: codecov/codecov-action@v5
59 | with:
60 | token: ${{ secrets.CODECOV_TOKEN }}
61 |
--------------------------------------------------------------------------------
/src/data-loaders/symbols.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Retrieves the internal version of loaders.
3 | * @internal
4 | */
5 | export const LOADER_SET_KEY = Symbol('loaders')
6 |
7 | /**
8 | * Retrieves the internal version of loader entries.
9 | * @internal
10 | */
11 | export const LOADER_ENTRIES_KEY = Symbol('loaderEntries')
12 |
13 | /**
14 | * Added to the loaders returned by `defineLoader()` to identify them.
15 | * Allows to extract exported useData() from a component.
16 | * @internal
17 | */
18 | export const IS_USE_DATA_LOADER_KEY = Symbol()
19 |
20 | /**
21 | * Symbol used to save the pending location on the router.
22 | * @internal
23 | */
24 | export const PENDING_LOCATION_KEY = Symbol()
25 |
26 | /**
27 | * Symbol used to know there is no value staged for the loader and that commit should be skipped.
28 | * @internal
29 | */
30 | export const STAGED_NO_VALUE = Symbol()
31 |
32 | /**
33 | * Gives access to the current app and it's `runWithContext` method.
34 | * @internal
35 | */
36 | export const APP_KEY = Symbol()
37 |
38 | /**
39 | * Gives access to an AbortController that aborts when the navigation is canceled.
40 | * @internal
41 | */
42 | export const ABORT_CONTROLLER_KEY = Symbol()
43 |
44 | /**
45 | * Gives access to the navigation results when the navigation is aborted by the user within a data loader.
46 | * @internal
47 | */
48 | export const NAVIGATION_RESULTS_KEY = Symbol()
49 |
50 | /**
51 | * Symbol used to save the initial data on the router.
52 | * @internal
53 | */
54 | export const IS_SSR_KEY = Symbol()
55 |
56 | /**
57 | * Symbol used to get the effect scope used for data loaders.
58 | * @internal
59 | */
60 | export const DATA_LOADERS_EFFECT_SCOPE_KEY = Symbol()
61 |
--------------------------------------------------------------------------------
/.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 | tsconfig.vitest-temp.json
83 | docs/.vitepress/cache
84 | vite.config.ts.timestamp-*
85 | .DS_Store
86 |
87 | # Playwright
88 | /test-results/
89 | /playwright-report/
90 | /blob-report/
91 | /playwright/.cache/
92 | /playwright/.auth/
93 | .osgrep
94 |
--------------------------------------------------------------------------------
/playground-experimental/src/App.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
76 |
--------------------------------------------------------------------------------
/src/core/__snapshots__/definePage.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`definePage > imports > keeps used default imports 1`] = `
4 | "import my_var from './lib'
5 | export default {
6 | meta: {
7 | [my_var]: 'hello',
8 | }
9 | }"
10 | `;
11 |
12 | exports[`definePage > imports > keeps used named imports 1`] = `
13 | "import {my_var, my_func, my_num} from './lib'
14 | export default {
15 | meta: {
16 | [my_var]: 'hello',
17 | other: my_func,
18 | custom() {
19 | return my_num
20 | }
21 | }
22 | }"
23 | `;
24 |
25 | exports[`definePage > imports > removes default unused imports 1`] = `"export default {name: 'ok'}"`;
26 |
27 | exports[`definePage > imports > removes unused star imports 1`] = `"export default {name: 'ok'}"`;
28 |
29 | exports[`definePage > imports > works when combining named and default imports 1`] = `
30 | "import my_var, {my_func} from './lib'
31 | export default {
32 | meta: {
33 | [my_var]: 'hello',
34 | other: my_func,
35 | }
36 | }"
37 | `;
38 |
39 | exports[`definePage > imports > works with star imports 1`] = `
40 | "import * as lib from './my-lib'
41 | export default {
42 | meta: {
43 | [lib.my_var]: 'hello',
44 | }
45 | }"
46 | `;
47 |
48 | exports[`definePage > removes definePage 1`] = `
49 | "
50 |
55 |
56 |
57 | hello
58 |
59 | "
60 | `;
61 |
62 | exports[`definePage > works if file is named definePage 1`] = `
63 | "
64 |
69 |
70 |
71 | hello
72 |
73 | "
74 | `;
75 |
76 | exports[`definePage > works with jsx 1`] = `
77 | "export default {
78 | name: 'custom',
79 | path: '/custom',
80 | }"
81 | `;
82 |
--------------------------------------------------------------------------------
/src/core/vite/index.ts:
--------------------------------------------------------------------------------
1 | import { type ViteDevServer } from 'vite'
2 | import { type ServerContext } from '../../options'
3 | import {
4 | MODULE_RESOLVER_PATH,
5 | MODULE_ROUTES_PATH,
6 | asVirtualId,
7 | } from '../moduleConstants'
8 |
9 | export function createViteContext(server: ViteDevServer): ServerContext {
10 | function invalidate(path: string): false | Promise {
11 | const foundModule = server.moduleGraph.getModuleById(path)
12 | // console.log(`🟣 Invalidating module: ${path}, found: ${!!foundModule}`)
13 | if (foundModule) {
14 | return server.reloadModule(foundModule)
15 | }
16 | return !!foundModule
17 | }
18 |
19 | function invalidatePage(filepath: string): Promise | false {
20 | const pageModules = server.moduleGraph.getModulesByFile(filepath)
21 | // console.log(`🟣 Invalidating page: ${filepath}, found: ${!!pageModule}`)
22 | if (pageModules) {
23 | return Promise.all(
24 | [...pageModules].map((mod) => server.reloadModule(mod))
25 | ).then(() => {})
26 | }
27 | return false
28 | }
29 |
30 | function reload() {
31 | server.ws.send({
32 | type: 'full-reload',
33 | path: '*',
34 | })
35 | }
36 |
37 | /**
38 | * Triggers HMR for the vue-router/auto-routes module.
39 | */
40 | async function updateRoutes() {
41 | const autoRoutesMod = server.moduleGraph.getModuleById(
42 | asVirtualId(MODULE_ROUTES_PATH)
43 | )
44 | const autoResolvedMod = server.moduleGraph.getModuleById(
45 | asVirtualId(MODULE_RESOLVER_PATH)
46 | )
47 |
48 | await Promise.all([
49 | autoRoutesMod && server.reloadModule(autoRoutesMod),
50 | autoResolvedMod && server.reloadModule(autoResolvedMod),
51 | ])
52 | }
53 |
54 | return {
55 | invalidate,
56 | invalidatePage,
57 | updateRoutes,
58 | reload,
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/playground/src/pages/todos/index.vue:
--------------------------------------------------------------------------------
1 |
48 |
49 |
50 |
54 |
61 |
62 | Enabled Query
63 |
64 | isLoading: {{ isLoading }}
65 |
66 | isFetching: {{ isFetching }}
67 |
68 | isError: {{ isError }}
69 |
70 | Error: {{ error }}
71 |
72 |
73 |
--------------------------------------------------------------------------------
/docs/data-loaders/basic/index.md:
--------------------------------------------------------------------------------
1 | # `defineBasicLoader()`
2 |
3 | Basic data loader that always reruns on navigation.
4 |
5 | ::: warning
6 | Data Loaders are experimental. Feedback is very welcome to shape the future of data loaders in Vue Router.
7 | :::
8 |
9 | ## Setup
10 |
11 | ## Example
12 |
13 | ```vue
14 |
28 |
29 |
39 |
40 |
41 |
42 | Basic Data Loader Example
43 | User: {{ route.params.id }}
44 |
45 |
46 | Controls
47 |
48 | Refetch
49 |
50 |
51 | Previous
54 | |
55 | Next
58 |
59 | State
60 |
61 |
62 | isLoading: {{ isLoading }}
63 |
64 | Error: {{ error }}
65 | {{ user == null ? String(user) : user }}
66 |
67 |
68 | ```
69 |
70 | ## SSR
71 |
72 | ## Nuxt
73 |
74 | ## Unresolved Questions
75 |
76 | - Should this basic version also track what is used in the route object, like [Svelte Data Loaders do](https://kit.svelte.dev/docs/load#rerunning-load-functions)?
77 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | import { access, constants } from 'node:fs/promises'
2 |
3 | /**
4 | * Maybe a promise maybe not
5 | * @internal
6 | */
7 | export type _Awaitable = T | PromiseLike
8 |
9 | /**
10 | * Creates a union type that still allows autocompletion for strings.
11 | *@internal
12 | */
13 | export type LiteralStringUnion =
14 | | LiteralType
15 | | (BaseType & Record)
16 |
17 | // for highlighting
18 | export const ts = String.raw
19 |
20 | export async function fileExists(filePath: string) {
21 | try {
22 | await access(filePath, constants.F_OK)
23 | return true
24 | } catch {
25 | return false
26 | }
27 | }
28 |
29 | /**
30 | * Pads a single-line string with spaces.
31 | *
32 | * @internal
33 | *
34 | * @param spaces The number of spaces to pad with.
35 | * @param str The string to pad, none if omitted.
36 | * @returns The padded string.
37 | */
38 | export function pad(spaces: number, str = ''): string {
39 | return ' '.repeat(spaces) + str
40 | }
41 |
42 | /**
43 | * Formats an array of union items as a multiline union type.
44 | *
45 | * @internal
46 | *
47 | * @param items The items to format.
48 | * @param spaces The number of spaces to indent each line.
49 | * @returns The formatted multiline union type.
50 | */
51 | export function formatMultilineUnion(items: string[], spaces: number): string {
52 | return (items.length ? items : ['never'])
53 | .map((s) => `| ${s}`)
54 | .join(`\n${pad(spaces)}`)
55 | }
56 |
57 | /**
58 | * Converts a string value to a TS string literal type.
59 | *
60 | * @internal
61 | *
62 | * @param str the string to convert to a string type
63 | * @returns The string wrapped in single quotes.
64 | * @example
65 | * stringToStringType('hello') // returns "'hello'"
66 | */
67 | export function stringToStringType(str: string): string {
68 | return `'${str}'`
69 | }
70 |
--------------------------------------------------------------------------------
/playground/src/pages/users/colada-loader.[id].vue:
--------------------------------------------------------------------------------
1 |
32 |
33 |
34 |
35 | Pinia Colada Loader
36 | User: {{ route.params.id }}
37 |
38 |
39 | Controls
40 |
41 |
42 | Throw on Fetch
43 |
44 |
45 | Refresh
46 | Refetch
47 | Copy
48 |
49 |
50 | Previous
53 | |
54 | Next
57 | |
58 | Random query
59 |
60 | PC 🍍🍹
61 |
62 |
63 | status: {{ status }}
64 | |
65 | asyncStatus: {{ asyncStatus }}
66 |
67 | isFetching: {{ isLoading }}
68 |
69 | Error: {{ error }}
70 | {{ user == null ? String(user) : user }}
71 |
72 |
73 |
--------------------------------------------------------------------------------
/examples/nuxt/pages/users/colada-[userId].vue:
--------------------------------------------------------------------------------
1 |
24 |
25 |
29 |
30 |
31 |
32 |
Refresh
33 |
Refetch
34 |
35 |
36 |
User {{ route.params.userId }}
37 |
An error ocurred: {{ user.error.message }}
38 |
39 |
40 |
Display all state
41 |
42 |
Loading fresh data...
43 |
{{ user.data }}
44 |
45 |
46 |
47 |
Display one of them
48 |
49 |
50 | Loading fresh data...
51 | {{ user.data }}
52 |
53 |
54 | Loading fresh data...
55 |
56 |
Something went wrong...
57 |
{{ user.error }}
58 |
Retry
59 |
60 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/playground/src/pages/users/tq-query-bug.vue:
--------------------------------------------------------------------------------
1 |
54 |
55 |
56 |
64 |
65 | all
66 | One
67 | Two
68 |
69 |
70 | {{ post.title }} {{ post.type }}
71 |
72 |
73 | Patch
74 |
75 |
--------------------------------------------------------------------------------
/playground/src/pages/users/tq-infinite-query.vue:
--------------------------------------------------------------------------------
1 |
53 |
54 |
55 |
56 |
57 | Load more (or scroll down)
58 |
59 |
60 | We have loaded {{ facts.pages.length }} facts
61 |
62 | Show raw
63 | {{ facts }}
64 |
65 |
66 |
67 | {{ fact }}
68 |
69 |
70 | Loading more...
71 |
72 |
73 |
74 |
75 |
76 |
--------------------------------------------------------------------------------
/e2e/routes.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createRoutesContext } from '../src/core/context'
3 | import { resolveOptions } from '../src/options'
4 | import { fileURLToPath, URL } from 'node: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 | describe('e2e routes', () => {
14 | it('generates the routes', async () => {
15 | const context = createRoutesContext(
16 | resolveOptions({
17 | // dts: join(__dirname, './.types/__types.d.ts'),
18 | dts: false,
19 | logs: false,
20 | watch: false,
21 | routesFolder: [{ src: join(__dirname, './fixtures/filenames/routes') }],
22 | })
23 | )
24 |
25 | await context.scanPages()
26 | expect(
27 | context
28 | .generateRoutes()
29 | .replace(
30 | /import\(["'](.+?)["']\)/g,
31 | (_, filePath) => `import('${normalize(filePath)}')`
32 | )
33 | .replace(/(import\(["'])(?:.+?)fixtures\/filenames/gi, '$1')
34 | ).toMatchSnapshot()
35 | })
36 |
37 | it.skip('works with mixed extensions', async () => {
38 | const context = createRoutesContext(
39 | resolveOptions({
40 | dts: false,
41 | logs: false,
42 | watch: false,
43 | routesFolder: [
44 | {
45 | src: join(__dirname, './fixtures/filenames/multi-extensions'),
46 | exclude: join(
47 | __dirname,
48 | './fixtures/filenames/multi-extensions/docs'
49 | ),
50 | },
51 | {
52 | src: join(__dirname, './fixtures/filenames/multi-extensions/docs'),
53 | extensions: ['.md', '.vue'],
54 | path: 'docs/[lang]/',
55 | },
56 | ],
57 | })
58 | )
59 |
60 | await context.scanPages()
61 | expect(
62 | context
63 | .generateRoutes()
64 | .replace(
65 | /import\(["'](.+?)["']\)/g,
66 | (_, filePath) => `import('${normalize(filePath)}')`
67 | )
68 | .replace(/(import\(["'])(?:.+?)fixtures\/filenames/gi, '$1')
69 | ).toMatchSnapshot()
70 | })
71 | })
72 |
--------------------------------------------------------------------------------
/src/volar/entries/sfc-route-blocks.ts:
--------------------------------------------------------------------------------
1 | import { allCodeFeatures, type VueLanguagePlugin } from '@vue/language-core'
2 | import { replace, toString } from 'muggle-string'
3 |
4 | const plugin: VueLanguagePlugin = () => {
5 | const routeBlockIdPrefix = 'route_'
6 | const routeBlockIdRe = new RegExp(`^${routeBlockIdPrefix}(\\d+)$`)
7 |
8 | return {
9 | version: 2.1,
10 | getEmbeddedCodes(_fileName, sfc) {
11 | const embeddedCodes = []
12 |
13 | // we add an embedded code for every route block we find with the same index as the block
14 | for (let i = 0; i < sfc.customBlocks.length; i++) {
15 | const block = sfc.customBlocks[i]!
16 |
17 | // TODO:
18 | // `` blocks without `lang="json"` are still interpreted as text right now.
19 | // See: https://github.com/vuejs/language-tools/issues/185#issuecomment-1173742726
20 | // This seems to be because `custom_block_x` is still seen as txt, even though the corresponding `route_x` is json.
21 | if (block.type === 'route') {
22 | const lang = block.lang === 'txt' ? 'json' : block.lang
23 | embeddedCodes.push({ id: `${routeBlockIdPrefix}${i}`, lang })
24 | }
25 | }
26 |
27 | return embeddedCodes
28 | },
29 | resolveEmbeddedCode(_fileName, sfc, embeddedCode) {
30 | const match = embeddedCode.id.match(routeBlockIdRe)
31 |
32 | if (match) {
33 | const i = parseInt(match[1]!)
34 | const block = sfc.customBlocks[i]
35 |
36 | // this shouldn't happen, but just in case
37 | if (!block) {
38 | return
39 | }
40 |
41 | embeddedCode.content.push([
42 | block.content,
43 | block.name,
44 | 0,
45 | allCodeFeatures,
46 | ])
47 |
48 | if (embeddedCode.lang === 'json') {
49 | const contentStr = toString(embeddedCode.content)
50 | if (
51 | contentStr.trim().startsWith('{') &&
52 | !contentStr.includes('$schema')
53 | ) {
54 | replace(
55 | embeddedCode.content,
56 | '{',
57 | '{\n "$schema": "https://raw.githubusercontent.com/posva/unplugin-vue-router/main/route.schema.json",'
58 | )
59 | }
60 | }
61 | }
62 | },
63 | }
64 | }
65 |
66 | export default plugin
67 |
--------------------------------------------------------------------------------
/src/core/utils.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { joinPath, 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 | describe('joinPath', () => {
20 | it('joins paths', () => {
21 | expect(joinPath('/foo', 'bar')).toBe('/foo/bar')
22 | expect(joinPath('/foo', 'bar', 'baz')).toBe('/foo/bar/baz')
23 | expect(joinPath('/foo', 'bar', 'baz', 'qux')).toBe('/foo/bar/baz/qux')
24 | expect(joinPath('/foo', 'bar', 'baz', 'qux', 'quux')).toBe(
25 | '/foo/bar/baz/qux/quux'
26 | )
27 | })
28 |
29 | it('adds a leading slash if missing', () => {
30 | expect(joinPath('foo')).toBe('/foo')
31 | expect(joinPath('foo', '')).toBe('/foo')
32 | expect(joinPath('foo', 'bar')).toBe('/foo/bar')
33 | expect(joinPath('foo', 'bar', 'baz')).toBe('/foo/bar/baz')
34 | })
35 |
36 | it('works with empty paths', () => {
37 | expect(joinPath('', '', '', '')).toBe('/')
38 | expect(joinPath('', '/', '', '')).toBe('/')
39 | expect(joinPath('', '/', '', '/')).toBe('/')
40 | expect(joinPath('', '/', '/', '/')).toBe('/')
41 | expect(joinPath('/', '', '', '')).toBe('/')
42 | })
43 |
44 | it('collapses slashes', () => {
45 | expect(joinPath('/foo/', 'bar')).toBe('/foo/bar')
46 | expect(joinPath('/foo', 'bar')).toBe('/foo/bar')
47 | expect(joinPath('/foo', 'bar/', 'foo')).toBe('/foo/bar/foo')
48 | expect(joinPath('/foo', 'bar', 'foo')).toBe('/foo/bar/foo')
49 | })
50 |
51 | it('keeps trailing slashes', () => {
52 | expect(joinPath('/foo', 'bar/')).toBe('/foo/bar/')
53 | expect(joinPath('/foo/', 'bar/')).toBe('/foo/bar/')
54 | expect(joinPath('/foo/', 'bar', 'baz/')).toBe('/foo/bar/baz/')
55 | expect(joinPath('/foo/', 'bar/', 'baz/')).toBe('/foo/bar/baz/')
56 | })
57 | })
58 | })
59 |
--------------------------------------------------------------------------------
/src/core/customBlock.ts:
--------------------------------------------------------------------------------
1 | import { SFCBlock, parse } from '@vue/compiler-sfc'
2 | import { ResolvedOptions } from '../options'
3 | import JSON5 from 'json5'
4 | import { parse as YAMLParser } from 'yaml'
5 | import { RouteRecordRaw } from 'vue-router'
6 | import { warn } from './utils'
7 | import type { DefinePageQueryParamOptions } from '../runtime'
8 |
9 | export function getRouteBlock(
10 | path: string,
11 | content: string,
12 | options: ResolvedOptions
13 | ) {
14 | const parsedSFC = parse(content, { pad: 'space' }).descriptor
15 | const blockStr = parsedSFC?.customBlocks.find((b) => b.type === 'route')
16 |
17 | if (blockStr) return parseCustomBlock(blockStr, path, options)
18 | }
19 |
20 | export interface CustomRouteBlock extends Partial<
21 | Omit<
22 | RouteRecordRaw,
23 | 'components' | 'component' | 'children' | 'beforeEnter' | 'name'
24 | >
25 | > {
26 | name?: string | undefined | false
27 |
28 | params?: {
29 | path?: Record
30 |
31 | query?: Record
32 | }
33 | }
34 |
35 | export interface CustomRouteBlockQueryParamOptions {
36 | parser?: string
37 | format?: DefinePageQueryParamOptions['format']
38 | // TODO: queryKey?: string
39 | default?: string
40 | }
41 |
42 | function parseCustomBlock(
43 | block: SFCBlock,
44 | filePath: string,
45 | options: ResolvedOptions
46 | ): CustomRouteBlock | void {
47 | const lang = block.lang ?? options.routeBlockLang
48 |
49 | if (lang === 'json5') {
50 | try {
51 | return JSON5.parse(block.content)
52 | } catch (err: any) {
53 | warn(
54 | `Invalid JSON5 format of <${block.type}> content in ${filePath}\n${err.message}`
55 | )
56 | }
57 | } else if (lang === 'json') {
58 | try {
59 | return JSON.parse(block.content)
60 | } catch (err: any) {
61 | warn(
62 | `Invalid JSON format of <${block.type}> content in ${filePath}\n${err.message}`
63 | )
64 | }
65 | } else if (lang === 'yaml' || lang === 'yml') {
66 | try {
67 | return YAMLParser(block.content)
68 | } catch (err: any) {
69 | warn(
70 | `Invalid YAML format of <${block.type}> content in ${filePath}\n${err.message}`
71 | )
72 | }
73 | } else {
74 | warn(
75 | `Language "${lang}" for <${block.type}> is not supported. Supported languages are: json5, json, yaml, yml. Found in in ${filePath}.`
76 | )
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/playground/src/pages/users/pinia-colada.[id].vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
50 |
51 |
52 |
53 | raw Pinia Colada
54 | User: {{ route.params.id }}
55 |
56 |
57 | Controls
58 |
59 |
60 | Throw on Fetch
61 |
62 |
63 | Refresh
64 | Refetch
65 | Copy
66 |
67 |
68 | Previous
71 | |
72 | Next
75 |
76 | PC 🍍
77 |
78 |
79 | status: {{ status }}
80 |
81 | isFetching: {{ pcIsFetching }}
82 |
83 |
84 |
85 | Error: {{ pcError }}
86 | {{ pcUSer == null ? String(pcUSer) : pcUSer }}
87 |
88 |
89 |
--------------------------------------------------------------------------------
/docs/guide/configuration.md:
--------------------------------------------------------------------------------
1 | # Configuration
2 |
3 | Have a glimpse of all the existing configuration options with their corresponding **default values**:
4 |
5 | ```ts twoslash
6 | import type { TreeNode } from 'unplugin-vue-router/types'
7 | const myOwnGenerateRouteName = (routeNode: TreeNode) => {
8 | return 'ok'
9 | }
10 | import { isPackageExists as isPackageInstalled } from 'local-pkg'
11 | function getFileBasedRouteName(routeNode: TreeNode) {
12 | return 'ok'
13 | }
14 | // const process = { cwd: () => '' }
15 | // ---cut---
16 | // @moduleResolution: bundler
17 | import VueRouter from 'unplugin-vue-router/vite'
18 |
19 | VueRouter({
20 | // how and what folders to scan for files
21 | routesFolder: [
22 | {
23 | src: 'src/pages',
24 | path: '',
25 | // override globals
26 | exclude: (excluded) => excluded,
27 | filePatterns: (filePatterns) => filePatterns,
28 | extensions: (extensions) => extensions,
29 | },
30 | ],
31 |
32 | // what files should be considered as a pages
33 | extensions: ['.vue'],
34 |
35 | // what files to include
36 | filePatterns: ['**/*'],
37 |
38 | // files to exclude from the scan
39 | exclude: [],
40 |
41 | // where to generate the types
42 | dts: './typed-router.d.ts',
43 |
44 | // how to generate the route name
45 | getRouteName: (routeNode) => getFileBasedRouteName(routeNode),
46 |
47 | // default language for custom blocks
48 | routeBlockLang: 'json5',
49 |
50 | // how to import routes, can also be a string
51 | importMode: 'async',
52 |
53 | // where are paths relative to
54 | root: process.cwd(),
55 |
56 | // options for the path parser
57 | pathParser: {
58 | // should `users.[id]` be parsed as `users/:id`?
59 | dotNesting: true,
60 | },
61 |
62 | // modify routes individually
63 | async extendRoute(route) {
64 | // ...
65 | },
66 |
67 | // modify routes before writing
68 | async beforeWriteFiles(rootRoute) {
69 | // ...
70 | },
71 | })
72 | ```
73 |
74 | ::: tip
75 | Highlight any of the options to see more details about it.
76 | :::
77 |
78 | ## SSR
79 |
80 | It might be necessary to mark `vue-router` as `noExternal` in your `vite.config.js` in development mode:
81 |
82 | ```ts{7}
83 | import { defineConfig } from 'vite'
84 | import Vue from '@vitejs/plugin-vue'
85 | import VueRouter from 'unplugin-vue-router/vite'
86 |
87 | export default defineConfig(({ mode }) => ({
88 | ssr: {
89 | noExternal: mode === 'development' ? ['vue-router'] : [],
90 | },
91 | plugins: [VueRouter(), Vue()],
92 | }))
93 | ```
94 |
--------------------------------------------------------------------------------
/src/data-loaders/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 |
--------------------------------------------------------------------------------
/playground/src/pages/users/[id].vue:
--------------------------------------------------------------------------------
1 |
42 |
43 |
63 |
64 |
65 |
66 | defineBasicLoader()
67 |
68 | User: {{ route.params.id }}
69 | {{ MY_VAL }}
70 |
71 | Previous
74 | |
75 | Next
78 |
79 | Loading...
80 | Error: {{ error || String(error) }}
81 | {{ user }}
82 |
83 |
84 |
85 |
86 | const a = 20 as 20 | 30
87 |
88 | console.log('WITHIN ROUTE_BLOCK', a)
89 |
90 | export default {
91 | alias: '/u/:id',
92 | meta: {
93 | a,
94 | other: 'other',
95 | },
96 | }
97 |
98 |
--------------------------------------------------------------------------------
/src/codegen/__snapshots__/generateRouteResolver.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`generateRouteResolver > does not encode RFC 3986 valid path characters 1`] = `
4 | "
5 | const __route_0 = normalizeRouteRecord({
6 | name: '/@profile',
7 | path: new MatcherPatternPathStatic('/@profile'),
8 | components: {
9 | 'default': () => import('@profile.vue')
10 | },
11 | })
12 |
13 | const __route_1 = normalizeRouteRecord({
14 | name: '/foo*bar',
15 | path: new MatcherPatternPathStatic('/foo*bar'),
16 | components: {
17 | 'default': () => import('foo*bar.vue')
18 | },
19 | })
20 |
21 | const __route_2 = normalizeRouteRecord({
22 | name: '/hello!',
23 | path: new MatcherPatternPathStatic('/hello!'),
24 | components: {
25 | 'default': () => import('hello!.vue')
26 | },
27 | })
28 |
29 | const __route_3 = normalizeRouteRecord({
30 | name: '/it's-fine',
31 | path: new MatcherPatternPathStatic('/it's-fine'),
32 | components: {
33 | 'default': () => import('it's-fine.vue')
34 | },
35 | })
36 |
37 | const __route_4 = normalizeRouteRecord({
38 | name: '/item(1)',
39 | path: new MatcherPatternPathStatic('/item(1)'),
40 | components: {
41 | 'default': () => import('item(1).vue')
42 | },
43 | })
44 |
45 | const __route_5 = normalizeRouteRecord({
46 | name: '/user',
47 | path: new MatcherPatternPathStatic('/user'),
48 | components: {
49 | 'domain': () => import('user@domain.vue')
50 | },
51 | })
52 |
53 | export const resolver = createFixedResolver([
54 | __route_0, // /@profile
55 | __route_1, // /foo*bar
56 | __route_2, // /hello!
57 | __route_3, // /it's-fine
58 | __route_4, // /item(1)
59 | __route_5, // /user
60 | ])
61 | "
62 | `;
63 |
64 | exports[`generateRouteResolver > encodes special characters in route resolver paths 1`] = `
65 | "
66 | const __route_0 = normalizeRouteRecord({
67 | name: '/café',
68 | path: new MatcherPatternPathStatic('/caf%C3%A9'),
69 | components: {
70 | 'default': () => import('café.vue')
71 | },
72 | })
73 |
74 | const __route_1 = normalizeRouteRecord({
75 | name: '/my page',
76 | path: new MatcherPatternPathStatic('/my%20page'),
77 | components: {
78 | 'default': () => import('my page.vue')
79 | },
80 | })
81 |
82 | const __route_2 = normalizeRouteRecord({
83 | name: '/users/hello world',
84 | path: new MatcherPatternPathStatic('/users/hello%20world'),
85 | components: {
86 | 'default': () => import('users/hello world.vue')
87 | },
88 | })
89 |
90 | export const resolver = createFixedResolver([
91 | __route_2, // /users/hello%20world
92 | __route_0, // /caf%C3%A9
93 | __route_1, // /my%20page
94 | ])
95 | "
96 | `;
97 |
--------------------------------------------------------------------------------
/src/codegen/generateDTS.ts:
--------------------------------------------------------------------------------
1 | import { ts, pad } from '../utils'
2 |
3 | /**
4 | * Removes empty lines and indent by two spaces to match the rest of the file.
5 | */
6 | function normalizeLines(code: string) {
7 | return (
8 | code
9 | .split('\n')
10 | // FIXME: the code should be cleaned up by the codegen functions. Removing empty lines here
11 | // reduces readability of the route file info map.
12 | .filter((line) => line.length !== 0)
13 | .map((line) => pad(2, line))
14 | .join('\n')
15 | )
16 | }
17 |
18 | export function generateDTS({
19 | routesModule,
20 | routeNamedMap,
21 | routeFileInfoMap,
22 | paramsTypesDeclaration,
23 | customParamsType,
24 | }: {
25 | routesModule: string
26 | routeNamedMap: string
27 | routeFileInfoMap: string
28 | paramsTypesDeclaration: string
29 | customParamsType: string
30 | }) {
31 | return ts`
32 | /* eslint-disable */
33 | /* prettier-ignore */
34 | // @ts-nocheck
35 | // noinspection ES6UnusedImports
36 | // Generated by unplugin-vue-router. !! DO NOT MODIFY THIS FILE !!
37 | // It's recommended to commit this file.
38 | // Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
39 |
40 | ${
41 | paramsTypesDeclaration
42 | ? `
43 | // Custom route params parsers
44 | ${paramsTypesDeclaration}
45 |
46 | `.trimStart()
47 | : ''
48 | }declare module 'vue-router/auto-resolver' {
49 | export type ParamParserCustom = ${customParamsType}
50 | }
51 |
52 | declare module '${routesModule}' {
53 | import type {
54 | RouteRecordInfo,
55 | ParamValue,
56 | ParamValueOneOrMore,
57 | ParamValueZeroOrMore,
58 | ParamValueZeroOrOne,
59 | } from 'vue-router'
60 |
61 | /**
62 | * Route name map generated by unplugin-vue-router
63 | */
64 | ${normalizeLines(routeNamedMap)}
65 |
66 | /**
67 | * Route file to route info map by unplugin-vue-router.
68 | * Used by the \`sfc-typed-router\` Volar plugin to automatically type \`useRoute()\`.
69 | *
70 | * Each key is a file path relative to the project root with 2 properties:
71 | * - routes: union of route names of the possible routes when in this page (passed to useRoute<...>())
72 | * - views: names of nested views (can be passed to )
73 | *
74 | * @internal
75 | */
76 | ${normalizeLines(routeFileInfoMap)}
77 |
78 | /**
79 | * Get a union of possible route names in a certain route component file.
80 | * Used by the \`sfc-typed-router\` Volar plugin to automatically type \`useRoute()\`.
81 | *
82 | * @internal
83 | */
84 | export type _RouteNamesForFilePath =
85 | _RouteFileInfoMap extends Record
86 | ? Info['routes']
87 | : keyof RouteNamedMap
88 | }
89 | `.trimStart()
90 | }
91 |
--------------------------------------------------------------------------------
/src/data-loaders/meta-extensions.test-d.ts:
--------------------------------------------------------------------------------
1 | import { describe, expectTypeOf, it } from 'vitest'
2 | import { defineComponent } from 'vue'
3 | import { createRouter, createMemoryHistory } from 'vue-router'
4 | import { defineBasicLoader } from './defineLoader'
5 | import type { UseDataLoader } from './createDataLoader'
6 |
7 | describe('meta-extensions', () => {
8 | it('works when adding routes', () => {
9 | const component = defineComponent({})
10 | const router = createRouter({
11 | history: createMemoryHistory(),
12 | routes: [
13 | // empty
14 | {
15 | path: '/',
16 | component,
17 | meta: {
18 | loaders: [],
19 | },
20 | },
21 |
22 | // mixed
23 | {
24 | path: '/',
25 | component,
26 | meta: {
27 | loaders: [
28 | defineBasicLoader(async () => ({ name: 'foo' })),
29 | defineBasicLoader(async () => ({ name: 'foo' }), {}),
30 | defineBasicLoader(async () => ({ name: 'foo' }), { lazy: true }),
31 | defineBasicLoader(async () => ({ name: 'foo' }), { lazy: false }),
32 | ],
33 | },
34 | },
35 |
36 | // only lazy: true
37 | {
38 | path: '/',
39 | component,
40 | meta: {
41 | loaders: [
42 | defineBasicLoader(async () => ({ name: 'foo' }), { lazy: true }),
43 | defineBasicLoader(async () => ({ name: 'foo' }), { lazy: true }),
44 | ],
45 | },
46 | },
47 |
48 | // only lazy: false
49 | {
50 | path: '/',
51 | component,
52 | meta: {
53 | loaders: [
54 | defineBasicLoader(async () => ({ name: 'foo' }), { lazy: false }),
55 | defineBasicLoader(async () => ({ name: 'foo' }), { lazy: false }),
56 | ],
57 | },
58 | },
59 | ],
60 | })
61 |
62 | router.addRoute({
63 | path: '/',
64 | component,
65 | meta: {
66 | loaders: [
67 | defineBasicLoader(async () => ({ name: 'foo' }), { lazy: false }),
68 | defineBasicLoader(async () => ({ name: 'foo' }), { lazy: false }),
69 | ],
70 | },
71 | })
72 |
73 | router.addRoute({
74 | path: '/',
75 | component,
76 | meta: {
77 | loaders: [
78 | defineBasicLoader(async () => ({ name: 'foo' }), { lazy: true }),
79 | defineBasicLoader(async () => ({ name: 'foo' }), { lazy: true }),
80 | ],
81 | },
82 | })
83 | })
84 |
85 | it('works when checking the type of meta', () => {
86 | const router = createRouter({
87 | history: createMemoryHistory(),
88 | routes: [],
89 | })
90 |
91 | expectTypeOf(
92 | router.currentRoute.value.meta.loaders
93 | )
94 | })
95 | })
96 |
--------------------------------------------------------------------------------
/src/codegen/generateRouteMap.ts:
--------------------------------------------------------------------------------
1 | import type { TreeNode, TreeNodeNamed } from '../core/tree'
2 | import type { ResolvedOptions } from '../options'
3 | import { generateParamsTypes, ParamParsersMap } from './generateParamParsers'
4 | import {
5 | EXPERIMENTAL_generateRouteParams,
6 | generateRouteParams,
7 | } from './generateRouteParams'
8 | import { pad, formatMultilineUnion, stringToStringType } from '../utils'
9 |
10 | export function generateRouteNamedMap(
11 | node: TreeNode,
12 | options: ResolvedOptions,
13 | paramParsersMap: ParamParsersMap
14 | ): string {
15 | if (node.isRoot()) {
16 | return `export interface RouteNamedMap {
17 | ${node
18 | .getChildrenSorted()
19 | .map((n) => generateRouteNamedMap(n, options, paramParsersMap))
20 | .join('')}}`
21 | }
22 |
23 | return (
24 | // if the node has a filePath, it's a component, it has a routeName and it should be referenced in the RouteNamedMap
25 | // otherwise it should be skipped to avoid navigating to a route that doesn't render anything
26 | (node.value.components.size && node.isNamed()
27 | ? pad(
28 | 2,
29 | `${stringToStringType(node.name)}: ${generateRouteRecordInfo(node, options, paramParsersMap)},\n`
30 | )
31 | : '') +
32 | (node.children.size > 0
33 | ? node
34 | .getChildrenSorted()
35 | .map((n) => generateRouteNamedMap(n, options, paramParsersMap))
36 | .join('\n')
37 | : '')
38 | )
39 | }
40 |
41 | export function generateRouteRecordInfo(
42 | node: TreeNodeNamed,
43 | options: ResolvedOptions,
44 | paramParsersMap: ParamParsersMap
45 | ): string {
46 | let paramParsers: Array = []
47 | if (options.experimental.paramParsers) {
48 | paramParsers = generateParamsTypes(node.params, paramParsersMap)
49 | }
50 |
51 | const typeParams = [
52 | stringToStringType(node.name),
53 | stringToStringType(node.fullPath),
54 | options.experimental.paramParsers
55 | ? EXPERIMENTAL_generateRouteParams(node, paramParsers, true)
56 | : generateRouteParams(node, true),
57 | options.experimental.paramParsers
58 | ? EXPERIMENTAL_generateRouteParams(node, paramParsers, false)
59 | : generateRouteParams(node, false),
60 | ]
61 |
62 | const childRouteNames: string[] =
63 | node.children.size > 0
64 | ? // TODO: remove Array.from() once Node 20 support is dropped
65 | Array.from(node.getChildrenDeep())
66 | // skip routes that are not added to the types
67 | .reduce((acc, childRoute) => {
68 | if (childRoute.value.components.size && childRoute.isNamed()) {
69 | acc.push(childRoute.name)
70 | }
71 | return acc
72 | }, [])
73 | .sort()
74 | : []
75 |
76 | typeParams.push(
77 | formatMultilineUnion(childRouteNames.map(stringToStringType), 4)
78 | )
79 |
80 | return `RouteRecordInfo<
81 | ${typeParams.map((line) => pad(4, line)).join(',\n')}
82 | >`
83 | }
84 |
--------------------------------------------------------------------------------
/src/data-loaders/meta-extensions.ts:
--------------------------------------------------------------------------------
1 | import type { EffectScope, App } from 'vue'
2 | import type { DataLoaderEntryBase, UseDataLoader } from './createDataLoader'
3 | import type {
4 | APP_KEY,
5 | LOADER_ENTRIES_KEY,
6 | LOADER_SET_KEY,
7 | PENDING_LOCATION_KEY,
8 | ABORT_CONTROLLER_KEY,
9 | NAVIGATION_RESULTS_KEY,
10 | IS_SSR_KEY,
11 | DATA_LOADERS_EFFECT_SCOPE_KEY,
12 | } from './symbols'
13 | import { type NavigationResult } from './navigation-guard'
14 | import { type RouteLocationNormalizedLoaded } from 'vue-router'
15 |
16 | /**
17 | * Map type for the entries used by data loaders.
18 | * @internal
19 | */
20 | export type _DefineLoaderEntryMap<
21 | DataLoaderEntry extends DataLoaderEntryBase =
22 | DataLoaderEntryBase,
23 | > = WeakMap<
24 | // Depending on the `defineLoader()` they might use a different thing as key
25 | // e.g. an function for basic defineLoader, a doc instance for VueFire
26 | object,
27 | DataLoaderEntry
28 | >
29 |
30 | // we want to import from this meta extensions to include the changes to route
31 | export * from './symbols'
32 |
33 | declare module 'vue-router' {
34 | export interface Router {
35 | /**
36 | * The entries used by data loaders. Put on the router for convenience.
37 | * @internal
38 | */
39 | [LOADER_ENTRIES_KEY]: _DefineLoaderEntryMap
40 |
41 | /**
42 | * Pending navigation that is waiting for data loaders to resolve.
43 | * @internal
44 | */
45 | [PENDING_LOCATION_KEY]: RouteLocationNormalizedLoaded | null
46 |
47 | /**
48 | * The app instance that is used by the router.
49 | * @internal
50 | */
51 | [APP_KEY]: App
52 |
53 | /**
54 | * Whether the router is running in server-side rendering mode.
55 | * @internal
56 | */
57 | [IS_SSR_KEY]: boolean
58 |
59 | /**
60 | * The effect scope used to run data loaders.
61 | * @internal
62 | */
63 | [DATA_LOADERS_EFFECT_SCOPE_KEY]: EffectScope
64 | }
65 |
66 | export interface RouteMeta {
67 | /**
68 | * The data loaders for a route record. Add any data loader to this array to have it called when the route is
69 | * navigated to. Note this is only needed when **not** using lazy components (`() => import('./pages/Home.vue')`) or
70 | * when not explicitly exporting data loaders from page components.
71 | */
72 | loaders?: UseDataLoader[]
73 |
74 | /**
75 | * Set of loaders for the current route. This is built once during navigation and is used to merge the loaders from
76 | * the lazy import in components or the `loaders` array in the route record.
77 | * @internal
78 | */
79 | [LOADER_SET_KEY]?: Set
80 |
81 | /**
82 | * The signal that is aborted when the navigation is canceled or an error occurs.
83 | * @internal
84 | */
85 | [ABORT_CONTROLLER_KEY]?: AbortController
86 |
87 | /**
88 | * The navigation results when the navigation is canceled by the user within a data loader.
89 | * @internal
90 | */
91 | [NAVIGATION_RESULTS_KEY]?: NavigationResult[]
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/src/data-loaders/auto-exports.ts:
--------------------------------------------------------------------------------
1 | import { createFilter } from 'unplugin-utils'
2 | import type { Plugin } from 'vite'
3 | import MagicString from 'magic-string'
4 | import { findStaticImports, parseStaticImport } from 'mlly'
5 | import { resolve } from 'pathe'
6 | import { StringFilter, type UnpluginOptions } from 'unplugin'
7 |
8 | export function extractLoadersToExport(
9 | code: string,
10 | filterPaths: (id: string) => boolean,
11 | root: string
12 | ): string[] {
13 | const imports = findStaticImports(code)
14 | const importNames = imports.flatMap((i) => {
15 | const parsed = parseStaticImport(i)
16 |
17 | // since we run post-post, vite will add a leading slash to the specifier
18 | const specifier = resolve(
19 | root,
20 | parsed.specifier.startsWith('/')
21 | ? parsed.specifier.slice(1)
22 | : parsed.specifier
23 | )
24 |
25 | // bail out faster for anything that is not a data loader
26 | if (!filterPaths(specifier)) return []
27 |
28 | return [
29 | parsed.defaultImport,
30 | ...Object.values(parsed.namedImports || {}),
31 | ].filter((v): v is string => !!v && !v.startsWith('_'))
32 | })
33 |
34 | return importNames
35 | }
36 |
37 | const PLUGIN_NAME = 'unplugin-vue-router:data-loaders-auto-export'
38 |
39 | /**
40 | * {@link AutoExportLoaders} options.
41 | */
42 | export interface AutoExportLoadersOptions {
43 | /**
44 | * Filter page components to apply the auto-export. Passed to `transform.filter.id`.
45 | */
46 | transformFilter: StringFilter
47 |
48 | /**
49 | * Globs to match the paths of the loaders.
50 | */
51 | loadersPathsGlobs: string | string[]
52 |
53 | /**
54 | * Root of the project. All paths are resolved relatively to this one.
55 | * @default `process.cwd()`
56 | */
57 | root?: string
58 | }
59 |
60 | /**
61 | * Vite Plugin to automatically export loaders from page components.
62 | *
63 | * @param options Options
64 | * @experimental - This API is experimental and can be changed in the future. It's used internally by `experimental.autoExportsDataLoaders`
65 |
66 | */
67 | export function AutoExportLoaders({
68 | transformFilter,
69 | loadersPathsGlobs,
70 | root = process.cwd(),
71 | }: AutoExportLoadersOptions): Plugin {
72 | const filterPaths = createFilter(loadersPathsGlobs)
73 |
74 | return {
75 | name: PLUGIN_NAME,
76 | transform: {
77 | order: 'post',
78 | filter: {
79 | id: transformFilter,
80 | },
81 |
82 | handler(code) {
83 | const loadersToExports = extractLoadersToExport(code, filterPaths, root)
84 |
85 | if (loadersToExports.length <= 0) return
86 |
87 | const s = new MagicString(code)
88 | s.append(
89 | `\nexport const __loaders = [\n${loadersToExports.join(',\n')}\n];\n`
90 | )
91 |
92 | return {
93 | code: s.toString(),
94 | map: s.generateMap(),
95 | }
96 | },
97 | },
98 | }
99 | }
100 |
101 | export function createAutoExportPlugin(
102 | options: AutoExportLoadersOptions
103 | ): UnpluginOptions {
104 | return {
105 | name: PLUGIN_NAME,
106 | vite: AutoExportLoaders(options),
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/e2e/hmr/fixtures/vite-server.ts:
--------------------------------------------------------------------------------
1 | import { test as base, expect } from '@playwright/test'
2 | import { createServer, type ViteDevServer } from 'vite'
3 | import { type AddressInfo } from 'node:net'
4 | import path from 'node:path'
5 | import fs from 'node:fs'
6 | import { fileURLToPath } from 'node:url'
7 |
8 | type ViteFixtures = {
9 | devServer: ViteDevServer
10 | baseURL: string
11 | projectRoot: string
12 | applyEditFile: (sourceFilePath: string, newContentFilePath: string) => void
13 | playgroundName: string
14 | }
15 |
16 | export const test = base.extend({
17 | // @ts-expect-error: all options are scoped per worker
18 | playgroundName: ['', { scope: 'worker', option: true }],
19 |
20 | // @ts-expect-error: we need to compute projectRoot per worker
21 | projectRoot: [
22 | async ({ playgroundName }, use, testInfo) => {
23 | const fixtureDir = fileURLToPath(
24 | new URL(
25 | `../playground-tmp-${playgroundName}-worker-${testInfo.workerIndex}`,
26 | import.meta.url
27 | )
28 | )
29 | const projectRoot = path.resolve(fixtureDir)
30 | await use(projectRoot)
31 | },
32 | { scope: 'worker' },
33 | ],
34 |
35 | // @ts-expect-error: type matched what is passed to use(server)
36 | devServer: [
37 | async ({ projectRoot, playgroundName }, use) => {
38 | const fixtureDir = projectRoot
39 | const sourceDir = fileURLToPath(new URL(`../playground`, import.meta.url))
40 |
41 | fs.rmSync(fixtureDir, { force: true, recursive: true })
42 | fs.cpSync(sourceDir, fixtureDir, {
43 | recursive: true,
44 | filter: (src) => {
45 | return (
46 | !src.includes('.cache') &&
47 | !src.endsWith('.sock') &&
48 | !src.includes('.output') &&
49 | !src.includes('.vite')
50 | )
51 | },
52 | })
53 | // Start a real Vite dev server with your plugin(s) & config.
54 | // If you already have vite.config.ts, omit configFile:false and rely on it.
55 | const server = await createServer({
56 | configFile: path.join(fixtureDir, `vite.config.${playgroundName}.ts`),
57 | // If you need to inline the plugin directly, you could do:
58 | // configFile: false,
59 | // plugins: [myPlugin()],
60 | server: { host: '127.0.0.1', port: 0, strictPort: false }, // random open port
61 | logLevel: 'error',
62 | })
63 |
64 | await server.listen()
65 |
66 | const http = server.httpServer
67 | if (!http) throw new Error('No httpServer from Vite')
68 |
69 | // Expose the running server & URL to tests
70 | await use(server)
71 |
72 | await server.close()
73 | },
74 | { scope: 'worker' },
75 | ],
76 |
77 | baseURL: async ({ devServer }, use) => {
78 | const http = devServer.httpServer!
79 | const addr = http.address() as AddressInfo
80 | await use(`http://127.0.0.1:${addr.port}`)
81 | },
82 |
83 | applyEditFile: async ({ projectRoot }, use) => {
84 | await use(function applyEdit(
85 | sourceFilePath: string,
86 | newContentFilePath: string
87 | ) {
88 | fs.writeFileSync(
89 | path.join(projectRoot, sourceFilePath),
90 | fs.readFileSync(path.join(projectRoot, newContentFilePath), 'utf8'),
91 | 'utf8'
92 | )
93 | })
94 | },
95 | })
96 |
97 | export { expect }
98 |
--------------------------------------------------------------------------------
/src/core/RoutesFolderWatcher.spec.ts:
--------------------------------------------------------------------------------
1 | import {
2 | describe,
3 | expect,
4 | it,
5 | vi,
6 | beforeAll,
7 | type Mock,
8 | afterAll,
9 | } from 'vitest'
10 | import {
11 | HandlerContext,
12 | RoutesFolderWatcher,
13 | resolveFolderOptions,
14 | } from './RoutesFolderWatcher'
15 | import { resolveOptions, RoutesFolderOption } from '../options'
16 | import pathe from 'pathe'
17 | import fs from 'node:fs/promises'
18 | import { tmpdir } from 'node:os'
19 | import { type FSWatcher } from 'chokidar'
20 |
21 | const FIXTURES_ROOT = pathe.resolve(
22 | pathe.join(tmpdir(), 'vue-router-' + Date.now())
23 | )
24 |
25 | const TEST_TIMEOUT = 4000
26 |
27 | describe('RoutesFolderWatcher', () => {
28 | beforeAll(async () => {
29 | await fs.mkdir(FIXTURES_ROOT, { recursive: true })
30 | })
31 |
32 | // keep track of all watchers to close them after the tests
33 | let watcherList: RoutesFolderWatcher[] = []
34 | let testId = 0
35 | async function createWatcher(routesFolderOptions: RoutesFolderOption) {
36 | const rootDir = pathe.join(FIXTURES_ROOT, `test-${testId++}`)
37 | const srcDir = pathe.join(rootDir, routesFolderOptions.src)
38 | const options = resolveFolderOptions(
39 | resolveOptions({ root: rootDir, watch: false }),
40 | routesFolderOptions
41 | )
42 |
43 | await fs.mkdir(srcDir, { recursive: true })
44 |
45 | const watcher = new RoutesFolderWatcher(options)
46 | await waitForWatcher(watcher.watcher)
47 | watcherList.push(watcher)
48 |
49 | return { watcher, options, rootDir, srcDir }
50 | }
51 |
52 | afterAll(async () => {
53 | await Promise.all(watcherList.map((watcher) => watcher.close()))
54 | watcherList = []
55 | })
56 |
57 | function waitForSpy(...spies: Mock[]) {
58 | if (spies.length < 1) {
59 | throw new Error('No spies provided')
60 | }
61 |
62 | return new Promise((resolve, reject) => {
63 | const checkInterval = setInterval(() => {
64 | if (spies.every((spy) => spy.mock.calls.length > 0)) {
65 | clearInterval(checkInterval)
66 | clearTimeout(checkTimeout)
67 | resolve()
68 | }
69 | }, 20)
70 | const checkTimeout = setTimeout(() => {
71 | clearInterval(checkInterval)
72 | clearTimeout(checkTimeout)
73 | reject(new Error('Spy was not called'))
74 | }, TEST_TIMEOUT)
75 | })
76 | }
77 |
78 | function waitForWatcher(watcher: FSWatcher) {
79 | return new Promise((resolve, reject) => {
80 | const timeout = setTimeout(() => {
81 | reject(new Error('timeout'))
82 | }, TEST_TIMEOUT)
83 | watcher.on('error', (...args) => {
84 | clearTimeout(timeout)
85 | reject(...args)
86 | })
87 | watcher.on('ready', (...args) => {
88 | clearTimeout(timeout)
89 | resolve(...args)
90 | })
91 | })
92 | }
93 |
94 | it('triggers when new pages are added', async () => {
95 | const { watcher, srcDir } = await createWatcher({ src: 'src/pages' })
96 |
97 | const add = vi.fn<(ctx: HandlerContext) => void>()
98 | // chokidar triggers change and/or add ???
99 | watcher.on('add', add)
100 | watcher.on('change', add)
101 |
102 | expect(add).toHaveBeenCalledTimes(0)
103 |
104 | await fs.writeFile(pathe.join(srcDir, 'a.vue'), '', 'utf-8')
105 |
106 | await waitForSpy(add)
107 | })
108 | })
109 |
--------------------------------------------------------------------------------
/src/data-loaders/tests-defineLoader.md:
--------------------------------------------------------------------------------
1 | ```ts
2 | /* eslint-disable */
3 | /* prettier-ignore */
4 | // @ts-nocheck
5 |
6 | import { DataLoaderEntryBase, createDataLoader } from './createDataLoader'
7 |
8 | export const defineVueFireLoader = createDataLoader({
9 | createEntry: (context) => {
10 | useCollection(...)
11 | },
12 | before: (context) => {
13 | // is this the first time?
14 | if (!context.entries.has) {
15 | }
16 | // isDirty: our loader depends on other loaders and one of them is dirty so we also are
17 | if (context.isDirty) {
18 | }
19 | },
20 | })
21 |
22 | const dl = createDataLoader({
23 | createEntry: (context, ...ExpectedArgs) => {
24 | },
25 | load: async (entry, context, ...ExpectedArgs) => {
26 | // called during navigation
27 | // should have access to the data entry created
28 | // can decide whether to call the loader or not
29 | if (entry.isDirty) {
30 | // can call an argument
31 | await loader({
32 | ...context,
33 | // add other stuff to context maybe
34 | })
35 | }
36 |
37 | // can collect dependencies by passing a proxy as the context
38 | )
39 | })
40 |
41 | dl(...ExpectedArgs, options)
42 | // this allows to accept different kind of values
43 | // e.g. vuefire wants just the function to generate an object or collection
44 | // appollo wants a gql query and then maybe a function to pass variables, etc
45 | // vue query
46 |
47 |
48 | // version with a whole collection, no dependency, no need to reexucute useCollection ever
49 | // but the loader could force refresh the data from time to time. Unlikely with Firebase though as they already handle the cache
50 | defineVueFireLoader('/documents', () => useCollection(...))
51 | // needs to be reexecuted when the id changes?
52 | defineVueFireLoader('/documents/[id]', (route) => {
53 | return useDocument(doc(collections('documents'), route.params.id))
54 | })
55 | // probably better to stick to vuefire api that accepts a computed:
56 | defineVueFireLoader('/documents/[id]', (route) => {
57 | return useDocument(computed(() => doc(collections('documents'), route.params.id)))
58 | })
59 | // or maybe we could have a way to pass a computed to the loader?
60 | defineVueFireLoader('/documents/[id]', ((route) => doc(collections('documents'), route.params.id)))
61 |
62 |
63 | // this means createLoader returns a function that:
64 |
65 | function _defineVueFireLoader(path: string, docOrCollectionOrQuery: () => unknown, options?: any) {
66 |
67 | // one time
68 | // 1. create the initial entry
69 | if (isDocument(docOrCollectionOrQuery)) {
70 | useDocument(docOrCollectionOrQuery)
71 | } else if (isCollection(docOrCollectionOrQuery)) {
72 | // collection
73 | } else {
74 | // query
75 | }
76 |
77 | // -> creates the resulting entry that should be a generic for createDataLoader
78 | useDocument() // creates data, isLoading, error, promise
79 |
80 | // then we have the entry
81 | // each entry will have a way to access the pending location based on the current navigation
82 | // should that be a WeakMap with `to` as the key?
83 | }
84 |
85 | export interface VueFireLoaderEntry extends DataLoaderEntryBase {
86 |
87 |
88 | }
89 |
90 | // vue query
91 |
92 | defineQueryLoader('/documents', () => useQuery(...))
93 | defineQueryLoader('/documents', optionsPassedToUseQuery)
94 | defineQueryLoader('/documents', to => optionsPassedToUseQuery)
95 | ```
96 |
--------------------------------------------------------------------------------
/src/codegen/generateRouteFileInfoMap.ts:
--------------------------------------------------------------------------------
1 | import { relative } from 'pathe'
2 | import type { PrefixTree, TreeNode } from '../core/tree'
3 | import { formatMultilineUnion, stringToStringType } from '../utils'
4 |
5 | export function generateRouteFileInfoMap(
6 | node: PrefixTree,
7 | {
8 | root,
9 | }: {
10 | root: string
11 | }
12 | ): string {
13 | if (!node.isRoot()) {
14 | throw new Error('The provided node is not a root node')
15 | }
16 |
17 | // the root node is not a route
18 | const routesInfoList = node
19 | .getChildrenSorted()
20 | .flatMap((child) => generateRouteFileInfoLines(child, root))
21 |
22 | // because the same file can be used for multiple routes, we need to group them
23 | const routesInfo = new Map()
24 | for (const routeInfo of routesInfoList) {
25 | // ensure we have an entry for the file
26 | let info = routesInfo.get(routeInfo.key)
27 | if (!info) {
28 | routesInfo.set(
29 | routeInfo.key,
30 | (info = {
31 | routes: [],
32 | views: [],
33 | })
34 | )
35 | }
36 |
37 | info.routes.push(...routeInfo.routeNames)
38 | info.views.push(...(routeInfo.childrenNamedViews || []))
39 | }
40 |
41 | const code = Array.from(routesInfo.entries())
42 | .map(
43 | ([file, { routes, views }]) =>
44 | `
45 | '${file}': {
46 | routes:
47 | ${formatMultilineUnion(routes.sort().map(stringToStringType), 6)}
48 | views:
49 | ${formatMultilineUnion(views.sort().map(stringToStringType), 6)}
50 | }`
51 | )
52 | .join('\n')
53 |
54 | return `export interface _RouteFileInfoMap {
55 | ${code}
56 | }`
57 | }
58 |
59 | /**
60 | * Generate the route file info for a non-root node.
61 | */
62 | function generateRouteFileInfoLines(
63 | node: TreeNode,
64 | rootDir: string
65 | ): Array<{
66 | key: string
67 | routeNames: string[]
68 | childrenNamedViews: string[] | null
69 | }> {
70 | const deepChildren =
71 | node.children.size > 0 ? node.getChildrenDeepSorted() : null
72 |
73 | const deepChildrenNamedViews = deepChildren
74 | ? Array.from(
75 | new Set(
76 | deepChildren.flatMap((child) =>
77 | Array.from(child.value.components.keys())
78 | )
79 | )
80 | )
81 | : null
82 |
83 | const routeNames: Array> = [
84 | node,
85 | ...(deepChildren ?? []),
86 | ]
87 | // unnamed routes and routes that don't correspond to certain components cannot be accessed in types
88 | .reduce((acc, node) => {
89 | if (node.isNamed() && node.value.components.size > 0) {
90 | acc.push(node.name)
91 | }
92 | return acc
93 | }, [])
94 |
95 | // Most of the time we only have one view, but with named views we can have multiple.
96 | const currentRouteInfo =
97 | routeNames.length === 0
98 | ? []
99 | : Array.from(node.value.components.values()).map((file) => ({
100 | key: relative(rootDir, file).replaceAll('\\', '/'),
101 | routeNames,
102 | childrenNamedViews: deepChildrenNamedViews,
103 | }))
104 |
105 | const childrenRouteInfo = node
106 | // if we recurse all children, we end up with duplicated entries
107 | // so we must go only with direct children
108 | .getChildrenSorted()
109 | .flatMap((child) => generateRouteFileInfoLines(child, rootDir))
110 |
111 | return currentRouteInfo.concat(childrenRouteInfo)
112 | }
113 |
--------------------------------------------------------------------------------
/src/data-loaders/defineQueryLoader.spec.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck TODO: remove this line when implementing
2 | /**
3 | * @vitest-environment happy-dom
4 | */
5 | import { Ref, shallowRef } from 'vue'
6 | import { defineQueryLoader } from './defineQueryLoader'
7 | import { expectType } from 'ts-expect'
8 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'
9 | import {
10 | NavigationResult,
11 | setCurrentContext,
12 | } from 'unplugin-vue-router/data-loaders'
13 | import { testDefineLoader } from '../../tests/data-loaders'
14 | import { enableAutoUnmount } from '@vue/test-utils'
15 |
16 | describe.skip('defineQueryLoader', () => {
17 | enableAutoUnmount(afterEach)
18 | testDefineLoader(
19 | ({ fn, key, ...options }) =>
20 | defineQueryLoader(fn, {
21 | ...options,
22 | queryKey: key ? [key] : ['id'],
23 | }),
24 | {
25 | beforeEach() {
26 | // invalidate current context
27 | setCurrentContext(undefined)
28 | },
29 | // TODO: query plugin
30 | // plugins: ({ pinia }) => [pinia],
31 | }
32 | )
33 | })
34 |
35 | // dts testing
36 | function dts(_fn: () => unknown) {}
37 |
38 | // FIXME: move to a test-d.ts file
39 | dts(async () => {
40 | interface UserData {
41 | id: string
42 | name: string
43 | }
44 |
45 | const useDataLoader = defineQueryLoader(async (route) => {
46 | const user = {
47 | id: route.params.id as string,
48 | name: 'Edu',
49 | }
50 |
51 | return user
52 | })
53 |
54 | expectType<{
55 | data: Ref
56 | error: Ref
57 | isLoading: Ref
58 | refresh: () => Promise
59 | }>(useDataLoader())
60 |
61 | // TODO: do we really need to support non-async usage?
62 | const useWithRef = defineQueryLoader(async (route) => {
63 | const user = shallowRef({
64 | id: route.params.id as string,
65 | name: 'Edu',
66 | })
67 |
68 | return user
69 | })
70 |
71 | expectType<{
72 | data: Ref
73 | error: Ref
74 | isLoading: Ref
75 | refresh: () => Promise
76 | }>(useWithRef())
77 |
78 | async function loaderUser() {
79 | const user: UserData = {
80 | id: 'one',
81 | name: 'Edu',
82 | }
83 |
84 | return user
85 | }
86 |
87 | expectType<{ data: Ref }>(
88 | defineQueryLoader(loaderUser, { lazy: true })()
89 | )
90 | expectType>(defineQueryLoader(loaderUser, { lazy: true })())
91 | expectType>(defineQueryLoader(loaderUser, {})())
92 | expectType<{ data: Ref }>(defineQueryLoader(loaderUser, {})())
93 | expectType<{ data: Ref }>(
94 | defineQueryLoader(loaderUser, { lazy: false })()
95 | )
96 | expectType<{ data: Ref }>(
97 | defineQueryLoader(loaderUser, { lazy: false })()
98 | )
99 |
100 | // it should allow returning a Navigation Result without a type error
101 | expectType<{ data: Ref }>(
102 | defineQueryLoader(
103 | async () => {
104 | if (Math.random()) {
105 | return loaderUser()
106 | } else {
107 | return new NavigationResult('/')
108 | }
109 | },
110 | { lazy: false }
111 | )()
112 | )
113 | expectType>(
114 | defineQueryLoader(
115 | async () => {
116 | if (Math.random()) {
117 | return loaderUser()
118 | } else {
119 | return new NavigationResult('/')
120 | }
121 | },
122 | { lazy: false }
123 | )()
124 | )
125 | })
126 |
--------------------------------------------------------------------------------
/src/runtime.ts:
--------------------------------------------------------------------------------
1 | import type { RouteRecordRaw, TypesConfig } from 'vue-router'
2 |
3 | /**
4 | * Defines properties of the route for the current page component.
5 | *
6 | * @param route - route information to be added to this page
7 | */
8 | export const definePage = (route: DefinePage) => route
9 |
10 | /**
11 | * Merges route records.
12 | *
13 | * @internal
14 | *
15 | * @param main - main route record
16 | * @param routeRecords - route records to merge
17 | * @returns merged route record
18 | */
19 | export function _mergeRouteRecord(
20 | main: RouteRecordRaw,
21 | ...routeRecords: Partial[]
22 | ): RouteRecordRaw {
23 | // @ts-expect-error: complicated types
24 | return routeRecords.reduce((acc, routeRecord) => {
25 | const meta = Object.assign({}, acc.meta, routeRecord.meta)
26 | const alias: string[] = ([] as string[]).concat(
27 | acc.alias || [],
28 | routeRecord.alias || []
29 | )
30 |
31 | // TODO: other nested properties
32 | // const props = Object.assign({}, acc.props, routeRecord.props)
33 |
34 | Object.assign(acc, routeRecord)
35 | acc.meta = meta
36 | acc.alias = alias
37 | return acc
38 | }, main)
39 | }
40 |
41 | /**
42 | * Type to define a page. Can be augmented to add custom properties.
43 | */
44 | export interface DefinePage extends Partial<
45 | Omit
46 | > {
47 | /**
48 | * A route name. If not provided, the name will be generated based on the file path.
49 | * Can be set to `false` to remove the name from types.
50 | */
51 | name?: string | false
52 |
53 | /**
54 | * Custom parameters for the route. Requires `experimental.paramParsers` enabled.
55 | *
56 | * @experimental
57 | */
58 | params?: {
59 | path?: Record
60 |
61 | /**
62 | * Parameters extracted from the query.
63 | */
64 | query?: Record
65 | }
66 | }
67 |
68 | export type ParamParserType_Native = 'int' | 'bool'
69 |
70 | export type ParamParserType =
71 | | (TypesConfig extends Record<'ParamParsers', infer ParamParsers>
72 | ? ParamParsers
73 | : never)
74 | | ParamParserType_Native
75 |
76 | /**
77 | * Configures how to extract a route param from a specific query parameter.
78 | */
79 | export interface DefinePageQueryParamOptions {
80 | /**
81 | * The type of the query parameter. Allowed values are native param parsers
82 | * and any parser in the {@link https://uvr.esm.is/TODO | params folder }. If
83 | * not provided, the value will kept as is.
84 | */
85 | parser?: ParamParserType
86 |
87 | // TODO: allow customizing the name in the query string
88 | // queryKey?: string
89 |
90 | /**
91 | * Default value if the query parameter is missing or if the match fails
92 | * (e.g. a invalid number is passed to the int param parser). If not provided
93 | * and the param parser throws, the route will not match.
94 | */
95 | default?: (() => T) | T
96 |
97 | /**
98 | * How to format the query parameter value.
99 | *
100 | * - 'value' - keep the first value only and pass that to parser
101 | * - 'array' - keep all values (even one or none) as an array and pass that to parser
102 | *
103 | * @default 'value'
104 | */
105 | format?: 'value' | 'array'
106 | }
107 |
108 | /**
109 | * TODO: native parsers ideas:
110 | * - json -> just JSON.parse(value)
111 | * - boolean -> 'true' | 'false' -> boolean
112 | * - number -> Number(value) -> NaN if not a number
113 | */
114 |
--------------------------------------------------------------------------------
/docs/data-loaders/organization.md:
--------------------------------------------------------------------------------
1 | # Loaders Organization
2 |
3 | While most examples show loaders defined in the same file as the page component, it's possible to define them in separate files and import them in the page component. This flexibility allows you to control not only the codebase organization but also **how chunks are split**.
4 |
5 | If a loader is used in multiple pages, it might be a better idea to extract it to a separate file instead of exporting it in one page and importing it in the others. This is because pages importing it will usually load the whole page component chunk in order to get the loader.
6 |
7 | ::: code-group
8 |
9 | ```ts [loaders/issues.ts]
10 | import { defineBasicLoader } from 'unplugin-vue-router/data-loaders/basic'
11 | import { getIssuesByProjectId } from '@/api'
12 |
13 | export const useProjectIssues = defineBasicLoader('/[projectId]/issues', (to) =>
14 | getIssuesByProjectId(to.params.projectId)
15 | )
16 | ```
17 |
18 | ```vue{2-3,7} [pages/[projectId]/issues.vue]
19 |
23 |
24 |
27 | ```
28 |
29 | ```vue{2,4,9} [pages/[projectId]/insights.vue]
30 |
35 |
36 |
40 | ```
41 |
42 | :::
43 |
44 | In the example above, the `useProjectIssues` loader is defined in a separate file and imported in two different pages, `pages/[projectId]/issues.vue` and `pages/[projectId]/insights.vue`. They both use the same data but present it in a different way so there is no reason to create two different loaders for issues. By extracting the loader into a separate file, we ensure an optimal chunk split.
45 |
46 | When using this pattern, remember to **export the loader** in all the page components that use it. This is what allows the router to await the loader before rendering the page.
47 |
48 | ## Usage outside of page components
49 |
50 | Until now, we have only seen loaders used in page components. However, one of the benefits of using loaders is that they can be **reused in many parts of your application**, just like regular composables. This will not only eliminate code duplication but also ensure an optimal and performant data fetching by **deduplicating requests and sharing the data**.
51 |
52 | To use a loader outside of a page component, you can simply **import it** and use it like any other composable, without the need to export it.
53 |
54 | ```vue
55 |
61 | ```
62 |
63 | ::: tip
64 |
65 | When using a loader in a non-page component, you must **export the loader** from the page components where it is used. If you only import and use the loader in a regular component, the router will not recognize it and won't trigger or await it during navigation.
66 |
67 | :::
68 |
69 | ## Nested Routes
70 |
71 | When defining nested routes, you don't need to worry about exporting the loader in both the parent and the child components. This will be automatically optimized for you and the loader will be shared between the parent and the child components.
72 | Because of this, it's simpler to **always export data loaders** in the page component where **they are used**.
73 |
--------------------------------------------------------------------------------
/src/codegen/generateRouteParams.ts:
--------------------------------------------------------------------------------
1 | import { TreeNode } from '../core/tree'
2 | import {
3 | isTreeParamOptional,
4 | isTreeParamRepeatable,
5 | isTreePathParam,
6 | } from '../core/treeNodeValue'
7 |
8 | export function generateRouteParams(node: TreeNode, isRaw: boolean): string {
9 | // node.pathParams is a getter so we compute it once
10 | // this version does not support query params
11 | const nodeParams = node.pathParams
12 | return nodeParams.length > 0
13 | ? `{ ${nodeParams
14 | .filter((param) => {
15 | if (!param.paramName) {
16 | console.warn(
17 | `Warning: A parameter without a name was found in the route "${node.fullPath}" in segment "${node.path}".\n` +
18 | `‼️ This is a bug, please report it at https://github.com/posva/unplugin-vue-router`
19 | )
20 | return false
21 | }
22 | return true
23 | })
24 | .map(
25 | (param) =>
26 | `${param.paramName}${param.optional ? '?' : ''}: ` +
27 | (param.modifier === '+'
28 | ? `ParamValueOneOrMore<${isRaw}>`
29 | : param.modifier === '*'
30 | ? `ParamValueZeroOrMore<${isRaw}>`
31 | : param.modifier === '?'
32 | ? `ParamValueZeroOrOne<${isRaw}>`
33 | : `ParamValue<${isRaw}>`)
34 | )
35 | .join(', ')} }`
36 | : // no params allowed
37 | 'Record'
38 | }
39 |
40 | export function EXPERIMENTAL_generateRouteParams(
41 | node: TreeNode,
42 | types: Array,
43 | isRaw: boolean
44 | ) {
45 | // node.params is a getter so we compute it once
46 | const nodeParams = node.params
47 | return nodeParams.length > 0
48 | ? `{ ${nodeParams
49 | .map((param, i) => {
50 | const isOptional = isTreeParamOptional(param)
51 | const isRepeatable = isTreeParamRepeatable(param)
52 |
53 | const type = types[i]
54 |
55 | let extractedType: string
56 |
57 | if (type?.startsWith('Param_')) {
58 | extractedType = `${isRepeatable ? 'Extract' : 'Exclude'}<${type}, unknown[]>`
59 | } else {
60 | extractedType = `${type ?? 'string'}${isRepeatable ? '[]' : ''}`
61 | }
62 |
63 | extractedType +=
64 | isTreePathParam(param) && isOptional && !isRepeatable
65 | ? ' | null'
66 | : ''
67 |
68 | return `${param.paramName}${isRaw && isOptional ? '?' : ''}: ${
69 | extractedType
70 | }`
71 | })
72 | .join(', ')} }`
73 | : // no params allowed
74 | 'Record'
75 | }
76 |
77 | // TODO: Remove in favor of inline types because it's easier to read
78 |
79 | /**
80 | * Utility type for raw and non raw params like :id+
81 | *
82 | */
83 | export type ParamValueOneOrMore = [
84 | ParamValue,
85 | ...ParamValue[],
86 | ]
87 |
88 | /**
89 | * Utility type for raw and non raw params like :id*
90 | *
91 | */
92 | export type ParamValueZeroOrMore = true extends isRaw
93 | ? ParamValue[] | undefined | null
94 | : ParamValue[] | undefined
95 |
96 | /**
97 | * Utility type for raw and non raw params like :id?
98 | *
99 | */
100 | export type ParamValueZeroOrOne = true extends isRaw
101 | ? string | number | null | undefined
102 | : string
103 |
104 | /**
105 | * Utility type for raw and non raw params like :id
106 | *
107 | */
108 | export type ParamValue = true extends isRaw
109 | ? string | number
110 | : string
111 |
--------------------------------------------------------------------------------
/docs/data-loaders/colada/index.md:
--------------------------------------------------------------------------------
1 | # `defineColadaLoader()`
2 |
3 | Loaders that use [@pinia/colada](https://github.com/posva/pinia-colada) under the hood. These loaders provide a more efficient way to have asynchronous state with cache, ssr support and more.
4 |
5 | The key used in these loaders are directly passed to `useQuery()` from `@pinia/colada` and are therefore invalidated by `useMutation()` calls.
6 |
7 | ::: warning
8 | Pinia Colada is Experimental (like data loaders). Feedback is very welcome to shape the future of data loaders in Vue Router.
9 | :::
10 |
11 | ## Setup
12 |
13 | Follow the installation instructions in [@pinia/colada](https://github.com/posva/pinia-colada).
14 |
15 | ## Example
16 |
17 |
25 |
26 | ```vue
27 |
40 |
41 |
53 |
54 |
55 |
56 | Pinia Colada Loader Example
57 | User: {{ route.params.id }}
58 |
59 |
60 | Controls
61 |
62 | Refresh
63 | Refetch
64 |
65 |
66 |
67 | Previous
68 |
69 | |
70 |
71 | Next
72 |
73 |
74 | State
75 |
76 |
77 | status: {{ status }}
78 |
79 | isLoading: {{ isLoading }}
80 |
81 | Error: {{ error }}
82 | {{ user == null ? String(user) : user }}
83 |
84 |
85 | ```
86 |
87 |
88 |
89 | ::: tip
90 | If you are using unplugin-vue-router, you can pass a route name to `defineColadaLoader` to get typed routes in the `query` function.
91 |
92 | ```ts
93 | export const useUserData = defineColadaLoader('/users/[id]', {
94 | // ...
95 | })
96 | ```
97 |
98 | :::
99 |
100 | ## Refresh by default
101 |
102 | To avoid unnecessary frequent refreshes, Pinia Colada refreshes the data when navigating (instead of _refetching_). Change the `staleTime` option to control how often the data should be refreshed, e.g. setting it to 0 will refresh the data every time the route changes.
103 |
104 | ## Route tracking
105 |
106 | The `query` function tracks what is used in the `to` parameter and will only refresh the data if **tracked** properties change. This means that if you use `to.params.id` in the `query` function, it will only refetch the data if the `id` parameter changes but not if other properties like `to.query`, `to.hash` or even `to.params.other` change. To make sure the data is updated, it will still refresh in these scenarios. Configure the `staleTime` option to control how often the data should be refreshed.
107 |
108 | ## SSR
109 |
110 | <--!
111 | Hydration does not trigger extra load
112 | -->
113 |
114 | ## Nuxt
115 |
116 | ## Unresolved questions
117 |
--------------------------------------------------------------------------------